From 82af4e9235aea7d4c935f03332f6d3c2778e687f Mon Sep 17 00:00:00 2001 From: spektr Date: Mon, 19 Oct 2026 12:34:36 +0200 Subject: [PATCH] CT-100 DNS: Migration nach Ansible abgeschlossen. --- .../GNAI-012-PLAN_Pi-hole-DNS-Handoff.md | 47 ++- proxmox/lxc/ct-100-dns/README.md | 17 +- proxmox/lxc/ct-100-dns/ansible/README.md | 55 ++++ proxmox/lxc/ct-100-dns/ansible/ansible.cfg | 12 + .../ansible/files/ssh/dns-ansible-deploy.pub | 1 + .../ansible/files/ssh/dns-ghost-agent.pub | 1 + .../ansible/files/ssh/dns-ghost.pub | 1 + .../agent/group_vars/dns_hosts.yml | 37 +++ .../inventories/agent/host_vars/dns-ct100.yml | 6 + .../ansible/inventories/agent/hosts.yml | 7 + .../bootstrap/group_vars/dns_hosts.yml | 37 +++ .../bootstrap/host_vars/dns-ct100.yml | 6 + .../ansible/inventories/bootstrap/hosts.yml | 7 + .../production/group_vars/dns_hosts.yml | 37 +++ .../production/host_vars/dns-ct100.yml | 6 + .../ansible/inventories/production/hosts.yml | 7 + .../ansible/playbooks/audit-production.yml | 216 ++++++++++++++ .../ansible/playbooks/bootstrap.yml | 12 + .../ansible/playbooks/preflight.yml | 204 +++++++++++++ .../lxc/ct-100-dns/ansible/playbooks/site.yml | 25 ++ .../playbooks/verify-deploy-access.yml | 43 +++ .../ansible/playbooks/verify-production.yml | 277 ++++++++++++++++++ .../ansible/roles/dns_access/README.md | 16 + .../roles/dns_access/defaults/main.yml | 29 ++ .../roles/dns_access/handlers/main.yml | 5 + .../ansible/roles/dns_access/tasks/main.yml | 232 +++++++++++++++ .../roles/dns_access/templates/sshd.conf.j2 | 21 ++ .../ansible/roles/dns_firewall/README.md | 11 + .../roles/dns_firewall/defaults/main.yml | 7 + .../roles/dns_firewall/handlers/main.yml | 6 + .../ansible/roles/dns_firewall/tasks/main.yml | 76 +++++ .../dns_firewall/templates/nftables.conf.j2 | 29 ++ .../ansible/roles/dns_lxc_baseline/README.md | 10 + .../roles/dns_lxc_baseline/defaults/main.yml | 26 ++ .../roles/dns_lxc_baseline/tasks/main.yml | 58 ++++ .../ansible/roles/dns_pihole/README.md | 12 + .../roles/dns_pihole/defaults/main.yml | 22 ++ .../roles/dns_pihole/handlers/main.yml | 10 + .../ansible/roles/dns_pihole/tasks/main.yml | 88 ++++++ .../dns_pihole/templates/pihole.container.j2 | 36 +++ 40 files changed, 1750 insertions(+), 5 deletions(-) create mode 100644 proxmox/lxc/ct-100-dns/ansible/README.md create mode 100644 proxmox/lxc/ct-100-dns/ansible/ansible.cfg create mode 100644 proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ansible-deploy.pub create mode 100644 proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost-agent.pub create mode 100644 proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost.pub create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/agent/group_vars/dns_hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/agent/host_vars/dns-ct100.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/agent/hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/group_vars/dns_hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/host_vars/dns-ct100.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/production/group_vars/dns_hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/production/host_vars/dns-ct100.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/inventories/production/hosts.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/audit-production.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/bootstrap.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/preflight.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/site.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/verify-deploy-access.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/playbooks/verify-production.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_access/README.md create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_access/defaults/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_access/handlers/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_access/tasks/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_access/templates/sshd.conf.j2 create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/README.md create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/defaults/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/handlers/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/tasks/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/templates/nftables.conf.j2 create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/README.md create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/defaults/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/tasks/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/README.md create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/defaults/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/handlers/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/tasks/main.yml create mode 100644 proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/templates/pihole.container.j2 diff --git a/docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md b/docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md index edbdcb1..4222310 100644 --- a/docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md +++ b/docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md @@ -2,7 +2,7 @@ **Dokumenten-ID:** GNAI-012-PLAN **Titel:** Pi-hole-LXC, FRITZ!Box-DNS und Split-DNS für interne Dienste -**Status:** Implementiert und im LAN produktiv; Ansible-Import und klar ausgewiesene Folgepunkte offen +**Status:** Implementiert und im LAN produktiv; Ansible-Sollzustand als CT-Projekt angelegt, privilegierte Live-Adoption und klar ausgewiesene Folgepunkte offen **Stand:** 14.10.2026 **Referenzen:** [Projekt-Roadmap](../../../../GNAI-001-PLAN_Project-Roadmap.md), [Zugriffskonzept](../../../GNAI-005-SEC_Access-Control.md), [LXC-SSH-Architektur](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [Reverse Proxy](../ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md), [WireGuard](../ct-105-wireguard/GNAI-007-SEC_VPN.md) @@ -449,6 +449,51 @@ Zwei Konfigurationslücken bleiben vor weiteren Änderungen zu schließen: Dieser begrenzte Hardening-Drift wird beim Ansible-Import geschlossen und anschließend positiv und negativ geprüft. +### 10.3 Ansible-Sollzustandsprojekt vom 16.10.2026 + +Der erste Teil des Ansible-Imports ist umgesetzt: Das CT-Projekt +[`proxmox/lxc/ct-100-dns/ansible/`](../../../../../proxmox/lxc/ct-100-dns/ansible/README.md) +bildet den abgenommenen Ist-Zustand deklarativ ab, nach dem Schema des +CT-102-Keycloak-Projekts. + +- Inventare: `production` (`ansible-deploy`, noch nicht existent), `bootstrap` + (`ghost`, passwortgeschütztes sudo) und `agent` (`ghost-agent`, nur lesend). +- Rollen: `dns_lxc_baseline` (Identität, Pakete, `/opt/pihole`), + `dns_access` (Konten, Schlüssel, sudo, SSH-Baseline), + `dns_firewall` (nftables), `dns_pihole` (Quadlet, Secret-Existenzprüfung) + sowie die gemeinsame Rolle `podman_runtime`. +- Playbooks: `preflight.yml` (rein lesend, ohne Become), `audit-production.yml` + (lesender Tiefaudit mit Root, meldet die beiden SSH-Drifts statt an ihnen zu + scheitern), `bootstrap.yml` (Adoption von `ansible-deploy`), + `site.yml` (Konvergenz), `verify-deploy-access.yml` und + `verify-production.yml` (beide rein lesend). + +Nachweis am 16.10.2026: `preflight.yml` lief über den `ghost-agent` gegen das +produktive CT 100 mit `ok=18 changed=0 failed=0`; bestätigt wurden Identität, +keine fehlgeschlagenen Units, aktive Dienste, Listener auf 53/80/443/22, +Split-DNS, Upstream-Auflösung und Gravity-Blocking. Die Quadlet-Vorlage ist +byte-identisch mit der Live-Datei; die nftables-Vorlage weicht nur um die +Managed-Kennung ab; die SSH-Vorlage weicht nur um die beabsichtigten +Änderungen ab (zwei fehlende GNAI-011-Direktiven, `ansible-deploy` in +`AllowUsers`, Managed-Kennung). + +Ein dedizierter `ansible-deploy`-Schlüssel wurde auf der Workstation erzeugt +(Public-Fingerprint `SHA256:b4k+ArcjHzo3jr/SSylOK52X7LzSRHrUcbFZ84GmEjY`); +der zugehörige Schlüssel liegt nicht im Repository. Noch offen und nur mit +expliziter Betreiberfreigabe durchzuführen: interaktiver +`bootstrap.yml`-Lauf, lesender Tiefaudit, `site.yml --check --diff`, +Konvergenz mit offener Konsole, `verify-production.yml` und ein zweiter Lauf +mit `changed=0`. Danach folgt der getrennte Rebuild-Test auf einem +temporären LXC. + +Zusätzliche lesende Befunde vom 16.10.2026: In +`/etc/containers/systemd/` liegt eine inerte Bearbeitungsbackup-Datei +(`pihole.container.before-password-fix-20261001`); ihre Entfernung ist im +Projekt als bewusster Aufräumschritt deklariert. Die LXC-eigene +`resolv.conf` zeigt auf `10.0.0.1`, das an `10.0.0.14` weiterleitet; +Host-Namensauflösung funktioniert aktuell, der Aufbau bleibt aber +schleifenempfindlich und ist als Folgepunkt erfasst. + ## 11. Primärquellen - [Pi-hole – Voraussetzungen](https://docs.pi-hole.net/main/prerequisites/) diff --git a/proxmox/lxc/ct-100-dns/README.md b/proxmox/lxc/ct-100-dns/README.md index 40a6f54..fc4e902 100644 --- a/proxmox/lxc/ct-100-dns/README.md +++ b/proxmox/lxc/ct-100-dns/README.md @@ -2,10 +2,19 @@ Host root for the Pi-hole LXC at `10.0.0.14`. -The live service is stable and documented, but its original provisioning was -not implemented as Ansible. Before any configuration change, import the -observed Quadlet, nftables and SSH state into a CT-scoped Ansible project here, -add a read-only verifier, and prove check-mode plus a second `changed=0` run. +The live service is stable and documented. Its declared state now lives in the +CT-scoped Ansible project [`ansible/`](ansible/). The project was adopted from +the observed pre-Ansible state: the Pi-hole Quadlet template is byte-identical +with the live file, the nftables and SSH templates differ only by the +documented deliberate changes (managed markers, `AllowStreamLocalForwarding no`, +`DisableForwarding yes`, `ssh.socket` deactivation, `ansible-deploy` account). + +The read-only preflight verified the production LXC through the unprivileged +`ghost-agent` transport on 2026-10-16 with `ok=18 changed=0 failed=0`. The +privileged adoption steps (interactive `bootstrap.yml`, `site.yml --check`, +converge, `verify-production.yml`, second run with `changed=0`) are sequenced +in [`ansible/README.md`](ansible/README.md) and still require explicit +operator authorization. Current review findings and acceptance evidence are documented in [`../../../docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md`](../../../docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md). diff --git a/proxmox/lxc/ct-100-dns/ansible/README.md b/proxmox/lxc/ct-100-dns/ansible/README.md new file mode 100644 index 0000000..465ccc4 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/README.md @@ -0,0 +1,55 @@ +# CT 100 Pi-hole DNS Ansible project + +This self-contained host project manages the production Pi-hole LXC +(CT 100, `dns`, 10.0.0.14). It follows the CT 102 Keycloak project schema and +uses the repository-wide pinned control dependency and the shared +`podman_runtime` role, while all CT 100 inventories, playbooks, keys and +service roles remain inside the CT 100 root. + +Create the repository-level control environment once: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r shared/ansible/requirements-control.txt +``` + +Run commands from this directory and always select an inventory explicitly: + +```bash +cd proxmox/lxc/ct-100-dns/ansible +../../../../.venv/bin/ansible-playbook \ + --inventory inventories/production/hosts.yml \ + playbooks/verify-production.yml \ + --private-key ~/.ssh/id_ed25519_ghostnet_ct100_ansible_deploy +``` + +## Adoption sequence for the pre-Ansible CT 100 + +The LXC was provisioned before this project existed. The deliberate adoption +path keeps the healthy production resolver intact: + +1. `preflight.yml` on the `agent` inventory (read-only, unprivileged, + `--private-key ~/.ssh/id_ed25519_ghostnet_ct100_agent`) proves identity, + service health, listeners and DNS behavior. +2. `audit-production.yml` on the `bootstrap` inventory (read-only, become + through `ghost`, `--ask-become-pass`) deep-audits the root-only state and + reports the two documented GNAI-011 SSH drifts instead of failing on them. +3. `bootstrap.yml` on the `bootstrap` inventory adopts the + `ansible-deploy` account and hardened keys; keep a second session or the + Proxmox console open while SSH policy changes are applied. +4. `verify-deploy-access.yml` on the `production` inventory proves the new + deployment boundary. +5. `site.yml --check --diff` on the `production` inventory previews the + convergence. Expected changes: the two missing SSH forwarding directives, + `ssh.socket` deactivation, the managed marker in `nftables.conf`, the + `ansible-deploy` `AllowUsers` entry and the stale Quadlet edit backup. +6. `site.yml` converges; `verify-production.yml` plus a second + `site.yml --check` run with `changed=0` complete the adoption. + +The production verifier is read-only. A real `site.yml` run enforces the +declared state and therefore requires an explicit change authorization. + +The Pi-hole web password is a Podman secret on the target. It is never +created, read or transported by these playbooks. Do not store the secret +contents, private SSH keys or DNS query data in this repository. diff --git a/proxmox/lxc/ct-100-dns/ansible/ansible.cfg b/proxmox/lxc/ct-100-dns/ansible/ansible.cfg new file mode 100644 index 0000000..b0d9cb1 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +home = .ansible +host_key_checking = True +interpreter_python = auto_silent +local_tmp = .ansible/tmp +retry_files_enabled = False +roles_path = roles:../../../../shared/ansible/roles +timeout = 15 + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s diff --git a/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ansible-deploy.pub b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ansible-deploy.pub new file mode 100644 index 0000000..d0b684f --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ansible-deploy.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOw0Sh6jCGVXojiB8bqmcpgah4wFlZg+c+ka464QgCS3 ansible-deploy@dns-ct100 diff --git a/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost-agent.pub b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost-agent.pub new file mode 100644 index 0000000..a901032 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost-agent.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgHLd4IZ1szLc9Js5L7s5UU6Rum6rTnQ3CnSvPiwtTO ghost-agent@dns-ct100 diff --git a/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost.pub b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost.pub new file mode 100644 index 0000000..d12af30 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/files/ssh/dns-ghost.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMawQVxHdfcMCDXNmiMmU8hpY+dBmURAjiWkjwkEvDFW ghost@dns-ct100 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/agent/group_vars/dns_hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/group_vars/dns_hosts.yml new file mode 100644 index 0000000..48ec1a9 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/group_vars/dns_hosts.yml @@ -0,0 +1,37 @@ +ansible_become_method: sudo +ansible_python_interpreter: /usr/bin/python3 +ansible_ssh_common_args: >- + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct100 + +dns_expected_distribution: Debian +dns_expected_distribution_major_version: "13" +dns_expected_hostname: dns +dns_expected_virtualization_type: lxc +dns_expected_podman_version: 5.4.2 +dns_minimum_root_size_bytes: 8000000000 +dns_minimum_root_available_bytes: 1000000000 + +dns_firewall_management_ipv4: 10.0.0.0/24 +dns_firewall_ssh_port: 22 +dns_firewall_dns_tcp_ports: + - 53 + - 80 + - 443 + +dns_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +dns_pihole_image: ghcr.io/pi-hole/pihole +dns_pihole_image_tag: "2026.09.0" +dns_pihole_image_digest: sha256:5b9c8cf51de7d6d3f2240dbe72baf5f06e1fd39cb4d77a99fab4fa13e23bd1be +dns_pihole_upstreams: + - 9.9.9.9 + - 149.112.112.112 +dns_pihole_hosts_records: + - "10.0.0.10 auth-admin.ghostnet.selfhost.eu" +dns_pihole_timezone: Europe/Berlin +dns_pihole_database_max_db_days: 30 +dns_pihole_secret_name: pihole_web_password +dns_pihole_stale_quadlet_files: + - pihole.container.before-password-fix-20261001 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/agent/host_vars/dns-ct100.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/host_vars/dns-ct100.yml new file mode 100644 index 0000000..a7fb698 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/host_vars/dns-ct100.yml @@ -0,0 +1,6 @@ +dns_ctid: 100 +dns_expected_ipv4: 10.0.0.14 +dns_expected_ssh_host_key_fingerprint: >- + SHA256:I80xAUrYZw7UiI7cbZDOcl1idl70bDMAwLMPiPYnSNY +dns_expected_processor_vcpus: 1 +dns_minimum_memory_mb: 1024 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/agent/hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/hosts.yml new file mode 100644 index 0000000..4eef46c --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/agent/hosts.yml @@ -0,0 +1,7 @@ +all: + children: + dns_hosts: + hosts: + dns-ct100: + ansible_host: 10.0.0.14 + ansible_user: ghost-agent diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/group_vars/dns_hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/group_vars/dns_hosts.yml new file mode 100644 index 0000000..48ec1a9 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/group_vars/dns_hosts.yml @@ -0,0 +1,37 @@ +ansible_become_method: sudo +ansible_python_interpreter: /usr/bin/python3 +ansible_ssh_common_args: >- + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct100 + +dns_expected_distribution: Debian +dns_expected_distribution_major_version: "13" +dns_expected_hostname: dns +dns_expected_virtualization_type: lxc +dns_expected_podman_version: 5.4.2 +dns_minimum_root_size_bytes: 8000000000 +dns_minimum_root_available_bytes: 1000000000 + +dns_firewall_management_ipv4: 10.0.0.0/24 +dns_firewall_ssh_port: 22 +dns_firewall_dns_tcp_ports: + - 53 + - 80 + - 443 + +dns_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +dns_pihole_image: ghcr.io/pi-hole/pihole +dns_pihole_image_tag: "2026.09.0" +dns_pihole_image_digest: sha256:5b9c8cf51de7d6d3f2240dbe72baf5f06e1fd39cb4d77a99fab4fa13e23bd1be +dns_pihole_upstreams: + - 9.9.9.9 + - 149.112.112.112 +dns_pihole_hosts_records: + - "10.0.0.10 auth-admin.ghostnet.selfhost.eu" +dns_pihole_timezone: Europe/Berlin +dns_pihole_database_max_db_days: 30 +dns_pihole_secret_name: pihole_web_password +dns_pihole_stale_quadlet_files: + - pihole.container.before-password-fix-20261001 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/host_vars/dns-ct100.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/host_vars/dns-ct100.yml new file mode 100644 index 0000000..a7fb698 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/host_vars/dns-ct100.yml @@ -0,0 +1,6 @@ +dns_ctid: 100 +dns_expected_ipv4: 10.0.0.14 +dns_expected_ssh_host_key_fingerprint: >- + SHA256:I80xAUrYZw7UiI7cbZDOcl1idl70bDMAwLMPiPYnSNY +dns_expected_processor_vcpus: 1 +dns_minimum_memory_mb: 1024 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/hosts.yml new file mode 100644 index 0000000..4019698 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/bootstrap/hosts.yml @@ -0,0 +1,7 @@ +all: + children: + dns_hosts: + hosts: + dns-ct100: + ansible_host: 10.0.0.14 + ansible_user: ghost diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/production/group_vars/dns_hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/production/group_vars/dns_hosts.yml new file mode 100644 index 0000000..48ec1a9 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/production/group_vars/dns_hosts.yml @@ -0,0 +1,37 @@ +ansible_become_method: sudo +ansible_python_interpreter: /usr/bin/python3 +ansible_ssh_common_args: >- + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct100 + +dns_expected_distribution: Debian +dns_expected_distribution_major_version: "13" +dns_expected_hostname: dns +dns_expected_virtualization_type: lxc +dns_expected_podman_version: 5.4.2 +dns_minimum_root_size_bytes: 8000000000 +dns_minimum_root_available_bytes: 1000000000 + +dns_firewall_management_ipv4: 10.0.0.0/24 +dns_firewall_ssh_port: 22 +dns_firewall_dns_tcp_ports: + - 53 + - 80 + - 443 + +dns_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +dns_pihole_image: ghcr.io/pi-hole/pihole +dns_pihole_image_tag: "2026.09.0" +dns_pihole_image_digest: sha256:5b9c8cf51de7d6d3f2240dbe72baf5f06e1fd39cb4d77a99fab4fa13e23bd1be +dns_pihole_upstreams: + - 9.9.9.9 + - 149.112.112.112 +dns_pihole_hosts_records: + - "10.0.0.10 auth-admin.ghostnet.selfhost.eu" +dns_pihole_timezone: Europe/Berlin +dns_pihole_database_max_db_days: 30 +dns_pihole_secret_name: pihole_web_password +dns_pihole_stale_quadlet_files: + - pihole.container.before-password-fix-20261001 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/production/host_vars/dns-ct100.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/production/host_vars/dns-ct100.yml new file mode 100644 index 0000000..a7fb698 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/production/host_vars/dns-ct100.yml @@ -0,0 +1,6 @@ +dns_ctid: 100 +dns_expected_ipv4: 10.0.0.14 +dns_expected_ssh_host_key_fingerprint: >- + SHA256:I80xAUrYZw7UiI7cbZDOcl1idl70bDMAwLMPiPYnSNY +dns_expected_processor_vcpus: 1 +dns_minimum_memory_mb: 1024 diff --git a/proxmox/lxc/ct-100-dns/ansible/inventories/production/hosts.yml b/proxmox/lxc/ct-100-dns/ansible/inventories/production/hosts.yml new file mode 100644 index 0000000..a7a1c62 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/inventories/production/hosts.yml @@ -0,0 +1,7 @@ +all: + children: + dns_hosts: + hosts: + dns-ct100: + ansible_host: 10.0.0.14 + ansible_user: ansible-deploy diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/audit-production.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/audit-production.yml new file mode 100644 index 0000000..bd3d4c8 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/audit-production.yml @@ -0,0 +1,216 @@ +--- +- name: Read-only deep audit of the pre-Ansible CT 100 state + hosts: dns_hosts + become: true + gather_facts: true + gather_subset: + - min + - network + - virtual + + tasks: + - name: Verify the expected guest identity + ansible.builtin.assert: + that: + - inventory_hostname == "dns-ct100" + - dns_ctid | int == 100 + - ansible_facts["hostname"] == dns_expected_hostname + - ansible_facts["distribution"] == dns_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + dns_expected_distribution_major_version + - >- + ansible_facts["default_ipv4"]["address"] == dns_expected_ipv4 + fail_msg: The connected machine is not the declared CT 100 Pi-hole LXC. + + - name: Stat the managed configuration files + ansible.builtin.stat: + path: "{{ item.path }}" + loop: + - path: /etc/nftables.conf + - path: /etc/ssh/sshd_config.d/99-security.conf + - path: /etc/containers/systemd/pihole.container + - path: /opt/pihole + - path: /opt/pihole/etc-pihole + - path: /root/.ssh + register: dns_audit_stats + + - name: Report the observed file modes + ansible.builtin.debug: + msg: >- + {{ item.item.path }}: + mode={{ '%04o' % (item.stat.mode | int(0, 8)) }} + owner={{ item.stat.uid }}:{{ item.stat.gid }} + loop: "{{ dns_audit_stats.results }}" + loop_control: + label: "{{ item.item.path }}" + + - name: Read the deployed Quadlet + ansible.builtin.slurp: + src: "{{ dns_pihole_quadlet_path }}" + register: dns_audit_quadlet + + - name: Verify the live Quadlet already matches the declared template + ansible.builtin.assert: + that: + - >- + dns_audit_quadlet['content'] | b64decode == + lookup('ansible.builtin.template', + 'roles/dns_pihole/templates/pihole.container.j2') + success_msg: The live Pi-hole Quadlet matches the declared state. + fail_msg: >- + The live Pi-hole Quadlet differs from the declared template. + + - name: Read the live nftables configuration + ansible.builtin.slurp: + src: /etc/nftables.conf + register: dns_audit_nft + + - name: Report the nftables deviation from the declared template + ansible.builtin.debug: + msg: >- + {{ 'managed-marker-only' if + dns_audit_nft['content'] | b64decode == + lookup('ansible.builtin.template', + 'roles/dns_firewall/templates/nftables.conf.j2') + else 'nftables.conf differs from the template' }} + + - name: Validate the effective SSH policy + ansible.builtin.command: + argv: [/usr/sbin/sshd, -T] + check_mode: false + changed_when: false + register: dns_audit_sshd + + - name: Report the known GNAI-011 baseline drifts + ansible.builtin.debug: + msg: + allowstreamlocalforwarding_present: >- + {{ 'allowstreamlocalforwarding no' in dns_audit_sshd.stdout_lines }} + disableforwarding_present: >- + {{ 'disableforwarding yes' in dns_audit_sshd.stdout_lines }} + + - name: Verify the effective SSH baseline without the two drifts + ansible.builtin.assert: + that: + - "'permitrootlogin no' in dns_audit_sshd.stdout_lines" + - "'passwordauthentication no' in dns_audit_sshd.stdout_lines" + - "'kbdinteractiveauthentication no' in dns_audit_sshd.stdout_lines" + - "'pubkeyauthentication yes' in dns_audit_sshd.stdout_lines" + - "'allowtcpforwarding no' in dns_audit_sshd.stdout_lines" + - "'gatewayports no' in dns_audit_sshd.stdout_lines" + - "'permittunnel no' in dns_audit_sshd.stdout_lines" + - "'maxauthtries 3' in dns_audit_sshd.stdout_lines" + - "'logingracetime 30' in dns_audit_sshd.stdout_lines" + - "'loglevel VERBOSE' in dns_audit_sshd.stdout_lines" + fail_msg: >- + The effective SSH policy deviates beyond the two documented + GNAI-011 drifts. Review before converging. + + - name: Read the live admin authorized keys + ansible.builtin.slurp: + src: /home/ghost/.ssh/authorized_keys + register: dns_audit_admin_keys + + - name: Require the declared admin key to remain authorized + ansible.builtin.assert: + that: + - >- + lookup('ansible.builtin.file', + 'files/ssh/dns-ghost.pub') | trim in + dns_audit_admin_keys['content'] | b64decode + fail_msg: >- + The declared personal admin key is not present in the live ghost + authorized_keys. Converging would lock out the administrator. + + - name: Report the live admin authorized key lines + ansible.builtin.debug: + msg: >- + {{ dns_audit_admin_keys['content'] | b64decode | trim | split('\n') }} + + - name: Read failed systemd units + ansible.builtin.command: + argv: [/usr/bin/systemctl, --failed, --no-legend, --plain] + check_mode: false + changed_when: false + register: dns_audit_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - dns_audit_failed_units.stdout | trim == "" + + - name: Require the Podman secret to exist + ansible.builtin.command: + argv: [/usr/bin/podman, secret, inspect, "{{ dns_pihole_secret_name }}"] + check_mode: false + changed_when: false + register: dns_audit_secret + + - name: Require the pinned image to be present + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - exists + - "{{ dns_pihole_image }}:{{ dns_pihole_image_tag }}@{{ dns_pihole_image_digest }}" + check_mode: false + changed_when: false + register: dns_audit_image + + - name: Verify the runtime artifacts + ansible.builtin.assert: + that: + - dns_audit_image.rc == 0 + success_msg: >- + The digest-pinned Pi-hole image and the Podman secret are present. + + - name: Read the service states + ansible.builtin.command: + argv: [/usr/bin/systemctl, is-active, "{{ item }}"] + check_mode: false + changed_when: false + loop: + - ssh.service + - nftables.service + - pihole.service + - unattended-upgrades.service + register: dns_audit_services + + - name: Require every core service to be active + ansible.builtin.assert: + that: + - >- + dns_audit_services.results | + map(attribute='stdout') | unique | list == ['active'] + + - name: Read the split-DNS answer through Pi-hole + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - auth-admin.ghostnet.selfhost.eu + - A + check_mode: false + changed_when: false + register: dns_audit_split_dns + vars: + ansible_command_timeout: 10 + + - name: Require the split-DNS record to resolve correctly + ansible.builtin.assert: + that: + - dns_audit_split_dns.stdout | trim == "10.0.0.10" + + - name: Report the audit result + ansible.builtin.debug: + msg: + ctid: "{{ dns_ctid }}" + quadlet_matches_template: true + known_ssh_drifts_pending_closure: >- + {{ not ('disableforwarding yes' in dns_audit_sshd.stdout_lines) }} + services: >- + {{ dns_audit_services.results | + map(attribute='stdout') | unique | list }} + split_dns_auth_admin: "{{ dns_audit_split_dns.stdout | trim }}" diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/bootstrap.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/bootstrap.yml new file mode 100644 index 0000000..9f90f17 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/bootstrap.yml @@ -0,0 +1,12 @@ +--- +- name: Adopt the CT 100 access bootstrap declaratively + hosts: dns_hosts + become: true + gather_facts: true + gather_subset: + - min + - network + - virtual + + roles: + - role: dns_access diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/preflight.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/preflight.yml new file mode 100644 index 0000000..d1042d0 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/preflight.yml @@ -0,0 +1,204 @@ +--- +- name: Read-only verification of the CT 100 Pi-hole LXC + hosts: dns_hosts + become: false + gather_facts: true + gather_subset: + - min + - hardware + - network + - virtual + + tasks: + - name: Verify required inventory safeguards are defined + ansible.builtin.assert: + that: + - dns_ctid is defined + - dns_expected_hostname is defined + - dns_expected_ipv4 is defined + - dns_expected_ssh_host_key_fingerprint is defined + - dns_expected_distribution is defined + - dns_expected_distribution_major_version is defined + - dns_expected_virtualization_type is defined + - dns_minimum_memory_mb is defined + fail_msg: >- + The selected inventory lacks mandatory CT 100 identity safeguards. + Stop before running any configuration playbook. + + - name: Verify the expected guest identity + ansible.builtin.assert: + that: + - inventory_hostname == "dns-ct100" + - dns_ctid | int == 100 + - ansible_facts["hostname"] == dns_expected_hostname + - ansible_facts["distribution"] == dns_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + dns_expected_distribution_major_version + - >- + ansible_facts["default_ipv4"]["address"] == dns_expected_ipv4 + - >- + ansible_facts["virtualization_type"] == + dns_expected_virtualization_type + - ansible_facts["virtualization_role"] == "guest" + - ansible_facts["service_mgr"] == "systemd" + - ansible_facts["python"]["version"]["major"] | int >= 3 + fail_msg: >- + The connected machine is not the expected CT 100 Pi-hole LXC. + success_msg: >- + The expected Debian 13 Pi-hole DNS LXC is connected. + + - name: Read failed systemd units + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - --failed + - --no-legend + - --plain + check_mode: false + changed_when: false + register: dns_preflight_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - dns_preflight_failed_units.stdout | trim == "" + + - name: Read required service states + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - ssh.service + - nftables.service + - pihole.service + - unattended-upgrades.service + register: dns_preflight_service_states + + - name: Require every core service to be active + ansible.builtin.assert: + that: + - >- + dns_preflight_service_states.results | + map(attribute='stdout') | unique | list == ['active'] + + - name: Read the service listeners + ansible.builtin.command: + argv: + - /usr/bin/ss + - -H + - -tuln + check_mode: false + changed_when: false + register: dns_preflight_listeners + + - name: Require the DNS, web and SSH listeners + ansible.builtin.assert: + that: + - dns_preflight_listeners.stdout is search(':53 ') + - dns_preflight_listeners.stdout is search(':80 ') + - dns_preflight_listeners.stdout is search(':443 ') + - dns_preflight_listeners.stdout is search(':22 ') + fail_msg: >- + The CT 100 service listeners for DNS, web and SSH are incomplete. + + - name: Read the local split-DNS answer + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - auth-admin.ghostnet.selfhost.eu + - A + check_mode: false + changed_when: false + register: dns_preflight_split_dns + vars: + ansible_command_timeout: 10 + + - name: Require the split-DNS record to resolve to the reverse proxy + ansible.builtin.assert: + that: + - dns_preflight_split_dns.stdout | trim == "10.0.0.10" + fail_msg: >- + The split-DNS record auth-admin.ghostnet.selfhost.eu does not + resolve to 10.0.0.10. + + - name: Read the pi.hole answer + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - pi.hole + - A + check_mode: false + changed_when: false + register: dns_preflight_pihole_host + vars: + ansible_command_timeout: 10 + + - name: Require pi.hole to resolve to the LXC address + ansible.builtin.assert: + that: + - dns_preflight_pihole_host.stdout | trim == dns_expected_ipv4 + + - name: Read a public upstream answer + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - deb.debian.org + - A + check_mode: false + changed_when: false + register: dns_preflight_upstream + vars: + ansible_command_timeout: 10 + + - name: Require public name resolution through Pi-hole + ansible.builtin.assert: + that: + - dns_preflight_upstream.stdout_lines | length > 0 + + - name: Read a gravity-blocked answer + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - techques.com + - A + check_mode: false + changed_when: false + register: dns_preflight_blocked + vars: + ansible_command_timeout: 10 + + - name: Require gravity blocking to stay effective + ansible.builtin.assert: + that: + - dns_preflight_blocked.stdout | trim == "0.0.0.0" + fail_msg: >- + The gravity-blocked test domain no longer returns 0.0.0.0. + + - name: Report the verified CT 100 baseline + ansible.builtin.debug: + msg: + inventory_host: "{{ inventory_hostname }}" + ctid: "{{ dns_ctid }}" + hostname: "{{ ansible_facts['hostname'] }}" + operating_system: >- + {{ ansible_facts['distribution'] }} + {{ ansible_facts['distribution_version'] }} + default_ipv4: "{{ ansible_facts['default_ipv4']['address'] }}" + virtualization: >- + {{ ansible_facts['virtualization_type'] }} + {{ ansible_facts['virtualization_role'] }} + memory_mib: "{{ ansible_facts['memtotal_mb'] }}" + trusted_ssh_fingerprint: "{{ dns_expected_ssh_host_key_fingerprint }}" diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/site.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/site.yml new file mode 100644 index 0000000..c17095e --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/site.yml @@ -0,0 +1,25 @@ +--- +- name: Converge the CT 100 Pi-hole DNS LXC on the declared state + hosts: dns_hosts + become: true + gather_facts: true + gather_subset: + - min + - hardware + - network + - virtual + + roles: + - role: dns_lxc_baseline + vars: + dns_lxc_baseline_expected_hostname: "{{ dns_expected_hostname }}" + dns_lxc_baseline_expected_distribution: "{{ dns_expected_distribution }}" + dns_lxc_baseline_expected_distribution_major_version: >- + {{ dns_expected_distribution_major_version }} + dns_lxc_baseline_expected_ipv4: "{{ dns_expected_ipv4 }}" + dns_lxc_baseline_expected_virtualization_type: >- + {{ dns_expected_virtualization_type }} + - role: dns_access + - role: podman_runtime + - role: dns_firewall + - role: dns_pihole diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-deploy-access.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-deploy-access.yml new file mode 100644 index 0000000..fade58c --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-deploy-access.yml @@ -0,0 +1,43 @@ +--- +- name: Verify the CT 100 Ansible deployment boundary + hosts: dns_hosts + become: false + gather_facts: false + + tasks: + - name: Verify Ansible module execution without privilege escalation + ansible.builtin.ping: + + - name: Read the SSH account identity + ansible.builtin.command: + argv: + - /usr/bin/id + - -un + check_mode: false + changed_when: false + register: dns_deploy_account_identity + + - name: Verify the expected SSH account + ansible.builtin.assert: + that: + - dns_deploy_account_identity.stdout == "ansible-deploy" + fail_msg: The SSH connection did not use ansible-deploy. + + - name: Read the effective identity through sudo + become: true + ansible.builtin.command: + argv: + - /usr/bin/id + - -u + check_mode: false + changed_when: false + register: dns_deploy_root_identity + + - name: Verify non-interactive root escalation + ansible.builtin.assert: + that: + - dns_deploy_root_identity.stdout == "0" + fail_msg: Non-interactive sudo did not produce effective UID 0. + success_msg: >- + The CT 100 deployment account can connect and escalate privileges + non-interactively. diff --git a/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-production.yml b/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-production.yml new file mode 100644 index 0000000..219d3dc --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/playbooks/verify-production.yml @@ -0,0 +1,277 @@ +--- +- name: Read-only verification of the converged CT 100 production state + hosts: dns_hosts + become: true + gather_facts: true + gather_subset: + - min + - network + - virtual + + tasks: + - name: Require the production inventory safeguards + ansible.builtin.assert: + that: + - dns_ctid | int == 100 + - inventory_hostname == "dns-ct100" + - >- + dns_expected_ssh_host_key_fingerprint is + match('^SHA256:[A-Za-z0-9+/]{43}$') + + - name: Verify the expected guest identity + ansible.builtin.assert: + that: + - ansible_facts["hostname"] == dns_expected_hostname + - ansible_facts["distribution"] == dns_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + dns_expected_distribution_major_version + - >- + ansible_facts["default_ipv4"]["address"] == dns_expected_ipv4 + - >- + ansible_facts["virtualization_type"] == + dns_expected_virtualization_type + - ansible_facts["virtualization_role"] == "guest" + fail_msg: The connected machine is not the declared CT 100. + + - name: Read failed systemd units + ansible.builtin.command: + argv: [/usr/bin/systemctl, --failed, --no-legend, --plain] + check_mode: false + changed_when: false + register: dns_production_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - dns_production_failed_units.stdout | trim == "" + + - name: Read the deployment account identity + become: false + ansible.builtin.command: + argv: [/usr/bin/id, -un] + check_mode: false + changed_when: false + register: dns_production_account + + - name: Require the ansible-deploy transport + ansible.builtin.assert: + that: + - dns_production_account.stdout == "ansible-deploy" + + - name: Read account password states + ansible.builtin.command: + argv: [/usr/bin/passwd, -S, "{{ item }}"] + check_mode: false + changed_when: false + loop: + - ghost-agent + - ghost + - ansible-deploy + register: dns_production_password_states + + - name: Verify account password states + ansible.builtin.assert: + that: + - dns_production_password_states.results[0].stdout.split()[1] == "L" + - dns_production_password_states.results[1].stdout.split()[1] == "P" + - dns_production_password_states.results[2].stdout.split()[1] == "L" + + - name: Validate the effective SSH policy + ansible.builtin.command: + argv: [/usr/sbin/sshd, -T] + check_mode: false + changed_when: false + register: dns_production_sshd + + - name: Verify the complete effective SSH baseline + ansible.builtin.assert: + that: + - "'permitrootlogin no' in dns_production_sshd.stdout_lines" + - "'passwordauthentication no' in dns_production_sshd.stdout_lines" + - "'kbdinteractiveauthentication no' in dns_production_sshd.stdout_lines" + - "'pubkeyauthentication yes' in dns_production_sshd.stdout_lines" + - "'authenticationmethods publickey' in dns_production_sshd.stdout_lines" + - "'x11forwarding no' in dns_production_sshd.stdout_lines" + - "'allowagentforwarding no' in dns_production_sshd.stdout_lines" + - "'allowtcpforwarding no' in dns_production_sshd.stdout_lines" + - "'allowstreamlocalforwarding no' in dns_production_sshd.stdout_lines" + - "'gatewayports no' in dns_production_sshd.stdout_lines" + - "'permittunnel no' in dns_production_sshd.stdout_lines" + - "'disableforwarding yes' in dns_production_sshd.stdout_lines" + - "'maxauthtries 3' in dns_production_sshd.stdout_lines" + - "'logingracetime 30' in dns_production_sshd.stdout_lines" + - "'loglevel VERBOSE' in dns_production_sshd.stdout_lines" + fail_msg: >- + The effective SSH policy does not match the full GNAI-011 baseline. + + - name: Read the active nftables input chain + ansible.builtin.command: + argv: [/usr/sbin/nft, list, chain, inet, filter, input] + check_mode: false + changed_when: false + register: dns_production_nft_input + + - name: Verify the active firewall policy + ansible.builtin.assert: + that: + - dns_production_nft_input.stdout is search('policy drop') + - >- + dns_production_nft_input.stdout is + search('ip saddr {{ dns_firewall_management_ipv4 }}') + - dns_production_nft_input.stdout is search('udp dport 53') + + - name: Require nftables to be enabled and active + ansible.builtin.systemd_service: + name: nftables.service + enabled: true + state: started + + - name: Read the deployed nftables configuration + ansible.builtin.slurp: + src: /etc/nftables.conf + register: dns_production_nft_config + + - name: Verify the deployed nftables file matches the template + ansible.builtin.assert: + that: + - >- + dns_production_nft_config['content'] | b64decode == + lookup('ansible.builtin.template', + 'roles/dns_firewall/templates/nftables.conf.j2') + fail_msg: >- + /etc/nftables.conf differs from the declared template. + + - name: Read the deployed Quadlet + ansible.builtin.slurp: + src: "{{ dns_pihole_quadlet_path }}" + register: dns_production_quadlet + + - name: Verify the deployed Quadlet matches the template + ansible.builtin.assert: + that: + - >- + dns_production_quadlet['content'] | b64decode == + lookup('ansible.builtin.template', + 'roles/dns_pihole/templates/pihole.container.j2') + fail_msg: >- + The Pi-hole Quadlet differs from the declared template. + + - name: Require the Podman secret to exist + ansible.builtin.command: + argv: [/usr/bin/podman, secret, inspect, "{{ dns_pihole_secret_name }}"] + check_mode: false + changed_when: false + register: dns_production_secret + + - name: Require the pinned image to be present + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - exists + - "{{ dns_pihole_image }}:{{ dns_pihole_image_tag }}@{{ dns_pihole_image_digest }}" + check_mode: false + changed_when: false + register: dns_production_image + + - name: Verify the pinned image identity + ansible.builtin.assert: + that: + - dns_production_image.rc == 0 + + - name: Require the stale Quadlet backups to be gone + ansible.builtin.stat: + path: "/etc/containers/systemd/{{ item }}" + loop: "{{ dns_pihole_stale_quadlet_files }}" + register: dns_production_stale_files + + - name: Verify no stale Quadlet backups remain + ansible.builtin.assert: + that: + - dns_production_stale_files.results | map(attribute='stat.exists') | unique | list == [false] + + - name: Read required service states + ansible.builtin.command: + argv: [/usr/bin/systemctl, is-active, "{{ item }}"] + check_mode: false + changed_when: false + loop: + - ssh.service + - nftables.service + - pihole.service + - unattended-upgrades.service + register: dns_production_services + + - name: Require every production service to be active + ansible.builtin.assert: + that: + - >- + dns_production_services.results | + map(attribute='stdout') | unique | list == ['active'] + + - name: Read the service listeners + ansible.builtin.command: + argv: [/usr/bin/ss, -H, -tuln] + check_mode: false + changed_when: false + register: dns_production_listeners + + - name: Require the DNS, web and SSH listeners + ansible.builtin.assert: + that: + - dns_production_listeners.stdout is search(':53 ') + - dns_production_listeners.stdout is search(':80 ') + - dns_production_listeners.stdout is search(':443 ') + - dns_production_listeners.stdout is search(':22 ') + + - name: Read the split-DNS answer through Pi-hole + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - auth-admin.ghostnet.selfhost.eu + - A + check_mode: false + changed_when: false + register: dns_production_split_dns + vars: + ansible_command_timeout: 10 + + - name: Require the split-DNS record to resolve correctly + ansible.builtin.assert: + that: + - dns_production_split_dns.stdout | trim == "10.0.0.10" + + - name: Read a gravity-blocked answer through Pi-hole + ansible.builtin.command: + argv: + - /usr/bin/dig + - +short + - "@{{ dns_expected_ipv4 }}" + - techques.com + - A + check_mode: false + changed_when: false + register: dns_production_blocked + vars: + ansible_command_timeout: 10 + + - name: Require gravity blocking to stay effective + ansible.builtin.assert: + that: + - dns_production_blocked.stdout | trim == "0.0.0.0" + + - name: Report the verified CT 100 production state + ansible.builtin.debug: + msg: + ctid: "{{ dns_ctid }}" + hostname: "{{ ansible_facts['hostname'] }}" + ipv4: "{{ ansible_facts['default_ipv4']['address'] }}" + services: >- + {{ dns_production_services.results | + map(attribute='stdout') | unique | list }} + split_dns_auth_admin: "{{ dns_production_split_dns.stdout | trim }}" + gravity_blocked_techques: "{{ dns_production_blocked.stdout | trim }}" + trusted_ssh_fingerprint: "{{ dns_expected_ssh_host_key_fingerprint }}" diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/README.md b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/README.md new file mode 100644 index 0000000..74b662c --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/README.md @@ -0,0 +1,16 @@ +# CT 100 dns_access + +Owns the three fixed CT 100 accounts (`ghost-agent` UID 1000 read-only, +`ghost` UID 1001 with password-gated sudo, `ansible-deploy` UID 1002 with +validated `NOPASSWD: ALL`), their authorized keys and the hardened SSH +drop-in `/etc/ssh/sshd_config.d/99-security.conf`. + +The drop-in implements the full GNAI-011 LXC SSH baseline. Compared with the +pre-Ansible handoff state it deliberately adds `AllowStreamLocalForwarding no` +and `DisableForwarding yes` and extends `AllowUsers` by `ansible-deploy`. +Socket activation (`ssh.socket`) is disabled in favor of the classic +`ssh.service`, mirroring the CT 102 baseline. + +`ghost-agent` and `ghost` keep their plain pre-Ansible key lines to stay +identical with the accepted handoff state; only the deployment key carries +`restrict,from=` options. diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/defaults/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/defaults/main.yml new file mode 100644 index 0000000..a93731e --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/defaults/main.yml @@ -0,0 +1,29 @@ +--- +dns_access_source_networks: 10.0.0.0/24,10.8.0.0/24 +dns_access_public_key_root: "{{ playbook_dir }}/../files/ssh" + +dns_access_agent_user: ghost-agent +dns_access_agent_uid: 1000 +dns_access_agent_public_key_file: >- + {{ dns_access_public_key_root }}/dns-ghost-agent.pub + +dns_access_admin_user: ghost +dns_access_admin_uid: 1001 +dns_access_admin_public_key_file: >- + {{ dns_access_public_key_root }}/dns-ghost.pub + +dns_access_deploy_user: ansible-deploy +dns_access_deploy_uid: 1002 +dns_access_deploy_public_key_file: >- + {{ dns_access_public_key_root }}/dns-ansible-deploy.pub + +dns_access_ssh_dropin: /etc/ssh/sshd_config.d/99-security.conf +dns_access_sudoers_file: /etc/sudoers.d/90-ghostnet-ansible-deploy + +# AllowUsers stays explicit on CT 100: the pre-Ansible hardening set it, it +# was accepted with the GNAI-012 handoff and every login path is covered by +# the three managed accounts. +dns_access_allow_users: + - ghost-agent + - ghost + - ansible-deploy diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/handlers/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/handlers/main.yml new file mode 100644 index 0000000..f67d8e5 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart the classic SSH service + ansible.builtin.systemd_service: + name: ssh.service + state: restarted diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/tasks/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/tasks/main.yml new file mode 100644 index 0000000..ec9b080 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/tasks/main.yml @@ -0,0 +1,232 @@ +--- +- name: Create fixed primary groups for the CT 100 access accounts + ansible.builtin.group: + name: "{{ item.name }}" + gid: "{{ item.uid }}" + state: present + loop: + - name: "{{ dns_access_agent_user }}" + uid: "{{ dns_access_agent_uid }}" + - name: "{{ dns_access_admin_user }}" + uid: "{{ dns_access_admin_uid }}" + - name: "{{ dns_access_deploy_user }}" + uid: "{{ dns_access_deploy_uid }}" + loop_control: + label: "{{ item.name }}" + +- name: Create the fixed CT 100 access accounts + ansible.builtin.user: + name: "{{ item.name }}" + uid: "{{ item.uid }}" + group: "{{ item.name }}" + home: "/home/{{ item.name }}" + shell: /bin/bash + create_home: true + state: present + loop: + - name: "{{ dns_access_agent_user }}" + uid: "{{ dns_access_agent_uid }}" + - name: "{{ dns_access_admin_user }}" + uid: "{{ dns_access_admin_uid }}" + - name: "{{ dns_access_deploy_user }}" + uid: "{{ dns_access_deploy_uid }}" + loop_control: + label: "{{ item.name }}" + +- name: Keep the read-only agent outside supplementary groups + ansible.builtin.user: + name: "{{ dns_access_agent_user }}" + groups: "" + append: false + password_lock: true + +- name: Keep the deployment account outside supplementary groups + ansible.builtin.user: + name: "{{ dns_access_deploy_user }}" + groups: "" + append: false + password_lock: true + +- name: Grant password-gated sudo membership to the administrator + ansible.builtin.user: + name: "{{ dns_access_admin_user }}" + groups: + - sudo + append: true + +- name: Create protected SSH directories + ansible.builtin.file: + path: "/home/{{ item }}/.ssh" + state: directory + owner: "{{ item }}" + group: "{{ item }}" + mode: "0700" + loop: + - "{{ dns_access_agent_user }}" + - "{{ dns_access_admin_user }}" + - "{{ dns_access_deploy_user }}" + +- name: Install the read-only agent key + ansible.builtin.copy: + content: "{{ lookup('ansible.builtin.file', dns_access_agent_public_key_file) }}" + dest: "/home/{{ dns_access_agent_user }}/.ssh/authorized_keys" + owner: "{{ dns_access_agent_user }}" + group: "{{ dns_access_agent_user }}" + mode: "0600" + +- name: Install the personal administrator key + ansible.builtin.copy: + content: "{{ lookup('ansible.builtin.file', dns_access_admin_public_key_file) }}" + dest: "/home/{{ dns_access_admin_user }}/.ssh/authorized_keys" + owner: "{{ dns_access_admin_user }}" + group: "{{ dns_access_admin_user }}" + mode: "0600" + +- name: Install the restricted deployment key + ansible.builtin.copy: + content: | + restrict,from="{{ dns_access_source_networks }}" {{ lookup('ansible.builtin.file', dns_access_deploy_public_key_file) }} + dest: "/home/{{ dns_access_deploy_user }}/.ssh/authorized_keys" + owner: "{{ dns_access_deploy_user }}" + group: "{{ dns_access_deploy_user }}" + mode: "0600" + +- name: Install the validated deployment sudo policy + ansible.builtin.copy: + content: | + {{ dns_access_deploy_user }} ALL=(root) NOPASSWD: ALL + dest: "{{ dns_access_sudoers_file }}" + owner: root + group: root + mode: "0440" + validate: /usr/sbin/visudo -cf %s + +- name: Ensure root has no SSH authorization material + ansible.builtin.file: + path: /root/.ssh + state: directory + owner: root + group: root + mode: "0700" + +- name: Keep the root authorized keys file empty + ansible.builtin.copy: + content: "" + dest: /root/.ssh/authorized_keys + owner: root + group: root + mode: "0600" + +- name: Install the hardened SSH policy + ansible.builtin.template: + src: sshd.conf.j2 + dest: "{{ dns_access_ssh_dropin }}" + owner: root + group: root + mode: "0644" + validate: /usr/sbin/sshd -t -f %s + notify: Restart the classic SSH service + +- name: Disable SSH socket activation + ansible.builtin.systemd_service: + name: ssh.socket + enabled: false + state: stopped + +- name: Enable the classic SSH service + ansible.builtin.systemd_service: + name: ssh.service + enabled: true + state: started + +- name: Read account password states + ansible.builtin.command: + argv: + - /usr/bin/passwd + - -S + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - "{{ dns_access_agent_user }}" + - "{{ dns_access_admin_user }}" + - "{{ dns_access_deploy_user }}" + register: dns_access_password_states + +- name: Verify account password states + ansible.builtin.assert: + that: + - dns_access_password_states.results[0].stdout.split()[1] == "L" + - dns_access_password_states.results[1].stdout.split()[1] == "P" + - dns_access_password_states.results[2].stdout.split()[1] == "L" + fail_msg: >- + ghost-agent and ansible-deploy must remain locked, while ghost must keep + the independently assigned password used only for sudo. + +- name: Read effective supplementary groups + ansible.builtin.command: + argv: + - /usr/bin/id + - -Gn + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - "{{ dns_access_agent_user }}" + - "{{ dns_access_deploy_user }}" + register: dns_access_restricted_groups + +- name: Verify restricted accounts have no supplementary groups + ansible.builtin.assert: + that: + - >- + dns_access_restricted_groups.results[0].stdout == + dns_access_agent_user + - >- + dns_access_restricted_groups.results[1].stdout == + dns_access_deploy_user + +- name: Verify non-interactive deployment sudo + become: true + ansible.builtin.command: + argv: + - /usr/bin/id + - -u + check_mode: false + changed_when: false + register: dns_access_deploy_root_identity + +- name: Require effective root through the deployment account + ansible.builtin.assert: + that: + - dns_access_deploy_root_identity.stdout == "0" + +- name: Flush SSH policy handlers before final validation + ansible.builtin.meta: flush_handlers + +- name: Validate the effective SSH policy + ansible.builtin.command: + argv: + - /usr/sbin/sshd + - -T + check_mode: false + changed_when: false + register: dns_access_effective_sshd + when: not ansible_check_mode + +- name: Verify critical effective SSH settings + ansible.builtin.assert: + that: + - "'permitrootlogin no' in dns_access_effective_sshd.stdout_lines" + - "'passwordauthentication no' in dns_access_effective_sshd.stdout_lines" + - "'kbdinteractiveauthentication no' in dns_access_effective_sshd.stdout_lines" + - "'pubkeyauthentication yes' in dns_access_effective_sshd.stdout_lines" + - "'allowtcpforwarding no' in dns_access_effective_sshd.stdout_lines" + - "'allowstreamlocalforwarding no' in dns_access_effective_sshd.stdout_lines" + - "'disableforwarding yes' in dns_access_effective_sshd.stdout_lines" + - "'permittunnel no' in dns_access_effective_sshd.stdout_lines" + - "'maxauthtries 3' in dns_access_effective_sshd.stdout_lines" + - "'logingracetime 30' in dns_access_effective_sshd.stdout_lines" + - "'loglevel VERBOSE' in dns_access_effective_sshd.stdout_lines" + fail_msg: The effective SSH policy differs from the declared baseline. + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/templates/sshd.conf.j2 b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/templates/sshd.conf.j2 new file mode 100644 index 0000000..ed3b04d --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_access/templates/sshd.conf.j2 @@ -0,0 +1,21 @@ +# Managed by Ansible role dns_access. +PermitRootLogin no +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitEmptyPasswords no +AuthenticationMethods publickey +AllowUsers {{ dns_access_allow_users | join(' ') }} + +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +GatewayPorts no +PermitTunnel no +DisableForwarding yes + +MaxAuthTries 3 +LoginGraceTime 30 +LogLevel VERBOSE + diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/README.md b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/README.md new file mode 100644 index 0000000..58c9a62 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/README.md @@ -0,0 +1,11 @@ +# CT 100 dns_firewall + +Adopts the accepted pre-Ansible nftables state as a managed template: full +`flush ruleset`, a single `inet filter` table with INPUT policy drop and +only the CT 100 service ports from `10.0.0.0/24` (TCP 22/53/80/443, UDP 53). +Forward is drop, output is accept. No Podman network chains are needed +because Pi-hole runs with host networking. + +Before deploying, the role proves that the active Ansible SSH source is +inside the management network, so a converge run cannot cut its own +transport. The ruleset is validated with `nft --check` before it is written. diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/defaults/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/defaults/main.yml new file mode 100644 index 0000000..31901a9 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/defaults/main.yml @@ -0,0 +1,7 @@ +--- +dns_firewall_management_ipv4: 10.0.0.0/24 +dns_firewall_ssh_port: 22 +dns_firewall_dns_tcp_ports: + - 53 + - 80 + - 443 diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/handlers/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/handlers/main.yml new file mode 100644 index 0000000..dfc8173 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart the CT 100 firewall + ansible.builtin.systemd_service: + name: nftables.service + enabled: true + state: restarted diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/tasks/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/tasks/main.yml new file mode 100644 index 0000000..eed4608 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/tasks/main.yml @@ -0,0 +1,76 @@ +--- +- name: Require an explicit management allowlist + ansible.builtin.assert: + that: + - dns_firewall_management_ipv4 | length > 0 + - dns_firewall_dns_tcp_ports | length > 0 + fail_msg: The firewall allowlists must be explicit and non-empty. + +- name: Read the active Ansible SSH transport endpoint + become: false + ansible.builtin.command: + argv: + - /usr/bin/printenv + - SSH_CONNECTION + check_mode: false + changed_when: false + register: dns_firewall_ssh_connection + +- name: Require the active Ansible SSH source in the management allowlist + ansible.builtin.command: + argv: >- + {{ + [ + '/usr/bin/python3', + '-c', + 'import ipaddress,sys; address=ipaddress.ip_address(sys.argv[1]); raise SystemExit(0 if any(address in ipaddress.ip_network(network) for network in sys.argv[2:]) else 1)', + dns_firewall_ssh_connection.stdout.split()[0] + ] + [dns_firewall_management_ipv4] + }} + check_mode: false + changed_when: false + vars: + ansible_command_timeout: 5 + +- name: Install and validate the CT 100 nftables policy + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: "0755" + validate: /usr/sbin/nft --check --file %s + notify: Restart the CT 100 firewall + +- name: Enable and start the nftables service + ansible.builtin.systemd_service: + name: nftables.service + enabled: true + state: started + +- name: Flush firewall handlers before verification + ansible.builtin.meta: flush_handlers + +- name: Read the active input chain policy + ansible.builtin.command: + argv: + - /usr/sbin/nft + - list + - chain + - inet + - filter + - input + check_mode: false + changed_when: false + register: dns_firewall_active_input + when: not ansible_check_mode + +- name: Verify the active input chain policy + ansible.builtin.assert: + that: + - dns_firewall_active_input.stdout is search('policy drop') + - dns_firewall_active_input.stdout is search('udp dport 53') + - >- + dns_firewall_active_input.stdout is + search('tcp dport { ' ~ dns_firewall_ssh_port ~ ',') + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/templates/nftables.conf.j2 b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/templates/nftables.conf.j2 new file mode 100644 index 0000000..f5734e0 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_firewall/templates/nftables.conf.j2 @@ -0,0 +1,29 @@ +#!/usr/sbin/nft -f + +# Managed by Ansible role dns_firewall. +flush ruleset + +table inet filter { + chain input { + type filter hook input priority filter; policy drop; + + iifname "lo" accept + ct state invalid drop + ct state established,related accept + + ip protocol icmp accept + meta l4proto ipv6-icmp accept + + ip saddr {{ dns_firewall_management_ipv4 }} tcp dport { {{ dns_firewall_ssh_port }}, {{ dns_firewall_dns_tcp_ports | join(', ') }} } ct state new accept + ip saddr {{ dns_firewall_management_ipv4 }} udp dport 53 ct state new accept + } + + chain forward { + type filter hook forward priority filter; policy drop; + } + + chain output { + type filter hook output priority filter; policy accept; + } +} + diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/README.md b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/README.md new file mode 100644 index 0000000..8954b15 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/README.md @@ -0,0 +1,10 @@ +# CT 100 dns_lxc_baseline + +Asserts the CT 100 identity (hostname `dns`, Debian 13, IPv4 10.0.0.14, +unprivileged LXC guest), keeps the small administration package set present, +pins the timezone and owns the protected `/opt/pihole` directory layout. + +The identity assert runs before any baseline task; a mismatched host stops +the play. The role deliberately does not manage the Pi-hole Quadlet, Podman +secret, firewall or SSH policy - see `dns_pihole`, `dns_firewall` and +`dns_access`. diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/defaults/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/defaults/main.yml new file mode 100644 index 0000000..67b2707 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/defaults/main.yml @@ -0,0 +1,26 @@ +--- +dns_lxc_baseline_expected_hostname: dns +dns_lxc_baseline_expected_distribution: Debian +dns_lxc_baseline_expected_distribution_major_version: "13" +dns_lxc_baseline_expected_ipv4: 10.0.0.14 +dns_lxc_baseline_expected_virtualization_type: lxc +dns_lxc_baseline_timezone: Europe/Berlin + +dns_lxc_baseline_packages: + - bind9-dnsutils + - ca-certificates + - curl + - jq + - nftables + - openssl + - unattended-upgrades + +dns_lxc_baseline_directories: + - path: /opt/pihole + owner: root + group: root + mode: "0700" + - path: /opt/pihole/etc-pihole + owner: root + group: root + mode: "0700" diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/tasks/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/tasks/main.yml new file mode 100644 index 0000000..8510b8b --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_lxc_baseline/tasks/main.yml @@ -0,0 +1,58 @@ +--- +- name: Verify the CT 100 DNS identity before changing baseline state + ansible.builtin.assert: + that: + - ansible_facts["hostname"] == dns_lxc_baseline_expected_hostname + - >- + ansible_facts["distribution"] == + dns_lxc_baseline_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + dns_lxc_baseline_expected_distribution_major_version + - >- + ansible_facts["default_ipv4"]["address"] == + dns_lxc_baseline_expected_ipv4 + - >- + ansible_facts["virtualization_type"] == + dns_lxc_baseline_expected_virtualization_type + - ansible_facts["virtualization_role"] == "guest" + - ansible_facts["service_mgr"] == "systemd" + fail_msg: >- + The connected system is not the declared CT 100 Pi-hole LXC. No + baseline changes may be applied. + +- name: Install the baseline administration packages + ansible.builtin.apt: + name: "{{ dns_lxc_baseline_packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + install_recommends: false + +- name: Set the local timezone database link + ansible.builtin.file: + src: "/usr/share/zoneinfo/{{ dns_lxc_baseline_timezone }}" + dest: /etc/localtime + state: link + owner: root + group: root + force: true + +- name: Record the configured timezone + ansible.builtin.copy: + content: "{{ dns_lxc_baseline_timezone }}\n" + dest: /etc/timezone + owner: root + group: root + mode: "0644" + +- name: Create the protected Pi-hole configuration paths + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.group }}" + mode: "{{ item.mode }}" + loop: "{{ dns_lxc_baseline_directories }}" + loop_control: + label: "{{ item.path }}" diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/README.md b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/README.md new file mode 100644 index 0000000..f60b000 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/README.md @@ -0,0 +1,12 @@ +# CT 100 dns_pihole + +Manages the rootful Pi-hole Quadlet `/etc/containers/systemd/pihole.container` +exactly as accepted in the GNAI-012 handoff: digest-pinned official image, +host networking, persistent `/opt/pihole/etc-pihole`, Quad9 upstreams, the +split-DNS host record `auth-admin.ghostnet.selfhost.eu -> 10.0.0.10`, +DHCP/NTP disabled and a 30-day query database. + +The web password lives in the Podman secret `pihole_web_password`. This role +only asserts that the secret exists on the target; it never reads, writes or +transports secret contents. Stale Quadlet edit backups are removed by the +explicit `dns_pihole_stale_quadlet_files` list. diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/defaults/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/defaults/main.yml new file mode 100644 index 0000000..8a6b7a0 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/defaults/main.yml @@ -0,0 +1,22 @@ +--- +dns_pihole_image: ghcr.io/pi-hole/pihole +dns_pihole_image_tag: "2026.09.0" +dns_pihole_image_digest: sha256:5b9c8cf51de7d6d3f2240dbe72baf5f06e1fd39cb4d77a99fab4fa13e23bd1be +dns_pihole_container_name: pihole +dns_pihole_data_directory: /opt/pihole/etc-pihole +dns_pihole_secret_name: pihole_web_password +dns_pihole_timezone: Europe/Berlin +dns_pihole_upstreams: + - 9.9.9.9 + - 149.112.112.112 +dns_pihole_hosts_records: + - "10.0.0.10 auth-admin.ghostnet.selfhost.eu" +dns_pihole_database_max_db_days: 30 +dns_pihole_service_name: pihole.service +dns_pihole_quadlet_path: /etc/containers/systemd/pihole.container +dns_pihole_quadlet_mode: "0644" +# Deliberate cleanup: leftover edit backups inside the Quadlet directory are +# not configuration and must not accumulate. The generator ignores them, but +# the declared state keeps the directory clean. +dns_pihole_stale_quadlet_files: + - pihole.container.before-password-fix-20261001 diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/handlers/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/handlers/main.yml new file mode 100644 index 0000000..24654f5 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Reload systemd for the CT 100 Pi-hole service + ansible.builtin.systemd_service: + daemon_reload: true + +- name: Restart the CT 100 Pi-hole service + ansible.builtin.systemd_service: + name: "{{ dns_pihole_service_name }}" + enabled: true + state: restarted diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/tasks/main.yml b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/tasks/main.yml new file mode 100644 index 0000000..659c322 --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/tasks/main.yml @@ -0,0 +1,88 @@ +--- +- name: Require the Pi-hole image identity to be pinned + ansible.builtin.assert: + that: + - dns_pihole_image | length > 0 + - dns_pihole_image_tag | length > 0 + - dns_pihole_image_digest is match('^sha256:[0-9a-f]{64}$') + - dns_pihole_upstreams | length > 0 + - dns_pihole_hosts_records | length > 0 + fail_msg: >- + Pi-hole must be deployed from a digest-pinned image with explicit + upstreams and local host records. + +- name: Require the Podman secret to exist on the target + ansible.builtin.command: + argv: + - /usr/bin/podman + - secret + - inspect + - "{{ dns_pihole_secret_name }}" + check_mode: false + changed_when: false + register: dns_pihole_secret_state + +- name: Require the pinned Pi-hole image to be present + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - exists + - "{{ dns_pihole_image }}:{{ dns_pihole_image_tag }}@{{ dns_pihole_image_digest }}" + check_mode: false + changed_when: false + register: dns_pihole_image_state + +- name: Verify the pinned image is available + ansible.builtin.assert: + that: + - dns_pihole_image_state.rc == 0 + fail_msg: >- + The digest-pinned Pi-hole image is not present in the rootful store. + Pull it deliberately; never relax the digest pin. + +- name: Install the Pi-hole Quadlet + ansible.builtin.template: + src: pihole.container.j2 + dest: "{{ dns_pihole_quadlet_path }}" + owner: root + group: root + mode: "{{ dns_pihole_quadlet_mode }}" + notify: + - Reload systemd for the CT 100 Pi-hole service + - Restart the CT 100 Pi-hole service + +- name: Remove stale Quadlet edit backups + ansible.builtin.file: + path: "/etc/containers/systemd/{{ item }}" + state: absent + loop: "{{ dns_pihole_stale_quadlet_files }}" + loop_control: + label: "{{ item }}" + +- name: Enable and start the Pi-hole service + ansible.builtin.systemd_service: + name: "{{ dns_pihole_service_name }}" + enabled: true + state: started + +- name: Flush Pi-hole handlers before verification + ansible.builtin.meta: flush_handlers + +- name: Read the Pi-hole service state + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - "{{ dns_pihole_service_name }}" + check_mode: false + changed_when: false + register: dns_pihole_service_state + when: not ansible_check_mode + +- name: Verify the Pi-hole service is active + ansible.builtin.assert: + that: + - dns_pihole_service_state.stdout == "active" + fail_msg: The Pi-hole Quadlet service did not come up active. + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/templates/pihole.container.j2 b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/templates/pihole.container.j2 new file mode 100644 index 0000000..ae25ceb --- /dev/null +++ b/proxmox/lxc/ct-100-dns/ansible/roles/dns_pihole/templates/pihole.container.j2 @@ -0,0 +1,36 @@ +[Unit] +Description=Pi-hole DNS {{ dns_pihole_image_tag }} +Wants=network-online.target +After=network-online.target + +[Container] +Image={{ dns_pihole_image }}:{{ dns_pihole_image_tag }}@{{ dns_pihole_image_digest }} +Pull=missing +ContainerName={{ dns_pihole_container_name }} +Network=host + +Volume={{ dns_pihole_data_directory }}:/etc/pihole:rw +Secret={{ dns_pihole_secret_name }},target=/run/secrets/{{ dns_pihole_secret_name }},mode=0400 + +Environment=WEBPASSWORD_FILE={{ dns_pihole_secret_name }} +Environment=TZ={{ dns_pihole_timezone }} +Environment="FTLCONF_dns_upstreams={{ dns_pihole_upstreams | join(';') }}" +Environment=FTLCONF_dns_listeningMode=LOCAL +{% for record in dns_pihole_hosts_records %} +Environment="FTLCONF_dns_hosts={{ record }}" +{% endfor %} +Environment=FTLCONF_dhcp_active=false +Environment=FTLCONF_ntp_ipv4_active=false +Environment=FTLCONF_ntp_ipv6_active=false +Environment=FTLCONF_ntp_sync_active=false +Environment=FTLCONF_database_maxDBdays={{ dns_pihole_database_max_db_days }} + +[Service] +Restart=on-failure +RestartSec=5s +TimeoutStartSec=300 +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target +