Convert Clash configurations to sing-box configurations.
| Clash type | Status | Note |
|---|---|---|
anytls |
O | |
direct |
O | |
http |
O | sing-box limitation: layer tls not supported |
hysteria |
O | sing-box limitation: protocol faketcp/wechat-video not supported |
hysteria2 |
O | |
openvpn |
? | TLS mode only, as an endpoints entry; TAP devices unsupported |
reject |
O | Becomes a block outbound |
snell |
? | Only version 4: sing-box implements 4 and 6, mihomo speaks 1-5 |
socks5 |
O | sing-box limitation: layer tls not supported |
ss |
O | Including plugin: shadow-tls — see below |
ssh |
O | |
tailscale |
O | Emitted as an endpoints entry — see below |
trojan |
? | Trojan-Go features not implemented |
tuic |
O | |
vmess |
O | sing-box limitation: protocol tcp not supported |
vless |
O | sing-box limitation: protocol tcp not supported |
wireguard |
O | Emitted as an endpoints entry — see below |
That is every mihomo proxy type sing-box can express. The rest have no counterpart:
mieru, sudoku, shadowquic, masque, trusttunnel, gost-relay, zerotier,
rematch, plus two sing-box removed outright — ssr (removed in 1.6.0) and dns
(removed in 1.13.0 in favour of route rule actions).
direct and reject stay selectable but are kept out of the generated URLTest group:
URLTest picks the lowest-latency member, and direct would always win the race while
block would always lose it.
A proxy that cannot be converted — an unknown protocol, an unsupported option, or a malformed entry — is skipped with a warning on stderr. The rest of the subscription is still converted.
ShadowTLS is a protocol-agnostic TLS masquerade that carries no encryption of its
own, so sing-box models it as a standalone outbound that the inner proxy reaches through
detour, while Clash folds it into the inner proxy as a SIP003-style plugin. One
ss proxy with plugin: shadow-tls therefore becomes two outbounds. Only the inner
one is offered in proxy groups: selecting a bare ShadowTLS shell would give you a tunnel
with nothing inside it.
WireGuard, Tailscale and OpenVPN become top-level endpoints entries rather
than outbounds — that is where sing-box puts them (WireGuard's outbound was deprecated in
1.11.0 and removed in 1.13.0; Tailscale arrived in 1.12.0 and OpenVPN in 1.14.0). They are
still ordinary proxy targets, so groups reference them. The endpoints section is only
emitted when one of these is actually present.
OpenVPN carries a few judgement calls worth knowing about: mihomo's legacy cipher maps
to data_ciphers_fallback (an explicit data-ciphers-fallback wins), the default
key-direction: bidirectional is dropped because sing-box accepts only server/client,
and dev: tap is reported as unsupported since sing-box provides TUN only.
| Name | Status | Note |
|---|---|---|
| Dial fields | O | udp/tfo/mptcp/interface-name/routing-mark/dialer-proxy, on every protocol |
| Multiplex | O | smux → multiplex (incl. brutal-opts), on ss/trojan/vmess/vless |
| V2Ray transport | O | ws/h2/http/grpc/httpupgrade, on vmess/vless/trojan |
| REALITY | O | From reality-opts, on every TLS-capable protocol |
| ECH | O | ech-opts.config → ech.config, rewrapped as PEM |
| uTLS fingerprint | O | From client-fingerprint |
| Client certificate | O | certificate + private-key → client_certificate + client_key |
| UDP packet encoding | O | packet-encoding → packet_encoding (packetaddr/xudp) |
| IP Version | ? | ip-version → domain_resolver.strategy, needs --outbound-domainresolver-tag |
| Certificate pinning | O | fingerprint → certificate_sha256, re-encoded from hex to base64 |
Targets the sing-box testing branch (1.15.0-alpha). domain_strategy is deliberately
not emitted: it was deprecated in 1.12.0 and removed in 1.14.0 in favour of
domain_resolver.
mihomo's fingerprint is not a browser profile — that is client-fingerprint — but the
SHA-256 of the whole server certificate. sing-box hashes the same thing in
certificate_sha256, new in 1.15.0-alpha.8, so the two differ only in encoding and the
value carries over. A fingerprint that is not a hex digest is dropped with a warning.
$ clash2sing-box convert --help
clash2sing-box convert v0.0.1 - Convert Clash and sing-box configurations into one sing-box config
Usage
$ clash2sing-box convert <input...> [flags]
Parameters
<input...> Array<string> Input files or http(s) URLs
Flags
--output, -o String Output file path (default: stdout)
--outbound-domainresolver-tag String The name of the domain resolver, required for setting resolver strategy
--outbound-entry-geo-db String Path to a MaxMind Country mmdb; appends |IN-<CC> for each proxy's entry address
--outbound-group Array<String> An extra proxy group as "tag=regex", matched against outbound tags; repeatable [default: []]
--outbound-no-urltest Array<String> Tag(s) to keep out of every URLTest group, selectable by hand only; repeatable or comma-separated [default: []]
--outbound-selector-default String Use the n-th outbound as the default in the selector outbound (integer)
--outbound-selector-tag Array<String> The name(s) of the selector outbound(s); repeatable, or comma-separated [default: []]
Global Flags
--help, -h Boolean Show help [default: false]
--version, -V Boolean Prints current version [default: false]Inputs may be local paths or http(s) URLs, and may be Clash YAML/JSON or sing-box JSON.
Each input is classified automatically: a document with proxies is treated as Clash, and
one with log/dns/inbounds/outbounds/endpoints/route as sing-box. Multiple inputs of the
same kind are deep-merged, so a sing-box config supplies the dns, log and route
sections that the generated outbounds are merged into.
--outbound-selector-tag takes one tag per occurrence, and each occurrence may also be a
comma-separated list — the two forms may be mixed.
$ clash2sing-box convert ./tests/clash.yaml -o ./sing-box.json
$ clash2sing-box convert https://example.com/subscription.yaml ./base.json -o ./sing-box.json
$ clash2sing-box convert ./tests/clash.yaml --outbound-selector-tag proxy,streaming
$ clash2sing-box convert ./tests/clash.yaml --outbound-selector-tag proxy --outbound-selector-tag streamingEvery conversion emits a proxy selector over everything and a urltest-proxy racing the
latency-testable subset. --outbound-group adds groups on top, each collecting the
outbounds whose tag the regex accepts:
$ clash2sing-box convert ./nodes.yaml \
--outbound-group 'hk=(?<!-)\bHK\b' \
--outbound-group 'netflix=\bNF\b'The flag is repeatable and is not comma-split, because a pattern may contain a comma.
It splits on the first =, so a pattern may contain one of those too. Patterns are
compiled without flags, which makes them case-sensitive and keeps test() from carrying
lastIndex between candidates.
Two behaviours are worth knowing before you point route rules at a group:
- A group whose regex matches nothing is still emitted, as a selector aliasing
urltest-proxy. Deleting it would leave your route rules referencing an outbound that is not there, and sing-box rejects both that and a group with an empty member list. - Groups appear in the
proxyselector, ahead of the individual nodes, but never insideurltest-proxy— a race between races would skew the latency comparison the same waydirectwould.
A tag that collides with a node, a selector or urltest-proxy is an error rather than a
config sing-box will reject later.
--outbound-no-urltest keeps a tag selectable but out of every URLTest group, including
named ones — "not latency-testable" is a property of the outbound, not of one group:
$ clash2sing-box convert ./nodes.yaml ./warp-endpoint.json --outbound-no-urltest warpThis is for an outbound you pick deliberately rather than by latency. A WARP endpoint is
the usual case: it is fast enough to keep winning a race it has no business being in, and
whatever wins urltest-proxy is where unmatched traffic goes. Unlike direct and block
— excluded by type, because they structurally break a race — this is a judgement about a
specific node, so it is named by tag. A tag matching no outbound is reported as a warning
rather than ignored, since the alternative is a silent typo.
Subscription checkers rename nodes after the address traffic exits from — the half you
cannot read off the config. The entry is the server field, sitting right there
ungeolocated, and the two differ whenever a node relays. --outbound-entry-geo-db fills
that half in, appending |IN-<CC> to each Clash proxy's name:
🇭🇰HK¹⁺_3|12.3MB/s|GPT⁺|YT|NF|0%|IN-JP
└──exit──┘ └entry┘
Point it at any MaxMind Country .mmdb. Hostnames are resolved once each, in parallel,
with a short DNS timeout; a node whose entry cannot be located keeps its original name and
converts as usual, with a warning on stderr.
The hyphen in IN-JP is deliberate: it is what keeps an entry tag out of a region group
written as (?<!-)\bJP\b, the same guard that already skips exit-side qualifiers like
TK-US or -US⁰.
Only Clash input is annotated. sing-box fragments are hand-written, so their tags already
say what you meant, and rewriting them would mean chasing every detour and group
membership pointing at the old tag.
A record's country wins where present, falling back to registered_country. Anycast
ranges — Cloudflare's, and plenty of entry hosts sit behind those — carry no country at
all and would otherwise go unlabelled; but the two disagree often enough that the order
matters, an Alibaba Hong Kong address reading country: HK, registered_country: US.
Caveats: lookups go through the system resolver, so with a TUN interface up a hostname resolves the way your proxy sees it; only the first address of a multi-record host is used; and a Country database gives you a country, not a city or an ASN.
This is a command line tool, not a library: it exposes no importable entry point, and
everything under src/ is an internal free to change between versions.
# Activate pnpm via corepack (version pinned by the packageManager field)
$ corepack enable
# Install dependencies
$ pnpm installWith devenv (and direnv), the Node.js + corepack environment is provisioned automatically
on entering the project directory — see devenv.nix.
$ pnpm start convert ./tests/clash.yaml # run the CLI from source
$ pnpm test # run the test suite with coverage
$ pnpm typecheck # tsc --noEmit
$ pnpm lint # oxlint
$ pnpm format # oxfmt --write
$ pnpm build # bundle to dist/