Skip to content

feat(protocols): broaden mihomo Clash/sing-box protocol coverage end-to-end#8

Merged
bitxwave merged 7 commits into
mainfrom
feat/expand-mihomo-protocols
Jun 5, 2026
Merged

feat(protocols): broaden mihomo Clash/sing-box protocol coverage end-to-end#8
bitxwave merged 7 commits into
mainfrom
feat/expand-mihomo-protocols

Conversation

@bitxwave
Copy link
Copy Markdown
Owner

@bitxwave bitxwave commented Jun 5, 2026

Expands the protocol pipeline in three orthogonal directions and tightens the cross-format conversion that was previously silently lossy. All work is bug-free across cargo test (165 tests, +27 new).

What changed

1. Five new protocols, end-to-end (7afa819)

Adds Clash YAML and sing-box outbound support for protocols missing from the registry:

  • VLESS — reality-opts ↔ tls.reality, client-fingerprint ↔ tls.utls.fingerprint
  • Hysteria2"30 Mbps" ↔ integer mbps; obfs: salamander ↔ nested {type, password}
  • Hysteria v1 — emits auth_str (not password); accepts integer or "30 Mbps"
  • TUIC — kebab ↔ snake (congestion-controller / udp-relay-mode / reduce-rtt); heartbeat-interval (ms) ↔ heartbeat ("Xms")
  • WireGuard — normalizes mihomo's simplified (top-level peer) and full (peers list) shapes to a single sing-box peers[]; splits combined ip/ipv6 back to Clash's separate keys on emit

Each protocol gets the full pipeline: typed Clash struct → Proxy enum variant → ProxyParams variant → source.rs typed extraction → template_processor emit/convert pair → subscription URL parser → detect.rs prefix.

2. Typed extraction promoted from Generic fallthrough (1a0c690, dff92ad)

Removes the _ => Generic catch-all from parse_clash_proxy and parse_singbox_outbound. Until now several protocols deserialized into the strong enum but got flattened back into ProxyParams::Generic — every protocol-specific field was demoted to extras: HashMap<String, String>, lost on cross-format re-emit.

New ProxyParams variants (shared between both formats where possible):

  • ShadowsocksR, Snell (Clash-specific)
  • Ssh, ShadowTls, Naive (sing-box-specific)
  • Socks, Http (shared, plug into both)

The Clash and sing-box matches are now exhaustive — adding a new protocol is a compile-time error, not silent typed-loss.

3. Subscription URL parsers (efe96b0)

Closes the gap where detect.rs flagged content as a subscription on the strength of ssr://, but parse_proxy_url then dropped the line — user saw "subscription detected" then 0 nodes.

  • parse_ssr_url — full base64(server:port:protocol:method:obfs:base64(password)/?obfsparam=...&remarks=...) with URL_SAFE_NO_PAD + STANDARD-with-padding fallbacks
  • parse_snell_url — psk@host:port?obfs=&obfs-host=&version=, reconstructs nested obfs-opts {mode, host}
  • parse_socks5_url — optional userinfo, ?tls, ?allowInsecure, default port 1080
  • parse_ssh_url — user[:password]@host[:port] (private-key auth can't round-trip via URL)

4. Cross-format bug fixes (050d6ba, a03e54e)

Bugs found by probing the emit side after typed extract landed:

  • socks5socks rename — Clash uses socks5, sing-box uses socks. Pass-through in either direction produced files the receiving side rejected.
  • socks TLS shape — Clash's flat tls: true + skip-cert-verify ↔ sing-box's nested tls: { enabled, server_name?, insecure? }.
  • snell password leakProxyServer.password (set during extraction for routing) was duplicated as a top-level password key by the generic emit branch; snell uses psk.
  • TUIC heartbeat unit mismatch — sing-box heartbeat: "10s" was emitted to Clash verbatim. mihomo expects integer milliseconds. Now "10s"10000, "500ms"500, bare int passes through.

5. Integration tests (3cfaa86)

Two new tests in tests/convert_integration.rs driving the full ConvertCommand::start_convert pipeline (not just unit-level create_node_config):

  • mixed plain-text subscription (ss/ssr/snell/socks5/ssh) yields exactly 5 nodes — locks the regression where ssr-only subscriptions returned 0
  • Clash YAML → sing-box JSON file: socks5socks rename + nested TLS rebuild survives the full pipeline

Test summary

Layer New cases Total
Clash extraction 4
sing-box extraction 5
URL parsers 5
Emit / cross-format 4 + 6
Integration 2
All +27 165 pass

Compatibility / risk notes

  • Same-format pass-through (clash↔clash, singbox↔singbox) for ssr / ssh / shadowtls / naive was probed and already worked via extras emit — no change there.
  • WireGuard's reserved field accepts both list and string forms (matches mihomo tolerance).
  • ShadowTLSVersion serializes as "V1"/"V2"/"V3" (project pre-existing); extraction normalizes to numeric 1/2/3 so downstream consumers don't re-parse.

Resolves the Clash YAML "proxies.[].type unsupported" error class for mihomo subscriptions containing any of vless / hysteria / hysteria2 / tuic / wireguard, and the silent "subscription detected → 0 nodes" failure for ssr-only sources.

bitxwave added 7 commits June 5, 2026 09:31
…tuic/wg)

Adds Clash YAML and sing-box outbound support for the five high-priority
protocols missing from the registry. Each protocol gets:

  - typed Clash struct (clash::proxy::*)
  - Proxy enum variant + name() arm
  - ProxyParams variant (or reuse for Vless/Hysteria2)
  - source.rs typed extraction (both Clash and sing-box paths)
  - clash + singbox template_processor emit/convert pair with kebab-case
    <-> snake_case + duration / bandwidth field normalization
  - subscription URL parser (extended for vless reality/fp; new for
    hysteria:// and tuic://)
  - detect.rs prefix recognition

Protocol-specific notes:
  - VLESS: rebuilds reality-opts <-> tls.reality and client-fingerprint
    <-> tls.utls.fingerprint between Clash and sing-box
  - Hysteria2: parses "30 Mbps" string back to integer mbps; obfs maps
    salamander to nested {type, password}
  - Hysteria v1: emits auth_str (not password); accepts integer or
    "30 Mbps" form
  - TUIC: maps mihomo congestion-controller / udp-relay-mode / reduce-rtt
    to sing-box snake_case; heartbeat-interval (ms) <-> heartbeat ("Xms")
  - WireGuard: normalizes mihomo's simplified (top-level peer) and full
    (peers list) shapes to a single peers array on the sing-box side;
    splits combined ip/ipv6 back to Clash's split keys when emitting
    Clash; preserves reserved as either list or string

Tests: 12 new cases in tests/parser_test.rs covering Clash extraction,
Clash -> sing-box emit, and subscription URL parsing for each protocol.
All 138 tests still pass.

Resolves the Clash YAML "proxies.[].type unsupported" error class for
mihomo subscriptions containing any of these five protocol types.
Closes the gap where detect.rs flagged content as a subscription on the
strength of an `ssr://` prefix, but parse_proxy_url then dropped that
line — the user saw "subscription detected" then 0 nodes. Now each
recognised prefix has a real parser, and detect.rs additionally accepts
`snell://`, `socks5://`, `ssh://`.

Parsers:
- parse_ssr_url: decodes the standard SSR layout
  base64(server:port:protocol:method:obfs:base64(password)/?obfsparam=...&remarks=...)
  with both URL_SAFE_NO_PAD and STANDARD-with-padding fallbacks. Typed
  fields (protocol/obfs/obfs-param/protocol-param/group) preserved in
  `extras` until ProxyParams::ShadowsocksR lands.
- parse_snell_url: psk@host:port?obfs=&obfs-host=&version=#name with
  obfs-opts {mode, host} reconstruction matching mihomo's nested shape.
- parse_socks5_url: optional userinfo, ?tls / ?allowInsecure / port
  default 1080.
- parse_ssh_url: user[:password]@host[:port]; private-key auth can't be
  expressed in a URL so only password auth round-trips.

All four use the existing split_authority helper for IPv6/host-only
authorities, and follow the same urlencoding::decode + Cow fallback
pattern as the existing parsers in the file.

Tests: 5 new cases in tests/parser_test.rs covering each parser plus a
subscription-level regression that ssr+ss together yield 2 nodes (not 1
or 0). All 144 tests pass.
Removes the `_ => Generic` catch-all in `parse_clash_proxy`. Until now
Clash proxies of type ssr / socks5 / http / snell deserialized into the
strong `clash::proxy::Proxy::*` enum but then got flattened back into
ProxyParams::Generic on extraction — every protocol-specific field was
demoted to a string entry in `extras`, lost on cross-format re-emit and
unreadable via the typed API.

New ProxyParams variants:
  - ShadowsocksR { cipher, protocol, obfs, obfs_param, protocol_param, udp }
  - Socks       { version, username, tls, udp }   (shared with sing-box)
  - Http        { username, tls }                  (shared with sing-box)
  - Snell       { psk, version, obfs_opts }        (Clash-only)

The Socks / Http variants are pre-shaped to match what sing-box
extraction will plug into in the next commit (ticket-5), so we don't
need a second round of variant churn.

ProxyParams::extras() updated to fan the four new arms into the same
single return path. The exhaustive match in the Clash branch now covers
all 13 Proxy variants without a fallback — adding new protocols stays a
compile-time error, not a silent typed-loss.

Tests: 4 new cases in tests/parser_test.rs asserting that each typed
field is read off the new variant (not from `extras`). All 148 tests
pass.
…cks/http

Closes the matching gap on the sing-box side: the Outbound enum already
had Socks/Http/Naive/Shadowtls/Ssh variants, but parse_singbox_outbound
fell through to ProxyParams::Generic for all five — same lossy demotion
the previous commit fixed for Clash.

New ProxyParams variants:
  - Ssh        { user, private_key, private_key_path,
                 private_key_passphrase, host_key, host_key_algorithms }
  - ShadowTls  { version: u8, tls }
  - Naive      { username, quic, tls }

Socks/Http reuse the variants introduced in the previous commit, so
sing-box and Clash now feed the same typed shape — cross-format conversion
will be straightforward to wire up later without another variant churn.

ShadowTLSVersion in singbox::outbound::shadowtls serializes as the strings
"V1"/"V2"/"V3" (a pre-existing project shape). Extraction maps those
back to numeric 1/2/3 in ProxyParams::ShadowTls so consumers don't have
to re-parse the enum string.

Tests: 5 new sing-box extract cases. All 153 tests pass.
Three real bugs found by probing the emit side after typed extract
landed:

1. Clash `socks5` → sing-box: `type: socks5` was passed through verbatim
   but sing-box only recognises `type: socks`. Output failed to load.
   Same direction also left Clash's flat `tls: true` boolean and
   `skip-cert-verify` on the node, where sing-box wants a nested
   `tls: { enabled, server_name?, insecure? }` object.

2. sing-box `socks` → Clash: `type: socks` carried through but mihomo
   rejects it; only `socks5` is valid Clash.

3. Clash `snell` → Clash: ProxyServer.password = the snell PSK (set
   during extraction for routing convenience), which the generic emit
   then duplicated under a `password` key. Snell uses `psk` (already
   carried via extras), so the spurious `password` key was wrong even
   though mihomo tolerated it.

Fix:
- Both processors now have a small protocol-name normalization step
  (mirroring the existing `ss ↔ shadowsocks` rename) that maps
  socks5 ↔ socks at emit time.
- New SingboxProcessor::convert_socks_params_to_singbox reads the typed
  ProxyParams::Socks (populated by both Clash and sing-box extraction in
  the previous commits) and emits the nested TLS object form.
- Clash emit suppresses its generic-branch `password` insert when the
  protocol is snell.

Tests: 4 new emit cases — clash socks5 → singbox (rename + tls rebuild +
no leaked udp/skip-cert-verify), singbox socks → clash (rename), clash
http → singbox (lock the symmetric pass-through), clash snell → clash
(no spurious password key). All 157 tests pass.

Same-format pass-through (clash↔clash, singbox↔singbox) for
ssr/ssh/shadowtls/naive was probed and already worked via extras emit;
no changes needed there.
Two new integration tests via tests/convert_integration.rs:

- integration_subscription_parse_mixed_with_new_protocols: a plain-text
  subscription with one node each of ss/ssr/snell/socks5/ssh produces
  exactly five ProxyServer entries in the right protocol order. Locks
  in the regression where ssr lines were detected-then-dropped (every
  ssr-only subscription returned 0 nodes).

- integration_clash_socks5_to_singbox_emits_socks_with_nested_tls:
  drives a Clash YAML through ConvertCommand::start_convert all the
  way to a sing-box JSON file on disk, then asserts the socks5 → socks
  rename and the nested-TLS rebuild survived the full pipeline (not
  just unit-level create_node_config). This is the real shape of the
  bug a user would have hit before the previous commit.

All 159 tests pass.
Found by probing the singbox → Clash direction for the five high-priority
protocols added in 7afa819: sing-box's `heartbeat: "10s"` was emitted to
Clash verbatim as `heartbeat-interval: "10s"`, but mihomo expects an
integer in milliseconds. Clash would have rejected the node.

The previous handler only stripped the "ms" suffix; "s" and bare integer
inputs both fell into the verbatim string branch. Now:

  "10s"   → 10000     (s → ms multiply)
  "500ms" → 500       (ms strip-and-parse, was already handled)
  "1000"  → 1000      (bare integer pass-through)
  unknown → string    (mihomo will reject; better than fabricating)

Tests: 6 new sing-box → Clash emit cases for the five high-priority
protocols (vless+reality / hysteria2 / hysteria v1 / tuic ×2 covering both
"s" and "ms" / wireguard) — locks in the round-trip and catches this kind
of unit-mismatch regression earlier next time. All 165 tests pass.

Same-format pass-through (singbox → singbox, clash → clash) for those
five was probed and already correct; no change needed there.
@bitxwave bitxwave changed the title feat(protocols): expand mihomo Clash protocol coverage (vless/hy2/hy/tuic/wg) feat(protocols): broaden mihomo Clash/sing-box protocol coverage end-to-end Jun 5, 2026
@bitxwave bitxwave merged commit a24af8c into main Jun 5, 2026
15 checks passed
@bitxwave bitxwave deleted the feat/expand-mihomo-protocols branch June 5, 2026 11:24
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.

1 participant