English | 简体中文
A friendly CLI shell for sing-box — proxied from boot, no GUI required, no user login required, all system traffic through a TUN proxy.
- Proxy from boot: systemd brings up the TUN interface before any user logs in. SSH, apt, every user process — all routed through the proxy.
- Zero GUI dependency: pure CLI. Runs on desktops, servers, headless boxes, containers.
- One-shot share-link import: supports
vless://vmess://trojan://ss://hysteria2://tuic:// - Hot node switch: applied instantly via sing-box's Clash API, no service restart.
- Live route mode switch:
rule/global/directwith one command. - Subscriptions:
sc sub add <url>— a refresh replaces the nodes that URL owns, so nodes the provider dropped disappear while nodes you added by hand are never touched. - Auto ruleset update: a systemd timer pulls
.srsrulesets and refreshes subscriptions at a configurable cadence. - Bilingual: English (default) and Simplified Chinese; pick at install time, switch any time with
sc lang en|zh.
- Linux with systemd or OpenRC — tested on Debian, Ubuntu, Fedora, RHEL/CentOS/Rocky/Alma, Arch/Manjaro, openSUSE, Alpine
- amd64 (x86_64) or arm64 (aarch64)
- Python 3.6+ (preinstalled on most distros)
- root (one-time sudoers setup, password-less afterwards)
- sing-box 1.12 or newer.
install.shdownloads the current release for you — but step 2 skips that download whenever anysing-boxis already onPATH, so an older binary (a distro package, an earlier manual install) stays. The generated document uses the 1.12+ DNS syntax, so an older core fails the config check at the last install step; remove the binary and re-run to replace it.
One line:
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/Alan-IFT/singbox-cli/main/install.sh)"The installer will:
- Prompt you to choose CLI language — English (default) or Simplified Chinese
- Download the sing-box binary from GitHub Releases and install it to
/usr/local/bin/sing-box - Install the
scCLI to/usr/local/bin/ - Create the service unit (systemd service + ruleset auto-update timer, or OpenRC init script on Alpine)
- Configure password-less sudo (scoped to the
sccommand only) - Download
.srsrulesets - Start sing-box and enable boot autostart
The language defaults to whatever your
$LANGenv var suggests (Chinese locale →zh, otherwiseen). Just hit Enter at the prompt to accept, or pick1/2.
Re-run the same one-liner. install.sh is idempotent: it replaces the sc CLI, the service units and the ruleset timer, and finishes by regenerating config.json and restarting the service. nodes.json is never touched, so your nodes and your active selection are preserved. settings.json is rewritten but every existing key is carried over — only lang is set, to whatever you pick at the prompt.
Re-running does not upgrade sing-box itself. Step 2 skips its download whenever a sing-box is already on PATH, so an existing binary stays at whatever version it is. To move it to the current release, remove it first and re-run — which is also the only way an already-installed host gets the checksum verification, since that runs only on a binary this installer actually downloads:
sudo rm /usr/local/bin/sing-box
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/Alan-IFT/singbox-cli/main/install.sh)"The service is down between those two commands and comes back at the end of the run. Your nodes, settings and rulesets are untouched by this.
Inspect first, then run (recommended for the cautious):
curl -fsSL https://raw.githubusercontent.com/Alan-IFT/singbox-cli/main/install.sh -o install.sh
less install.sh
sudo bash install.shgit clone (for development):
git clone https://github.com/Alan-IFT/singbox-cli.git
cd singbox-cli
sudo ./install.shEverything this tool does after it is installed works without reaching GitHub: sc add parses the share link, generates the config, asks sing-box check and restarts the service, all locally — no request leaves the machine. The problem is the bootstrap, because two things must come from GitHub first: the installer's own files and the sing-box binary.
Mirrors are built in. Every github.com / raw.githubusercontent.com URL the installer fetches is tried against the canonical host first and then against two public GitHub reverse proxies. The canonical host is first on purpose: a host that can reach GitHub never routes its bytes through a third party. A host that cannot pays one 10-second connect timeout per endpoint and then proceeds. The version lookup has three independent sources, so api.github.com — the one host no mirror carries — is no longer a single point of failure.
The sing-box binary is checksum-verified before it is installed. Its sha256 is compared against the digest GitHub publishes for that exact release asset, and the digest is fetched from api.github.com only — never through the mirror list. That is what makes it a check rather than a formality: the tarball may come from a mirror, the digest never does, so no single party supplies both. A mismatch deletes the file and ends the run; nothing is unpacked and nothing is installed.
If the digest cannot be fetched at all — the case on a host that can reach a mirror but not api.github.com — the installer says so plainly, prints the sha256 it actually downloaded, and continues. Refusing there would lock out exactly the hosts the mirror list exists for, over a check no previous version performed at all. To require a match on such a host, obtain the digest out of band and pass it in:
sudo SB_SHA256=d34d987e...c495 bash install.shThat leaves fetching install.sh itself, which happens before any of this code runs. Use a mirror for that one URL:
sudo bash -c "$(curl -fsSL https://ghfast.top/https://raw.githubusercontent.com/Alan-IFT/singbox-cli/main/install.sh)"Your own mirror, replacing the built-in list entirely (whitespace-separated; include an explicit "" to keep the canonical host as a candidate):
sudo SB_GH_MIRROR="https://mirror.example.internal/" bash install.shFully offline / air-gapped. The installer skips its only large download when a sing-box binary is already on PATH, so place one yourself and the GitHub dependency is limited to the six small artifact files (which git clone, a tarball copy or a USB stick can supply just as well):
sudo install -m 755 ./sing-box /usr/local/bin/sing-box # from any trusted source
sudo ./install.sh # step 2 reports "already installed"SB_VERSION=1.13.15 pins the version and skips the lookup entirely.
If the rulesets fail to download, the install still completes and the service still starts: sc degrades to "no splitting" — every rule referencing a missing .srs is dropped and all traffic takes the default outbound, i.e. the proxy. Add a node, confirm traffic works, then run sc update-rules; it now downloads through your own proxy and rule-based splitting comes back on the next regeneration. The ruleset mirror list is ordered by reachability for the same reason as above, and sc update-rules --mirror URL overrides it.
sc add 'vless://uuid@host:443?security=reality&pbk=...&fp=chrome&flow=xtls-rprx-vision#LosAngeles-US'
⚠️ Share links contain?&#and other shell-special characters. Wrap the link in single quotes.
A node is kept only if a configuration carrying it was accepted. If sing-box check rejects the document the link produces — an unsupported cipher, a field this sing-box build does not know — sc quotes the checker, restores nodes.json byte for byte and exits non-zero, leaving config.json untouched. The link is not stored, so it cannot fail every later sc reload / sc use / unattended ruleset-timer regeneration until someone finds it and runs sc rm.
sc ls # list all nodes, with their delay
sc now # print just the active node's tag
sc use 1 # by index
sc use US # by name fragment
sc use auto # the auto-select group (see below)Switching is applied instantly via the Clash API — no service restart.
With at least one node added, sc also emits an auto-select group tagged auto: sing-box probes every node every 3 minutes against https://www.gstatic.com/generate_204 and routes traffic to the fastest one that answers. sc use auto selects that group, so a node that slows down — or starts refusing connections — stops carrying traffic, and stops carrying DNS with it, without anyone having to type a command; the switch happens on the next probe round, which means up to about 3 minutes of failing requests before it lands, not an instant cut-over. One failure this does not cover: a node that still accepts connections and then never answers hangs the probe instead of failing it, and a probe that never finishes never revises the choice — in testing the group stayed on such a node for as long as the test ran. If traffic is dead and the group has not moved, switch by hand. sc use <name> still pins a single node exactly as before; a fresh install selects the group on the first sc add, and an existing install keeps the node it was already on until you run sc use auto.
sc ls shows the group on a row of its own, with no index number, and its address column names the node the group is on right now:
# On Type Name Address Delay
● urltest auto → JP-2 141 ms
1 vless US-1 1.1.1.1:443 210 ms
2 vless JP-2 2.2.2.2:443 141 ms
3 vless SG-3 3.3.3.3:443 -
The delay figure is not a measurement sc takes: it is a value the running sing-box already holds, produced by the group's own probing and read once over the Clash API. It therefore exists only while the group is in use — on a host pinned to a single node the group is idle, probing stops, and the column shows - everywhere (or keeps showing the last values it had). - means "no stored delay", never "0 ms" and never "unreachable"; with the service stopped sc issues no query at all and every cell is -.
If one of your own nodes is already tagged
auto, that host gets no auto-select group — two outbounds may not share a tag.sc use autothere pins that node, so theSwitched to: autoit prints does not mean failover is on. Rename the node and the group appears by itself on the nextsc reload.
sc ping # every node, now
sc ping JP # one node (name, substring or index)sc ls's delay column is a history: whatever the auto-select group's own probing last recorded, which is why a node nobody has probed shows -. sc ping takes a fresh measurement — it asks the running sing-box, through the Clash API, to fetch https://www.gstatic.com/generate_204 through each node in turn and reports the round trip. That is the same endpoint the auto-select group probes, so the two can never disagree about what "reachable" means, and the API records each result, so sc ls shows these numbers afterwards.
One line per node, printed as its answer arrives; the index column is the same number sc use takes. Each node gets 5 seconds. The service must be running (sc on) — the measurement is made by sing-box, not by sc. The command exits non-zero only when no node answered: one dead node is information, a list in which nothing answers is a run that failed.
sc export /root/nodes-backup.json # write the node list to a file
sc import /root/nodes-backup.json # merge a node list into this hostsc export writes what nodes.json holds to the path you name, at mode 600, atomically. It never prints to stdout, and no flag changes that: the document carries every password and UUID, while /etc/sudoers.d/sc lets the install user run sc without a password — printing it to a stream that user controls would hand over the whole credential set, which is exactly what sc config's unconditional masking exists to prevent. Moving the backup to another machine is your job, and it is a file full of credentials: treat it like one.
sc import merges. Nothing already on this host is removed, so an import cannot lose a node. A node that is already present — identical in everything but its name — is skipped, so importing the same backup twice is a no-op rather than a second copy of everything. A name that collides with a different node gets a #2 suffix, exactly as sc add does. The configuration is then regenerated and checked before the import is kept: if no usable configuration comes out of it, nodes.json goes back byte-for-byte and the command exits non-zero.
sc sub add 'https://example.com/sub?token=...' # fetch it now and keep its nodes
sc sub # list them (same as `sc sub show`)
sc sub update # refresh every subscription now
sc sub rm 'https://example.com/sub?token=...' # forget it, and remove its nodesA subscription is a URL whose body is a list of share links — plain text, or the base64 of one, which is what most providers serve. sc reads both, skips blank lines and # comments, and counts the lines no parser recognises rather than failing on them: one unsupported protocol in a list of forty must not cost you the other thirty-nine.
A node remembers which subscription it came from. That single fact is what makes everything below true, and it is why a refresh is a replacement rather than a merge:
- a node the provider has dropped disappears on the next refresh — merging could never express that;
- a node you added with
sc addcarries no subscription, so no subscription operation can see it, let alone remove it; - a subscription whose fetch fails keeps the nodes it last gave you. A network outage can never empty
nodes.json, and a run in which every fetch failed exits non-zero; - a name that collides — with your own node, or with another subscription's — gets a
#2suffix, exactly assc adddoes; - the new node list is composed and checked before it is kept: if no usable configuration comes out of it,
nodes.jsongoes back byte-for-byte.settings.jsonis written last, so a refusal leaves both files untouched.
Subscriptions refresh automatically, on the schedule the ruleset timer already runs — sc update-interval sets the cadence for both, and there is no second timer to configure. A refresh that finds nothing changed does not touch the service.
sc sub stores only the URL, in settings.json. If yours carries a token, that file is the one holding it — it is mode 600, root-only, like everything else sc writes.
sc mode rule # rule-based routing (default)
sc mode global # everything via proxy
sc mode direct # everything directThe mode lives in the running sing-box: its cache file carries the mode across restarts, and nothing sc writes to disk can carry it — a mode in config.json loses to the cached one anyway. So sc mode needs the service up. With sing-box stopped it changes nothing and says so, instead of recording a preference that would never take effect; start it with sc on and set the mode again.
sc telemetry block # answer every listed name "no such domain" locally (the default)
sc telemetry allow # resolve the listed names normally
sc telemetry show # print the setting and every name on the listsc ships a fixed list of 17 telemetry names. With the setting at block — the value of a host that has never set it — a query for a listed name, or for any subdomain of one, is answered by sing-box itself with NXDOMAIN and no records, in a few milliseconds, and no query is sent to any DNS server. A name is on the list only when both clauses hold: its sole function is carrying usage, diagnostic, crash or advertising-identifier data to a vendor, and blocking it disables no user-visible function of the product it belongs to. No update, activation, licensing, authentication, push-delivery, CDN-content, captcha or security-feature endpoint qualifies — which is why analytics.google.com (it also serves the Analytics console), omtrdc.net (Adobe Target delivers page content), googletagmanager.com, settings-win.data.microsoft.com and the vendors' push hosts are deliberately absent.
Matching is by label boundary: crashlytics.com covers that name and every subdomain of it at any depth, in any letter case, and does not cover notcrashlytics.com.
The setting is persisted in /etc/sing-box/settings.json; an absent key means block, so a fresh install and a host upgrading to this build behave identically, and a value that is neither block nor allow is named on stderr and treated as block. sc telemetry <value> regenerates the config and restarts sing-box only when the effective setting actually changes — otherwise it says nothing changed and names sc reload, which is what applies the setting to a config.json generated before it existed. sc telemetry show changes no setting, regenerates nothing and touches the service in no way — but, like every command except sc doctor, it still runs the ordinary start-up path first, which on a fresh host creates /etc/sing-box and /var/lib/sing-box and seeds nodes.json / settings.json.
The rule that carries the list is evaluated ahead of both routing-mode rules, so changing the route mode does not lift the rejection: sc mode global and sc mode direct change which resolver answers other names, never whether a listed name is rejected. It references no ruleset, so a host whose .srs files are missing still gets it, and it needs no usable node — a listed name is rejected on a host with no nodes at all.
The list — vendor and class for every shipped name; sc telemetry show prints the same names on the host itself:
| Name | Vendor | What it carries | Class |
|---|---|---|---|
telemetry.microsoft.com |
Microsoft | Windows diagnostics, crash and error reporting | OS diagnostics |
vortex.data.microsoft.com |
Microsoft | Windows diagnostic-data upload (DiagTrack) | OS diagnostics |
vortex-win.data.microsoft.com |
Microsoft | the Windows-specific sibling of the above | OS diagnostics |
metrics.ubuntu.com |
Canonical | the ubuntu-report installer / hardware survey |
OS diagnostics |
daisy.ubuntu.com |
Canonical | whoopsie / Apport crash-report submission | OS diagnostics |
incoming.telemetry.mozilla.org |
Mozilla | Firefox telemetry ping submission | Browser telemetry |
google-analytics.com |
Google Analytics hit collection | Analytics SDK | |
app-measurement.com |
Firebase Analytics measurement upload | Analytics SDK | |
crashlytics.com |
Firebase Crashlytics crash and session reports | Analytics SDK | |
demdex.net |
Adobe | Experience Cloud ID / Audience Manager | Analytics SDK |
scorecardresearch.com |
Comscore | audience-measurement beacons | Analytics SDK |
hm.baidu.com |
Baidu | Baidu Tongji (百度统计) web analytics | Domestic analytics SDK |
cnzz.com |
Alibaba (Umeng+/CNZZ) | CNZZ web-analytics counters and log collection | Domestic analytics SDK |
mmstat.com |
Alibaba | group usage / behaviour beacon logging | Domestic analytics SDK |
ulogs.umeng.com |
Alibaba (Umeng) | U-App analytics SDK log upload | Domestic analytics SDK |
tracking.miui.com |
Xiaomi | MIUI system usage / analytics upload | Domestic analytics SDK |
data.mistat.xiaomi.com |
Xiaomi | MiStat statistics SDK data upload | Domestic analytics SDK |
A rejection does not look like a broken network. It arrives in milliseconds and carries an rcode — status: NXDOMAIN, ANSWER: 0, the aa flag set — where a network failure gives you nothing at all until your own client's timeout expires. dig +nocookie crashlytics.com is enough to tell the two apart, and sc telemetry show tells you whether the name you are chasing is on the list at all.
If a listed name breaks an application, there are two ways out and neither needs bin/sc edited: sc telemetry allow turns the whole list off, or the recipe below restores exactly one name. Both survive sc reload.
Adding your own names, and excepting one of ours. Both are edits to /etc/sing-box/override.json (the Custom configuration section below explains how that file works). Both anchor on {"server": "hosts_dns"}, the rule that answers from the built-in hosts table: that element is emitted in both settings states and in every ruleset state, so an override written today keeps working after sc telemetry allow, and $after places your rules ahead of ours.
Add names of your own:
{
"dns": {
"rules": {
"$after": {
"match": { "server": "hosts_dns" },
"values": [
{ "action": "predefined", "rcode": "NXDOMAIN",
"domain_suffix": ["tracker.example.com", "beacon.example.net"] }
]
}
}
}
}Except one of ours — this one resolves hm.baidu.com normally while the other 16 stay rejected. Use direct_dns for a name that should be resolved domestically and remote_dns for one that should be resolved abroad:
{
"dns": {
"rules": {
"$after": {
"match": { "server": "hosts_dns" },
"values": [
{ "server": "direct_dns", "domain_suffix": ["hm.baidu.com"] }
]
}
}
}
}There is only one override.json, and one array takes only one directive. The two recipes above cannot be two separate files, and they cannot be two directives ($after and $before) in the same dns.rules object — that is refused with $after cannot be combined with other keys in the same object. If you want both, put both rules inside the one directive, the exception first so it is matched first:
{
"dns": {
"rules": {
"$after": {
"match": { "server": "hosts_dns" },
"values": [
{ "server": "direct_dns", "domain_suffix": ["hm.baidu.com"] },
{ "action": "predefined", "rcode": "NXDOMAIN",
"domain_suffix": ["tracker.example.com"] }
]
}
}
}
}What this does not do. It matches names, and only names that reach this config's DNS rules. An application that ships its own DoH/DoT resolver, or that connects to a hard-coded IP address, is unaffected by any of this — nothing here blocks anything at the IP or the route layer, and nothing here inspects traffic. The list is fixed in bin/sc and never updates itself; sc telemetry allow and the recipes above are how you change what it does on your host.
sc on # start + enable on boot
sc off # stop + disable on boot
sc default-tun on|off # boot autostart only — leaves the running service alone
sc status # service status, TUN interface, rule-set status + age, current node, egress IP
sc doctor # one-pass read-only health report (see below)
sc watchdog status # the optional policy-route watchdog (off by default, see below)
sc log -f # follow logs in real time
sc version # which build this is — reads nothing, writes nothingsc doctorOne pass, one screen, nine facts — printed in causal order, so every cause appears above the effects it can produce:
| # | Section | What it reports |
|---|---|---|
| 1 | sing-box binary | which build of sc this is, plus the resolved path of the sing-box binary and its version |
| 2 | Rule-sets | one row per .srs: usable / missing / not a rule-set file / too small / unreadable, the byte count from that same read, and how long ago the file was written — a usable rule-set older than 60 days is reported as a problem naming sc update-rules |
| 3 | Configuration | whether config.json exists, whether it is still what sc last generated, and what sing-box check says about it |
| 4 | Service | running now, and registered to start at boot — two separate facts |
| 5 | TUN interface | whether sb-tun exists, and its addresses |
| 6 | Policy routing | whether the kernel still routes through that device: the route table named by config.json carries a default route via the tun, ip rule holds no [unresolved] entry, every goto has its target rule, some rule other than the tun's own iif entry feeds the table, ip route get 1.1.1.1 really resolves through the tun — and whether systemd-networkd is configured not to delete these rules in the first place |
| 7 | Clash API | the port recorded in settings.json, whether it answers, how many of your nodes carry a stored delay and which outbound auto-select is on right now, and one name lookup answered by the running sing-box — which may answer it from its own DNS cache — with the time that took |
| 8 | Egress IP | the observed public address (queried even when the service is down) |
| 9 | File permissions | any file directly inside /etc/sing-box that grants access to group or other, and whether the directory itself is group- or other-writable — each offending path named with its mode and the command that narrows it |
A live sing-box and an existing sb-tun do not mean your traffic is proxied. sing-box's auto_route installs a policy-routing set — one route table holding the default route through the tun, plus a band of ip rule entries that send traffic into it. That band can be lost while the process keeps running and the device keeps existing: the service is active, sb-tun is up, table 2022 still holds default via … dev sb-tun, and yet ip rule is down to local / main / default plus one dangling goto … [unresolved]. Nothing routes into the table any more, so every local connection quietly takes the physical interface — a silent direct leak, with sc status and every pre-0.6.0 sc doctor row still green. Section 6 is the check that sees it; sc reload reinstalls the rules.
Who deletes them, and why this is prevented rather than merely detected. The known culprit is systemd-networkd: its ManageForeignRoutingPolicyRules= defaults to yes, which means networkd takes ownership of routing policy rules created by other programs and removes the ones it does not recognise — sing-box's are exactly that. Any networkd reload (a network change, a resume from suspend, a systemctl restart systemd-networkd) can therefore wipe them. The same thing happens to other VPNs; see netbirdio/netbird#4578.
sing-box cannot recover on its own: its Linux TUN backend installs these rules once at start-up and never re-checks them — unlike its own nftables path, which reconciles. Tailscale hit the identical problem and solved it by subscribing to netlink rule-deletion events and reinstalling (tailscale@b3af74e, #10857).
So install.sh drops in /etc/systemd/networkd.conf.d/singbox-cli-keep-foreign-rules.conf containing ManageForeignRoutingPolicyRules=no, which stops the deletion at its source. It is installed only where systemd-networkd exists, never starts it, and sc uninstall removes it again. Section 6's last row reports whether that protection is in place — and reports its absence as a problem even while the rules are currently intact, because such a host will lose them again at the next networkd reload.
The interface name and the table index are read from config.json, not hard-coded, so an override.json that sets interface_name or iproute2_table_index is diagnosed against its own values. Section 6 issues three ip queries and no commands: ip route get selects a route from the kernel's tables and prints it, transmitting nothing, so this check reaches no network and works on a host whose proxy is down.
Every row is marked [OK], [PROBLEM] or [UNKNOWN] ([正常] / [异常] / [未知] under sc lang zh), so sc doctor | grep '^\[PROBLEM\]' lists exactly what is wrong. [UNKNOWN] means the check could not run at all — a missing tool, a permission denial — never "the thing being checked is broken". One failing check never ends the run: all nine sections are always printed.
sc doctor changes nothing. It writes no config, downloads nothing, and never starts, stops, restarts, enables or repairs anything. Unlike every other subcommand it does not even create /etc/sing-box or persist a Clash API port on first run — on a broken or fresh machine the emptiness of those paths is often the diagnosis, and a diagnostic must not destroy the evidence it was run to collect. The one thing it asks of the outside world is section 7's name lookup (section 6's ip route get is a local table lookup and reaches nothing): the command itself still touches no path, but the resolution is performed by the running sing-box, which may record it in its own DNS cache (/var/lib/sing-box/cache.db) exactly as it would any other query — and may equally answer a later query from that cache, which is why the row names the cache as a possible source rather than claiming the name was resolved upstream on this query. It is safe to run repeatedly, concurrently, and as the very first thing after a failure.
Exit status:
| Exit | Meaning |
|---|---|
0 |
every section OK |
1 |
at least one [PROBLEM] — any section: a missing binary, an unusable or stale rule-set, a config.json changed outside sc, a failed config check, a stopped or non-autostarting service, a missing TUN device, an incomplete policy-routing set (a direct-leak risk), an unanswered Clash API port, no node carrying a stored delay, a name lookup that produced no answer, a failed egress query, a credential file or a configuration directory open to group or other |
2 |
no [PROBLEM], but at least one [UNKNOWN] — a check could not run: no sing-box binary to check the config with, no record of what sc last generated, no init system detected, ip missing, a config.json that cannot be read or that installs no policy routing at all, no Clash API port recorded in settings.json (which also leaves the node-delay and DNS rows unprobed), a nodes.json that cannot be read, or a configuration directory that is absent or cannot be listed |
sc watchdog on # install + enable the systemd timer
sc watchdog status # timer state, last check, last result, last automatic repair
sc watchdog off # disable and stop itThis is a fallback, not the fix. The known deleter is handled at its source by the networkd drop-in described above, which costs nothing and drops no connections. This watchdog only notices the damage afterwards and undoes it by restarting sing-box, which interrupts every live connection. It exists because that drop-in covers one deleter and not every deleter — a hand-run ip rule flush, another VPN's teardown, a manager nobody has met yet. If you find yourself needing it regularly, something else is deleting your rules, and sc doctor's rule-protection row is where that investigation starts.
Off by default. Nothing installs, enables or starts it until you run sc watchdog on — a component that restarts a network service has to be something you chose. It is systemd only; on any other init system the command says so and does nothing rather than failing quietly.
It repairs exactly one fault: the policy-routing set described above having gone missing while sing-box still runs. A systemd timer (OnBootSec=2min, OnUnitActiveSec=1min) runs a oneshot unit that asks the same question section 6 asks, and:
- it judges kernel state only —
ip rule,ip route. It never contacts a website, never resolves a name and never measures a node, so an unreachable site, a slow DNS server or a failing node cannot make it restart anything. Node quality is a separate question with a separate answer:sc ping; - one failing check is not enough. It re-checks after ~5 seconds and acts only if both fail — sing-box rewrites its own rules during a reload, and a single sample taken mid-reload looks exactly like the fault;
- the repair is
systemctl restart sing-box, notsc reload: the configuration was never wrong, the kernel state was, so there is nothing to regenerate; - at most one restart every 5 minutes. A host that comes back still broken stays broken and visible in the journal rather than being restarted every minute forever;
sc offstands. On a host where sing-box is stopped and disabled, the watchdog records that it stood down and starts nothing;- a healthy host is silent — no restart, and no log line per minute;
- after a restart it re-checks the kernel and logs whether the rules actually came back, exiting non-zero when they did not.
An automatic repair briefly interrupts connections, exactly as sc reload does. That is the trade: a few dropped connections against traffic leaving unproxied until someone notices.
journalctl -u sing-box-route-watchdog.service # what it did, and whyDiagnosing and repairing by hand, in the order worth trying:
sc doctor # section 6 names the condition that failed
ip rule show # the rule band; look for [unresolved] and for a missing 9010 nop
ip route get 1.1.1.1 # must resolve via sb-tun, not via your physical NIC
sc reload # regenerate + restart, which reinstalls the rulessc configPrints /etc/sing-box/config.json — the document sing-box is actually running — with every node credential masked. Redaction is unconditional: no flag, setting or environment variable prints an unmasked value, because sc runs under a password-less sudo rule scoped to itself, so an opt-out would turn a root-only read of a 0600 credential file into a password-free one. The unredacted document stays reachable exactly one way: reading the file as root.
What is masked:
- Inside
outbounds, at every depth: every key that is not in the visible key set —type,tag,server,server_port,detour, the transport / TLS / Reality / obfs fields and the protocol-tuning and auto-select-group settings. Souuid,password,public_keyandshort_idare masked, and so is any keyscnever emits, including one introduced by an outbound you added throughoverride.jsonor by a future sing-box version. - Everywhere in the document:
password,uuid,secret,token,private_key,pre_shared_key.
A masked value is always the same literal, ******. Only the value is replaced — the key stays — so which fields are configured stays visible while their contents do not.
The document goes to stdout and everything sc says about it goes to stderr, so sc config > current.json yields a JSON document a parser accepts — whenever stdout's encoding can represent that document — and sc config | grep -n server_name works. On a stdout that cannot represent it (a non-UTF-8 locale, or PYTHONIOENCODING set to a narrower codec), a character sc cannot encode is written as a backslash escape rather than ending the run: the whole masked document still reaches stdout and the command still exits 0. Which escape appears is decided by the character — \xNN for one in the Latin-1 range, \uNNNN for one elsewhere in the BMP (the CJK case), \UNNNNNNNN for one above the BMP — and of those three only \uNNNN is a JSON escape. A saved file whose escapes are all of that form is therefore still valid JSON; one carrying a \xNN or a \UNNNNNNNN is not. In every case, running the command under a UTF-8 stdout is what gets you the document unescaped. The stderr notes give the file's absolute path, state that credentials are masked, and — when a drift record exists — say whether the document on disk is what sc last generated or has been changed since.
sc config writes nothing. No file is created, modified or removed anywhere, not even /etc/sing-box itself on a host that does not have it; it downloads nothing, starts nothing, touches no service, and forms no opinion about whether the configuration is valid — that is sc doctor's answer.
The limit. The mask covers the credentials sc itself writes. A secret you place in your own /etc/sing-box/override.json outside the outbounds array, under a key that is not one of the six names above — an inbound user's auth_token, say — is printed verbatim. Check your own override before pasting the output anywhere.
sc update-rules # update once now
sc update-rules --mirror <base-url> # force a specific mirror (repeatable)
sc update-interval daily # update every day
sc update-interval weekly # update every week (default)
sc update-interval 'Mon *-*-* 04:00:00' # every Monday at 04:00
sc update-interval show # show current cadence + next runsc update-rules tries several mirrors in order (jsDelivr → testingcf → ghfast → raw.githubusercontent) and validates every download before installing it, so a truncated body or an HTML error page is never written to /etc/sing-box/rules/. Progress is shown while downloading on a terminal; redirected output keeps one completion line per ruleset.
--mirror replaces the built-in mirror list (it does not fall back to it), is repeatable, and one value may hold several whitespace-separated URLs. The SB_RULES_BASE="<url> [url...]" environment variable does the same, but only when sc already runs as root (the systemd timer, a root shell) — from a normal shell sc re-execs itself through sudo, whose default env_reset drops the variable. Prefer --mirror.
A ruleset that cannot be downloaded is no longer fatal. The generated config drops that ruleset and every routing rule referencing it, warns which ones are unusable and why, and the service still starts — you lose routing granularity, not connectivity. Run sc update-rules (or sc reload once the files are in place) and the full rules come back automatically.
sc lang en # English
sc lang zh # 简体中文The setting is persisted in /etc/sing-box/settings.json and applies to all subsequent sc output (errors, status, help).
sc helpboot
└─ systemd starts sing-box (root)
├─ reads /etc/sing-box/config.json
├─ creates the sb-tun interface (172.19.0.1/30)
├─ connects to nodes directly (no user login required)
└─ loads local .srs rulesets
↓
all system traffic through the proxy (incl. SSH pre-login, GDM login screen)
User runs the sc CLI:
└─ edits /etc/sing-box/nodes.json or settings.json
└─ regenerates config.json
└─ Clash API tells sing-box to apply changes (no restart)
| Purpose | Path |
|---|---|
| sing-box binary | /usr/local/bin/sing-box |
| sc CLI | /usr/local/bin/sc |
| sing-box config (auto-generated) | /etc/sing-box/config.json (mode 600) |
| Node list (with credentials) | /etc/sing-box/nodes.json (mode 600) |
| Settings | /etc/sing-box/settings.json (mode 600) |
| Your own config overrides (optional, yours) | /etc/sing-box/override.json |
Record of what sc last generated (internal) |
/etc/sing-box/.config.sha256 |
| Rulesets | /etc/sing-box/rules/*.srs |
| systemd service | /etc/systemd/system/sing-box.service (systemd only) |
| Auto-update timer | /etc/systemd/system/sing-box-rules-update.timer (systemd only) |
| Auto-update cadence override | /etc/systemd/system/sing-box-rules-update.timer.d/override.conf (systemd only) |
| Policy-rule protection (stops systemd-networkd deleting them) | /etc/systemd/networkd.conf.d/singbox-cli-keep-foreign-rules.conf (only where systemd-networkd exists) |
Route watchdog units (only after sc watchdog on) |
/etc/systemd/system/sing-box-route-watchdog.{service,timer} (systemd only) |
| Route watchdog state (last check / result / repair) | /var/lib/sing-box/watchdog.json |
| OpenRC service | /etc/init.d/sing-box (OpenRC/Alpine only) |
| Periodic update scripts | /etc/periodic/{daily,weekly,monthly}/singbox-update-rules (OpenRC/Alpine only) |
| Password-less sudo | /etc/sudoers.d/sc |
| Uninstall script | /usr/local/lib/singbox-cli/uninstall.sh |
| Logs | journalctl -u sing-box or sc log (systemd); sc log reads /var/log/sing-box/ on OpenRC |
config.json is generated: sc reload, sc add and sc rm rewrite it from scratch every time, and sc use and sc update-rules may do so as well, so anything you hand-edit there is discarded without a word. Put your changes in /etc/sing-box/override.json instead. sc never creates, writes or deletes that file, and applies it last — over everything sc composes — so it survives every regeneration and survives re-running install.sh.
An override that is absent, empty, or {} changes nothing. One that cannot be applied stops the command before anything is written: config.json is left exactly as it was, the running service is not touched, and the message names the file and the problem.
Three defaults worth knowing about before you write one. This build does not do IPv6: AAAA (query type 28), and the SVCB / HTTPS types 64 and 65, are answered with an empty NOERROR locally, always — no resolver is asked, and there is no setting or command that changes it. Stated plainly, the consequence is that a node whose address resolves only over AAAA is unreachable here. The same stance holds at the connection layer: the TUN carries a ULA IPv6 address purely so that on a dual-stack host IPv6 traffic enters it instead of bypassing the proxy, and a route rule answers every IPv6 connection with an immediate reset — an address obtained around this DNS (an app's own DoH, a hardcoded literal) fails in milliseconds and the application's own dual-stack fallback retries over IPv4, instead of leaking your real address or hanging where you cannot see why. Link-scoped LAN traffic (fe80::, multicast) never follows the default route into the TUN and is unaffected. The generated document also rejects UDP port 443 for every destination — that pushes QUIC / HTTP-3 back onto TCP so the routing rules can see it, and it applies to direct domestic traffic too, not only to proxied traffic. And its DNS is domestic-first: 119.29.29.29 for local names, plus a direct-resolved allow-list of Chinese DoH hosts. That is the right default inside mainland China and a suboptimal one outside it. The last two live in CONFIG_BASE in bin/sc, and the AAAA rule is put at the head of dns.rules by _dns_overlay(); changing any of them from here means $replace-ing the whole route.rules, dns.servers or dns.rules array, because an array changes as a whole or not at all — and undoing the IPv6 stance takes route.rules and inbounds together, because the address and the rule are halves of one decision.
Objects merge by depth. A key you do not mention keeps its value and its position:
{ "log": { "level": "debug" } }→ only log.level changes; every other key of log, and its position, stays as it was.
Arrays change only under an explicit directive, because "add one DNS rule" and "replace every DNS rule" must never look the same:
| Directive | Effect |
|---|---|
$replace |
the array becomes exactly the value you give |
$prepend |
your elements go in front of the existing ones |
$append |
your elements go after the existing ones |
$before |
your elements go immediately before the element matched by match |
$after |
your elements go immediately after the element matched by match |
$before / $after take {"match": {…}, "values": […]}. match selects by subset equality — every key/value in it must equal the element's — and must match exactly one element; zero or several is an error, never a silent no-op. Anchors rather than numeric indices, because sing-box evaluates dns.rules and route.rules in order and an index is wrong the moment anything inserts earlier.
A key whose current value is an array therefore accepts only a directive object: an object, a scalar, null or a bare array written there is an error naming the five directives, never a silent replacement. To empty an array, use $replace with [].
Example — insert an AAAA-suppressing DNS rule immediately after the clash_mode: Direct rule:
{
"dns": {
"rules": {
"$after": {
"match": { "clash_mode": "Direct" },
"values": [
{ "action": "predefined", "rcode": "NOERROR", "query_type": [28] }
]
}
}
}
}Values you insert are copied verbatim: nothing inside them is re-interpreted, so an inserted rule carrying its own rule_set or domain_suffix array is emitted exactly as written. A bare array where the generated config already has one is refused with a message naming the directives; a bare array at a key the generated config does not have is simply accepted and creates it.
scdepends on parts of the config it generates. Removingexperimental.clash_api.external_controller, or renaming theproxyoutbound, yields a filesing-box checkstill accepts whilesc useandsc statusstop working.scdoes not stop you — it is your file.
If you already hand-edited config.json, the next command that regenerates it prints one line on stderr: that the file was changed outside sc, that the change is about to be replaced, and where to put it so it lasts. The comparison is against /etc/sing-box/.config.sha256, a digest of what sc last wrote; a host that has never run this version has no record yet, so nothing is printed until after its first regeneration.
Pick any:
sc uninstall # easiest, on installed systems
sudo ./uninstall.sh # in the repo dir
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/Alan-IFT/singbox-cli/main/uninstall.sh)" # one-line remoteThis wipes the service unit, /etc/sing-box/ (incl. nodes), /var/lib/sing-box/, /var/log/sing-box/, sudoers, /usr/local/bin/sc, /usr/local/lib/singbox-cli/. Then it asks whether to also remove the sing-box binary — answer y for truly zero residue.
nodes.jsoncontains node credentials/UUIDs, mode 600, root-only readable.config.jsonis generated fromnodes.jsonand embeds the same credentials, so it is mode 600 too. Both are written to a fresh file that is already mode 600 before its first byte, then moved into place, so neither is ever readable by anyone but root — not even for the instant it is being written. One consequence:sing-box check -c /etc/sing-box/config.jsonnow needs root, as it should.scuses sudoers NOPASSWD, scoped to/usr/local/bin/sconly.scis owned by root, regular users cannot modify it, so NOPASSWD cannot be bypassed.- For multi-user machines, consider switching NOPASSWD back to password-required.
Before opening a PR, run python3 selftest.py from the repo root. One file, standard library only, no framework and no root: it loads bin/sc as a module, repoints every path constant into a temporary directory, and touches nothing else on the machine. It uses the sing-box binary and /etc/sing-box/rules/*.srs when the host has them — to prove the emitted document is one sing-box actually accepts — and names every check it had to skip when it does not.
PRs welcome. Top priorities:
- Subscription link auto-update
- urltest support beyond selector (auto-pick the fastest node)
- RHEL / Fedora / Arch family support
-
sc pingfor node latency testing - Node import/export (JSON backup)
MIT — see LICENSE.