user@elrise.io:~/projects/$ ← elrise.io
· [active] tags: shclisystemdlinux

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.

→ repository

Overview

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:

  • a local SOCKS/mixed proxy on 127.0.0.1:12334 for browsers and CLI tools that support SOCKS5 with remote DNS resolution;
  • switching between multiple outbound servers (different providers, protocols, IPv4/IPv6);
  • automatic switch to a backup profile when the active upstream fails, via a systemd timer.

What it is and what it is not

The project does:

  • store a set of outbound profiles as plain JSON files;
  • pick the active profile and render the final config.json from the template;
  • start and control sing-box through systemd;
  • validate the configuration via sing-box check;
  • expose a local mixed/SOCKS endpoint and Clash API on loopback;
  • optionally switch profiles when the upstream fails.

The project does not:

  • deploy a VPN server or generate server-side configs for Hysteria2, VLESS, Xray, and the like;
  • create a TUN/TAP interface or proxy system traffic automatically;
  • ship policy-routing/nftables rules for transparent proxying (the TPROXY inbound is present in the template, but routing for it is not configured);
  • implement a kill switch;
  • provide a GUI, traffic indicator, or per-app split tunneling;
  • manage mobile, macOS, or Windows clients — external apps (Nekoray, v2rayN, etc.) are used for those;
  • guarantee DPI bypass or any specific blocking avoidance — that depends on the chosen protocol and server.

Requirements

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.

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 files in the repository (in practice in ~/sing-box-vpn/profiles/). Stored in Git; real secrets are in .gitignore.
  2. Runtime profiles — copies in /etc/sing-box/profiles/ with 0640 permissions. The generator reads these.
  3. Active marker — the file /etc/sing-box/active_profile with 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 parameters.

The sing-box-config.json template contains:

  • two inbounds: mixed-in on :: / 12334 and tproxy-in on :: / 12335 (routing for TPROXY is not configured in the project);
  • a selector select, direct, and the placeholder proxy-out, which is replaced with the active outbound;
  • DNS stack: 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;
  • routes: sniff, hijack on port 53, reject loopback:9090, bypass for the 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, tries AUR, otherwise downloads the 1.11.0 binary (be aware — see "Upgrading sing-box");
  3. creates /var/lib/sing-box, the runtime directories, and copies the template with profiles;
  4. if the active marker is absent, 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; disabled with SKIP_FAILOVER=1;
  9. enables nftables and saves the current ruleset to /etc/nftables.conf;
  10. finishes with a direct curl check of the external IP without SOCKS.
Important

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

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 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

Environment variables

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.

Profiles

JSON schema

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).

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) — 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.

Adding from JSON

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.

IPv6 servers

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.

Secret storage

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.).

CLI commands

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._-]+$.

Applying and testing profiles

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.

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 :: — that is, all IPv6 interfaces and, depending on the system, IPv4. Without a firewall this potentially opens the proxy wider than loopback.
  • No authentication is set on the inbound.

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

  • 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.

Bypass

/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:

  • DNS hijack (port 53 → hijack-dns);
  • reject loopback:9090;
  • direct for .local, .localhost, a number of private IPv4/IPv6 CIDRs, and the .ru domain;
  • everything else → proxy-out.

For the full list of bypass domains and CIDRs see 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 via a SOCKS request to cloudflare.com/cdn-cgi/trace with a timeout of PROBE_TIMEOUT (8 seconds by default);
  2. success — at least one of the lines ip=, colo=, loc= is present;
  3. on failure, records a timestamp in /var/tmp/sing-box-vpn-broken/<profile> (TTL = BROKEN_TTL_SEC, 30 minutes);
  4. obtains candidates via vpn list, excluding the active one and recently failed profiles;
  5. calls sudo vpn use <candidate> one by one and repeats the probe;
  6. the first successful profile remains active;
  7. if all fail — returns 1; the last tested profile may remain 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 executed and does not check candidates.

Exit codes

Code Meaning
0 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 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.

Installation specifics

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).

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               # 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/.

Upgrading sing-box

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.

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 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.

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 runs the direct check, not 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.

Security

  • Secrets in profiles. Passwords, UUIDs, and private keys live in JSON files in Git (unless .gitignored). Passing share-links as CLI arguments leaves the secret in shell history and briefly makes it visible in ps.
  • File permissions. Source profiles created by 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.
  • Mixed inbound. Listens on :: (all IPv6 interfaces and potentially IPv4), no authentication is set. Without a firewall the proxy may be reachable beyond loopback.
  • Clash API. Restricted 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, etc. are enabled.
  • tls.insecure=true. Several Hysteria2 profiles disable server-certificate verification. This simplifies MITM and must be used deliberately.
  • DNS leaks. A leak-free guarantee holds only for apps that use the proxy and do not perform their own DoH/DoT. System-level resolvers (systemd-resolved, NetworkManager) may bypass the tunnel.
  • Kill switch. Not implemented. If sing-box crashes, apps that are not configured for SOCKS will continue to use direct network.
  • .ru, private CIDR, private-domains bypass the VPN by design.

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 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.
  • Behavior 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.

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;
  • and does not guarantee restoration of the original network configuration.

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.

Reference

CLI

See the "CLI commands" section.

Environment variables

See the "Environment variables" section.

Systemd units

  • sing-box.service — main service.
  • vpn-failover.service + vpn-failover.timer — automatic failover.

Logs

  • journalctl -u sing-box
  • journalctl -u vpn-failover.service
  • /var/log/sing-box.log
  • /tmp/sing-box-install.log

Repository and sources