Fleet

Node security

Vortexa nodes are trusted with a self-signed certificate pinned by fingerprint plus two independent HMAC/bearer secrets — never a real CA chain. This page covers the trust model, the firewall that protects the agent port, the /nodes-security UI, and hardening/rotation procedures.

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. 1
    install.sh generates a self-signed EC (prime256v1) certificate/key at /etc/autoscript/mtls/server.crt / server.key (10-year validity, CN=grvpn-node-<NODE_ID>).
  2. 2
    The agent serves HTTPS on AGENT_PORT (default 4001) with that cert (node/agent/main.py::_serve).
  3. 3
    On 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-Timestamp and, if SHARED_SECRET is set, X-Signature = HMAC_SHA256(SHARED_SECRET, "<ts>.<body>"). Optional Authorization: Bearer <BEARER>.
  4. 4
    The panel (node-handshake.ts) verifies the HMAC via verifyNodeHmac, stores {fingerprint, agentPort, pairedAt, updatedAt, version} in an in-memory Map mirrored to durable KV via persist.savePin, keyed by nodeId. GET /api/public/node-handshake?nodeId=... lets the UI show "paired" without exposing the fingerprint.
  5. 5
    Every later RPC call from the panel (node-rpc.ts) opens a raw TLS socket to the node with rejectUnauthorized:false and instead compares the presented certificate's SHA-256 against the pinned fingerprint in secureConnect. 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.
  6. 6
    If the fingerprint doesn't match, the RPC request is destroyed before a byte of the real payload is sent ("fingerprint mismatch" error).
  7. 7
    If 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.
  8. 8
    Response 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_response middleware). 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".
  9. 9
    Manual 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.tsx calls POST /api/nodes/:id/force-repin).
SecretProves
BEARERA 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_SECRETHMAC 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

The Node security & audit page exposes a "Control-plane firewall" tab to edit the allowed panel-IP allowlist live per node, backed by RPC method 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

  1. 1
    Generate a new value and set it as a per-node override (NODE_SECRET_<SLUG>) or update the fleet-wide NODE_SHARED_SECRET.
  2. 2
    Re-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.
  3. 3
    Confirm the next heartbeat/RPC call authenticates successfully before removing the old secret from the vault.

Rotating the TLS certificate / fixing a pin mismatch

  1. 1
    Re-run install.sh on the node (or let acme.sh auto-renew) to reissue the certificate.
  2. 2
    In the panel, use "Force re-pin" on the node detail page, or call POST /api/public/node-rpc {method:"_force_repin"}.
  3. 3
    The 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

This means the node's certificate changed (reinstall, cert rotation) but the panel's pin is stale. Use "Force re-pin" in the UI, or wait for the next heartbeat to auto-recover the pin ifreadBeats()[nodeId].fingerprint is fresh.