next ai changes

This commit is contained in:
nick committed 2026-07-15 18:50:19 -07:00
1 parent 911b1f8c5d
commit b745376a65
23 files changed
+705 -155

No files matched your search

+15 -2
View File
@@ -1,6 +1,6 @@
# Copy to .env, fill in, then: chmod 600 .env # Copy to .env, fill in, then: chmod 600 .env
# Values here are rendered into unbound.conf, authproxy.cfg and pam_radius # Values here are rendered into authproxy.cfg, pam_radius config and the
# config at container start — this is the ONLY place DC IPs live. # Technitium forwarder zones at container start — the ONLY place DC IPs live.
# ── Domain controllers ──────────────────────────────────────────────── # ── Domain controllers ────────────────────────────────────────────────
# AHDC01 (Trochalakis, 192.168.20.0/24) # AHDC01 (Trochalakis, 192.168.20.0/24)
@@ -27,3 +27,16 @@ DUO_SECURITY_GROUP_DN=
# ── OpenVPN ↔ Duo Auth Proxy RADIUS shared secret ───────────────────── # ── OpenVPN ↔ Duo Auth Proxy RADIUS shared secret ─────────────────────
# Generate with: openssl rand -hex 24 # Generate with: openssl rand -hex 24
RADIUS_SECRET= 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
+7
View File
@@ -6,3 +6,10 @@ openvpn/pki/*
!openvpn/pki/README.md !openvpn/pki/README.md
duo-authproxy/certs/* duo-authproxy/certs/*
!duo-authproxy/certs/README.md !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
+30 -7
View File
@@ -10,7 +10,7 @@ Oswald↔Trochalakis tunnel — this box never carries it.
roaming client ══ovpn══▶ │ tun0 172.31.8.1 │ roaming client ══ovpn══▶ │ tun0 172.31.8.1 │
(172.31.8.x) │ openvpn ─▶ duo-authproxy ─▶ Duo cloud │ (172.31.8.x) │ openvpn ─▶ duo-authproxy ─▶ Duo cloud │
DNS/routes pushed │ (127.0.0.1:1812) └──LDAPS 636──┐ │ 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 ─────────────────────┼──┤ │ wg0 172.31.255.1 ─────────────────────┼──┤
└──────────┬──────────────────┬─────────┘ │ └──────────┬──────────────────┬─────────┘ │
wg .255.2 wg .255.3 (via wg0) 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 | | OpenVPN clients | `172.31.8.0/24` | routed (not NATed) to both sites |
Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp
(WireGuard) and 1194/udp (OpenVPN) are open; DNS/RADIUS/mgmt bind to (WireGuard) and 1194/udp (OpenVPN) are open. RADIUS binds loopback only;
loopback and tunnel IPs only. Technitium's DNS (:53) and web console (:5380) are firewalled to the
tunnels and additionally guarded by its recursion ACL.
## Layout ## 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) - [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` - [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 - [host/](host/) — `setup.sh` (sysctl, iptables, wg keygen), `wg0.conf.example`, firewall template
- [client/](client/) — `.ovpn` template for end users - [client/](client/) — `.ovpn` template for end users
- [ansible/](ansible/) — one idempotent playbook for setup **and** updates, deploys to `/opt/<service>` (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) - [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, 1. **Host**: `sudo host/setup.sh` — installs wireguard + iptables rules,
generates the WG key, prints the public key for the sites. 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. 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 - **MTU**: OpenVPN `tun-mtu 1400` / `mssfix 1360` keeps client packets
inside wg0's 1420 so nothing fragments on the middle leg. 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 - **Duo flow**: password → push; `password,passcode` → offline fallback
(`allow_concat=true`). `reneg-sec 0` + `auth-gen-token` prevent surprise (`allow_concat=true`). `reneg-sec 0` + `auth-gen-token` prevent surprise
re-prompts mid-session. `failmode=safe` fails *open* if Duo cloud is 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` + server instance (`proto tcp4`, `port 443`, separate `dev tun1` +
firewall rule). Not enabled by default. firewall rule). Not enabled by default.
- **Firewall reloads**: `netfilter-persistent reload` flushes Docker's - **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.
+70
View File
@@ -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`.
+8
View File
@@ -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
+41
View File
@@ -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
+11
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
collections:
- name: community.docker
version: ">=3.10.0"
- name: ansible.posix
version: ">=1.5.0"
+305
View File
@@ -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('<RACKNERD_WG_PRIVATE_KEY>', wg_key_raw.content | b64decode | trim)
| replace('<TROCHALAKIS_WG_PUBLIC_KEY>', troch_wg_public_key | default('<TROCHALAKIS_WG_PUBLIC_KEY>', true))
| replace('<OSWALD_WG_PUBLIC_KEY>', oswald_wg_public_key | default('<OSWALD_WG_PUBLIC_KEY>', 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
+26
View File
@@ -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
+43 -20
View File
@@ -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: # All services use network_mode: host on purpose:
# * openvpn's tun0 must live in the host netns so the kernel can route # * openvpn's tun0 must live in the host netns so the kernel can route
# tun0 <-> wg0 directly (wg0 is host-level wg-quick). # tun0 <-> wg0 directly (wg0 is host-level wg-quick).
# * unbound/duo bind ONLY loopback + tunnel IPs (enforced in their own # * duo binds only 127.0.0.1; technitium binds wildcard, but the host
# configs), so host networking exposes nothing on the public IP. # 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) # Nothing here publishes ports; the host firewall (host/rules.v4.template)
# is the single source of truth for what is reachable from where. # 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/<service> 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 name: racknerd-vpnhub
services: services:
unbound: technitium:
build: ./unbound # Pin a version (e.g. :13.6.0) for controlled upgrades
container_name: unbound 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 network_mode: host
env_file: .env env_file: .env
volumes: depends_on:
- ./unbound/unbound.conf.template:/etc/unbound/unbound.conf.template:ro - technitium
restart: unless-stopped restart: "no"
healthcheck:
test: ["CMD", "dig", "@127.0.0.1", "+time=3", "+tries=1", "cloudflare.com"]
interval: 30s
timeout: 5s
retries: 3
duo-authproxy: 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 container_name: duo-authproxy
network_mode: host # RADIUS listener bound to 127.0.0.1:1812 only network_mode: host # RADIUS listener bound to 127.0.0.1:1812 only
env_file: .env env_file: .env
volumes: volumes:
# AD LDAPS issuing-CA chain, exported from your CA (see certs/README.md) # 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 restart: unless-stopped
openvpn: openvpn:
build: ./openvpn build: ${OPENVPN_DIR:-./openvpn}
container_name: openvpn container_name: openvpn
network_mode: host # creates tun0 in the host netns network_mode: host # creates tun0 in the host netns
cap_add: cap_add:
@@ -45,12 +68,12 @@ services:
- /dev/net/tun - /dev/net/tun
env_file: .env env_file: .env
volumes: volumes:
- ./openvpn/server.conf:/etc/openvpn/server.conf:ro - ${OPENVPN_DIR:-./openvpn}/server.conf:/etc/openvpn/server.conf:ro
- ./openvpn/pki:/etc/openvpn/pki:ro - ${OPENVPN_DIR:-./openvpn}/pki:/etc/openvpn/pki:ro
- openvpn-state:/var/lib/openvpn - openvpn-state:/var/lib/openvpn
depends_on: depends_on:
- duo-authproxy - duo-authproxy
- unbound - technitium
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1 -1
View File
@@ -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. 49152-65535, NTP 123, GC 3268/3269.
Allow from **`172.31.255.1/32`** (the RackNerd host itself): 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) - TCP 636 → AHDC01, AHDC03 (Duo Auth Proxy LDAPS)
## 4. Active Directory ## 4. Active Directory
+11 -3
View File
@@ -13,10 +13,10 @@ ping -c3 <TROCH_DC1_IP> # AHDC01 across the tunnel
ping -c3 <OSWALD_DC_IP> # AH-OSWLDC across the tunnel ping -c3 <OSWALD_DC_IP> # AH-OSWLDC across the tunnel
``` ```
## 1. Unbound (on the VPS) ## 1. Technitium DNS (on the VPS)
```bash ```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): # 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 _ldap._tcp.dc._msdcs.ad.atlashorizon.net SRV +short
dig @127.0.0.1 ahdc01.ad.atlashorizon.net +short dig @127.0.0.1 ahdc01.ad.atlashorizon.net +short
@@ -25,6 +25,10 @@ dig @127.0.0.1 -x <TROCH_DC1_IP> +short # reverse zone
dig @127.0.0.1 google.com +short # public recursion 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) ## 2. Duo Auth Proxy (on the VPS)
```bash ```bash
@@ -68,12 +72,16 @@ This stage proves the **LDAP bind** leg independently of OpenVPN.
```bash ```bash
nmap -sU -p 53,1194,51820 <racknerd_public_ip> # 53 must NOT respond nmap -sU -p 53,1194,51820 <racknerd_public_ip> # 53 must NOT respond
nmap -sT -p 22,53,636,1812,7505 <racknerd_public_ip> # only 22 open nmap -sT -p 22,53,636,1812,5380,7505 <racknerd_public_ip> # only 22 open
# If the VPS has a public IPv6 address, repeat over v6:
nmap -6 -sT -p 22,53,5380 <racknerd_public_ipv6> # only 22 open
``` ```
## 6. Persistence ## 6. Persistence
```bash ```bash
docker compose down && docker compose up -d --build # survives cleanly 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 reboot # then: wg show, docker compose ps, re-run stage 3 quick connect
``` ```
+1 -1
View File
@@ -17,7 +17,7 @@ port=636
ssl_ca_certs_file=/certs/ad-ldaps-ca.pem ssl_ca_certs_file=/certs/ad-ldaps-ca.pem
; We target the DCs by IP, so hostname verification can't match the cert ; 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: ; 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. ; set this to true.
ssl_verify_hostname=false ssl_verify_hostname=false
; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env ; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env
+6 -2
View File
@@ -22,14 +22,18 @@
-A INPUT -i ${PUBIF} -p udp --dport 51820 -j ACCEPT -A INPUT -i ${PUBIF} -p udp --dport 51820 -j ACCEPT
-A INPUT -i ${PUBIF} -p udp --dport 1194 -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 udp --dport 53 -j ACCEPT
-A INPUT -i tun0 -d 172.31.8.1/32 -p tcp --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 udp --dport 53 -j ACCEPT
-A INPUT -i wg0 -p tcp --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) ──────────────── # ── FORWARD (routed, no NAT — DCs see real client IPs) ────────────────
-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FORWARD -m conntrack --ctstate INVALID -j DROP -A FORWARD -m conntrack --ctstate INVALID -j DROP
+27
View File
@@ -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
+10
View File
@@ -18,8 +18,18 @@ echo "==> sysctl (ip_forward)"
install -m 0644 99-vpnhub-sysctl.conf /etc/sysctl.d/99-vpnhub.conf install -m 0644 99-vpnhub-sysctl.conf /etc/sysctl.d/99-vpnhub.conf
sysctl --system >/dev/null 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)" 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.v4.template > /etc/iptables/rules.v4
sed "s/\${PUBIF}/${PUBIF}/g" rules.v6.template > /etc/iptables/rules.v6
netfilter-persistent reload netfilter-persistent reload
# reload flushed Docker's chains; put them back if Docker is running # reload flushed Docker's chains; put them back if Docker is running
if systemctl is-active --quiet docker; then if systemctl is-active --quiet docker; then
+1 -1
View File
@@ -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 192.168.7.0 255.255.255.0"
push "route 172.31.255.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). # Windows from leaking AD lookups to the local LAN resolver).
push "dhcp-option DNS 172.31.8.1" push "dhcp-option DNS 172.31.8.1"
push "dhcp-option DOMAIN ad.atlashorizon.net" push "dhcp-option DOMAIN ad.atlashorizon.net"
+7
View File
@@ -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"]
+80
View File
@@ -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 <zone> <forwarder> [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."
-12
View File
@@ -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"]
-17
View File
@@ -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
-89
View File
@@ -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}