v2.1.0 — production readiness
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-subscriptionRe-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.tmplandclient-dual.json.tmpl: baseline (sniff,hijack-dns, private-direct) → ad/trackerreject→ 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_suffixlist is evaluated before the remotegeosite-cn/geoip-cnrule 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_detourroutes 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, withexperimental.cache_filepersisting rule sets across restarts. Content-Dispositionnow emits an ASCIIfilename="…"alongside the percent-encoded RFC 5987filename*=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 withlogrotate -d. - Verified backups — the archive must list cleanly under
tar -tzfand actually containetc/sing-box/conf/andetc/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=0on 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/libnow means "the counter stops moving".
Security
- Subscription services no longer run as root. They run as the unprivileged
anyreality-subaccount with onlyAmbientCapabilities=CAP_NET_BIND_SERVICE. systemd reads the EnvironmentFile as root before dropping privileges, sosecrets.envstays 0600 root-only and is unreadable from the HTTP server itself. The subscription units additionally gainProtectKernelTunables,RestrictRealtime,RestrictSUIDSGID,LockPersonality, andSystemCallArchitectures=native. - Optional TLS for the subscription endpoint —
TLS_CERT_FILE/TLS_KEY_FILE, or--sub-tls-cert/--sub-tls-keyat 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_URLis pinned to http(s) —urlopenwould otherwise acceptfile://and read a local path into the usage cache.- Dot entries rejected in path resolution. pathlib does not normalise
.., so the separator check letFILE_DIR/..through; the caller'sis_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.mddocuments 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 inFILE_DIRis served under the same token path, which is why backups must not go there.
Installer and uninstaller
phase_firewallno longer risks locking you out.--ssh-port Nonly 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 atufw --force enable. The ports sshd is actually bound to are now always allowed, with a warning. A failing--harden-sshphase is also no longer swallowed by a trailing|| true.uninstall.shno longer aborts partway through. Two[[ -f … ]] && rm …loop bodies returned non-zero when the last candidate file was absent — the normal case — whichset -Eeuo pipefailturned 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/confinstead of assuming 443 (deliberately leaving SSH rules alone), plus the new/usr/local/sbinoperator 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.orgalone is blocked or rate-limited often enough that fresh installs produced a profile with no server address. --dry-runworks on a host that already hassecrets.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 (
--snias the final argument) died with a bare$2: unbound variablefromset -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.logsize, clock sync, UFW,secrets.envpermissions). 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-executable10-offsite.sh.exampledocuments the contract on the box, and the health check warns when no hook is configured. -
Operations runbook —
docs/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