336 lines
12 KiB
YAML
336 lines
12 KiB
YAML
---
|
|
# 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)
|
|
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
|