Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SAS

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.

Why

  • Kernel WireGuard over netlink. rtnetlink + genetlink + netlink-packet-wireguard. No wg or ip binaries are shipped; nft is the only subprocess ever spawned.
  • Kill switch first, always. An nftables policy drop is installed before any config is read and is never flushed. Every ruleset change is an atomic nft -f transaction, 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 only cap_add: NET_ADMIN, no /dev/net/tun.

Quick start

# 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 IP

Getting a NordLynx private key is the same procedure as for Gluetun (see the Gluetun NordVPN wiki).

Server selection

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" 
    }
  ]
}

Environment variables

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).

Control server

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.

Security properties

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 wg0 down 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.

Testing

# 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.sh

Requires 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.

Building

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/SAS

License

MIT.

About

Minimal, kill-switch-first WireGuard (NordVPN/NordLynx) gateway container in Rust. It's a tiny fail-closed Gluetun alternative (~24 MB image, <20 MB RAM)

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages