CT-100 DNS: Migration nach Ansible abgeschlossen.
This commit is contained in:
@@ -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/)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user