Skip to content

fix: convert place disguise host in h2-opts.host for legacy base64-JSON VMess#2739

Merged
wwqgtxx merged 1 commit into
MetaCubeX:Alphafrom
wangwei354:fix/converter-legacy-vmess-h2-host
Apr 22, 2026
Merged

fix: convert place disguise host in h2-opts.host for legacy base64-JSON VMess#2739
wwqgtxx merged 1 commit into
MetaCubeX:Alphafrom
wangwei354:fix/converter-legacy-vmess-h2-host

Conversation

@wangwei354

Copy link
Copy Markdown

概要 / Summary

修复 #2738。老式 v2rayN 风格 base64-JSON VMess 分享链接在 net: h2(或 net: http 经 remap 后)导入时,伪装 host 被静默丢失——common/convert/converter.go:333-343case "h2": 分支把 host 写进了 HTTP2Options 不存在的 Headers 字段里,解码器直接丢弃。

Fixes #2738. Legacy v2rayN-style base64-JSON VMess share links with net: h2 (or net: http after remap) silently lose their disguise host during import — common/convert/converter.go:333-343 writes host into a Headers field that HTTP2Options does not have, so the decoder silently discards it.

本 PR 与 #2737同根源的姊妹 PR(都是 case "http": 分支的 copy-paste 后代),但各自运行在不同的分享链接入口上,可独立合并:

This PR is a sibling with the same root cause as #2737 — both originate from copy-paste of the adjacent case "http": block — but each runs on a different share-link entry point. They merge independently:

复现 / Reproduction

最小复现用的 v2rayN 风格 base64 VMess 分享链接(内层 JSON 含 net: h2 和伪装 host):

Minimal reproducer — a v2rayN-style base64-encoded VMess share link with net: h2 and a disguise host:

proxies:
  - vmess://eyJ2IjoiMiIsInBzIjoiZGVtbyIsImFkZCI6InNlcnZlci5leGFtcGxlLmNvbSIsInBvcnQiOiI0NDMiLCJpZCI6ImI4MzEzODFkLTYzMjQtNGQ1My1hZDRmLThjZGE0OGIzMDgxMSIsImFpZCI6IjAiLCJzY3kiOiJhdXRvIiwibmV0IjoiaDIiLCJ0eXBlIjoibm9uZSIsImhvc3QiOiJjZG4uZXhhbXBsZS5jb20iLCJwYXRoIjoiL2dycGMiLCJ0bHMiOiJ0bHMifQ==

base64 解码后的 JSON:

The base64 payload decodes to:

{"v":"2","ps":"demo","add":"server.example.com","port":"443","id":"b831381d-6324-4d53-ad4f-8cda48b30811","aid":"0","scy":"auto","net":"h2","type":"none","host":"cdn.example.com","path":"/grpc","tls":"tls"}

导入后 ConvertsV2Ray 产出的 h2-opts 是:

After ConvertsV2Ray import, the produced h2-opts is:

map[string]any{
    "headers": map[string]any{"Host": []string{"cdn.example.com"}},
    "path":    "/grpc",
}

h2-opts.host 完全缺失。伪装 host cdn.example.com 被写到了下游没有定义的 headers 键里,解码成 HTTP2Options 后完全丢失。

h2-opts.host is missing entirely. The disguise host cdn.example.com is stranded in a headers key that does not exist on the downstream struct, and is discarded when decoded into HTTP2Options.

根因 / Root cause

common/convert/converter.go:333-343 当前代码:

common/convert/converter.go:333-343 currently:

case "h2":
    headers := make(map[string]any)
    h2Opts := make(map[string]any)
    if host, ok := values["host"].(string); ok && host != "" {
        headers["Host"] = []string{host}    // 写入错误的 map + 错误的 key / wrong map, wrong key
    }
    h2Opts["path"] = values["path"]          // 直接赋 any(可能是 nil)/ raw any assignment
    h2Opts["headers"] = headers              // HTTP2Options 无 Headers 字段 / HTTP2Options has no Headers field
    vmess["h2-opts"] = h2Opts

下游 schema (adapter/outbound/vmess.go:80-83):

Downstream schema (adapter/outbound/vmess.go:80-83):

type HTTP2Options struct {
    Host []string `proxy:"host,omitempty"`
    Path string   `proxy:"path,omitempty"`
}

没有 Headers 字段。common/structure 解码器(WeaklyTypedInput: true、未启 ErrorUnused)把未知的 headers 键静默丢弃,host 随之丢失。

There is no Headers field. The common/structure decoder (WeaklyTypedInput: true, ErrorUnused not enabled) silently drops the unknown headers key, and the host value inside it is lost.

问题如何引入 / How it was introduced

bug 分两步进入代码:

The bug entered the code in two steps:

  • f8366f6e (2022-06-07) feat: 新增grpc h2 http 等支持 首次加入 case "h2": 分支。初版只有 headers["User-Agent"] = RandUserAgent()——作者按照 case "http":(HTTP/1.1 伪装)的模式建了一个 headers map,但 h2 transport 实际并不消费它。

  • 5141ddc9 (2022-06-21) fix: Converter for vless/vmess/ss URI Scheme 加入 headers["Host"] = []string{host}——这就是 host-loss bug 具体引入的 commit。作者想正确处理 JSON 里的 host 字段,但误把它当成 HTTP/1.1 伪装场景(那里 headers["Host"] 确实是正确做法),而没有意识到 HTTP/2 transport 的目标 struct HTTP2Options 根本没有 Headers 字段。

  • f8366f6e (2022-06-07) feat: 新增grpc h2 http 等支持 first added the case "h2": branch. The initial version only wrote headers["User-Agent"] = RandUserAgent() — the author mirrored the case "http": (HTTP/1.1 camouflage) pattern and created a headers map, but h2 transport does not actually consume it.

  • 5141ddc9 (2022-06-21) fix: Converter for vless/vmess/ss URI Scheme then added headers["Host"] = []string{host}this is the commit that introduced the host-loss bug. The author intended to handle the JSON host field correctly, but mistakenly treated it as an HTTP/1.1 camouflage scenario (where headers["Host"] is indeed the right target), without realizing that the H2 transport's downstream struct HTTP2Options has no Headers field at all.

为什么 block 长得像 HTTP/1.1 伪装 / Why the block looks like HTTP/1.1 camouflage

对照两个 transport 的 struct schema 就能看出"抄错模板"的由来:

Comparing the two transport struct schemas shows why the template was copied incorrectly:

字段 / Field HTTPOptions (HTTP/1.1 camouflage, adapter/outbound/vmess.go:74-78) HTTP2Options (adapter/outbound/vmess.go:80-83)
Path []string string
Host (无,由 Headers 承载 / absent, carried by Headers) []string (直接字段 / top-level field)
Headers map[string][]string (真字段,由 HTTP/1.1 流量伪装消费 / real field, consumed by HTTP/1.1 traffic disguise) (不存在 / does not exist)

HTTP/1.1 伪装场景下 headers["Host"] 是正确的目标——HTTP/1.1 请求本来就要靠 Host header 标识目标。但 HTTP/2 transport 的伪装点在外层 TLS SNI / ALPN,帧层 header 已加密且非伪装入口,所以 HTTP2Options 从 2020 年 commit 5bd189f2 引入以来就只有 Host []string + Path string,从未有过 Headers 字段;transport/vmess/h2.go:H2Config 同样只有 Hosts + Path;执行层 establishConn() 里 HTTP/2 请求的 header 就硬编码一条 Accept-Encoding: identity。整套链路都不读用户自定义的 H2 header。

In the HTTP/1.1 camouflage case, headers["Host"] is the correct target — HTTP/1.1 requests identify the target via the Host header itself. HTTP/2 transport, however, is disguised at the outer TLS SNI / ALPN layer, and its frame-level headers are encrypted and not a disguise channel. HTTP2Options has had exactly Host []string + Path string since it was introduced in 2020 (commit 5bd189f2) and never a Headers field; transport/vmess/h2.go:H2Config similarly has only Hosts + Path; and establishConn() hardcodes the sole HTTP/2 header Accept-Encoding: identity. No code path in the chain reads a user-supplied H2 header.

所以 legacy 分支的 h2Opts["headers"]headers["Host"] 完全是复制 HTTP/1.1 模板时带进来的残留:headers map 纯粹没被消费,host 值也因此在最简单的路径上丢失。

The legacy branch's h2Opts["headers"] and headers["Host"] are therefore pure residue from copying the HTTP/1.1 template: the headers map is never consumed, and the host value ends up lost on the most basic path.

修复 / The fix

common/convert/converter.go (+5 / -6)

 	case "h2":
-		headers := make(map[string]any)
 		h2Opts := make(map[string]any)
-		if host, ok := values["host"].(string); ok && host != "" {
-			headers["Host"] = []string{host}
+		h2Opts["path"] = "/"
+		if path, ok := values["path"].(string); ok && path != "" {
+			h2Opts["path"] = path
+		}
+		if host, ok := values["host"].(string); ok && host != "" {
+			h2Opts["host"] = []string{host}
 		}
-
-		h2Opts["path"] = values["path"]
-		h2Opts["headers"] = headers
-
 		vmess["h2-opts"] = h2Opts

三点改动:

Three changes:

  1. host 写进正确的 keyh2Opts["host"] = []string{host}。下游 HTTP2Options.Host []string 能正确消费。

    host goes into the correct key: h2Opts["host"] = []string{host}. Downstream HTTP2Options.Host []string can now consume it.

  2. path 默认 "/":旧代码 h2Opts["path"] = values["path"] 直接把 any(可能是 nil,当 JSON 里 path 字段缺失时)赋进 map;解码器在 isNil 分支回退到空字符串 "",导致 HTTP/2 请求 URL 没有路径。新代码默认 "/",仅在 JSON 提供非空 path 时覆盖——与 xray URI spec、同文件 case "http":case "ws": 的默认策略一致。考虑到该分支先前因 host 错位几乎不可能被用户配成可用节点(disguise host 丢失),这个默认值改动基本无实际兼容风险。

    path defaults to "/": The previous code h2Opts["path"] = values["path"] raw-assigned any (which can be nil when the JSON omits path); the decoder falls back to an empty string "" via the isNil branch, resulting in an HTTP/2 request with an empty path. The new code defaults to "/", overridden only when the JSON provides a non-empty path — matching the xray URI spec and the default strategies in the adjacent case "http": and case "ws": blocks. Because the branch almost never produced a working node before (due to the host loss), this default change has essentially no compatibility risk.

  3. 删掉无用的 headers map:HTTP2Options 无对应字段,headers 从 2022 年起就是死代码。

    Drop the dead headers map: HTTP2Options has no such field; headers has been dead code since 2022.

common/convert/converter_test.go (+40, two new regression tests)

  • TestConvertsV2RayVmessBase64H2Transport —— 显式 net: h2 输入路径

  • TestConvertsV2RayVmessBase64HTTPRemappedToH2Transport —— net: http, type: noneconverter.go:296-305 的 remap 规则落到 "h2" 的等价路径。单独覆盖是为了防止未来改动 remap 规则时引入回归。

  • TestConvertsV2RayVmessBase64H2Transport — explicit net: h2 input path

  • TestConvertsV2RayVmessBase64HTTPRemappedToH2Transport — the equivalent path where net: http, type: none is remapped to "h2" via converter.go:296-305. Covered separately to guard against regressions if that remap rule is changed later.

两条测试通过精确匹配产出 map 锁住形状契约。已通过 git stash 回退 converter.go 修复后跑这两个测试验证,确认它们会 FAIL(bug 精确重现),恢复后 PASS。

Both tests lock the shape contract via precise map equality. Verified via git stash that reverting the converter.go fix makes both tests fail with the exact bug signature; restoring the fix makes them pass.

刻意不做 / Deliberately deferred

不创建 adapter/parser_test.go 的端到端测试:该文件由 #2737 新建,本 PR 基于 upstream/Alpha 不含它。若本 PR 也创建同名文件,两个 PR 合并时会产生"新文件创建冲突"。本 bug 的症状是"数据错位到不存在字段"(不是"类型失配让解码直接 error"),converter 层的 map 形状断言已能精确锁定。未来若需要 legacy 分支的 ParseProxy 端到端覆盖,可在 #2737 合并后追加。

No adapter/parser_test.go end-to-end test added. That file is introduced by #2737; this PR, based on upstream/Alpha, does not include it. If this PR also created the same file, the two PRs would collide on "new file creation" at merge time. The failure mode here is "data routed to a non-existent field" (not "type mismatch causing a decode error"), and converter-level map-shape assertions are precise enough to lock it down. If legacy-branch ParseProxy end-to-end coverage is desired later, it can be added in a follow-up once #2737 lands.

Related

…ON VMess

The case "h2" block in common/convert/converter.go (the legacy v2rayN
base64-JSON VMess share-link parser) has been writing the disguise host
into a non-existent h2-opts.headers.Host key since 5141ddc (2022-06).
HTTP2Options has no Headers field, so common/structure silently drops
the unknown key and every VMess h2 subscription loses its disguise
host on import.

The bug is the direct parallel of MetaCubeX#2555 / MetaCubeX#2737, which fixed the modern
VMess AEAD / VLESS URL path in common/convert/v.go. Both branches were
copy-pasted from the adjacent case "http" (HTTP/1.1 camouflage), whose
HTTPOptions struct really does have a Headers field. HTTP2Options does
not. The disguise host belongs in the top-level h2-opts.host.

Align the h2 branch with HTTP2Options exactly: write host into
h2-opts.host, default path to "/" (matching the xray URI spec and the
adjacent case "http" / case "ws" branches), and drop the dead headers
residue. Add two regression tests: one for explicit net=h2 and one for
net=http, type=none which is remapped to h2.

Fixes MetaCubeX#2738
@wwqgtxx wwqgtxx merged commit 98aa42e into MetaCubeX:Alpha Apr 22, 2026
kuno pushed a commit to kuno/mihomo that referenced this pull request May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants