diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c5cb7a --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Copy to .env, fill in, then: chmod 600 .env +# Values here are rendered into unbound.conf, authproxy.cfg and pam_radius +# config at container start — this is the ONLY place DC IPs live. + +# ── Domain controllers ──────────────────────────────────────────────── +# AHDC01 (Trochalakis, 192.168.20.0/24) +TROCH_DC1_IP=192.168.20.CHANGEME +# AHDC03 (Trochalakis, 192.168.20.0/24) +TROCH_DC2_IP=192.168.20.CHANGEME +# AH-OSWLDC (Oswald, 192.168.7.0/24) +OSWALD_DC_IP=192.168.7.CHANGEME + +# ── Duo (create an "OpenVPN" / RADIUS application in the Duo Admin Panel) ── +DUO_IKEY= +DUO_SKEY= +DUO_API_HOST=api-XXXXXXXX.duosecurity.com + +# ── AD service account for the Duo Auth Proxy (read-only domain user) ── +# sAMAccountName, not UPN +DUO_SVC_USER=svc-duoproxy +DUO_SVC_PASSWORD= +AD_SEARCH_DN=DC=ad,DC=atlashorizon,DC=net +# Optional: restrict VPN to a group. Leave empty to allow any enabled user. +# Used only if you uncomment security_group_dn in authproxy.cfg.template. +DUO_SECURITY_GROUP_DN= + +# ── OpenVPN ↔ Duo Auth Proxy RADIUS shared secret ───────────────────── +# Generate with: openssl rand -hex 24 +RADIUS_SECRET= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..50a1064 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# These files execute or get parsed inside Linux containers / on the VPS — +# CRLF would break them. Force LF regardless of core.autocrlf. +* text=auto +*.sh text eol=lf +*.conf text eol=lf +*.template text eol=lf +*.cfg text eol=lf +Dockerfile text eol=lf +pam-openvpn text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bc9610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Secrets and key material never go in git +.env +*.key +*.pem +openvpn/pki/* +!openvpn/pki/README.md +duo-authproxy/certs/* +!duo-authproxy/certs/README.md diff --git a/README.md b/README.md index 8d50c0c..2fd672a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,79 @@ -# racknerd +# RackNerd VPN Hub +Client VPN entry point + DNS forwarder for the AtlasHorizon AD environment. +Roaming clients terminate here (OpenVPN + Duo MFA) and are routed over +WireGuard to the two sites. Inter-site traffic stays on the existing direct +Oswald↔Trochalakis tunnel — this box never carries it. + +``` + ┌────────────── RackNerd VPS ──────────────┐ + roaming client ══ovpn══▶ │ tun0 172.31.8.1 │ + (172.31.8.x) │ openvpn ─▶ duo-authproxy ─▶ Duo cloud │ + DNS/routes pushed │ (127.0.0.1:1812) └──LDAPS 636──┐ │ + │ unbound 127.0.0.1 / .8.1 / .255.1 │ │ + │ wg0 172.31.255.1 ─────────────────────┼──┤ + └──────────┬──────────────────┬─────────┘ │ + wg .255.2 wg .255.3 (via wg0) + Trochalakis 192.168.20/24 Oswald 192.168.7/24 + AHDC01, AHDC03 ◀━━ direct s2s ━━▶ AH-OSWLDC +``` + +## Address plan + +| Net | Subnet | Notes | +|----------------------|-------------------|----------------------------------| +| Trochalakis LAN | `192.168.20.0/24` | AHDC01, AHDC03 | +| Oswald LAN | `192.168.7.0/24` | AH-OSWLDC | +| WG transfer | `172.31.255.0/24` | hub .1, Troch .2, Oswald .3 | +| OpenVPN clients | `172.31.8.0/24` | routed (not NATed) to both sites | + +Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp +(WireGuard) and 1194/udp (OpenVPN) are open; DNS/RADIUS/mgmt bind to +loopback and tunnel IPs only. + +## Layout + +- [docker-compose.yml](docker-compose.yml) — openvpn, duo-authproxy, unbound (all `network_mode: host`) +- [openvpn/](openvpn/) — server.conf, PAM→RADIUS glue, offline-CA workflow in [pki/README.md](openvpn/pki/README.md) +- [duo-authproxy/](duo-authproxy/) — built from Duo's source tarball; cfg rendered from `.env` +- [unbound/](unbound/) — conditional forwarder: AD zones → DCs over wg0, everything else public recursion +- [host/](host/) — `setup.sh` (sysctl, iptables, wg keygen), `wg0.conf.example`, firewall template +- [client/](client/) — `.ovpn` template for end users +- [docs/](docs/) — [SITE-SIDE.md](docs/SITE-SIDE.md) (DC/firewall work at the sites), [TESTPLAN.md](docs/TESTPLAN.md) + +## Deploy order + +1. **Host**: `sudo host/setup.sh` — installs wireguard + iptables rules, + generates the WG key, prints the public key for the sites. +2. **Sites**: do [docs/SITE-SIDE.md](docs/SITE-SIDE.md) (WG peers, return + routes, DC firewall, Duo app, LDAPS CA export). Paste site pubkeys into + `/etc/wireguard/wg0.conf`, then `systemctl enable --now wg-quick@wg0`. +3. **Secrets**: `cp .env.example .env && chmod 600 .env`, fill in DC IPs, + Duo keys, service account, `RADIUS_SECRET`. +4. **PKI**: offline CA per [openvpn/pki/README.md](openvpn/pki/README.md); + copy server cert/key + `ta.key` into `openvpn/pki/`. Drop the AD CA + chain in `duo-authproxy/certs/ad-ldaps-ca.pem`. +5. **Stack**: `docker compose up -d --build`. +6. **Verify**: [docs/TESTPLAN.md](docs/TESTPLAN.md), stages 0→6. + +## Design notes / gotchas + +- **WireGuard is host-level** (`wg-quick@wg0`), not containerized — tun0 + and wg0 share the host netns so the kernel routes between them with no + Docker bridge/NAT tricks, and the tunnel survives Docker restarts. +- **Routed, not NATed**: DCs see real `172.31.8.x` client IPs. Requires the + site-side return routes in SITE-SIDE.md. Fallback masquerade snippet is + commented at the bottom of [host/rules.v4.template](host/rules.v4.template). +- **No hairpin**: RackNerd's peer AllowedIPs only carry each site's own + LAN; the sites' RackNerd peer only carries `172.31.8.0/24` + the hub IP. +- **MTU**: OpenVPN `tun-mtu 1400` / `mssfix 1360` keeps client packets + inside wg0's 1420 so nothing fragments on the middle leg. +- **Duo flow**: password → push; `password,passcode` → offline fallback + (`allow_concat=true`). `reneg-sec 0` + `auth-gen-token` prevent surprise + re-prompts mid-session. `failmode=safe` fails *open* if Duo cloud is + down — flip to `secure` in the template if you'd rather fail closed. +- **TCP/443 fallback**: if client networks block 1194/udp, add a second + server instance (`proto tcp4`, `port 443`, separate `dev tun1` + + firewall rule). Not enabled by default. +- **Firewall reloads**: `netfilter-persistent reload` flushes Docker's + chains — restart docker afterwards (setup.sh does this). diff --git a/client/client.ovpn.template b/client/client.ovpn.template new file mode 100644 index 0000000..f85caa1 --- /dev/null +++ b/client/client.ovpn.template @@ -0,0 +1,44 @@ +# AtlasHorizon VPN — client profile template. +# Per user: paste ca.crt, the user's cert/key, and ta.key (from the offline +# CA, see openvpn/pki/README.md) into the inline blocks below, save as +# .ovpn, import into OpenVPN Connect. +# +# Login: AD username; password field takes either +# ADpassword -> Duo push +# ADpassword,123456 -> appended Duo passcode (offline fallback) +# +# Connect Before Logon (Windows): install OpenVPN Connect v3 machine-wide +# with CBL enabled (MSI: OVPNCONNECTOR_CBL=true or enable in app settings), +# import this profile for all users. + +client +dev tun +proto udp +remote vpn.atlashorizon.net 1194 +resolv-retry infinite +nobind +persist-key +persist-tun + +remote-cert-tls server +tls-version-min 1.2 +data-ciphers AES-256-GCM:CHACHA20-POLY1305 +auth SHA256 + +auth-user-pass +auth-retry interact +hand-window 120 +verb 3 + + +# ----- paste ca.crt ----- + + +# ----- paste .crt ----- + + +# ----- paste .key ----- + + +# ----- paste ta.key ----- + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c580e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +# RackNerd VPN hub — OpenVPN (road warriors) + Duo Auth Proxy + Unbound. +# +# All services use network_mode: host on purpose: +# * openvpn's tun0 must live in the host netns so the kernel can route +# tun0 <-> wg0 directly (wg0 is host-level wg-quick). +# * unbound/duo bind ONLY loopback + tunnel IPs (enforced in their own +# configs), so host networking exposes nothing on the public IP. +# Nothing here publishes ports; the host firewall (host/rules.v4.template) +# is the single source of truth for what is reachable from where. + +name: racknerd-vpnhub + +services: + unbound: + build: ./unbound + container_name: unbound + network_mode: host + env_file: .env + volumes: + - ./unbound/unbound.conf.template:/etc/unbound/unbound.conf.template:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "dig", "@127.0.0.1", "+time=3", "+tries=1", "cloudflare.com"] + interval: 30s + timeout: 5s + retries: 3 + + duo-authproxy: + build: ./duo-authproxy + container_name: duo-authproxy + network_mode: host # RADIUS listener bound to 127.0.0.1:1812 only + env_file: .env + volumes: + # AD LDAPS issuing-CA chain, exported from your CA (see certs/README.md) + - ./duo-authproxy/certs:/certs:ro + restart: unless-stopped + + openvpn: + build: ./openvpn + container_name: openvpn + network_mode: host # creates tun0 in the host netns + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun + env_file: .env + volumes: + - ./openvpn/server.conf:/etc/openvpn/server.conf:ro + - ./openvpn/pki:/etc/openvpn/pki:ro + - openvpn-state:/var/lib/openvpn + depends_on: + - duo-authproxy + - unbound + restart: unless-stopped + +volumes: + openvpn-state: diff --git a/docs/SITE-SIDE.md b/docs/SITE-SIDE.md new file mode 100644 index 0000000..4b4c25a --- /dev/null +++ b/docs/SITE-SIDE.md @@ -0,0 +1,78 @@ +# Site-side configuration (manual steps at Trochalakis and Oswald) + +Everything the RackNerd box needs from the two sites, the DCs, and the Duo +admin panel. Nothing here runs on the VPS. + +## 1. WireGuard peers (both sites) + +Add the RackNerd peer to each site's existing WG config. The sites +**initiate** (RackNerd has the static IP) and keep the tunnel alive: + +```ini +# Trochalakis WG box — add: +[Peer] # RackNerd hub +PublicKey = # printed by host/setup.sh +Endpoint = vpn.atlashorizon.net:51820 +AllowedIPs = 172.31.255.1/32, 172.31.8.0/24 +PersistentKeepalive = 25 +``` + +Oswald: identical block. Site WG interface addresses on the new mesh links: +Trochalakis `172.31.255.2/32`, Oswald `172.31.255.3/32` (add to the site +[Interface] Address list if the WG box doesn't already carry an address in +this transfer net). + +**Do NOT** add the other site's LAN to the RackNerd peer's AllowedIPs — +inter-site traffic stays on your existing direct tunnel. The mesh only adds +`172.31.8.0/24` (roaming clients) and `172.31.255.1` (the hub) via RackNerd. + +## 2. Return-path routing (both sites) + +Hosts at each site must route the client subnet back via the site WG box. +If the WG box is **not** the LAN default gateway, add static routes on the +LAN router (or directly on the DCs): + +| Destination | Via | +|------------------|------------------------| +| `172.31.8.0/24` | site WireGuard box | +| `172.31.255.1/32`| site WireGuard box | + +Verify no asymmetry: `tracert 172.31.8.10` from a DC must leave via the +local WG box → RackNerd, never via the other site. + +## 3. Windows Firewall / segmentation on the DCs + +Allow from **`172.31.8.0/24`** (roaming clients, real source IPs): standard +AD client ports — DNS 53, Kerberos 88/464, LDAP 389/636, SMB 445, RPC 135 + +49152-65535, NTP 123, GC 3268/3269. + +Allow from **`172.31.255.1/32`** (the RackNerd host itself): +- TCP/UDP 53 → AHDC01, AHDC03, AH-OSWLDC (Unbound forwarder queries) +- TCP 636 → AHDC01, AHDC03 (Duo Auth Proxy LDAPS) + +## 4. Active Directory + +- **Sites and Services**: create subnet `172.31.8.0/24` and assign it to the + Trochalakis site (clients will then prefer AHDC01/AHDC03 for DC discovery). +- **Duo service account**: plain domain user, read-only, no interactive + logon rights; goes in `.env` as `DUO_SVC_USER` / `DUO_SVC_PASSWORD`. +- Optional: a `VPN Users` security group if you want `security_group_dn` + gating in the auth proxy. + +## 5. LDAPS certificates + +AHDC01 and AHDC03 need valid LDAPS certs (AD CS auto-enrollment or +manually issued). Export the issuing CA chain as PEM → +`duo-authproxy/certs/ad-ldaps-ca.pem` on the VPS +(see that directory's README). + +## 6. Duo Admin Panel + +Applications → Protect an Application → **OpenVPN** (RADIUS). Copy the +integration key, secret key, and API hostname into `.env`. Recommended +policy: enrolled users only; new-user policy deny. + +## 7. Public DNS + +Create `vpn.atlashorizon.net` → RackNerd static IP (public registrar DNS, +not AD). Used by roaming clients and both site WG endpoints. diff --git a/docs/TESTPLAN.md b/docs/TESTPLAN.md new file mode 100644 index 0000000..7e916be --- /dev/null +++ b/docs/TESTPLAN.md @@ -0,0 +1,79 @@ +# Test plan + +Run the stages in order — each depends on the previous one. + +## 0. Host plumbing (on the VPS) + +```bash +sysctl net.ipv4.ip_forward # = 1 +wg show wg0 # both peers: recent "latest handshake" +ping -c3 172.31.255.2 # Trochalakis WG box +ping -c3 172.31.255.3 # Oswald WG box +ping -c3 # AHDC01 across the tunnel +ping -c3 # AH-OSWLDC across the tunnel +``` + +## 1. Unbound (on the VPS) + +```bash +docker compose ps unbound # healthy +# AD SRV record via conditional forward (the money query): +dig @127.0.0.1 _ldap._tcp.dc._msdcs.ad.atlashorizon.net SRV +short +dig @127.0.0.1 ahdc01.ad.atlashorizon.net +short +dig @127.0.0.1 ah-oswldc.ad.oswaldtheotter.net +short +dig @127.0.0.1 -x +short # reverse zone +dig @127.0.0.1 google.com +short # public recursion +``` + +## 2. Duo Auth Proxy (on the VPS) + +```bash +docker compose logs duo-authproxy # connectivity tool output: LDAPS bind + # to both DCs OK, Duo API reachable +# End-to-end RADIUS (expect a push to your phone, then Access-Accept): +apt-get install -y freeradius-utils +radtest '' '' 127.0.0.1:1812 10 "$RADIUS_SECRET" +# Negative: wrong password -> immediate Access-Reject, no push. +``` + +This stage proves the **LDAP bind** leg independently of OpenVPN. + +## 3. Full client connect (from outside, e.g. phone hotspot) + +1. Import a built `.ovpn`, connect with AD username + password → approve + the Duo push → connected. +2. `ipconfig /all`: address in `172.31.8.0/24`, DNS `172.31.8.1`, suffix + `ad.atlashorizon.net`. +3. DNS through the tunnel: + `nslookup -type=SRV _ldap._tcp.dc._msdcs.ad.atlashorizon.net` +4. Reachability, one host per site: + `ping ahdc01.ad.atlashorizon.net` and `ping ah-oswldc.ad.oswaldtheotter.net` +5. AD actually works: `dir \\ad.atlashorizon.net\SYSVOL` then `klist` + (Kerberos tickets present, no NTLM fallback). +6. Passcode-append fallback: reconnect using `password,` from the + Duo app — must succeed with no push. + +## 4. Routing correctness + +- On AHDC01, Security log 4624/4768 for the test user shows the **real** + client IP (`172.31.8.x`), not a NAT address — confirms routed (no + masquerade) design. +- From the client: `tracert ` → first hop `172.31.8.1`. +- From a DC at each site: `tracert 172.31.8.x` → leaves via the **local** + WG box (no path through the other site = no asymmetric routing). +- From a Trochalakis host: `tracert ` still uses the direct + site-to-site tunnel (RackNerd never appears). + +## 5. Exposure check (from any outside box) + +```bash +nmap -sU -p 53,1194,51820 # 53 must NOT respond +nmap -sT -p 22,53,636,1812,7505 # only 22 open +``` + +## 6. Persistence + +```bash +docker compose down && docker compose up -d --build # survives cleanly +reboot # then: wg show, docker compose ps, re-run stage 3 quick connect +``` diff --git a/duo-authproxy/Dockerfile b/duo-authproxy/Dockerfile new file mode 100644 index 0000000..5a9b880 --- /dev/null +++ b/duo-authproxy/Dockerfile @@ -0,0 +1,38 @@ +# Duo publishes no official image; build from their source tarball. +# Install flags match Duo's documented non-interactive install. + +FROM ubuntu:24.04 AS build + +ARG DUO_VERSION=latest +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential libssl-dev libffi-dev perl zlib1g-dev wget ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -q "https://dl.duosecurity.com/duoauthproxy-${DUO_VERSION}-src.tgz" -O /tmp/duo-src.tgz \ + && mkdir /tmp/duo \ + && tar xzf /tmp/duo-src.tgz -C /tmp/duo --strip-components=1 + +RUN groupadd -r -g 35505 duo \ + && useradd -r -u 35505 -g duo -d /opt/duoauthproxy -s /usr/sbin/nologin duo \ + && cd /tmp/duo && make \ + && cd duoauthproxy-build \ + && ./install --install-dir /opt/duoauthproxy --service-user duo \ + --log-group duo --create-init-script no --enable-selinux no + +FROM ubuntu:24.04 + +# gettext-base = envsubst for rendering authproxy.cfg from env vars +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates gettext-base \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r -g 35505 duo \ + && useradd -r -u 35505 -g duo -d /opt/duoauthproxy -s /usr/sbin/nologin duo + +COPY --from=build --chown=duo:duo /opt/duoauthproxy /opt/duoauthproxy +COPY --chown=duo:duo authproxy.cfg.template /opt/duoauthproxy/conf/authproxy.cfg.template +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER duo +ENTRYPOINT ["/entrypoint.sh"] diff --git a/duo-authproxy/authproxy.cfg.template b/duo-authproxy/authproxy.cfg.template new file mode 100644 index 0000000..eb346a9 --- /dev/null +++ b/duo-authproxy/authproxy.cfg.template @@ -0,0 +1,42 @@ +; Duo Authentication Proxy — rendered from env vars at container start. +; Chain: OpenVPN (pam_radius) -> [radius_server_auto] -> [ad_client] LDAPS +; to AHDC01/AHDC03 over wg0, factor via Duo cloud (outbound 443). + +[main] +log_stdout=true +debug=false + +[ad_client] +host=${TROCH_DC1_IP} +host_2=${TROCH_DC2_IP} +service_account_username=${DUO_SVC_USER} +service_account_password=${DUO_SVC_PASSWORD} +search_dn=${AD_SEARCH_DN} +transport=ldaps +port=636 +ssl_ca_certs_file=/certs/ad-ldaps-ca.pem +; We target the DCs by IP, so hostname verification can't match the cert +; SAN. The chain is still validated against your AD CA. To go strict: +; use DC FQDNs in host/host_2, point the VPS resolver at Unbound, and +; set this to true. +ssl_verify_hostname=false +; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env +; and uncomment): +;security_group_dn=${DUO_SECURITY_GROUP_DN} + +[radius_server_auto] +ikey=${DUO_IKEY} +skey=${DUO_SKEY} +api_host=${DUO_API_HOST} +client=ad_client +; Only OpenVPN on this host may talk to us +interface=127.0.0.1 +port=1812 +radius_ip_1=127.0.0.1 +radius_secret_1=${RADIUS_SECRET} +; Password-only = auto (push); "password,123456" appends a passcode — +; the fallback for connect-before-logon when the phone is offline. +allow_concat=true +; safe = allow password-only auth if Duo cloud is unreachable (Duo default). +; Set to "secure" to fail closed instead. +failmode=safe diff --git a/duo-authproxy/certs/README.md b/duo-authproxy/certs/README.md new file mode 100644 index 0000000..479c093 --- /dev/null +++ b/duo-authproxy/certs/README.md @@ -0,0 +1,18 @@ +# AD LDAPS CA chain + +Place the PEM-encoded CA chain that issued your domain controllers' LDAPS +certificates here as **`ad-ldaps-ca.pem`** (root + any intermediates, +concatenated). + +If you run AD CS, export it from any domain-joined box: + +```powershell +# Grab the issuing chain from AHDC01's LDAPS cert +$tcp = New-Object Net.Sockets.TcpClient('ahdc01.ad.atlashorizon.net', 636) +# ...or just export from the CA console / certutil: +certutil -ca.cert ca.der +certutil -encode ca.der ad-ldaps-ca.pem +``` + +The Duo Auth Proxy refuses to start LDAPS without it (checked in +`entrypoint.sh`). Everything except this README is gitignored. diff --git a/duo-authproxy/entrypoint.sh b/duo-authproxy/entrypoint.sh new file mode 100644 index 0000000..7af7abb --- /dev/null +++ b/duo-authproxy/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +for v in TROCH_DC1_IP TROCH_DC2_IP DUO_IKEY DUO_SKEY DUO_API_HOST \ + DUO_SVC_USER DUO_SVC_PASSWORD AD_SEARCH_DN RADIUS_SECRET; do + eval "val=\${$v:-}" + [ -n "$val" ] || { echo "ERROR: $v is not set (see .env.example)" >&2; exit 1; } +done + +if [ ! -f /certs/ad-ldaps-ca.pem ]; then + echo "ERROR: /certs/ad-ldaps-ca.pem missing — export your AD LDAPS issuing" >&2 + echo "CA chain into duo-authproxy/certs/ (see certs/README.md)" >&2 + exit 1 +fi + +envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${DUO_IKEY} ${DUO_SKEY} ${DUO_API_HOST} ${DUO_SVC_USER} ${DUO_SVC_PASSWORD} ${AD_SEARCH_DN} ${RADIUS_SECRET}' \ + < /opt/duoauthproxy/conf/authproxy.cfg.template \ + > /opt/duoauthproxy/conf/authproxy.cfg +chmod 600 /opt/duoauthproxy/conf/authproxy.cfg + +# Optional pre-flight (LDAPS reachability, Duo API ping). Non-fatal so a +# temporarily unreachable DC doesn't crash-loop the container. +/opt/duoauthproxy/bin/authproxy_connectivity_tool || true + +exec /opt/duoauthproxy/bin/authproxy diff --git a/host/99-vpnhub-sysctl.conf b/host/99-vpnhub-sysctl.conf new file mode 100644 index 0000000..3a07d88 --- /dev/null +++ b/host/99-vpnhub-sysctl.conf @@ -0,0 +1,2 @@ +# RackNerd VPN hub — route between tun0 (OpenVPN clients) and wg0 (sites) +net.ipv4.ip_forward = 1 diff --git a/host/rules.v4.template b/host/rules.v4.template new file mode 100644 index 0000000..249a5a3 --- /dev/null +++ b/host/rules.v4.template @@ -0,0 +1,54 @@ +# iptables ruleset for the RackNerd VPN hub. +# ${PUBIF} is substituted by host/setup.sh with the detected public interface. +# Installed to /etc/iptables/rules.v4 (iptables-persistent). +# +# NOTE: after any manual `netfilter-persistent reload`, run +# `systemctl restart docker` — the reload flushes Docker's chains. + +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] +:DOCKER-USER - [0:0] + +# ── INPUT ────────────────────────────────────────────────────────────── +-A INPUT -i lo -j ACCEPT +-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +-A INPUT -m conntrack --ctstate INVALID -j DROP +-A INPUT -p icmp -j ACCEPT + +# Public interface: SSH (key-only!), WireGuard, OpenVPN — nothing else. +-A INPUT -i ${PUBIF} -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT +-A INPUT -i ${PUBIF} -p udp --dport 51820 -j ACCEPT +-A INPUT -i ${PUBIF} -p udp --dport 1194 -j ACCEPT + +# OpenVPN clients: DNS to Unbound only (no SSH, no mgmt from clients) +-A INPUT -i tun0 -d 172.31.8.1/32 -p udp --dport 53 -j ACCEPT +-A INPUT -i tun0 -d 172.31.8.1/32 -p tcp --dport 53 -j ACCEPT + +# Site tunnels: allow DNS in case site hosts want to use Unbound +-A INPUT -i wg0 -p udp --dport 53 -j ACCEPT +-A INPUT -i wg0 -p tcp --dport 53 -j ACCEPT + +# ── FORWARD (routed, no NAT — DCs see real client IPs) ──────────────── +-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +-A FORWARD -m conntrack --ctstate INVALID -j DROP + +# Roaming clients -> both sites + WG transfer net +-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 192.168.20.0/24 -j ACCEPT +-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 192.168.7.0/24 -j ACCEPT +-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 172.31.255.0/24 -j ACCEPT + +# Site -> client NEW connections (e.g. RDP/RSAT from a DC to a roaming +# laptop). Off by default; uncomment if you need it. +#-A FORWARD -i wg0 -o tun0 -d 172.31.8.0/24 -j ACCEPT + +COMMIT + +# ── Fallback per project brief ───────────────────────────────────────── +# If site-side return routing for 172.31.8.0/24 becomes a pain, NAT the +# clients behind the wg0 address instead (DCs then see 172.31.255.1): +#*nat +#:POSTROUTING ACCEPT [0:0] +#-A POSTROUTING -s 172.31.8.0/24 -o wg0 -j MASQUERADE +#COMMIT diff --git a/host/setup.sh b/host/setup.sh new file mode 100644 index 0000000..aeb80fc --- /dev/null +++ b/host/setup.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Host-side bootstrap for the RackNerd VPN hub (Ubuntu). +# Run as root from the repo: sudo host/setup.sh +# Idempotent: safe to re-run; never overwrites an existing wg0.conf. +set -euo pipefail +cd "$(dirname "$0")" + +[ "$(id -u)" -eq 0 ] || { echo "Run as root."; exit 1; } + +PUBIF=$(ip -4 route show default | awk '{print $5; exit}') +echo "==> Public interface: ${PUBIF}" + +echo "==> Installing wireguard + iptables-persistent" +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard iptables-persistent + +echo "==> sysctl (ip_forward)" +install -m 0644 99-vpnhub-sysctl.conf /etc/sysctl.d/99-vpnhub.conf +sysctl --system >/dev/null + +echo "==> Firewall (WARNING: INPUT policy DROP — port 22 stays open)" +sed "s/\${PUBIF}/${PUBIF}/g" rules.v4.template > /etc/iptables/rules.v4 +netfilter-persistent reload +# reload flushed Docker's chains; put them back if Docker is running +if systemctl is-active --quiet docker; then + systemctl restart docker +fi + +echo "==> WireGuard" +if [ ! -f /etc/wireguard/wg0.conf ]; then + umask 077 + PRIV=$(wg genkey) + sed "s||${PRIV}|" wg0.conf.example > /etc/wireguard/wg0.conf + echo + echo " RackNerd WG public key (add as a peer at BOTH sites):" + echo " $(echo "${PRIV}" | wg pubkey)" + echo + echo " >>> Now edit /etc/wireguard/wg0.conf and paste in the" + echo " >>> Trochalakis and Oswald public keys, then:" + echo " >>> systemctl enable --now wg-quick@wg0" +else + echo " /etc/wireguard/wg0.conf exists — leaving it alone." + systemctl enable --now wg-quick@wg0 +fi + +echo "==> Done. Next: cp .env.example .env, fill it in, docker compose up -d --build" diff --git a/host/wg0.conf.example b/host/wg0.conf.example new file mode 100644 index 0000000..90af1cf --- /dev/null +++ b/host/wg0.conf.example @@ -0,0 +1,27 @@ +# /etc/wireguard/wg0.conf — RackNerd hub end of the three-way mesh. +# host/setup.sh installs this with a freshly generated private key; you +# paste in each site's public key afterwards. + +[Interface] +Address = 172.31.255.1/24 +ListenPort = 51820 +PrivateKey = +# wg-quick auto-adds routes for each peer's AllowedIPs — that is exactly +# the routing we want, so no PostUp needed. No MASQUERADE: client traffic +# is routed, DCs see real 172.31.8.x source IPs. + +# ── Trochalakis (AHDC01 / AHDC03 behind this peer) ── +[Peer] +PublicKey = +AllowedIPs = 172.31.255.2/32, 192.168.20.0/24 +# No Endpoint: the site initiates to our static IP and holds the tunnel +# open with PersistentKeepalive = 25 on its side (see docs/SITE-SIDE.md). + +# ── Oswald (AH-OSWLDC behind this peer) ── +[Peer] +PublicKey = +AllowedIPs = 172.31.255.3/32, 192.168.7.0/24 + +# Deliberately NOT listing the other site's LAN under either peer: +# Oswald<->Trochalakis traffic must stay on their direct tunnel, never +# hairpin through this box. diff --git a/openvpn/Dockerfile b/openvpn/Dockerfile new file mode 100644 index 0000000..442d3a7 --- /dev/null +++ b/openvpn/Dockerfile @@ -0,0 +1,13 @@ +FROM debian:bookworm-slim + +# libpam-radius-auth: PAM -> RADIUS bridge to the Duo Auth Proxy on 127.0.0.1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openvpn libpam-radius-auth iproute2 procps ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY pam-openvpn /etc/pam.d/openvpn +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/openvpn/entrypoint.sh b/openvpn/entrypoint.sh new file mode 100644 index 0000000..4657cc4 --- /dev/null +++ b/openvpn/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +: "${RADIUS_SECRET:?RADIUS_SECRET must be set in .env}" + +# pam_radius format: server[:port] secret timeout +# 60s timeout leaves room for the user to approve a Duo push. +printf '127.0.0.1:1812\t%s\t60\n' "$RADIUS_SECRET" > /etc/pam_radius_auth.conf +chmod 600 /etc/pam_radius_auth.conf + +mkdir -p /var/lib/openvpn +[ -c /dev/net/tun ] || { mkdir -p /dev/net; mknod /dev/net/tun c 10 200; } + +exec openvpn --cd /etc/openvpn --config server.conf diff --git a/openvpn/pam-openvpn b/openvpn/pam-openvpn new file mode 100644 index 0000000..13cdd14 --- /dev/null +++ b/openvpn/pam-openvpn @@ -0,0 +1,5 @@ +# PAM service used by openvpn-plugin-auth-pam. +# pam_radius_auth reads /etc/pam_radius_auth.conf (written by entrypoint.sh) +# and forwards the username/password to the Duo Auth Proxy on 127.0.0.1:1812. +auth required pam_radius_auth.so +account required pam_permit.so diff --git a/openvpn/pki/README.md b/openvpn/pki/README.md new file mode 100644 index 0000000..6c64df9 --- /dev/null +++ b/openvpn/pki/README.md @@ -0,0 +1,50 @@ +# OpenVPN PKI — offline CA workflow + +The CA private key **never touches the VPS**. Run easy-rsa on an offline +machine (or at minimum a machine that isn't this server), then copy only the +issued material here. + +## One-time CA + server cert (on the offline machine) + +```bash +git clone https://github.com/OpenVPN/easy-rsa.git && cd easy-rsa/easyrsa3 +./easyrsa init-pki +./easyrsa build-ca # sets the CA passphrase — keep offline +./easyrsa build-server-full racknerd-vpn nopass +openvpn --genkey tls-crypt ta.key # shared tls-crypt key +./easyrsa gen-crl +``` + +## Copy to the VPS (this directory) + +| File on offline box | Name here | +|--------------------------------------|---------------| +| `pki/ca.crt` | `ca.crt` | +| `pki/issued/racknerd-vpn.crt` | `server.crt` | +| `pki/private/racknerd-vpn.key` | `server.key` | +| `ta.key` | `ta.key` | +| `pki/crl.pem` (optional, see server.conf) | `crl.pem` | + +`chmod 600 server.key ta.key` after copying. + +## Per-user client certs (offline) + +```bash +./easyrsa build-client-full nick nopass +``` + +Build the client's `.ovpn` from `client/client.ovpn.template`, pasting +`ca.crt`, the client cert/key, and `ta.key` into the inline blocks. +CN should match the AD username — the server logs use it +(`username-as-common-name` keeps them consistent). + +## Revoking a client + +```bash +./easyrsa revoke nick && ./easyrsa gen-crl +``` + +Copy the fresh `crl.pem` here, uncomment `crl-verify` in `server.conf`, and +`docker compose restart openvpn`. + +Everything in this directory except this README is gitignored. diff --git a/openvpn/server.conf b/openvpn/server.conf new file mode 100644 index 0000000..76bb8d4 --- /dev/null +++ b/openvpn/server.conf @@ -0,0 +1,65 @@ +# OpenVPN road-warrior endpoint — RackNerd hub +# Auth chain: client cert (offline CA) + user/pass -> PAM -> RADIUS +# -> Duo Auth Proxy (127.0.0.1:1812) -> LDAPS to the DCs. + +port 1194 +proto udp4 +dev tun0 +topology subnet + +server 172.31.8.0 255.255.255.0 +ifconfig-pool-persist /var/lib/openvpn/ipp.txt + +# Split tunnel: only the AD sites + transfer net ride the VPN. +push "route 192.168.20.0 255.255.255.0" +push "route 192.168.7.0 255.255.255.0" +push "route 172.31.255.0 255.255.255.0" + +# All client DNS goes to Unbound on this box (block-outside-dns stops +# Windows from leaking AD lookups to the local LAN resolver). +push "dhcp-option DNS 172.31.8.1" +push "dhcp-option DOMAIN ad.atlashorizon.net" +push "dhcp-option DOMAIN-SEARCH ad.atlashorizon.net" +push "dhcp-option DOMAIN-SEARCH ad.calliecorner.net" +push "dhcp-option DOMAIN-SEARCH ad.oswaldtheotter.net" +push "dhcp-option DOMAIN-SEARCH ad.trochalakis.net" +push "block-outside-dns" + +# PKI — issued from the offline CA, see pki/README.md +ca /etc/openvpn/pki/ca.crt +cert /etc/openvpn/pki/server.crt +key /etc/openvpn/pki/server.key +dh none +tls-crypt /etc/openvpn/pki/ta.key +# Enable once you publish a CRL from the offline CA (revoked laptops etc.): +;crl-verify /etc/openvpn/pki/crl.pem + +remote-cert-tls client +tls-version-min 1.2 +data-ciphers AES-256-GCM:CHACHA20-POLY1305 +auth SHA256 + +# User auth via Duo (password only = push; "password,123456" = passcode) +plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so openvpn +verify-client-cert require +username-as-common-name +hand-window 120 # allow time for the Duo push round-trip +reneg-sec 0 # no mid-session TLS renegotiation (would re-prompt Duo) +auth-gen-token 43200 # reconnects within 12h reuse a token instead of a new push + +keepalive 10 60 +explicit-exit-notify 1 + +# wg0 MTU is 1420; keep the client tunnel small enough that client->site +# packets never fragment inside the WireGuard leg. +tun-mtu 1400 +mssfix 1360 + +user nobody +group nogroup +persist-key +persist-tun + +status /var/lib/openvpn/status.log 30 +management 127.0.0.1 7505 +verb 3 diff --git a/unbound/Dockerfile b/unbound/Dockerfile new file mode 100644 index 0000000..c23d033 --- /dev/null +++ b/unbound/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.20 + +# gettext = envsubst (renders DC IPs from .env into the config) +# bind-tools = dig, used by the compose healthcheck +RUN apk add --no-cache unbound gettext bind-tools ca-certificates + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Starts as root to bind :53 and set IP_FREEBIND, then unbound itself +# drops to the "unbound" user (username: directive in the config). +ENTRYPOINT ["/entrypoint.sh"] diff --git a/unbound/entrypoint.sh b/unbound/entrypoint.sh new file mode 100644 index 0000000..3f6fada --- /dev/null +++ b/unbound/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +: "${TROCH_DC1_IP:?TROCH_DC1_IP must be set in .env}" +: "${TROCH_DC2_IP:?TROCH_DC2_IP must be set in .env}" +: "${OSWALD_DC_IP:?OSWALD_DC_IP must be set in .env}" + +envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${OSWALD_DC_IP}' \ + < /etc/unbound/unbound.conf.template \ + > /etc/unbound/unbound.conf + +# DNSSEC root trust anchor for public resolution (exit 1 just means "updated") +unbound-anchor -a /etc/unbound/root.key || true +chown unbound:unbound /etc/unbound/root.key + +unbound-checkconf /etc/unbound/unbound.conf +exec unbound -d -c /etc/unbound/unbound.conf diff --git a/unbound/unbound.conf.template b/unbound/unbound.conf.template new file mode 100644 index 0000000..e81825b --- /dev/null +++ b/unbound/unbound.conf.template @@ -0,0 +1,89 @@ +# Unbound — caching resolver + conditional forwarder for AD zones. +# ${TROCH_DC1_IP}-style tokens are rendered from .env at container start. + +server: + verbosity: 1 + username: "unbound" + do-daemonize: no + chroot: "" + + # Listen ONLY on loopback and tunnel-facing IPs — never the public IP. + # ip-freebind lets us bind 172.31.8.1 even before OpenVPN brings up tun0. + interface: 127.0.0.1 + interface: 172.31.8.1 + interface: 172.31.255.1 + ip-freebind: yes + port: 53 + do-ip6: no + + access-control: 0.0.0.0/0 refuse + access-control: 127.0.0.0/8 allow + access-control: 172.31.8.0/24 allow # OpenVPN clients + access-control: 172.31.255.0/24 allow # WireGuard transfer net + access-control: 192.168.20.0/24 allow # Trochalakis LAN (optional use) + access-control: 192.168.7.0/24 allow # Oswald LAN (optional use) + + hide-identity: yes + hide-version: yes + harden-glue: yes + harden-dnssec-stripped: yes + qname-minimisation: yes + prefetch: yes + msg-cache-size: 64m + rrset-cache-size: 128m + + auto-trust-anchor-file: "/etc/unbound/root.key" + + # AD zones return RFC1918 answers and are unsigned — exempt them from + # rebind protection and DNSSEC validation. + private-address: 10.0.0.0/8 + private-address: 172.16.0.0/12 + private-address: 192.168.0.0/16 + private-domain: "ad.atlashorizon.net" + private-domain: "ad.calliecorner.net" + private-domain: "ad.oswaldtheotter.net" + private-domain: "ad.trochalakis.net" + domain-insecure: "ad.atlashorizon.net" + domain-insecure: "ad.calliecorner.net" + domain-insecure: "ad.oswaldtheotter.net" + domain-insecure: "ad.trochalakis.net" + domain-insecure: "20.168.192.in-addr.arpa" + domain-insecure: "7.168.192.in-addr.arpa" + local-zone: "20.168.192.in-addr.arpa." nodefault + local-zone: "7.168.192.in-addr.arpa." nodefault + +# ── Conditional forwarding: AD zones → DCs over the WireGuard tunnel. ── +# Everything not listed below is resolved publicly by normal recursion. + +forward-zone: + name: "ad.atlashorizon.net." # also covers _msdcs.ad.atlashorizon.net + forward-addr: ${TROCH_DC1_IP} # AHDC01 + forward-addr: ${TROCH_DC2_IP} # AHDC03 + +forward-zone: + name: "ad.calliecorner.net." + forward-addr: ${TROCH_DC1_IP} + forward-addr: ${TROCH_DC2_IP} + +forward-zone: + name: "ad.trochalakis.net." + forward-addr: ${TROCH_DC1_IP} + forward-addr: ${TROCH_DC2_IP} + +forward-zone: + name: "ad.oswaldtheotter.net." + forward-addr: ${OSWALD_DC_IP} # AH-OSWLDC + # Root DCs as fallback — keep ONLY if this zone replicates forest-wide + # (ForestDnsZones); remove these two lines otherwise. + forward-addr: ${TROCH_DC1_IP} + forward-addr: ${TROCH_DC2_IP} + +# Reverse zones so PTR lookups for site hosts work +forward-zone: + name: "20.168.192.in-addr.arpa." + forward-addr: ${TROCH_DC1_IP} + forward-addr: ${TROCH_DC2_IP} + +forward-zone: + name: "7.168.192.in-addr.arpa." + forward-addr: ${OSWALD_DC_IP}