Sing Box
Sing-box Operations
Config lifecycle, validation, deployment, debugging, and DNS architecture for sing-box across the fleet.
Architecture Overview
The fleet uses sing-box in two distinct roles:
| Role | Hosts | Mode | Inbounds | Outbounds | DNS |
|---|---|---|---|---|---|
| Client | id3-eniac, id3-yoga, lab-matrix | TUN + mixed | tun-in (tun0), mixed-in (:2080) | VLESS → pacman/ultraman, auto-urltest selector | Quad9 via proxy, AliDNS/Cloudflare direct, FakeIP, Tailscale MagicDNS |
| Server | vps-pacman, vps-ultraman | Inbound-only | VLESS+REALITY (:10443 :20443 :30443 etc.) | direct, block | Cloudflare primary, Quad9/Google fallback, Tailscale MagicDNS |
Client intercepts local traffic via TUN and routes it through VLESS outbounds.
Server accepts VLESS+REALITY connections and forwards them to the internet via direct.
Client (eniac) Server (pacman/ultraman)
┌──────────────┐ ┌──────────────────┐
│ App → TUN │ │ VLESS+REALITY │
│ → sing-box │── VLESS+REALITY ──→ │ → direct │── Internet
│ → pacman-1 │ (encrypted) │ → Cloudflare │
└──────────────┘ └──────────────────┘
Both roles share identical DNS tag naming conventions (see DNS Tags).
Config File Locations
All sing-box configs are agenix-encrypted secrets stored in the secrets repo:
nix-secrets/secrets/config/sing-box/
├── id3-eniac.singboxConfigFile.json # Client (workstation)
├── vps-pacman.singboxConfigFile.json # Server (L1 gateway: +nginx, +oauth2-proxy)
├── vps-ultraman.singboxConfigFile.json # Server (pure proxy node)
├── id3-yoga.singboxConfigFile.json # Client (laptop)
├── lab-matrix.singboxConfigFile.json # Client (lab)
└── ...
The Nix module (modules/services/net/sing-box.nix) wires these into systemd:
age.secrets."${hostName}.singboxConfigFile".path
→ LoadCredential = "config.json:${secretPath}"
→ ExecStart = "sing-box run -c ${CREDENTIALS_DIRECTORY}/config.json"
Per-host options are set in hosts/<name>/modules/modules.nix:
sing-box = {
enable = true;
tun.enable = false; # false = server mode, true = client mode
webPanel.enable = true; # metacubexd UI (Clash API port set in encrypted config, default :9090)
ports = [10443 20443 30443];
};
Config Lifecycle
1. Edit
Edit the plaintext JSON in nix-secrets/secrets/config/sing-box/<host>.singboxConfigFile.json.
Safety rules:
- Never commit plaintext secrets to the dotfiles repo — these files live in the separate
nix-secretsrepo - Run all changes against a live sing-box before committing
- Keep the original file as backup until verified
- Do not change UUIDs, private keys, or short_ids unless you know the corresponding client/server outbounds
2. Validate (Python + sing-box)
Always validate in two steps — strict JSON first, then sing-box semantic check:
# Step 1: Strict JSON validity
python3 -c "import json; json.load(open('config.json'))" && echo "valid"
# Step 2: sing-box semantic check (must pass on target host's sing-box version)
ssh <host>_root 'cat > /tmp/check.json && /nix/store/*/bin/sing-box check -c /tmp/check.json' < config.json
Always validate on the target host — sing-box versions differ across hosts and flag compatibility changes between releases (e.g., domain_strategy deprecated in 1.12, sniff on inbounds removed in 1.13).
Common validation failures:
| Error | Cause | Fix |
|---|---|---|
decode config: EOF | Empty or truncated file | Re-upload the config |
legacy inbound fields | sniff/sniff_timeout on inbound (1.13+) | Remove from inbound, keep route-level sniff only |
outbound DNS rule item is deprecated | "outbound": "any" in DNS rules (1.12+) | Remove the filter criterion |
missing route.default_domain_resolver | (1.12+ requirement) | Add "default_domain_resolver": "<tag>" to route |
legacy domain strategy options | Per-server domain_strategy (1.12+) | Remove all per-server domain_strategy, use root-level strategy only |
detour to an empty direct outbound makes no sense | DoH server has detour: direct but direct outbound missing domain_resolver | Remove detour from DoH servers |
Cant convert map to FilterReturnType | matugen issue (not sing-box) | See docs/toolchain.md §7 |
3. Deploy
Write to the agenix secret path then restart:
# Upload (separate SSH call — stdin is consumed!)
ssh <host>_root 'cat > /run/agenix/<host>.singboxConfigFile' < config.json
# Verify file size (must be > 100 bytes)
ssh <host>_root 'wc -c /run/agenix/<host>.singboxConfigFile'
# Restart
ssh <host>_root 'systemctl restart sing-box'
Critical: Always upload and restart in separate SSH calls. Chaining cat > file && restart inside a single ssh "..." causes the second command to receive EOF on stdin.
For the local host, use sudo:
sudo cp config.json /run/agenix/$(hostname).singboxConfigFile
sudo systemctl restart sing-box
4. Verify
# Check service health
ssh <host>_root 'systemctl is-active sing-box'
# active
# Check for errors (filter out expected REALITY scanner noise)
ssh <host>_root "journalctl -u sing-box --since '2min ago' --no-pager" |
grep -v 'invalid connection\|mux connection closed\|Scanner'
Healthy output: zero FATAL lines, zero lookup.*EOF lines.
Expected noise: REALITY: processed invalid connection from random IPs (internet scanners probing REALITY ports — this is normal and means REALITY is working).
Debugging
Service Status
systemctl status sing-box --no-pager
systemctl is-active sing-box
journalctl -u sing-box -n 100 --no-pager
Common Error Patterns
| Log pattern | Meaning | Action |
|---|---|---|
lookup <domain>: (exchange6: unexpected EOF | exchange4: unexpected EOF) | DNS server unreachable | Check DNS config; try different resolver |
REALITY: processed invalid connection | Scanner probe hit a REALITY port | Normal — ignore unless volume is extreme |
mux connection closed: read frame header: EOF | Client disconnected mid-session | Client-side network issue; server is fine |
TLS handshake: REALITY: failed to dial dest: lookup <dest> | REALITY dest server DNS failed | Check DNS reachability for the dest domain |
dial tcp <ip>:<port>: connect: no route to host | Outbound routing failure | VPS provider routing issue; try different dest |
FATAL: decode config: EOF | Config file is empty | Re-upload config (separate SSH call from validation) |
DNS Chain Testing (Client)
# Test local DoH (AliDNS for China domains)
curl -s --max-time 3 --doh-url https://223.5.5.5/dns-query example.com
# Test remote DoH through proxy
curl -sk --max-time 5 --socks5 127.0.0.1:2080 \
'https://dns.quad9.net/dns-query?name=example.com' -o /dev/null -w "HTTP %{http_code}\n"
# Test Tailscale MagicDNS (should respond)
ping -c1 -W2 100.100.100.100
DNS Chain Testing (Server)
# Test each DoH resolver
curl -s --max-time 3 --doh-url https://1.1.1.1/dns-query example.com | head -3
curl -s --max-time 3 --doh-url https://9.9.9.9/dns-query example.com | head -3
curl -s --max-time 3 --doh-url https://8.8.8.8/dns-query example.com | head -3
# Test Tailscale DNS via MagicDNS
dig @100.100.100.100 <host>.ts.net +short
Proxy Chain Testing (Client)
# Test internet through SOCKS5 proxy
curl -sk --max-time 5 --socks5 127.0.0.1:2080 https://www.google.com \
-o /dev/null -w "HTTP %{http_code}\n"
# Test Tailscale reachability through proxy
curl -sk --max-time 5 --socks5 127.0.0.1:2080 http://<tailscale-ip>:5001 \
-o /dev/null -w "HTTP %{http_code}\n"
# Test via TUN (traffic automatically intercepted)
curl -sk --max-time 5 https://www.google.com -o /dev/null -w "HTTP %{http_code}\n"
TUN Interface Verification (Client)
# TUN must be UP
ip link show tun0 | grep -q UP && echo "tun0 OK"
# Default route through TUN
ip route show default | grep tun0
# Tailscale MUST bypass TUN
ip route get 100.64.243.70 | grep tailscale0 # should show tailscale0
# Public internet MUST go through TUN
ip route get 8.8.8.8 | grep tun0 # should show tun0
Proxy Target Verification (Server)
# Verify sing-box is listening on expected ports
ss -tlnp | grep sing-box
# Expected: :10443 :20443 :30443 (or host-specific ports)
# Verify no port conflicts with nginx/other services
ss -tlnp | grep -E ':80 |:443 '
DNS Tag Reference
All configs use a unified naming convention across client and server:
| Tag | Resolver | Via | Used By | Purpose |
|---|---|---|---|---|
dns-cloudflare | 1.1.1.1 DoH | direct (server) / proxy (client) | Both | Fast global fallback |
dns-remote | 9.9.9.9 DoH | direct (server) / proxy (client) | Both | Privacy-oriented primary |
dns-tailscale | 100.100.100.100 UDP | direct | Both | Tailscale MagicDNS, .ts.net domains |
dns-fallback | 8.8.8.8 DoH | direct | Server only | Last-resort backup |
dns-local | 223.5.5.5 DoH | direct | Client only | China/private domains, geosite-cn |
dns-fakeip | FakeIP (198.18.0.0/15) | — | Client only | Domain-aware TUN routing |
Server DNS flow:
Query → [ad/tracker rules? → reject]
→ [.ts.net / .tailscale.net? → dns-tailscale (100.100.100.100)]
→ [HTTPS/SVCB/ANY? → reject]
→ final: dns-cloudflare (1.1.1.1)
Client DNS flow:
Query → [ad/tracker rules? → reject]
→ [.ts.net? → dns-tailscale (100.100.100.100)]
→ [China/private? → dns-local (223.5.5.5)]
→ [!cn domains? → dns-fakeip (FakeIP)]
→ [A/AAAA catch-all? → dns-fakeip]
→ final: dns-cloudflare (1.1.1.1 via proxy)
default_domain_resolver setting:
- Server:
dns-cloudflare(fordirectoutbound when it needs to resolve domains) - Client:
dns-local(fordirectoutbound — China/local domains resolve via AliDNS)
Route Rule Reference
Server Rules (priority order)
| Rule | Match | Action | Why |
|---|---|---|---|
hijack-dns | protocol: dns | hijack | Intercept DNS from inbound traffic through sing-box DNS |
sniff | VLESS inbounds | sniff | Protocol detection for routing decisions |
| Tailscale CGNAT | 100.64.0.0/10 | direct | Tailnet reachable through tailscale0 |
| Private IPs | RFC1918 + CGNAT + ULA | reject | Block internal IP leaks through proxy |
| Ad/tracker domains | geosite rule_sets | reject | Block ads, trackers, adult content |
| Unwanted domains | keywords + suffixes | reject | Block specific unwanted sites |
| Tailscale domains | .ts.net, .tailscale.net | direct | Tailnet hostnames go direct |
| Default | — | direct | Everything else → internet directly |
Client Rules (priority order)
| Rule | Match | Action | Why |
|---|---|---|---|
| TUN self-ranges | 172.18.0.0/30 etc. | reject | Don’t route TUN’s own addresses |
hijack-dns | protocol: dns | hijack | Intercept DNS through sing-box |
sniff | TUN + mixed inbounds | sniff | Protocol detection |
| Ad/tracker domains | geosite rule_sets | reject | Block ads, trackers, adult content |
| Unwanted domains | keywords + suffixes | reject | Block specific unwanted sites |
| Tailscale domains | .ts.net, .tailscale.net | direct | Tailnet hostnames via tailscale0 |
| Tailscale CGNAT | 100.64.0.0/10 | direct | Tailnet IPs via tailscale0 |
| Private IPs | RFC1918 | direct | LAN access |
| Proxy/VPS endpoints | Specific IPs | direct | Outbound servers reachable directly |
| FakeIP ranges | 198.18.0.0/15 | proxy | FakeIP entries route through proxy |
| !CN domains | geosite-geolocation-!cn | proxy | Foreign domains → proxy |
| China/private | geosite-cn, geosite-private | direct | China domains → direct |
| China IPs | geoip-cn | direct | China IPs → direct |
| Default | 0.0.0.0/0 | proxy | Everything else → proxy |
Tailscale Integration
Both server and client configs are designed to coexist with Tailscale:
DNS:
.ts.netand.tailscale.netdomains resolve viadns-tailscale(100.100.100.100) — Tailscale’s virtual MagicDNS server, always reachable whentailscaledis runningdisable_cache: trueon Tailscale DNS rules prevents stale MagicDNS entriesdns-tailscaledoes NOT usedetour— MagicDNS (100.100.100.100) is a virtual IP handled by the tailscale daemon, not a real network endpoint
Routing:
100.64.0.0/10(Tailscale CGNAT) andfd7a:115c:a1e0::/48(Tailscale IPv6 ULA) routedirect- Server: placed BEFORE the private-IP reject rule (so Tailscale IPs are allowed)
- Client: placed before the catch-all
proxyrule (so tailnet traffic stays local) - Client TUN: these ranges are in
route_exclude_addressso they never enter TUN
No TUN on server: The server doesn’t use TUN mode. Local processes (nginx, oauth2-proxy, sshd) use the host’s DNS and networking directly — sing-box does not intercept their traffic.
TUN Coexistence with Tailscale (Client)
On client hosts, sing-box’s TUN (tun0) and Tailscale’s WireGuard
(tailscale0) operate side by side. Making them coexist requires three
mechanisms working together:
1. Route Exclusion (route_exclude_address)
The TUN inbound explicitly excludes Tailscale IP ranges from interception:
"route_exclude_address": [
...
"100.64.0.0/10", // Tailscale IPv4 mesh
"fd7a:115c:a1e0::/48" // Tailscale IPv6 ULA
]
This means packets destined for any 100.64.x.x address are not captured by
tun0 — they follow the host’s normal routing table instead.
2. Route Rules (direct priority)
The route rule chain places Tailscale before the catch-all proxy:
Rule priority (top to bottom):
...
→ 100.64.0.0/10 → direct ← Tailscale IPs go direct
→ fd7a:115c:a1e0::/48 → direct ← Tailscale IPv6 ULA direct
→ ip_is_private: true → direct ← LAN (192.168.x.x, 10.x, 172.16.x)
→ 198.18.0.0/15 → proxy ← FakeIP → proxy
→ geosite-!cn → proxy ← Foreign domains → proxy
→ 0.0.0.0/0 → proxy ← Everything else → proxy
This ensures that even if a process explicitly sends traffic through sing-box’s
SOCKS5 proxy (:2080) or the TUN doesn’t exclude the range, the route rule
still sends it direct — no proxy hop.
3. Verification:
# Tailscale traffic must NOT enter tun0
ip route get 100.64.243.70
# Expected: 100.64.243.70 dev tailscale0 table 52
# Public internet MUST enter tun0
ip route get 8.8.8.8
# Expected: 8.8.8.8 dev tun0 ...
Failure mode: If route_exclude_address is missing 100.64.0.0/10,
Tailscale traffic enters tun0 → sing-box processes it → if the route rule
sends it to proxy instead of direct, WireGuard packets get forwarded to a
VLESS outbound → Tailscale can’t establish P2P → forced DERP relay.
DNS Interaction with Tailscale MagicDNS
Sing-box’s DNS module and Tailscale MagicDNS operate at different layers:
Application DNS query
→ intercepted by sing-box TUN (protocol: dns → hijack-dns)
→ sing-box DNS rules:
├─ .ts.net / .tailscale.net? → dns-tailscale (100.100.100.100:53)
│ └─ Tailscale daemon resolves to 100.64.x.x mesh IP
├─ geosite-private? → dns-local (223.5.5.5)
└─ final → dns-cloudflare (1.1.1.1 via proxy)
Key details:
- MagicDNS (100.100.100.100) is a virtual address handled by
tailscaled— it never leaves the host, so it does NOT need adetourin sing-box .ts.netqueries are routed todns-tailscalewithdisable_cache: trueto prevent stale MagicDNS cache entries- Tailscale hostnames resolve to
100.64.x.xaddresses, which the route rules senddirectviatailscale0— no proxy involvement - Non-Tailscale DNS follows the normal sing-box flow (domains → direct or proxy depending on geosite rules)
DNS leak check: Verify that Tailscale hostnames resolve correctly:
dig @100.100.100.100 nas-nasa.ts.net +short
# Should return a 100.64.x.x address
Application-Layer Impact of 100.64.0.0/10
Even with perfect sing-box/Tailscale routing, applications may misclassify
Tailscale mesh IPs because 100.64.0.0/10 is not RFC 1918 private:
| IP range | RFC | is_private (Python) | is_global (Python) |
|---|---|---|---|
192.168.0.0/16 | 1918 (private) | True | False |
10.0.0.0/8 | 1918 (private) | True | False |
172.16.0.0/12 | 1918 (private) | True | False |
100.64.0.0/10 | 6598 (CGNAT) | False | True |
198.18.0.0/15 | 2544 (benchmark) | False | False |
This means any application that determines “local vs remote” via
ipaddress.is_private / is_global will treat your Tailscale mesh as the
public internet.
Consequences:
- Streaming apps (jellyfin-mpv-shim, Plex, Emby): apply
remote_kbpsthrottle (often ~10 Mbps) instead of fulllocal_kbps - File sync (Syncthing, Resilio): force relay-only mode for “non-LAN” peers
- Any Python app doing
ipaddress.is_privatechecks: misclassifies your NAS
Mitigation:
- Always prefer LAN IPs (
192.168.x.x) for services on your local network. Use Tailscale IPs only for remote access. - Check app configs for
remote_kbps/local_kbpsor similar local-vs-remote split settings. - For apps that support it, explicitly mark
100.64.0.0/10as a local network range (some apps allow custom LAN subnet configuration).
See also networking-vpn.md and networking-topology.md for extended discussion and real-world examples.
Sleep/Resume (Client Hosts)
On client hosts that can suspend (workstations, laptops), sing-box is stopped before sleep (Conflicts=sleep.target) and automatically restarted on wake via powerManagement.resumeCommands. The TUN interface is torn down on suspend and recreated when sing-box restarts.
Previously this used a bindsTo=sleep.target companion service with ExecStopPost, but sleep.target doesn’t reliably deactivate on resume across all hardware/kernel combinations. The powerManagement.resumeCommands hook runs directly after the kernel resumes, independent of systemd target state.
To verify the resume hook is active:
grep -r "sing-box" /run/current-system/sw/etc/systemd/system/
If sing-box is dead after resume, check:
journalctl -u sing-box --since "10min ago" --no-pager | grep -E "stop|start|Deactivat"
Pre-Deploy Checklist
Before deploying a config change:
-
python3 -c "import json; json.load(open('config.json'))"passes -
sing-box check -c config.jsonpasses (on target host) - No
domain_strategyon individual DNS servers -
default_domain_resolvermatches a defined DNS tag -
dns-tailscaletag points to100.100.100.100:53 -
.ts.netdomains use leading dot (subdomain matching) -
100.64.0.0/10routes todirect(not blocked) - All
rule_settags are defined inroute.rule_set - No trailing commas in strict JSON (though sing-box accepts them)
- Original config backed up before deploy
Recovering from Bad Config
If sing-box fails to start after a config change:
# 1. Check what's wrong
journalctl -u sing-box --since '1min ago' --no-pager | grep FATAL
# 2. Restore the previous config (if backed up)
ssh <host>_root 'cp /run/agenix/<host>.singboxConfigFile.bak /run/agenix/<host>.singboxConfigFile'
# 3. Or revert to the nix-store config from the last successful build
ssh <host>_root 'cp /nix/store/*-source/config/sing-box/<host>.singboxConfigFile.json /run/agenix/<host>.singboxConfigFile'
# 4. Restart
ssh <host>_root 'systemctl restart sing-box'