next ai changes
This commit is contained in:
1 parent
911b1f8c5d
commit
b745376a65
23 files changed
+705
-155
No files matched your search
+15
-2
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<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)
|
||||
|
||||
## 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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
collections:
|
||||
- name: community.docker
|
||||
version: ">=3.10.0"
|
||||
- name: ansible.posix
|
||||
version: ">=1.5.0"
|
||||
@@ -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
|
||||
@@ -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
@@ -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/<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
|
||||
|
||||
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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+11
-3
@@ -13,10 +13,10 @@ ping -c3 <TROCH_DC1_IP> # AHDC01 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
|
||||
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 <TROCH_DC1_IP> +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 <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
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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."
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user