vps: WireGuard + rootless HAProxy Quadlet relay replaces frp #301

Merged
mysticalsoap merged 5 commits from feat/vps-wireguard-relay into trunk 2026-09-20 23:56:32 -04:00
Owner

Problem

frps on the VPS ran as root from a bare binary, taking token-authenticated logins on a public port, and held 80/443 so anything else on the box would have needed a second proxy stacked in front of it. Debian 12's Podman 4.3 predated Quadlet and pasta (#100, #101). The live frps.toml also lacked the documented allowPorts, and ufw had 22 on allow rather than limit.

Fix

  • VPS reinstalled to Debian 13 in place (IP kept). _helper/vps/ is the whole host state; install.sh converges it, just vps-deploy ships it over a vps ssh alias so the repo never holds the address.
  • WireGuard on the VPS host. The private key is generated on the box and loaded by PostUp; the conf in git carries public keys only. install.sh refuses to start the tunnel while the peer key is the placeholder and restarts the interface when the key file no longer matches the running one, so rotation is "delete the key, deploy".
  • HAProxy in TCP mode as a rootless Quadlet (user relay, pasta) on 80/443/27341 with PROXY v2 to the home end. A stick table on 27341 replaces ufw limit; the 443 frontend already inspects the ClientHello so the status page later is a use_backend on SNI. No start limit on the unit: at boot the user manager can start pasta before the default route exists.
  • Home: wireguard container (LSIO) dials the VPS; a haproxy sidecar in its network namespace re-emits PROXY v2 to Traefik and forwards 2222 to Forgejo. Forwarding is off in that namespace and each listener rejects any peer but the VPS's tunnel address, so the tunnel reaches those three listeners and nothing else. Traefik now trusts PROXY headers from the container's fixed /32 instead of the whole proxy subnet.
  • frpc, frpc.toml and the frp secret consumers removed. Docs, README, topology diagram, Renovate (regex manager for Quadlet Image= lines; needs-vps-sync on _helper/vps/**) updated. A subagent security review's findings are folded in.

Verification

  • just vps-deploy on the fresh Debian 13: wg0 up, haproxy.service active under user@1002 with pasta holding 80/443/27341, ~130 MB peak.
  • just up infra: wireguard healthy (handshake-age healthcheck), handshake 32 s old at check time, keepalive 25 s.
  • curl --resolve mysticalsoap.com:443:<vps> from home: 200; port 80: 301. Traefik's access log shows the real client address, not 172.18.3.254.
  • ssh -T to Forgejo through <vps>:27341: authenticated with the real key.
  • A forged PROXY header sent from a container on proxy to 172.18.3.254:8080 gets no response; the same request through the tunnel gets the redirect.
  • Both haproxy.cfg files pass haproxy -c with the pinned image; docker compose config is clean.
  • Not verified yet: the source address in the VPS-side tcplog lines (needs sudo on the box), and the first VPS reboot.

Closes #100, closes #101. PR #13 (frpc 0.71.0) is superseded; close it rather than merge.

After merge: the VPS already runs this tree, so no just vps-deploy. Delete infra/secrets/frp_server_addr.txt and frp_token.txt by hand.

## Problem frps on the VPS ran as root from a bare binary, taking token-authenticated logins on a public port, and held 80/443 so anything else on the box would have needed a second proxy stacked in front of it. Debian 12's Podman 4.3 predated Quadlet and pasta (#100, #101). The live `frps.toml` also lacked the documented `allowPorts`, and ufw had 22 on `allow` rather than `limit`. ## Fix - VPS reinstalled to Debian 13 in place (IP kept). `_helper/vps/` is the whole host state; `install.sh` converges it, `just vps-deploy` ships it over a `vps` ssh alias so the repo never holds the address. - WireGuard on the VPS host. The private key is generated on the box and loaded by PostUp; the conf in git carries public keys only. `install.sh` refuses to start the tunnel while the peer key is the placeholder and restarts the interface when the key file no longer matches the running one, so rotation is "delete the key, deploy". - HAProxy in TCP mode as a rootless Quadlet (user `relay`, pasta) on 80/443/27341 with PROXY v2 to the home end. A stick table on 27341 replaces `ufw limit`; the 443 frontend already inspects the ClientHello so the status page later is a `use_backend` on SNI. No start limit on the unit: at boot the user manager can start pasta before the default route exists. - Home: `wireguard` container (LSIO) dials the VPS; a `haproxy` sidecar in its network namespace re-emits PROXY v2 to Traefik and forwards 2222 to Forgejo. Forwarding is off in that namespace and each listener rejects any peer but the VPS's tunnel address, so the tunnel reaches those three listeners and nothing else. Traefik now trusts PROXY headers from the container's fixed `/32` instead of the whole proxy subnet. - frpc, `frpc.toml` and the frp secret consumers removed. Docs, README, topology diagram, Renovate (regex manager for Quadlet `Image=` lines; `needs-vps-sync` on `_helper/vps/**`) updated. A subagent security review's findings are folded in. ## Verification - `just vps-deploy` on the fresh Debian 13: wg0 up, `haproxy.service` active under `user@1002` with pasta holding 80/443/27341, ~130 MB peak. - `just up infra`: `wireguard` healthy (handshake-age healthcheck), handshake 32 s old at check time, keepalive 25 s. - `curl --resolve mysticalsoap.com:443:<vps>` from home: 200; port 80: 301. Traefik's access log shows the real client address, not `172.18.3.254`. - `ssh -T` to Forgejo through `<vps>:27341`: authenticated with the real key. - A forged PROXY header sent from a container on `proxy` to `172.18.3.254:8080` gets no response; the same request through the tunnel gets the redirect. - Both `haproxy.cfg` files pass `haproxy -c` with the pinned image; `docker compose config` is clean. - Not verified yet: the source address in the VPS-side tcplog lines (needs sudo on the box), and the first VPS reboot. Closes #100, closes #101. PR #13 (frpc 0.71.0) is superseded; close it rather than merge. After merge: the VPS already runs this tree, so no `just vps-deploy`. Delete `infra/secrets/frp_server_addr.txt` and `frp_token.txt` by hand.
Replaces the bare frps binary; the VPS's state lives in _helper/vps.
HAProxy on the VPS owns 80 and 443, so a later service on this box
(status page) is a use_backend on SNI rather than a second proxy stacked
in front of frps. WireGuard answers nothing to an unauthenticated
packet, where frps took token-authenticated logins on a public port.
Both ends are packages or a digest-pinned image; no release-tarball
fetch.

Rootless with pasta because pasta keeps the client's source address on
inbound connections; a rootless bridge network masquerades it and the
whole CrowdSec/geoblock/ratelimit chain keys on that address. The port
floor sysctl exists so pasta can bind 80 as the relay user, and again
inside the container because the image runs as its own user. The unit
carries no start limit: at boot the relay user's manager can start
pasta before the default route exists.

The private key is generated on the box and loaded by PostUp, so the
conf in git carries public keys only; install.sh refuses to start the
tunnel while the peer key is still the placeholder, and restarts the
interface when the key file no longer matches the running one, so a
rotation is "delete the key, deploy". The sshd rules are a 00- drop-in
because cloud-init ships a 50- one and sshd keeps the first value it
reads. The 443 frontend rejects what never sends a ClientHello; the
ssh frontend's stick table rejects the sixth connection in 30 s from
one source, the same threshold ufw limit applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wireguard container dials the VPS, so nothing at home accepts an
inbound connection. haproxy shares its network namespace; forwarding is
off there and each listener rejects any peer but the VPS's tunnel
address, so the tunnel reaches those three listeners and nothing else.
frp exposed three ports; without the sysctl a WireGuard peer could inject
packets at any proxy-network container. It accepts the PROXY v2 header
from the VPS and re-emits it to Traefik; the container has a fixed
address and Traefik now trusts the header from that /32 instead of the
whole proxy subnet, so a compromised app container can no longer hand
Traefik a forged client address. Unprivileged listen ports because the
image runs as its own user and a joined namespace cannot carry its own
sysctls.

wg0.conf is mounted whole from the secrets dir: WireGuard carries the
private key inline and the image reads a file, not an env var. The
healthcheck exists because the image stays up with no tunnel when
wg-quick fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
just vps-deploy goes through an ssh alias so the repo never holds the
VPS address, and ships the tree with tar over ssh so a fresh image needs
nothing installed first. Renovate has no Quadlet manager, so a regex
manager reads the Image= lines; its anchors are newline-based because
Renovate matches against the whole file. needs-vps-sync now applies to
anything under _helper/vps/ instead of the frpc image. The diagram's
pinned-version callout existed for frpc's server coupling, which
WireGuard does not have.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vps: home peer public key
All checks were successful
validate / validate (pull_request) Successful in 25s
538a51faa5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs: labelled key-pair command
All checks were successful
validate / validate (pull_request) Successful in 22s
babcef6be5
tee into a process substitution prints the two keys in whichever order
the processes finish.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mysticalsoap deleted branch feat/vps-wireguard-relay 2026-09-20 23:56:33 -04:00
Sign in to join this conversation.
No description provided.