Skip to content

fix: convert emit HTTP2Options-shaped h2-opts for share-link HTTP/2 transport#2737

Merged
wwqgtxx merged 2 commits into
MetaCubeX:Alphafrom
wangwei354:fix/vmess-share-link-h2-opts
Apr 22, 2026
Merged

fix: convert emit HTTP2Options-shaped h2-opts for share-link HTTP/2 transport#2737
wwqgtxx merged 2 commits into
MetaCubeX:Alphafrom
wangwei354:fix/vmess-share-link-h2-opts

Conversation

@wangwei354

@wangwei354 wangwei354 commented Apr 21, 2026

Copy link
Copy Markdown

概要 / Summary

修复 #2555。带 type=http(HTTP/2 transport)的 VMess / VLESS 分享链接在导入后会静默消失:common/convert/v.go 产出的 h2-opts map 把 path 写成 []string,但下游 HTTP2Options.Path 类型是 string,所以 adapter.ParseProxy 解码失败、整个节点被丢弃。

本 PR 修正类型错配,并移除同一次 copy-paste 带来的 headers 死代码残留,然后补充端到端回归测试锁住 convert → ParseProxy 链路契约。

Fixes #2555. VMess / VLESS share links with type=http (HTTP/2 transport) silently fail to import: the converter in common/convert/v.go emits an h2-opts map whose path is []string, but HTTP2Options.Path is string, so adapter.ParseProxy rejects the entire node during decoding.

This PR fixes the type mismatch, removes the dead headers residue from the same copy-paste, and adds an end-to-end regression test that locks the converter-to-ParseProxy contract.

复现方式 / Reproduction

最小复现用的 VLESS 分享链接:

Minimal VLESS share link that triggers the bug:

vless://b831381d-6324-4d53-ad4f-8cda48b30811@example.com:443?security=tls&type=http&host=cdn.example.com&path=%2Fgrpc#vless-h2

在当前 Alpha 分支上,经过 convert.ConvertsV2Rayadapter.ParseProxy 会返回:

On current Alpha, running this through convert.ConvertsV2Rayadapter.ParseProxy returns:

'h2-opts.path' expected type 'string', got unconvertible type '[]string'

节点被静默丢弃。

The node is silently dropped from the subscription.

根因 / Root cause

common/convert/v.go:93-104 当前产出:

common/convert/v.go:93-104 currently emits:

case "h2":
    headers := make(map[string]any)
    h2Opts := make(map[string]any)
    h2Opts["path"] = []string{"/"}
    if path := query.Get("path"); path != "" {
        h2Opts["path"] = []string{path}
    }
    if host := query.Get("host"); host != "" {
        h2Opts["host"] = []string{host}
    }
    h2Opts["headers"] = headers
    proxy["h2-opts"] = h2Opts

adapter/outbound/vmess.go:80-83 的 schema 是:

But adapter/outbound/vmess.go:80-83 declares:

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

common/structure.decodeString(被 adapter.ParseProxy 调用)在 WeaklyTypedInput 模式下只接受 string / int / uint / float不会把 slice 转成 string。所以 h2-opts.path 解码失败,ParseProxy 返回 error,节点被丢弃。

common/structure.decodeString (invoked by adapter.ParseProxy) accepts only string / int / uint / float under WeaklyTypedInput; it does not convert []string → string. The decode of h2-opts.path fails and ParseProxy returns an error, discarding the node.

h2Opts["headers"] = headers 也是一个 no-op:HTTP2Options 没有 Headers 字段。由于 common/structure 没启用 ErrorUnused,这个未知键会被静默忽略——对解码不构成问题,但残留在代码里毫无意义。

The h2Opts["headers"] = headers assignment is additionally a no-op: HTTP2Options has no Headers field. Since common/structure does not enable ErrorUnused, the unknown key is silently ignored — harmless for decoding, but pure noise in the source.

问题如何引入 / How it was introduced

  • b23a0710 (2022) 首次添加 case "h2": block,当时 switch 标签写的是 case "http":。但函数顶部 network 已被 remap 成 "h2",所以 switch 的 case "http": 永远匹配不到——整个 block 从出生那天起就是不可达的死代码path 类型错没人发现,因为分支从不执行。

  • PR fix(convert): normalize VLESS share-link transport mapping #2694 / 0495d295 (2026-04-13) 修正了这个控制流错配,把 case 标签改为 "h2":激活了死代码[]string 类型错从此第一次真正生效,用户开始遇到节点消失。

  • b23a0710 (2022) introduced the case "h2": block, originally labelled case "http":. At the top of the same function, network = "http" is remapped to "h2", but the switch case was literal "http". As a result the entire block was unreachable dead code from the day it was written. The wrong path type went unnoticed because the branch never ran.

  • PR fix(convert): normalize VLESS share-link transport mapping #2694 / commit 0495d295 (2026-04-13) fixed the control-flow mismatch by renaming the case to "h2":, activating the dead code for the first time. The []string type error only began affecting users at that point.

Issue #2555 提交于 2026-02-04,在 #2694 合并之前。报告人通过静态阅读代码就发现了两处问题;#2694 修了控制流那一半,本 PR 修类型那一半。

Issue #2555 was filed 2026-02-04, before #2694 was merged. The reporter caught both problems by static reading; #2694 fixed the control-flow half, this PR fixes the type half.

为什么 block 满是 copy-paste 残留 / Why the block is full of copy-paste residue

case "h2": 几乎是相邻 case "http":(HTTP/1.1 伪装)的逐行镜像,但两个目标 struct 的 schema 不同:

The case "h2": block is a near-verbatim clone of the adjacent case "http": (HTTP/1.1 camouflage), but the two target structs have different schemas:

字段 / Field HTTPOptions (HTTP/1.1 camouflage) HTTP2Options (HTTP/2 transport)
Path []string string
Headers map[string][]string(真字段,实际被消费) (不存在 / does not exist)

所以从 case "http": 直接复制一份到 case "h2": 就会带上两处残留:[]string 类型的 path 和永不被消费的 headers map。两者自 2022 年起潜伏,直到 #2694 把分支唤醒才暴露。

A direct copy from case "http": to case "h2": therefore carries two artifacts: the slice-typed path and the never-consumed headers map. Both have been latent since 2022 and surfaced only after #2694 woke up the branch.

为什么 HTTP2Options 没有 Headers 字段 / Why HTTP2Options has no Headers field

不是遗漏——是 5 年以上一致的设计:

Not an omission — it is the consistent design across 5+ years:

  1. adapter/outbound/vmess.go:80 —— HTTP2Options struct 在 2020 年 commit 5bd189f2 引入,精确 Host + Path 两个字段。之后从未变过。

  2. transport/vmess/h2.go:23 —— transport 层的 H2Config 也精确是 Hosts + Path

  3. transport/vmess/h2.go:28-49 —— establishConn() 构造的 HTTP/2 请求 Method 硬编码为 "PUT",Header 硬编码为唯一一条 Accept-Encoding: identity根本没有读用户 H2 header 的代码路径

  4. adapter/outbound/vmess.go:80HTTP2Options struct was introduced in 2020 (5bd189f2) with exactly Host + Path. Never changed.

  5. transport/vmess/h2.go:23 — the transport-layer H2Config also has exactly Hosts + Path.

  6. transport/vmess/h2.go:28-49establishConn() builds the HTTP/2 request with a hardcoded Method: "PUT" and a single hardcoded header Accept-Encoding: identity. There is no code path that reads user-supplied H2 headers.

对比有 Headers 字段的兄弟:HTTPOptions(HTTP/1.1 伪装——需要伪造 Host/User-Agent 让流量看起来像明文 HTTP)和 WSOptions(WebSocket——握手本身是 HTTP upgrade,自定义 header 用于 CDN 路由)。HTTP/2 的伪装锚点在外层 TLS SNI / ALPN,不在 HTTP/2 frame 的 header 层,这正是项目始终没把自定义 H2 header 接进来的原因。

Contrast with the siblings that do carry a Headers field: HTTPOptions (HTTP/1.1 camouflage — needs Host/User-Agent forging to look like plain HTTP) and WSOptions (WebSocket — HTTP upgrade handshake naturally uses custom headers for CDN routing). HTTP/2 disguise is anchored at the outer TLS SNI / ALPN layer, not inside the HTTP/2 frame headers, which is why the project never wired custom H2 headers through.

修复 / The fix

common/convert/v.go case "h2": (+2 / -4)

 	case "h2":
-		headers := make(map[string]any)
 		h2Opts := make(map[string]any)
-		h2Opts["path"] = []string{"/"}
+		h2Opts["path"] = "/"
 		if path := query.Get("path"); path != "" {
-			h2Opts["path"] = []string{path}
+			h2Opts["path"] = path
 		}
 		if host := query.Get("host"); host != "" {
 			h2Opts["host"] = []string{host}
 		}
-		h2Opts["headers"] = headers
 		proxy["h2-opts"] = h2Opts

产出的 map 现在精确对齐 HTTP2Options schema——只含 hostpath,无多余字段、无类型错配。

The resulting map now precisely matches the HTTP2Options schema — exactly host and path, nothing else.

common/convert/converter_test.go (+2 / -3)

TestConvertsV2RayVlessHTTPTransportUsesH2Opts 之前把 buggy 形状(path[]string、以及空 headers map)锁在断言里。同步更新期望值,使之对齐修复后的输出。

TestConvertsV2RayVlessHTTPTransportUsesH2Opts previously locked in the buggy shape (path as []string, plus the dead headers map). Updated the expected map to match the corrected output.

adapter/parser_test.go (+24, new)

新增的回归测试跑完整 convert.ConvertsV2Ray → adapter.ParseProxy 链路,锁住"converter 产物必须能被下游解码"的契约:

Added a regression test that runs the full convert.ConvertsV2Ray → adapter.ParseProxy chain, locking the contract that the converter's output must be decodable by the typed config parser:

// Regression test for MetaCubeX/mihomo#2555: the output of
// convert.ConvertsV2Ray must be decodable by adapter.ParseProxy.
func TestParseProxy_AcceptsShareLinkH2Transport(t *testing.T) {
    const shareLink = "vless://b831381d-6324-4d53-ad4f-8cda48b30811@example.com:443?security=tls&type=http&host=cdn.example.com&path=%2Fgrpc#vless-h2"

    proxies, err := convert.ConvertsV2Ray([]byte(shareLink))
    require.NoError(t, err)
    require.Len(t, proxies, 1)

    _, err = adapter.ParseProxy(proxies[0])
    require.NoError(t, err)
}

已验证:git stash 回退 v.go 修复后,此测试以 issue 报告里的完全相同错误信息 FAIL;恢复后 PASS。这道测试不仅覆盖 path 本身,还能防止未来 h2-opts 任何字段的类型漂移。

Verified that reverting the v.go fix (via git stash) makes this test fail with the exact same error from the issue report; restoring the fix makes it pass. This guards against future type drift in any h2-opts field, not just path.

放在 adapter_test 外部测试包里,避开 common/convert → adapter 的反向 import cycle,同时仍通过公开 ParseProxy 接口做端到端验证。

Placed in adapter_test (external test package) to avoid the common/convert → adapter import cycle while still exercising the public ParseProxy surface.

为什么顺手清理 headers 残留而不是只改 path / Why clean up the headers residue instead of a minimal path-only fix

只改 path 类型就能让节点解析通过,但会留下两行与 path bug 源自同一次 copy-paste 的死代码。本次清理严格在同一个 case "h2": block 内,不触碰任何相邻代码,让分支的最终形状精确镜像 HTTP2Options schema。反之,如果留着 h2Opts["headers"] = headers,将来读这段代码的人每次都要重新确认一遍"解码器容忍这个键吗?"——这不是可靠性问题,但是代码卫生问题。

A one-line type fix would make the node parse, but it would leave two lines of dead code that were born from the same copy-paste event as the path bug. The cleanup stays strictly inside the same case "h2": block — no neighboring code touched — and makes the branch's final shape mirror the HTTP2Options schema exactly. Leaving h2Opts["headers"] = headers in would create semantic noise: a future reader would have to verify, again, that the decoder tolerates it.

不在本 PR 范围 / Out of scope (follow-ups for separate PRs)

  • common/convert/converter.go:333-343 — legacy base64-JSON VMess case "h2": 存在一个平行 bug:host 被写进了不存在的 headers["Host"] key 而不是 h2Opts["host"],通过这条入口导入的节点伪装 host 会被静默丢弃。同一 copy-paste 根源,但入口(v2rayN 老式 base64 订阅 vs 新式 VMess AEAD URL)不同,本 PR 不扩散。已单独跟踪:[Bug] legacy base64-JSON VMess subscription silently drops disguise host for h2 transport #2738

  • common/convert/converter.go:333-343 — legacy base64-JSON VMess case "h2": has a parallel bug: host is written into a non-existent headers["Host"] key instead of h2Opts["host"], so the disguise host is silently dropped on that entry path. Same copy-paste origin as this PR, but a different entry point (legacy v2rayN base64 subscription vs modern VMess AEAD URL). Tracked separately: [Bug] legacy base64-JSON VMess subscription silently drops disguise host for h2 transport #2738.

相关 / Related

…ransport

The case "h2" block in common/convert/v.go was dead code from b23a071
until PR MetaCubeX#2694 renamed the switch label and activated it. Activation
exposed a copy-paste mistake from case "http": h2-opts.path was emitted
as []string while HTTP2Options.Path is string, and h2-opts.headers was
populated although HTTP2Options has no Headers field. The path mismatch
makes adapter.ParseProxy drop every VMess/VLESS node whose share link
uses type=http; the headers residue is silently ignored by the decoder
but serves no purpose.

Align the h2 branch with HTTP2Options exactly (host + path, nothing
else). Update the existing converter unit test whose expectation locked
in the buggy shape. Add an end-to-end regression test in a new
adapter/parser_test.go that runs convert.ConvertsV2Ray through
adapter.ParseProxy so future drift in any h2-opts field is caught at
the contract boundary.

Fixes MetaCubeX#2555
@wwqgtxx wwqgtxx merged commit f5cfb8d 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