Skip to content

v2.1.0 — production readiness

Choose a tag to compare

@tytsxai tytsxai released this 28 Jul 06:18
· 9 commits to main since this release
0ca7132

Production-readiness release. No breaking changes — re-running the installer is the upgrade path, secrets.env is reused, and no client needs to re-import.

bash <(curl -fsSL https://raw.githubusercontent.com/tytsxai/anyreality-resi-stack/main/install/install.sh) \
  --node-name "US-Resi-01" --sni addons.mozilla.org --with-subscription

Re-running on an existing host creates the anyreality-sub service account, moves the subscription services onto it, installs the logrotate policy and the operator tools, and leaves the sing-box keys untouched.

Re-pull your subscription after upgrading. Client profiles are regenerated with a full routing stack (see below). Nothing needs to be re-imported — refreshing the subscription URL in your client is enough.

Why this release exists

These are the gaps that only show up after a node has been running a while, or in the middle of an incident:

Gap Consequence
Client profiles had no routing rules Everything went through the node; in TUN mode there is no direct toggle, so domestic traffic silently detoured abroad
Rule sets download from GitHub at first start A blocked download left the set empty and proxied every domestic site, with no local fallback
--ssh-port was read as "move sshd" Passing a port sshd was not listening on fenced off the real one at ufw --force enable
uninstall.sh aborted partway UFW rules and the fail2ban jail were left behind on a "successful" uninstall
box.log had no size bound Any persistent fault fills a small VPS disk, taking journald and the backup timer with it
Backups were never verified A corrupt archive silently rotates out the last good one; you find out when you need to roll back
Backups were local-only They die with the host
Subscription servers ran as root The only publicly-listening component here held root and could read secrets.env
Bad EnvironmentFile → bare traceback Endless crash loop under Restart=always with no indication of which variable was wrong
Accounting failure → failed request A full /var/lib broke every client's subscription refresh, though serving the profile is the primary job
systemd default start limit One transient fault could park sing-box permanently in a failed state
Single public-IP provider Install fell through to a vague warning and a profile with no server address
No health check, no alerting Nothing to answer "is this node healthy"
Leaked subscription URL Only remedy was a full reinstall
safe_target_path accepted .. FILE_DIR/.. got through; only the caller's is_file() stopped it

Routing

The generated sing-box profiles previously carried a single ip_is_private → direct rule and sent everything else through the node. That is merely wasteful in a mixed-inbound client and unusable in TUN mode, where there is no global/direct toggle to fall back on.

  • Four-layer rule stack in both client-single.json.tmpl and client-dual.json.tmpl: baseline (sniff, hijack-dns, private-direct) → ad/tracker reject → China-direct → a fallback that rejects UDP/443 before sending the rest to the node. The Clash templates gain the matching ad-block rule and the same inline safety net.
  • China-direct is deliberately two-tier. An inline 61-entry domain_suffix list is evaluated before the remote geosite-cn / geoip-cn rule sets, because those download from GitHub — commonly unreachable at first start, and a failed download yields an empty set that proxies every domestic site. The inline list needs no network request, so high-traffic domestic services stay direct regardless. download_detour routes the rule-set downloads themselves through the node.
  • Split DNS — domestic names resolve via 223.5.5.5, everything else over DoT through the node, with experimental.cache_file persisting rule sets across restarts.
  • Content-Disposition now emits an ASCII filename="…" alongside the percent-encoded RFC 5987 filename*=UTF-8''… form. Clients that cannot parse the header were inventing a numeric snowflake id for the imported profile.

Routing is documented in docs/zh-CN/ROUTING.md / docs/en/ROUTING.md, including how to verify a domain really goes direct without being fooled by a TUN client hijacking the test.

Stability

  • Log rotation for sing-box/etc/logrotate.d/sing-box: daily, immediate rotation past 20 MiB, 7 compressed generations, copytruncate. Validated at install time with logrotate -d.
  • Verified backups — the archive must list cleanly under tar -tzf and actually contain etc/sing-box/conf/ and etc/anyreality-resi-stack/. A failure deletes the bad archive and exits non-zero instead of rotating out the last good backup in its favour. Archives also stop carrying /etc/sing-box/logs, which the header comment already claimed was excluded.
  • StartLimitIntervalSec=0 on all three units. systemd's default 5-starts-in-10s limit can park sing-box — the only proxy on the box — in a failed state that nothing recovers from.
  • Accounting failures degrade instead of failing the request. Serving the profile is the primary job; a full or read-only /var/lib now means "the counter stops moving".

Security

  • Subscription services no longer run as root. They run as the unprivileged anyreality-sub account with only AmbientCapabilities=CAP_NET_BIND_SERVICE. systemd reads the EnvironmentFile as root before dropping privileges, so secrets.env stays 0600 root-only and is unreadable from the HTTP server itself. The subscription units additionally gain ProtectKernelTunables, RestrictRealtime, RestrictSUIDSGID, LockPersonality, and SystemCallArchitectures=native.
  • Optional TLS for the subscription endpointTLS_CERT_FILE / TLS_KEY_FILE, or --sub-tls-cert / --sub-tls-key at install time. Standard library only, TLS 1.2 floor. The subscription URL is a credential (the profile behind it contains the node password), so plain HTTP exposes it to anyone on the path. The health check fails when the certificate expires within 7 days — expiry is otherwise a silent outage where clients refuse the subscription while liveness probes stay green.
  • REMOTE_STATUS_URL is pinned to http(s)urlopen would otherwise accept file:// and read a local path into the usage cache.
  • Dot entries rejected in path resolution. pathlib does not normalise .., so the separator check let FILE_DIR/.. through; the caller's is_file() was load-bearing by accident.
  • Startup configuration validation. Every environment variable is range-checked; a typo exits 2 naming the variable instead of crash-looping on a traceback.
  • SECURITY.md documents the subscription URL exposure surface — the profile is served over plain HTTP on :80 and contains the node password, so the URL is a credential; anything placed in FILE_DIR is served under the same token path, which is why backups must not go there.

Installer and uninstaller

  • phase_firewall no longer risks locking you out. --ssh-port N only tells UFW which port to keep open — it does not move sshd, which is --harden-ssh — so passing a port sshd was not listening on fenced off the real one at ufw --force enable. The ports sshd is actually bound to are now always allowed, with a warning. A failing --harden-ssh phase is also no longer swallowed by a trailing || true.
  • uninstall.sh no longer aborts partway through. Two [[ -f … ]] && rm … loop bodies returned non-zero when the last candidate file was absent — the normal case — which set -Eeuo pipefail turned into an aborted uninstall that left UFW rules and the fail2ban jail behind. It now also removes the UFW rule for the port actually configured in /etc/sing-box/conf instead of assuming 443 (deliberately leaving SSH rules alone), plus the new /usr/local/sbin operator tools and /etc/logrotate.d/sing-box.
  • Public IP detection is no longer a single point of install failure. Detection tries four independent providers with a 5 s timeout each and validates the result is an IPv4 address; api.ipify.org alone is blocked or rate-limited often enough that fresh installs produced a profile with no server address.
  • --dry-run works on a host that already has secrets.env. The reuse path returned without defining the template variables, so the next phase died on an unbound variable; dry-run now exports placeholders instead of reading real secrets.
  • A flag given without its value (--sni as the final argument) died with a bare $2: unbound variable from set -u. The parser now names the flag.

Operations

  • anyreality-resi-stack-healthcheck — read-only, 15 checks (service state and restart count, sing-box check, inbound port listening, subscription /healthz, TLS certificate expiry, profile file, backup freshness/failure, off-box hook presence, disk, box.log size, clock sync, UFW, secrets.env permissions). Non-zero when degraded, silent while healthy, so one cron line is a complete alerting setup:

    */10 * * * * /usr/local/sbin/anyreality-resi-stack-healthcheck --quiet
    
  • anyreality-resi-stack-rotate-sub-token — rotates the subscription token in place, verifies the new path serves a profile, and rolls back automatically if the service does not come back healthy.

  • Off-box backup hooks — executables in /etc/anyreality-resi-stack/backup-hooks.d/ receive the verified archive path. A failing hook fails the backup run, because an off-box copy you believe exists but does not is the dangerous case. A non-executable 10-offsite.sh.example documents the contract on the box, and the health check warns when no hook is configured.

  • Operations runbookdocs/zh-CN/OPERATIONS.md / docs/en/OPERATIONS.md: pre-launch checklist, alerting, log and disk bounds, backup verification plus a restore drill safe to run on a live host, rollback paths, token rotation, enabling HTTPS, and the boundaries that remain by design (NIC-level accounting, single host, single user).

Documentation

Beyond the routing and operations guides above, this release adds a standalone English README.en.md, an FAQ (zh / en), and command-example guides (zh / en).

Internals

The two subscription servers carried line-for-line duplicates of routing, path safety, Content-Disposition, and the HTTP server — which is how they drifted apart (the leaf and aggregator health payloads had different ensure_ascii handling). That half moved to subscription/_common.py; current_usage() and refresh_usage_cache() collapsed into one function. Deployment is unchanged: the file ships next to the servers and each anchors sys.path on its own directory, so there is still no packaging and no PYTHONPATH.

Verification

54 tests (up from 20), plus shellcheck / shfmt / ruff / yamllint / jsonlint / redact / examples-drift / link checks — all green on main.

New coverage where there was none: tests/test_endpoints.py boots the real leaf server on an ephemeral port and drives it over HTTP, including a TLS class that generates a certificate and asserts the wrap_socket path actually serves — a mistake there shows up as dropped connections, not a startup error. The .. hole was found by a new test, not in production.

Two CI/tooling problems were fixed along the way: tests/ was not covered by the project's ruff configuration (it was linted with whatever default rule set the installed ruff shipped, so a ruff release could break main with no code change — it did), and scripts/redact.sh reported long snake_case identifiers as leaked secrets.

Not verified: the installer phases have not been executed on a real Ubuntu/Debian host — only syntax-checked, shellchecked, and validated as extracted scripts. Before rolling out: --dry-run on a clean VPS, then a real install, then anyreality-resi-stack-healthcheck.

Full changelog: CHANGELOG.md · v2.0.0...v2.1.0