feat(protocols): broaden mihomo Clash/sing-box protocol coverage end-to-end#8
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
"30 Mbps"↔ integer mbps;obfs: salamander↔ nested{type, password}auth_str(notpassword); accepts integer or"30 Mbps""Xms")peers[]; splits combined ip/ipv6 back to Clash's separate keys on emitEach protocol gets the full pipeline: typed Clash struct →
Proxyenum variant →ProxyParamsvariant →source.rstyped extraction →template_processoremit/convert pair → subscription URL parser →detect.rsprefix.2. Typed extraction promoted from
Genericfallthrough (1a0c690,dff92ad)Removes the
_ => Genericcatch-all fromparse_clash_proxyandparse_singbox_outbound. Until now several protocols deserialized into the strong enum but got flattened back intoProxyParams::Generic— every protocol-specific field was demoted toextras: HashMap<String, String>, lost on cross-format re-emit.New
ProxyParamsvariants (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.rsflagged content as a subscription on the strength ofssr://, butparse_proxy_urlthen dropped the line — user saw "subscription detected" then 0 nodes.parse_ssr_url— fullbase64(server:port:protocol:method:obfs:base64(password)/?obfsparam=...&remarks=...)with URL_SAFE_NO_PAD + STANDARD-with-padding fallbacksparse_snell_url— psk@host:port?obfs=&obfs-host=&version=, reconstructs nestedobfs-opts {mode, host}parse_socks5_url— optional userinfo,?tls,?allowInsecure, default port 1080parse_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:
socks5↔socksrename — Clash usessocks5, sing-box usessocks. Pass-through in either direction produced files the receiving side rejected.tls: true+skip-cert-verify↔ sing-box's nestedtls: { enabled, server_name?, insecure? }.passwordleak —ProxyServer.password(set during extraction for routing) was duplicated as a top-levelpasswordkey by the generic emit branch; snell usespsk.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.rsdriving the fullConvertCommand::start_convertpipeline (not just unit-levelcreate_node_config):ss/ssr/snell/socks5/ssh) yields exactly 5 nodes — locks the regression where ssr-only subscriptions returned 0socks5→socksrename + nested TLS rebuild survives the full pipelineTest summary
Compatibility / risk notes
reservedfield accepts both list and string forms (matches mihomo tolerance).ShadowTLSVersionserializes as"V1"/"V2"/"V3"(project pre-existing); extraction normalizes to numeric1/2/3so 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.