Infrastructure
Operating Multiple Servers with Codex Remote
A troubleshooting-first account of networking, SSH, Tailscale, and remote agent development.
The full English text is not available yet
This article is currently using its Chinese source. Add a matching file under content_en/writing/posts/ to enable the English version.
一直在想用codex的ssh来调度多台设备。
我有三台 Linux 服务器,一台 Ubuntu Server 放同步服务,还有一台 Windows 主机保留原生开发环境。想法很简单:以后打开 Codex,选一个名字就能进对应机器干活,不用再翻聊天记录找 IP。真正动手之后,几乎每一步都卡在“连不上”三个字上:Ubuntu 开机没有网,Tailscale 显示节点在线但 22 端口不通,Windows 放了公钥还是被拒,SSH 明明认证成功,Codex Remote 又在下一步断掉。
这篇文章不打算罗列一套“万能配置”,而是把这次排错里真正管用的判断方法写下来:当一台机器连不上时,应该从哪一层开始看,每一层又在解决什么问题。文中的设备名、地址、用户名都换成了示例值;私钥、密码、API Key 和 LiveSync Setup URI 不会出现在公开文章里。
第 1 层:链路——没有网
新装好的 Ubuntu Server 开机第一件事:没有网。我第一反应是找 NetworkManager,因为桌面版 Ubuntu 的网络经常由它管理,网上教程让我敲 nmcli。但这台机器装的是 Server 版,系统里根本没有这个服务。查过之后我才弄明白,Ubuntu 的 Netplan 只是配置入口,真正把配置落到系统的是后端:桌面版常见 NetworkManager,服务器版常见 systemd-networkd。
遇到“没网”,问系统三个问题
ip -br link # 网卡在不在,链路有没有起来
ip -br addr # 网卡拿到了什么地址
ip route # 数据包不知道往哪走时,默认交给谁
三个问题里最容易看走眼的是 ip link 的两个状态。UP 表示接口在管理意义上被启用了,相当于“我允许这块网卡工作”;LOWER_UP 表示物理层真的检测到了载波,通俗地说,就是网线另一头有设备在通电应答。一张网卡完全可以是 UP 却没有 LOWER_UP——网线没插好、交换机端口没开、驱动没起来,都会出现这种情况。
这个区别帮我省了很多无用功:没有载波的时候,改 DNS、配 SSH、装 Tailscale 全是白费。正确做法是先检查网线、网卡、交换机和驱动。链路先成立,后面才有 DHCP 的机会。
第 2 层:IP 与 DHCP——自动联网
很多人把“自动联网”和 DHCP 当成一回事,其实 DHCP 只负责一件事:租借网络参数。新装好的机器连自己的 IP 是多少都不知道,更不知道局域网里谁说了算,所以第一步只能广播:“我来了,谁能给我分个地址?”
这个广播过程就是常说的 DORA,四个字母对应四步:
- Discover:客户端广播,问局域网里有没有人能分配地址;
- Offer:服务器提出一个可用地址;
- Request:客户端广播,表示接受这个方案;
- Ack:服务器确认租约,同时给出掩码、网关、DNS 和租期。
理解 DORA 之后,很多“想当然”就解释得通了。比如把网线从路由器拔下来直接插到另一台电脑上,为什么通常不能自动联网?因为那台电脑默认不是 DHCP 服务器,也没开互联网共享。两张网卡物理上通了,却没人给新主机发 IP、网关和 DNS。
直连不是不能用。可以给两端手动配同一个子网的静态地址,比如一端 192.168.50.1/24,另一端 192.168.50.2/24;但若还想上网,其中一端还得承担转发、NAT 和 DNS 的活。对当时的我来说,老老实实把网线插回路由器,让它正常发 DHCP,是最省事的一条路。
Ubuntu Server 用 networkd 时,Netplan 配置大致是这样:
network:
version: 2
renderer: networkd
ethernets:
eno1:
dhcp4: true
optional: true
eno1 不能照抄。Linux 的“可预测网卡命名”会根据固件、总线和插槽生成 eno1、eno2、enp3s0 这类名字,真实名字得从 ip link 的输出里找。
远程改网络配置时,我还会用 sudo netplan try 而不是直接 netplan apply:前者会在限定时间内等你确认,如果新配置把 SSH 连接切断了,系统会自动回滚;直接 apply 就可能把自己锁在门外。
第 3 层:路由与 DNS——有 IP 不等于能上网
网络“通了”之后,我又把“能上网”拆成三个独立的问题:
- 能不能到达同一局域网的网关(网关就是局域网里负责把数据包转发到别处的设备,通常是路由器);
- 能不能通过默认路由到达公网 IP;
- 能不能把域名解析成 IP(DNS 就是干这个的)。
路由表不是“有一个网关就完事了”。内核会把目标地址和每一条路由做比较,选前缀最长、也就是最具体的那条:局域网内的地址通常直接从本地接口出去;没有更具体的匹配时,才走 default via ...。
所以下面这几个命令的结果含义不同:
ip route
ping -c 2 192.168.0.1
ping -c 2 1.1.1.1
getent hosts example.com
- 网关不通:查链路、地址、掩码和 VLAN;
- 网关通、公网 IP 不通:查默认路由、上游网络和防火墙;
- 公网 IP 通、域名解析失败:查 DNS;
- 域名能解析但网页打不开:再看 TCP、TLS、代理和应用。
ping 不是绝对裁判,有些设备会丢弃 ICMP。判断某个服务是否真的可达,最终要看对应的 TCP 端口,例如 nc -vz host 22。
第 4 层:覆盖网络与代理——SSH 不通
网络通了之后,我给几台机器都装上了 Tailscale。它带来的方便很直接:设备不在同一个物理局域网,也能用稳定的 Tailscale 地址或 MagicDNS 名字互相访问。
但 Tailscale 不是 SSH。Tailscale 基于 WireGuard 建了一条加密的覆盖网络:它负责寻找节点之间的路径,能直连就尽量穿过 NAT 直连,不能直连就用 DERP 中继。它只解决“数据包怎样到达那台机器”;目标机器有没有跑 sshd、22 端口有没有被防火墙挡、公钥对不对,那是 SSH 自己的事。
所以我现在的检查顺序是:
tailscale status
tailscale ping sync-node
nc -vz sync-node 22
ssh -vvv sync-node
tailscale ping 通了,只证明覆盖网络层可达;nc 通,才说明 TCP 22 有程序在听;最后 ssh -vvv 才进入主机身份和用户认证。这个顺序看起来啰嗦,却能省下大量时间:以前看到“连接失败”,我会同时重启 Tailscale、改公钥、调防火墙;现在只要确定失败停在哪一步,就不会去动无关的那一层。
Mac 上还跑着 Clash,于是又多了一层容易混的东西。浏览器能通过系统 HTTP 代理上网,不代表 ssh 也会走同一个代理:HTTP 代理理解的是 HTTP 请求,HTTPS 一般靠 CONNECT 建隧道,SSH 是另一套协议。想让 SSH 明确走 Clash 的 SOCKS5 入口,需要写 ProxyCommand:
Host gpu-a-via-clash
HostName <target>
User <user>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ProxyCommand nc -X 5 -x 127.0.0.1:7891 %h %p
端口要以本机 Clash 配置为准,7891 只是常见示例。如果开了 TUN 模式,情况又不同:TUN 会创建一个虚拟网卡,用路由把流量接管走,SSH 即使没有 ProxyCommand 也可能被透明地带进去。这时要特别注意局域网、Tailscale 网段和回环地址的直连规则,否则本应走本地的 192.168.0.0/16、100.64.0.0/10 和 127.0.0.1 也可能被送进代理。
我自己后来是这样记这三者的:Tailscale 决定两台机器能否在覆盖网络上互相到达;Clash 决定某类流量直接走还是绕代理;SSH 在已经可达的 TCP 连接上验证身份、建立会话。三者可以叠加,但没有谁能替代另外两个。
第 5 层:SSH——把多台机器收进几个别名
底层网络稳定之后,Mac 的 ~/.ssh/config 才真正派上用场。我给每台机器起一个别名:
Host gpu-a
HostName <address>
User <linux-user>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
Host sync-node
HostName <tailscale-name>
User <linux-user>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Host win-dev
HostName <lan-or-tailscale-address>
User <windows-user>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
这样应用只需要记住 gpu-a、win-dev;IP、用户名和密钥换了,只改底层配置。
检查配置是否生效,用 ssh -G win-dev:它会把合并了所有 Host、Include 和通配规则之后的最终参数打出来,比盯着配置文件猜可靠。
“SSH 密钥”这个说法也容易误导人,排错时必须区分两套:主机密钥证明“这台服务器是谁”,服务器保存主机私钥,客户端把公钥指纹记在 known_hosts;用户密钥证明“我是谁”,客户端保存用户私钥,服务器把公钥放在 authorized_keys。
所以服务器重装后主机指纹变了,不代表用户公钥坏了;反过来,Permission denied (publickey) 也不该靠删 known_hosts 解决。公钥认证时,私钥不会上传到服务器:客户端用私钥对会话相关的数据签名,服务器用公钥验证。把私钥复制到远端既没必要,也扩大了泄露面。
Windows:这次最曲折的一台
Linux 上公钥通常放在 ~/.ssh/authorized_keys,Windows OpenSSH 看起来差不多,细节却足够让人绕很久。
第一坑是 Windows Hello PIN。PIN 绑定的是本机的 Windows Hello 凭据,不等同于可供 SSH 使用的账户密码;密码认证时要的是真实的 Windows 账户密码。
第二坑是管理员账户。普通用户一般读取:
C:\Users\<User>\.ssh\authorized_keys
属于 Administrators 组的账户,按默认 OpenSSH 配置读取:
C:\ProgramData\ssh\administrators_authorized_keys
文件位置对了还不够,ACL 必须足够严格:
icacls 'C:\ProgramData\ssh\administrators_authorized_keys' /inheritance:r
icacls 'C:\ProgramData\ssh\administrators_authorized_keys' /grant:r 'Administrators:(F)' 'SYSTEM:(F)'
Restart-Service sshd
第三坑最隐蔽:SSH 已经显示认证成功,Codex Remote 仍然失败。Windows OpenSSH 默认的远程 shell 往往是 cmd.exe,而远端引导过程用的是 POSIX shell 语法:cmd 不认识 sh -c、$SHELL、方括号条件和单引号规则。于是日志一边显示 Authenticated,一边报语法错误。
我最后在这台机器上把 Git Bash 设成 OpenSSH 的默认 shell:
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' `
-Name DefaultShell `
-PropertyType String `
-Value 'C:\Program Files\Git\bin\bash.exe' `
-Force
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' `
-Name DefaultShellCommandOption `
-PropertyType String `
-Value '-c' `
-Force
Restart-Service sshd
需要说明的是,这是我这次环境里的解决办法,不是所有 Windows 主机都应该照搬的默认答案。它是机器级设置,会影响所有通过这个 sshd 登录的用户,改之前要留好本地管理员入口。Git Bash 只负责解释命令,真正启动的仍然是 Windows 原生的 Codex,并不等于切到了 WSL。
第 6 层:会话与应用——Codex Remote 实际做了什么
到这里,网络和 SSH 都通了,才真正轮到 Codex。
按照目前的 Codex Remote Connections 说明,桌面应用会读取 Mac 的 ~/.ssh/config,用 OpenSSH 连接远端,再通过远端用户的登录 shell 查找并启动 codex app-server。项目文件、命令和依赖全部来自远端环境,Mac 主要提供界面和 SSH 客户端。
所以“我能打开 SSH 终端”只是必要条件,不是充分条件。交互式 shell 会读取 .bashrc,非交互命令不一定走同一套初始化逻辑。如果 Node、npm 或 Codex 装在 NVM、自定义 npm prefix 或临时 PATH 里,你在终端里能运行,Remote 引导时却可能找不到。
我常用下面两条命令模拟更接近 Remote 的环境:
ssh gpu-a 'printf "shell=%s\n" "$SHELL"; command -v node; command -v codex; codex --version'
ssh gpu-a 'sh -c "command -v codex && codex app-server --help >/dev/null && echo app-server-ok"'
如果远端 profile 会在标准输出打印欢迎语,还可能污染机器可读协议——登录提示最好只在交互 shell 里输出。
还有一条安全边界:Remote 通过 SSH 启动和管理 app-server,不应该把它的监听端口直接暴露到公共网络。跨网络连接优先使用 VPN、Tailscale 这类私有组网,或放在经过认证和 TLS 保护的通道之后。ws:// 更适合回环地址或 SSH 端口转发;远端监听应使用 wss:// 并配置认证。相关细节见 Codex App Server。
界面里的 Reconnecting,可能说的是两件事
这次排错里最容易误判的,就是界面上的“重连”。
至少要区分两条链路:一是 Mac 桌面应用经 SSH 与远端 app-server 之间的控制链路;二是远端 Codex 访问 OpenAI 或第三方 Provider 的上游传输链路。第一条断,远端项目本身会断开,原因可能是 SSH 掉线、主机休眠、登录 shell 异常或 app-server 退出;第二条断,SSH 仍然完全正常,只是模型的流式输出收不到。
模型侧常见的流式传输有两种:WebSocket 和 HTTP/SSE。WebSocket 先通过 HTTP Upgrade 完成握手,之后在同一条 TCP/TLS 连接上双向发送帧;SSE 则保持一条普通 HTTP 响应,由服务器单向持续推送事件。
为什么普通 HTTPS 能用,WebSocket 仍可能失败?因为中间设备对两条路的处理不一定相同:代理允许普通 CONNECT 却没正确承载 Upgrade;反向代理忘了转发 WebSocket 所需的头;Clash 对 WSS 与普通 HTTPS 命中了不同规则、DNS 或出口;防火墙和 NAT 允许短请求,却提前回收空闲长连接;TLS 拦截环境给普通 HTTP 客户端装了 CA,WebSocket 客户端却没走同一信任链。
所以看到 Reconnecting,我现在先看日志里的上下文,而不是直接重装 Codex:包含 SSH host alias、远端初始化或 app-server 的,多半是控制链路;包含 provider、responses、stream、WebSocket fallback 的,多半是模型传输。另外,某些版本和 Provider 会在 WebSocket 失败后回退到 HTTP/SSE,但有没有回退、重试几次,取决于版本和 Provider 配置,不能只凭一次观察写成永远成立的规则。
多台 GPU 服务器,最麻烦的其实是 PATH
几台 Linux 服务器的环境并不完全一样:有的 Node 来自系统包,有的来自 NVM,有的装过多个 Codex 版本,还有的没有 sudo 权限。
排查时我常用:
type -a codex
command -v codex
npm prefix -g
codex --version
type -a 会列出所有同名命令的位置,能发现“我明明升级了,实际运行的仍是旧路径”这类问题。shell 还可能缓存旧的命令位置,调整 PATH 后可以用 hash -r 清掉。
第三方模型 Provider 也必须做真实请求。版本号只能证明二进制能启动,证明不了认证、模型名、网络和流式传输都正常:
codex exec --skip-git-repo-check --ephemeral --color never '只回复:Provider OK'
我遇到过默认 Provider 已经切换、旧会话仍带着另一个服务的模型名、最后被服务端拒绝的情况。改完 Provider 或模型,新建会话再验收,比只看启动欢迎语可靠。
LiveSync
Obsidian Self-hosted LiveSync 的问题看起来和 SSH 无关,实际还是同一套分层方法。
CouchDB 在服务器上监听 127.0.0.1:5984。127.0.0.1 是回环地址,只属于服务器自己;如果把它原样填进 Mac 的 Obsidian,Mac 访问的是 Mac 自己的 5984 端口,并不会神奇地跳到服务器。
正确链路应当类似:
Mac Obsidian
↓ HTTPS
Tailscale 域名或私有地址
↓ Tailscale Serve / Caddy / Nginx
服务器的 127.0.0.1:5984
↓
CouchDB
反向代理负责 TLS、域名和转发,CouchDB 继续只监听回环地址。这样既能让其他 Tailscale 设备访问,也不必把数据库直接暴露到公网。
LiveSync 也不是“两台设备互相覆盖文件”那么简单。每台设备会跟踪本地变化,把内容和元数据写入本地状态,再通过 CouchDB 的修订与变化接口交换:文件保存、进入待上传队列、远端收到修订、另一台设备应用变化,是几个不同的阶段。
所以执行 Initialise、Reset、Rebuild 或 Overwrite Server 之前,必须先确认哪一份数据才是权威副本,并做独立备份。Setup URI 可能包含服务器地址、数据库名、账户和加密配置,也应该视作敏感凭据。
最后留下的排错顺序
网卡有没有载波
↓
有没有 IP、正确的掩码和默认路由
↓
DNS 是否正常
↓
目标 TCP 端口是否监听
↓
SSH 主机验证与用户认证是否通过
↓
远端 shell、PATH 和 codex 是否可用
↓
app-server 是否成功启动
↓
模型 Provider 与流式传输是否正常