从网线到模型流:Mac 管理多台 Linux、Windows 与 Codex Remote 的完整实践
这几天我原本只想做一件很简单的事:坐在 Mac 前面,把实验室和手边的几台电脑都连起来。
机器并不算少。几台 Linux 服务器负责跑 GPU 任务,一台 Ubuntu Server 放同步服务,还有一台 Windows 主机保留原生开发环境。我希望以后打开 Codex,选中一个名字,就能直接进入对应机器上的项目,而不是先翻聊天记录找 IP,再想这台机器到底用哪个用户名、哪把密钥。
听起来不过是“把 SSH 配好”。真正做起来,却像剥洋葱:Ubuntu 开机没有网,Tailscale 能看到节点但 22 端口不通,Windows 明明放了公钥却仍然拒绝登录,SSH 已经显示认证成功,Codex Remote 又在下一步断掉。后来甚至连界面里的一个 Reconnecting,背后都可能是两条完全不同的连接。
这篇文章不打算罗列一套“万能配置”。我更想把这次排错中真正有用的判断方法写下来:当一台机器连不上时,究竟应该从哪一层开始看;每一层又在解决什么问题。
文中的设备名、地址、用户名和目录都换成了示例值。私钥、密码、API Key 和 LiveSync Setup URI 不应该出现在公开文章里。
第一台 Ubuntu Server,先在联网这里给了我一个下马威
最开始的问题非常朴素:系统装好了,但它没有网。
我第一反应是找 NetworkManager。桌面版 Ubuntu 经常由它管理网络,所以很多教程上来就是 nmcli。但这台机器装的是 Ubuntu Server,系统里根本没有对应服务。后来才明白,Netplan 只是配置入口,真正负责应用配置的后端可能是 NetworkManager,也可能是 systemd-networkd。服务器版常见的是后者。
这件事给我的第一个教训是:不要先猜网卡坏了,也不要先复制网上的 YAML。先问系统三个问题:
1
2
3
ip -br link
ip -br addr
ip route
这三个命令分别回答:
- 网卡在不在,链路有没有起来;
- 网卡拿到了什么地址;
- 数据包不知道往哪走时,默认交给谁。
UP 和 LOWER_UP 不是一回事
ip link 的输出里,UP 表示接口在管理意义上被启用;LOWER_UP 更接近“物理层真的检测到了载波”。一张网卡可以是 UP,但网线没插好、交换机端口没开时仍然没有 LOWER_UP。
这一区别很实用。没有载波时,继续改 DNS、SSH 和 Tailscale 都没有意义。此时应该检查的是网线、网卡、交换机端口和驱动。只有链路先成立,后面的 DHCP 才有机会发生。
DHCP 到底替我们做了什么
很多时候我们把“自动联网”和 DHCP 当成同一个词,其实 DHCP 只是负责租借网络参数。新装好的机器一开始连自己的 IP 都不知道,更不知道 DHCP 服务器在哪里,所以第一步只能广播:
- 客户端发送
DHCPDISCOVER,问局域网里有没有人能分配地址; - DHCP 服务器返回
DHCPOFFER,提出一个可用地址; - 客户端广播
DHCPREQUEST,表示接受其中一个方案; - 服务器以
DHCPACK确认租约,同时给出掩码、网关、DNS 和租期。
这就是常说的 DORA。理解这个过程之后,我才明白为什么“把网线直接从路由器拔下来插到另一台电脑上”经常不能自动联网:那台电脑默认并不是 DHCP 服务器,也不一定开启了互联网共享。两张网卡虽然物理相连,却没人给新主机分地址、默认网关和 DNS。
这种直连并非不能用。可以给两端手动配置同一子网的静态地址,例如一端 192.168.50.1/24,另一端 192.168.50.2/24;但若还要访问互联网,其中一端还得承担转发、NAT 和 DNS 等工作。对当时的我来说,重新接回路由器,让它正常提供 DHCP,显然更省事。
Ubuntu Server 使用 networkd 时,Netplan 配置大致如下:
1
2
3
4
5
6
7
network:
version: 2
renderer: networkd
ethernets:
eno1:
dhcp4: true
optional: true
这里的 eno1 不能照抄。Linux 的“可预测网卡命名”会根据固件、总线和插槽生成 eno1、eno2、enp3s0 等名称,真实名字必须从 ip link 获取。
远程修改 Netplan 时,我现在会先用:
1
sudo netplan try
它会要求在限定时间内确认。如果新配置把 SSH 连接切断,系统会自动回滚;这比直接 netplan apply 后把自己锁在门外安全得多。
有 IP,也不等于“网络已经好了”
网络恢复后,我又慢慢习惯把“能上网”拆成三个问题:
- 能不能到达同一局域网的网关;
- 能不能通过默认路由到达公网 IP;
- 能不能把域名解析成 IP。
路由表不是“有一个网关就结束了”。内核会拿目标地址与每一条路由比较,选择前缀最长、也就是最具体的那条。局域网流量通常直接从本地接口出去;只有没有更具体匹配时,才走 default via ...。
因此下面几种结果含义不同:
1
2
3
4
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。
Tailscale 显示在线,为什么 SSH 仍然不通
网络通了以后,我给几台机器装上 Tailscale。它带来的方便很直接:设备不在同一个物理局域网,也可以使用稳定的 Tailscale 地址或 MagicDNS 名称互相访问。
但 Tailscale 不是 SSH。
Tailscale 基于 WireGuard 建立加密的覆盖网络。它负责寻找节点之间的路径,能直连时尽量穿过 NAT 直连,不能直连时可以通过 DERP 中继。它解决的是“数据包怎样到达那台机器”。至于目标机器有没有运行 sshd、22 端口是否被防火墙允许、公钥是否正确,仍然属于 SSH 自己的事情。
所以我现在按下面的顺序检查:
1
2
3
4
tailscale status
tailscale ping sync-node
nc -vz sync-node 22
ssh -vvv sync-node
tailscale ping 成功,只能证明覆盖网络层可达;nc 成功才说明 TCP 22 有程序接受连接;最后的 ssh -vvv 才进入主机身份与用户认证。
这种分层看上去有点啰嗦,却能省下大量时间。以前看到“连接失败”,我会同时重启 Tailscale、修改公钥、调整防火墙。现在只要确定失败停在哪一步,就不会去动无关的那一层。
Clash、Tailscale 和 SSH,三者不要混在一起想
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 也可能被送进代理。
我的理解后来变得很简单:
1
2
3
Tailscale:决定两台机器能否通过覆盖网络互相到达
Clash:决定某类流量直接走,还是经代理节点走
SSH:在已经可达的 TCP 连接上验证主机、验证用户并建立会话
三者可以叠加,但没有谁能替代另外两个。
把多台机器收进 ~/.ssh/config
等底层网络稳定以后,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 可以输出 OpenSSH 合并所有 Host、Include 和通配规则后的最终参数。这比肉眼盯着配置文件可靠,因为先匹配到的选项、通配块和包含文件都可能改变结果。
SSH 里其实有两套密钥
我以前也会把“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 账户密码。
第二处坑是管理员账户。普通用户一般读取:
1
C:\Users\<User>\.ssh\authorized_keys
而属于 Administrators 组的账户,常按默认 OpenSSH 配置读取:
1
C:\ProgramData\ssh\administrators_authorized_keys
文件位置正确还不够,ACL 也必须足够严格:
1
2
3
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 的命令解释层:
1
2
3
4
5
6
7
8
9
10
11
12
13
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。
Codex Remote 实际上做了什么
到这里,网络和 SSH 都通了,才真正轮到 Codex。
按照当前的 Codex Remote Connections 说明,桌面应用会读取 Mac 的 ~/.ssh/config,用 OpenSSH 连接远端,并通过远端用户的登录 shell 查找和启动 codex app-server。项目文件、命令和依赖都来自远端环境,Mac 主要提供界面和 SSH 客户端。
这意味着:
1
2
3
4
5
6
7
8
9
Mac Codex Desktop
↓ 读取 SSH alias
OpenSSH 登录远端
↓ 使用远端登录 shell
查找远端 PATH 中的 codex
↓
启动 codex app-server
↓
读写远端项目、执行远端命令
因此“我可以打开 SSH 终端”只是必要条件,不是充分条件。交互式 shell 可能会读取 .bashrc,而无交互命令不一定走同一套初始化逻辑。Node、npm 或 Codex 若装在 NVM、自定义 npm prefix 或临时 PATH 中,人在终端里能运行,Remote 引导时却可能找不到。
我会用下面两条命令模拟更接近 Remote 的环境:
1
2
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,不应该把 app-server 的监听端口直接暴露到公共网络。如果确实需要跨网络连接,优先使用 VPN、Tailscale 这类私有组网,或放在经过认证和 TLS 保护的通道之后。ws:// 更适合回环地址或 SSH 端口转发;远端监听应使用 wss:// 并配置认证。相关传输与安全边界可见 Codex App Server。
界面里的 Reconnecting,可能说的是两件事
这次排错里最容易误判的,就是“重连”。
至少要区分两条链路:
- Mac 桌面应用经 SSH 与远端 Codex/app-server 之间的控制链路;
- 远端 Codex 访问 OpenAI 或第三方模型 Provider 的上游传输链路。
第一条失败时,远端项目本身会断开。原因可能是 SSH 掉线、主机休眠、登录 shell 异常或 app-server 退出。第二条失败时,SSH 仍然可以完全正常,只是模型的流式输出收不到。
模型侧常见的两种流式传输是 WebSocket 和 HTTP/SSE。WebSocket 先通过 HTTP Upgrade 完成握手,之后在同一条 TCP/TLS 连接上双向发送帧;SSE 则保持一条普通 HTTP 响应,由服务器单向持续推送事件。
为什么普通 HTTPS 能用,WebSocket 仍可能失败?因为中间设备处理的路径不一定相同:
- HTTP 代理允许普通
CONNECT,但没有正确承载 Upgrade 或长连接; - 反向代理忘了转发 WebSocket 所需的头;
- Clash 对 WSS 与普通 HTTPS 命中了不同规则、DNS 或出口;
- 防火墙和 NAT 允许短请求,却提前回收空闲长连接;
- TLS 拦截环境对普通 HTTP 客户端安装了 CA,但 WebSocket 客户端没有使用同一信任链。
所以看到 Reconnecting 时,我现在先看日志里的上下文,而不是直接重装 Codex。包含 SSH host alias、远端初始化或 app-server 的,多半属于 Remote 控制链路;包含 provider、responses、stream、WebSocket fallback 的,多半属于模型传输。
某些 Codex 版本和 Provider 会在 WebSocket 失败后回退到 HTTP/SSE,但重试预算和是否支持回退属于版本与 Provider 配置,不能只凭一次观察写成永远不变的规则。
多台 GPU 服务器,最麻烦的反而是 PATH
几台 Linux 服务器没有完全相同的环境。有的 Node 来自系统包,有的来自 NVM,有的曾装过多个 Codex 版本,还有的没有 sudo 权限。
排查时我常用:
1
2
3
4
type -a codex
command -v codex
npm prefix -g
codex --version
type -a 会列出所有同名命令,能发现“我明明升级了,实际运行的仍是旧路径”这类问题。shell 还可能缓存旧的命令位置,调整 PATH 后可以用 hash -r 清掉缓存。
第三方模型 Provider 也必须做真实请求。版本号只能证明二进制能启动,不能证明认证、模型名、网络和流式传输都正确:
1
codex exec --skip-git-repo-check --ephemeral --color never '只回复:Provider OK'
我曾经遇到默认 Provider 已经切换,旧会话仍带着另一个服务的模型名,最终被服务端拒绝。修改 Provider 或模型后,新建会话再验收,比只看启动欢迎语可靠。
LiveSync 又让我遇到一次 127.0.0.1
Obsidian Self-hosted LiveSync 的问题看起来与 SSH 无关,实际仍然可以用相同的分层方法处理。
CouchDB 在服务器上监听 127.0.0.1:5984,这个地址只属于服务器自己。如果把它原样填进 Mac 的 Obsidian,Mac 访问的是 Mac 自己的 5984 端口,并不会神奇地跳到服务器。
正确链路应当类似:
1
2
3
4
5
6
7
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 可能包含服务器地址、数据库名、账户和加密配置,也应该视作敏感凭据。
我最后留下的排错顺序
折腾完这些机器以后,我没有得到一条能解决所有问题的命令,倒是形成了一套比较稳定的顺序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
网卡有没有载波
↓
有没有 IP、正确的掩码和默认路由
↓
DNS 是否正常
↓
目标 TCP 端口是否监听
↓
SSH 主机验证与用户认证是否通过
↓
远端 shell、PATH 和 codex 是否可用
↓
app-server 是否成功启动
↓
模型 Provider 与流式传输是否正常
这套顺序最大的价值,不是显得有条理,而是避免“因为最上层报错,就把下面所有东西都重装一遍”。
回头看,这几天真正搭起来的并不只是 Mac 到几台服务器的连接。更重要的是,我终于能把一句含糊的“连不上”,拆成更具体的问题:是网卡没有载波,DHCP 没拿到租约,默认路由缺失,TCP 22 没有监听,公钥放错位置,Windows shell 破坏了引导命令,还是 Codex 上游的 WebSocket 被代理截断。
问题一旦能被准确地说出来,通常就已经解决了一半。