Fleet
Node security
There is no real mutual TLS / CA chain. Instead, the panel pins the node's certificate fingerprint the first time it sees it and verifies that same fingerprint on every later call.
- 1
install.shgenerates a self-signed EC (prime256v1) certificate/key at/etc/autoscript/mtls/server.crt/server.key(10-year validity,CN=grvpn-node-<NODE_ID>). - 2The agent serves HTTPS on
AGENT_PORT(default 4001) with that cert (node/agent/main.py::_serve). - 3On boot the agent POSTs its fingerprint to the panel at
POST /api/public/node-handshake(node/agent/handshake.py): fingerprint =sha256(DER bytes of server.crt), with a fallback to sha256 of the raw PEM file bytes for older panels. Body:{"nodeId", "fingerprint", "clientFingerprint" (legacy), "agentPort", "version":"node-2"}. Headers:X-Timestampand, ifSHARED_SECRETis set,X-Signature = HMAC_SHA256(SHARED_SECRET, "<ts>.<body>"). OptionalAuthorization: Bearer <BEARER>. - 4The panel (
node-handshake.ts) verifies the HMAC viaverifyNodeHmac, stores{fingerprint, agentPort, pairedAt, updatedAt, version}in an in-memory Map mirrored to durable KV viapersist.savePin, keyed bynodeId.GET /api/public/node-handshake?nodeId=...lets the UI show "paired" without exposing the fingerprint. - 5Every later RPC call from the panel (
node-rpc.ts) opens a raw TLS socket to the node withrejectUnauthorized:falseand instead compares the presented certificate's SHA-256 against the pinned fingerprint insecureConnect. It tries 7 fingerprint encodings (certFingerprints(): 64/76-col PEM, LF/CRLF, with/without trailing newline, and raw DER) to survive OpenSSL/Python formatting differences — any single match is accepted. - 6If the fingerprint doesn't match, the RPC request is destroyed before a byte of the real payload is sent ("fingerprint mismatch" error).
- 7If the panel has no pin yet (fresh node) it recovers the fingerprint from the last heartbeat body (
readBeats()[nodeId].fingerprint) instead of failing every call — self-healing after a panel cold start. - 8Response authentication: every RPC response from the node is signed with
X-Node-Timestamp/X-Node-Signature(HMAC-SHA256 over"<ts>.<body>",rpc.py's_sign_responsemiddleware). The panel checks this (verifyNodeSignature) and only trusts the pin as "secretVerified" once the node proves it holds the shared secret — this is what lets the panel safely re-pin a node after a certificate re-issue rather than permanently failing with "fingerprint mismatch". - 9Manual re-pin:
POST /api/public/node-rpc {method:"_force_repin"}drops the stored pin so the next call re-derives it from the next heartbeat. Also exposed from the Node detail page as "force-repin" (nodes_.$id.tsxcallsPOST /api/nodes/:id/force-repin).
| Secret | Proves |
|---|---|
BEARER | A per-node bearer token, checked with hmac.compare_digest against Authorization: Bearer <token> on every RPC and the WebSocket handshake. Proves who is calling. |
SHARED_SECRET | HMAC key over "<ts>.<raw body>". Proves the body wasn't tampered with in flight and blocks replay (±300s window, MAX_SKEW_SEC in rpc.py). |
An attacker needs both secrets to forge a request; leaking one from a log doesn't compromise the other. On the panel side these resolve via getNodeSecret(nodeId) / getNodeBearer(nodeId)— per-node overrides (NODE_SECRET_<SLUG>) fall back to a fleet-wideNODE_SHARED_SECRET. Bearer tokens live in a server-side vault (src/lib/node-secret-vault.server.ts) and are never sent to the browser — aSECRET_SENTINEL placeholder is what the UI actually holds.
The install.sh phase "Locking down agent port" resolves the panel's hostname (from $MASTER) to its A/AAAA records and builds an iptables chain GRVPN_AGENT:
- Accept from lo.
- Accept ESTABLISHED,RELATED.
- Accept from each resolved panel IPv4/IPv6.
- DROP everything else on tcp/$AGENT_PORT.
- DROP all udp/$AGENT_PORT outright (the agent is TCP-only).
Rules are persisted via iptables-persistent to /etc/iptables/rules.v4/v6. User-facing VPN ports (Xray/ZIVPN/SSH) are not firewalled here — they must stay open to the internet.
Live reconciliation
POST /rpc/firewall.apply — no reinstall is needed when the panel's egress IP changes.Two tabs:
- Transport security — explains the pinned agent TLS + shared-secret model (read-only status summary).
- Control-plane firewall — per-node editable allowlist of panel IPs allowed to reach the agent port; "Save allowlist" calls RPC firewall.apply to reconcile the iptables chain live.
The page also documents and links the append-only audit log for every fleet action.
- Keep AGENT_PORT firewalled to the panel's IPs only — never expose it to the open internet even though it uses TLS.
- Rotate SHARED_SECRET and BEARER independently; a leak of one alone does not compromise request integrity or identity.
- Set JWT_SECRET on the panel — requireAdmin() fails closed (503) without it, so panel RPC callers can never be authorized by accident.
- Treat /etc/default/autoscript-node as sensitive: it is mode 0600 and holds NODE_ID, MASTER, AGENT_PORT, BEARER and SHARED_SECRET in plaintext.
- If the panel's egress IP ever changes (new region, new load balancer), update the allowlist via the Control-plane firewall tab immediately, or every node will start failing RPC with a cooling-down circuit breaker.
Rotating the shared secret or bearer token
- 1Generate a new value and set it as a per-node override (NODE_SECRET_<SLUG>) or update the fleet-wide NODE_SHARED_SECRET.
- 2Re-run the install one-liner against the node with the new SHARED_SECRET/BEARER values — the installer is idempotent and rewrites /etc/default/autoscript-node.
- 3Confirm the next heartbeat/RPC call authenticates successfully before removing the old secret from the vault.
Rotating the TLS certificate / fixing a pin mismatch
- 1Re-run install.sh on the node (or let acme.sh auto-renew) to reissue the certificate.
- 2In the panel, use "Force re-pin" on the node detail page, or call POST /api/public/node-rpc {method:"_force_repin"}.
- 3The next heartbeat republishes the fingerprint; the panel re-derives the pin automatically without manual intervention if the response signature (X-Node-Signature) still verifies against SHARED_SECRET.
fingerprint mismatch on every RPC
readBeats()[nodeId].fingerprint is fresh.