Writing

Infrastructure

Operating Multiple Servers with Codex Remote

A troubleshooting-first account of networking, SSH, Tailscale, and remote agent development.

SSHCodexTailscale

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,四个字母对应四步:

  1. Discover:客户端广播,问局域网里有没有人能分配地址;
  2. Offer:服务器提出一个可用地址;
  3. Request:客户端广播,表示接受这个方案;
  4. 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 不等于能上网

网络“通了”之后,我又把“能上网”拆成三个独立的问题:

  1. 能不能到达同一局域网的网关(网关就是局域网里负责把数据包转发到别处的设备,通常是路由器);
  2. 能不能通过默认路由到达公网 IP;
  3. 能不能把域名解析成 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 与流式传输是否正常

参考资料