一个窗口如何跨越三台机器

这是「远程 GUI 与跳板机链路」系列的篇①。

本篇的第一性问题:我在办公机上敲一行命令,程序跑在几百公里外的 Linux 服务器上,
中间还隔着一台跳板机 —— 那个窗口是怎么跑到我面前这块屏幕上的,
以及凭什么允许它画在我的屏幕上。

回链总述:本篇负责总述里的推论一(角色必须反转)和推论二(协议里必须有授权)。
这两条不是 X11 的设计偏好,是「传命令」这个选择的必然结果 ——
为什么必然,在总述的 2.3。

本篇能解释故障表里的哪几行:DISPLAY 为空 / cannot open display,
以及 sudo 之后 GUI 打不开。

全文四部分:一 角色(谁是 server)→ 二 通道(ssh -X 干了什么)→
三 授权(cookie 凭什么算数)→ 四 多跳(链路断在哪、怎么查)。

第一部分 角色:拿着屏幕的那端才是 server

1.1 先把结论摆出来

我面前这块屏幕是 X server。跑在服务器上的那个 GUI 程序是 X client。

几乎每个人第一次听到都要愣一下,因为它和「server = 机房里那台大机器」的日常直觉正好相反。
但这不是命名失误。它是「传命令」模型推出来的:命令要有人接收并执行,
谁执行?拿着显卡、键盘、鼠标的那一端。在网络编程的通用语汇里,
提供服务、被连接的那端叫 server —— 屏幕提供的是「显示服务」,程序是来消费这个服务的。

1.2 1984 年那间机房

X11 诞生在分时终端的年代:一台大机器,一屋子瘦终端。终端没有算力去做别的,
而绘图命令比位图小得多,那时候的网络也扛不住传位图。

所以有一句话值得记住:X11 从第一天起就是网络协议。
「远程显示」不是后来加的功能,是它的原生形态。你在本机用它,
只是走了 Unix domain socket 的一个特例。

这句话看着像掌故,其实是本篇后面所有内容的地基 ——
正因为协议本来就假设两端在不同机器上,所以后面那些机制(DISPLAY、cookie、转发)
才不是「补丁」,而是原本就在的东西。

1.3 同一套角色,换了一根管子

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px","primaryTextColor":"#333333","lineColor":"#888888","clusterBkg":"#fafafa","clusterBorder":"#cccccc","titleColor":"#555555"},"flowchart":{"nodeSpacing":34,"rankSpacing":48,"padding":12,"useMaxWidth":false}}}%% flowchart TD subgraph L["本机跑 GUI"] L1["GUI 程序<br/>发出的是「怎么画」的命令<br/>它是 X client"] L2["X server(Xorg)<br/>握着显卡、键盘、鼠标"] L3["屏幕 / 键盘 / 鼠标"] L1 -->|"Unix domain socket<br/>/tmp/.X11-unix/X0"| L2 L2 --> L3 end subgraph R["远程跑 GUI(这条链路)"] R1["GUI 程序<br/>跑在几百公里外的 server-a 上<br/>它还是 X client"] R2["X server(VcXsrv / X410)<br/>跑在我的 Windows 办公机上"] R3["屏幕 / 键盘 / 鼠标"] R1 ==>|"SSH channel,跨三台机器<br/>流过去的字节和左边一模一样"| R2 R2 --> R3 end class L1 proc class L2 accent class L3 host class R1 proc class R2 accent class R3 host classDef host fill:#ffe6cc,stroke:#d79b00,color:#333333 classDef net fill:#dae8fc,stroke:#6c8ebf,color:#333333 classDef proc fill:#d5e8d4,stroke:#82b366,color:#333333 classDef idea fill:#e1d5e7,stroke:#9673a6,color:#333333 classDef bad fill:#f8cecc,stroke:#b85450,color:#333333 classDef plain fill:#f5f5f5,stroke:#999999,color:#333333 classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 1 同一套角色,换了一根管子 —— 本机使用才是那个特例

左右两边的角色**完全一样**。变的只有中间那根管子:本机是 Unix domain socket, 远程是一条 SSH channel。X client 发出去的字节,两边一模一样 —— 它根本不知道自己是不是在跨网络。

1.4 立刻自洽的三个现象

理解了角色反转,下面三件事就不用背了:

  • 为什么 DISPLAY 这个变量设在服务器上,指的却是我这边?
    因为它是 X client 用来找 X server 的地址,而 X server 在我这边。
  • 为什么我本地要先装一个 VcXsrv 或 X410?
    因为我这台 Windows 机器上本来没有 X server,得先把这个「服务」开起来。
  • 为什么关掉本地那个小图标,远端程序立刻就崩了?
    因为 server 没了。client 的连接断了,它无处可画。

第二部分 通道:ssh -X 是怎么骗过 GUI 程序的

2.1 服务器那头发生的四件事

关键在于:GUI 程序只认 DISPLAY 这一个入口。它读到什么地址就连什么地址,
不会去验证那头是不是真的 X server。于是整件事的实现只需要一个动作 ——
在 DISPLAY 指的那个地址上,摆一个假的 X server。

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px","primaryTextColor":"#333333","lineColor":"#888888","clusterBkg":"#fafafa","clusterBorder":"#cccccc","titleColor":"#555555"},"flowchart":{"nodeSpacing":34,"rankSpacing":48,"padding":12,"useMaxWidth":true}}}%% flowchart TD S1["① 我敲 ssh -X 登到跳板机<br/>再从跳板机 ssh -X 登到服务器<br/>(在我的办公机上)"] S2["② 服务器上的 sshd 悄悄做了四件事<br/>挑一个空闲的 display 号,比如 10<br/>在 localhost:6010 上开始监听<br/>调 xauth 往 ~/.Xauthority 写一条 cookie<br/>把登录 shell 的 DISPLAY 设成 localhost:10.0"] S3["③ GUI 程序启动,读 DISPLAY,连上 localhost:6010<br/>它以为那头就是 X server<br/>它不知道有 SSH 这回事"] S4["④ sshd 收下这些字节,塞进 SSH 的一个 X11 channel<br/>沿已经建立好的那条连接往回走"] S5["⑤ 办公机上的 ssh 客户端<br/>把字节转投给本地真正的 X server<br/>窗口出现在我面前"] S1 --> S2 --> S3 --> S4 --> S5 N["骗局在这一步<br/>X client 只认 DISPLAY 这一个入口<br/>在那个地址上摆一个假的 X server,就够了"] N -.-> S3 class S1 host class S2 net class S3 proc class S4 net class S5 accent class N idea classDef host fill:#ffe6cc,stroke:#d79b00,color:#333333 classDef net fill:#dae8fc,stroke:#6c8ebf,color:#333333 classDef proc fill:#d5e8d4,stroke:#82b366,color:#333333 classDef idea fill:#e1d5e7,stroke:#9673a6,color:#333333 classDef bad fill:#f8cecc,stroke:#b85450,color:#333333 classDef plain fill:#f5f5f5,stroke:#999999,color:#333333 classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 2 ssh -X 做的事:在 DISPLAY 指的那个地址上,摆一个假的 X server

摊开说,你敲下 `ssh -X` 之后,服务器上的 sshd 悄悄做了四件事:
  1. 挑一个空闲的 display 号,通常从 10 开始;
  2. 在 localhost:6010 上开始监听(display 号 N 对应端口 6000 + N);
  3. 调 xauth 往 ~/.Xauthority 里写一条针对这个 display 的 cookie;
  4. 把登录 shell 的 DISPLAY 设成 localhost:10.0。

之后 GUI 程序启动,读 DISPLAY,老老实实连上 localhost:6010。
sshd 收下这些字节,塞进 SSH 的一个 X11 channel,
沿已经建立好的那条连接往回走;办公机上的 ssh 客户端接住,转投给本地真正的 X server。

注意「沿已经建立好的那条连接」这半句 —— 这就是总述 3.1 讲的方向反转:
连接是我发起的(办公机 → 跳板机 → 服务器),X11 字节流却是反着回来的
(服务器 → 跳板机 → 办公机),两者共用同一条连接。
服务器从来没有主动连过我,这也是为什么办公机在 NAT 后面、没有公网 IP 也照样能用。

登上去之后,看一眼就知道这四件事做没做成:

$ echo $DISPLAY
localhost:10.0

有值就是通道搭起来了;空的就是没搭起来,后面第四部分专门讲这个。

2.2 三件套:两个开关 + 一个不起眼的命令

要让上面四件事发生,需要三样东西同时到位,缺一不可:

在哪 是什么 不满足会怎样
服务器 /etc/ssh/sshd_config X11Forwarding yes sshd 直接拒绝转发请求
客户端(命令行 -X 或 ssh_config 的 ForwardX11) 打开转发 压根没发起请求
服务器上装了 xauth 一个命令行小工具 cookie 写不进去,转发建不起来

前两个是开关,好查。第三个最容易漏:xauth 通常在 xorg-x11-xauth
(或 xauth)这个包里,最小化安装的服务器镜像经常不带它。
没有它,sshd 第 3 步就做不成,DISPLAY 也就设不上 —— 而人第一反应总是去查 sshd_config。

服务器上验证一句话就够:

$ command -v xauth
/usr/bin/xauth

2.3 X11UseLocalhost:容器里跑 GUI 的那个坑

sshd_config 里还有一个开关,默认值是 yes,意思是转发出来的那个假 X server
只绑 127.0.0.1
:

X11UseLocalhost yes      # 只监听 127.0.0.1:6010(默认)
X11UseLocalhost no       # 监听 0.0.0.0:6010

默认值是对的 —— 只绑回环意味着同一台机器上的别人也连不上你这个 display,安全。

坑在于:如果 GUI 程序不在 sshd 所在的那个网络命名空间里,
比如你 ssh 到宿主机之后在容器里跑程序,那么容器里的 127.0.0.1
是容器自己的回环,根本不是宿主机那个监听端口。
于是 DISPLAY=localhost:10.0 在容器里指向一个不存在的东西,报 cannot open display。

这个坑的价值不在于「怎么绕过去」(改成 no 是把监听暴露到全网卡,一般不该干),
而在于它验证了本篇的模型:DISPLAY 是一个地址,地址的解析发生在
X client 所处的网络环境里。想清楚这一点,这类问题就不用试错了。

2.4 怎么亲眼看见这条通道

想确认客户端确实发起了 X11 转发请求,加 -v:

$ ssh -v -X user@server-a
...
debug1: Requesting X11 forwarding with authentication spoofing.

authentication spoofing 这个词组本身就是对本节的注解 ——
ssh 自己把它做的事情叫「认证冒名」。

如果服务器那边没开转发,你会看到这一句,它是会打出来的:

X11 forwarding request failed on channel 0

如果本地 xauth 出了问题,会看到这一句:

Warning: untrusted X11 forwarding setup failed: xauth key data not generated

记住这两句长什么样,因为最难查的情况恰恰是这两句都没有 —— 第四部分会讲到。

第三部分 授权:cookie 凭什么能证明是我

3.1 为什么必须有这道门

如果谁都能连上我的 X server 发命令,那网络上任何人都能在我屏幕上画东西 ——
而且远不止画东西(篇③会展开到底能干多过分)。

所以协议里必然要有一道门。注意这个推论的方向:
cookie 不是 X11 的一个附加特性,它是「传命令」模型的必然产物。
你选了让远端给你的屏幕发指令,你就必须回答一个问题:
「凭什么相信来连我的,是我自己启动的那个程序?」

X11 的答案朴素得很:一个随机数。

~/.Xauthority 里按 display 分别存放着一串随机十六进制值。
程序连上来时把它出示一遍,对得上就放行,对不上就拒绝。

$ xauth list
server-a/unix:10  MIT-MAGIC-COOKIE-1  a3f2…

注意左边那个 server-a/unix:10 —— cookie 是按 display 存的,不是按用户存的。
一台机器上开了三个 ssh -X 会话,这里就会有三条记录,互不通用。

它的全部安全性来自「只有该知道的人知道」。所以两句话概括它的脾气:
泄漏 = 权限泄漏,丢失 = 你自己也进不去。

3.3 三个经典故障,同一个原因

由 3.2 那一句「按 display 存在家目录的一个文件里」,可以直接推出三个故障,不用背:

现象 为什么
sudo 之后 GUI 打不开 HOME 变成了 /root,去新家目录找 ~/.Xauthority,那里没有这条记录
家目录在 NFS 上,或者磁盘满了 cookie 根本写不进去,转发建不起来
换了个终端窗口就打不开了 那是另一个会话、另一个 display 号,对应的是另一条记录

第一个最常见。它的正确解法不是「关掉 cookie」,而是把凭据带过去 ——
比如 sudo -E(保留环境变量)配合把 XAUTHORITY 指向原来的文件,
或者干脆用 xauth 把那条记录导到目标用户的家目录下。

3.4 xhost + 是拆门锁,不是修钥匙

网上搜这类报错,第一条答案往往是 xhost +。它确实能让报错消失,
所以值得说清楚它到底干了什么:

xhost + 的意思是「取消基于主机的访问控制」——
在这之后,网络上任何能连到你 display 的人,都可以连上来。
不需要 cookie,不需要任何凭据。

这不是「修好了钥匙」,是「把门锁卸了」。而上面 3.3 那三个故障,
根因全都是凭据没被正确地带到,一个都不是「锁坏了」。

要临时放行,用 xhost +SI:localuser:<用户名> 这种带限定的形式,
把范围收到一个具体用户,而不是敞开给整个网络。

第四部分 多跳:一条翻译链,断在哪都不报错

4.1 每一跳都是重新翻译,不是转交

到这里模型已经齐了,但真实链路是两跳:办公机 → 跳板机 → 服务器。
多一跳之后,很多人的心智模型是「cookie 从我这儿一路传到服务器」。不是的。

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px","primaryTextColor":"#333333","lineColor":"#888888","clusterBkg":"#fafafa","clusterBorder":"#cccccc","titleColor":"#555555"},"flowchart":{"nodeSpacing":34,"rankSpacing":48,"padding":12,"useMaxWidth":false}}}%% flowchart LR H1["我的办公机<br/>真正的 X server 在这里<br/>DISPLAY = :0<br/>~/.Xauthority 里是 :0 的 cookie A"] H2["跳板机 jump01<br/>这一跳的 sshd 分配 display 10<br/>DISPLAY = localhost:10.0<br/>~/.Xauthority 里是 cookie B<br/>B 是新生成的,不是 A"] H3["目标服务器 server-a<br/>这一跳的 sshd 又分配一次 display 10<br/>DISPLAY = localhost:10.0<br/>~/.Xauthority 里是 cookie C<br/>C 又是新生成的"] H1 ==>|"重新翻译一次<br/>不是把 A 转交过去"| H2 H2 ==>|"再翻译一次<br/>不是把 B 转交过去"| H3 B1["断点① 堡垒机为做会话审计<br/>只放行 shell channel<br/>X11 channel 被直接吃掉"] B2["断点② 这一跳的 sshd 没开 X11Forwarding<br/>翻译链到这里就停了"] B3["断点③ 这一跳没装 xauth<br/>cookie 写不进去,转发静默失败"] B1 -.-> H1 B2 -.-> H2 B3 -.-> H3 class H1 host class H2 net class H3 proc class B1 bad class B2 bad class B3 bad classDef host fill:#ffe6cc,stroke:#d79b00,color:#333333 classDef net fill:#dae8fc,stroke:#6c8ebf,color:#333333 classDef proc fill:#d5e8d4,stroke:#82b366,color:#333333 classDef idea fill:#e1d5e7,stroke:#9673a6,color:#333333 classDef bad fill:#f8cecc,stroke:#b85450,color:#333333 classDef plain fill:#f5f5f5,stroke:#999999,color:#333333 classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 3 多跳不是把 cookie 转交下去,是每一跳重新翻译一次

每一跳的 sshd 都**独立地**走一遍第二部分那四件事: 自己挑 display 号、自己监听、自己调 `xauth` 生成一条**新的** cookie。 所以跳板机上的 cookie B 和我办公机上的 cookie A 没有任何关系, 服务器上的 cookie C 又是另一个。

这是一条翻译链,不是一条隧道。理解这个差别,下面两件事就顺了:

  • 为什么每一跳都得单独加 -X(少加一次,链子就从那里断);
  • 为什么两跳之后 DISPLAY 还是 localhost:10.0 ——
    那个 10 是这一跳本地的编号,和上一跳的 10 毫无关系,纯属巧合地相同。

4.2 堡垒机:最容易漏掉的第四个变量

画出来是三台机器,排查的时候得按四个变量算 —— 跳板机上那套堡垒机软件本身是第四个。

很多堡垒机为了做会话审计,只放行 shell channel,其他 channel 一律丢弃。
X11 channel 就这么被吃掉了 —— 而且这一种失败,2.4 里那两句报错一句都不会出现。
从客户端看,请求发出去了,也没被拒绝,只是那条 channel 的数据进了黑洞。
你能看到的全部信息,就是 DISPLAY 是空的。

它值得单独拎出来,是因为这是排查时最容易漏掉的一环:
ssh -X 的参数、服务器的 sshd_config、本地的 X server,
三样都查过了,全对 —— 问题出在中间那台谁都没去看的机器上。

4.3 排查顺序:逐跳 echo,第一个变空的地方就是断点

既然报错不可靠,就别从报错入手。按代价从低到高:

  1. 在每一跳上 echo $DISPLAY。 从跳板机开始,再到服务器。
    第一个变空的地方,就是断点所在的那一跳。
  2. 断点那一跳,确认客户端参数。 上一跳登进来的时候加 -X 了吗?
  3. 断点那一跳,command -v xauth。 没有就是它。
  4. 断点那一跳,看 sshd_config 的 X11Forwarding。
    同时 ssh -v 看有没有 X11 forwarding request failed on channel 0。
  5. 前四步全对、且断点在跳板机 —— 那基本就是 4.2 那个变量,去问管堡垒机的人。

这个顺序的逻辑很简单:先定位在哪一跳,再定位是哪一环。
反过来做(先把所有配置查一遍)会把工作量乘以跳数。

4.4 可以自己反推的练习

盖住答案,先想一遍。这几个都能用本篇的模型直接推出来:

  1. 我在服务器上 su 成另一个用户,GUI 就打不开了。为什么?
    (提示:3.2 那句「cookie 按 display 存在家目录里」。)
  2. 同一台服务器我开了两个终端,A 窗口里能开 GUI,B 窗口里不能。
    两个窗口的 echo $DISPLAY 会一样吗?
  3. 我在服务器上跑了个后台进程,然后退出登录。
    过一会儿重新 ssh -X 上去,kill -CONT 唤醒它,它的窗口会回来吗?为什么?
    (这题的完整答案在篇③,但你现在应该已经能猜到方向了。)
  4. 本地 X server 没开,远端执行 GUI 程序,报错会是「连接被拒绝」还是「超时」?
    (提示:2.1 里 X client 连的是哪个地址。)
  5. 如果我把 X11UseLocalhost 改成 no,2.3 那个容器的问题解决了吗?
    代价是什么?

第 5 题值得多想一层:它解决的是地址可达性,
而它的代价是把一个只靠 cookie 保护的入口暴露到全网卡上。
这就滑到篇③要讲的话题了 —— 借出一个 display,到底等于交出了什么。

posted @ 2026-09-18 18:08  起飞的乌龟  阅读(13)  评论(0)    收藏  举报