切换深浅色
第23章 XTLS Vision
23.1 双层加密到底重复了哪些工作
浏览器通过VLESS+TLS代理访问HTTPS网站时,浏览器与网站先后产生的TLS记录,在代理客户端看来是待转发正文。代理客户端再用外层TLS保护这份正文,代理服务器解开外层后,将仍受网站TLS保护的字节送往网站。下行执行相反过程。两份TLS有不同端点、密钥和记录序号,并没有因为都用TLS就合并。
普通路径中,上行内层记录先在浏览器加密,再在代理客户端作为外层明文被第二次加密;服务器执行外层解密,却不解开正常浏览器—网站TLS。下行的网站密文在代理服务器再加外层保护,到客户端解掉外层后才交浏览器。长时间传输受保护正文时,两次记录处理会带来额外AEAD计算、标签、分段、缓冲和调用。
Vision尝试在合适阶段改变这条数据路径:初始VLESS身份、目标和早期握手字节仍经相应安全连接传递,并用专用Padding结构调整早期流量;识别到符合条件的内层TLS状态后,双方以命令同步结束Padding或切换后续处理,在允许穿透的路径上直接转发内层已加密的TLS记录。
text
普通阶段:网站TLS记录 → Vision结构 → 外层安全记录 → 网络
可直接转发阶段:网站TLS记录 → 适用的直接复制路径 → 网络后一个箭头省去哪些外层处理,取决于真实连接类型。普通RAW上的TLS或REALITY路径可能切到下层TCP;Encryption路径要检查是否还有XorConn、HTTP承载或另一份安全包装不能绕过。第22章已经指出,random模式不能被简单穿透。不能把概念图理解成所有组合都会移除全部外层。
这不是TLS标准里新增一个能让第三方跳过认证标签的扩展。浏览器与网站的TLS完全按照各自会话工作;代理两端对外层的处理按Vision状态切换。普通TLS接收器若仍拿外层密钥去打开随后直传的内层记录,不会神奇地验证成功;正是代理自己的读写路径不再继续这样处理。
Vision也不需要取得网站私钥。它可以观察内层公开握手和记录边界,判断是否适合后续优化;内容保密仍依赖浏览器与网站的真实TLS验证。仅通过长度、类型和握手特征识别出来的“像TLS”,不等于代理独立验证了网站证书或证明任意后续内容都安全;识别策略的能力与局限会在23.2和23.10展开。
减少外层AEAD与减少用户态复制又是两项不同优化。即使后续不再做外层加密,进程仍可能Read到Buffer再Write出去,执行内存复制和系统调用。只有底层连接、状态、平台和调用路径都满足时,才可能进一步使用Linux splice等机制。直接转发、零拷贝和带宽更高不能作为三个同义词。
短连接也不一定从这些优化中取得相同收益。若大部分成本来自DNS、连接建立、TLS握手或初始Padding,进入直接阶段之前应用就结束了,减少后续批量处理的价值有限。长下载在CPU或复制受限时可能更受益,但是否达到链路上限,需要实际负载与平台证据;本章不提供虚构吞吐数字。
本章仍固定Xray-core 0888beb38a9ba45440557175e71ce21cf8ba3b13。Vision主体在proxy/proxy.go,VLESS的Flow检查、连接对象与包装安装见入站和出站。下文从具体状态和字节展开,而不是由Flow参数名称推断已经启用splice。
23.2 它怎样认出TLS:有限缓冲窗口内的特征检查
Vision需要在仍看得到内层握手时判断后续是否适合直接转发。固定实现的TrafficState同时跟踪TLS识别及两方向Padding状态,其中NumberOfPacketToFilter初始为8,IsTLS、IsTLS12orAbove、EnableXtls初始均为false。这里变量名里的Packet在实际循环中对应Buffer,不是IP包,也不严格等于一次Read。
同一端点的读、写路径共同更新这一流量状态:发送方向可以看到应用原始字节,接收方向在剥掉Vision结构后再检查内容。若一组MultiBuffer里有多个Buffer,计数会逐个消耗;网络分片、承载缓冲与内部Buffer组织都可能改变观察窗口。因此“先看8个包”若不说明单位,就会误导读者。
客户端握手识别较宽:Buffer至少6字节、开头为16 03,并且偏移5为握手类型01,就标记IsTLS。这个位置以TLS记录起点为零:前五字节是记录头,偏移5是内部握手消息类型。它没有在这里完整验证ClientHello的所有长度、扩展或证书。
服务端握手要求开头16 03 03、偏移5为02。识别后根据记录头的两字节长度,设置待观察的服务端记录总长,并标记IsTLS和IsTLS12orAbove。在足够长的Buffer中,它从记录偏移43读取Session ID长度,再按该长度定位后面的两字节密码套件。这是基于已知ServerHello布局的快速检查,不是调用一份完整TLS握手解码器。
接下来在被认为属于这份服务端记录的范围内搜索:
text
00 2b 00 02 03 04
扩展43 长度2 TLS 1.3版本值找到这六字节后,结合此前识别出的套件,决定是否设置EnableXtls。固定字典认识1301、1302、1303、1304和1305,但对TLS_AES_128_CCM_8_SHA256(1305)不启用该直接路径;其他已识别字典项可启用。未知套件不会仅凭版本特征自动变成可直接转发。
这个实现的含义比“所有TLS 1.3流量都识别成功”窄。它保存服务端记录剩余范围,却在当前Buffer内做bytes.Contains;若关键六字节跨Buffer拆开,单次搜索不一定命中。套件提取也依赖当前足够的首批字节。合法TLS流被不同方式缓冲,可能得到不同优化结果;这体现快速识别与完整协议解析的取舍。
反过来,搜索出一串字节不是密码学证明。这里不验证网站证书、CertificateVerify或Finished,不持有浏览器—网站流量密钥,也没有对每份内层记录验证标签。一个受控的发送者可以构造看起来相似的字节;所以EnableXtls是本地实现的流量处理判断,不能被引用为网站身份认证已经完成。
识别到可接受TLS状态后,还需要找到适合切换的边界。VisionWriter调用IsCompleteRecord,将这批MultiBuffer内容拼起来,按五字节头检查一份或多份17 03 03记录,按各自长度跳过内容,并要求输入恰好在最后一份记录结束处终止。只有前面记录的一半,或前面混有16开头的握手记录,都不满足这一批完整应用记录条件。
这一步只核对明文可见的外壳。TLS 1.3的17类型内部还可能是加密握手、告警或应用数据,Vision不解密来区分它们;后续由真实TLS端点验证。TLS记录边界与Web页面正文边界也不同,进入直接阶段不意味着浏览器已经收到第一个HTTP响应。
若识别窗口结束而没有取得允许条件,Vision可以结束早期Padding,继续普通受保护复制,不应被描述成“识别失败也一律移除外层加密”。如果字节是明文HTTP,通常不会满足可接受内层TLS的直接条件。TLS 1.2、未知套件与异常分片同样需要按具体状态说明,不能统一算进已优化流量。
状态初始化、有限识别和整批边界检查见NewTrafficState、XtlsFilterTls与IsCompleteRecord。这里描述所列实现,未做所有合法分片的覆盖测试,也没有把快速字节检查赋予完整TLS验证的能力。
23.3 Flow选择、首块用户标记与两方向状态
启用Vision先从基本VLESS请求的附加信息开始。客户端在线上发送的Flow为16字节xtls-rprx-vision,Protobuf字段为0a 10加文本,共18字节,外层附加信息长度为12。第19章的example.com TCP目标因此得到52字节基本请求头;接下来的Vision正文仍是另一套格式,不能把Flow文本本身当成正文Padding头。
服务端按账户允许的Flow检查请求,再安装VisionReader和VisionWriter。若账户不允许、命令或底层连接对象不满足条件,即使Protobuf能解码也会拒绝。固定代码实际支持读取Encryption CommonConn的状态,不应仅凭仍写着“只支持TLS和REALITY”的错误字符串,遗漏这个较新的分支。
客户端还能使用带-udp443后缀的本地Flow配置,但发送之前会把它截成上述16字节。该后缀影响客户端是否放行某类UDP/443目标,不是线上Flow多出七字节,也不是在Vision数据块里新增一个端口字段。相应UDP路径会结合XUDP/Mux处理,不能套用本章普通TCP记录切换来解释所有UDP数据报。
一旦进入Vision正文,每个方向首次生成Padding块时,都会在块头之前放入16字节用户标记。固定实现复制TrafficState.UserUUID作为writeOnceUserUUID,第一次写出后清空这个待写切片,后续块不再反复附加。两方向各有自己的Writer,因此不是整条双向连接合计只出现一次。
这16字节与基本VLESS头中的ID对应,但角色不同:基本头用于账户查找,Vision首块用于识别该流的Padding起点。它不带新MAC,不构成第二轮独立身份认证,也不是Nonce。初始阶段的安全仍依赖保护这份内容的外层连接与已经通过的账户检查。
本书固定提交的routeUUID细节在这里也有实际影响。服务端建立TrafficState时使用客户端实际发送的userSentID,保留第6、7字节原值;不会将账户查找归一化后的ID直接替换掉它。否则账户能查到,首块标记却可能无法匹配。字段用于认证查找时被部分归一化,不代表它在所有后续协议状态中都可以删改。
接收方初次剥Padding时,XtlsUnpadding检查当前Buffer至少有21字节,且前16字节与UserUUID匹配,随后才前移16字节、开始读五字节块头。21是16字节标记加5字节头的最小范围,不是“每份Padding内容必须至少21字节”。后续头中的长度才决定内容与填充各占多少。
这里还有一处重要的实现边界:初始标记不满足上述条件时,函数直接返回当前Buffer,没有在这个函数中专门缓存一个不足16字节的标记前缀并等下一份拼齐。因此不能把它概括成对任意分片都完整增量解析的通用Vision解码器。完整标记之后的块头、内容与填充确实有剩余量状态,而初始识别依赖当前Buffer条件。两者必须分别说明。
客户端下行先由基本VLESS响应解析器消费00 00,再进入VisionReader。服务端基本responseAddons为空,客户端仍依照请求的Flow建立VisionReader;没有再要求服务端把Flow文本回显一遍。这正是请求状态必须跨到响应阶段保留的一个例子。
Padding与直接复制状态分别按方向推进。发送方可以已经写出一个结束或切换块,而接收方仍在等该块剩余内容和Padding;对端上行也可能还处于另一状态。TrafficState共享TLS识别信息,却为两方向保存不同的剩余头、剩余内容、剩余Padding和直接读写标志。不能看到一个Command=02,就认为同一瞬间两端所有读写路径都已经可splice。
这些状态是连接局部的,不能由另一条使用同一UUID的连接代替。相同账户可以同时有很多连接,各自观察不同目标TLS握手、维护不同缓冲和切换阶段。把UUID当成全进程唯一会话编号,会混合本来独立的记录与命令。
Flow处理见VLESS出站与入站;首标记、状态初始化和识别条件见NewVisionWriter、NewTrafficState及XtlsUnpadding。下一节把那个五字节块头展开到具体字节。
23.4 Padding块:内容长度、填充长度和切换命令
Vision早期正文是若干Padding块的串联。一个方向第一块前有上一节的16字节用户标记;每块本身的格式为:
| 顺序 | 长度 | 字段 |
|---|---|---|
| 1 | 1 | Command |
| 2 | 2 | 内容字节数C,大端 |
| 3 | 2 | 填充字节数P,大端 |
| 4 | C | 被代理的原始内容 |
| 5 | P | 接收方丢弃的填充 |
两个长度都不包含五字节块头,也不包含首块的用户标记。每个长度字段虽然可表示65535,固定生成器仍受8192字节Buffer和额外空间限制;不能由字段宽度推断正常实现会一次生成这么大的块。
三种命令决定该块全部消费完后的方向状态:
| 命令 | 名称 | 该方向之后的处理 |
|---|---|---|
| 00 | Continue | 下一批内容继续按Padding块解析 |
| 01 | End | 结束Padding,后续进入普通内容路径,不因此移除外层保护 |
| 02 | Direct | 结束Padding并请求该方向切换到相应直接路径 |
Command不是VLESS基本头中的TCP/UDP命令。这里01表示Padding结束,不是建立TCP连接;02表示直接路径,不是UDP。一份数据在不同层次出现相同数值,不能相互套用语义。
构造第一块内容abc、填充两字节、命令Continue;再构造内容de、无填充、命令End。使用本书虚构ID,经过外层解密后的Vision视图为:
text
00 11 22 33 44 55 46 77 88 99 aa bb cc dd ee ff
首块标记
00 | 00 03 | 00 02 | 61 62 63 | 00 00
继续 内容3 填充2 abc 填充
01 | 00 02 | 00 00 | 64 65
结束 内容2 填充0 de首块共16+5+3+2=26字节,第二块7字节,合计33;剥离后交付五字节abcde,不保留原先3/2内容分块的应用边界。这是格式构造,不是声称真实TLS内容会等于abcde,也没有触发直接TLS路径。
接收方用RemainingCommand、RemainingContent、RemainingPadding保存状态。若先取得首标记、块头及ab,下一次来的c仍属于内容;之后两字节即使与正常应用字节相同,也必须丢弃,因为此时状态是Padding。Continue块结束后,下一字节重新进入五字节块头,而不是再等待一个16字节UUID。
对Direct也一样,必须先读完该块的C字节和P字节才切换。如果看到Command=02就立刻从下层Socket读取,当前块尚未消费的Padding会污染网站流,外层缓冲里尚有的内容也可能被跳过。命令声明与实际执行点之间存在严格边界,下一节具体追踪它。
固定生成器NewVisionWriter采用默认参数(900,500,900,256)。当longPadding为真且内容少于900字节时,填充为随机0—499 + 900 − C,使内容加填充共900—1399字节;其他分支填充取0—255字节,再受Buffer剩余空间约束。首块因此还需另加16标记和5头,后续块只加5头。随机的是长度,不是要求每个填充字节都调用随机数发生器。
XtlsPadding最后通过Buffer.Extend扩展Padding区域;固定Buffer实现会清零这段区域。所以本例填充00与生成器行为相符。正常早期Padding位于外层安全记录内,线上看到的是加密后的内容,而不是借明文零串直接增加可识别标记。识别到TLS时使用较长Padding,非TLS且窗口将结束时则可以发End,避免无休止维持块结构。
缓冲空间不足时,ReshapeMultiBuffer会先把接近容量的内容重新分成较小Buffer,为最坏情况下的16字节标记和5字节头预留21字节。改变Buffer组织不会改变应用字节顺序;它也不表示网站TLS记录本身被重新加密。块封装、内层TLS记录和外层加密记录仍是三套边界。
Vision Padding不同于第22章Encryption握手Padding:前者就在基本VLESS正文中,带Command和两份长度;后者发生在基本头之前,是握手里的加密长度与填充块。两者同时启用时各自消费自己的字节,不可以把同一份长度拿来解释两层。
字段、默认参数与剥离状态见XtlsPadding、XtlsUnpadding及VisionWriter,填充内容清零见Buffer.Extend。这些长度变化只能说明本实现如何调整早期流量,不能单凭Padding存在就宣称所有长度特征都消失。
23.5 Direct命令之后,什么时候真的换读写器
切换必须同时解决两件事:发送方告诉接收方从哪里改变解释规则,双方又不能跳过已在缓冲中的字节。只知道内层TLS版本还不够,随时改用下层Socket会使同一字节被重复解密、漏读或按错误格式解释。
固定VisionWriter在Padding期间检查TLS识别状态、当前内容是否以17 03 03开头,以及这一整批是否由完整记录构成。符合条件时,可以结束Padding;若EnableXtls已成立,会设置待切换标志,并在这批最后一个Padding块使用Direct=02。若只是结束Padding而没有直接条件,则用End=01。
最重要的发送顺序是:带02的最后一批仍通过当前Writer写出,保留当时应有的外层保护;到下一次WriteMultiBuffer,才检查待切换标志,抑制不再适用的外层关闭消息,用UnwrapRawConn取得下一阶段写入对象,再替换Writer。02块不是已经脱离外层的裸明文控制字节,否则接收方还在解外层时无法可靠找到它。
text
本次调用:原Writer写出 [受保护的Vision块:02、C、P、内容、Padding]
下一次调用:换Writer,再发送下一批内层TLS记录读方向则先按原Reader取得输入,再完成该块内容与Padding消费。只有剩余量都归零、当前命令为02,VisionReader才设置该方向DirectCopy。它把当前已剥离内容保留下来,还依次取出原安全连接input与rawInput里的剩余字节,追加到当前输出之后,然后清空旧缓冲引用并更换后续Reader。
这些字段是TLS、REALITY或CommonConn内部维护的不同阶段缓冲;源码借反射与unsafe取得位置,不是操作系统Socket的通用属性。排空的目的,是保留已经从Socket读进用户态、却尚未交给上层的字节。它要求切换边界与缓冲所属阶段一致,不能将“直接读取下层”视为可以丢弃旧缓冲的许可。
假设当前剥离后有100字节,旧input还存20字节,rawInput有30字节;正确交付顺序是先这100,再20,再30,之后才取新的底层数据。若先从Socket读到后面50字节再回头交缓冲,就会将顺序改成100、50、20、30;TCP本身仍按序,出错的是本地切换。这里是数据路径构造,不表示每个真实连接都会恰好拥有这三段数量。
DirectCopy与splice可用性仍然分开。UnwrapRawConn可能返回裸TCP,也可能保留XorConn、HTTP承载或另一份TLS连接。返回的是下一阶段仍必须经过的对象,不是“名叫Raw就必定已得到TCP”。本地读写器切换可以减少某一层处理,却未必满足内核直接在两个TCP Socket之间复制的条件。
服务端下行还有一个并发顺序约束:某次Write完成之后,才将可参与splice的入站状态由候选改为就绪。固定源码明确将这一步放在当前Writer.WriteMultiBuffer成功返回之后,避免另一条复制路径提前直接写同一TCP连接,越过尚未发完的当前数据。优化状态不仅关心格式,还要关心两个执行路径谁先写。
外层关闭通知也与切换有关。TLS或REALITY包装器若在直接阶段仍按旧外层密钥产生close_notify,接收端此时可能已把流当作网站TLS记录转发,浏览器就会看到不属于自己会话的密文记录。固定实现通过SuppressOuterCloseNotify要求相应包装器关闭底层而不插入这份外层告警。这不是删除网站TLS自己的关闭语义,网站与浏览器仍维护自己的会话。
若发生读写错误,不能把状态“回滚”到旧外层,再重用其记录序号补发任意明文。已有字节可能已被对端消费,切换已改变双方解释阶段。错误应沿实际连接生命周期传播;应用是否创建新连接重试,仍是更上层决定。
全部顺序直接见VisionWriter.WriteMultiBuffer、VisionReader.ReadMultiBuffer、SuppressOuterCloseNotify与UnwrapRawConn。本节没有把候选标志当成已执行内核splice,也没有声称状态切换对所有内部库版本都稳定兼容。
23.6 同一个Flow,明文HTTP与HTTPS走不同路径
考虑两条都配置Vision的普通TCP连接,外层为RAW+TLS。一条访问明文HTTP服务,另一条由浏览器与网站协商TLS 1.3。UUID、Flow与代理入口完全相同,正文不同,流量状态就会不同。
明文HTTP的开头可能是GET / HTTP/1.1。这些字节不会因为目标端口是80或Flow名带有XTLS就变成TLS记录。初期Vision可以用Padding包住它们;当有限识别窗口结束、没有取得可接受内层TLS状态时,发End=01结束块封装,后续仍使用正常外层TLS读写。结束Padding节省的是块头与填充,不是把HTTP明文裸露到C—P网络。
P解开外层后,将原始HTTP发送给目标,因此P能读取这份请求,P—目标这一段若也没有其他保护,网络中间节点也可能读取。这是明文目标协议的边界,Vision没有给目标服务器增加一份它不认识的端到端加密。外层保护与目标协议保护应分别列出。
HTTPS连接的起点则是浏览器ClientHello。Vision先保留原样内容,并在相应安全连接中承载它;目标网站返回ServerHello,识别器可能据此取得TLS 1.3与套件信息。随后出现的17 03 03完整记录可以触发结束Padding及Direct;对TLS 1.3而言,这类外部记录也可能承载加密握手,不能把切换时刻一律说成“网站开始下载网页正文”。
| 内容阶段 | 明文HTTP连接 | 符合识别条件的HTTPS连接 |
|---|---|---|
| 初始请求 | Vision初期Padding及外层保护 | 内层Hello经Vision及外层保护 |
| 识别判断 | 通常没有允许直接的TLS状态 | 根据内层ServerHello和套件建立状态 |
| 结束早期Padding | 用01,可继续普通外层记录 | 可用02,按方向同步直接路径 |
| 持续正文 | 仍依赖外层TLS保护C—P | 可直接搬运内层TLS密文,具体包装另查 |
此处“可”非常具体:内层握手分片未被识别、套件不在允许范围、记录批次不完整,都可能改变何时或是否进入直接阶段。HTTPS这个URL方案名不是源码直接切换的输入,目标端口443也不是充分条件。反过来,在其他端口正常使用TLS 1.3的应用,也不因端口不同就失去这种记录结构。
识别失败时,合理理解是优化覆盖范围变窄,而不是网络协议突然失去可靠性。网站仍由浏览器验证自己的TLS;普通复制仍可能继续传输。但快速识别与状态机的实际接受范围,不能由这个职责解释代替完整互操作测试,本书也没有宣称所有合法分片都表现相同。
还有一种连接先用明文交互,再升级TLS,例如某些STARTTLS协议。若TLS出现前已耗尽当前有限识别窗口,不能假定Vision会无限等待并重新启用同样检查;“正文未来可能会加密”与“当前状态允许切换”不同。这里不增加按应用名字猜测的特殊例外。
HTTP/3则通常是浏览器到网站的QUIC/UDP,不是这条普通TCP连接里的TLS记录流。固定客户端默认Vision分支会对目标UDP/443作限制,可选后缀及XUDP另有处理;那不把QUIC数据报改造成17 03 03记录,也不使本节TCP直接复制条件自动适用。浏览器是否退回其他协议,需要应用与网络条件,不可当作代理必然保证。
最后,即使进入HTTPS直接阶段,P依然知道这条VLESS连接的目标与账户,仍能观察长度、时序和持续时间;它省去了部分外层处理,没有删除控制面信息。浏览器与网站TLS的认证和保密,也不能用“代理已识别TLS 1.3”替代。两项工作分别由真实TLS端点和Vision状态机完成。
本节两条路径依据VisionWriter的End/Direct分支与TLS过滤器,UDP/443条件见VLESS出站。示例描述状态差异,没有把某次真实网站抓包或回退行为作为既成事实。
23.7 加密省下来了,为什么CPU还会忙
普通用户态转发将接收Socket里的字节读到进程缓冲,再把缓冲写入发送Socket。即使这些字节已是网站TLS密文,进程仍需调度读写、管理Buffer、更新计时器和统计,并经过内核与用户态边界。Vision减少某层加解密之后,这些工作可能成为剩余瓶颈。
可以将成本拆成五类:每字节的密码运算,每字节的内存搬运,每次读写的系统调用与调度,每份记录/块的解析与对象管理,以及每条连接的常驻状态。它们的缩放变量不同。将批次扩大能摊薄每次调用成本,却不会把每字节AEAD运算降为零;去掉AEAD也不会自动消除所有内存搬运。
构造一个理想计数:连续转发64 KiB,用8 KiB缓冲,每次都读满且写完,不考虑错误、就绪轮询与短读写,则需要8次读和8次写。若每次只转1 KiB,同样条件下就是64次读与64次写。字节数没变,每次调用的固定成本却增加。真实网络可以短读、阻塞或合并,这个算术不是Xray实测系统调用数。
分散/聚集I/O提供另一种摊薄方式。readv可以一次把连续输入放进多个用户态缓冲,writev类路径可以一次提交多段内容,减少为了合并成一个连续数组而做的复制,也可能减少系统调用。数据仍进入用户态,不等于内核splice。固定Xray的ReadVReader按读取情况调整Buffer数组规模;能否使用取决于实际Reader与底层接口,而不是所有buf.Reader都会自动调用readv。
Vision本身的早期工作也有成本。IsCompleteRecord会将整批内容复制到一个连续数组再检查边界;XtlsPadding把内容写进新Buffer,ReshapeMultiBuffer可能拆分接近容量的Buffer,XtlsUnpadding把有效内容提取出来。它们为格式与切换服务,不能因后续存在直接阶段就忽略前期分配与复制。
进入直接阶段后,Xray仍可能走普通buf.Copy或readV路径:绕过了不再需要的包装层,数据却仍经用户态缓冲。该路径可更新活动计时器和统计,兼容范围也较宽。日志里出现DirectCopy或函数名Raw,不足以证明载荷从此不进入进程内存;必须继续跟到最终复制函数和平台分支。
Buffer复用主要减少分配与回收,不改变已经需要的内容处理。一个池中8 KiB内存重复使用,可以避免每批都申请新数组;若调用者仍把旧内容复制到另一个Buffer,复制费用并没有因为来源是池而消失。垃圾回收压力、对象数量与实际搬运字节数需要分别评价。
并发会放大每连接内存。假设每条连接额外保留64 KiB缓冲,10000条就是625 MiB;这只计算这一项,未含TLS状态、Go对象、内核收发缓冲与连接跟踪。缩小某个用户态Buffer可能减少常驻量,却也可能增加调用和碎片处理,不能只看单连接峰值吞吐。
多个方向还可以并行工作,CPU时间、网络等待和内存访问相互交叠,所以不宜把它们的理想耗时简单相加,当成完整传输总时长。若出口只有较低带宽,节省AEAD后吞吐可能没有变化;若单核密码运算已满,减少一层处理则可能有明显作用。短连接、长下载和大量低速并发应分别观察。
下一节的splice针对其中“载荷反复进入用户态”这条路径,而不是取消TCP序号、网络传输、内核调度或应用端TLS。内核也仍需要管理页、Socket、拥塞与背压;零拷贝应说明省去哪一段复制,不能作为绝对没有任何数据移动的口号。
本节实际实现入口为Vision解析与复制函数、ReadVReader与Buffer池。这里给出成本模型和明确的算术例,不将它们包装成某台VPS上的性能结果。
23.8 splice:先满足状态,再交给内核路径
Linux splice允许载荷在内核中经管道缓冲流转,减少反复跨用户态搬运。典型Socket转发可理解为“接收Socket → 内核pipe → 发送Socket”,用户态负责驱动与等待,不必每批都先把载荷读进自己的数组。它仍使用系统调用、页与队列,不是两块网卡间完全没有内核工作的硬件直连。Linux内核splice说明
Xray没有在每个Vision写入位置直接调用splice。固定CopyRawConnIfExist先解开允许解开的包装,检查平台与连接,再等待会话状态满足条件,最终调用目标TCPConn的ReadFrom。它将优化机会交给Go网络库,不是自己把TLS对象随意当成文件描述符。
这份函数的门槛包括:运行平台分支为linux或android;写端实际为TCPConn;读写端均存在;会话中存在入站与出站元数据;没有相关入站或出站将CanSpliceCopy设为3。若不满足,返回普通readV/buf.Copy路径。
即使没有明确禁止,也不是马上调用ReadFrom。在循环中,入站及所有相关出站状态都必须为1才视为splice就绪;状态2表示还在等待条件,3表示禁止,其他未就绪值也不满足“全部为1”。等待期间仍按普通方式读取、写出并更新活动与统计,不会为了等一个优化标志而停止所有内容传输。
这解释了23.5为什么把就绪赋值放在当前写完成后。如果另一个复制任务先看到1,可能直接向同一TCP写入后面的载荷,越过仍在用户态写出的前一批。状态切换要同时维护线上顺序和并发可见性,不能只检查“网站TLS已经加密”。
Go 1.27.0的TCPConn.readFrom首先尝试spliceFrom,未处理时再选择其他适用路径或普通复制;spliceFrom只对识别出的TCP及流式Unix连接等对象启用。因而调用了ReadFrom,仍不等于这一运行时一定执行了内核splice。Xray进入该分支的日志证明选了入口,不单独证明后面的系统调用。固定Go readFrom、固定Go spliceFrom
Go当前公开的内部实现进一步使用pipe池,先把源Socket数据移入pipe,再把其中数据送到目标,遇到暂不可读写时等待就绪;不支持的源类型或部分平台条件可使优化不可用。这里引用的是2026-10-15查阅的实现说明,实际二进制所用Go版本仍须另外确认。Go内部splice路径
splice不会替代理协议做内容变换。如果还需要AES-GCM加密、WebSocket掩码、gRPC消息编码或XHTTP请求分批,直接把输入Socket字节搬到输出Socket通常会跳过必要处理。Vision必须先把不再需要的层与仍必须保留的层分清;能结束某层记录,不保证剩余路径已经适合内核直送。
固定代码也没有把上下行splice对称开启:VisionReader和Writer中有部分上行就绪赋值仍处于注释,活跃逻辑重点推进相应下行状态。看到一个方向进入Direct,不能推断两个方向都已进入同样的内核路径。平台、方向和实际连接包装需要同时记录。
进入ReadFrom长复制后,Xray将相关计时器期限设为24小时,并在复制返回时把总字节计入相应计数器;它不再像普通循环那样逐Buffer更新同一组逻辑。这是优化时连统计和生命周期都要调整的例子,不应把某个瞬时统计显示的变化直接当成载荷已停止传输。
网络可靠性与背压仍由两段TCP分别维护。发送端拥塞、接收窗口变小或pipe无空间,都可能使搬运等待;splice不消除丢包重传,也不让P—网站与C—P成为同一份TCP连接。前段已经确认的字节,仍不等于后段应用已完成消费。
本节Xray条件来自CopyRawConnIfExist及Vision就绪状态与freedom下行调用点。本书没有在Linux主机执行运行时系统调用追踪,因此结论限定为这些源码条件和内核机制,不冒称当前写作环境已经实际走到splice。
23.9 Encryption在最外面一层,还是在另一个承载之上
把VLESS Encryption和Vision放在一起时,首先进入的是CommonConn。Vision读写的初始VLESS字节经Encryption保护;发生Direct后,UnwrapRawConn从CommonConn取出其Conn字段,供后续读写使用。关键问题是:这个Conn究竟是什么?
固定函数专门记录isEncryption,取出CommonConn.Conn后,不再继续按普通分支穿透TLS、uTLS或REALITY,避免一次切换越过两层安全包装。若取出的是XorConn,更立即返回它,保留记录头变换。由此得到几条不同数据路径:
| 进入Vision前的主要组合 | Direct后可绕过的处理 | 仍需经过的处理 |
|---|---|---|
| RAW + TLS,无Encryption | 对应外层TLS记录处理 | 裸TCP等允许的下层 |
| RAW + REALITY,无Encryption | 对应REALITY安全记录处理 | 裸TCP等允许的下层 |
| RAW + native/xorpub Encryption | CommonConn的AEAD记录 | 底层RAW连接 |
| RAW + random Encryption | CommonConn的AEAD记录 | XorConn记录头变换及其下层 |
| HTTP承载 + Encryption | CommonConn的AEAD记录 | HTTP承载本身及其安全连接 |
| RAW + TLS或REALITY + Encryption | CommonConn的AEAD记录 | 原先已经存在的TLS或REALITY包装 |
表表示实际包装与解除逻辑,不承诺所有额外配置选项都可组合,也不表示每条连接都能满足TLS识别和切换条件。非RAW或还有其他安全包装时,固定VLESS安装阶段会将CanSpliceCopy标为3;这阻止把后续路径误送进裸TCP splice。
状态3不等于“Vision绝不会发Direct”。Vision可以结束CommonConn记录,但保留另一层必需对象;是否切换与是否允许进一步内核穿透,是两个判断。比如XHTTP+Encryption在相应有效配置中,后续网站TLS记录仍需要作为XHTTP上传和下载内容传输,不会从源站TCP中裸露出一串跳过HTTP的字节。
random组合尤其能看出差异。Direct后XorConn可继续读出内层TLS五字节记录头、按长度跳过其密文,并对头做对应CTR变换;它不再要求每份内容都有一层CommonConn额外AEAD。浏览器与网站原有TLS密文仍保留,而XorConn的头状态也必须在代理两端继续同步。跳过XorConn直接splice会破坏这种同步,因此该层不可穿透。
这不是“random同时保留所有双重加密”的同义表述。保留头变换只需处理相应头与边界;去掉CommonConn AEAD也不意味着剩余路径可以完全不经过用户态。只有把每一层实际保留的工作列出来,才能解释CPU、字节开销与复制路径分别改变多少。
若Encryption下面还有TLS,Direct以后内层网站TLS仍在原先外层TLS里传输。它省掉的是中间CommonConn这一层,不是把所有加密都降成网站一层。于是“启用了Vision”无法单独回答还有几层加密;最初包装堆栈与Unwrap规则共同决定结果。
恢复模式也不能决定这一点。Encryption的1rtt或0rtt选择初始密钥建立与缓存使用,Vision的TLS识别和Direct发生在随后正文阶段。0rtt可能让初始请求更早发出,却不让未识别的明文HTTP自动具备直接条件,也不改变XorConn与HTTP承载仍需处理的事实。
安全属性同样随阶段改变。若后续已绕过提供后量子混合交换的Encryption或REALITY记录,不能继续声称这些字节仍受那份代理层流量密钥保护;它们可能主要依赖浏览器—网站自己的TLS。内层是否也使用后量子混合交换,要另看网站TLS协商,Vision的基本识别并没有验证该项。下一节把这类边界与代码依赖一并说明。
包装与解除以UnwrapRawConn为准,候选和禁止条件见VLESS入站及出站,头变换见XorConn。这些都是固定源码的路径判断,未由配置可构建推定已经完成所有组合的运行时验证。
23.10 优化依赖的假设,不能提升成任意流量的安全证明
Vision把一部分工作从代理层转移给已经存在的内层保护,因而安全分析要跟着数据阶段变化。初始阶段靠外层保护VLESS身份、目标与早期内容;直接阶段若已绕过那层记录,主要依赖内层TLS及仍保留的包装。握手建立时证明过的属性,不一定以相同方式覆盖后来每一个字节。
识别器取得的证据是有限的:看起来像ClientHello、ServerHello中出现TLS 1.3扩展、套件属于对应字典、当前批次有完整17 03 03记录。这些信息可以支持实现作优化判断,却不能证明发送者诚实执行了TLS密钥派生,也不能证明所谓密文实际上来自正常AEAD输出。
因此如果一个已获代理账户的发送者刻意模拟这些外壳,不能从Vision状态反过来保证它随后传输的任意自定义内容都已加密。内容安全承诺来自实际应用端点,而不是代理对几处字节模式的分类。正常浏览器访问HTTPS时,浏览器仍验证网站身份、握手与记录;代理用户自定义了其他字节流,就需要按其他协议的真实保证评价。
同样,EnableXtls没有检查网站TLS的混合密钥组。即使代理入口用ML-KEM保护初始交换,直接阶段若只剩一个纯X25519网站TLS,会面临它自己的未来解密假设。不能将入口“后量子”标签贴到被绕过后不再使用入口密钥的全部流量上。需要保留哪层保护,是性能与安全模型的共同取舍。
分片接受范围也值得分开说明。基本VLESS头可逐字段ReadFull,Vision首块标记却要求当前Buffer至少21字节并匹配UUID;TLS识别又做当前Buffer中的特征搜索。它们不是同一份完整增量解析器。某种分片导致优化未覆盖,与导致协议互操作问题,后果也不相同;本书没有通过全部分片组合测试来替实现补出一个更强保证。
内部接口依赖进一步影响版本兼容。固定VLESS通过反射查找安全连接结构里的input和rawInput字段,再用unsafe按偏移取得指针。它并非只依赖稳定的net.Conn接口;所依赖的TLS、uTLS、REALITY及CommonConn结构布局必须与代码预期一致。编译通过能检查类型层面的很多问题,却不足以自动证明这些内部缓冲访问与阶段切换全部正确。
这也是为何本书固定Xray提交及REALITY依赖,而不拿某个配置参数名做跨软件保证。另一客户端即使支持同样Flow名称,也需要正确处理首块标记、命令时机、缓冲排空、外层关闭和内部连接包装;少一项都可能出现“账户认证通过但正文失败”。
splice还可能改变可以逐块执行的功能。若某个下层需要内容变换、精细逐块统计或特定封装,绕过它会使行为不一致。固定代码用CanSpliceCopy及包装类型保留这些条件,并在进入长复制前调整计时器、复制返回后补统计。评价优化不能只看数据有没有变快,也要看原先各层职责是否继续得到执行。
一个完整实现审查应追踪实际控制流,而不是挑一句注释。例如错误文本仍称“只支持TLS和REALITY”,实际分支已有CommonConn;函数名Raw可能返回XorConn;日志称splice,Go库仍可能选择普通复制。名称与意图帮助导航,返回对象、状态与最终调用决定行为。
本章已逐路径检查固定Vision主体和VLESS连接状态获取,但未执行独立形式验证、所有承载互操作或恶意输入覆盖测试。以上边界是正文机制本身的一部分,不以“性能优化”四字省略。
23.11 沿一条HTTPS连接判断优化实际发生在哪里
构造一条RAW+native Encryption+Vision连接,客户端已有可用恢复缓存,服务端用freedom访问目标。暂不在Encryption下面再加TLS或HTTP承载。浏览器和网站使用可被本版本识别的TLS 1.3;各阶段Buffer也满足相应识别与切换边界。这个模型将配置、字节和复制路径连在一起,不冒充一次已运行的部署。
首先,客户端完成承载拨号,Encryption恢复生成98字节前缀并保存在PreWrite;VLESS准备向example.com:443发送请求,Vision Flow使基本头为52字节。浏览器的一份201字节ClientHello记录作为最初正文,这是与第16章公开TLS向量同量级的明确长度输入,不要求真实浏览器永远发送201字节。
假设这一份内容恰好放进一个Vision首块,长Padding随机值使填充为799字节,则内容加填充1000,首块总长16+5+201+799=1021。基本头与这块若合并成一次1073字节Encryption写入,且只产生一条记录,增加21字节后为1094;再连同98字节恢复前缀,首次上行为1192字节。每个加法对应不同层,任何一层重新分批都可能改变合计。
服务端先完成恢复票据与nfsKey去重,打开记录后消费52字节VLESS头,再由Vision去掉首标记、块头和799字节填充。交给目标连接的仍是原201字节TLS记录;P没有把自己的UUID或Padding发给网站。目标网站按照浏览器自己的TLS握手返回数据。
当双方局部状态根据ServerHello识别出相应TLS 1.3套件,后续某批完整17 03 03记录满足切换条件,发送端把最后Vision块标记02,仍经Encryption记录写出。接收端读完内容与填充、排空旧CommonConn缓冲,然后才换到其下层RAW连接。之后不再为每批网站TLS密文新增CommonConn的五字节头和16字节标签。
这时可以确认“正常路径少了一层AEAD记录”,还不能确认splice。服务端freedom下行另有自己的使用开关与目标连接类型检查;会话元数据必须全部就绪,Vision也要在完成相应写入后将候选状态更新。满足这些条件才进入TCPConn.ReadFrom,再由Go与内核选择实际复制机制。若平台不适用,就保留用户态搬运已经加密的内容。
把同一模型只改成random Encryption,第一阶段基本密钥交换和Vision识别仍可发生,直接阶段却保留XorConn头变换,状态禁止再穿透到裸TCP splice。若改成XHTTP+Encryption,则保留HTTP组织与相应安全连接。若浏览器改用无法识别的协议或内层TLS条件未满足,又可能仅结束Padding而继续普通外层记录。三个变化分别影响不同门槛,不宜统一称为“Vision失效”。
还要检查它在本次负载中是否值得。短会话初期增加Padding和解析,即使之后可直接复制,总耗时仍可能主要取决于握手与应用等待。长会话若受密码运算、系统调用或内存带宽限制,减少一层记录与合适的复制路径才可能发挥更大作用。源码只证明提供了条件化路径,不证明所有场景吞吐都会上升。
至此,分析一条实际配置至少要回答四个相连问题:首批字节由哪层保护;识别状态有没有满足;Direct后究竟保留哪些包装;最终复制函数有没有取得适用的裸连接与就绪状态。每一步都有对应函数与字节边界,不能仅从flow字符串给整条路径盖一个“零拷贝”结论。
完整调用关系见VLESS出站的恢复后请求写入、Vision切换与freedom的候选状态及下行复制。下一章转向XHTTP自身的会话与HTTP请求状态,解释当承载必须保留时,哪些机制决定上传、下载、复用和中间节点兼容性。