English · 简体中文
Reproducible IKEv2 road-warrior VPN on Docker + host systemd, with the failure modes that cost me a day already fixed.
Phones connect, get a virtual IP, route all traffic out through the gateway's address. That is the whole scope.
Probably not, if you just want a VPN. Use
hwdsl2/setup-ipsec-vpn — broader
protocol support, real client management (ikev2.sh --addclient/--revokeclient),
certificate/EAP auth, tested across distributions.
This kit exists for a narrower case: you run vimagick/strongswan in
network_mode: host on a Debian/Ubuntu box behind NAT, and you want the host
path plane to be correct and durable. It is a reproducible record of one working
deployment, not a general-purpose installer.
Worth stealing regardless of how you deploy:
scripts/vpn-doctor.sh— read-only diagnosis in the order that converges- the MSS clamp in
scripts/vpn-path-persist.sh— why
Three planes, and debugging them out of order is what wastes the day:
| Plane | Owns | Lives in | Survives docker restart? |
|---|---|---|---|
| Load | charon read conf/secrets | container | yes if volumes are right |
| Tunnel | SA, VIP, ESP counters | container | yes |
| Path | forward hook, VTI, route, SNAT, MSS | host | no — must be re-applied |
network_mode: host misleads people here. The container shares the host netns
but does not own its routes or iptables. A perfectly healthy container with a
dead path plane is exactly "connects, no internet" — and if the Load plane is
down, every NAT theory you test is noise.
Needs: Debian/Ubuntu, root, Docker, iptables, nftables.
git clone https://github.com/Jieoz/strongswan-roadwarrior-kit.git
cd strongswan-roadwarrior-kit
cp vpn.env.example vpn.env
$EDITOR vpn.env # the only file you edit
sudo ./install.sh
sudo /root/ipsec-vpn/vpn-doctor.shOpen UDP 500 and UDP 4500 to the host. Nothing else. 1701 is L2TP; this
kit is IKEv2-only.
install.sh renders config from vpn.env, installs the units, gates on the load
plane (fails loudly if charon read 0 bytes), and re-runs the path script to prove
rules do not duplicate.
| Key | Notes |
|---|---|
HOST_IP |
address actually on the uplink; the SNAT target. Behind NAT this is private |
OUT_IF |
uplink interface (ip -br a) |
PUBLIC_ID |
what clients dial; used for leftid |
VIP_NET / VIP_GW |
must not overlap your LAN |
MARK |
shared by mark_in/mark_out and the VTI key — must match |
CLAMP_MSS |
~40 below TUNNEL_MTU |
VPN_CLIENTS |
id:psk pairs, space-separated |
Generate keys: openssl rand -base64 24
Each device gets an ID and its own PSK:
VPN_CLIENTS="phone:xxxx laptop:yyyy spare:zzzz"
On the phone: type IKEv2/IPsec PSK, the ID in the identifier field, that device's key as the pre-shared key. Some UIs label the ID field "username" — there is no account system here; see docs/CLIENTS.md.
This is not decoration. Several live global : PSK lines do not work: IKEv2
must choose a signing key before it has seen IDi, so only the last line is ever
used and every other device gets MAC mismatched. Named IDs are what make
per-device logging and revocation possible.
/root/ipsec-vpn/vpn-doctor.sh # read-only, safe on a live box
docker exec strongswan-vpn ipsec statusall # SAs and byte counters
tail -f /var/log/vpn-status.log # 15-minute snapshots
bash /root/ipsec-vpn/vpn-path-persist.sh # re-apply path plane (idempotent)Add or revoke a client: edit VPN_CLIENTS, re-run ./install.sh, then
docker restart strongswan-vpn. This drops every active session.
Enabled units are not proof. Two failures pass every static check:
- Non-idempotent rules. A bare
-Ain a script that runs on every boot doubles rules each time (2 → 4 → 6) whilegrep,bash -nandsystemctl is-enabledall stay happy. Every rule here is-Cguarded, andinstall.shre-runs the script and counts. - Never actually rebooting. Only a real reboot proves it.
unattended-upgradescan stall shutdown so it looks rebooted when it was not:
sudo reboot
# after it comes back:
uptime -p # must be minutes
iptables -t mangle -S FORWARD | grep -c -i tcpmss # must be 2, not 4
/root/ipsec-vpn/vpn-doctor.shNine failures, what each looked like, and why: docs/LESSONS.md.
The one that generalises furthest: "another client works fine" does not clear
the server. It proves auth, tunnel, 0.0.0.0/0 and SNAT are good. It says
nothing about whether the server compensates for client differences — MTU/PMTU,
IPv6, split routing. A missing MSS clamp shows up as "one specific site never
loads, on one specific phone", and looks exactly like a client bug.
Also: the address clients dial is not necessarily the address the world sees.
Behind upstream NAT they differ, and any IP allowlist needs the egress one.
vpn-doctor.sh prints both.
vpn.env.example the only file you edit
install.sh render config, install units, verify
scripts/
vpn-path-persist.sh THE authoritative path plane (idempotent)
vpn-doctor.sh read-only diagnosis, LOAD -> TUNNEL -> PATH -> MTU
vpn-path-watch.sh re-apply after docker restart / ipsec0 loss
vpn-status.sh quiet periodic snapshot
config/ ipsec.conf + compose templates
systemd/ boot unit, watchdog, status timer
docs/ LESSONS.md, TROUBLESHOOTING.md, CLIENTS.md
tests/ 50 logic checks + 36 real path-plane checks (CI)
One authoritative path script. If something needs fixing, fix it there — a second "just this once" NAT script is how this becomes unmaintainable.
Does: IKEv2 PSK road-warrior, full-tunnel, per-device IDs, durable host path plane, MSS clamp, self-heal, read-only doctor.
Does not: certificates/EAP, IPv6 VIPs (IPv4 pool only), split tunnelling, L2TP/Cisco IPsec, web UI, distributions other than Debian/Ubuntu. For those, use hwdsl2.
MIT