diff --git a/.env.example b/.env.example index 8c5cb7a..39f8526 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # 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. +# Values here are rendered into authproxy.cfg, pam_radius config and the +# Technitium forwarder zones at container start — the ONLY place DC IPs live. # ── Domain controllers ──────────────────────────────────────────────── # AHDC01 (Trochalakis, 192.168.20.0/24) @@ -27,3 +27,16 @@ DUO_SECURITY_GROUP_DN= # ── OpenVPN ↔ Duo Auth Proxy RADIUS shared secret ───────────────────── # Generate with: openssl rand -hex 24 RADIUS_SECRET= + +# ── Technitium DNS web console admin password ───────────────────────── +# Applied on FIRST start only; change later via the console itself. +TECHNITIUM_ADMIN_PASSWORD= + +# ── Optional (defaults shown) ───────────────────────────────────────── +# Duo Auth Proxy version; pin (e.g. 6.5.1) for deterministic updates +#DUO_AUTHPROXY_VERSION=latest +# Service directories — only set these when the stack lives in /opt +# (the Ansible deploy writes them automatically) +#OPENVPN_DIR=/opt/openvpn +#DUO_DIR=/opt/duo-authproxy +#TECHNITIUM_DIR=/opt/technitium diff --git a/.gitignore b/.gitignore index 8bc9610..8533cb1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ openvpn/pki/* !openvpn/pki/README.md duo-authproxy/certs/* !duo-authproxy/certs/README.md + +# Technitium runtime state (config DB) when running from the repo dir +technitium/config/ + +# Ansible: real inventory + secret-bearing vars stay local (vault or not) +ansible/inventory.yml +ansible/group_vars/vpnhub.yml diff --git a/README.md b/README.md index 2fd672a..d5407a2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Oswald↔Trochalakis tunnel — this box never carries it. 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 │ │ + │ technitium :53 / console :5380 │ │ │ wg0 172.31.255.1 ─────────────────────┼──┤ └──────────┬──────────────────┬─────────┘ │ wg .255.2 wg .255.3 (via wg0) @@ -28,20 +28,31 @@ Oswald↔Trochalakis tunnel — this box never carries it. | 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. +(WireGuard) and 1194/udp (OpenVPN) are open. RADIUS binds loopback only; +Technitium's DNS (:53) and web console (:5380) are firewalled to the +tunnels and additionally guarded by its recursion ACL. ## Layout -- [docker-compose.yml](docker-compose.yml) — openvpn, duo-authproxy, unbound (all `network_mode: host`) +- [docker-compose.yml](docker-compose.yml) — openvpn, duo-authproxy, technitium (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 +- [technitium/](technitium/) — Technitium DNS: AD conditional-forwarder zones created by an idempotent API init job ([configure.sh](technitium/configure.sh)), everything else public recursion; web console on :5380 (VPN/tunnel only) - [host/](host/) — `setup.sh` (sysctl, iptables, wg keygen), `wg0.conf.example`, firewall template - [client/](client/) — `.ovpn` template for end users +- [ansible/](ansible/) — one idempotent playbook for setup **and** updates, deploys to `/opt/` (see [ansible/README.md](ansible/README.md)) - [docs/](docs/) — [SITE-SIDE.md](docs/SITE-SIDE.md) (DC/firewall work at the sites), [TESTPLAN.md](docs/TESTPLAN.md) -## Deploy order +## Deploy: Ansible (preferred) + +`ansible/site.yml` does everything in the manual list below — Docker +install, sysctl, firewall, WireGuard keygen, and the compose stack under +`/opt` — and re-running it is the update mechanism. See +[ansible/README.md](ansible/README.md). Site-side work +([docs/SITE-SIDE.md](docs/SITE-SIDE.md)) and the offline CA stay manual +either way. + +## Deploy: manual 1. **Host**: `sudo host/setup.sh` — installs wireguard + iptables rules, generates the WG key, prints the public key for the sites. @@ -68,6 +79,15 @@ loopback and tunnel IPs only. 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. +- **Technitium is stateful**: `DNS_SERVER_*` env vars seed config on the + *first* start only; after that its state lives in + `/opt/technitium/config` (persists `down`/`up`, backed up like data). + The init job only creates *missing* forwarder zones — console edits are + never overwritten. Changed a DC IP in `.env`? Delete the zone in the + console and rerun `docker compose up technitium-init`. +- **Port 53 on the host**: systemd-resolved's stub listener is disabled + (setup.sh/Ansible) — Technitium binds wildcard :53 and would collide; + `/etc/resolv.conf` is repointed at resolved's upstream file. - **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 @@ -76,4 +96,7 @@ loopback and tunnel IPs only. 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). + chains — restart docker afterwards (setup.sh/Ansible handler do this). +- **IPv6 is locked down too**: [host/rules.v6.template](host/rules.v6.template) + default-drops the public v6 address (SSH/WG/OpenVPN only, ICMPv6 + allowed) since Technitium and the console bind dual-stack wildcard. diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..b81e68e --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,70 @@ +# Ansible deploy + +One playbook does both jobs: the **first run** provisions the host (Docker, +WireGuard, sysctl, firewall) and deploys the stack; **every later run is the +update path** — it only touches what changed and restarts only the affected +services. + +Deployed layout on the VPS (per-service dirs under `/opt`): + +| Path | Contents | +|-----------------------|-------------------------------------------| +| `/opt/vpnhub/` | `docker-compose.yml`, `.env` | +| `/opt/openvpn/` | build context, `server.conf`, `pki/` | +| `/opt/duo-authproxy/` | build context, cfg template, `certs/` | +| `/opt/technitium/` | init-job build context + `config/` (live zone DB — never overwritten by the playbook) | + +The playbook renders the firewall and wg0 config from the same +`host/rules.v4.template` / `host/wg0.conf.example` files the manual path +uses — nothing is duplicated inside `ansible/`. + +## Prerequisites + +A Linux control node (WSL is fine — this repo checked out inside WSL or +accessed via `/mnt/c/...`), `ansible-core` >= 2.15, then: + +```bash +cd ansible +ansible-galaxy collection install -r requirements.yml +``` + +## First-time setup + +```bash +cp inventory.example.yml inventory.yml # set host/user +cp group_vars/vpnhub.example.yml group_vars/vpnhub.yml +$EDITOR group_vars/vpnhub.yml # DC IPs, Duo keys, secrets +ansible-vault encrypt group_vars/vpnhub.yml # both files are gitignored anyway + +# Local key material (both gitignored, synced to /opt by the playbook): +# ../openvpn/pki/ server.crt, server.key, ta.key (offline CA) +# ../duo-authproxy/certs/ ad-ldaps-ca.pem + +ansible-playbook site.yml +``` + +The first run prints the **hub WireGuard public key** and stops short of +starting `wg0` (site peer keys are still empty). Add the hub as a peer at +both sites (docs/SITE-SIDE.md), paste each site's public key into +`group_vars/vpnhub.yml` (`troch_wg_public_key` / `oswald_wg_public_key`), +and run it again — wg0 comes up and the stack starts. + +## Updating + +| What changed | Command | +|---------------------------------------|------------------------------------------------------------| +| Any config in the repo, `.env` values | `ansible-playbook site.yml` (affected services recreate) | +| Just stack files, skip host checks | `ansible-playbook site.yml --tags stack` | +| Base images / rebuild everything | `ansible-playbook site.yml -e vpnhub_force_rebuild=true` | +| Duo Auth Proxy version | bump `duo_authproxy_version`, then force-rebuild as above | +| Firewall only | `ansible-playbook site.yml --tags firewall` | +| WireGuard peers/config | `ansible-playbook site.yml --tags wireguard` | + +Note on `duo_authproxy_version: latest`: Docker's layer cache means +"latest" won't re-download a newer tarball on rebuild. Pin a real version +(the cache busts when it changes) — that's the reliable update mechanism. + +## Tags + +`docker`, `host` (packages + sysctl + firewall + wg), `firewall`, +`wireguard`, `stack`. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..6853251 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,8 @@ +[defaults] +inventory = inventory.yml +interpreter_python = auto_silent +# Uncomment once group_vars/vpnhub.yml is vault-encrypted: +# vault_password_file = ~/.vault-pass-vpnhub + +[privilege_escalation] +become_ask_pass = False diff --git a/ansible/group_vars/vpnhub.example.yml b/ansible/group_vars/vpnhub.example.yml new file mode 100644 index 0000000..2234f0a --- /dev/null +++ b/ansible/group_vars/vpnhub.example.yml @@ -0,0 +1,41 @@ +# Copy to vpnhub.yml, fill in, then ENCRYPT before committing anything: +# ansible-vault encrypt group_vars/vpnhub.yml +# (vpnhub.yml is gitignored regardless, belt and braces.) + +# ── Domain controllers ── +troch_dc1_ip: 192.168.20.CHANGEME # AHDC01 +troch_dc2_ip: 192.168.20.CHANGEME # AHDC03 +oswald_dc_ip: 192.168.7.CHANGEME # AH-OSWLDC + +# ── WireGuard site peers ── +# Leave empty on the first run: the playbook generates the hub key, prints +# the hub PUBLIC key to add at both sites, and holds off starting wg0. +# Paste each site's public key here and re-run. +troch_wg_public_key: "" +oswald_wg_public_key: "" + +# ── Duo (Admin Panel -> Applications -> OpenVPN/RADIUS) ── +duo_ikey: "" +duo_skey: "" +duo_api_host: api-XXXXXXXX.duosecurity.com + +# ── AD service account for the Duo Auth Proxy (read-only) ── +duo_svc_user: svc-duoproxy # sAMAccountName, not UPN +duo_svc_password: "" +ad_search_dn: DC=ad,DC=atlashorizon,DC=net +# Only used if you uncomment security_group_dn in authproxy.cfg.template: +duo_security_group_dn: "" + +# ── OpenVPN <-> Duo RADIUS shared secret (openssl rand -hex 24) ── +radius_secret: "" + +# ── Technitium web console admin password ── +# Seeded on the FIRST container start only; afterwards change it in the +# console (this var must still match for the init job's API login). +technitium_admin_password: "" + +# ── Optional ── +# Pin for deterministic Duo updates (e.g. "6.5.1"); bumping it forces a +# refetch of the tarball on the next rebuild. +duo_authproxy_version: latest +# vpnhub_base_dir: /opt diff --git a/ansible/inventory.example.yml b/ansible/inventory.example.yml new file mode 100644 index 0000000..4a3b976 --- /dev/null +++ b/ansible/inventory.example.yml @@ -0,0 +1,11 @@ +# Copy to inventory.yml and adjust. +all: + children: + vpnhub: + hosts: + racknerd: + ansible_host: vpn.atlashorizon.net + ansible_user: root + # Non-root instead? Use: + # ansible_user: nick + # ansible_become: true diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..4634f91 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,5 @@ +collections: + - name: community.docker + version: ">=3.10.0" + - name: ansible.posix + version: ">=1.5.0" diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..3e1421e --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,305 @@ +--- +# RackNerd VPN hub — full host + stack provisioning. Idempotent: +# the first run is setup, every later run is the update path. +# +# ansible-playbook site.yml # converge everything +# ansible-playbook site.yml --tags stack # only the compose stack +# ansible-playbook site.yml -e vpnhub_force_rebuild=true # rebuild/pull images +# +# Deployed layout (per-service dirs under /opt): +# /opt/vpnhub/ docker-compose.yml + .env +# /opt/openvpn/ build context, server.conf, pki/ +# /opt/duo-authproxy/ build context, cfg template, certs/ +# /opt/technitium/ init-job build context + config/ (runtime state) +# +# Host files (firewall, wg0.conf) are rendered from the same host/*.template +# files the manual setup uses — no duplicated config in ansible/. + +- name: RackNerd VPN hub + hosts: vpnhub + become: true + + vars: + vpnhub_base_dir: /opt + vpnhub_stack_dir: "{{ vpnhub_base_dir }}/vpnhub" + vpnhub_force_rebuild: false + public_iface: "{{ ansible_default_ipv4.interface }}" + wg_peers_configured: >- + {{ (troch_wg_public_key | default('')) != '' + and (oswald_wg_public_key | default('')) != '' }} + + handlers: + - name: reload firewall + ansible.builtin.shell: | + netfilter-persistent reload + # the reload flushes Docker's chains — put them back + if systemctl is-active --quiet docker; then systemctl restart docker; fi + + - name: restart wireguard + ansible.builtin.systemd_service: + name: wg-quick@wg0 + state: restarted + when: wg_peers_configured | bool + + - name: restart systemd-resolved + ansible.builtin.shell: systemctl try-restart systemd-resolved || true + + - name: rerun technitium init + ansible.builtin.command: docker compose up -d --build --force-recreate technitium-init + args: + chdir: "{{ vpnhub_stack_dir }}" + + - name: recreate duo-authproxy + ansible.builtin.command: docker compose up -d --force-recreate duo-authproxy + args: + chdir: "{{ vpnhub_stack_dir }}" + + - name: recreate openvpn + ansible.builtin.command: docker compose up -d --force-recreate openvpn + args: + chdir: "{{ vpnhub_stack_dir }}" + + - name: recreate stack + ansible.builtin.command: docker compose up -d --force-recreate + args: + chdir: "{{ vpnhub_stack_dir }}" + + tasks: + # ── Docker engine ────────────────────────────────────────────────── + - name: Docker engine + tags: [host, docker] + block: + - name: Prerequisite packages + ansible.builtin.apt: + name: [ca-certificates, curl, gnupg] + state: present + update_cache: true + cache_valid_time: 3600 + + - name: Docker apt signing key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + - name: Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }} + signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + filename: docker + + - name: Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + + - name: Docker enabled and running + ansible.builtin.systemd_service: + name: docker + enabled: true + state: started + + # ── Host: packages, sysctl, firewall ─────────────────────────────── + - name: WireGuard + iptables-persistent + tags: host + ansible.builtin.apt: + name: [wireguard, iptables-persistent] + state: present + environment: + DEBIAN_FRONTEND: noninteractive + + - name: Enable IPv4 forwarding + tags: host + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: "1" + sysctl_file: /etc/sysctl.d/99-vpnhub.conf + reload: true + + # Technitium binds 0.0.0.0:53 — systemd-resolved's stub listener on + # 127.0.0.53:53 would collide with it. + - name: resolved drop-in directory + tags: host + ansible.builtin.file: + path: /etc/systemd/resolved.conf.d + state: directory + mode: "0755" + + - name: Disable systemd-resolved stub listener (frees :53 for Technitium) + tags: host + ansible.builtin.copy: + dest: /etc/systemd/resolved.conf.d/99-vpnhub.conf + mode: "0644" + content: | + [Resolve] + DNSStubListener=no + notify: restart systemd-resolved + + - name: Point /etc/resolv.conf at resolved's upstream file (not the dead stub) + tags: host + ansible.builtin.file: + src: /run/systemd/resolve/resolv.conf + dest: /etc/resolv.conf + state: link + force: true + + - name: Firewall rules (rendered from host/rules.v4/v6.template) + tags: [host, firewall] + ansible.builtin.copy: + dest: "/etc/iptables/rules.{{ item }}" + mode: "0640" + content: "{{ lookup('file', playbook_dir ~ '/../host/rules.' ~ item ~ '.template') + | replace('${PUBIF}', public_iface) }}\n" + loop: [v4, v6] + notify: reload firewall + + # ── WireGuard (host-level wg-quick, key generated in place) ──────── + - name: Generate WireGuard private key (first run only) + tags: [host, wireguard] + ansible.builtin.shell: umask 077 && wg genkey > /etc/wireguard/wg0.key + args: + creates: /etc/wireguard/wg0.key + + - name: Read WireGuard private key + tags: [host, wireguard] + ansible.builtin.slurp: + src: /etc/wireguard/wg0.key + register: wg_key_raw + no_log: true + + - name: Derive WireGuard public key + tags: [host, wireguard] + ansible.builtin.command: wg pubkey + args: + stdin: "{{ wg_key_raw.content | b64decode | trim }}" + register: wg_pubkey + changed_when: false + no_log: true + + - name: Hub public key — add as a peer at BOTH sites (docs/SITE-SIDE.md) + tags: [host, wireguard] + ansible.builtin.debug: + msg: "RackNerd WG public key: {{ wg_pubkey.stdout }}" + + - name: Deploy wg0.conf (rendered from host/wg0.conf.example) + tags: [host, wireguard] + ansible.builtin.copy: + dest: /etc/wireguard/wg0.conf + mode: "0600" + content: "{{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example') + | replace('', wg_key_raw.content | b64decode | trim) + | replace('', troch_wg_public_key | default('', true)) + | replace('', oswald_wg_public_key | default('', true)) }}\n" + no_log: true + notify: restart wireguard + + - name: Enable wg-quick@wg0 + tags: [host, wireguard] + ansible.builtin.systemd_service: + name: wg-quick@wg0 + enabled: true + state: started + when: wg_peers_configured | bool + + - name: Waiting on site public keys + tags: [host, wireguard] + ansible.builtin.debug: + msg: >- + troch_wg_public_key / oswald_wg_public_key not set in group_vars — + wg0 deployed but NOT started. Fill them in and re-run. + when: not (wg_peers_configured | bool) + + - name: Apply pending host changes before touching the stack + tags: [host, docker, firewall, wireguard, stack] + ansible.builtin.meta: flush_handlers + + # ── Compose stack under /opt ─────────────────────────────────────── + - name: Service directories + tags: stack + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ vpnhub_stack_dir }}" + - "{{ vpnhub_base_dir }}/openvpn" + - "{{ vpnhub_base_dir }}/duo-authproxy" + - "{{ vpnhub_base_dir }}/technitium" + + # File-by-file on purpose: /opt/technitium/config is Technitium's live + # state (zones DB) — a recursive dir copy must never touch it. + - name: Sync technitium init-job files + tags: stack + ansible.builtin.copy: + src: "{{ playbook_dir }}/../technitium/{{ item }}" + dest: "{{ vpnhub_base_dir }}/technitium/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - Dockerfile + - configure.sh + notify: rerun technitium init + + - name: Sync duo-authproxy service files (incl. certs/ if populated) + tags: stack + ansible.builtin.copy: + src: "{{ playbook_dir }}/../duo-authproxy/" + dest: "{{ vpnhub_base_dir }}/duo-authproxy/" + owner: root + group: root + notify: recreate duo-authproxy + + - name: Sync openvpn service files (incl. pki/ if populated) + tags: stack + ansible.builtin.copy: + src: "{{ playbook_dir }}/../openvpn/" + dest: "{{ vpnhub_base_dir }}/openvpn/" + owner: root + group: root + notify: recreate openvpn + + - name: Tighten OpenVPN key material permissions + tags: stack + ansible.builtin.shell: >- + find {{ vpnhub_base_dir }}/openvpn/pki -type f -name '*.key' + -exec chmod 600 {} + 2>/dev/null; true + changed_when: false + + - name: Deploy compose file + tags: stack + ansible.builtin.copy: + src: "{{ playbook_dir }}/../docker-compose.yml" + dest: "{{ vpnhub_stack_dir }}/docker-compose.yml" + owner: root + group: root + mode: "0644" + + - name: Render .env from group_vars + tags: stack + ansible.builtin.template: + src: templates/env.j2 + dest: "{{ vpnhub_stack_dir }}/.env" + owner: root + group: root + mode: "0600" + notify: recreate stack + + - name: Build and start the stack + tags: stack + community.docker.docker_compose_v2: + project_src: "{{ vpnhub_stack_dir }}" + state: present + build: "{{ 'always' if vpnhub_force_rebuild | bool else 'policy' }}" + pull: "{{ 'always' if vpnhub_force_rebuild | bool else 'policy' }}" + remove_orphans: true diff --git a/ansible/templates/env.j2 b/ansible/templates/env.j2 new file mode 100644 index 0000000..e406bce --- /dev/null +++ b/ansible/templates/env.j2 @@ -0,0 +1,26 @@ +# Managed by Ansible — edit ansible/group_vars/vpnhub.yml and re-run +# site.yml instead of touching this file. + +TROCH_DC1_IP={{ troch_dc1_ip }} +TROCH_DC2_IP={{ troch_dc2_ip }} +OSWALD_DC_IP={{ oswald_dc_ip }} + +DUO_IKEY={{ duo_ikey }} +DUO_SKEY={{ duo_skey }} +DUO_API_HOST={{ duo_api_host }} + +DUO_SVC_USER={{ duo_svc_user }} +DUO_SVC_PASSWORD={{ duo_svc_password }} +AD_SEARCH_DN={{ ad_search_dn }} +DUO_SECURITY_GROUP_DN={{ duo_security_group_dn | default('') }} + +RADIUS_SECRET={{ radius_secret }} + +TECHNITIUM_ADMIN_PASSWORD={{ technitium_admin_password }} + +DUO_AUTHPROXY_VERSION={{ duo_authproxy_version | default('latest') }} + +# /opt layout — consumed by compose ${*_DIR} interpolation +OPENVPN_DIR={{ vpnhub_base_dir }}/openvpn +DUO_DIR={{ vpnhub_base_dir }}/duo-authproxy +TECHNITIUM_DIR={{ vpnhub_base_dir }}/technitium diff --git a/docker-compose.yml b/docker-compose.yml index 9c580e6..a86d3ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,65 @@ -# RackNerd VPN hub — OpenVPN (road warriors) + Duo Auth Proxy + Unbound. +# RackNerd VPN hub — OpenVPN (road warriors) + Duo Auth Proxy + Technitium DNS. # # 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. +# * duo binds only 127.0.0.1; technitium binds wildcard, but the host +# firewall default-drops the public interface and the recursion ACL +# only allows tunnel/site networks — nothing is usable from outside. # Nothing here publishes ports; the host firewall (host/rules.v4.template) # is the single source of truth for what is reachable from where. +# +# The ${*_DIR:-./...} variables default to the repo layout for manual use; +# the Ansible deploy sets them in .env to the /opt/ layout. +# +# NOTE: technitium's DNS_SERVER_* env vars only seed config on FIRST start +# (empty config dir). After that, change settings in the web console — +# or wipe ${TECHNITIUM_DIR}/config to re-seed. name: racknerd-vpnhub services: - unbound: - build: ./unbound - container_name: unbound + technitium: + # Pin a version (e.g. :13.6.0) for controlled upgrades + image: technitium/dns-server:latest + container_name: technitium + network_mode: host + environment: + - DNS_SERVER_DOMAIN=vpnhub + - DNS_SERVER_ADMIN_PASSWORD=${TECHNITIUM_ADMIN_PASSWORD} + - DNS_SERVER_WEB_SERVICE_HTTP_PORT=5380 + - DNS_SERVER_RECURSION=UseSpecifiedNetworkACL + - DNS_SERVER_RECURSION_NETWORK_ACL=127.0.0.0/8,172.31.8.0/24,172.31.255.0/24,192.168.20.0/24,192.168.7.0/24 + volumes: + - ${TECHNITIUM_DIR:-./technitium}/config:/etc/dns + restart: unless-stopped + + technitium-init: + build: ${TECHNITIUM_DIR:-./technitium} + container_name: technitium-init 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 + depends_on: + - technitium + restart: "no" duo-authproxy: - build: ./duo-authproxy + build: + context: ${DUO_DIR:-./duo-authproxy} + args: + # Pin a version (e.g. "6.5.1") to get deterministic updates — + # bumping it busts the Docker layer cache and refetches the tarball. + DUO_VERSION: ${DUO_AUTHPROXY_VERSION:-latest} 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 + - ${DUO_DIR:-./duo-authproxy}/certs:/certs:ro restart: unless-stopped openvpn: - build: ./openvpn + build: ${OPENVPN_DIR:-./openvpn} container_name: openvpn network_mode: host # creates tun0 in the host netns cap_add: @@ -45,12 +68,12 @@ services: - /dev/net/tun env_file: .env volumes: - - ./openvpn/server.conf:/etc/openvpn/server.conf:ro - - ./openvpn/pki:/etc/openvpn/pki:ro + - ${OPENVPN_DIR:-./openvpn}/server.conf:/etc/openvpn/server.conf:ro + - ${OPENVPN_DIR:-./openvpn}/pki:/etc/openvpn/pki:ro - openvpn-state:/var/lib/openvpn depends_on: - duo-authproxy - - unbound + - technitium restart: unless-stopped volumes: diff --git a/docs/SITE-SIDE.md b/docs/SITE-SIDE.md index 4b4c25a..db46114 100644 --- a/docs/SITE-SIDE.md +++ b/docs/SITE-SIDE.md @@ -47,7 +47,7 @@ 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/UDP 53 → AHDC01, AHDC03, AH-OSWLDC (Technitium forwarder queries) - TCP 636 → AHDC01, AHDC03 (Duo Auth Proxy LDAPS) ## 4. Active Directory diff --git a/docs/TESTPLAN.md b/docs/TESTPLAN.md index 7e916be..eaec3dd 100644 --- a/docs/TESTPLAN.md +++ b/docs/TESTPLAN.md @@ -13,10 +13,10 @@ ping -c3 # AHDC01 across the tunnel ping -c3 # AH-OSWLDC across the tunnel ``` -## 1. Unbound (on the VPS) +## 1. Technitium DNS (on the VPS) ```bash -docker compose ps unbound # healthy +docker compose logs technitium-init # "Technitium AD forwarder zones OK." # 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 @@ -25,6 +25,10 @@ dig @127.0.0.1 -x +short # reverse zone dig @127.0.0.1 google.com +short # public recursion ``` +Web console: from a VPN client or site host, `http://172.31.8.1:5380` +(or `http://172.31.255.1:5380` over the site tunnels) — verify the six +conditional-forwarder zones exist. Must NOT load from the public IP. + ## 2. Duo Auth Proxy (on the VPS) ```bash @@ -68,12 +72,16 @@ This stage proves the **LDAP bind** leg independently of OpenVPN. ```bash nmap -sU -p 53,1194,51820 # 53 must NOT respond -nmap -sT -p 22,53,636,1812,7505 # only 22 open +nmap -sT -p 22,53,636,1812,5380,7505 # only 22 open +# If the VPS has a public IPv6 address, repeat over v6: +nmap -6 -sT -p 22,53,5380 # only 22 open ``` ## 6. Persistence ```bash docker compose down && docker compose up -d --build # survives cleanly +# Technitium zones must still exist afterwards (state in /opt/technitium/config) +dig @127.0.0.1 _ldap._tcp.dc._msdcs.ad.atlashorizon.net SRV +short reboot # then: wg show, docker compose ps, re-run stage 3 quick connect ``` diff --git a/duo-authproxy/authproxy.cfg.template b/duo-authproxy/authproxy.cfg.template index eb346a9..84c8536 100644 --- a/duo-authproxy/authproxy.cfg.template +++ b/duo-authproxy/authproxy.cfg.template @@ -17,7 +17,7 @@ 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 +; use DC FQDNs in host/host_2, point the VPS resolver at Technitium, and ; set this to true. ssl_verify_hostname=false ; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env diff --git a/host/rules.v4.template b/host/rules.v4.template index 249a5a3..0fbf9e1 100644 --- a/host/rules.v4.template +++ b/host/rules.v4.template @@ -22,14 +22,18 @@ -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) +# OpenVPN clients: DNS to Technitium 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 +# Site tunnels: allow DNS in case site hosts want to use Technitium -A INPUT -i wg0 -p udp --dport 53 -j ACCEPT -A INPUT -i wg0 -p tcp --dport 53 -j ACCEPT +# Technitium web console — VPN clients and site tunnels only, never public +-A INPUT -i tun0 -p tcp --dport 5380 -j ACCEPT +-A INPUT -i wg0 -p tcp --dport 5380 -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 diff --git a/host/rules.v6.template b/host/rules.v6.template new file mode 100644 index 0000000..e776e2d --- /dev/null +++ b/host/rules.v6.template @@ -0,0 +1,27 @@ +# ip6tables ruleset for the RackNerd VPN hub. +# The VPS has a public IPv6 address; all tunnel/VPN traffic is IPv4-only, +# so v6 is locked down to SSH + the same public services as v4. +# ${PUBIF} is substituted by host/setup.sh / Ansible. +# Installed to /etc/iptables/rules.v6 (iptables-persistent). + +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] + +-A INPUT -i lo -j ACCEPT +-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +-A INPUT -m conntrack --ctstate INVALID -j DROP + +# ICMPv6 is mandatory (NDP, PMTUD) — dropping it breaks IPv6 entirely +-A INPUT -p ipv6-icmp -j ACCEPT +# DHCPv6 replies from link-local, if the provider uses it +-A INPUT -s fe80::/10 -p udp --dport 546 -j ACCEPT + +# Same public exposure as v4: SSH, WireGuard, OpenVPN — nothing else. +# Technitium :53/:5380 and RADIUS stay unreachable over v6. +-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 + +COMMIT diff --git a/host/setup.sh b/host/setup.sh index aeb80fc..50acc91 100644 --- a/host/setup.sh +++ b/host/setup.sh @@ -18,8 +18,18 @@ echo "==> sysctl (ip_forward)" install -m 0644 99-vpnhub-sysctl.conf /etc/sysctl.d/99-vpnhub.conf sysctl --system >/dev/null +echo "==> Freeing port 53 (systemd-resolved stub listener) for Technitium" +mkdir -p /etc/systemd/resolved.conf.d +printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/99-vpnhub.conf +# resolv.conf must stop pointing at the (now dead) 127.0.0.53 stub +if [ -e /run/systemd/resolve/resolv.conf ]; then + ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf +fi +systemctl try-restart systemd-resolved || true + echo "==> Firewall (WARNING: INPUT policy DROP — port 22 stays open)" sed "s/\${PUBIF}/${PUBIF}/g" rules.v4.template > /etc/iptables/rules.v4 +sed "s/\${PUBIF}/${PUBIF}/g" rules.v6.template > /etc/iptables/rules.v6 netfilter-persistent reload # reload flushed Docker's chains; put them back if Docker is running if systemctl is-active --quiet docker; then diff --git a/openvpn/server.conf b/openvpn/server.conf index 76bb8d4..4a3cc93 100644 --- a/openvpn/server.conf +++ b/openvpn/server.conf @@ -15,7 +15,7 @@ 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 +# All client DNS goes to Technitium 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" diff --git a/technitium/Dockerfile b/technitium/Dockerfile new file mode 100644 index 0000000..e0f92d4 --- /dev/null +++ b/technitium/Dockerfile @@ -0,0 +1,7 @@ +# One-shot init job: drives the Technitium HTTP API to create the AD +# conditional-forwarder zones. Idempotent — existing zones are left alone. +FROM alpine:3.20 +RUN apk add --no-cache curl jq +COPY configure.sh /configure.sh +RUN chmod +x /configure.sh +ENTRYPOINT ["/configure.sh"] diff --git a/technitium/configure.sh b/technitium/configure.sh new file mode 100644 index 0000000..afb0eaf --- /dev/null +++ b/technitium/configure.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# Declarative-ish config for Technitium: ensures the AD conditional +# forwarder zones exist. Zones that already exist are NOT touched, so +# manual tweaks in the web console survive re-runs. To re-apply a zone +# from here, delete it in the console and re-run: +# docker compose up technitium-init +set -eu + +API="http://127.0.0.1:5380/api" + +: "${TECHNITIUM_ADMIN_PASSWORD:?set in .env}" +: "${TROCH_DC1_IP:?set in .env}" +: "${TROCH_DC2_IP:?set in .env}" +: "${OSWALD_DC_IP:?set in .env}" + +echo "Waiting for the Technitium API..." +i=0 +until curl -sf -o /dev/null "$API/user/login" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -le 60 ] || { echo "ERROR: API not up after 120s" >&2; exit 1; } + sleep 2 +done + +TOKEN=$(curl -sf -G "$API/user/login" \ + --data-urlencode "user=admin" \ + --data-urlencode "pass=$TECHNITIUM_ADMIN_PASSWORD" | jq -r '.token // empty') +[ -n "$TOKEN" ] || { echo "ERROR: login failed — check TECHNITIUM_ADMIN_PASSWORD" >&2; exit 1; } +AUTH="Authorization: Bearer $TOKEN" + +ZONES_JSON=$(curl -sf -H "$AUTH" "$API/zones/list?pageNumber=1&zonesPerPage=200") + +zone_exists() { + echo "$ZONES_JSON" | jq -e --arg z "$1" \ + '.response.zones[]? | select(.name == $z)' >/dev/null +} + +api_ok() { jq -e '.status == "ok"' >/dev/null; } + +# ensure_fwd_zone [more forwarders...] +ensure_fwd_zone() { + zone=$1; shift + if zone_exists "$zone"; then + echo "zone $zone already exists — leaving as-is" + return 0 + fi + first=$1; shift + echo "creating conditional forwarder zone $zone -> $first" + curl -sf -H "$AUTH" -G "$API/zones/create" \ + --data-urlencode "zone=$zone" \ + --data-urlencode "type=Forwarder" \ + --data-urlencode "initializeForwarder=true" \ + --data-urlencode "protocol=Udp" \ + --data-urlencode "forwarder=$first" \ + --data-urlencode "dnssecValidation=false" | api_ok + for extra in "$@"; do + echo " adding forwarder $extra to $zone" + curl -sf -H "$AUTH" -G "$API/zones/records/add" \ + --data-urlencode "domain=$zone" \ + --data-urlencode "zone=$zone" \ + --data-urlencode "type=FWD" \ + --data-urlencode "protocol=Udp" \ + --data-urlencode "forwarder=$extra" \ + --data-urlencode "dnssecValidation=false" | api_ok + done +} + +# AD forward zones -> DCs over the WireGuard tunnel. +# ad.atlashorizon.net also covers _msdcs.ad.atlashorizon.net. +ensure_fwd_zone ad.atlashorizon.net "$TROCH_DC1_IP" "$TROCH_DC2_IP" +ensure_fwd_zone ad.calliecorner.net "$TROCH_DC1_IP" "$TROCH_DC2_IP" +ensure_fwd_zone ad.trochalakis.net "$TROCH_DC1_IP" "$TROCH_DC2_IP" +# Append "$TROCH_DC1_IP" "$TROCH_DC2_IP" here ONLY if this zone replicates +# forest-wide (ForestDnsZones) to the root DCs: +ensure_fwd_zone ad.oswaldtheotter.net "$OSWALD_DC_IP" + +# Reverse zones so PTR lookups for site hosts work +ensure_fwd_zone 20.168.192.in-addr.arpa "$TROCH_DC1_IP" "$TROCH_DC2_IP" +ensure_fwd_zone 7.168.192.in-addr.arpa "$OSWALD_DC_IP" + +echo "Technitium AD forwarder zones OK." diff --git a/unbound/Dockerfile b/unbound/Dockerfile deleted file mode 100644 index c23d033..0000000 --- a/unbound/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 3f6fada..0000000 --- a/unbound/entrypoint.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/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 deleted file mode 100644 index e81825b..0000000 --- a/unbound/unbound.conf.template +++ /dev/null @@ -1,89 +0,0 @@ -# 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}