Skip to content

M23 — Upload the VPN configuration from the dashboard

Status: accepted. Builds on M21 (docs/design/m21.md).

The owner replaces the WireGuard configuration of the vpn service by uploading the .conf file their VPN provider gives them, in Settings → VPN, instead of editing .env on the VM. The switch happens without a shell on the VM, and without weakening any M21 guarantee:

  • nothing leaves the stack outside the tunnel, before, during or after the switch;
  • a new exit address is not used until the owner trusts it in the UI;
  • the API gets no Docker socket, no NET_ADMIN and no route to the internet;
  • the private key is never shown again, never logged, never committed.
  1. The admin opens Settings → VPN and picks a .conf file (at most 4 KiB). They type their password again (the existing re-authentication pattern).
  2. The API parses the file strictly (section 3). On success it writes wg.env and meta.json atomically into the vpn-config volume, and records an audit entry. The response gives the non-secret summary only.
  3. The vpn container watches wg.env. When its content changes, it stops gluetun and exits; Docker restarts it (restart: unless-stopped) and the new start uses the uploaded file. While it restarts, ingest and collector have no route out at all (they sit on internal networks and reach the outside only through vpn): the kill switch holds by construction.
  4. The egress gate in ingest and collector checks the new exit address. If it is outside the allowed ranges, the gate stays closed with ip_not_allowed and publishes the address it saw (already the case in M21).
  5. The UI shows that address and offers “Trust this exit”: the API stores the /24 (IPv4) or /48 (IPv6) that contains the address the gate observed, never a range the client types. The gates add trusted ranges to EGRESS_ALLOWED_CIDRS and open.
  6. “Use the .env settings again” deletes the uploaded files; the vpn container restarts on the .env values.

Input is the text of a wg-quick file. Accepted, case-insensitive keys:

SectionKeyRule
[Interface]PrivateKeybase64 of 32 bytes (44 chars ending in =), required
[Interface]Addresscomma list of IPv4/IPv6 interface CIDRs; at least one IPv4
[Interface]DNSaccepted and ignored (gluetun resolves over the tunnel itself)
[Peer]PublicKeybase64 of 32 bytes, required
[Peer]EndpointIPv4:port literal only (no host name: no DNS lookup outside the tunnel), a global unicast address, port 1–65535
[Peer]AllowedIPsmust contain 0.0.0.0/0 (full tunnel); ::/0 accepted
[Peer]PersistentKeepaliveaccepted and ignored
[Peer]PresharedKeyrefused for now (gluetun custom provider support not wired)

Everything else is refused, in particular PreUp, PostUp, PreDown, PostDown, Table, FwMark, SaveConfig, MTU, a second [Peer] or [Interface], and any NUL or non-ASCII byte. Comments (#, ;) and blank lines are dropped. Errors are returned as codes, never by echoing the input.

Mounted at /vpn-config: read-write in api, read-only in vpn, and in no other service (guardrail test). Written with a temp file + rename, so vpn never sees half a file.

wg.env, one KEY=value per line, exactly these keys, no quotes:

WIREGUARD_PRIVATE_KEY=<base64>
WIREGUARD_PUBLIC_KEY=<base64>
WIREGUARD_ADDRESSES=<comma list>
WIREGUARD_ENDPOINT_IP=<ipv4>
WIREGUARD_ENDPOINT_PORT=<port>

meta.json (non-secret): {"uploaded_at", "uploaded_by", "endpoint_ip", "endpoint_port", "addresses", "peer_key_fingerprint"}; the fingerprint is the first 12 hex chars of the sha256 of the peer public key.

The vpn entrypoint never sources the file: it reads each key with a fixed regular expression and refuses to start (fail closed) if any value does not match. Without wg.env it uses the VPN_WG_* values from .env, as in M21.

Table egress_trusted_exits (id, cidr UNIQUE, created_at, created_by), at most 8 rows. suckerpot_api may select, insert and delete; suckerpot_ingest and suckerpot_collector select only. The API inserts only:

  • the /24 or /48 containing egress_ip of the egress (or egress_collector) row of enrichment_status,
  • when that row says reason = "ip_not_allowed" and was checked in the last 10 minutes,
  • and when the address is global unicast.

The gate reads the table at most once a minute; a DB error keeps the last good list (or the .env list alone at start). The tunnel, echo-agreement and global-address checks of M21 are unchanged.

All routes are admin only (viewers get 403); the mutating ones need the re-authentication password and pass the existing CSRF check.

RouteResult
GET /api/v1/settings/vpn{source: "env" | "upload", uploaded_at, uploaded_by, endpoint_ip, endpoint_port, addresses, peer_key_fingerprint, gate: {state, reason, egress_ip, checked_at}, trusted_exits: [{id, cidr, created_at, created_by}], trust_candidate: cidr | null}
PUT /api/v1/settings/vpnbody {conf, password}; 200 with the summary; 413 too_large; 422 invalid_wireguard_config with problems: [code]; 403 reauth_failed
DELETE /api/v1/settings/vpnbody {password}; deletes the uploaded files; 204
POST /api/v1/settings/vpn/trusted-exitsbody {password}; trusts trust_candidate; 201 with the row; 409 no_candidate; 409 too_many
DELETE /api/v1/settings/vpn/trusted-exits/{id}body {password}; 204

Audit actions: vpn.upload, vpn.revert, vpn.trust_exit, vpn.untrust_exit; the target is the endpoint or the CIDR, never a key.

  • A compromised admin session can change the tunnel. It could already read every captured record. Mitigations: re-authentication, audit entries, strict parsing, and the endpoint only ever learns what any VPN provider learns.
  • The WireGuard endpoint always sees the home IP (it is the other end of the tunnel). The UI says so: only upload a file from your own VPN provider.
  • A file readable by two containers. wg.env is mode 0644 inside a volume that only api and vpn mount: gluetun’s root has no CAP_DAC_OVERRIDE and could not read a 0600 file owned by the API user.
  • The trust candidate comes from the ingest egress row only, never from the collector’s row (the collector handles attacker content).
  • The gate closes with exit_is_self when the exit is one of the honeypot’s own addresses (report_self_terms, the same “active” rule as the abuse worker), and with exit_asn_denied when its AS is listed in EGRESS_DENY_ASNS. Such an exit is never offered for trust (trust_blocked in the GET answer, 409 on POST). The ingest gate publishes the exit’s AS and organisation from the local GeoLite2 database.
  • The vpn entrypoint refuses a non-global uploaded endpoint and parses a tmpfs snapshot of wg.env, so the checksum always matches the values in use.