diff --git a/.env.example b/.env.example index 39f8526..b8f429c 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,10 @@ RADIUS_SECRET= # Applied on FIRST start only; change later via the console itself. TECHNITIUM_ADMIN_PASSWORD= +# ── DNS zone for VPN client registration (A + PTR via learn-address) ── +# Leave empty to disable client DNS registration entirely. +VPN_DNS_ZONE=roam.atlashorizon.net + # ── Optional (defaults shown) ───────────────────────────────────────── # Duo Auth Proxy version; pin (e.g. 6.5.1) for deterministic updates #DUO_AUTHPROXY_VERSION=latest diff --git a/ansible/group_vars/vpnhub.example.yml b/ansible/group_vars/vpnhub.example.yml index 2234f0a..3650f93 100644 --- a/ansible/group_vars/vpnhub.example.yml +++ b/ansible/group_vars/vpnhub.example.yml @@ -34,6 +34,11 @@ radius_secret: "" # console (this var must still match for the init job's API login). technitium_admin_password: "" +# DNS zone where VPN clients get registered as . with a +# matching PTR (Technitium primary zone, created by the init job). Set to +# "" to disable registration. +vpn_dns_zone: roam.atlashorizon.net + # ── Optional ── # Pin for deterministic Duo updates (e.g. "6.5.1"); bumping it forces a # refetch of the tarball on the next rebuild. diff --git a/ansible/site.yml b/ansible/site.yml index 3e1421e..96e6408 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -23,6 +23,7 @@ vpnhub_base_dir: /opt vpnhub_stack_dir: "{{ vpnhub_base_dir }}/vpnhub" vpnhub_force_rebuild: false + vpnhub_host_dns: [1.1.1.1, 9.9.9.9] public_iface: "{{ ansible_default_ipv4.interface }}" wg_peers_configured: >- {{ (troch_wg_public_key | default('')) != '' @@ -30,9 +31,9 @@ handlers: - name: reload firewall + # the reload flushes the Docker-managed chains, so restart docker after 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 @@ -50,17 +51,18 @@ chdir: "{{ vpnhub_stack_dir }}" - name: recreate duo-authproxy - ansible.builtin.command: docker compose up -d --force-recreate duo-authproxy + # --build: entrypoint/cfg-template changes are baked into the image + ansible.builtin.command: docker compose up -d --build --force-recreate duo-authproxy args: chdir: "{{ vpnhub_stack_dir }}" - name: recreate openvpn - ansible.builtin.command: docker compose up -d --force-recreate openvpn + ansible.builtin.command: docker compose up -d --build --force-recreate openvpn args: chdir: "{{ vpnhub_stack_dir }}" - name: recreate stack - ansible.builtin.command: docker compose up -d --force-recreate + ansible.builtin.command: docker compose up -d --build --force-recreate args: chdir: "{{ vpnhub_stack_dir }}" @@ -76,9 +78,12 @@ update_cache: true cache_valid_time: 3600 + # Distro-agnostic: ansible_distribution is Ubuntu or Debian, and + # Docker publishes separate repos per distro (a Debian codename + # like trixie does not exist under linux/ubuntu and vice versa). - name: Docker apt signing key ansible.builtin.get_url: - url: https://download.docker.com/linux/ubuntu/gpg + url: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg" dest: /etc/apt/keyrings/docker.asc mode: "0644" @@ -87,7 +92,7 @@ repo: >- deb [arch={{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }} signed-by=/etc/apt/keyrings/docker.asc] - https://download.docker.com/linux/ubuntu + https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable filename: docker @@ -124,10 +129,19 @@ 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. + # Technitium binds 0.0.0.0:53 — free the port while keeping host DNS + # working. Ubuntu runs systemd-resolved (stub on 127.0.0.53:53 must be + # disabled); plain Debian has no resolved and just needs a sane static + # /etc/resolv.conf. + - name: Check whether systemd-resolved is in use + tags: host + ansible.builtin.stat: + path: /run/systemd/resolve/resolv.conf + register: resolved_upstream + - name: resolved drop-in directory tags: host + when: resolved_upstream.stat.exists ansible.builtin.file: path: /etc/systemd/resolved.conf.d state: directory @@ -135,6 +149,7 @@ - name: Disable systemd-resolved stub listener (frees :53 for Technitium) tags: host + when: resolved_upstream.stat.exists ansible.builtin.copy: dest: /etc/systemd/resolved.conf.d/99-vpnhub.conf mode: "0644" @@ -143,14 +158,29 @@ DNSStubListener=no notify: restart systemd-resolved - - name: Point /etc/resolv.conf at resolved's upstream file (not the dead stub) + - name: Point /etc/resolv.conf at resolved upstream file (not the dead stub) tags: host + when: resolved_upstream.stat.exists ansible.builtin.file: src: /run/systemd/resolve/resolv.conf dest: /etc/resolv.conf state: link force: true + - name: Static resolv.conf (no systemd-resolved on this host) + tags: host + when: not resolved_upstream.stat.exists + ansible.builtin.copy: + dest: /etc/resolv.conf + follow: false # replace a dangling symlink rather than write through it + mode: "0644" + content: | + # Managed by Ansible (vpnhub). Public resolvers on purpose: the + # host must resolve registries/repos even when the stack is down. + {% for ns in vpnhub_host_dns %} + nameserver {{ ns }} + {% endfor %} + - name: Firewall rules (rendered from host/rules.v4/v6.template) tags: [host, firewall] ansible.builtin.copy: diff --git a/ansible/templates/env.j2 b/ansible/templates/env.j2 index e406bce..3ac7381 100644 --- a/ansible/templates/env.j2 +++ b/ansible/templates/env.j2 @@ -18,6 +18,8 @@ RADIUS_SECRET={{ radius_secret }} TECHNITIUM_ADMIN_PASSWORD={{ technitium_admin_password }} +VPN_DNS_ZONE={{ vpn_dns_zone | default('roam.atlashorizon.net') }} + DUO_AUTHPROXY_VERSION={{ duo_authproxy_version | default('latest') }} # /opt layout — consumed by compose ${*_DIR} interpolation diff --git a/client/AtlasHorizon.ovpn b/client/AtlasHorizon.ovpn new file mode 100644 index 0000000..951b1dc --- /dev/null +++ b/client/AtlasHorizon.ovpn @@ -0,0 +1,62 @@ +client +dev tun +proto udp +remote connect.atlashorizon.net 1194 +resolv-retry infinite +nobind +persist-key +persist-tun +dhcp-option DNS 172.31.8.1 + +remote-cert-tls server +tls-version-min 1.2 +data-ciphers AES-256-GCM:CHACHA20-POLY1305 +auth SHA256 + +auth-user-pass +auth-retry interact +hand-window 120 +verb 3 + + +-----BEGIN CERTIFICATE----- +MIIDTjCCAjagAwIBAgIUFUdfgwj+K1Tt/fHBAvkfbCzjVhkwDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNzE2MDQyNTQyWhcNMzYw +NzEzMDQyNTQyWjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAKFZW5dKKR0/l6iPab513g8mCD+h8Kg8VmFY+PYS +jURdeb1hhTtVYuRir8jVKBmX1ck/KSATomzozhA97CpAkrZSrruHGwtJLAZDr+oQ +Fj7bzH2T94Pk35IbwI1HgIg9ZG18yONo9Lv6ODECZpVLQHzZpsBPWbWTSLiGYylp +1EiUo9PW7Dwx5xl/X1/oFjs+2wzv2F9GZZNNqQfDWupq18KwobUzpUAgNXbB1ZiV +AU//XWJg/s4fSdSsWY5ih+PoJawQBpdTxd4DWwC/tK3NDia/Rr5oeL6RctlO+Tps +BGjOtP5OHdqnuEC0QbuJcc9b8wuBdQzrWV5weCUOjMD6F6UCAwEAAaOBkzCBkDAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSEH5TGxRg/19B0j1Gih0XUWTJW3DBR +BgNVHSMESjBIgBSEH5TGxRg/19B0j1Gih0XUWTJW3KEapBgwFjEUMBIGA1UEAwwL +RWFzeS1SU0EgQ0GCFBVHX4MI/itU7f3xwQL5H2ws41YZMAsGA1UdDwQEAwIBBjAN +BgkqhkiG9w0BAQsFAAOCAQEAoI0jRxiKlNPbxSguWqW3Lr5t/a20Ntck5YY/6I3z +CR97hRJhLHOfrni4EOKASpEW/afFezH/yuQyYwWxjzkJYUnKA1SQxQYGtaQbV+yu +0n6uWwvhBwZTsESe0SrWLgw1IhKf609cRt6F/DdaX7oujSgJCQT/yzNjmCHHts87 +4qEN/fM3yE5peacO3g1zc0wjhdlG4sp8GJcpXFnCDcJsfNbgj9JYZTHulL9dhxmh +NZKMUGK3pXIQq0IU12GDh9gKCFUpe3ILfQ8gJCm/u1EYRSoC27ZDmtD+Pgsjwr0E +uLKM4f2c43Kq2XLdldreuRHMPLOZ0XQDo3lOdfVRp8Rcqw== +-----END CERTIFICATE----- + + +-----BEGIN OpenVPN Static key V1----- +5fedf4b825a32eb88722a14f801ef416 +2d015c4b5c41654c556108fc2fa5006e +f83c97ea210113935dbaf47469ba9cd4 +752f928caf48926b47b6da2b4573f6fa +ed909502d027ba02e509dc48c6542d74 +53c3013ce00e443b43faac4f8f87717f +1fe942bd1f1412fb38566a88ba7e0d5a +b722ea79cb4a1b5e172185d2644db81b +33fdc631815e47f6ae5ce099beee5c37 +b0880c7ff8f9c4ff7a8302e7eb692ce9 +eab50f743de8aa91bff266b2dc4f504c +775137ae64d71c79e35a40e8bedb8f67 +65bfa02d05bfc2a5e8824eff9be34a26 +a8c8b151a215ae26c67c006e431c8a57 +8985d2feec6787b629b2e30dfdc5ffea +a0d26b0b0f1aca83ccbfc77b660cceed +-----END OpenVPN Static key V1----- + diff --git a/docker-compose.yml b/docker-compose.yml index a86d3ec..7fb566f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,7 @@ services: env_file: .env volumes: - ${OPENVPN_DIR:-./openvpn}/server.conf:/etc/openvpn/server.conf:ro + - ${OPENVPN_DIR:-./openvpn}/learn-address.sh:/etc/openvpn/learn-address.sh:ro - ${OPENVPN_DIR:-./openvpn}/pki:/etc/openvpn/pki:ro - openvpn-state:/var/lib/openvpn depends_on: diff --git a/duo-authproxy/Dockerfile b/duo-authproxy/Dockerfile index 5a9b880..1ba5753 100644 --- a/duo-authproxy/Dockerfile +++ b/duo-authproxy/Dockerfile @@ -1,5 +1,11 @@ # Duo publishes no official image; build from their source tarball. # Install flags match Duo's documented non-interactive install. +# +# Slimming option: switch BOTH stages to debian:bookworm-slim (~80MB less, +# and the base layer is shared with the openvpn image). Both must move +# together — the proxy links against the builder's glibc. Alpine/musl +# will not work. +#FROM debian:bookworm-slim AS build FROM ubuntu:24.04 AS build @@ -20,6 +26,8 @@ RUN groupadd -r -g 35505 duo \ && ./install --install-dir /opt/duoauthproxy --service-user duo \ --log-group duo --create-init-script no --enable-selinux no +# Slim variant (must match the build stage base — see note above): +#FROM debian:bookworm-slim FROM ubuntu:24.04 # gettext-base = envsubst for rendering authproxy.cfg from env vars diff --git a/duo-authproxy/authproxy.cfg.template b/duo-authproxy/authproxy.cfg.template index 84c8536..4ad0d9e 100644 --- a/duo-authproxy/authproxy.cfg.template +++ b/duo-authproxy/authproxy.cfg.template @@ -8,7 +8,7 @@ debug=false [ad_client] host=${TROCH_DC1_IP} -host_2=${TROCH_DC2_IP} +;host_2=${TROCH_DC2_IP} service_account_username=${DUO_SVC_USER} service_account_password=${DUO_SVC_PASSWORD} search_dn=${AD_SEARCH_DN} @@ -22,7 +22,7 @@ ssl_ca_certs_file=/certs/ad-ldaps-ca.pem ssl_verify_hostname=false ; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env ; and uncomment): -;security_group_dn=${DUO_SECURITY_GROUP_DN} +security_group_dn=${DUO_SECURITY_GROUP_DN} [radius_server_auto] ikey=${DUO_IKEY} diff --git a/duo-authproxy/entrypoint.sh b/duo-authproxy/entrypoint.sh index 7af7abb..da5d1cc 100644 --- a/duo-authproxy/entrypoint.sh +++ b/duo-authproxy/entrypoint.sh @@ -13,7 +13,7 @@ if [ ! -f /certs/ad-ldaps-ca.pem ]; then exit 1 fi -envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${DUO_IKEY} ${DUO_SKEY} ${DUO_API_HOST} ${DUO_SVC_USER} ${DUO_SVC_PASSWORD} ${AD_SEARCH_DN} ${RADIUS_SECRET}' \ +envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${DUO_IKEY} ${DUO_SKEY} ${DUO_API_HOST} ${DUO_SVC_USER} ${DUO_SVC_PASSWORD} ${AD_SEARCH_DN} ${RADIUS_SECRET} ${DUO_SECURITY_GROUP_DN}' \ < /opt/duoauthproxy/conf/authproxy.cfg.template \ > /opt/duoauthproxy/conf/authproxy.cfg chmod 600 /opt/duoauthproxy/conf/authproxy.cfg diff --git a/openvpn/Dockerfile b/openvpn/Dockerfile index 442d3a7..993f221 100644 --- a/openvpn/Dockerfile +++ b/openvpn/Dockerfile @@ -3,7 +3,7 @@ FROM debian:bookworm-slim # libpam-radius-auth: PAM -> RADIUS bridge to the Duo Auth Proxy on 127.0.0.1 RUN apt-get update \ && apt-get install -y --no-install-recommends \ - openvpn libpam-radius-auth iproute2 procps ca-certificates \ + openvpn libpam-radius-auth iproute2 procps ca-certificates socat curl jq \ && rm -rf /var/lib/apt/lists/* COPY pam-openvpn /etc/pam.d/openvpn diff --git a/openvpn/learn-address.sh b/openvpn/learn-address.sh new file mode 100644 index 0000000..1b1dc02 --- /dev/null +++ b/openvpn/learn-address.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# OpenVPN learn-address hook: registers VPN clients in Technitium DNS. +# add/update -> A .$VPN_DNS_ZONE + PTR, TTL 60 +# delete -> removes both records +# +# CRITICAL: this script must ALWAYS exit 0 — a non-zero exit makes +# OpenVPN reject the client's address. DNS registration is best-effort; +# it must never take the VPN down with it. + +OP="$1"; ADDR="$2"; CN="${3:-}" + +API="http://127.0.0.1:5380/api" +[ -n "${TECHNITIUM_ADMIN_PASSWORD:-}" ] || exit 0 +[ -n "${VPN_DNS_ZONE:-}" ] || exit 0 + +log() { echo "learn-address: $*"; } + +TOKEN=$(curl -sf -m 5 -G "$API/user/login" \ + --data-urlencode "user=admin" \ + --data-urlencode "pass=$TECHNITIUM_ADMIN_PASSWORD" 2>/dev/null \ + | jq -r '.token // empty') +[ -n "$TOKEN" ] || { log "Technitium API login failed; skipping $OP $ADDR"; exit 0; } +AUTH="Authorization: Bearer $TOKEN" + +# 172.31.8.5 -> 5.8.31.172.in-addr.arpa +PTR_HOST=$(printf '%s' "$ADDR" | awk -F. '{print $4"."$3"."$2"."$1".in-addr.arpa"}') + +deregister() { + # PTR tells us which A record this IP belonged to + TARGET=$(curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/get" \ + --data-urlencode "domain=$PTR_HOST" 2>/dev/null \ + | jq -r '.response.records[]? | select(.type=="PTR") | .rData.ptrName' | head -n1) + [ -n "$TARGET" ] || return 0 + curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/delete" \ + --data-urlencode "domain=$PTR_HOST" \ + --data-urlencode "type=PTR" \ + --data-urlencode "ptrName=$TARGET" >/dev/null 2>&1 || log "PTR delete failed for $ADDR" + curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/delete" \ + --data-urlencode "domain=$TARGET" \ + --data-urlencode "zone=$VPN_DNS_ZONE" \ + --data-urlencode "type=A" \ + --data-urlencode "ipAddress=$ADDR" >/dev/null 2>&1 || log "A delete failed for $TARGET/$ADDR" +} + +register() { + # sanitize CN (= AD username) into a DNS label + NAME=$(printf '%s' "$CN" | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' | sed 's/^-*//;s/-*$//' | cut -c1-63) + [ -n "$NAME" ] || return 0 + curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/add" \ + --data-urlencode "domain=$NAME.$VPN_DNS_ZONE" \ + --data-urlencode "zone=$VPN_DNS_ZONE" \ + --data-urlencode "type=A" \ + --data-urlencode "ttl=60" \ + --data-urlencode "ipAddress=$ADDR" \ + --data-urlencode "ptr=true" \ + --data-urlencode "createPtrZone=true" >/dev/null 2>&1 \ + || log "A/PTR add failed for $NAME/$ADDR" +} + +case "$OP" in + add) register ;; + update) deregister; register ;; # IP reassigned to a different client + delete) deregister ;; +esac + +exit 0 diff --git a/openvpn/server.conf b/openvpn/server.conf index e9a488d..35f45c3 100644 --- a/openvpn/server.conf +++ b/openvpn/server.conf @@ -10,21 +10,28 @@ dev tun0 topology subnet server 172.31.8.0 255.255.255.0 -ifconfig-pool-persist /var/lib/openvpn/ipp.txt + +# Identity = AD username (username-as-common-name), and one user WILL +# connect from several devices (laptop + phone). duplicate-cn allows the +# concurrent sessions and gives each its own pool IP. Sticky per-user IPs +# (ifconfig-pool-persist) are incompatible with that — do not re-enable. +duplicate-cn # Split tunnel: only the AD sites + transfer net ride the VPN. push "route 192.168.20.0 255.255.255.0" push "route 192.168.7.0 255.255.255.0" push "route 172.31.255.0 255.255.255.0" -# All client DNS goes to Technitium on this box (block-outside-dns stops -# Windows from leaking AD lookups to the local LAN resolver). +# ALL client DNS goes to Technitium. Deliberately NO "dhcp-option DOMAIN" +# or DOMAIN-SEARCH here: in OpenVPN3 clients (Connect on macOS/iOS/ +# Android/Windows) those options MEAN split-DNS — pushing them scopes the +# VPN DNS to just those domains. With only DNS pushed, 172.31.8.1 becomes +# the default resolver for every query. ADAPTER_DOMAIN_SUFFIX sets the +# Windows connection-specific suffix (short names like "ahdc01" still +# resolve) without triggering split-DNS. block-outside-dns additionally +# stops Windows from leaking queries to the local LAN resolver. push "dhcp-option DNS 172.31.8.1" -push "dhcp-option DOMAIN ad.atlashorizon.net" -push "dhcp-option DOMAIN-SEARCH ad.atlashorizon.net" -push "dhcp-option DOMAIN-SEARCH ad.calliecorner.net" -push "dhcp-option DOMAIN-SEARCH ad.oswaldtheotter.net" -push "dhcp-option DOMAIN-SEARCH ad.trochalakis.net" +push "dhcp-option ADAPTER_DOMAIN_SUFFIX ad.atlashorizon.net" push "block-outside-dns" # PKI — server certificate only (offline CA, see pki/README.md); @@ -44,8 +51,12 @@ plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so openvpn verify-client-cert none username-as-common-name hand-window 120 # allow time for the Duo push round-trip -reneg-sec 0 # no mid-session TLS renegotiation (would re-prompt Duo) -auth-gen-token 43200 # reconnects within 12h reuse a token instead of a new push +# Hourly rekey + 12h auth token: renegotiations are satisfied silently by +# the pushed token (no mid-session Duo prompts); a fresh password + push +# is only required once the token expires. (auth-gen-token requires a +# non-zero reneg-sec — do not set reneg-sec 0 here.) +reneg-sec 3600 +auth-gen-token 43200 keepalive 10 60 explicit-exit-notify 1 @@ -60,6 +71,15 @@ group nogroup persist-key persist-tun +# Register each client as .$VPN_DNS_ZONE (A + PTR) in Technitium +# on connect, clean up on disconnect. Invoked via sh so the mounted script +# needs no exec bit; env (TECHNITIUM_ADMIN_PASSWORD, VPN_DNS_ZONE) comes +# from the container environment. +script-security 2 +learn-address "/bin/sh /etc/openvpn/learn-address.sh" + status /var/lib/openvpn/status.log 30 -management 127.0.0.1 7505 +# Unix socket (root-only) instead of a passwordless TCP port: +# docker compose exec openvpn sh -c "socat - UNIX:/var/lib/openvpn/mgmt.sock" +management /var/lib/openvpn/mgmt.sock unix verb 3 diff --git a/technitium/configure.sh b/technitium/configure.sh index afb0eaf..60a6afd 100644 --- a/technitium/configure.sh +++ b/technitium/configure.sh @@ -77,4 +77,23 @@ ensure_fwd_zone ad.oswaldtheotter.net "$OSWALD_DC_IP" 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" +# Primary zones for VPN client registration (openvpn learn-address hook +# writes .$VPN_DNS_ZONE A records + PTRs for 172.31.8.0/24) +ensure_primary_zone() { + zone=$1 + if zone_exists "$zone"; then + echo "zone $zone already exists — leaving as-is" + return 0 + fi + echo "creating primary zone $zone" + curl -sf -H "$AUTH" -G "$API/zones/create" \ + --data-urlencode "zone=$zone" \ + --data-urlencode "type=Primary" | api_ok +} + +if [ -n "${VPN_DNS_ZONE:-}" ]; then + ensure_primary_zone "$VPN_DNS_ZONE" + ensure_primary_zone "8.31.172.in-addr.arpa" +fi + echo "Technitium AD forwarder zones OK."