sing-box-vpn — VPN profile manager for sing-box
A CLI wrapper around sing-box: a set of JSON profiles, share-link parser, configuration generator, systemd service, and automatic upstream failover.
A CLI wrapper around sing-box: a set of JSON profiles, share-link parser, configuration generator, systemd service, and automatic upstream failover.
sing-box-vpn is a local client-side profile manager for sing-box. The repository contains a set of scripts and a configuration template: the scripts read *.json profiles, substitute the active profile into a shared template, validate the result, and run sing-box through systemd.
At any given moment exactly one profile is active. It is substituted into a single outbound tagged proxy-out; everything else — inbounds, DNS, routing, and rule sets — lives in the shared template and does not change when the profile is switched.
The main practical use cases are:
127.0.0.1:12334 for browsers and CLI tools that support SOCKS5 with remote DNS resolution;The project does:
config.json from the template;sing-box check;The project does not:
| Component | Requirement |
|---|---|
| OS | Linux with systemd ≥ 245 |
| Kernel | 4.18+ |
| Bash | effectively mandatory (bash arrays and [[ ]] are used) |
| Python | 3.8+ (for the URL parser) |
| sing-box | 1.13.0+ (the template uses route.default_domain_resolver and action: sniff) |
| Other utilities | jq, curl, systemctl, journalctl, nft/iptables, GNU coreutils |
| Permissions | root for install, apply, test-all, on/off/restart, use/add/add-json/del |
| Network | working IPv4 or IPv6 egress; checks depend on https://cloudflare.com/cdn-cgi/trace and https://1.1.1.1/dns-query |
Tested on Arch-based and Debian-family distributions. Behavior on other distributions is not guaranteed.
~/sing-box-vpn/profiles/<name>.json
│
│ apply-profiles.sh (copies source → runtime)
▼
/etc/sing-box/profiles/<name>.json
│
│ generate-config.sh (reads template + active profile)
▼
/etc/sing-box/config.json
│
│ sing-box.service
▼
mixed/SOCKS on 127.0.0.1:12334
│
│ proxy-out → upstream (Hysteria2 / VLESS / Shadowsocks / VMess / Trojan)
▼
server
Layers:
*.json files in the repository (in practice in ~/sing-box-vpn/profiles/). Stored in Git; real secrets are in .gitignore./etc/sing-box/profiles/ with 0640 permissions. The generator reads these./etc/sing-box/active_profile with the name of the active profile./etc/sing-box/config.json, assembled from sing-box-config.json by substituting the active outbound.sing-box.service runs /usr/local/bin/sing-box run -c /etc/sing-box/config.json with hardening parameters.The sing-box-config.json template contains:
mixed-in on :: / 12334 and tproxy-in on :: / 12335 (routing for TPROXY is not configured in the project);select, direct, and the placeholder proxy-out, which is replaced with the active outbound;dns-remote (DoH 1.1.1.1 via proxy), dns-direct (the same DoH directly), dns-lan (UDP on 192.168.0.1:53), dns-system-hosts, dns-local; final = dns-remote, strategy = prefer_ipv4;private-domains rule set, bypass for a number of private CIDRs and .ru;127.0.0.1:9090 without authentication;route.default_domain_resolver = dns-remote (requires sing-box ≥ 1.13).The standard install runs from the repository directory as root:
sudo ./install.sh
The script:
/tmp/sing-box-install.log;sing-box in PATH; if missing, tries AUR, otherwise downloads the 1.11.0 binary (be aware — see "Upgrading sing-box");/var/lib/sing-box, the runtime directories, and copies the template with profiles;warp-client;sing-box check;sing-box.service;/etc/sing-box/private-domains.txt (after the service starts);SKIP_FAILOVER=1;/etc/nftables.conf;Before running install.sh the repository must already contain a runtime-ready profile (by default warp-client.json is expected). Without it the configuration generation step will fail. The final VPN OK message means only that the direct egress check succeeded and does not confirm that SOCKS is working.
Alternative install without failover:
sudo SKIP_FAILOVER=1 ./install.sh
| Path | Purpose |
|---|---|
~/sing-box-vpn/ |
Repository directory |
~/sing-box-vpn/profiles/*.json |
Source profiles (in Git) |
~/sing-box-vpn/vpn |
CLI script |
~/sing-box-vpn/failover.sh |
Failover script |
~/sing-box-vpn/generate-config.sh |
Configuration generator |
~/sing-box-vpn/sing-box-config.json |
Configuration template |
/usr/local/bin/sing-box |
sing-box binary |
/usr/local/libexec/sing-box-vpn/ |
Directory where the installer copies vpn and failover.sh |
/etc/sing-box/ |
Runtime directory (configurable via RUNTIME_DIR) |
/etc/sing-box/config.json |
Generated active configuration |
/etc/sing-box/profiles/*.json |
Runtime copies of profiles, used at runtime |
/etc/sing-box/active_profile |
Active profile name |
/etc/sing-box/private-domains.txt |
Rule set for bypass domains |
/etc/systemd/system/sing-box.service |
Systemd unit |
/etc/systemd/system/vpn-failover.{service,timer} |
Failover unit + timer |
/var/log/sing-box.log |
sing-box file log |
/var/lib/sing-box |
Directory allowed by systemd sandboxing |
/var/tmp/sing-box-vpn-broken/ |
Blacklist of temporarily broken profiles |
/tmp/sing-box-install.log |
Installer log |
| Variable | Default | Scope |
|---|---|---|
PROJECT_DIR |
directory where vpn lives |
CLI, generator, failover, installer |
RUNTIME_DIR |
/etc/sing-box |
CLI, generator, apply, installer |
PROFILES_DIR |
<PROJECT_DIR>/profiles (CLI/apply) / <RUNTIME_DIR>/profiles (generator) |
semantics differ per script |
RUNTIME_PROFILES_DIR |
$RUNTIME_DIR/profiles |
CLI, apply |
ACTIVE_FILE |
$RUNTIME_DIR/active_profile |
CLI, generator |
TEMPLATE |
$PROJECT_DIR/sing-box-config.json |
generator |
OUT |
$RUNTIME_DIR/config.json |
generator |
GEN_SCRIPT |
$PROJECT_DIR/generate-config.sh |
CLI |
CLI |
$PROJECT_DIR/vpn |
apply, test-all |
SERVICE_NAME |
sing-box |
CLI/apply/installer |
MIXED_PORT |
12334 |
CLI, failover; the port is hard-coded in the template and not substituted |
SING_BOX_BIN |
/usr/local/bin/sing-box |
installer |
SING_BOX |
/usr/local/bin/sing-box |
apply |
LOG |
/tmp/sing-box-install.log or rollback log |
installer, rollback |
SKIP_FAILOVER |
0 |
installer |
TRACE_HOST |
https://cloudflare.com/cdn-cgi/trace |
failover |
PROBE_TIMEOUT |
8 seconds |
failover |
DRY_RUN |
0 |
failover |
BROKEN_DIR |
/var/tmp/sing-box-vpn-broken |
failover |
BROKEN_TTL_SEC |
1800 |
failover |
Some variables are declared as overrides in vpn but are not actually read by the unit or the template: RUNTIME_DIR, SING_BOX_BIN, MIXED_PORT (the template always uses 12334), CONFIG_TEMPLATE/CONFIG_OUT (the generator uses TEMPLATE/OUT). Treat non-standard values of these variables as unsupported without manual edits to the unit and template.
A single profiles/<name>.json file corresponds to a single outbound. The file name must match the field inside (although vpn add-json does not verify this — see below).
The minimum set of fields:
{
"type": "hysteria2",
"server": "example.com",
"server_port": 443,
"password": "..."
}
Protocol-specific fields are supported: tls, transport, multiplex, flow, etc. They are carried into the generated outbound almost verbatim; only the service fields are excluded (name, description, type, tag, server, server_port).
sudo ./vpn add my-profile 'vless://uuid@server:443?type=tcp&security=reality#name'
Supported schemes: vless://, hy2:// / hysteria2://, ss://, vmess://, trojan://. The parser does not cover every share-link variant (WebSocket/gRPC transport, Shadowsocks plugins, non-standard schemes) — use add-json for those cases.
Passing the URL as an argument keeps the secret in shell history and briefly makes it visible in the process list.
sudo ./vpn add-json my-profile ./profile.json
Only JSON validity and the presence of type, server, server_port are verified. Full validation via sing-box check is not performed; run it manually.
In the JSON file the IPv6 address is specified without square brackets:
{ "type": "hysteria2", "server": "2001:db8::1", "server_port": 443, ... }
For an IPv6-only profile to work, the host must have an IPv6 default route. Check: ip -6 route show default.
Real profiles are excluded from Git via .gitignore. This is not encryption and is not a guarantee that secrets did not leak into commit history before being added to .gitignore. To share secrets across machines use a separate encrypted channel (gpg-agent, age, vault, etc.).
| Command | Root | What it does |
|---|---|---|
./vpn on |
yes | Starts sing-box.service via systemd; if it is already active, generates the config and runs a SOCKS trace |
./vpn off |
yes | Stops sing-box.service. nftables/iptables are not reset |
./vpn restart |
yes | systemctl restart sing-box. The config is not regenerated first |
./vpn status |
no | Shows service state, active marker, source/runtime paths, and Clash API; checks the external IP directly, not through SOCKS |
./vpn list |
no | Prints source profiles with description and the active marker |
./vpn current |
no | Prints the name of the active profile or (none) |
./vpn use <name> |
yes | Writes the active marker, generates the config, restarts the service only if it was already active; sing-box check is not run |
./vpn add <name> <URL> |
yes | Creates a source profile from a share-link; runtime and service are not touched |
./vpn add-json <name> <file> |
yes | Copies JSON as a source profile with 0644 permissions; basic validation (JSON + three required fields) |
./vpn del <name> |
yes | Removes source and runtime files; the active profile cannot be deleted; config/service are not regenerated |
./vpn test |
no | Up to three SOCKS requests to Cloudflare; expects ip=, colo=, loc= lines. Due to set -euo pipefail a failing pipeline may abort the command before retries |
./vpn help |
no | Prints help |
Profile names in mutating commands are not sanitized. The safe format is ^[A-Za-z0-9._-]+$.
In addition to vpn there are three auxiliary scripts:
sudo ./generate-config.sh — only assembles /etc/sing-box/config.json from the template and the active profile. Does not validate and does not restart the service.sudo ./apply-profiles.sh — copies source profiles to runtime, iterates over all profiles, for each changes the active marker, generates the config, and runs a SOCKS test; on full success restores the original active (or warp-client). Does not delete stale runtime files.sudo ./test-all.sh — starts the service, iterates over and tests all source profiles; does not restore the original active profile.Because these scripts use set -euo pipefail, the first failing check may abort execution before a full summary is collected. If you need the full picture, run vpn test for each profile manually.
127.0.0.1:12334 (or [::1]:12334).:: — that is, all IPv6 interfaces and, depending on the system, IPv4. Without a firewall this potentially opens the proxy wider than loopback.Verification:
curl --max-time 8 --socks5-hostname 127.0.0.1:12334 \
https://cloudflare.com/cdn-cgi/trace
--socks5-hostname is required: otherwise DNS resolution bypasses the proxy.
dns-remote — DoH https://1.1.1.1/dns-query via proxy-out (DNS traffic goes through the tunnel).dns-direct — the same DoH directly.dns-lan — UDP on 192.168.0.1:53 for local names.dns-system-hosts and dns-local — for /etc/hosts and short names.final = dns-remote, strategy = prefer_ipv4./etc/sing-box/private-domains.txt — a sing-box source JSONL rule set. By default it directs those domains to direct. The template also contains:
hijack-dns);.local, .localhost, a number of private IPv4/IPv6 CIDRs, and the .ru domain;proxy-out.For the full list of bypass domains and CIDRs see sing-box-config.json and etc-sing-box-private-domains.txt.
127.0.0.1:9090.failover.sh:
cloudflare.com/cdn-cgi/trace with a timeout of PROBE_TIMEOUT (8 seconds by default);ip=, colo=, loc= is present;/var/tmp/sing-box-vpn-broken/<profile> (TTL = BROKEN_TTL_SEC, 30 minutes);vpn list, excluding the active one and recently failed profiles;sudo vpn use <candidate> one by one and repeats the probe;1; the last tested profile may remain active.TRACE_HOST, PROBE_TIMEOUT, MIXED_PORT, LOG_TAG, DRY_RUN, BROKEN_DIR, BROKEN_TTL_SEC, VPN (path to vpn).
DRY_RUN=1 only prints which vpn use would be executed and does not check candidates.
| Code | Meaning |
|---|---|
0 |
Current profile is healthy or the switch succeeded |
1 |
No candidate worked |
2 |
No sudo or vpn not found |
System-wide timer: first run 2 minutes after boot, then every 5 minutes, persistent.
TimeoutStartSec=30s on the service. A full sweep of multiple profiles may not fit (one probe — up to 8 seconds, plus sleep 2 between candidates). With many profiles increase the timeout via a drop-in.
The standard install.sh copies vpn and failover.sh into /usr/local/libexec/sing-box-vpn. After that, vpn starts treating that directory as PROJECT_DIR, but the template and profiles/ are not there. For correct rotation, set in /etc/default/vpn-failover:
VPN=/home/alex/sing-box-vpn/vpn
The per-user instruction in docs/FAILOVER.md copies only unit files, while the unit expects the scripts in %h/.local/bin/ and %h/.local/libexec/sing-box-vpn/. Place them there manually or use the system-wide install.
For logs it is more reliable to use:
journalctl -u vpn-failover.service
rather than journalctl -t vpn-failover (the script does not call logger, and the unit does not set SyslogIdentifier).
sudo ./vpn status # service state and active profile
./vpn list # all source profiles
./vpn current # active profile name
sudo ./vpn use my-profile # switch
sudo ./vpn add new-profile 'vless://...' # add from share-link
sudo ./vpn del old-profile # remove a profile
sudo systemctl restart sing-box # apply changes outside the CLI
journalctl -u sing-box -f # live sing-box log
journalctl -u vpn-failover.service # failover log
systemctl list-timers vpn-failover.timer # timer state
The repository does not include files with shell aliases (vpnon, vpnuse, etc.) mentioned in docs/OPERATIONS.md. If you need them, add them yourself in ~/.bashrc or /etc/profile.d/.
The template targets sing-box ≥ 1.13 and uses route.default_domain_resolver and action: sniff. When upgrading to 1.14+ a DNS-schema migration will be required (see CHANGELOG.md).
Recommended sequence:
# 1. check versions
/usr/local/bin/sing-box version
sing-box version # new binary
# 2. backup
cp -a /etc/sing-box /etc/sing-box.bak
cp -a ~/sing-box-vpn ~/sing-box-vpn.bak
# 3. validate with the new binary
sudo /usr/local/bin/sing-box check -c /etc/sing-box/config.json
# 4. regenerate and restart
sudo ~/sing-box-vpn/generate-config.sh
sudo systemctl restart sing-box
# 5. verify
~/sing-box-vpn/vpn test
systemctl status vpn-failover.timer
The repo ships scripts/run-after-upgrade.sh, but it is hard-coded to a specific list of profiles and restarts the service before swapping the symlink. For production, use it only as a starting point.
~/sing-box-vpn/vpn status
~/sing-box-vpn/vpn current
~/sing-box-vpn/vpn list
systemctl status sing-box
systemctl is-active sing-box
active_profile, the runtime profile, and the generated config must agree with each other:
cat /etc/sing-box/active_profile
ls /etc/sing-box/profiles/
sudo sing-box check -c /etc/sing-box/config.json
The generator will fail if the active marker or the runtime profile are missing.
curl https://cloudflare.com/cdn-cgi/trace
curl --socks5-hostname 127.0.0.1:12334 https://cloudflare.com/cdn-cgi/trace
Compare the ip= and colo= fields. vpn status runs the direct check, not SOCKS — keep that in mind.
journalctl -u sing-box -n 200 --no-pager
journalctl -u sing-box -f
tail -f /var/log/sing-box.log
journalctl -u vpn-failover.service -n 100 --no-pager
ip -6 route show default
No default route — an IPv6-only profile will not work, even if the server itself responds.
.gitignored). Passing share-links as CLI arguments leaves the secret in shell history and briefly makes it visible in ps.vpn add inherit the user's umask; vpn add-json creates 0644. Runtime profiles receive 0640 via install.sh and apply-profiles.sh. This is not encryption and does not protect against root.:: (all IPv6 interfaces and potentially IPv4), no authentication is set. Without a firewall the proxy may be reachable beyond loopback.127.0.0.1:9090, but no authentication.sing-box.service runs as root with CAP_NET_ADMIN, CAP_NET_RAW, CAP_NET_BIND_SERVICE; NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp, ProtectKernelTunables, etc. are enabled.tls.insecure=true. Several Hysteria2 profiles disable server-certificate verification. This simplifies MITM and must be used deliberately..ru, private CIDR, private-domains bypass the VPN by design.add-json followed by sing-box check for those.[2001:db8::1]:443) in URLs is handled incorrectly; write IPv6 without brackets in JSON.vpn test, apply-profiles.sh, and test-all.sh use set -euo pipefail — the first failing check may abort the script before a full summary is collected.vpn status shows the external IP via a direct request, not through SOCKS. To check the tunnel itself use curl --socks5-hostname manually.apply-profiles.sh does not delete stale runtime files when the corresponding source profile is removed.rollback.sh — a partial emergency rollback, not an uninstall.rollback.sh disables and stops sing-box.service, removes the nftables table inet sing-box and individual policy-routing rules, attempts to remove the iptables chain SINGBOX, and tries to re-enable a previously disabled Hiddify user service. It recommends a reboot.
rollback.sh does not remove:
/etc/sing-box (config and profiles remain);vpn-failover.timer;/usr/local/bin/vpn and /usr/local/bin/vpn-failover;For a complete manual uninstall:
sudo systemctl disable --now sing-box vpn-failover.timer
sudo rm -f /etc/systemd/system/sing-box.service \
/etc/systemd/system/vpn-failover.service \
/etc/systemd/system/vpn-failover.timer
sudo systemctl daemon-reload
sudo rm -rf /etc/sing-box /usr/local/libexec/sing-box-vpn
sudo rm -f /usr/local/bin/vpn /usr/local/bin/vpn-failover
After that, revert any changes in /etc/nftables.conf made by the installer.
See the "CLI commands" section.
See the "Environment variables" section.
sing-box.service — main service.vpn-failover.service + vpn-failover.timer — automatic failover.journalctl -u sing-boxjournalctl -u vpn-failover.service/var/log/sing-box.log/tmp/sing-box-install.log