CT-100 DNS: Migration nach Ansible abgeschlossen.

This commit is contained in:
2026-10-19 12:34:36 +02:00
parent 00ed091890
commit 82af4e9235
40 changed files with 1750 additions and 5 deletions
@@ -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/)
+13 -4
View File
@@ -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).
+55
View File
@@ -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.
@@ -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
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOw0Sh6jCGVXojiB8bqmcpgah4wFlZg+c+ka464QgCS3 ansible-deploy@dns-ct100
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgHLd4IZ1szLc9Js5L7s5UU6Rum6rTnQ3CnSvPiwtTO ghost-agent@dns-ct100
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMawQVxHdfcMCDXNmiMmU8hpY+dBmURAjiWkjwkEvDFW ghost@dns-ct100
@@ -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
@@ -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
@@ -0,0 +1,7 @@
all:
children:
dns_hosts:
hosts:
dns-ct100:
ansible_host: 10.0.0.14
ansible_user: ghost-agent
@@ -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
@@ -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
@@ -0,0 +1,7 @@
all:
children:
dns_hosts:
hosts:
dns-ct100:
ansible_host: 10.0.0.14
ansible_user: ghost
@@ -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
@@ -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
@@ -0,0 +1,7 @@
all:
children:
dns_hosts:
hosts:
dns-ct100:
ansible_host: 10.0.0.14
ansible_user: ansible-deploy
@@ -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 }}"
@@ -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
@@ -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 }}"
@@ -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
@@ -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.
@@ -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 }}"
@@ -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.
@@ -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
@@ -0,0 +1,5 @@
---
- name: Restart the classic SSH service
ansible.builtin.systemd_service:
name: ssh.service
state: restarted
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -0,0 +1,6 @@
---
- name: Restart the CT 100 firewall
ansible.builtin.systemd_service:
name: nftables.service
enabled: true
state: restarted
@@ -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
@@ -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;
}
}
@@ -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`.
@@ -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"
@@ -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 }}"
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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