Fleet
Node agent
autoscript-node) is a FastAPI + uvicorn Python service running on every edge node. It is the only process the panel ever talks to, and it owns enrolment, liveness, remote control, local enforcement, WARP egress, benchmarking and log hygiene.The panel drives a node entirely over a signed HTTPS RPC channel (/rpc/*) that it proxies through its own server route, src/routes/api/public/node-rpc.ts — browsers cannot pin a self-signed certificate by fingerprint themselves, so the Node.js server does it on their behalf.
Browser (panel UI)
-> Panel server route /api/public/node-rpc (Node.js https client)
-> HTTPS + cert-pin + HMAC
-> Node agent /rpc/* (FastAPI/uvicorn, self-signed TLS on AGENT_PORT)
-> xray (systemd) - VMess/VLESS/Trojan
-> zivpn (systemd) - UDP
-> sshd - SSH tunnels
-> nginx - front-end reverse proxy / TLS
-> stunnel4 - SSH over TLS (445/447/777)
Node agent -> POST /api/public/node-handshake (enrol / re-pin, once + periodic)
Node agent -> POST /api/public/node-heartbeat (every ~3-5s / adaptive)
Node agent -> POST /api/public/node-ssh-usage (SSH byte ledger, own channel)A companion process, autoscript-node-ssh-ws (module agent.ssh_ws), bridges SSH over WebSocket in its own systemd unit at higher CPU priority than the agent, so telemetry never throttles real tunnel throughput.
File: node/agent/heartbeat.py (~1570 lines). Runs as an asyncio task alongside the RPC server and the SSH-usage loop (agent/main.py::_main).
Adaptive cadence
run(cfg, interval=3.0) implements a Remnawave-style 3-tier cadence based on measured VPN throughput, not raw NIC traffic:
| Mode | Trigger | Payload | Interval |
|---|---|---|---|
| ACTIVE | live user traffic (> IDLE_BPS = 8 kbps) | full telemetry snapshot | ~5s |
| QUIET | ~1 min without traffic (QUIET_AFTER=12) | full telemetry snapshot | 60s (checked every QUIET_INTERVAL=4s) |
| DEEP | ~5 min without traffic (DEEP_AFTER=60) | 120-byte beacon {nodeId, idle:true} | every 5 min, polled every DEEP_INTERVAL=4s |
- Every 20th DEEP beat (DEEP_FULL_EVERY) still sends a full snapshot so dashboards don't go stale on an empty node.
- The first beat after (re)connect is always full (hello=True) so the panel gets a complete picture immediately.
- Every POST is HMAC-signed with SHARED_SECRET the same way as the handshake.
- Presence must flip offline->online within ~4s — even idle cadences poll at 4s so the UI never lags on a sudden traffic burst.
- Log bundle (
LOG_UNITS = (xray, zivpn, ssh, nginx, autoscript-node)) refreshes every ~10 min (LOGS_EVERY=40 beats) viajournalctl -u <unit> -n <lines> --output short-iso, capped to 8000 chars per unit, plus the cert issuance log tail (/var/log/autoscript/cert.log).
Full heartbeat snapshot (_snapshot_blocking)
- cpu, ram, disk (percent, via psutil); cores, ramTotalBytes/ramUsedBytes, diskTotalBytes/diskUsedBytes — real hardware inventory.
- nicIn/nicOut — live VPN throughput (bits/s), NOT raw VPS NIC.
- rxBytes/txBytes — lifetime customer VPN bytes (idle background VPS traffic never counted).
- xrayRxBytes/xrayTxBytes, sshRxBytes/sshTxBytes, zivpnRxBytes/zivpnTxBytes — per-protocol cumulative split of the same VPN bytes.
- vpsRxBytes/vpsTxBytes, vpsNicIn/vpsNicOut — raw physical-interface counters, kept separate from customer VPN traffic.
- vpnSessions, onlineUsers — account UUIDs with measured traffic this beat.
- userDelta, userDeltaDown, userDeltaUp, userDeltaByProto — per-user byte deltas for this interval.
- userTotalsByProto — durable cumulative per-user totals (panel recovery source after a panel restart).
- sessions — full connected-user detail (user, ip, protocol, transport).
- userIps — accountId -> client IPs (for fleet-wide IP-limit enforcement).
- userConns — accountId -> {ip: live connection count} so a NATed household isn't miscounted as "1 device".
- statsSource: "xray" when Xray's stats API answered, else "sockets" (fallback estimate from ss socket counts).
- installed — false while install.sh is still provisioning.
- vpnDomain/panelDomain — domains chosen at install time.
- services — live systemd state per plane (svcstat.sample()).
- nics — per-interface inventory: name, up/down, speedMbps, mtu, duplex, ip, byte counters, rxBps/txBps, error/drop counts.
- usersRev — revision of the user set the node currently applies.
Accounting correctness
- SSH bytes are billed exactly once, via the separate cumulative channel
POST /api/public/node-ssh-usage(agent/sshreport.py). The heartbeat intentionally zeroes SSH's protocol delta (proto_drx["ssh"]=0) to avoid double-billing. - Directional swap migration: an early build stored rx=download/tx=upload backwards vs. the panel's convention (rx=upload, tx=download);
_load_vpn_state()performs a one-timedirectionVersionmigration and persists it immediately to survive a crash mid-swap. - Reverse-proxy double counting: raw NIC deltas see every proxied byte twice. When Xray's stats API is unreachable, the agent prefers iptables client-leg counters (services/wireacct.py) and only falls back to halving raw NIC deltas as a last resort, attributing the larger side to download.
- Counter reset detection:
counter_delta.delta_since()centralizes "counter went backwards" handling and callson_reset, pushed as acounter.resetnode event. - User ledger pruning: when a user disappears from a push,
_prune_user_ledgerdrops their cumulative row and forgets Xray/SSH counter baselines so a recycled username never inherits a deleted account's history, emittinguser.ledger.pruned. - MAX_REASONABLE_BPS = 100 Gbps — anything above this is treated as a counter glitch and reported as 0 rather than a fake multi-Tbps spike.
The panel is the single source of truth for accounts. It pushes the full user set to a node either piggybacked on the heartbeat response (pull model, usersRev diffing avoids re-pushing unchanged sets), or directly via POST /rpc/users.apply (push model, used for immediate changes like "user was deleted"). Both paths converge on the same logic:
- 1
enforce.set_account_map(all_users)+enforce.apply_resets(all_users)— refresh username↔accountId map, honour panel-side quota resets. - 2
enforce.filter_active()splits each protocol's user list into servable vs. blocked (expired / over-quota / panel forceBlock/status). - 3
enforce.publish_gate()writes the connect-time admission gate (tmpfs marker files under/run/autoscript-node/gate/{managed,allow}/<login>) before any service reload, so a just-blocked user can never sneak a login in during the reload window.enforce.admit(name)is an O(1) stat()-based check a login hook calls. - 4
xray.apply_users(),ssh_svc.apply_users(),zivpn_svc.apply_users()rewrite/usr/local/etc/xray/config.json, OS-level SSH accounts, and/etc/zivpn/config.json, reloading the affected service. - 5Accounts removed from the panel are removed from the OS/Xray config and their local quota/traffic ledger rows are wiped (enforce.reset_user, _purge_vpn_user).
- 6system_policy.apply(policy, all_users) — ports / DNS / DoH block / torrent block / outbound routing for SSH+ZIVPN planes.
- 7warp.apply(all_users, policy) — kernel-level WARP egress for SSH/ZIVPN accounts assigned a WARP outbound (Xray handles its own WARP internally).
- 8limits.set_users(all_users) — refresh per-user IP-limit/speed-limit state.
All endpoints are POST /rpc/<name> unless noted, requireAuthorization: Bearer <BEARER> plus (X-Timestamp, X-Signature) when SHARED_SECRET is configured, and are proxied by the panel through POST /api/public/node-rpc {nodeId, host, port, method, payload, bearer?, timeoutMs?}.
| Method | Purpose |
|---|---|
GET /rpc/status | Cheap liveness + per-service up/down (xray/nginx/zivpn/ssh) + agent version. |
POST /rpc/agent.version | Confirm the agent build after an update. |
GET /rpc/ws | WebSocket live event push (service state changes, alerts); requires a signed first message {ts, sig}. |
POST /rpc/users.apply | Direct authoritative user-set push. Returns {ok, blocked, usersRev}. |
POST /rpc/traffic.reset | Zero this node's lifetime VPN byte ledger. |
POST /rpc/xray.version | Current Xray-core version (+ releases list when withReleases:true). |
POST /rpc/xray.install | Pin Xray-core to a specific release (upgrade or downgrade). |
POST /rpc/logs.purge | Wipe all node logs ({deep:true} for a deeper journal wipe). |
POST /rpc/firewall.apply | Live-reconcile the control-plane IP allowlist (no reinstall needed). |
POST /rpc/system.tune | Re-apply BBR + sysctl tuning without a reinstall ({bbr:false} reverts to cubic). |
POST /rpc/backup.snapshot | Restorable node state (Xray config, ZIVPN config, users.rev, quota.json, vpn-traffic.json, account list); secrets excluded. |
POST /rpc/service.restart | Restart one named service plane (xray/nginx/zivpn/ssh). |
POST /rpc/logs | On-demand journal tails per requested unit + certificate log. |
POST /rpc/cert.issue | Issue/renew a SAN TLS cert for the node's VPN domain(s) via acme.sh; reloads nginx/xray/zivpn. |
POST /rpc/node.config | Panel pushes protocol/status config (protocols, zivpnObfs, status: enabled|disabled); best-effort apply. |
POST /rpc/ping | Liveness probe called when heartbeats stop; returns per-service up/down. |
POST /rpc/vpn.halt | Stop the traffic planes (Xray/ZIVPN) but keep the agent alive; {resume:true} un-halts. sshd stays up. |
POST /rpc/node.restart | Restart every service plane, then self-restart the agent (recovery for a reachable-but-silent node). |
POST /rpc/node.health | Post-update health checks; returns which checks failed. |
POST /rpc/net.speedtest | On-box network capacity benchmark. |
POST /rpc/net.probe | Latency/jitter/packet-loss probe to a target list. |
POST /rpc/node.rebuild | Re-apply permanent tuning + restart planes without touching users/certs/domains/ports. |
POST /rpc/agent.update_safe | Atomic safe update: snapshot -> tune -> rebuild -> health-check -> agent.update. Aborts with a concrete error before detaching if any step fails. |
POST /rpc/agent.stop | systemctl stop autoscript-node (used by bulk "Stop all"); node stays down until an admin restarts it. |
POST /rpc/agent.update | In-place agent code update: runs /usr/local/bin/autoscript-node-update if present, else curl | bash against AGENT_UPDATE_URL, else pulls $MASTER/api/public/node-agent.tar.gz. Runs detached via systemd-run --unit=autoscript-node-update (falls back to Popen). |
note
x-node-timestamp/x-node-signature, middleware_sign_response) so the panel can verify the reply really came from the node holdingSHARED_SECRET.- Per-method timeout table (
METHOD_TIMEOUT_MS): most calls 5s default;net.speedtest90s,net.probe45s,agent.update/agent.update_safe/node.rebuild300s,node.restart120s,node.health60s,backup.snapshot120s,backup.restore_latest180s,users.apply/users.push90s,logs.purge/firewall.apply/system.tune60s,xray.install300s,xray.version30s,agent.stop30s,ping3s. - A circuit breaker trips after 2 consecutive failures on read-only status-style methods (ping, status, xray.version, net.probe, node.health) and fast-fails for 45s (BREAKER_COOLDOWN_MS) instead of opening a new TLS connection each poll — keeps the whole panel UI from freezing when a node is dead.
- Dials host (panelDomain/ip), falling back to fallbackHost then raw ip when DNS resolution fails.
- Keep-alive https.Agent pool, one per host:port, so repeat calls reuse an already pin-verified socket.
node/agent/services/enforce.py runs node-local, even while the panel is unreachable:
- Expiry — expiresAt (epoch ms or ISO), blocked once past.
- Quota — quotaGb (lifetime) and dailyQuotaGb (resets at local midnight). Ledger is keyed by the panel's stable accountId (fallback: username). Uses max(carry, local_total) — never sum() — against the panel's fleet-wide usedBytes to avoid double counting.
- Panel resets — usageResetAt/dailyResetAt watermarks let every node independently honour a reset exactly once.
- Fleet-wide verdicts — the panel computes suspension/disable across the whole fleet and ships it every heartbeat as forceBlock/status/_fleet_blocks; the node treats this as authoritative and cuts service immediately.
- Connect-time admission gate — tmpfs marker files at
/run/autoscript-node/gate/managed/<login>and.../allow/<login>, refreshed only on diff. A login hook does two stat() calls to answer "may this login connect right now?" in microseconds. Login name =grvpn-<accountId>(or hashed suffix if too long) — never the recyclable username. - note_blocked()/push_event() emit user.blocked/user.unblocked/user.quota.reset/user.removed/user.ledger.pruned node events, which ride the heartbeat's event queue and are also fired as outbound webhooks (Telegram/Discord) via agent/webhook.py.
| Var | Meaning |
|---|---|
| NODE_ID | Stable node identifier the agent echoes on every handshake/heartbeat. |
| MASTER | Panel origin, e.g. https://panel.example.com (no trailing slash). |
| AGENT_PORT | RPC/heartbeat HTTPS listener port (default 4001). |
| BEARER | Per-node bearer token for RPC/WebSocket auth. |
| SHARED_SECRET | HMAC key for request/response signing + replay protection. |
| VPN_DOMAIN / PANEL_DOMAIN | Customer-facing VPN hostname / node API hostname, install-time only, reported back on every heartbeat. |
| AGENT_UPDATE_URL | Optional override URL for agent.update's curl | bash path (box-local only). |
| ACME_EMAIL | Contact email for Let's Encrypt account registration. |
| EXPECTED_IP | Install-time-only safety check against the wrong VPS. |
| DNS_API | acme.sh DNS provider plugin name for wildcard (dns-01) issuance. |
| PRESENCE_WINDOW_SEC | Override for the "online now" IP presence window (default 45s). |
| SHAPE_* (SHAPE_UP_BURST_SEC, SHAPE_BURST_SEC, SHAPE_BURST_FLOOR_KB, SHAPE_CODEL_TARGET, SHAPE_CODEL_INTERVAL, SHAPE_QUEUE_SEC, SHAPE_INITCWND) | Fine-tuning knobs for the HTB/fq_codel speed shaper. |
| NETBENCH_BUDGET_SEC | Wall-clock budget override for net.speedtest (default 55s). |
Panel side
- NODE_SHARED_SECRET
- Fleet-wide fallback HMAC secret used when no per-node override exists.
- NODE_SECRET_<SLUG>
- Per-node HMAC secret override (<SLUG> = sanitized nodeId).
- JWT_SECRET
- Required for requireAdmin() to authorize panel RPC callers (fails closed / 503 if unset — no bypass).
| Unit | Role |
|---|---|
| autoscript-node.service | The Python node agent (RPC + heartbeat + SSH-usage loop). Sandboxed (ProtectSystem=strict, explicit ReadWritePaths), Nice=10/CPUWeight=20 so telemetry never outranks the data plane, AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE. |
| autoscript-node-ssh-ws.service | SSH-over-WebSocket bridge (python -m agent.ssh_ws), Nice=-5/CPUWeight=800 — deliberately outranks the agent since it is customer data plane. |
| autoscript-node-stunnel.service | stunnel4 for SSH-over-TLS on 445/447/777. |
| autoscript-node-logclean.service / .timer | Journal vacuum + /var/log/autoscript prune every 10 minutes. |
| xray.service (+ xray.service.d/99-autoscript-tuning.conf) | Xray-core; override grants root + CAP_NET_ADMIN/CAP_NET_BIND_SERVICE/CAP_NET_RAW for SO_MARK shaping, raises file-descriptor limits, always restarts. |
| zivpn.service | ZIVPN UDP server, generated at install time. |
| ssh / sshd.service | Native OpenSSH, tuned via sshd_config.d/99-autoscript-node.conf. |
| nginx.service | TLS termination + Cloudflare-port fan-out reverse proxy. |
| Legacy: autoscript-ip-limit.service/.timer | Old single-box per-UID shaper; actively disabled + masked by install.sh if found. |
| backend/systemd/autoscript-* (agent, bot, ip-limit, ssh-ws, stunnel, web) | Single-box ("local node") panel install units — same roles as above, for the panel's own built-in node. |
