--- # 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 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('')) != '' and (oswald_wg_public_key | default('')) != '' }} handlers: - name: reload firewall # the reload flushes the Docker-managed chains, so restart docker after ansible.builtin.shell: | netfilter-persistent reload 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 # --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 --build --force-recreate openvpn args: chdir: "{{ vpnhub_stack_dir }}" - name: recreate stack ansible.builtin.command: docker compose up -d --build --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 # 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/{{ ansible_distribution | lower }}/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/{{ ansible_distribution | lower }} {{ 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 — 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 mode: "0755" - 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" content: | [Resolve] DNSStubListener=no notify: restart systemd-resolved - 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: 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 + travel peers) tags: [host, wireguard] ansible.builtin.copy: dest: /etc/wireguard/wg0.conf mode: "0600" content: | {{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example') | replace('', wg_key_raw.content | b64decode | trim) | replace('', troch_wg_public_key | default('', true)) | replace('', oswald_wg_public_key | default('', true)) }} {% for p in wg_travel_peers | default([]) %} # ── Travel router: {{ p.name }} (roaming /32, appended by Ansible) ── [Peer] PublicKey = {{ p.public_key }} AllowedIPs = {{ p.ip }}/32 {% endfor %} 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