X11 转发的代价,以及它为什么正在被淘汰

这是「远程 GUI 与跳板机链路」系列的篇③,也是这组文章的收尾。

本篇的第一性问题:把 display 借给远端,实际授予了什么权限?
以及,既然「传命令」这条路有这么多硬伤,它为什么还活着 —— 又为什么正在死去。

回链总述:本篇负责
总述 2.3 的推论四
—— 「命令流本身没有状态可存,所以断线即丢失」。
同时把 篇① 3.4 留下的那个问题接过来:
门锁讲完了,门后面到底有什么。

本篇能解释故障表里的哪几行:断网重连后程序没了;
以及那一整类不在表上、但每天都在发生的事 —— 「-X 报错,换成 -Y 就好了」。

本篇最想让你带走的一句话:cookie 解决的是准入,不是隔离。
一个 display 上的 client 之间没有任何隔离,这不是配置没做好,是协议里根本没有这个概念。

全文四部分:一 代价(借出 display 等于交出什么)→
二 取舍(-X 还是 -Y,一张决策表)→
三 断线(推论四的完整展开)→ 四 出路(五个方案,一根轴,一个被时代推翻的前提)。

第一部分 代价:把 display 借出去,等于交出什么

1.1 先把结论摆出来:不是「可能被看到」,是「随便看」

篇①讲完了 cookie:谁拿着有效的 cookie,谁就能连上我的 display 画东西。
当时留了一句话没展开 —— 「而且远不止画东西」。

现在展开。一个持有有效 cookie 的 client,在我的 display 上可以:

  • 截下整个桌面的像素,随时,不留痕迹,包括我另一个窗口里的密码管理器;
  • 读到我敲的每一个键,不只是敲在它自己窗口里的,
    包括我在终端里敲的 sudo 密码;
  • 伪造输入事件,往任意一个窗口注入按键和鼠标点击,
    而这些事件在协议层面和我真的敲下去无法区分。

这三件事里没有一件是漏洞,没有一件需要提权,没有一件会触发告警。
用的全是 X11 协议里的正常请求。

所以 ssh -X server-a 这条命令的真实含义是:

把本机的键盘和屏幕,交给对端有 root 的那个人。

不是「有一定风险」,不是「理论上可能」。是默认就是这样,而且做起来很轻松。

1.2 为什么协议里没有「隔离」这个概念

这件事值得多问一层:X11 的设计者不是不懂安全,为什么会留下这么大一个口子?

回到 总述 2.2 那间 1984 年的机房。
那时候一个 display 是什么?是桌上一台 X terminal —— 一块屏幕、一副键鼠、
一个跑着 X server 的小盒子。连到它上面的所有 client,都是同一个人启动的。

在那个前提下,「client A 能不能看见 client B 的窗口」根本不是个问题。
能看见是功能,不是缺陷 —— 窗口管理器要给所有窗口画标题栏,
截屏工具要读整个屏幕,宏录制工具要注入按键。这些都是正经需求,
而它们能实现,正是因为协议允许任意 client 这么干。

于是 X11 的授权模型是二值的:

cookie 能做什么
对 全部
不对 什么都不能

没有中间档。 协议里没有「这个 client 只能画自己的窗口」这种表达方式,
因为没有人需要它。

这正是推论二的另一面。总述的推论二
说:选了「传命令」,协议里就必须有授权 —— 否则谁都能发命令。
注意那句话解决的问题是准入:这个连接是不是自己人。
它从来就没打算回答第二个问题:进来之后,自己人之间要不要分家。

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 C1["我的密码管理器<br/>本机启动的"] C2["我的终端<br/>正在敲 sudo 密码"] C3["server-a 上转发过来的程序<br/>拿到了 cookie,是合法 client"] S["我办公机上的 X server(一个 display)<br/>对它来说,上面三个都只是「持有有效 cookie 的 client」<br/>一视同仁,协议里没有第二档"] C1 --> S C2 --> S C3 --> S S --> A1["截全屏<br/>整个桌面,随时,不留痕<br/>包括那个密码管理器的窗口"] S --> A2["抓所有键盘输入<br/>不只是它自己的窗口<br/>你在别的窗口敲的密码也算"] S --> A3["伪造输入事件<br/>往任意窗口注入按键和点击<br/>和你自己敲的无法区分"] A1 --> F["这三件事用的全是协议里的正常请求,没有一个是漏洞<br/>一个 display 上的 client 之间没有任何隔离 —— 这不是配置没做好,<br/>是 X11 协议里根本没有这个概念<br/>所以 ssh -X 的真实含义是:把本机的键盘和屏幕,交给对端有 root 的那个人"] A2 --> F A3 --> F class C1 proc class C2 proc class C3 bad class S host class A1 bad class A2 bad class A3 bad class F accent 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 同一个 display 里没有「别人」:拿到 cookie 的一方能做的三件事

具体到协议层,这三件事分别靠什么:

截全屏。 X 的窗口是一棵全局的树,根是 root window。
任何 client 都可以 XQueryTree 从根往下枚举,拿到屏幕上每一个窗口的 ID、
位置、大小、标题。然后对 root window 调 XGetImage,读回来的就是整屏像素。
scrot、import、xwd 这些命令行截图工具,干的就是这件事 ——
它们不是黑客工具,它们只是普通程序。

抓键盘。 两条路。一条是 XGrabKeyboard,把键盘整个抢过来;
这条路太张扬,焦点会明显不对劲。更安静的是 XRECORD 扩展 ——
它本来是给宏录制和自动化测试用的,作用是「把 server 收到的所有输入事件抄一份给我」。
一个 client 挂上 XRECORD,从此我在这个 display 上敲的每个键它都收得到,
不管焦点在谁身上,也不管那个窗口是谁的。

注入输入。 XTEST 扩展,XTestFakeKeyEvent / XTestFakeButtonEvent。
它本来是给自动化测试用的,作用是「造一个和真实输入完全一样的事件」——
注意「完全一样」是它的设计目标,所以接收方在协议层没有办法区分。
xdotool 就是这么工作的。

这三样都可以在本机验证,而且完全无害。开两个终端窗口,在其中一个里敲:

# 焦点切到另一个终端,3 秒后它会自己打出一行字
sleep 3; xdotool type 'hello from another client'

第二个终端里会凭空出现这行文字。那个进程和它没有任何父子关系、
没有共享 tty、没有任何权限,它只是同一个 display 上的另一个 client。

把这件事放回远程场景:那个「另一个 client」是从 server-a 上转发过来的。
它跑在别人的机器上,而那台机器上有别的 root。

1.4 门锁和门后面:xhost + 现在是另一个量级的事

篇① 3.4 说过 xhost + 是拆门锁。
当时那个判断是从「凭据管理」的角度下的:三个经典故障的根因都是凭据没带到,
一个都不是锁坏了,所以卸锁是答非所问。

现在可以从第二个角度再说一遍,而这个角度要重得多:

xhost + 之后,网络上任何能连到你 display 的人,都自动获得了 1.3 那三件事的能力。
不需要 cookie,不需要任何凭据。截屏、抓键盘、注入输入,对所有人开放。

cookie 泄漏和 xhost + 的差别只在「谁能拿到」。
拿到之后能干的事,完全一样。

所以篇①那条建议现在有了更硬的理由 —— 要临时放行,
用 xhost +SI:localuser:<用户名> 把范围收到一个具体的本机用户,
而不是敞开给整个网络。

第二部分 取舍:-X 还是 -Y

2.1 -X 到底做了什么

-X 和 -Y 的区别,网上一句话就带过了:「-Y 不安全,尽量用 -X」。
这句话作为口号没错,但它解释不了为什么大家最后用的都是 -Y ——
因为 -X 经常跑不起来,而「跑不起来」永远赢「不安全」。

要能做取舍,得先知道 -X 具体多做了什么。它做了两件事:

第一,生成的是一个 untrusted cookie。 篇① 2.1 讲过,
sshd 在服务器那头会调 xauth 生成一条新的 cookie 记录。
-X 的情况下,ssh 客户端在本机用的是 xauth generate ... untrusted,
这条 cookie 在我的 X server 眼里带着一个标记:这个连接不受信任。

第二,这个 cookie 带超时。 默认由 ForwardX11Timeout 控制,
通常是 20 分钟。到点之后,用这条 cookie 建立新连接会被拒绝。
现象就是:ssh 会话还连着,前面开的窗口也还在,
但再敲一次 xterm & 就打不开了 —— 而且报错文案和「没开转发」几乎一样。

标记为 untrusted 的连接,由 X server 里的 SECURITY 扩展接管。
它做的事恰好就是给 1.2 那张二值表补上中间档:untrusted client 只能操作自己的资源,
读不到别的 client 的窗口和像素,grab 不了键盘,XTEST 注入被拒,
相当一部分扩展对它直接不可见。

换句话说,-X 是事后用一个扩展,去补协议本身没有的隔离。
这个定性很重要 —— 它解释了下面 2.2 的全部现象。

2.2 为什么很多程序在 -X 下报错

补丁不是重做。SECURITY 扩展是在一个「所有 client 一视同仁」的协议上
硬加出来的限制层,而上层的工具包并不知道自己正跑在受限模式下。
于是它照常发请求,被拒,然后按「这是个致命错误」处理。

-X 下的典型症状:

X Error of failed request:  BadAccess (attempt to access private resource denied)
  Major opcode of failed request:  33 (X_GrabKey)
Xlib:  extension "RANDR" missing on display "localhost:10.0".
Xlib:  extension "GLX" missing on display "localhost:10.0".

这几条分别对应:程序想注册一个全局快捷键(grab 被拒);
程序想查屏幕分辨率和多显示器布局(RANDR 在 untrusted 下不可见);
程序想用 3D(GLX 不可见,于是连
篇② 4.1
里那条「能跑但很难用」的 indirect 路都走不了)。

还有两类更隐蔽的:

  • 输入法。候选框是另一个 X client(篇② 4.4),XIM 协议要在 client 之间传事件,
    untrusted 下这条路常常不通 —— 现象是能打字但候选框不出来,或者出来了选不中。
  • 跑到一半才坏。超时一到,新窗口开不出来,
    但已经开着的窗口还好好的。这种时间错位最容易让人误判成「网络抽了一下」。

看到这里,那句「-X 报错就换 -Y」的来历就清楚了:
它不是因为大家不在乎安全,是因为 -X 的失败方式看起来像故障,不像保护。
报错里没有任何一个字提到 untrusted。

2.3 在动手换 -Y 之前:先确认你现在用的是不是已经是 -Y 了

这一条值得单独拎出来,因为它能省掉一大半无谓的纠结。

OpenSSH 客户端有一个配置项 ForwardX11Trusted。
它打开的时候,-X 的实际效果就等于 -Y ——
不生成 untrusted cookie,不走 SECURITY 扩展,不设超时。

而 Debian 系的发行版历来在 /etc/ssh/ssh_config 里默认打开它,
理由正是 2.2 那一堆报错太劝退。所以在很多机器上,
你以为自己一直在用 -X 的保护,其实一次都没有用上。

一条命令就能查清楚。ssh -G 打印的是最终生效的配置,
所有配置文件和命令行参数都算进去了,不会真的去连:

ssh -G -X server-a | grep -i forwardx11
forwardx11 yes
forwardx11trusted yes
forwardx11timeout 0

上面这个输出的意思是:-X 已经被降级成 -Y 了,超时也没有。
如果你想要真的 untrusted,得显式关掉:

ssh -X -o ForwardX11Trusted=no server-a

然后 2.2 那些报错才会出现 —— 那不是环境坏了,那是保护第一次真的生效了。

2.4 决策表

把上面的东西合成一张表。列的不是「安不安全」,是在什么情况下选什么,以及残余风险是什么:

场景 选 为什么 残余风险 / 更好的做法
对端只有我一个 root,是我自己的机器 -Y(或默认被降级的 -X) 威胁模型里没有第三方;为省事付出的代价可控 对端被入侵 = 我的桌面被入侵。别在这台机器上装来路不明的东西
对端是共享开发机,别人也有 root 都不该用 1.3 那三件事对所有共享者开放,而你无法审计 在对端起 VNC / Xpra,只把画面拿回来(见 4.1)
只跑一个短命小工具(xclock、xmessage、xdpyinfo) -X,显式加 -o ForwardX11Trusted=no 这类程序不 grab、不用 RANDR / GLX,untrusted 完全够用 基本没有。这是 -X 唯一真正好用的场景
程序在真 -X 下报错,而我必须用它 先问「是不是能换模型」,不行再 -Y 换 -Y 等于把 2.1 那层补丁整个拆掉,不是「放宽一点」 拆了就是全开。至少把 ForwardX11Timeout 设短,别长期挂着
跨堡垒机 / 生产环境 不要用 X11 转发 见 2.5 用堡垒机自己提供的图形会话,或对端 VNC + 端口转发
容器里的 GUI 安全上和「共享开发机」同档 容器不是安全边界,同宿主上的东西该怎么看还是怎么看 通道怎么打通见 篇① 2.3

这张表里唯一需要记的只有一行:第三行是 -X 真正成立的地方,其余的要么无所谓,要么该换模型。
-X 不是「安全版的 -Y」,它是一个能力更弱、适用面更窄的东西。
把它当成默认值来推荐,才是那句口号真正的问题。

2.5 为什么绝大多数堡垒机默认关掉 X11 转发

这不是配置懒,是三条独立的理由,每一条单独都够。

一、审计做不到。 堡垒机的核心价值是「谁在什么时候敲了什么命令」可以回放。
shell channel 里跑的是文本,录下来就是证据。
X11 channel 里跑的是二进制的 X 协议流 —— 录下来是一堆 PolyLine 和 PutImage,
不是命令。审计系统对它无能为力。
等于在一条受审计的通道里,开了一条不受审计的旁路。

二、数据流的方向是反的。
总述 3.1
讲过这件事:连接是办公机 → 跳板机 → 服务器发起的,
X11 字节流却是服务器 → 跳板机 → 办公机回来的。
对堡垒机来说,这是一条从受控区往办公网回灌数据的通道,而且内容不可解析。
剪贴板尤其敏感 —— X selection 能双向搬运任意字节(篇② 4.2),
它是一条完整的文件传输通道,只是长得不像。

三、攻击面的方向也是反的。
堡垒机的威胁模型通常假定办公机可信、服务器区需要保护。
而 1.3 那三件事的受害者是办公机,不是服务器。
X11 转发恰好把这个假定打破了:一台被入侵的服务器,
可以通过一条合法的、由内向外发起的 SSH 连接,反过来控制运维人员的桌面。

顺带补上篇①留的一个尾巴。
篇① 4.2
说过,堡垒机吃掉 X11 channel 的时候通常不报错,只是 DISPLAY 为空。
现在可以说清楚为什么:那不是 bug,是有意为之的默认。
SSH 协议里,channel 打开失败对客户端来说是一件平常事,客户端不会当成致命错误;
而堡垒机也没有动机去告诉你「我拦了」。
一个安静的默认拒绝,比一条报错更符合它的设计目标。

第三部分 断线:推论四的完整展开

3.1 状态攥在谁手里:断线之后谁还活着

场景是这样的:在 server-a 上起了一个仿真,GUI 开着,跑了六个小时。
合上笔记本去开会,回来一看 —— 窗口没了,终端里躺着一行:

X connection to localhost:10.0 broken (explicit kill or server shutdown).

六个小时白跑。重连之后,没有任何办法接回去。

%%{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 L1["X11 转发:状态在我这边<br/>窗口、像素、控件状态全都活在我办公机的 X server 里<br/>远端进程手里只有一串 XID"] L2["SSH 断了<br/>网络抖动 / 合盖 / VPN 重连 / 堡垒机超时"] L3["X 连接断,server 销毁这个 client 的全部资源<br/>远端进程下一个请求写进死掉的 socket<br/>X connection to :10.0 broken / I/O error"] L4["进程死了。重连也接不回去 —— 没有东西可接"] L1 --> L2 L2 --> L3 L3 --> L4 R1["VNC / Xpra:状态在那边<br/>服务器上自己跑着一个 X server<br/>窗口、像素、控件状态都在那台机器上,我这边只是个看画面的观众"] R2["连接断了<br/>同样的网络抖动 / 合盖 / VPN 重连"] R3["观众走了,演出照常<br/>程序还连着那边的 X server,继续跑<br/>仿真跑了 6 小时也不受影响"] R4["重连就回到原样。还能换台机器连,还能多人同时看"] R1 --> R2 R2 --> R3 R3 --> R4 L4 --> F["差别不在「哪个软件做得好」,在于「谁手里攥着状态」<br/>X11 转发传的是命令流,命令流本身没有状态可存<br/>这不是实现缺陷,是选了「传命令」之后就跑不掉的推论<br/>想要断线可续,就必须让状态留在服务端 —— 也就是让服务端自己有个 X server"] R4 --> F class L1 net class L2 host class L3 bad class L4 bad class R1 net class R2 host class R3 proc class R4 proc class F 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 断线之后谁还活着:区别不在软件好坏,在于状态攥在谁手里

为什么接不回去,答案分两层,而第二层才是要命的那层。

第一层:资源在我这边,连接一断就被销毁了。

X client 手里其实什么都没有。窗口、Pixmap、GC、Colormap ——
这些全都是 server 侧的对象,client 手里只有一串 XID,也就是一堆整数。
真正的窗口内容、真正的像素,活在我办公机的 X server 里。

X 协议规定,一个 client 断开连接时,server 要走一遍资源回收,
把这个 client 创建的所有资源销毁掉。
所以 SSH 一断,我这边的 X server 立刻把那六个小时的窗口全部清掉了。

严格说协议里并非完全没有留口子 —— XSetCloseDownMode(RetainPermanent)
可以让资源在 client 断开后留下来。但它解决的不是这个问题:
它保留的只是资源,既不保留连接,也没提供把这些资源交给一个新 client 的办法。
那套机制是给会话管理器收拾残局用的,不是给远程续会话用的。

第二层:就算资源留着,那个进程也已经死了。

这才是真正过不去的坎。远端那个进程持有的是一个 socket fd 和一堆 XID。
socket 死了之后,它下一次调 XFlush 或者任何一个 Xlib 函数,
写进去就是 EPIPE。Xlib 的默认 I/O error handler 做的事是:
打印上面那行 X connection ... broken,然后 exit(1)。

而且这个 handler 按约定不允许返回 —— 它要么退出,要么 longjmp 走。
这意味着哪怕程序作者想自救、想「等网络回来再重连」,
标准 Xlib 也没给他一个干净的回来的地方。
(新一些的 libX11 加了可以返回的变体,但生态基本没跟上,
你手上那个 Qt / GTK 程序几乎肯定还是老行为。)

所以「重连接不回去」不是因为没人做这个功能,是因为没有东西可接:
状态在我这边,已经被销毁了;进程在那边,已经退出了。

3.2 这不是实现缺陷,是推论四

到这儿可以把总述那条推论补完了。
总述的推论四
原话是:命令流本身没有状态可存,所以断线即丢失。

现在能看清这句话的分量了。它说的不是「X11 忘了做会话恢复」,是:

选了「传命令」这条路,会话状态只能落在接收命令的那一端;
而接收命令的那一端,按推论一,是我这台办公机。
我这台办公机会关机、会合盖、会换网络 —— 状态放在它身上,本来就不可能续。

反过来看右边那一栏就通了。VNC / Xpra 能断线重连,不是因为它们的工程做得更细,
是因为它们把状态放在了服务器那边:服务器上自己跑着一个 X server
(Xvnc、Xvfb、Xpra 自带的那个),程序连的是它,从头到尾没跨过网络。
我这边只是个看画面的观众。观众走了,演出照常。

所以:

差别不在「哪个软件做得好」,在于「谁手里攥着状态」。
想要断线可续,就必须让状态留在服务端 —— 也就是让服务端自己有个 X server。
而这一步,恰恰就是往轴的右边挪一格。

这也解释了 4.1 那张图里一个看着奇怪的地方:VNC 的「代价」栏写着
「服务端必须先有个 X server」。那不是安装麻烦,那是它能断线重连的原因本身。

3.3 一个很常见的误解:tmux 和 nohup 救不了 GUI

这个误解值得单列,因为它太自然了 —— 命令行任务怕断线,tmux 就解决了;
那 GUI 怕断线,套一层是不是也行?

不行,而且原因很干脆:它们保的东西不一样。

工具 它真正保住的是 对 X11 转发有用吗
tmux / screen 一个伪终端,以及挂在上面的进程组 没用。GUI 程序死在 X 连接上,和它的 tty 在哪没关系
nohup 忽略 SIGHUP 没用。X 连接断掉根本不发 SIGHUP,是 socket 读写直接失败
setsid / disown 脱离会话 / 不被 shell 管 同上
Xvnc / Xpra 一个真的 X server 有用 —— 因为它换的是状态的位置,不是进程的归属

在 tmux 里跑一个 GUI 程序,SSH 一断,tmux 会话确实还在、进程也还在几毫秒。
然后那个进程的下一次绘图调用写进死掉的 socket,exit(1)。
tmux 眼睁睁看着它退出,什么也做不了。

一句话:tmux 保的是终端,X 转发丢的是 display。前者救不了后者。
唯一能救的办法在表的最后一行 —— 换一个「状态在服务端」的模型。

第四部分 出路:五个方案,一根轴,一个被时代推翻的前提

4.1 五个方案在这根轴上的位置

总述 2.1 那个穷举,到这里可以兑现了:
跨过网络的只有「怎么画」和「画成了什么」两类东西,没有第三种。
所以所有远程 GUI 方案都落在同一根轴上,挑方案就是挑在这根轴上站哪儿。

%%{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 AX["◀ 传命令 省带宽、文字锐利、本地渲染   |   抗网络延迟、状态留在服务端 传画面 ▶<br/>挑方案不是挑软件,是挑在这根轴上站哪儿。站的位置一定,省带宽 / 抗延迟 / 能不能断线重连,就跟着定了"] AX --> P1["X11 转发 ssh -X<br/>传:绘图命令(PolyLine、FillRectangle、ImageText8)<br/>断线:丢失,进程跟着死<br/>代价:每次问答一个 RTT;现代工具包自己画位图,优势被抵消<br/>适合:内网、低延迟、单个窗口"] AX --> P2["NX / Xpra<br/>传:还是 X 协议,但中间架了代理:缓存 + 压缩 + 合并往返<br/>断线:Xpra 能 detach / attach<br/>代价:服务端要多跑一个代理<br/>适合:想保留单窗口的用法,又要能断线重连(rootless 窗口)"] AX --> P3["RDP / xrdp<br/>传:主要是画面,但协议层带语义:位块搬移、剪贴板、音频<br/>断线:会话保留<br/>代价:协议比 VNC 聪明,但 Linux 侧(xrdp)是模拟实现,深度有限<br/>适合:客户端本来就是 Windows"] AX --> P4["VNC RFB 协议<br/>传:帧缓冲的差异块,纯像素<br/>断线:状态在服务端,可续,还能换台机器连、多人同时看<br/>代价:服务端必须先有个 X server;文字是缩放过的像素,边缘发糊<br/>适合:长跑任务、网络不稳"] AX --> P5["VirtualGL<br/>传:不自己传 —— 让远端 GPU 渲染,再把像素交给 VNC / Xpra 送<br/>断线:跟随所搭配的那个通道<br/>代价:服务端得真有 GPU<br/>适合:OpenGL 重载。它不是第五种传输方案,是给前四种补 GPU"] P1 --> F["Wayland 不在这根轴上 —— 它的协议里压根没有「网络」这一条<br/>X11 当年为了网络透明性付出的全部代价(角色反转、cookie、round-trip、无会话状态),<br/>Wayland 一开始就拒绝支付:合成器和客户端只通过本机 socket 说话,远程要靠 waypipe 之类的外挂另做一层<br/>这不是 Wayland 缺功能,是它认定「所有 GUI 都可能来自网络」这个 1984 年的前提,在今天不成立了"] P2 --> F P3 --> F P4 --> F P5 --> F class AX plain class P1 bad class P2 net class P3 net class P4 net class P5 accent class F 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

图 3 五个方案在「传命令—传画面」轴上的位置 —— 以及不在这根轴上的 Wayland

图里有三个点,文字上再强调一遍:

第一,站的位置一定,代价就跟着定了。
越靠左,越省带宽、文字越锐利(因为文字是在我本地用我的字体渲染的),
但越吃 RTT、越不可能断线重连。越靠右,越抗延迟、越能断线重连,
但文字变成缩放过的像素,带宽也上去了。这不是各家实现水平的差别,是位置的推论。

第二,NX / Xpra 是真正的中间态,不是「更好的 X11 转发」。
它们传的还是 X 协议,但两端各架一个代理:缓存重复的请求、压缩、
把多个往返合并成一次。Xpra 更进一步 —— 服务端那个代理自己就是个 X server,
所以它同时拿到了「状态在服务端」(能 detach / attach)和
「rootless 窗口」(单个窗口贴在我本地桌面上,不是一整个远程桌面)。
如果你对 ssh -X 的依赖主要是「我只想看一个窗口」,Xpra 基本就是为你写的。

第三,VirtualGL 不是第五种传输方案。
它解决的是「渲染在哪」,不是「像素怎么过来」——
让服务器的 GPU 正常渲染,把结果帧读回来交给 VNC 或 Xpra 去送。
所以它总是和右侧的某个方案搭配出现,自己不构成一个选项。
这一点在 篇② 4.1
已经推过:3D 远程这件事,在「传命令」这一侧没有解。

4.2 怎么挑:三个问题就够了

不用记那张表。按顺序回答三个问题,方案基本就定了:

问题一:我需要「断线之后还能接回去」吗?
需要 → 排除 X11 转发,往右走。这是最硬的一条,
因为它不是性能问题,是 3.2 那个「状态在谁手里」的结构问题,没有参数可调。

问题二:我要的是一个窗口,还是一整个桌面?
一个窗口 → Xpra(rootless),或者在不需要续会话时用 X11 转发。
一整个桌面 → VNC(或者客户端是 Windows 的话,xrdp)。
这一条常被忽略,但它是很多人换到 VNC 之后觉得别扭的真正原因 ——
他们要的从来不是远程桌面,是一个窗口。

问题三:我要跑 3D 吗?服务器上有 GPU 吗?
要跑且有 → VirtualGL 加一个右侧通道。
要跑但没有 → 这件事在远程做不了,换思路(比如离线渲染出图,再看图)。

三个问题答完,剩下的分歧就不是技术选型了,是部署成本:
在那台服务器上装东西、起进程、可能还要开端口,
在受管控的环境里这是一道审批 —— 而 ssh -X 不用。
这也正是 4.4 要说的事。

4.3 Wayland:协议里根本没有「网络」这一条

X11 的继任者 Wayland,在这根轴上的位置是:不在轴上。

这不是修辞。Wayland 协议传的不是绘图命令,也不是像素,
是缓冲区的文件描述符 —— 客户端把一帧画在一块 wl_shm 或 dmabuf 缓冲区里,
然后通过 Unix socket 的 SCM_RIGHTS 把那个 fd 递给合成器。

fd 这个东西,本身就是一个纯粹的本机概念。
它不是数据,是内核里一张表的索引。它跨不过网络,
不是因为没人写序列化代码,是因为没有东西可序列化。

所以「Wayland 不支持远程」这句话的准确说法是:
Wayland 的数据模型里,压根就没有为网络留位置。
waypipe 这类工具的做法,是在两端各跑一个代理,
把 fd 背后那块缓冲区的内容真的拷过去、压缩、在对端重建一块新的。
干的事和 Xpra 同类 —— 都是在协议层做代理 ——
但 waypipe 要做得更多,因为 Wayland 从没给它留接口。

顺带一提,1.3 那三件事在 Wayland 里被顺手解决了:
截屏和注入输入都必须走 portal 或者合成器的显式授权,
普通客户端默认拿不到别的客户端的像素和输入。
注意这里的解法是重做协议,不是打补丁 —— 因为那从来就不是 bug,
而是 2.1 那个 SECURITY 扩展一直在勉强对抗的协议本身的设计。

这就是这组文章最想让人看到的那个结构:

1984 年,「所有 GUI 都可能来自网络」是一个合理的前提。
为它付出网络透明性的代价 —— 角色反转、cookie、round-trip、无会话状态 ——
在当时是划算的。

2026 年,几乎所有 GUI 都跑在本机。那份代价变成了纯负担,
而它换来的那个能力,有了更好的实现(VNC、RDP、Xpra、waypipe)。

不是 X11 做错了,是前提变了。

一个设计取舍被时代推翻的标准样本。而且推翻它的不是某个更聪明的人,
是「人们怎么用计算机」这件事本身变了。

4.4 那它为什么还活着

说了这么多,结论不是「别用 ssh -X」。X11 转发在 2026 年仍然到处都在用,
而且理由相当硬:

一、它是零部署的。 对端不用装任何东西、不用起 daemon、不用开端口。
SSH 已经在那儿了,加一个 -X 就完事。
VNC 和 Xpra 都要在对端装软件、起进程 —— 在一台受管控的生产服务器上,
这中间隔着一道审批和一次变更窗口。这个差距比技术差距大得多。

二、单窗口。 很多时候我只想看一个 xterm、一个波形查看器、一个配置界面。
VNC 给我的是一整个远程桌面,嵌一个桌面在桌面里,鼠标焦点、
快捷键、分辨率全都要打一遍架。

三、XWayland 在替它续命。 我的桌面换成 Wayland 之后,
ssh -X 照样能用 —— 因为 X client 走的是 XWayland 这个兼容层,
它在 Wayland 合成器里跑了一个真的 X server。
这意味着 X11 转发的淘汰会很慢:兼容层把它的寿命拉得很长,
长到「什么时候彻底不能用了」这个问题在可见的将来都不会有答案。

所以本篇真正的结论是:

知道你在用什么,知道代价在哪,然后在这三种情况下别用它 ——
对端不完全可信(第一部分)、需要长跑或断线重连(第三部分)、
跨堡垒机或生产环境(2.5)。

其余时候,ssh -X 依然是那个最省事的选择。

4.5 回头看:一个选择,四条推论,四篇文章

这是最后一篇,把整条线收一下。

一切从一个穷举开始:画面要出现在我的屏幕上,程序在几百公里外,
中间只有一条网络 —— 能跨过去的只有「怎么画」和「画成了什么」,没有第三种。

X11 选了左边,选了「传命令」。这一个选择,结出四条由不得设计者的推论:

推论 长成了什么 在哪一篇
角色必须反转 拿着屏幕的那端才是 server 篇①
协议里必须有授权 cookie,以及一条逐跳重新翻译的链 篇①
必然有 round-trip 带宽很闲,窗口却像幻灯片 篇②
必然无会话状态 断线即丢失,tmux 救不了 本篇第三部分

外加一个例外:MIT-SHM 违反了「网络透明」这个承诺本身,
于是成了所有远程和容器场景第一个崩的地方 ——
篇④。

而本篇第一、二部分讲的「同一 display 内没有隔离」,看起来像是独立的安全话题,
其实是推论二的另一面:协议里那道授权,是为了回答「这个 client 能不能画」,
从来就不是为了回答「这个 client 能不能看别人的」。

cookie 解决的是准入,不是隔离。 四条推论到这里合上了。

如果这组文章只留下一个东西,希望是这个习惯:
遇到一个搞不懂的现象,先别去搜报错文案,先问
「这条链路的哪个假设不成立了」。
因为绝大多数看起来千奇百怪的故障,都是几条推论交叉的结果 ——
把推论一条条拆开,故障就变回可推导的。

4.6 可以自己反推的练习

盖住答案,先想一遍:

  1. 我在办公机上跑 ssh -X server-a,然后在 server-a 上跑一个程序。
    这个程序能不能截到我办公机上另一个 SSH 会话的终端窗口?
    如果那个会话连的是另一台服务器呢?
  2. 同事说「我加了 -X 所以是安全的」。用一条命令证明他可能错了。
  3. 一个程序在 -X 下报 extension "RANDR" missing,
    换成 -Y 就好了。这说明 RANDR 在服务器上没装吗?
  4. 我在 tmux 里跑 GUI 程序,SSH 断了之后重连,tmux attach 回去,
    会话在、shell 在,但 GUI 程序没了。tmux 哪里没起作用?
  5. 我把 VNC 换成了 X11 转发,发现「窗口拖动流畅多了」。
    这和篇②的结论矛盾吗?(提示:想想你拖的是什么程序。)
  6. 为什么 Xpra 能做到「断线重连」,而 NX 相对弱一些?
    (提示:看 4.1 图里 Xpra 的「传」那一栏多了什么。)
  7. 一台服务器上装不了任何软件,我必须远程用一个会跑六小时的 GUI 程序。
    按 4.2 的三个问题,我还有什么办法?

第 1 题的答案是能,而且两种情况都能。
截屏截的是 root window 的像素 —— 屏幕上有什么就截到什么,
和那个窗口背后连着哪台机器毫无关系。
这正是 1.1 那句话的分量:交出去的不是「这个程序的窗口」,是整块屏幕。

第 3 题的答案是不说明。-Y 能用恰恰证明 RANDR 装了、也能用 ——
-X 下看不见它,是 SECURITY 扩展(2.1)把它对 untrusted client 藏起来了。
把「我这边看不见」读成「那边没有」,是这一整类 -X 报错最常见的误诊。

第 5 题不矛盾,而且它是篇②那个结论的正面印证:
X11 转发在老式、真的发绘图命令的程序上(xterm、xclock)确实流畅;
一换成现代工具包写的程序就崩盘。
你拖得动的那个,多半正是前者。

第 7 题的答案是:把状态挪到服务端这件事,不一定要靠装软件。
如果程序支持无界面模式,让它在服务器上跑完、出文件,再把结果拿回来看 ——
这是把「远程 GUI」这个问题直接消掉,往往比在轴上挑位置更划算。
挑不出好方案的时候,回头看一眼问题本身是不是必须存在,通常是最便宜的一步。

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