A minimal WireGuard gateway container for NordVPN (NordLynx), written in Rust. sas is French for airlock: the container is sealed by default and only opens a controlled passage once the tunnel is up and verified.
It is a partial drop-in for Gluetun: same environment-variable names, same control-server API shape but a fraction of the footprint (final image ≈ 24 MB, idle RAM < 20 MB. 3,4 MB of ram on my NAS).
Point your other containers at it with network_mode: "service:sas" and their
traffic goes through the tunnel, or nowhere at all.
- Kernel WireGuard over netlink. rtnetlink + genetlink + netlink-packet-wireguard.
No
wgoripbinaries are shipped;nftis the only subprocess ever spawned. - Kill switch first, always. An nftables
policy dropis installed before any config is read and is never flushed. Every ruleset change is an atomicnft -ftransaction, so there is never a drop-less window, even during reconnects and server rotation. - Fail-closed everywhere. Invalid config, DNS failure, a dead tunnel, or a crashed process all leave the container sealed and unhealthy rather than leaking.
- Small and auditable. Static musl binary +
nft+ ca-certificates, nothing else. Needs onlycap_add: NET_ADMIN, no/dev/net/tun.
# docker-compose.yml
services:
sas:
build: . # or image: your/sas
container_name: sas
cap_add: [NET_ADMIN]
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
environment:
VPN_SERVICE_PROVIDER: nordvpn
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY} # your NordLynx private key
SERVER_COUNTRIES: Netherlands
HTTP_CONTROL_SERVER_ADDRESS: ":8000"
ports:
- "8000:8000" # control server (optional)
# any container can share the tunnel's network namespace:
app:
image: nicolaka/netshoot
network_mode: "service:sas"
command: ["sleep", "infinity"]export WIREGUARD_PRIVATE_KEY=<your_nordlynx_key>
docker compose up -d
docker exec app curl -s https://api.ipify.org # -> the VPN's public IPGetting a NordLynx private key is the same procedure as for Gluetun (see the Gluetun NordVPN wiki).
Three mutually exclusive modes, chosen from the environment:
| Mode | How to trigger | Behaviour |
|---|---|---|
| Dynamic | SERVER_COUNTRIES=<country> |
Resolves a recommended WireGuard server via the NordVPN API at boot. Supports rotation and failover. |
| Pinned | VPN_ENDPOINT_IP + WIREGUARD_PUBLIC_KEY |
A single fixed endpoint. No API call, no rotation. |
| Pinned list | WIREGUARD_SERVERS_FILE=<path> |
A JSON file of servers, picked at random and rotated among. |
WIREGUARD_SERVERS_FILE format:
{
"servers": [
{
"endpoint_ip": "1.2.3.4",
"public_key": "base64…",
"endpoint_port": 51820,
"name": "nl1"
}
]
}Gluetun-compatible names; defaults in parentheses.
| Variable | Description |
|---|---|
WIREGUARD_PRIVATE_KEY |
Required. NordLynx private key (base64). |
VPN_TYPE |
wireguard (only supported value). |
VPN_SERVICE_PROVIDER |
nordvpn (only supported value). |
SERVER_COUNTRIES |
Country for dynamic mode (only the first is used). |
VPN_ENDPOINT_IP / WIREGUARD_PUBLIC_KEY |
Pinned-mode endpoint + peer key. |
WIREGUARD_SERVERS_FILE |
Path to a pinned-list JSON file. |
WIREGUARD_PRESHARED_KEY |
Optional preshared key (base64). |
WIREGUARD_ADDRESSES |
Tunnel address (10.5.0.2/32). |
WIREGUARD_MTU |
Interface MTU (1420). |
VPN_ENDPOINT_PORT |
WireGuard endpoint port (51820). |
DNS_ADDRESS |
Upstream DNS written to /etc/resolv.conf (1.1.1.1). |
HTTP_CONTROL_SERVER_ADDRESS |
Control server bind (:8000). |
FIREWALL_INPUT_PORTS |
Extra inbound ports to allow (control port is always allowed). |
FIREWALL_VPN_INPUT_PORTS |
Inbound ports allowed only on wg0. |
FIREWALL_OUTBOUND_SUBNETS |
Subnets routed outside the tunnel (LAN bypass). |
PUBLIC_IP_TTL |
Public-IP cache TTL, seconds (300). |
HEALTHCHECK_INTERVAL |
Monitor tick, seconds (15). |
HANDSHAKE_STALE_SECONDS |
Handshake age considered stale (150). |
RECONNECT_FAIL_THRESHOLD |
Consecutive failures before reconfiguring (3). |
ROTATION_INTERVAL_SECONDS |
Periodic server rotation; 0 disables (0). |
FAILOVER_ENABLED |
Switch server after repeated reconnect failures (true). |
FAILOVER_THRESHOLD |
Failed reconnects before failover (4). |
FAILOVER_BAD_TTL |
How long a failed server is avoided, seconds (3600). |
LOG_LEVEL |
error/warn/info/debug (info). |
HTTP API on :8000 by default (Gluetun-compatible shape). The control port is
always allowed through the firewall so you can never lock yourself out.
| Method & path | Purpose |
|---|---|
GET /v1/publicip/ip |
Public IP ({ "public_ip": … }); 503 when unknown. |
GET /v1/vpn/status |
running / stopped + current endpoint. |
GET /version |
Build version. |
GET /health |
200 when the tunnel is healthy, 503 otherwise. |
POST /v1/vpn/rotate |
Trigger a server rotation. |
The container also has a Docker HEALTHCHECK wired to /health.
The kill switch is the reason this project exists. The test harness verifies seven
properties (see tests/verify.sh):
- P1, no egress without a tunnel: TCP v4/v6 and UDP are all dropped from boot.
- P2, egress actually leaves through the VPN, not the host uplink.
- P3, bringing
wg0down produces no leak, then the monitor reconnects. - P4, no plaintext DNS on the Docker bridge.
- P5, an invalid config stays unhealthy with no public IP (fail-closed).
- P6, killing the process leaks nothing and the DROP is re-posted on restart.
- P7, the temporary bootstrap firewall hole (DNS/443 to the API) is fully closed once the tunnel is up.
# structural, offline checks (P1, P5, P6) run without a key:
./tests/verify.sh
# full suite (adds P2, P3, P4, P7) with a real key:
export WIREGUARD_PRIVATE_KEY=<your_nordlynx_key>
./tests/verify.shRequires root and: docker, jq, nsenter, ip, nft, tcpdump, timeout, curl.
The harness (tests/verify.sh + docker-compose.test.yml) is the source of truth for
behaviour, it is never weakened to make code pass.
docker build -t sas . # multi-stage, static musl build
# or locally (needs the musl target and nftables at runtime):
cargo build --release # produces target/release/SASMIT.