Overview
sing-box-vpn is a local manager for client profiles of sing-box. The repository holds a small 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.
Exactly one profile is active at any moment. It is substituted into a single outbound with the tag proxy-out; everything else — inbounds, DNS, routing, rule sets — lives in the shared template and does not change between profile switches.
Practical scenarios:
- local SOCKS / mixed proxy on
127.0.0.1:12334for browsers and CLI tools that support SOCKS5 with remote DNS resolution; - switching between several outbound servers (different providers, protocols, IPv4/IPv6);
- automatic failover to a backup profile when the upstream becomes unreachable, through a systemd timer.
Who this is for: people who keep VPN profiles in git as plain JSON files, do not want a GUI, run Linux with systemd, and are fine with a CLI. If you want Nekobox / Nekoray / Hiddify with per-app split tunneling and a tray indicator, this is the wrong tool.
Quick start
Minimum scenario: you have a share-link and sing-box 1.13+, and you want a local SOCKS proxy in five minutes.
# 1. Clone
git clone https://github.com/alexk136/sing-box-vpn ~/sing-box-vpn
cd ~/sing-box-vpn
# 2. Add a profile from a share-link
sudo ./vpn add my-profile 'vless://uuid@server:443?type=tcp&security=reality#name'
# 3. Install (root, ~30 seconds)
sudo ./install.sh
# 4. Verify that SOCKS actually proxies traffic
curl --socks5-hostname 127.0.0.1:12334 https://cloudflare.com/cdn-cgi/trace | grep -E '^(ip|colo|loc)='
In step 4 the IP in the response must match the VPN server's IP, not your ISP's. If it does not, see §Diagnostics. For non-standard share-links (WebSocket / gRPC / H2) use vpn add-json with a ready JSON instead of vpn add.
What it is and what it is not
The project does:
- store outbound profiles as plain JSON files;
- pick the active profile and render the final
config.jsonfrom the template; - run and control sing-box through systemd;
- validate the configuration via
sing-box check; - expose a local mixed / SOCKS endpoint and a Clash API on loopback;
- optionally switch the profile when upstream fails.
The project does not:
- deploy a VPN server or generate server-side configs (Hysteria2, VLESS, Xray, etc.);
- create a TUN/TAP interface or proxy system traffic automatically;
- ship policy-routing / nftables rules for transparent proxying (a TPROXY inbound is present in the template, but routing for it is not configured);
- provide a GUI, traffic indicator, or per-app split tunneling;
- manage mobile, macOS, and Windows clients — for those use external apps (Nekoray, v2rayN, etc.).
Requirements
| Component | Requirement |
|---|---|
| OS | Linux with systemd ≥ 245 |
| Kernel | 4.18+ |
| Bash | essentially required (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; the probes depend on https://cloudflare.com/cdn-cgi/trace and https://1.1.1.1/dns-query |
Tested on Arch-based and Debian-family distributions. Behaviour on others is not guaranteed.
Architecture and data flow
~/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:
- Source profiles — canonical
*.jsonin the repository (in practice under~/sing-box-vpn/profiles/). Stored in Git, real secrets are gitignored. - Runtime profiles — copies in
/etc/sing-box/profiles/with mode0640. The generator reads them. - Active marker — file
/etc/sing-box/active_profilecontaining the name of the active profile. - Generated config —
/etc/sing-box/config.json, assembled fromsing-box-config.jsonby substituting the active outbound. - Systemd unit —
sing-box.serviceruns/usr/local/bin/sing-box run -c /etc/sing-box/config.jsonwith hardening flags.
The sing-box-config.json template contains:
- two inbounds:
mixed-inon::/12334andtproxy-inon::/12335(routing for TPROXY is not configured by the project); - a
selectselector,direct, and aproxy-outplaceholder that is replaced by the active outbound; - DNS stack:
dns-remote(DoH 1.1.1.1 via the proxy),dns-direct(the same DoH directly),dns-lan(UDP to192.168.0.1:53),dns-system-hosts,dns-local;final = dns-remote,strategy = prefer_ipv4; - routes: sniff, hijack of port 53, reject loopback:9090, bypass for
private-domainsrule set, bypass for a number of private CIDRs and.ru; - Clash API on
127.0.0.1:9090without authentication; route.default_domain_resolver = dns-remote(requires sing-box ≥ 1.13).
Installation
The standard install runs from the repository directory as root:
sudo ./install.sh
The script:
- requires root and writes a log to
/tmp/sing-box-install.log; - looks for
sing-boxinPATH; if missing it tries AUR, otherwise downloads the 1.11.0 binary (see "Upgrading sing-box"); - creates
/var/lib/sing-box, runtime directories, and copies the template with profiles; - if the active marker is missing, picks
warp-client; - generates the configuration and runs it through
sing-box check; - installs and starts
sing-box.service; - creates
/etc/sing-box/private-domains.txt(after the service starts); - by default installs and enables failover; disable with
SKIP_FAILOVER=1; - enables nftables and saves the current ruleset to
/etc/nftables.conf; - finishes with a direct curl probe of the external IP without SOCKS.
Before install.sh runs, the repository must already contain a runtime-usable profile (by default, warp-client.json is expected). Without it the configuration generation step fails. The final VPN OK message only confirms the direct egress probe — it does not prove SOCKS works.
Alternative install without failover:
sudo SKIP_FAILOVER=1 ./install.sh
Files and directories
| 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 |
Profile copies used by the runtime |
/etc/sing-box/active_profile |
Name of the active profile |
/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 |
File log of sing-box |
/var/lib/sing-box |
Directory allowed by the systemd sandbox |
/var/tmp/sing-box-vpn-broken/ |
Blacklist of temporarily broken profiles |
/tmp/sing-box-install.log |
Installer log |
Environment variables
| Variable | Default | Where it takes effect |
|---|---|---|
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 between scripts |
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 template hard-codes the port |
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 documented as override hooks in vpn but the unit and template do not actually honour them: RUNTIME_DIR, SING_BOX_BIN, MIXED_PORT (the template always uses 12334), CONFIG_TEMPLATE / CONFIG_OUT (the generator uses TEMPLATE / OUT). Non-standard values of those variables are unsupported without manual edits to the unit and template.
Profiles
JSON schema
A single profiles/<name>.json file maps to one outbound. The file name should match the field inside it (although vpn add-json does not check this — see below).
Minimum required fields:
{
"type": "hysteria2",
"server": "example.com",
"server_port": 443,
"password": "..."
}
Protocol-specific fields (tls, transport, multiplex, flow, etc.) are carried into the generated outbound mostly verbatim; only the service fields (name, description, type, tag, server, server_port) are excluded.
Adding from a URL
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) — for those use add-json.
Passing the URL on the command line keeps the secret in shell history and momentarily exposes it in ps.
Adding from JSON
sudo ./vpn add-json my-profile ./profile.json
Only JSON validity and the presence of type, server, server_port are checked. The full sing-box check validation is not run; run it manually.
IPv6 servers
In the JSON file, an IPv6 address is given 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.
Storing secrets
Real profiles are gitignored. This is not encryption and not a guarantee that the secrets are not in the commit history before being added to gitignore. For sharing between machines use a separate encrypted channel (gpg-agent, age, vault, etc.).
CLI commands
| Command | Root | What it does |
|---|---|---|
./vpn on |
yes | Starts sing-box.service through systemd; if it is already active, regenerates the config and runs the SOCKS probe |
./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 the 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 active profile name or (none) |
./vpn use <name> |
yes | Writes the active marker, regenerates 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 untouched |
./vpn add-json <name> <file> |
yes | Copies the JSON as a source profile with mode 0644; basic validation (JSON + three required fields) |
./vpn del <name> |
yes | Deletes the source and runtime file; the active profile cannot be deleted; the config and service are not regenerated |
./vpn test |
no | Up to three SOCKS requests to Cloudflare; expects ip=, colo=, loc= lines. Because of set -euo pipefail, a failed pipeline may abort the command before retries |
./vpn help |
no | Prints the help text |
Profile names in mutating commands are not sanitised. The safe format is ^[A-Za-z0-9._-]+$.
Applying and testing profiles
In addition to vpn, there are three helper scripts:
sudo ./generate-config.sh— only assembles/etc/sing-box/config.jsonfrom the template and the active profile. Does not validate or restart the service.sudo ./apply-profiles.sh— copies the source profiles to runtime, iterates through every profile, swaps the active marker for each, regenerates the config, and runs the SOCKS test; on full success restores the original active profile (orwarp-client). Stale runtime files are not removed.sudo ./test-all.sh— starts the service, iterates and tests every source profile; does not restore the original active profile.
Because of set -euo pipefail in those scripts, the first failed check can abort execution before the full summary is collected. If you need the complete picture, run vpn test per profile by hand.
SOCKS, DNS and routing
SOCKS endpoint
- Protocol: mixed (SOCKS4, SOCKS5, HTTP CONNECT).
- Default address:
127.0.0.1:12334(or[::1]:12334). - The inbound in the template listens on
::— all IPv6 interfaces and, depending on the system, IPv4 as well. Without a firewall, this potentially opens the proxy wider than loopback. - Authentication on the inbound is not configured.
Check:
curl --max-time 8 --socks5-hostname 127.0.0.1:12334 \
https://cloudflare.com/cdn-cgi/trace
--socks5-hostname is required: without it DNS resolution happens outside the proxy.
DNS
dns-remote— DoHhttps://1.1.1.1/dns-querythroughproxy-out(DNS traffic goes through the tunnel).dns-direct— the same DoH directly.dns-lan— UDP to192.168.0.1:53for local names.dns-system-hostsanddns-local— for/etc/hostsand short names.final = dns-remote,strategy = prefer_ipv4.
Bypass
/etc/sing-box/private-domains.txt is a sing-box source-format JSONL rule set. By default it routes the listed domains to direct. The template also includes:
- hijack of DNS (port 53 →
hijack-dns); - reject loopback:9090;
- direct for
.local,.localhost, a number of private IPv4/IPv6 CIDRs, and.ru; - everything else →
proxy-out.
The full bypass list is in sing-box-config.json and etc-sing-box-private-domains.txt.
Clash API
- Address:
127.0.0.1:9090. - Authentication is not configured.
- This is the control plane (rule inspection, live traffic), not another proxy endpoint.
Automatic failover
Algorithm
failover.sh:
- probes the active profile through a SOCKS request to
cloudflare.com/cdn-cgi/tracewithPROBE_TIMEOUT(8 seconds by default); - success — at least one of the lines
ip=,colo=,loc=is present; - on failure, records the timestamp in
/var/tmp/sing-box-vpn-broken/<profile>(TTL =BROKEN_TTL_SEC, 30 minutes); - lists candidates via
vpn list, excluding the active and the recently-broken; - runs
sudo vpn use <candidate>one at a time and repeats the probe; - the first successful profile stays active;
- if all candidates fail — returns
1; the last one tried may stay active.
Variables
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 run and does not probe candidates.
Exit codes
| Code | Meaning |
|---|---|
0 |
The current profile is healthy or the switch succeeded |
1 |
No candidate worked |
2 |
No sudo or vpn not found |
Systemd
System-wide timer: first run 2 minutes after boot, then every 5 minutes, persistent.
TimeoutStartSec=30s on the service. A full rotation across several profiles may not fit (one probe — up to 8 seconds, plus sleep 2 between candidates). For many profiles, increase the timeout through a drop-in.
Install specifics
The standard install.sh copies vpn and failover.sh to /usr/local/libexec/sing-box-vpn. After that, vpn treats that directory as PROJECT_DIR, but the template and profiles/ are not there. For rotation to work, set in /etc/default/vpn-failover:
VPN=~/sing-box-vpn/vpn
The per-user instructions in docs/FAILOVER.md copy only the unit files, while the unit expects the scripts under %h/.local/bin/ and %h/.local/libexec/sing-box-vpn/. Place them there by hand, or use the system-wide install.
For reliable logs:
journalctl -u vpn-failover.service
not journalctl -t vpn-failover (the script does not call logger, and the unit does not set SyslogIdentifier).
Day-to-day operations
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 # sing-box logs in real time
journalctl -u vpn-failover.service # failover logs
systemctl list-timers vpn-failover.timer # timer status
The repository does not include shell aliases (vpnon, vpnuse, etc.) that docs/OPERATIONS.md references. If needed, add them to ~/.bashrc or /etc/profile.d/.
Upgrading sing-box
The template is built for sing-box ≥ 1.13 and uses route.default_domain_resolver and action: sniff. Upgrading to 1.14+ will require a DNS schema migration (see CHANGELOG.md).
Recommended order:
# 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
There is a scripts/run-after-upgrade.sh, but it is hard-coded to a specific profile list and restarts the service before swapping the symlink. For production, treat it only as a starting point.
Diagnostics
State
~/sing-box-vpn/vpn status
~/sing-box-vpn/vpn current
~/sing-box-vpn/vpn list
systemctl status sing-box
systemctl is-active sing-box
Runtime consistency
active_profile, the runtime profile, and the generated config must agree:
cat /etc/sing-box/active_profile
ls /etc/sing-box/profiles/
sudo sing-box check -c /etc/sing-box/config.json
The generator fails if the active marker or the runtime profile are missing.
Direct vs SOCKS probe
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 does a direct probe, not through SOCKS — keep that in mind.
Logs
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
IPv6
ip -6 route show default
No default route — an IPv6-only profile will not work, even if the server itself responds.
Limitations and security
Limitations
- The URL parser does not cover every share-link variant (WebSocket/gRPC/H2 transport, Shadowsocks plugins, non-standard schemes). Use
add-jsonfollowed bysing-box checkfor those. - Bracketed IPv6 (
[2001:db8::1]:443) in the URL is parsed incorrectly; in JSON write IPv6 without brackets. vpn test,apply-profiles.sh, andtest-all.shuseset -euo pipefail— the first failed check may abort the script before the full summary is collected.vpn statusshows the external IP through a direct request, not through SOCKS. For a true tunnel check, use a manualcurl --socks5-hostname.apply-profiles.shdoes not remove stale runtime files when the corresponding source profile is deleted.rollback.shis a partial emergency rollback, not an uninstall.- Behaviour on distributions outside Arch-based and Debian-family has not been verified.
- sing-box 1.11 (which the installer may download) is incompatible with the current template.
- sing-box 1.14+ will require a DNS schema migration.
- Kill switch is not implemented. If sing-box crashes, apps that are not configured to use SOCKS will continue with direct network access.
- DPI bypass or unblocking is not guaranteed — that depends on the chosen protocol and server.
Security
- Secrets in profiles. Passwords, UUIDs, and private keys live in JSON files in Git (unless gitignored). Passing the share-link on the CLI leaves the secret in shell history and makes it briefly visible in
ps. - File permissions. Source profiles from
vpn addinherit the user's umask; fromvpn add-jsonthey get0644. Runtime profiles get0640viainstall.shandapply-profiles.sh. This is not encryption and does not protect against root. - Mixed inbound. Listens on
::(all IPv6 interfaces and potentially IPv4), with no authentication. Without a firewall, the proxy can be reachable beyond loopback. - Clash API. Bound to
127.0.0.1:9090, but no authentication. - Systemd hardening.
sing-box.serviceruns as root withCAP_NET_ADMIN,CAP_NET_RAW,CAP_NET_BIND_SERVICE;NoNewPrivileges,ProtectSystem,ProtectHome,PrivateTmp,ProtectKernelTunablesand similar flags are enabled. tls.insecure=true. Several Hysteria2 profiles disable server certificate verification. This simplifies MITM and must be used deliberately.- DNS leaks. No-leak guarantee is only possible for apps that use the proxy and do not run their own DoH/DoT. The system layer (systemd-resolved, NetworkManager) may go around the proxy.
- Bypass by design.
.ru, private CIDR, andprivate-domainsbypass the VPN — this is a feature, not a bug, but the reader should be aware.
Rollback and removal
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);- source profiles;
- the systemd unit;
vpn-failover.timer;/usr/local/bin/vpnand/usr/local/bin/vpn-failover;- it does not guarantee restoring the original network configuration.
For full manual removal:
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
Afterwards, revert the changes in /etc/nftables.conf if the installer made them.