sing-box profiles as systemd template instances, with the whole client declared in NixOS ◦°˚\(*❛‿❛)/˚°◦
A profile is one file holding a single outbound tagged proxy; everything shared — the TUN, DNS, routing — is one base config the NixOS module renders from options. Switching profiles is starting a different instance of one systemd template unit:
sudo skvpn sub set https://my-panel.example/sub/… # store the subscription, pull the profiles
sudo skvpn up SE-1 # and the tunnel is up
skvpn ls --names # no root needed — and <TAB> knows it tooThe last active profile comes back on boot, the subscription refreshes daily and on every up, and a node the subscription dropped is pruned — unless it is the one running
Came over from my rice, rokokol/huix
| Command | What it does |
|---|---|
skvpn sub set <url> |
store a subscription and sync it right away |
skvpn sub sync [--if-stale] |
refresh profiles from the stored subscription; --if-stale only after a day |
skvpn add <uri>… |
add profiles from vless://, hysteria2:///hy2:// or trojan:// share links |
skvpn rm <name>… |
delete profiles; the running one is refused |
skvpn ls [--names | --servers] |
list profiles; --names prints bare names and needs no root; --servers adds each node's address, kept out of the table otherwise |
skvpn up <name> |
sync if stale, stop the active profile, start this one, remember it for boot |
skvpn down |
stop the active profile and forget the boot choice |
skvpn restart |
start the active profile over on the base as it is now — how a changed split list gets onto the wire |
skvpn restore |
start the profile chosen for boot — the boot-time half of up |
skvpn boot [<name> | last] |
pin a profile for boot regardless of what is up; last goes back to following up; no argument shows the choice |
skvpn split add [name|path|ip|domain] <value>… |
route a process (by name, the default), an executable (absolute path), an address/CIDR or a site (a bare name, a URL or *.example.com) around the tunnel; asks for a skvpn restart |
skvpn split rm [name|path|ip|domain] <value>… |
drop split entries; same reminder |
skvpn split [ls] |
both split lists — the declared one marked declared, then the one add edits; an IDN shows as a person reads it with the wire form beside it, пример.рф (xn--e1afmkfd.xn--p1ai); no root |
skvpn ping [--servers] [<name>…] |
latency to the ping site through every profile, or the named ones, without switching: one throwaway sing-box carries them all; --servers adds the addresses |
skvpn ping set <host|url> |
the site to reach for; a bare host becomes https://host/, https only — sing-box's test quietly swaps a plain-http site for its own; default https://www.google.com/generate_204 |
skvpn status --ping [--servers] |
the status lines, then the ping table |
skvpn status |
the active profile, the boot choice (only while boot restore is enabled), the age of the last sync |
Everything that writes or talks to systemd needs root, and so does ping (it reads the profiles); ls --names, status, split ls and a bare boot do not
down forgets the last up but leaves a pin alone — pinning is precisely "this one on boot, whatever I switch to meanwhile". A pinned profile that gets deleted or pruned drops its pin, and boot follows the last up again. The word last is reserved by boot, so a profile of that name cannot be pinned
/etc/sing-box/base.d/00-base.json TUN, DNS, routing — rendered by NixOS or the installer
/etc/sing-box/base.d/50-extra.json extraSettings, when set
/etc/sing-box/base.d/70-split.json the imperative split list — written by `skvpn split`
/etc/sing-box/ping.url the site `skvpn ping` reaches for, when set
/etc/sing-box/profiles/<name>.json one outbound tagged `proxy` — written by skvpn
/var/lib/skvpn/active the last profile brought up — what boot follows by default
/var/lib/skvpn/boot a profile pinned for boot by `skvpn boot`, beating the above
The unit runs sing-box -C /etc/sing-box/base.d -c /etc/sing-box/profiles/<name>.json, so the base and the profile merge by sing-box's own rules. The profiles directory lists for everyone while the files stay 0640: names are how completion works without root, contents carry node credentials
skvpn ping measures every profile at once without switching: it starts one throwaway sing-box with no inbounds, every profile as an outbound tagged with its name and the Clash API on loopback, asks it for a URL test through each, and stops it. The probe binds to the physical interface and carries the mark the active TUN's auto_redirect lets out (0x2024, read back from the base if moved), so with a profile up the numbers are still measured outside the tunnel, not inside it. Only the nodes' own hostnames are resolved locally, by the system resolver like the base's bootstrap; the site's name travels to each node and is resolved there, as it does through the tunnel, so nothing DNS-shaped leaves the client to be blocked. Needs root — the profiles are root-only — and a sing-box binary: the one on PATH, else /run/current-system/sw/bin, else /usr/bin, overridden by SKVPN_SING_BOX. The target lives in /etc/sing-box/ping.url
Bypass only: a listed process name, executable path, destination address or site leaves around the tunnel, everything else keeps going through the proxy. A domain entry is the same rule as direct.zones / --direct-zone, taking a bare name, a URL, *.example.com or a whole zone as .ru (example.com covers its subdomains too, mail.example.com only what sits under it): it matches the name the client asked for — sniffed or answered — never an address, so a CDN sharing its addresses with the world changes nothing; a site pulling resources from other domains needs those listed too. A connection that carries no name — a game server, any binary protocol the sniffer cannot read — still meets the rule, because the base keeps a reverse mapping of every DNS answer it gave and hands the name back to the connection; only an app that arrives with a ready address, never having asked, is not caught. One trap: a name that is only an SRV record (a Minecraft server address, say) sends the client to another name, and that is the name the connection carries — list the SRV target, dig SRV _minecraft._tcp.<name> shows it; a CNAME needs nothing, the address is remembered under the name asked for. sing-box cannot match a PID, so path is the exact knob for a binary whose process name is shared. Names and paths take wildcards — chrom*, /opt/*/bin/tor, /nix/store/**/bin/x? — which sing-box's exact fields cannot, so they are rendered as a regex on the executable's path (* one segment, ** any run, ? one character) and shown back as the glob. Two lists coexist — the declarative one (split.names/paths/ips on NixOS, --split for the installer) is rendered into 00-base.json, the imperative one (skvpn split add|rm) into 70-split.json, and sing-box's -C appends the second after the first. Process kinds also steer their DNS to the local bootstrap resolver, the way direct zones do; on a host where systemd-resolved's stub answers, the resolver is the process the DNS rule sees, while the connection itself still matches and goes direct
A change needs a restart, and asks for it. sing-box reads its routing rules only at start, and dropping the tunnel is your call: skvpn split add/rm write the file and print <active> is still on the old rules — apply with sudo skvpn restart; with nothing running they say the rule takes effect on the next up. skvpn split ls shows both lists, the declared one marked declared. Neither --uninstall nor a NixOS rebuild touches 70-split.json: it is state, like the profiles
Subscription bodies are fetched with a custom User-Agent — Cloudflare's bot rules answer 403 to the stock Python one. Profiles you add by hand are never overwritten or pruned by a sync: the manifest remembers which names came from the subscription. The converse holds too — an add over a subscription-owned name keeps that name in the manifest, so the next sync writes the subscription's version back
The module owns the mechanism — the sing-box@ template unit, boot restore, the sync timer, the service user, the base config. The routing policy is yours:
{
imports = [ inputs.skvpn.nixosModules.default ];
services.skvpn = {
enable = true;
# Russian destinations leave directly instead of dying on an exit that refuses them:
# zone suffixes plus the geosite/geoip rule-sets pinned in this flake's own lock
direct.russia.enable = true;
# Your own additions, same shape as what the preset provides
direct.zones = [ ".by" ];
direct.geosite.geosite-custom = ./my-set.srs;
# Follows services.tailscale.enable by default: with both running, the tailnet is
# unreachable through the TUN unless its ranges are excluded
tailscale.enable = true;
};
}| Option | Default | What it does |
|---|---|---|
direct.russia.enable, direct.china.enable, direct.iran.enable |
false |
country presets: the domestic zone suffixes (IDN ccTLDs included) plus the matching geosite/geoip rule-sets, pinned in this flake's lock and refreshed by its weekly lock bump |
direct.zones |
[ ] |
domain suffixes resolved by the local bootstrap and routed past the tunnel |
direct.geosite / direct.geoip |
{ } |
local binary rule-sets routed direct, keyed by tag; local on purpose — a remote set would arrive through the tunnel it is meant to steer |
split.names / split.paths / split.ips |
[ ] |
split tunnelling in the bypass sense: process names, absolute executable paths and destination CIDRs that leave around the tunnel; names and paths take */? wildcards, and the process kinds resolve locally too. skvpn split add keeps an imperative list beside these |
tailscale.enable |
follows services.tailscale.enable |
keep the tailnet ranges out of the TUN |
docker.enable |
follows virtualisation.docker.enable |
keep Docker bridges out of the TUN: follow the configured default bridge name, bypass dynamic br-* interfaces in nftables, and exclude virtualisation.docker.daemon.settings.default-address-pools from TUN routes |
extraSettings |
{ } |
a second base.d file, merged by sing-box -C semantics: objects merge, arrays append, scalars replace |
trustedUsers |
[ ] |
run skvpn without typing sudo: a NOPASSWD rule for exactly this command plus a system-wide skvpn = "sudo skvpn" alias |
restore.enable |
true |
bring the last active profile back on boot |
sync.interval |
"daily" |
OnCalendar for the subscription refresh |
fixDiscordVoice |
true |
loosen the firewall's reverse-path filter (as a default an explicit host value beats): replies to tunnelled UDP arrive on the TUN while the route points at the LAN, and a strict filter drops them — Discord voice is how that shows up |
tun.interfaceName, tun.address, dns.remoteServer |
skvpn-tun, 172.19.0.1/30 + ULA, 8.8.8.8 |
the base config's fixed points |
tun.ipv6 |
true |
give the TUN a v6 address. Turn it off on a host with no IPv6 upstream: auto_route otherwise installs a v6 default route to nowhere, and whatever reaches for v6 first waits on it — the DNS strategy does not cover this, because an application carrying its own addresses never asks |
tun.stack |
system |
the TCP/IP stack behind the TUN: system handles TCP and UDP with the host stack, mixed uses the host for TCP and gVisor for UDP, and gvisor handles both in userspace |
package, singBoxPackage |
this flake's CLI, pkgs.sing-box |
what to install and what the unit runs |
bash and zsh, installed by the package. Subcommands are spelled in the completion files and checked against the CLI's own command table by the test suite; profile names are completed live through skvpn ls --names, so sudo skvpn up <TAB> offers what is actually on disk
As a flake input:
inputs.skvpn = {
url = "github:rokokol/skvpn";
inputs.nixpkgs.follows = "nixpkgs";
};then import inputs.skvpn.nixosModules.default and enable as above. Without the module, packages.default and overlays.default carry the bare CLI. On Arch Linux without Nix:
sudo pacman -S sing-box
sudo ./install.sh # PREFIX=/usr/local; --prefix/--destdir supported
sudo ./install.sh --fix-discord-voice # loosen IPv4 reverse-path filtering for tunnelled UDP
sudo ./install.sh --uninstall # remove everything by the install manifest
./install.sh --version # skvpn x.y.z, from the VERSION fileThe Arch package supplies the binary, service user and template unit. The installer adds the base config, an ExecStart drop-in for skvpn's split base/profile layout, boot restore and the daily subscription timer, and writes an install manifest under share/skvpn naming every file it created — --uninstall consumes that manifest, so it removes exactly what was written and leaves profiles, the subscription and active-profile state intact. With --destdir files are only staged; --no-systemd is a real install that skips every live systemctl and sysctl call, for containers and image builds
Debian and Ubuntu use the official sing-box APT repository. Its package supplies the same binary, template unit and service user expected by the installer. A preflight checks all runtime dependencies before writing files and prints distro-specific guidance when anything is missing — every runnable line as $ command, exactly what to type; nothing is ever installed on your behalf
The NixOS policy options have matching installer flags: --tailscale, --docker, --direct-russia, --direct-china, --direct-iran, repeatable --direct-zone, --direct-geosite TAG=PATH and --direct-geoip TAG=PATH, repeatable --split [name|path|ip|domain] VALUE (the kind defaults to name; a process literally called ip is --split name ip), --tun-interface, repeatable --tun-address, --no-ipv6, --stack, --dns-server, --extra-settings, --no-restore, --sync-interval and repeatable --trusted-user. Country presets use the official Arch rule-set packages:
sudo pacman -S sing-geoip-rule-set sing-geosite-rule-set
sudo ./install.sh --direct-russia./install.sh --help is the complete command reference. The installer is declarative: each run converges the system to exactly the flags given, so repeating a command reproduces its state and omitting a flag — the Discord fix included — undoes what that flag installed, the way unsetting a NixOS option does on rebuild. A re-run while a profile is up restarts that instance by name onto the new base and watches it stay active; if it dies, the previous base files come back, the instance is restarted onto them, and the run fails with the unit's journal — the tunnel never goes down over a bad flag
Tab completion for the installer itself is in the checkout: source completions/install.sh.bash (or completions/install.sh.zsh), and ./install.sh --<TAB> knows every flag above
./tests/run.sh # scratch SKVPN_ROOT, stubbed systemctl, file:// subscriptions
./tests/run.sh --update # rewrite the golden parser outputs
nix flake check # the suite, the packaged CLI, module wiring, a real-nixpkgs eval, shell lint
./tests/distro.sh debian # real root install in a docker container: preflight → its own printed
# guidance → install → smoke → uninstall; also ubuntu, arch, fedoraThe distro suite runs in CI on every push to master and weekly against each distribution's :latest image — the badges above are its verdicts — but never on pull requests, so a flaky mirror cannot redden a change