M23 — Upload the VPN configuration from the dashboard
Status: accepted. Builds on M21 (docs/design/m21.md).
1. Goal
Section titled “1. Goal”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_ADMINand no route to the internet; - the private key is never shown again, never logged, never committed.
2. Flow
Section titled “2. Flow”- The admin opens Settings → VPN and picks a
.conffile (at most 4 KiB). They type their password again (the existing re-authentication pattern). - The API parses the file strictly (section 3). On success it writes
wg.envandmeta.jsonatomically into thevpn-configvolume, and records an audit entry. The response gives the non-secret summary only. - The
vpncontainer watcheswg.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,ingestandcollectorhave no route out at all (they sit on internal networks and reach the outside only throughvpn): the kill switch holds by construction. - The egress gate in
ingestandcollectorchecks the new exit address. If it is outside the allowed ranges, the gate stays closed withip_not_allowedand publishes the address it saw (already the case in M21). - 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_CIDRSand open. - “Use the .env settings again” deletes the uploaded files; the
vpncontainer restarts on the.envvalues.
3. Strict parsing (API)
Section titled “3. Strict parsing (API)”Input is the text of a wg-quick file. Accepted, case-insensitive keys:
| Section | Key | Rule |
|---|---|---|
[Interface] | PrivateKey | base64 of 32 bytes (44 chars ending in =), required |
[Interface] | Address | comma list of IPv4/IPv6 interface CIDRs; at least one IPv4 |
[Interface] | DNS | accepted and ignored (gluetun resolves over the tunnel itself) |
[Peer] | PublicKey | base64 of 32 bytes, required |
[Peer] | Endpoint | IPv4:port literal only (no host name: no DNS lookup outside the tunnel), a global unicast address, port 1–65535 |
[Peer] | AllowedIPs | must contain 0.0.0.0/0 (full tunnel); ::/0 accepted |
[Peer] | PersistentKeepalive | accepted and ignored |
[Peer] | PresharedKey | refused 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.
4. Files in the vpn-config volume
Section titled “4. Files in the vpn-config volume”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.
5. Trusted exits
Section titled “5. Trusted exits”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_ipof theegress(oregress_collector) row ofenrichment_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.
6. API
Section titled “6. API”All routes are admin only (viewers get 403); the mutating ones need the re-authentication password and pass the existing CSRF check.
| Route | Result |
|---|---|
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/vpn | body {conf, password}; 200 with the summary; 413 too_large; 422 invalid_wireguard_config with problems: [code]; 403 reauth_failed |
DELETE /api/v1/settings/vpn | body {password}; deletes the uploaded files; 204 |
POST /api/v1/settings/vpn/trusted-exits | body {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.
7. Risks
Section titled “7. Risks”- 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.envis mode 0644 inside a volume that onlyapiandvpnmount: gluetun’s root has noCAP_DAC_OVERRIDEand could not read a 0600 file owned by the API user.
8. Changes after the security review
Section titled “8. Changes after the security review”- The trust candidate comes from the ingest
egressrow only, never from the collector’s row (the collector handles attacker content). - The gate closes with
exit_is_selfwhen the exit is one of the honeypot’s own addresses (report_self_terms, the same “active” rule as the abuse worker), and withexit_asn_deniedwhen its AS is listed inEGRESS_DENY_ASNS. Such an exit is never offered for trust (trust_blockedin the GET answer, 409 on POST). The ingest gate publishes the exit’s AS and organisation from the local GeoLite2 database. - The
vpnentrypoint refuses a non-global uploaded endpoint and parses a tmpfs snapshot ofwg.env, so the checksum always matches the values in use.