切换深浅色
第 19 章 VLESS请求与响应的字节级分析
19.1 版本与UUID:先确定前18字节的解释
本章继续采用第18章固定的Xray-core提交,按实际EncodeRequestHeader与DecodeRequestHeader说明格式。展示的基本头都是经过必要外层解密后、送入VLESS解析器的字节视图;只有没有任何保护的相应路径,网络上才会直接出现相同明文。不能看到报文字段表就认为所有TLS连接在线上也暴露这些字段。
请求的前部为:
| 相对请求起点的偏移 | 字节数 | 字段 |
|---|---|---|
| 0 | 1 | Version,本提交为00 |
| 1—16 | 16 | 实际发送的用户ID |
| 17 | 1 | 附加信息的字节长度A |
| 18起 | A | 附加信息内容,A=0时不占字节 |
| 18+A | 1 | Command,随后按命令决定是否读目标 |
偏移从0开始,长度与数值在未说明时以通常整数理解;报文中的成对十六进制字符表示一个字节。Version=00意味着这里确实有一个零字节,不是“版本字段不存在”。未知版本在固定解码器中进入错误分支;它不能按HTTP文本的方式跳过一个不认识的标签再继续。
选择虚构ID00112233-4455-4677-8899-aabbccddeeff。其16字节按字符串十六进制顺序直接写入:
text
00 11 22 33 44 55 46 77 88 99 aa bb cc dd ee ff这里不对前几个组做小端反转,也不把UUID变成一个128位网络整数后另行调整字节序。源码UUID.Bytes返回该16字节数组,编码器直接写它。某些系统的GUID内存布局不能不加核对地替代这份线上顺序。
附加信息为空时,前18字节为:
text
00 | 00 11 22 33 44 55 46 77 88 99 aa bb cc dd ee ff | 00
版本 | ID | A=0第一和第二个字节恰好都为00,但角色不同:整体偏移0是版本,偏移1是ID首字节。解析器按照固定位置消费,不能用“找第一个非零字节”识别ID的起点,也不能把17字节全解释为一个更长UUID。
然后才读取命令。例如普通TCP的Command=01位于偏移18,下一节之外的端口和地址解析从19开始。若A不为0,命令就向后移动A字节;一个把命令固定死在18的解析器只适用于明确A=0的子集,遇到Vision附加信息时会把Protobuf内容误读成目标控制。
认证查找使用第18章已经说明的版本特定ProcessUUID规则:ID内部索引6、7,在整份请求里对应偏移7、8。解码器仍读入完整16字节,并保留原值;忽略部分字节用于查找,不意味着线上ID只有14字节,也不允许从流中删掉这两字节让后续字段左移。
版本号也不能作为完整能力目录。基本Version仍为00,不说明实现年代、是否支持VLESS Encryption或某一种承载。那些机制可能在基本头以外建立状态,或通过附加信息选择路径;应继续检查具体提交及对应层,而不是把一个字节00翻译成“只能使用最早期功能”。
上述布局直接对应encoding.go及UUID字节表示。下面展开A的精确内容,并说明它如何改变后续偏移。
19.2 附加信息:一个长度包住一份Protobuf消息
基本头需要留出扩展空间,却不能让旧解析器猜测扩展在哪里结束。VLESS用一个字节A界定附加信息的范围,然后才读命令。A只计算附加信息内容,不包括A自身,也不包括随后命令。内容由Protobuf编码;它有自己的字段编号和长度规则,与外部这一字节不是同一个层次。
固定提交的Addons定义有两个字段:
| 字段 | 编号 | 声明类型 | 含义边界 |
|---|---|---|---|
| Flow | 1 | string | 请求选择的流控名称 |
| Seed | 2 | bytes | 模式定义中的字节字段,存在于结构定义不等于每条连接都会发送或使用 |
以本实现实际编码的xtls-rprx-vision为例。这段ASCII文本有16字节。Protobuf的字段标记把编号放在高位,低3位表示线上类型;编号1、长度限定类型2,得到十六进制0a。其后是文本长度10,再跟16字节文本:
text
0a 10 78 74 6c 73 2d 72 70 72 78 2d 76 69 73 69 6f 6e
标记 长度 xtls-rprx-vision这份消息共18字节,因此VLESS外层写入的A为十六进制12。完整的附加信息部分为12 | 0a 10 | 16字节文本,共19字节。空附加信息也要占一个A=00的字节,所以从空值改成Vision,整份请求头净增加18字节,而不是19字节。
前18字节之后,偏移18是0a,偏移19是10,偏移20—35为文本,命令移到偏移36;若是TCP,端口从37开始。若把十六进制12误当成十进制12,就会提前6字节结束附加信息,把文本尾部错当成命令。若把10当成另一个外层长度,也会破坏两层范围的关系。
Protobuf长度使用可变长整数,小于128的值恰好占一个字节;这不意味着Protobuf所有长度都只占一字节。这里恰好是16,而VLESS的A始终固定为一字节。本章正常实例都在A可表示的0—255范围内。固定编码函数直接把序列化长度转换成byte,没有建立一个可推广到任意大扩展的自动分片机制;新增字段的实现必须审查这个上限,不能只依赖Protobuf本身能表示长消息。
这也不是gRPC的消息前缀。第11章的gRPC层有一个压缩标志加四字节长度;VLESS Addons前面没有那五字节。两种协议恰好都能使用Protobuf,不能据此相互套用封装。
编码与接受范围也要分别看。EncodeHeaderAddons仅在Flow等于xtls-rprx-vision时序列化Addons,其他分支写A=0。DecodeHeaderAddons先准确读取A字节,再调用Protobuf解码;这个函数内没有完成有意义的Flow授权检查。是否允许Vision,还要看入站账户、命令与连接条件。成功还原一个字符串,不能替代后续策略判断;客户端随意填一个名称,也不会让服务端自动具备对应实现。
因此读取扩展的正确顺序是:先定位A的边界,在这个边界内按Protobuf规则解析,再将得到的语义交给相应机制验证。不能扫描字节流寻找vision文本,也不能把未知字段当成无限跳过后续命令的许可。
字段定义和实际编码分支见固定提交的addons.proto与addons.go;字段标记与可变长整数规则见Protobuf官方编码说明。
19.3 先读命令,再决定目标字段是否存在
同一位置上的一字节命令决定后面的语法。固定实现的01表示TCP,02表示UDP;这两种命令都紧接目标端口和目标地址。03表示Mux,04表示反向连接Rvs,它们的基本头到命令为止,不在这里携带目标端口和地址。Mux中的各逻辑会话还有自己的目标信息,不能从基本头少了目标就推断整条连接无需目标。
TCP和UDP的目标部分顺序为:
text
端口:2字节大端 | 地址类型:1字节 | 按类型解释的地址端口443写成01 bb,53写成00 35;即使数值小于256,也不能省掉前面的00。端口是目标服务端口,不是代理服务监听端口。客户端外层Socket可能连的是代理服务器8443,而这里仍然是网站443;代理服务器读完它,才知道下一段连接想访问什么。
三种地址编码如下:
| 地址类型 | 类型值 | 后续地址字节 | 连同端口与类型的总长 |
|---|---|---|---|
| IPv4 | 01 | 4字节地址 | 7 |
| 域名 | 02 | 1字节长度N,随后N字节域名 | 4+N |
| IPv6 | 03 | 16字节地址 | 19 |
SOCKS5的域名和IPv6类型分别为03、04;VLESS这里分别是02、03。字段角色相似不意味着编号相同,直接复用SOCKS5地址编码会把合法域名请求解析成另一种语法。
以下三个片段都只表示目标,不包含版本、ID、附加信息或命令:
text
01 bb | 01 | c0 00 02 01
443 IPv4 192.0.2.1
01 bb | 02 | 0b | 65 78 61 6d 70 6c 65 2e 63 6f 6d
443 域名 11 example.com
01 bb | 03 | 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01
443 IPv6 2001:db8::1前后两个IP为文档用途地址,不能把它们当成真实公共代理或网站。IPv6的::只是人类文本表示中的省略法;在线上仍要恢复成16字节。方括号和冒号不放入这份二进制IPv6字段,端口也已经在它前面。
域名长度按字节计算,example.com为11字节,长度字段0b;这里没有DNS报文中逐标签的07 example 03 com 00编码,也没有字符串结尾的00。国际化域名需要先按适用的域名规则转换成可用的ASCII名称,不能把一个汉字算作一个线上字节。单字节长度的上限、DNS标签长度与DNS名称长度是不同约束,满足其中一个不代表另外几个自动满足。
固定地址解析器还做了实现层面的处理:接收的域名字节只允许字母、数字、连字符、点和下划线;某些以数字或左方括号开头的字符串会先尝试解释为IP。这说明“类型标为域名”与“内部最终一定保存为DomainAddress”并非在所有输入上等价。正常域名实例不依赖这种兼容分支,发送方也不应把该字符白名单当成完整的DNS合法性证明。
读出域名没有触发一次不可避免的DNS查询。解析器首先得到地址对象和端口,后续路由可能按域名选择出站,出站也可能把域名继续交给下一跳。若发送的是IP,基本头本身已经丢失原始域名;后续流量探测有时能获得名称,但不能倒推出所有IP连接最初的DNS问题。
对Mux和Rvs,固定解码器分别设置内部标识v1.mux.cool与v1.rvs.cool。这些是分派约定,不能在报文中凭空补上相应文本,更不能据此认为服务器需要解析这两个名称。遇到未知命令,解码器无法得到有效目标,进入错误路径,而不是把剩余字节按TCP随意转发。
顺序及兼容处理见common/protocol/address.go,类型值见payload.go,命令分支见VLESS编码器。
19.4 请求头之后:字节流与数据报需要不同边界
对没有Vision处理的基本TCP请求,头结束后就是被代理连接的字节。VLESS不会在每次应用写入前加一个TCP消息长度。浏览器的TLS ClientHello可能紧接目标域名,也可能过一段时间才到;它可以横跨数次网络读取,仍然属于同一目标连接。服务端只消费自己的头,然后把余下字节交给选出的出站。
假设应用依次写入三字节abc、两字节de。在这条基本TCP路径,VLESS正文就是61 62 63 64 65。接收方可以一次得到五字节,也可以先得一字节、再得四字节。VLESS没有承诺恢复那两次Write。若这些字节属于HTTP或TLS,对应协议自己恢复消息或记录边界。
UDP不同:应用的两份数据报不能合并成一份,也不能将一份拆成两份交给目标应用。把UDP放入可靠字节流时,基本VLESS UDP路径采用两字节大端长度恢复数据报边界:
text
00 03 | 61 62 63 | 00 02 | 64 65
长3 abc 长2 de接收方先收满长度2字节,再收满相应负载,分别得到3字节和2字节的数据报。前一份负载跨越三个TCP段也不影响结果。反过来,即使两份封装碰巧在一个TCP段中,依然要输出两份数据报。长度不包含自己占用的两字节。
基本UDP请求头已经指定这一关联的目标。每份上述正文只带长度与数据,不在长度后重复端口和地址;客户端要使用另一目标时,不能悄悄把它的地址文本塞进这份数据报,期待服务端自动改发。需要另一会话,或者使用另外定义了目标与会话管理的封装。
两字节无符号长度能表示0—65535,但这是字段容量,不是所有实现路径保证发送65535字节数据报。固定提交中,MultiLengthPacketWriter按输入的每个Buffer形成一份报文,并跳过零长度以及“负载长度+2超过buf.Size”的Buffer。该提交的buf.Size为8192,所以这个具体分支的单Buffer负载上限是8190字节。另一LengthPacketWriter先合并一组Buffer作为一份报文,其路径与限制又不完全相同。接收的LengthPacketReader可以把一份长度指定的数据分散到多个Buffer;内存Buffer边界不等于线上数据报边界。
因此正确的兼容性陈述必须同时说明线上格式、发送路径和接收路径。仅看到uint16就宣称“该实现端到端支持64 KiB UDP负载”,会忽略发送缓冲、IP/UDP尺寸限制和中间层限制。零长度字段虽可编码,接收器也能读出零,但固定发送分支会跳过空报文,不能据此承诺空UDP数据报必然往返保留。
还有一个容易在实际抓包中误判的变化:应用目标为UDP,并不意味着线上基本命令一定为02。固定出站会在Vision等特定条件下把请求改成Mux命令,再用XUDP的PacketWriter携带目标和数据。此时基本头没有目标字段,正文也不能按上述最简单的“长度、负载”反复解析。XUDP承担了额外会话语义,它与基本UDP封装不是两个可以任意互换的名称。
Vision本身也改变正文处理。EncodeBodyAddons先判断基本UDP命令,再判断Flow是否为Vision;服务端又会校验Vision可接受的命令组合。不能仅从函数名称带有Addons就认为它在所有正文前追加同一种小头,更不能把源码注释中的“encrypt”当成基本VLESS自行执行AEAD的证据。普通TCP直通、UDP长度封装、Vision处理、VLESS Encryption保护,是不同分支或层次。
这些边界直接见addons.go的读写器、Buffer尺寸和出站的UDP转Mux分支。若承载最终使用TCP,UDP语义保留并不消除TCP重传造成的队头阻塞;可靠流中的前段字节没到,后面的完整数据报也要等待。
19.5 响应的两个零,不是一个成功状态码
VLESS基本响应比请求短得多:
| 顺序 | 长度 | 内容 |
|---|---|---|
| 1 | 1 | 与请求相同的版本 |
| 2 | 1 | 响应附加信息长度B |
| 3 | B | 响应附加信息 |
| 4 | 由后续传输决定 | 返回的正文 |
固定入站构造空的responseAddons,编码结果为00 00。第一个00是版本,第二个00是附加信息长度。这里没有“成功=00”字段,没有SOCKS5的REP,没有HTTP的200,也没有重复UUID或绑定地址。若目标先返回abc,基本无Vision路径上的下行字节可以是:
text
00 00 | 61 62 63
响应头 abc接收方必须先消费响应头,随后才将正文交给本地应用。把这两个零交给浏览器,会污染原本属于网站TLS或HTTP的字节;反过来,若误以为回应还带16字节ID,就会吞掉网站响应的前16字节。
DecodeResponseHeader检查收到的版本是否等于该请求版本,然后调用与请求相同的附加信息解析器。它不解析“连接成功”状态,也不能从没有定义的字段恢复详细错误原因。版本或附加信息读不完整会失败;连这两字节都没收到,可能是认证失败、目标连接失败、承载中断或读超时,单凭这一现象不能判断是哪一层。
服务端什么时候生成响应,还要区分逻辑生成、缓冲和真正发送。固定入站在建立返回通路时,把EncodeResponseHeader的结果先写入BufferedWriter,再设置后续刷新行为,之后才把链路交给分派器。源码中执行“编码响应头”这一行,不是“已经完成目标TCP三次握手”的可观测证明,也不是客户端此刻已收到数据的证明。缓冲器、出站和错误分支都会影响实际发送时刻。
客户端的上行任务与下行任务并发执行。它不必等一个相当于SOCKS5成功答复的控制消息,才发送全部首批应用数据;请求头与首批负载可以一起送出。这个设计减少了基本代理层对额外往返的需求,但外层TCP、TLS、REALITY或VLESS Encryption是否先完成自己的握手,是另外的问题。
回应也不把Flow机械地回显一次。固定服务端responseAddons中的Flow赋值处于注释状态,而客户端仍依据自己请求的Flow安装VisionReader。于是“响应A=0”不能证明这条连接没用Vision。解释下行正文需要保留请求阶段的状态,不能把响应头孤立成一份足以自描述所有处理方式的消息。
对于UDP,消费响应头之后,基本UDP正文仍按两字节长度恢复每份数据报;对于Mux、XUDP或Vision,则进入相应处理路径。基本响应只建立这一层的解析起点,没有统一替代所有正文格式。
相应证据为EncodeResponseHeader与DecodeResponseHeader、入站responseAddons及返回缓冲链路、出站并发请求与响应任务。观察到网站经过自身TLS认证后的有效响应,比仅看到一个VLESS响应前缀提供了更多信息;它仍不能证明所有后续应用操作都会成功。
19.6 一份完整请求:每个字节属于哪一层
构造一条Flow为空、目标为example.com:443的TCP请求,正文仅为五个ASCII字节hello。这里的hello用于把头与负载分开观察,不是发给443端口的合法TLS握手,也不是实际抓取的网络会话。ID继续使用本章虚构值。基本VLESS视图共39字节:
text
0000: 00 00 11 22 33 44 55 46 77 88 99 aa bb cc dd ee
0010: ff 00 01 01 bb 02 0b 65 78 61 6d 70 6c 65 2e 63
0020: 6f 6d 68 65 6c 6c 6f行首0000、0010、0020为十六进制偏移,分别是十进制0、16、32;它们不在报文内。按字段拆开后为:
| 十进制偏移 | 长度 | 字节或解释 |
|---|---|---|
| 0 | 1 | 00,版本 |
| 1—16 | 16 | 用户ID |
| 17 | 1 | 00,附加信息长度 |
| 18 | 1 | 01,TCP命令 |
| 19—20 | 2 | 01 bb,443 |
| 21 | 1 | 02,域名类型 |
| 22 | 1 | 0b,域名11字节 |
| 23—33 | 11 | example.com |
| 34—38 | 5 | hello正文 |
因此请求头长度为1+16+1+1+2+1+1+11=34。不是从数据里“认出hello”才找到边界,而是域名长度用完后,语法已经确定从偏移34进入正文。正文换成任意二进制字节,都不能改变这个结论。
空附加信息的TCP或基本UDP请求头可以统一计算为:IPv4为26字节,IPv6为38字节,域名为23+N字节。N=11时为34。Mux和Rvs无目标字段,空附加信息基本头为19字节。若附加信息消息长度为A,各结果再加A;A自身那一个字节已计入基础值。Vision的A=18使本例请求头成为52字节,但Vision正文还要按第23章的处理计算,不能简单在这份hello前面换头就宣称构成完整Vision会话。
本例普通TCP上行头34字节,若基本响应附加信息为空,下行头2字节,两方向固定头合计36字节。之后转发一百万字节不意味着再加一百万份34字节;一个基本TCP请求服务于这条逻辑连接。相反,另开100条同目标的连接,在这些假设下就有100份请求头和100份响应头。
换成基本UDP请求,目标和ID均不变,只有命令02;两份负载abc与de总计5字节,长度前缀总计4字节,故上行基本VLESS字节数为34+4+5=43。若逐份负载很小,两字节前缀占比就明显;它所购买的是数据报边界,而TCP路径没有购买这项服务。
这些是基本协议字节数,不能直接当成链路开销。比如本例39字节若恰好放入一个TLS 1.3应用记录,使用16字节标签、没有TLS Padding,会再增加5字节记录头、1字节内部内容类型和16字节标签,记录合计61字节。这仍然不含TLS握手、TCP/IP、确认包或重传。若承载层把数据拆成两个TLS记录,标签和记录头就各有两份。
同样,TLS可以把多个上层写入合成一个记录;WebSocket、gRPC还会增加自己的边界。固定头加法适合在明确分层和分片假设下算账,不提供一个脱离连接生命周期和负载大小的“VLESS固定带宽损耗百分比”。上述长度按本章已核对的请求编码器及第16章TLS记录结构计算,未作吞吐量测量。
19.7 数据没有一次到齐时,解析器知道自己还缺什么
用上一节的39字节实例观察流式解析。假设已经剥掉承载层,解析器所见输入依次增加8、13、5、13字节。这里讨论正常VLESS读取路径,不含下一节单独说明的首读回退判断。
| 输入累计字节 | 现在拥有的范围 | 此时能确定的事情 |
|---|---|---|
| 8 | 偏移0—7 | 版本已到,ID还缺9字节 |
| 21 | 偏移0—20 | ID、空附加信息、TCP命令、443端口均已到;还缺地址类型 |
| 26 | 偏移0—25 | 域名类型与长度已到,域名字节只到exa,仍缺8字节 |
| 39 | 偏移0—38 | 域名收满,请求头完成,剩余5字节为正文 |
第一次Read返回8,不是“UUID只有7字节”,也不是“一个短VLESS请求”。它只说明这次调用取得了8字节。读完版本后,解析器要么继续等待ID剩余部分,要么因为EOF、超时或其他错误终止。不能先用7字节做一次认证,再把后续9字节当作新的协议字段。
固定解码器以ReadFull形式逐字段索取所需字节:版本1、ID16、A1、附加信息A、命令1,随后按命令读取端口、类型和地址。它的调用形式看起来是顺序函数,但其含义正是一个解析状态机:每一次“读满”都可能在内部跨越多个底层Read。内存里先缓存21字节或直接从连接读,并不会改变字段依赖关系。
解析器也不能把一次Read拿到的所有字节都当成头。最后13字节使域名收齐后,只有其中前8字节属于域名,余下hello必须保留给正文。若头部函数把余下字节丢掉,TCP可靠性也救不回来:网络已经正确交付,错误发生在本地应用消费边界。若余下字节被重复交给正文,应用就会看到重复数据,即使TCP本身没有重复交付。
附加信息非空时,状态转移多了一层有限范围。在A=18的Vision实例中,读到12 0a仍不足以证明消息完整;需要收齐外层指定的18字节,Protobuf才有完整输入。长度为18而内容只有17字节时,如果连接仍开着就可能继续等待;若流已结束,则是截断。若已收齐18字节但内部字段长度不匹配,则属于完整输入中的格式错误。这两类情形的修复方法不同,不能都解释为“网络丢了一个包”。
正在绘制图表…
查看图表原文
flowchart LR
V[读取版本1字节] --> I[读取ID16字节并查找账户]
I --> A[读取A及A字节附加信息]
A --> C[读取命令]
C -->|TCP或UDP| P[读取端口与地址]
C -->|Mux或Rvs| M[设置内部会话目标]
P --> B[保留剩余字节并进入正文]
M --> B
图中每个读取框都隐含“等待足够数据,或以错误退出”,没有隐含“必须一次Read完成”。未知版本、未知用户、非法附加信息、非法地址又是不同错误出口。身份查找先于附加信息和目标解析;但匹配账户后仍要通过Flow、命令、反向账户权限等检查,不能把状态图中的查找成功直接连到任意转发。
字段长度也约束资源使用。VLESS基本头无需根据一个32位或64位远端长度立即分配数GiB内存;A和域名长度都很小。不过,小头并不会自动消除慢速发送连接消耗、并发连接数和握手计算成本。入站还需要读截止时间、连接生命周期管理等工程机制。它们控制等多久、占用多少资源,不应被描述成协议自己提供的密码学认证。
顺序读满的具体实现见DecodeRequestHeader和地址解析器。接下来把这个语法状态机放回服务端入口,解释为什么带回退的首读优化需要额外区分。
19.8 从源码入口看认证、回退与分派
追踪一个协议,只有字段表还不够。必须知道谁把连接交给解析器,哪些层已被剥除,谁在解析后决定目标,以及错误时原字节还在不在。固定提交可沿下面这条链定位:
| 位置 | 关键工作 | 不由它独自完成的工作 |
|---|---|---|
| 传输层接入 | 将RAW、WebSocket等连接呈现给代理入站 | 决定VLESS账户是否有权访问目标 |
| inbound.Handler.Process | 可选Encryption握手、首读、请求解码、Flow与账户检查 | 替目标网站完成浏览器的TLS认证 |
| encoding.DecodeRequestHeader | 版本、ID查找、Addons、命令与目标解析 | 公共CA证书验证或任意形式的应用认证 |
| validator.Get | 按固定提交的ID查找规则取得账户 | 证明目标网络可达 |
| DispatchLink | 将目标和双向链路交给路由、出站 | 把VLESS基本头继续发送给普通网站 |
| outbound.Handler.Process | 对下一跳编码VLESS请求并并行复制两方向 | 将本地每次Write变成端到端消息 |
其中Encryption握手发生在基本VLESS解码之前;它的记录即使包含一个偶然的00字节,也不能直接送入DecodeRequestHeader。普通TLS或REALITY又可能已在传输接入阶段完成相应处理。函数接收的是连接接口,不意味着线上最外层必定是裸VLESS。
入站先设置握手读截止时间,然后调用一次first.ReadFrom,保存第一批可读字节。BufferedReader把这一批放在后续输入之前。如果没有配置VLESS fallbacks,解码器通过这个BufferedReader读满所需字段;首批只有8字节时,仍可继续从底层连接获取剩余ID。这就是上一节普通增量解析适用的路径。
若配置了fallbacks,固定实现首先检查firstLen是否小于18。小于18会直接进入回退分支;它没有先等待到完整的最短前缀。若不少于18,解码器可直接从first读取版本和ID;只有身份查找成功,才把这17字节从缓冲中前移掉,然后继续解析A、命令和目标。
这个行为有很明确的适用边界:TCP不保证一次读取至少交付18字节。因此“一个合法VLESS请求头最终能到齐”与“配置回退时首读必然被判为VLESS”不是同一承诺。分片到达、外层缓冲和实现读取策略都可能影响首读长度。这里记录的是该固定提交的分派启发式,不是把18字节首读提升成TCP规范。
回退还依赖错误发生在哪个阶段。版本不支持或ID不匹配时,解码器能保留可回退标志;这时原始首批内容尚可交给配置的其他服务。身份成功后,附加信息或命令地址解析出错,返回的可回退标志已为false,不能笼统说“只要VLESS有任何错误就交给网站”。否则已消费的前缀怎么恢复、后续服务收到什么,都没有可靠定义。
回退目标来自服务端配置,并可依据已有的TLS名称、ALPN等信息选择;它不是从未经认证的VLESS目标字段中任意取一个地址。回退到网站表示这条输入走了另一个服务分支,不表示ID被接受,也不表示已经给未认证方开放了正向代理。
VLESS Encryption启用时,本提交配置构建器禁止同时配置这套VLESS fallbacks。原因至少在实现边界上很直接:基本解析之前已经有另一份加密握手,不能假定任意普通网站流量都能越过它,到达同一个回退判别点。REALITY握手自身的非授权处理在另一个层次,第21章单独说明;不要将这两种路径混为一种通用fallback。
认证通过后,入站把原始发送ID的第6、7字节转换成VlessRoute元数据,并用原始ID初始化后续流量状态。这是第18章已说明的实现差异:账户查找对这两字节作归一化,后续用途却仍保留原值。读源码时若只跟踪账户对象里的配置ID,就会漏掉这条数据路径。
最后,Flow与命令权限检查、正文读写器建立、基本响应头缓冲、路由分派相继发生。解码成功只是这个过程的一个中间状态。源码中的错误日志、访问接受日志、出站连接错误和网站自身错误具有不同证明力,不能都用“VLESS握手成功/失败”覆盖。
本节直接依据固定提交的入站入口、解码器的isfb返回值、账户查找及配置组合检查。本书未以运行时分片测试验证首读行为,所述边界由调用顺序和读取契约推导。
19.9 格式能解析,不代表请求表达了原来的意思
仍用19.6的完整请求,只把偏移22的域名长度0b改成0a,其他字节不动。解析器按10字节读取,得到example.co;原来的最后一个字节6d,也就是m,成为正文第一个字节。若忽略实际网络可达性和部署策略,从基本格式上它可以是一条合法请求:目标变了,正文也变成mhello。
这不是解析器应该“聪明纠正”的情形。格式只告诉它读多少字节,没有另一个可靠字段证明发送方本来想写example.com。扫描正文寻找熟悉的域名后缀,既会误伤合法二进制负载,也会引入两个解析器对同一输入解释不同的风险。若需要防止途中这类改变,应依赖覆盖这些字节的完整性保护;VLESS基本头自身没有用于检测它的校验和或AEAD标签。
另一个例子是A=1、唯一附加信息字节为0a。外层确实收满了A字节,但Protobuf中的0a声明一个长度限定字段,后面连内部长度都没有,解码会失败。相反,A=18但网络只送来17字节时,首先失败的是读取完整输入这一步。报文分析中区分这两种错误,才能判断证据究竟指向不完整输入还是已完整的畸形消息。
再把Command改成03。按该命令的语法,随后不存在基本目标字段,于是原来的端口和域名字节全部进入Mux正文。它们不会因为看起来像一个地址就自动恢复成普通TCP请求,后续Mux解析还有自己的合法性要求。协议解析始终依赖前面已经确定的状态;同一段01 bb 02没有脱离上下文的唯一含义。
上述变化都只描述基本解析层。若外面是已经正确认证的TLS或Encryption记录,途中改一位通常先导致外层认证失败,基本VLESS解析器根本看不到改后的明文。已经掌握会话密钥的端点却能够生成新的有效记录,所以通道保护不能替代对已认证用户的目标授权与资源限制。身份、字节完整性和访问权限需要各自成立。
这种分析方法也规定了本章结论的范围:报文字段与分支来自固定提交,长度来自逐项计算;没有使用虚构抓包证明真实网络时序,没有把源码中存在一个类型当成端到端支持所有组合,也没有把基本头的轻量性推广成吞吐量排名。版本更新后,应该重新检查编码、解码、账户查找、传输适配与配置校验的组合。
下一章沿着同一个接口继续:VLESS看到的是一条可读写的逻辑字节流,而承载层负责把这条流映射到TCP连接、WebSocket消息或HTTP流。只要能正确还原字节顺序与结束语义,基本头可以保持不变;承载的握手、分片、复用和错误传播却可能完全不同。