user@elrise.io:~/sing-box-vpn
· [active]

sing-box-vpn — VPN profile manager for sing-box

→ репозиторий

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:12334 for 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.json from 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:

  1. Source profiles — canonical *.json in the repository (in practice under ~/sing-box-vpn/profiles/). Stored in Git, real secrets are gitignored.
  2. Runtime profiles — copies in /etc/sing-box/profiles/ with mode 0640. The generator reads them.
  3. Active marker — file /etc/sing-box/active_profile containing the name of the active profile.
  4. Generated config/etc/sing-box/config.json, assembled from sing-box-config.json by substituting the active outbound.
  5. Systemd unitsing-box.service runs /usr/local/bin/sing-box run -c /etc/sing-box/config.json with hardening flags.

The sing-box-config.json template contains:

  • two inbounds: mixed-in on :: / 12334 and tproxy-in on :: / 12335 (routing for TPROXY is not configured by the project);
  • a select selector, direct, and a proxy-out placeholder 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 to 192.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-domains rule set, bypass for a number of private CIDRs and .ru;
  • Clash API on 127.0.0.1:9090 without 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:

  1. requires root and writes a log to /tmp/sing-box-install.log;
  2. looks for sing-box in PATH; if missing it tries AUR, otherwise downloads the 1.11.0 binary (see "Upgrading sing-box");
  3. creates /var/lib/sing-box, runtime directories, and copies the template with profiles;
  4. if the active marker is missing, picks warp-client;
  5. generates the configuration and runs it through sing-box check;
  6. installs and starts sing-box.service;
  7. creates /etc/sing-box/private-domains.txt (after the service starts);
  8. by default installs and enables failover; disable with SKIP_FAILOVER=1;
  9. enables nftables and saves the current ruleset to /etc/nftables.conf;
  10. finishes with a direct curl probe of the external IP without SOCKS.
Important

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.json from 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 (or warp-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 — DoH https://1.1.1.1/dns-query through proxy-out (DNS traffic goes through the tunnel).
  • dns-direct — the same DoH directly.
  • dns-lan — UDP to 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.

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:

  1. probes the active profile through a SOCKS request to cloudflare.com/cdn-cgi/trace with PROBE_TIMEOUT (8 seconds by default);
  2. success — at least one of the lines ip=, colo=, loc= is present;
  3. on failure, records the timestamp in /var/tmp/sing-box-vpn-broken/<profile> (TTL = BROKEN_TTL_SEC, 30 minutes);
  4. lists candidates via vpn list, excluding the active and the recently-broken;
  5. runs sudo vpn use <candidate> one at a time and repeats the probe;
  6. the first successful profile stays active;
  7. 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-json followed by sing-box check for those.
  • Bracketed IPv6 ([2001:db8::1]:443) in the URL is parsed incorrectly; in JSON write IPv6 without brackets.
  • vpn test, apply-profiles.sh, and test-all.sh use set -euo pipefail — the first failed check may abort the script before the full summary is collected.
  • vpn status shows the external IP through a direct request, not through SOCKS. For a true tunnel check, use a manual curl --socks5-hostname.
  • apply-profiles.sh does not remove stale runtime files when the corresponding source profile is deleted.
  • rollback.sh is 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 add inherit the user's umask; from vpn add-json they get 0644. Runtime profiles get 0640 via install.sh and apply-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.service runs as root with CAP_NET_ADMIN, CAP_NET_RAW, CAP_NET_BIND_SERVICE; NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp, ProtectKernelTunables and 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, and private-domains bypass 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/vpn and /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.