From 220d921e0963036088ff1f064d917fd419cd06ad Mon Sep 17 00:00:00 2001 From: spektr Date: Mon, 26 Oct 2026 19:20:36 +0100 Subject: [PATCH] CT-101 Samba: Ansible-Migration (GNAI-017/018/019) mit PVE-Skripten, Runbooks und Fresh-State-Rebuild aufgenommen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ansible-Projekt proxmox/lxc/ct-101-samba/ansible: 7 Playbooks, 6 Rollen, 3 Inventories (agent/bootstrap/production), 11 fail-closed Platzhalter - Design GNAI-019: Samba nativ (templated smb.conf mit testparm-Gate), NFS-Ganesha als rootful Quadlet mit Host-Networking und leerem lxc.seccomp.profile, NFSv4-only, gn-media GID 970, passdb-Erhalt - PVE-Skripte: upgrade-ct101-to-debian13.sh (Adoption), provision-ct101-samba.sh (Fresh-State-Rebuild), sha256-gepinnt - Runbooks: Adoption (15 Schritte) und Fresh-State-Rebuild (9 Schritte) - Dokumentation: GNAI-019 (DEC), GNAI-017 aktualisiert, Index-Einträge; Befund Dist-Upgrade 21.10.2026: smb.conf-Verlust aus vzdump-Backup wiederhergestellt und verifiziert - Secrets (SMB-Passwörter, passdb, private Schlüssel) nicht im Repository Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- docs/GNAI-000-DOC_Index.md | 16 +- docs/platform/proxmox-server/README.md | 4 + .../GNAI-017-PLAN_Samba-NFS-Export.md | 186 +++++ ...I-018-REQ_Encoding-Pipeline-Integration.md | 186 +++++ .../GNAI-019-DEC_Ansible-Migration.md | 323 +++++++++ .../lxc/ct-101-samba/Containerfile.ganesha | 22 + proxmox/lxc/ct-101-samba/README.md | 32 + proxmox/lxc/ct-101-samba/ansible/README.md | 179 +++++ proxmox/lxc/ct-101-samba/ansible/ansible.cfg | 12 + .../files/ssh/samba-ansible-deploy.pub | 1 + .../ansible/files/ssh/samba-ghost-agent.pub | 1 + .../ansible/files/ssh/samba-ghost.pub | 1 + .../agent/group_vars/samba_hosts.yml | 225 ++++++ .../agent/host_vars/samba-ct101.yml | 16 + .../ansible/inventories/agent/hosts.yml | 8 + .../bootstrap/group_vars/samba_hosts.yml | 225 ++++++ .../bootstrap/host_vars/samba-ct101.yml | 16 + .../ansible/inventories/bootstrap/hosts.yml | 8 + .../production/group_vars/samba_hosts.yml | 225 ++++++ .../production/host_vars/samba-ct101.yml | 16 + .../ansible/inventories/production/hosts.yml | 8 + .../ansible/playbooks/audit-production.yml | 305 ++++++++ .../ct-101-samba/ansible/playbooks/backup.yml | 201 ++++++ .../ansible/playbooks/bootstrap.yml | 12 + .../ansible/playbooks/preflight.yml | 400 +++++++++++ .../ct-101-samba/ansible/playbooks/site.yml | 28 + .../playbooks/verify-deploy-access.yml | 43 ++ .../ansible/playbooks/verify-production.yml | 659 ++++++++++++++++++ .../ansible/roles/samba_access/README.md | 24 + .../roles/samba_access/defaults/main.yml | 27 + .../roles/samba_access/handlers/main.yml | 5 + .../samba_access/meta/argument_specs.yml | 44 ++ .../ansible/roles/samba_access/tasks/main.yml | 351 ++++++++++ .../roles/samba_access/templates/sshd.conf.j2 | 24 + .../ansible/roles/samba_firewall/README.md | 27 + .../roles/samba_firewall/defaults/main.yml | 13 + .../roles/samba_firewall/handlers/main.yml | 10 + .../samba_firewall/meta/argument_specs.yml | 27 + .../roles/samba_firewall/tasks/main.yml | 95 +++ .../samba_firewall/templates/nftables.conf.j2 | 49 ++ .../nftables.service-override.conf.j2 | 5 + .../roles/samba_lxc_baseline/README.md | 15 + .../samba_lxc_baseline/defaults/main.yml | 53 ++ .../samba_lxc_baseline/handlers/main.yml | 1 + .../meta/argument_specs.yml | 31 + .../roles/samba_lxc_baseline/tasks/main.yml | 93 +++ .../ansible/roles/samba_media/README.md | 28 + .../roles/samba_media/defaults/main.yml | 32 + .../roles/samba_media/meta/argument_specs.yml | 39 ++ .../ansible/roles/samba_media/tasks/main.yml | 316 +++++++++ .../ansible/roles/samba_nfs/README.md | 44 ++ .../ansible/roles/samba_nfs/defaults/main.yml | 18 + .../ansible/roles/samba_nfs/handlers/main.yml | 9 + .../roles/samba_nfs/meta/argument_specs.yml | 47 ++ .../ansible/roles/samba_nfs/tasks/main.yml | 294 ++++++++ .../roles/samba_nfs/templates/ganesha.conf.j2 | 32 + .../templates/ghostnet-samba-nfs.container.j2 | 50 ++ .../ansible/roles/samba_smb/README.md | 34 + .../ansible/roles/samba_smb/defaults/main.yml | 22 + .../ansible/roles/samba_smb/handlers/main.yml | 10 + .../roles/samba_smb/meta/argument_specs.yml | 29 + .../ansible/roles/samba_smb/tasks/main.yml | 166 +++++ .../roles/samba_smb/templates/smb.conf.j2 | 77 ++ .../ct-101-samba/ghostnet-ganesha-healthcheck | 9 + proxmox/lxc/ct-101-samba/scripts/README.md | 136 ++++ .../scripts/provision-ct101-samba.sh | 295 ++++++++ .../scripts/upgrade-ct101-to-debian13.sh | 275 ++++++++ 67 files changed, 6210 insertions(+), 4 deletions(-) create mode 100644 docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md create mode 100644 docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md create mode 100644 docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md create mode 100644 proxmox/lxc/ct-101-samba/Containerfile.ganesha create mode 100644 proxmox/lxc/ct-101-samba/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/ansible.cfg create mode 100644 proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ansible-deploy.pub create mode 100644 proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost-agent.pub create mode 100644 proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost.pub create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/agent/group_vars/samba_hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/agent/host_vars/samba-ct101.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/agent/hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/group_vars/samba_hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/host_vars/samba-ct101.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/production/group_vars/samba_hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/production/host_vars/samba-ct101.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/inventories/production/hosts.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/audit-production.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/backup.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/bootstrap.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/preflight.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/site.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/verify-deploy-access.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/playbooks/verify-production.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/handlers/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_access/templates/sshd.conf.j2 create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/handlers/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.conf.j2 create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.service-override.conf.j2 create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/handlers/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_media/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_media/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_media/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_media/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/handlers/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ganesha.conf.j2 create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ghostnet-samba-nfs.container.j2 create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/README.md create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/defaults/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/handlers/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/meta/argument_specs.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/tasks/main.yml create mode 100644 proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/templates/smb.conf.j2 create mode 100755 proxmox/lxc/ct-101-samba/ghostnet-ganesha-healthcheck create mode 100644 proxmox/lxc/ct-101-samba/scripts/README.md create mode 100644 proxmox/lxc/ct-101-samba/scripts/provision-ct101-samba.sh create mode 100644 proxmox/lxc/ct-101-samba/scripts/upgrade-ct101-to-debian13.sh diff --git a/docs/GNAI-000-DOC_Index.md b/docs/GNAI-000-DOC_Index.md index ad08066..07e65b9 100644 --- a/docs/GNAI-000-DOC_Index.md +++ b/docs/GNAI-000-DOC_Index.md @@ -1,6 +1,6 @@ # Ghostnet.AI – Dokumentationsindex und Projektstand -**Stand:** 2026-10-14 +**Stand:** 2026-10-21 **Status:** Plattformgrundlage vorhanden; Sicherheitsabschluss und LLM-Serving offen @@ -38,6 +38,9 @@ jeweiligen Fachdokumenten. Die priorisierte weitere Umsetzung beschreibt der | [VPN-Client-Verwaltung](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN_Client-Management.md) | Ausgabe, Änderung und Widerruf von Geräteprofilen | | [Windows 11](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN_Clients_Windows11.md) / [Linux](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN_Clients_Linux.md) | Client-SOPs | | [VPN-Implementation-Log](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN_Implementation-Log.md) | historische Umsetzung und datierte Evidenz | +| [CT 101 / GNAI-017](platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md) | Samba-Dateiserver `10.0.0.9`: Aufnahme ins Repository, NFS-Export und Encoding-Shares | +| [CT 101 / GNAI-018](platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md) | Requirements der Encoding-Pipeline an CT 101 — Übergabe an den Ansible-Rebuild | +| [CT 101 / GNAI-019](platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md) | Entwurfsentscheidungen der CT-101-Ansible-Migration: natives Samba, NFS-Ganesha als Quadlet, Fresh-State-Rebuild | ### KI und Deployment @@ -51,13 +54,17 @@ jeweiligen Fachdokumenten. Die priorisierte weitere Umsetzung beschreibt der | [LLM-Server-Host-Ansible](../llm-server/host/ansible/README.md) | ausführbare Host-/ROCm-Playbooks und Rollen für `10.0.0.90` | | [Encoding-Ansible](../llm-server/encoding/ansible/README.md) | ausführbares Ansible-Projekt für den CPU-Encoding-Workload auf `10.0.0.90` | | [Keycloak-Ansible](../proxmox/lxc/ct-102-keycloak/ansible/README.md) | ausführbare Provisionierungs-, Restore- und Prüfautomation für CT 102 | +| [Samba-Ansible](../proxmox/lxc/ct-101-samba/ansible/README.md) | ausführbare Provisionierungs-, Backup- und Prüfautomation für CT 101 | -### Video-Encoding +### Video-Encoding und Monitoring | Dokument | Verbindlicher Inhalt | | --- | --- | | [GNENC-000](platform/llm-server/encoding/GNENC-000-DOC_Index.md) | Bereichsstatus und offene Festlegungen | -| [GNENC-001](platform/llm-server/encoding/GNENC-001-PLAN_Architecture.md) | vorläufige Architektur des CPU-Encoding-Workloads | +| [GNENC-001](platform/llm-server/encoding/GNENC-001-PLAN_Architecture.md) | finale Architektur des CPU-Encoding-Workloads | +| [GNENC-002](platform/llm-server/encoding/GNENC-002-PLAN_Pipeline.md) | operative Encoding-Pipeline: Komponenten, Verzeichnislayout, Validierung, Statusmodell | +| [GNMON-000](platform/llm-server/monitoring/GNMON-000-DOC_Index.md) | Monitoring-Bereichsstatus (Metriken, Dashboard) | +| [GNMON-001](platform/llm-server/monitoring/GNMON-001-PLAN_Architecture.md) | Host-Monitoring: Metrics-Collector, Dashboard, Anbindung Encoding | Die Dokumente wurden am 21.09.2026 neu geordnet: Die Roadmap belegt `GNAI-001`; die zuvor bestehenden Dokumente `GNAI-001` bis `GNAI-009` wurden @@ -84,7 +91,8 @@ die Dokumentation folgt derselben Dreiteilung. | DNS / Pi-hole | CT 100 unter `10.0.0.14` mit digest-gepinntem Pi-hole 2026.09.0 als Podman-Quadlet; SSH/nftables, UDP/TCP-DNS, Split-DNS, Blocking/Allowlist, FRITZ!Box-Cutover, LXC-Reboot und Stop-Mode-Backup am 01.10.2026 abgenommen | vollständige App-/OIDC-Regression, praktischer FRITZ!Box-Rollback, mobile DuckDuckGo-/IPv6-Strategie, WireGuard-Profile und hostunabhängiger Restore-Test | | Reverse Proxy | CT-103-Zugänge, SSH-/Firewall-Baseline, Reboot und lokales Backup abgenommen; NPM 2.15.1 routet öffentlichen OIDC- und internen Admin-vHost getrennt; öffentliche Keycloak-Administration liefert 403 | Fail2Ban-Jails/Bans und Wiederanlauf; hostunabhängige Backupkopie/Restore-Test | | Keycloak | CT 102 unter `10.0.0.4` reproduzierbar per Ansible aufgebaut; gepinntes Keycloak 26.2.4 und PostgreSQL 16.15, Restore von CT 104, Container-/DB-Härtung, NPM-Cutover, interner Adminzugang, öffentliche Admin-Sperre, realer Vaultwarden-OIDC-Login und LXC-Neustart abgenommen | 14-tägige Stabilisierungsphase, praktischer NPM-Rückfall auf CT 104, Betriebs-/Backup-/Restore-SOP und anschließende Rückfallbereinigung | -| Encoding | Konto, Pfade, eigener rootless Store und CPU-Isolation per Ansible verwaltet und verifiziert | Workload, Zeitsteuerung, Ressourcenlimits und Lasttest | +| Encoding | Konto, Pfade, eigener rootless Store und CPU-Isolation per Ansible verwaltet und verifiziert; Planungsabschluss 19.10.2026 (Pipeline GNENC-002, Monitoring GNMON-001, Speicher auf CT-101) | Implementierung (NFS-Mount, Images, Quadlets, Skripte, Worker, Dashboard, Collector); Lasttest und Limit-Feintuning | +| Samba (CT 101) | Share-Bestand in GNAI-017 dokumentiert; Ansible-Projekt nach GNAI-019 gebaut und lokal verifiziert; Runbooks vorhanden; In-Place-Upgrade auf Debian 13 am 21.10.2026; smb.conf-Verlust beim Upgrade aus vzdump-Backup wiederhergestellt, Media-Shares und Workstation-Mounts verifiziert | produktive Adoption (Platzhalter, Schlüssel, Convergence, Verifikation), NFS-Export und Encoding-Shares produktiv, UID/GID-Abgleich produktiv, Backup-/Restore-Test | Ein Zielbild, eine Betreiberangabe oder ein erfolgreicher Teiltest gilt nicht als vollständige Abnahme. Datierte Details stehen im zuständigen Fachdokument. diff --git a/docs/platform/proxmox-server/README.md b/docs/platform/proxmox-server/README.md index af5f3f7..7df4667 100644 --- a/docs/platform/proxmox-server/README.md +++ b/docs/platform/proxmox-server/README.md @@ -14,6 +14,10 @@ Hypervisors selbst. | [CT-104-Rückfallartefakte](../../../proxmox/lxc/ct-104-keycloak/README.md) | erhaltene Blue/Green-Referenz unter `10.0.0.16` | | [GNAI-006 / CT 103](lxc/ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md) | `10.0.0.10`: Reverse Proxy, Keycloak-Adminpfad und Fail2Ban | | [GNAI-007 / CT 105](lxc/ct-105-wireguard/GNAI-007-SEC_VPN.md) | `10.0.0.15`: WireGuard-Gateway, Rollen-Firewall und Clientverfahren | +| [GNAI-017 / CT 101](lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md) | `10.0.0.9`: Samba-Dateiserver, NFS-Export und Encoding-Shares | +| [GNAI-018 / CT 101](lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md) | Requirements der Encoding-Pipeline an CT 101 für den Ansible-Rebuild (Übergabe) | +| [GNAI-019 / CT 101](lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md) | Entwurfsentscheidungen der Ansible-Migration: natives Samba, NFS-Ganesha als Quadlet, Fresh-State-Rebuild | +| [CT-101-Automation](../../../proxmox/lxc/ct-101-samba/README.md) | produktives Samba-Ansible-Projekt und CT-spezifische PVE-Helfer | Die versionierte Quelle für den optionalen, secretfreien Community-Scripts-Post-install-Hook liegt unter diff --git a/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md new file mode 100644 index 0000000..04ff632 --- /dev/null +++ b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md @@ -0,0 +1,186 @@ +# CT 101 – Samba-Dateiserver + +**Dokumenten-ID:** GNAI-017-PLAN +**Titel:** CT 101 Samba: Aufnahme ins Repository, NFS-Export und Shares für die Encoding-Pipeline +**Stand:** 2026-10-21 +**Status:** Plan; bestehende Shares produktiv und bislang manuell verwaltet; Ansible-Projekt gebaut (GNAI-019), Adoption des produktiven LXC ausstehend +**Referenzen:** [GNENC-001](../../../llm-server/encoding/GNENC-001-PLAN_Architecture.md), [GNENC-002](../../../llm-server/encoding/GNENC-002-PLAN_Pipeline.md), [GNMON-001](../../../llm-server/monitoring/GNMON-001-PLAN_Architecture.md), [GNAI-011](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [GNAI-018](GNAI-018-REQ_Encoding-Pipeline-Integration.md), [GNAI-019](GNAI-019-DEC_Ansible-Migration.md) + +## 1. Zweck und Geltungsbereich + +CT 101 (`10.0.0.9`) ist der Samba-Dateiserver des Proxmox-Kontexts und trägt +den Medienspeicher (Filme, Serien, Anime, Musik, Software, Downloads) auf +mehreren Platten (`/media/daten1`, `/media/daten2`, `/media/daten3`, +`/media/datenhdd2`, `/media/datenhdd3`). Bislang ist die LXC nicht im +Infrastructure-Repository verwaltet; am 19.10.2026 wurde beschlossen, sie +aufzunehmen (Betreiberentscheidung). + +Dieses Dokument hält den aktuellen Share-Bestand, die geplanten Änderungen für +die Encoding-Pipeline (NFS-Export, zwei Shares, Gruppen-Abgleich) und die +offenen Punkte fest. Bestehende Shares bleiben unberührt. + +## 2. System-Baseline + +| Parameter | Wert | +| --- | --- | +| Container-ID | CT 101 | +| IPv4 | 10.0.0.9 | +| Samba-Rolle | standalone server, Workgroup `Ghost.net` (aus `smb.conf`) | +| SMB-Benutzer | `ghost`, `daidalaw`, `debugofen` | +| Betriebssystem | Debian 13 (trixie) seit In-Place-Upgrade durch den Betreiber am 21.10.2026 (manuell; nicht via `scripts/upgrade-ct101-to-debian13.sh`) | +| Hostname | nicht erfasst — `preflight.yml` meldet ihn vor der Adoption (Inventory-Platzhalter `samba_expected_hostname`) | + +## 3. Bestand: Shares (aus `smb.conf`, Stand 19.10.2026) + +| Share | Pfad | Zugriff | Besonderheiten | +| --- | --- | --- | --- | +| `[homes]` | Home-Verzeichnisse | read-only, `valid users = %S` | Debian-Standard | +| `[printers]` | `/var/tmp` | printable, read-only | **Abschnitt doppelt vorhanden** (siehe Abschnitt 6) | +| `[print$]` | `/var/lib/samba/printers` | read-only | Druckertreiber | +| `[Dokumente]` | `/media/daten1/Dokumente` | RO, nur `ghost` | — | +| `[Filme]` | `/media/daten1/Filme` | RO, `ghost`/`daidalaw`/`debugofen`, write `ghost` | — | +| `[Filme2]` | `/media/datenhdd3/Filme` | wie `[Filme]` | — | +| `[Serien]` | `/media/daten2/Serien` | RO, alle drei, write `ghost` | `force user/group = root` | +| `[Serien2]` | `/media/daten3/Serien` | wie `[Serien]` | — | +| `[Animation]` | `/media/daten1/Animation` | RO, alle drei, write `ghost` | — | +| `[Anime]` | `/media/daten3/Anime` | RO, alle drei, write `ghost` | — | +| `[Extern]` | `/media/datenhdd2/extern` | RW, nur `ghost` | — | +| `[Musik]` | `/media/daten1/Musik` | RO, alle drei, write `ghost` | — | +| `[Audiobooks]` | `/media/daten1/Audiobooks` | RO, alle drei, write `ghost` | — | +| `[Software]` | `/media/datenhdd3/Software` | RO, alle drei, write `ghost`/`daidalaw` | — | +| `[Downloads]` | `/media/datenhdd3/Downloads` | RW, alle drei | bleibt unberührt; `.dlc`-Drop läuft künftig über `[Encode-In]` | + +Globale Einstellungen (Auszug): `map to guest = bad user`, `usershare allow +guests = yes`, `obey pam restrictions = yes`. + +## 4. Geplante Änderungen für die Encoding-Pipeline + +### 4.1 NFS-Export + +Ursprünglich war `nfs-kernel-server` mit `/etc/exports` geplant. **Überholt +durch GNAI-019 Entscheidung 2 und 3:** Der Kernel-NFS-Server ist in einem +unprivileged LXC nicht betreibbar; der Export läuft stattdessen als +**NFS-Ganesha** in einem rootful Podman-Quadlet-Container +(`ghostnet-samba-nfs.service`, Host-Networking, NFSv4-only). Dafür wird in +`/etc/pve/lxc/101.conf` ein leerer `lxc.seccomp.profile:`-Eintrag gesetzt +(Ganesha braucht `open_by_handle_at`). Umsetzung: Rolle `samba_nfs` plus die +PVE-Skripte `scripts/upgrade-ct101-to-debian13.sh` (Adoption) und +`scripts/provision-ct101-samba.sh` (Fresh-State-Rebuild). + +- Export ausschließlich für `ghostnet-ki` (10.0.0.90), NFSv4.2-only (TCP+UDP), + `SecType = sys`, Standard-Squash bleibt aktiv (`Squash = rootid`): Die + rootless Container schreiben als UID 982/983, nicht als root. Kein NFSv3, + kein mountd/rpcbind — `showmount` gilt nicht; Abnahme ist ein realer + NFSv4.2-Mount von `ghostnet-ki` (GNAI-018 Abschnitt 4 Punkt 2, angepasst). +- Mount auf `ghostnet-ki`: `/var/lib/ghostnet-encoding` (NFSv4.2, `hard`, + `_netdev`, `noatime`) — verwaltet vom Encoding-Ansible (GNENC-001 Abschnitt 6). +- Vorab wird nur das Export-Verzeichnis `/media/datenhdd3/Encoding` angelegt + (`root:gn-media 2775` — Abweichung von root:root 0755: Die Pipeline erzeugt + die Top-Level-Verzeichnisse via NFS als UID 982/983 und braucht group-write + plus setgid auf dem Export-Root); die Pipeline-Unterverzeichnisse werden via + NFS von `ghostnet-ki` durch die Ansible-Rolle angelegt (Owner/Group gemäß + GNENC-002 Abschnitt 4). + +### 4.2 Gruppen- und UID-Abgleich + +- Gruppe `gn-media` mit **GID 970** auf CT-101 anlegen (identisch zu + `ghostnet-ki`). +- Benutzer `ghost` in `gn-media` aufnehmen: Schreib-/Löschrecht auf den + Pipeline-Verzeichnissen, damit die Abholung per `mv` aus `[Encode]` + funktioniert. `daidalaw`/`debugofen` optional für reinen Lesezugriff. +- Die Platte `datenhdd3` erscheint im Monitoring über den NFS-Mount auf + `ghostnet-ki` (Disk-Metrik des NFS-Exports, GNMON-001). + +### 4.3 Shares + +```ini +[Encode] +comment = Encoding-Pipeline: fertige Encodes (Abholung per mv) +path = /media/datenhdd3/Encoding/output +public = no +writeable = yes +valid users = ghost, daidalaw, debugofen +admin users = ghost +write list = ghost +force group = gn-media +create mask = 0664 +directory mask = 0775 + +[Encode-In] +comment = Encoding-Pipeline: .dlc-Drop (input) +path = /media/datenhdd3/Encoding/input +public = no +writeable = yes +valid users = ghost +admin users = ghost +write list = ghost +force group = gn-media +create mask = 0664 +directory mask = 0775 +``` + +- `[Encode]`: RW für `ghost` — die Entnahme per `mv` in die Bibliotheks-Shares + (Filme/Serien/Anime, teils andere Platten → Kopie+Verschieben auf CT-101) + räumt `output/` auf. `force group` stellt sicher, dass auch über SMB + angelegte Dateien der Gruppe `gn-media` angehören. +- `[Encode-In]`: RW für `ghost` — `.dlc`-Dateien werden in + `Encode-In\\` abgelegt (Profil = Preset-Name); jDownloader2 + räumt sie nach der Verarbeitung nach `jd/dlc_done/` bzw. `jd/failed/` auf. +- Vor Produktivsetzung: `testparm` laufen lassen (siehe Abschnitt 6). + +### 4.4 Verzeichnisstruktur + +`/media/datenhdd3/Encoding` ist die Pipeline-Wurzel (vollständiges Layout in +GNENC-002 Abschnitt 4): `input/`, `jd/`, `queue/`, `work/`, `output/`, +`state/`. Anlage und Rechte erfolgen via NFS durch die Ansible-Rolle +`video_encoding` auf `ghostnet-ki`. + +## 5. Abholung und Clean-Job + +- Abholung: Entnahme per `mv` aus `[Encode]`; das `mv` räumt `output/` auf. +- Optionaler Clean-Job (Worker-Housekeeping auf `ghostnet-ki`, GNENC-002 + Abschnitt 6): löscht Dateien in `output/` älter als 30 Tage. + +## 6. Bekannte Befunde und offene Punkte + +1. **Ansible-Rebuild inkl. Pipeline-Anbindung** — erledigt: Das Projekt + `proxmox/lxc/ct-101-samba/` ist gebaut (Design in + [GNAI-019](GNAI-019-DEC_Ansible-Migration.md), Requirements in + [GNAI-018](GNAI-018-REQ_Encoding-Pipeline-Integration.md)) und lokal + verifiziert (Syntax-Checks, Check-Mode-Smoke). **Ausstehend:** Adoption des + produktiven LXC gemäß Runbook in `ansible/README.md` (Dist-Upgrade auf + Debian 13, elf Inventory-Platzhalter, Schlüssel, Convergence, Verifikation). +2. **`[printers]` doppelt** in der `smb.conf` — erledigt: Das Duplikat (inkl. + Kommentarblöcke) ist im templated `smb.conf` bereinigt; `testparm` ist als + `validate:`-Gate im Rollen-Flow verankert (GNAI-019 Entscheidung 7). +3. **`usershare allow guests = yes`** — entschieden: beibehalten und im + Template dokumentiert; Änderung nur nach Betreiberentscheidung (GNAI-018 R4, + GNAI-019 Entscheidung 7). +4. **Backup** — entschieden: `backup.yml` (one-shot) sichert `/etc/samba` und + `/var/lib/samba` nach `/var/backups/ghostnet/samba/` vor der ersten + Convergence; alle fünf Medien-Mounts tragen `backup=no`; das Upgrade-Skript + fährt vor dem Dist-Upgrade einen `vzdump --mode stop` (GNAI-019 Entscheidung + 6). **Offen:** hostunabhängiger Restore-Test (GNAI-000 Abschnitt 4 Punkt 4). +5. **System-Baseline** — erfasst: OS-Version (Debian 13 nach Upgrade, GNAI-019 + Entscheidung 10), Hostname und Ressourcen-Mindestwerte über die + preflight-Platzhalter im Inventory (`samba_expected_hostname`, + `samba_expected_processor_vcpus`, `samba_minimum_memory_mb`, + `samba_minimum_root_size_bytes`, `samba_minimum_root_available_bytes`). + Plattengrößen/Kapazität siehe Punkt 6. +6. **Kapazitäts-Monitoring** der daten-Platten auf CT-101 — offen: heute nur + indirekt über den NFS-Mount auf ghostnet-ki sichtbar (GNMON-001); eigener + CT-101-Collector später. +7. **smb.conf-Verlust beim Dist-Upgrade (21.10.2026)** — erledigt: Beim + manuellen In-Place-Upgrade auf Debian 13 wurde die produktive `smb.conf` + durch die Paket-Default ersetzt; die 12 Media-Shares fehlten vorübergehend + (`smbclient -L` zeigte nur `homes`/`printers`/`print$`; die Media-Shares + antworteten `NT_STATUS_BAD_NETWORK_NAME`). Die passdb blieb intakt. Der + Betreiber stellte die produktive `smb.conf` am 21.10.2026 aus dem + vzdump-Backup vor dem Upgrade wieder her. Verifiziert am 21.10.2026: alle 12 + Media-Shares antworten, die 10 CIFS-Mounts der Admin-Workstation + (`/etc/fstab`, `//10.0.0.9/…`) sind gehängt und lesbar, keine failed + `mnt-*.mount`-Units. Das bereitgestellte + `scripts/upgrade-ct101-to-debian13.sh` verwendet `--force-confold` und hätte + den Verlust verhindert. Bei der kommenden Ansible-Adoption wird die + `smb.conf` planmäßig durch das Template ersetzt (zusätzlich + `[Encode]`/`[Encode-In]`, `[printers]` bereinigt). diff --git a/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md new file mode 100644 index 0000000..e2d379d --- /dev/null +++ b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md @@ -0,0 +1,186 @@ +# Übergabe: Requirements CT 101 (Samba/NFS) für die Encoding-Pipeline + +**Dokumenten-ID:** GNAI-018-REQ +**Titel:** Requirements der LLM-Server-Encoding-Pipeline an CT 101 — Spezifikation für den Ansible-Rebuild +**Stand:** 2026-10-19 +**Status:** Übergabedokument; verbindliche Anforderungsliste für den ausführenden Agenten +**Adressat:** Ansible-Rebuild CT 101 (ausführendes Projekt: `proxmox/lxc/ct-101-samba/ansible/`) +**Quellen:** [GNAI-017](GNAI-017-PLAN_Samba-NFS-Export.md), [GNENC-001](../../../llm-server/encoding/GNENC-001-PLAN_Architecture.md), [GNENC-002](../../../llm-server/encoding/GNENC-002-PLAN_Pipeline.md), [GNMON-001](../../../llm-server/monitoring/GNMON-001-PLAN_Architecture.md) + +## 1. Zweck und Adressat + +CT 101 (`10.0.0.9`) ist der Samba-Dateiserver mit der Medienbibliothek und wird +derzeit manuell verwaltet. Betreiberentscheidung 19.10.2026: Die Umstellung auf +ein reproduzierbares Ansible-Projekt und die Anbindung der Encoding-Pipeline +werden **in einem Schritt** durchgeführt. + +Dieses Dokument ist die verbindliche Requirements-Liste aus Sicht der +Encoding-Pipeline auf `ghostnet-ki` (`10.0.0.90`, Spezifikation GNENC-002). Der +ausführende Agent baut das Ansible-Projekt und setzt die Pipeline-Anbindung um. +Die Pipeline selbst (Mount, Container, Worker auf `ghostnet-ki`) ist **nicht** +Bestandteil dieses Auftrags. + +**Sequenzhinweis:** Dieser Rebuild ist Voraussetzung für die +Encoding-Implementierung auf `ghostnet-ki`. Die gemeinsame Verifikation der +Pipeline erfolgt nach Fertigstellung durch den Betreiber. + +## 2. Kontext + +- Die Encoding-Pipeline arbeitet vollständig auf einem NFS-Mount: + `/var/lib/ghostnet-encoding` auf `ghostnet-ki` ← Export + `/media/datenhdd3/Encoding` von CT-101. +- Verzeichnislayout auf dem Export (GNENC-002 Abschnitt 4): `input//`, + `jd/`, `queue///`, `work/`, `output/` (inkl. `_failed/`), + `state/`. +- Die Pipeline schreibt via NFS als UID 982 (`gn-encoding`) bzw. UID 983 + (`gn-download`); Dateien entstehen mit umask 022 (0644/0755, Verzeichnisse + 2770/2750 mit setgid, Gruppe `gn-media`). +- Abholung: Benutzer `ghost` entnimmt fertige Encodes per `mv` aus dem Share + `[Encode]` (räumt `output/` auf) und legt `.dlc`-Dateien in `[Encode-In]` ab. + +## 3. Anforderungen (Muss) + +### R1 – NFS-Export für `ghostnet-ki` + +- Paket `nfs-kernel-server` installieren, Dienst aktivieren und starten. +- `/etc/exports` (genau diese Zeile, Export nur für `ghostnet-ki`): + + ```text + /media/datenhdd3/Encoding 10.0.0.90(rw,sync,no_subtree_check) + ``` + +- Export-Root `/media/datenhdd3/Encoding` anlegen (root:root, 0755). + **Die Pipeline-Unterverzeichnisse werden nicht hier angelegt** — sie + entstehen via NFS durch die Ansible-Rolle `video_encoding` auf + `ghostnet-ki`. +- Kein `no_root_squash` erforderlich (rootless Container schreiben als + UID 982/983, nicht als root); Standard-Squash bleibt. +- `exportfs -ra` ausführen; Persistenz über Dienst-Unit sicherstellen. + +### R2 – Samba-Shares für die Pipeline + +Zwei neue Shares, exakt wie folgt (bestehende Shares unberührt): + +```ini +[Encode] +comment = Encoding-Pipeline: fertige Encodes (Abholung per mv) +path = /media/datenhdd3/Encoding/output +public = no +writeable = yes +valid users = ghost, daidalaw, debugofen +admin users = ghost +write list = ghost +force group = gn-media +create mask = 0664 +directory mask = 0775 + +[Encode-In] +comment = Encoding-Pipeline: .dlc-Drop (input) +path = /media/datenhdd3/Encoding/input +public = no +writeable = yes +valid users = ghost +admin users = ghost +write list = ghost +force group = gn-media +create mask = 0664 +directory mask = 0775 +``` + +### R3 – Gruppe und Benutzer-Abgleich + +- Gruppe `gn-media` mit **GID 970** anlegen (identisch zu `ghostnet-ki`, + siehe GNAI-017 Abschnitt 4.2). +- Benutzer `ghost` in `gn-media` aufnehmen — erforderlich für das Löschen + (mv-Abholung) in `output/` und das Anlegen/Rename in `input/`, da die + Pipeline-Verzeichnisse Gruppe `gn-media` mit Mode 2770 tragen. +- `daidalaw`/`debugofen` benötigen keine Gruppenmitgliedschaft (reiner + Lesezugriff über world-read der Dateien). + +### R4 – Bestandserhalt + +- Alle bestehenden Shares bleiben funktionsgleich erhalten: `[homes]`, + `[printers]`, `[print$]`, `[Dokumente]`, `[Filme]`, `[Filme2]`, `[Serien]`, + `[Serien2]`, `[Animation]`, `[Anime]`, `[Extern]`, `[Musik]`, `[Audiobooks]`, + `[Software]`, `[Downloads]` (Bestandsliste in GNAI-017 Abschnitt 3). +- Vor der Umstellung `smb.conf` und den Samba-State (`/var/lib/samba`, + insb. `private/passdb.tdb`) sichern (Backup-Pfad dokumentieren). +- Das doppelte `[printers]`-Abschnitt in der bestehenden `smb.conf` bereinigen. +- `usershare allow guests = yes` dokumentieren; Änderung nur nach + Betreiberentscheidung (offen, siehe Abschnitt 7). + +### R5 – Samba-Benutzer und Secrets + +- Bestehende SMB-Benutzer `ghost`, `daidalaw`, `debugofen` samt Passwörtern + dürfen nicht verloren gehen (kein Lockout der produktiven Nutzung). +- passdb-Strategie festlegen: Erhalt des bestehenden passdb (Backup/Restore) + oder Neuvergabe mit server-lokal generierten Secrets. **Secrets werden auf + dem Ziel generiert und nie über Inventory oder git übergeben** (Konvention + analog CT-102-Ansible-Projekt). + +### R6 – Idempotentes Ansible-Projekt + +- Projekt unter `proxmox/lxc/ct-101-samba/ansible/`, analog + `proxmox/lxc/ct-102-keycloak/ansible/`: `ansible.cfg`, + `inventories//hosts.yml`, `playbooks/`, `roles/`, `files/`. +- Repo-weites Control-Venv (`shared/ansible/requirements-control.txt`) nutzen. +- Deploy-Zugang nach GNAI-011 (SSH-Key, z. B. + `~/.ssh/id_ed25519_ghostnet_ct101_ansible_deploy`); Key liegt nicht im Repo. +- Check-Mode (`--check --diff`) muss für das Site-Playbook funktionieren; + zwei aufeinanderfolgende Läufe müssen `changed=0` melden (Idempotenz). +- `testparm` als Verifikationsschritt im Rollen-Flow (fail bei Fehlern). +- Die `smb.conf` wird als Template gepflegt (vollständige Datei, nicht + snippets appended) — der Sollzustand ist reproduzierbar. + +### R7 – Netzwerk und Firewall + +- NFS (2049/tcp+udp) nur von `10.0.0.90` erreichbar; SMB (139/445) wie bisher + aus dem LAN. Ist-Zustand der Firewall dokumentieren, Sollzustand herstellen. +- CT-101 muss nach dem Rebuild booten und Dienste starten (Reboot-Test). + +## 4. Abnahmekriterien + +Der Rebuild gilt als abgenommen, wenn alles Folgende nachgewiesen ist +(datierte Nachweise in GNAI-017): + +1. `testparm` fehlerfrei (inkl. bereinigtem `[printers]`-Duplikat). +2. `showmount -e localhost` zeigt `/media/datenhdd3/Encoding`. +3. `smbclient -L localhost` zeigt `[Encode]`, `[Encode-In]` und alle + Bestands-Shares. +4. Von `ghostnet-ki` (10.0.0.90) via NFS: Schreiben/Lesen im Export als + UID 982 und UID 983 erfolgreich (z. B. nach dem Mount auf + `ghostnet-ki` oder ein gezielter Mount-Test von CT-101 aus). +5. `ghost` kann in `[Encode-In]` Dateien anlegen und in `[Encode]` löschen + (mv-Simulation), dank `gn-media`-Mitgliedschaft. +6. `testparm`-Vergleich vor/nach: bestehende Shares funktional unverändert. +7. Idempotenz: zweiter Ansible-Lauf meldet `changed=0`. +8. Reboot-Test: Dienste (smbd, nfs-kernel-server) starten nach Neustart. +9. GNAI-017 (Abschnitt 6) und dieser Index aktualisiert. + +## 5. Grenzen (Out of Scope) + +- `ghostnet-ki`-Seite: NFS-Mount, Konten, Container, Worker, Dashboard — + anderes Projekt (GNENC/GNMON). +- Anlage der Pipeline-Unterverzeichnisse — erfolgt via NFS durch die Rolle + `video_encoding` auf `ghostnet-ki` (R1: nur Export-Root). +- Passwort-Neuvergabe für bestehende Benutzer ohne Betreiberentscheidung. +- Monitoring auf CT-101 (Plattenkapazität ist über den NFS-Mount auf + `ghostnet-ki` sichtbar, GNMON-001; eigener CT-101-Collector später). +- LLM-Metriken. + +## 6. Referenzen + +- [GNAI-017](GNAI-017-PLAN_Samba-NFS-Export.md) — Bestand, Plan, smb.conf-Schnipsel +- [GNENC-001](../../../llm-server/encoding/GNENC-001-PLAN_Architecture.md) — Architektur, Entscheidungen +- [GNENC-002](../../../llm-server/encoding/GNENC-002-PLAN_Pipeline.md) — Pipeline, Verzeichnislayout, Rechte +- [GNMON-001](../../../llm-server/monitoring/GNMON-001-PLAN_Architecture.md) — Monitoring-Anbindung +- [GNAI-011](../../GNAI-011-SEC_LXC-SSH-Architecture.md) — SSH-Zugang der LXCs +- [`proxmox/lxc/ct-102-keycloak/ansible/README.md`](../../../../../../proxmox/lxc/ct-102-keycloak/ansible/README.md) — Konventionen des Ansible-Projekts + +## 7. Offene Punkte für den Rebuild + +1. passdb-Strategie (Erhalt vs. Neuvergabe) — Entscheidung vor der Migration. +2. Backup-Ziel für `smb.conf` und Samba-State (Pfad, Aufbewahrung). +3. `usershare allow guests = yes` — beibehalten oder härten? +4. System-Baseline erfassen: Hostname, OS-Version, Platten/Kapazität (GNAI-017 + Abschnitt 6). diff --git a/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md new file mode 100644 index 0000000..6ff1c66 --- /dev/null +++ b/docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md @@ -0,0 +1,323 @@ +# CT 101 – Samba-Dateiserver: Entwurfsentscheidungen der Ansible-Migration + +**Dokumenten-ID:** GNAI-019-DEC +**Titel:** CT 101 Samba: Design-Entscheidungen für die Ansible-Migration (natives Samba, NFS-Ganesha als Quadlet, Fresh-State-Rebuild) +**Stand:** 2026-10-21 +**Status:** Entwurfsentscheidungen verbindlich; im Projekt `proxmox/lxc/ct-101-samba/` umgesetzt und lokal verifiziert (Syntax-Checks, Check-Mode-Smoke); die Adoption des produktiven LXC steht aus und erfordert Betreiber-Autorisierung +**Referenzen:** [GNAI-017](GNAI-017-PLAN_Samba-NFS-Export.md), [GNAI-018](GNAI-018-REQ_Encoding-Pipeline-Integration.md), [GNAI-011](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [GNAI-016](../ct-102-keycloak/GNAI-016-PLAN_Keycloak-Ansible-Rebuild.md), [GNENC-001](../../../llm-server/encoding/GNENC-001-PLAN_Architecture.md), [GNENC-002](../../../llm-server/encoding/GNENC-002-PLAN_Pipeline.md), [GNMON-001](../../../llm-server/monitoring/GNMON-001-PLAN_Architecture.md); Projektkonventionen: `proxmox/lxc/ct-100-dns/` und `proxmox/lxc/ct-102-keycloak/` + +## 1. Zweck und Geltungsbereich + +CT 101 (`10.0.0.9`) ist der produktive Samba-Dateiserver und bislang manuell +verwaltet. Betreiberentscheidungen vom 19.10.2026 (GNAI-017/GNAI-018): Aufnahme +ins Repository und Anbindung der Encoding-Pipeline in einem Schritt. Dieses +Dokument hält die verbindlichen Design-Entscheidungen für die Ansible-Migration +fest — insbesondere die Antworten auf die offene Frage „Samba nativ oder im +Podman-Container?“ — und begründet die Abweichungen von GNAI-017 Abschnitt 4.1 +und GNAI-018 R1. Es ist das Referenzdokument für das ausführende Projekt +`proxmox/lxc/ct-101-samba/` (Rollen, Playbooks, PVE-Skripte, Runbooks). + +## 2. Ausgangslage und Randbedingungen + +- Produktiver, unprivileged LXC (Proxmox VE), Debian 12 (bookworm), manuell + verwaltet; Samba-Dateiserver mit 14 Shares auf fünf Host-Mounts + (`/media/daten1`, `/media/daten2`, `/media/daten3`, `/media/datenhdd2`, + `/media/datenhdd3`). +- Produktiver Samba-State: `smb.conf` plus `/var/lib/samba` inkl. + `private/passdb.tdb`; SMB-Benutzer `ghost`, `daidalaw`, `debugofen` mit + produktiven Passwörtern. +- Die Encoding-Pipeline auf `ghostnet-ki` (`10.0.0.90`) schreibt via NFS als + UID 982 (`gn-encoding`) bzw. UID 983 (`gn-download`); die Gruppe `gn-media` + (GID 970) muss auf beiden Seiten numerisch identisch sein (GNAI-018 R3, + GNENC-002). +- Verbindliche Anforderungen: GNAI-018 R1–R7. Projektkonventionen: CT 100 + (Pi-hole-Quadlet, nftables-Pattern, Inventories agent/bootstrap/production) + und CT 102 (Keycloak-Ansible-Projekt, `podman_runtime`-Rolle, Quadlet-Pattern + mit digest-gepinntem Basisimage und sha256-gepinntem Containerfile). +- Secrets (SMB-Passwörter, passdb, `ghost`-Passwort, private SSH-Schlüssel, + Podman-Secrets) werden nie im Repository, im Inventory oder in Logs abgelegt + (Konvention analog CT 102). + +## 3. Entscheidung 1: Samba bleibt nativ (Debian-Paket) + +**Entscheidung:** Samba wird nicht containerisiert. Der Dienst läuft als +natives Debian-Paket (`samba`, `samba-common`, `samba-common-bin`, `smbclient`) +mit `smbd`/`nmbd` als systemd-Dienste in CT 101. Die vollständige `smb.conf` +ist als Jinja2-Template im Repository gepflegt (Entscheidung 8). + +**Begründung:** + +- Der produktive Bestand umfasst gewachsene POSIX-Rechte auf fünf Host-Mounts + und einen Samba-State (tdb/passdb). Ein Container-Wechsel würde die + Datei- und ID-Mapping-Semantik verändern, ohne einen Sicherheitsgewinn zu + bringen: Der LXC bleibt unprivileged, und Samba ist der Dienst, der die + Mounts direkt sehen muss. +- Image-Pinning und Container-Isolation (die sonst typische Begründung für + Podman, siehe CT 100/102) betreffen hier einen einzelnen, langlebigen + Systemdienst auf dem eigenen Host — der Mehrwert ist gering, die Komplexität + (State-Verlagerung, Druckerspool, ID-Mapping) hoch. +- Debian-Paketpflege ist über `unattended-upgrades` etabliert + (Rolle `samba_lxc_baseline`). +- Reproduzierbarkeit wird über das vollständig templated `smb.conf` plus + `testparm`-Gate hergestellt, nicht über ein Container-Image. + +**Konsequenz:** Rolle `samba_smb`; native Dienste mit Handler-Neustart bei +Konfigurationsänderung; Post-State-Verifikation per `testparm`, `pdbedit -L` +und anonymer `smbclient -L`-Abfrage. + +## 4. Entscheidung 2: NFS-Export via NFS-Ganesha als rootful Podman-Quadlet mit Host-Networking (statt `nfs-kernel-server`) + +**Entscheidung:** Der NFS-Export für die Encoding-Pipeline (GNAI-018 R1) wird +nicht mit `nfs-kernel-server`, sondern mit **NFS-Ganesha** als **rootful** +Podman-Quadlet-Container (`ghostnet-samba-nfs.service`) mit **Host-Networking** +umgesetzt. Dafür wird in `/etc/pve/lxc/101.conf` ein **leerer +`lxc.seccomp.profile:`**-Eintrag gesetzt (Deaktivierung des LXC-Seccomp-Filters +für den gesamten CT). + +**Begründung:** + +- GNAI-018 R1 verlangte `nfs-kernel-server`. Der Kernel-NFS-Server kann in + einem unprivileged LXC nicht betrieben werden (kein Zugriff auf das + Kernel-NFS-Subsystem des Hosts). Ganesha ist ein Userspace-NFS-Server (VFS + FSAL) und läuft in einem unprivileged LXC. +- **Rootful** ist fachlich nötig: Der VFS-FSAL wendet die AUTH_SYS-Credentials + der Pipeline (UID 982/983) pro Request an; das erfordert `CAP_SETUID`/ + `CAP_SETGID` innerhalb der LXC-User-Namespace, was ein rootless Container + nicht leisten kann. Standard-Squash bleibt aktiv (`Squash = rootid`, R1). +- **Host-Networking** bindet den Listener 2049 im Netz-Namespace von CT 101; + die nftables-Input-Chain deckt ihn ab. Die Quadlet-Unit trägt + `Requires=nftables.service` — ohne Firewall startet der Export nicht + (fail closed). +- **Leerer Seccomp-Eintrag:** Ganesha benötigt `open_by_handle_at` (VFS FSAL), + das das PVE-Standard-Seccomp-Profil blockiert. Der leere Eintrag deaktiviert + den Filter für den gesamten CT. Das ist vertretbar, weil der CT unprivileged + bleibt und Samba nativ keine privilegierten Syscalls jenseits der + User-Namespace benötigt. Beide PVE-Skripte setzen bzw. verifizieren den + leeren Eintrag (`scripts/upgrade-ct101-to-debian13.sh`, + `scripts/provision-ct101-samba.sh`). +- **Abweichung vom Export-Root aus R1:** Statt `root:root 0755` wird + `/media/datenhdd3/Encoding` als `root:gn-media 2775` angelegt. Die Pipeline + erzeugt die Top-Level-Verzeichnisse via NFS als UID 982/983 und benötigt + group-write plus setgid auf dem Export-Root (GNAI-017 Abschnitt 4.4); die + Pipeline-Unterverzeichnisse selbst entstehen weiterhin via NFS durch die + Rolle `video_encoding` auf `ghostnet-ki`. + +**Konsequenz:** PVE-seitige Änderung am produktiven CT (Seccomp) — im +Upgrade-Skript durch einen `vzdump --mode stop` abgesichert. Die +Quadlet-Unit folgt dem CT-100/102-Pattern: digest-gepinntes Debian-13-Basisimage +(`debian:trixie-slim`), sha256-gepinntes `Containerfile.ganesha`, +`Pull=never`, Runtime-Tag `localhost/ghostnet/ganesha:trixie-ansible1`, +Healthcheck, read-only rootfs mit tmpfs `/run` und `/tmp`. Ohne dbus logged +Ganesha eine nicht-fatale Admin-Interface-Warnung (dokumentiert in +`roles/samba_nfs/README.md`). + +## 5. Entscheidung 3: NFSv4-only — kein NFSv3, kein `showmount` + +**Entscheidung:** Der Export ist NFSv4-only (`Protocols = 4`, kein NLM, kein +RQUOTA, kein mountd/rpcbind), Transport TCP+UDP, `SecType = sys`, +`Squash = rootid`, client-beschränkt auf `10.0.0.90`, `Pseudo` identisch zum +Export-Pfad (dokumentierter Mount-Pfad `10.0.0.9:/media/datenhdd3/Encoding` +bleibt unverändert). + +**Begründung:** NFSv4 benötigt keinen Portmapper und kein mountd — weniger +Angriffsfläche und keine Legacy-Protokolle. Die Pipeline mountet NFSv4.2 +(`hard`, `_netdev`, `noatime`; GNENC-001 Abschnitt 6). + +**Konsequenz:** `showmount -e` ist kein zulässiges Abnahmekriterium +(NFSv4-only). GNAI-018 Abschnitt 4 Punkt 2 wird als **realer NFSv4.2-Mount-Test +von `ghostnet-ki`** interpretiert; dasselbe gilt für Punkt 8 (Dienste nach +Reboot: `ghostnet-samba-nfs.service` statt `nfs-kernel-server`). + +## 6. Entscheidung 4: Identität — `gn-media` GID 970, aufgezeichnete Medien-UIDs, Mitgliedschaft nur `ghost` + +**Entscheidung:** + +- Gruppe `gn-media` mit GID 970 (identisch zu `ghostnet-ki`). Die Rolle + `samba_media` fails closed, wenn der GID-Slot oder der Gruppenname von einer + anderen Identität belegt ist. +- Die Medien-Konten `ghost`, `daidalaw`, `debugofen` werden mit ihren + **produktiv aufgezeichneten UIDs** im Inventory deklariert + (`samba_media_users`; UID-Platzhalter stoppen die Rollen-Validierung fail + closed, bis die Ist-Werte eingetragen sind). Bestehende Konten werden nur + auf die UID validiert und nie verändert; fehlende Konten werden im + Fresh-State-Rebuild mit exakt diesen UIDs angelegt (primäre Gruppe + `gid=uid`, `password_lock: true`, `daidalaw`/`debugofen` mit + `/usr/sbin/nologin` und ohne Home-Anlage). +- Mitgliedschaft in `gn-media`: nur `ghost` (GNAI-018 R3). `daidalaw` und + `debugofen` benötigen keine Mitgliedschaft — reiner Lesezugriff genügt. + +**Begründung:** Die UID-Stabilität ist Voraussetzung der Pipeline +(AUTH_SYS-Credentials, `mv`-Abholung aus `[Encode]`, `.dlc`-Drop in +`[Encode-In]` über Gruppe `gn-media` mit Mode 2770). Fail-closed verhindert +Drift zwischen Soll- und Ist-Identität. + +## 7. Entscheidung 5: passdb-Erhalt bei der Adoption; Secrets nie im Repository + +**Entscheidung:** + +- **Adoption (Default):** Die produktive passdb bleibt erhalten. Die Rolle + `samba_smb` assertet fail-closed, dass `/var/lib/samba/private/passdb.tdb` + vor der Convergence existiert, dass die `pdbedit -L`-Userliste vor und nach + der Convergence identisch ist und dass `ghost`, `daidalaw`, `debugofen` + enthalten sind (`samba_smb_preserve_passdb: true`). +- **Fresh-State-Rebuild:** Der erste `site.yml`-Lauf erfolgt einmalig mit + `-e samba_smb_preserve_passdb=false` (eine frische passdb existiert noch + nicht). Anschließend setzt der Operator die SMB-Passwörter interaktiv per + `smbpasswd -a ghost/daidalaw/debugofen` auf CT 101; der zweite Lauf mit dem + Default validiert die gefüllte passdb mit den vollen Guardrails. +- Secrets (SMB-Passwörter, passdb, `ghost`-Passwort, private SSH-Schlüssel, + Podman-Secrets) werden nie im Repository, im Inventory oder in Ansible-Logs + abgelegt; `pdbedit -L` listet nur Benutzernamen. + +**Begründung:** GNAI-018 R5 (kein Lockout der produktiven Nutzung) und R4 +(Backup vor der Umstellung); Konvention analog CT-102 (Secrets werden auf dem +Ziel generiert bzw. operator-seitig gesetzt). + +## 8. Entscheidung 6: Backup-Strategie + +**Entscheidung:** + +- **Adoption:** `playbooks/backup.yml` (one-shot) sichert vor der ersten + Convergence `/etc/samba` und `/var/lib/samba` nach + `/var/backups/ghostnet/samba/` (root-only, `0700`). `verify-production.yml` + assertet das Vorhandensein dieses pre-migration Backups + (`samba_verify_require_premigration_backup: true`; Fresh-State-Rebuild: + `-e samba_verify_require_premigration_backup=false`). +- **Medien-Mounts:** Alle fünf Mounts tragen `backup=no` — `vzdump` archiviert + die Medienbibliothek nicht. +- **Upgrade:** `scripts/upgrade-ct101-to-debian13.sh` fährt vor dem + Dist-Upgrade einen `vzdump --mode stop --storage local --compress zstd` als + Sicherheitsnetz und sichert den laufenden Zustand (leerer Seccomp-Eintrag). +- **Restore-Pfad:** vzdump-Restore des CT plus erneuter `site.yml`-Convergence + (idempotent) bzw. das pre-migration Backup für den Samba-State. + +**Offen (GNAI-017 Abschnitt 6 Punkt 4, GNAI-000 Abschnitt 4 Punkt 4):** Ein +hostunabhängiger Backup-/Restore-Test für CT 101 steht noch aus (analog zu den +anderen LXCs). + +## 9. Entscheidung 7: `usershare allow guests = yes` beibehalten; `[printers]`-Duplikat bereinigt + +**Entscheidung:** + +- `usershare allow guests = yes` bleibt im templated `smb.conf` erhalten und + ist dort dokumentiert. Eine Änderung erfolgt nur nach ausdrücklicher + Betreiberentscheidung (GNAI-018 R4 und Abschnitt 7 Punkt 3). +- Der doppelte `[printers]`-Abschnitt aus dem Live-Bestand (inkl. duplizierter + Kommentarblöcke) ist im Template bereinigt (GNAI-018 R4). + +**Begründung:** Bestandserhalt (R4) — eine Änderung des Guest-Verhaltens ohne +Betreiberentscheidung wäre ein funktionaler Eingriff in den produktiven +Betrieb. + +## 10. Entscheidung 8: Config-as-Code — alle Konfigurationen als Repo-Templates + +**Entscheidung:** Jede Konfigurationsdatei auf CT 101 ist ein Jinja2-Template +im Repository, das Ansible rendert und installiert; es gibt keinen +hand-editierten Zustand auf dem Ziel: + +- `smb.conf` (vollständige Datei, 14 Shares: 12 Bestand-Shares + `[Encode]` und + `[Encode-In]` exakt nach GNAI-018 R2) — validiert per `testparm -s` vor der + Installation (`validate:`-Gate, GNAI-018 R6). +- `sshd` security drop-in (`/etc/ssh/sshd_config.d/99-security.conf`, + GNAI-011-Baseline ohne `AllowUsers` — siehe Template-Kommentar). +- `nftables.conf` plus systemd-Drop-in (Ghostnet-eigene Tabelle + `inet ghostnet_filter`, Netavark-Tabellen bleiben unberührt; `nft --check` + vor Installation). +- `ganesha.conf` (NFSv4-only, siehe Entscheidung 3). +- Quadlet `ghostnet-samba-nfs.container` (siehe Entscheidung 2). + +`verify-production.yml` rendert die Templates per `lookup` erneut und +vergleicht sie mit den Live-Dateien (Drift-Erkennung). + +## 11. Entscheidung 9: Fresh-State-Rebuild-Fähigkeit + +**Entscheidung:** Die Playbooks bauen CT 101 von einem frischen Zustand +genau so wieder auf. Der dokumentierte Ablauf (Runbook in +`ansible/README.md`): + +1. `scripts/provision-ct101-samba.sh` auf dem Proxmox-Host: frischer CT 101 aus + einer Debian-13-Vorlage mit dem aufgezeichneten Profil, fünf Medien-Mounts + (`backup=no`), leerer Seccomp-Eintrag, Baseline-Hook mit den + aufgezeichneten Konto-UIDs. +2. Operator auf der PVE-Konsole: `passwd ghost`, öffentliche Schlüssel aus + `ansible/files/ssh/` eintragen; Workstation: ssh-Aliase und frischer + known_hosts-Eintrag (Fingerprint gegenprüfen, GNAI-011 Abschnitt 3.3). +3. Erster `site.yml`-Lauf mit `-e samba_smb_preserve_passdb=false` + (`samba_media` legt fehlende Konten mit den aufgezeichneten UIDs an, + `samba_smb` installiert Samba und die templated `smb.conf`). +4. Operator: `smbpasswd -a` für `ghost`, `daidalaw`, `debugofen` (Secrets nie + im Repo). +5. Zweiter `site.yml`-Lauf mit Default: volle passdb-Guardrails, erwartet + `changed=0` (Idempotenz). +6. `verify-production.yml` mit + `-e samba_verify_require_premigration_backup=false`. +7. NFSv4.2-Mount-Test von `ghostnet-ki` (`10.0.0.90`). + +Die einzige nicht deklarierbare State-Größe sind die Secrets (Schritt 4) — +sie existieren ausschließlich in der passdb auf CT 101. + +## 12. Entscheidung 10: In-Place-Dist-Upgrade auf Debian 13 vor der Adoption + +**Entscheidung:** Der bestehende produktive LXC wird vor der Übernahme per +`scripts/upgrade-ct101-to-debian13.sh` in-place von Debian 12 (bookworm) auf +Debian 13 (trixie) gehoben (Betreiberentscheidung: bestehender LXC bleibt +erhalten, zieht mit CT 100/102 gleich). + +**Begründung:** Gleicher OS-Stand wie die übrigen Ansible-LXCs (CT 100/102 auf +Debian 13); frische Paketbasis für die Migration; die Baseline-Rolle erwartet +Debian 13. + +**Ablauf (Skript):** Guards (CT läuft, unprivileged, Gast Debian 12, Dienste +aktiv, IPv4, uid_map, `backup=no` auf allen fünf Mounts) → `vzdump --mode stop` +→ laufender Zustand gesichert (leerer Seccomp-Eintrag) → In-Guest-Upgrade +(`bookworm` → `trixie` in deb822- und One-Line-Quellen, `dist-upgrade` mit +`--force-confold` — hält die produktive `smb.conf`, `autoremove`, +wtmpdb-Cleanup analog Baseline) → Reboot → Post-Verify (Debian 13, Dienste, +keine failed units, Listener 22/139/445, anonyme `smbclient -L`-Abfrage, +uid_map intakt, Seccomp deaktiviert, fünf Mounts eingehängt). + +## 13. Verworfene Alternativen + +| Alternative | Grund der Ablehnung | +| --- | --- | +| Samba im Podman-Container (analog CT 100/102) | Kein Sicherheitsgewinn bei unprivileged LXC; verändert ID-Mapping/State der gewachsenen Mounts; höherer Aufwand (Entscheidung 1) | +| `nfs-kernel-server` nativ (GNAI-018 R1) | In einem unprivileged LXC nicht betreibbar; Ganesha ist die Userspace-Alternative (Entscheidung 2) | +| NFSv3 mit `showmount`-Abnahme | Zusätzliche Legacy-Dienste (mountd/rpcbind); `showmount` ist kein verlässlicher Indikator; NFSv4-only (Entscheidung 3) | +| passdb-Neuvergebung als Default | Lockout-Risiko für produktive Nutzer (GNAI-018 R5); Erhalt mit Guardrails (Entscheidung 5) | +| SMB auch aus dem Admin-VPN (`10.8.0.0/24`) öffnen | Medien-Clients leben im LAN; Erweiterung ist ausdrückliche Betreiberentscheidung (GNAI-018 R7) | +| Medien-Mounts mit `backup=yes` | `vzdump` würde die Medienbibliothek archivieren (Ressourcen, Datenschutz); `backup=no` plus gezieltes State-Backup (Entscheidung 6) | + +## 14. Auswirkungen auf GNAI-017 und GNAI-018 + +- **GNAI-017 Abschnitt 4.1** (`nfs-kernel-server`, `/etc/exports`) ist durch + Entscheidung 2 und 3 überholt und wird entsprechend aktualisiert. +- **GNAI-017 Abschnitt 2** (System-Baseline) wird um OS-Version (Debian 13 nach + Upgrade) und Hostname (Erfassung per `preflight.yml`) ergänzt; Abschnitt 6 + (offene Punkte) wird um die Entscheidungen dieses Dokuments aktualisiert. +- **GNAI-018 R1** wird revidiert: NFS-Ganesha-Quadlet statt + `nfs-kernel-server`; Export-Root `root:gn-media 2775` statt `root:root 0755` + (begründete Abweichung, siehe Entscheidung 2). +- **GNAI-018 Abschnitt 4** (Abnahme): Punkt 2 (`showmount`) wird als realer + NFSv4.2-Mount-Test von `ghostnet-ki` interpretiert (Entscheidung 3); Punkt 8 + nennt `ghostnet-samba-nfs.service` statt `nfs-kernel-server`. +- **GNAI-018 Abschnitt 7** (offene Punkte) ist entschieden: passdb-Erhalt + (Entscheidung 5), Backup-Ziel `/var/backups/ghostnet/samba/` plus vzdump im + Upgrade-Skript (Entscheidung 6), `usershare allow guests` beibehalten + (Entscheidung 7), System-Baseline via preflight-Platzhalter (Entscheidung 9). + +## 15. Offene Punkte + +1. Adoption des produktiven CT 101 gemäß Runbook in + `proxmox/lxc/ct-101-samba/ansible/README.md` (erfordert Betreiber-Autorisierung). +2. Elf Inventory-Platzhalter mit den Ist-Werten füllen (`preflight.yml` / + `audit-production.yml` liefern sie). +3. Drei Ed25519-Schlüsselpaare generieren (Operator); nur die öffentlichen + Schlüssel liegen im Repo unter `ansible/files/ssh/`. +4. Dist-Upgrade auf Debian 13 ausführen (`scripts/upgrade-ct101-to-debian13.sh`). +5. Reboot-Test nach der Convergence (GNAI-016 Abschnitt 7, LXC-boot_id-Caveat). +6. Hostunabhängiger Backup-/Restore-Test (GNAI-000 Abschnitt 4 Punkt 4). +7. Kapazitäts-Monitoring der daten-Platten auf CT 101 (GNAI-017 Abschnitt 6 + Punkt 6; heute nur indirekt über den NFS-Mount auf `ghostnet-ki` sichtbar, + GNMON-001; eigener Collector später). +8. Erweiterung der SMB-Allowlist auf das Admin-VPN (`10.8.0.0/24`) — + Betreiberentscheidung, aktuell nicht geöffnet. diff --git a/proxmox/lxc/ct-101-samba/Containerfile.ganesha b/proxmox/lxc/ct-101-samba/Containerfile.ganesha new file mode 100644 index 0000000..bde7703 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/Containerfile.ganesha @@ -0,0 +1,22 @@ +FROM docker.io/library/debian:trixie-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 + +ENV DEBIAN_FRONTEND=noninteractive + +# NFS-Ganesha is the userspace NFSv4 server for the unprivileged CT 101 LXC +# (GNAI-019): the kernel nfsd cannot run inside an unprivileged container. +# The healthcheck helper is deliberately not baked in; the Quadlet mounts it +# read-only from /etc/ghostnet/samba/ganesha (CT 102 pattern). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + nfs-ganesha \ + nfs-ganesha-vfs \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /var/lib/nfs-ganesha /etc/ganesha + +LABEL eu.selfhost.ghostnet.stack="samba" \ + eu.selfhost.ghostnet.role="nfs-export" \ + eu.selfhost.ghostnet.base-digest="sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483" + +CMD ["/usr/bin/ganesha.nfsd", "-F", "-f", "/etc/ganesha/ganesha.conf", "-L", "/dev/stderr"] diff --git a/proxmox/lxc/ct-101-samba/README.md b/proxmox/lxc/ct-101-samba/README.md new file mode 100644 index 0000000..ce03f1a --- /dev/null +++ b/proxmox/lxc/ct-101-samba/README.md @@ -0,0 +1,32 @@ +# CT 101 – Samba file server + +Host root for the Samba file server LXC at `10.0.0.9`. The live service is +stable and was managed manually until 2026-10-19; the LXC is being imported +into this repository step by step. + +The declared state now lives in the CT-scoped Ansible project +[`ansible/`](ansible/). The design (GNAI-019) keeps Samba **native** (Debian +package, fully templated `smb.conf` with a `testparm` gate) and runs +NFS-Ganesha as a **rootful Podman/Quadlet container with host networking**; +the nftables firewall, the `gn-media` group identity (GID 970) and the +recorded media account UIDs are declared in the inventory. Every configuration +file is a repository template that Ansible renders and installs, so the LXC can +be rebuilt from a fresh state to exactly this state. + +The project was prepared for the adoption of the productive LXC: the PVE-side +dist-upgrade to Debian 13 and the fresh-state provisioning are scripted under +[`scripts/`](scripts/), and both runbooks (adoption of the productive CT 101 +and rebuild from a fresh container) are sequenced in +[`ansible/README.md`](ansible/README.md). The privileged adoption steps still +require explicit operator authorization. + +Planning and requirements are documented in +[`GNAI-017`](../../../docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md) +(share inventory and NFS plan), +[`GNAI-018`](../../../docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-018-REQ_Encoding-Pipeline-Integration.md) +(encoding-pipeline handover requirements) and +[`GNAI-019`](../../../docs/platform/proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md) +(design decisions for the Ansible migration). + +Do not store SMB passwords, the Samba passdb, the `ghost` password, private +SSH keys or Podman secret contents in this repository. diff --git a/proxmox/lxc/ct-101-samba/ansible/README.md b/proxmox/lxc/ct-101-samba/ansible/README.md new file mode 100644 index 0000000..07ad61d --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/README.md @@ -0,0 +1,179 @@ +# CT 101 Samba Ansible project + +Self-contained host project for the production Samba file server LXC +(CT 101, `10.0.0.9`). It follows the CT 100/102 project schema and uses the +repository-wide pinned control environment plus the shared `podman_runtime` +role; all CT 101 inventories, playbooks, public keys and service roles stay +inside the CT 101 root. + +Design decisions (GNAI-019): Samba stays **native** (Debian package, fully +templated `smb.conf` with a `testparm` gate), NFS-Ganesha runs as a **rootful +Podman/Quadlet container with host networking**, the firewall is the shared +nftables pattern, and the media identity (`gn-media` GID 970, recorded UIDs) +is declared in the inventory. All configuration files are Jinja2 templates in +this repository; Ansible renders and installs them — there is no hand-edited +state on the target. + +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-101-samba/ansible +../../../../.venv/bin/ansible-playbook \ + --inventory inventories/production/hosts.yml \ + playbooks/verify-production.yml \ + --private-key ~/.ssh/id_ed25519_ghostnet_ct101_ansible_deploy +``` + +## Inventories and access paths + +| Inventory | Account | Key | Become | Use | +| --- | --- | --- | --- | --- | +| `agent` | `ghost-agent` | `~/.ssh/id_ed25519_ghostnet_ct101_agent` | no | read-only preflight | +| `bootstrap` | `ghost` | `~/.ssh/id_ed25519_ghostnet_ct101_ghost` | `--ask-become-pass` (sudo) | audit, access bootstrap | +| `production` | `ansible-deploy` | `~/.ssh/id_ed25519_ghostnet_ct101_ansible_deploy` | NOPASSWD sudo | convergence and verification | + +The operator generates the three Ed25519 keypairs (`agent` and +`ansible-deploy` without a passphrase, `ghost` with the operator passphrase) +and commits only the public halves under `files/ssh/`. Private keys stay in +`~/.ssh` with mode `0600` and are never committed. The `ghost` login password +is set interactively (`passwd ghost` on the PVE console) and the SMB passwords +are set interactively (`smbpasswd -a `); neither ever enters the +repository, the inventory, Ansible logs or this README. + +## Placeholders + +The inventory carries `REPLACE-WITH-*` placeholders that fail closed (the +role argument specs reject them before any task runs). `preflight.yml` on the +`agent` inventory reports the live values to copy: + +- `samba_expected_hostname`, `samba_expected_processor_vcpus`, + `samba_minimum_memory_mb`, `samba_minimum_root_size_bytes`, + `samba_minimum_root_available_bytes` (group_vars) +- `samba_access_admin_uid`, `samba_access_agent_uid`, + `samba_access_deploy_uid` (host_vars) +- `samba_media_users[ghost|daidalaw|debugofen].uid` (group_vars; the recorded + productive UIDs — the pipeline depends on them, GNAI-018 R1) + +## Playbooks + +| Playbook | Inventory | Purpose | +| --- | --- | --- | +| `preflight.yml` | `agent` | read-only identity, health, listener and placeholder-source report | +| `audit-production.yml` | `bootstrap` | read-only deep pre-migration audit (file stats, `testparm` dump, `pdbedit`, sshd drift, mounts, account/group databases) | +| `bootstrap.yml` | `bootstrap` | adopts the access boundary: `ansible-deploy`, hardened keys, sudoers, SSH policy (runs for real, not in check mode) | +| `verify-deploy-access.yml` | `production` | proves the deployment account and its sudo path | +| `backup.yml` | `production` | one-shot pre-migration backup of `/etc/samba` and `/var/lib/samba` (adoption only) | +| `site.yml` | `production` | converges the full declared state (roles in order: `samba_lxc_baseline`, `samba_access`, `podman_runtime`, `samba_firewall`, `samba_media`, `samba_smb`, `samba_nfs`) | +| `verify-production.yml` | `production` | read-only verification of the converged state, including template-drift checks | + +`site.yml --check` is clean: post-state assertions that depend on freshly +created state are guarded. `bootstrap.yml` is executed for real during the +adoption; only `site.yml` must be check-mode clean. + +## Adoption runbook for the productive CT 101 + +The LXC exists and serves production traffic. The deliberate adoption path +keeps the healthy file server and its Samba passdb intact: + +1. Generate the three keypairs; commit only `files/ssh/*.pub`. +2. On the Proxmox host, run `scripts/upgrade-ct101-to-debian13.sh` (see + `scripts/README.md`): in-place Debian 12 → 13 dist-upgrade of the existing + CT 101 with a pre-upgrade `vzdump` and the empty `lxc.seccomp.profile`. +3. On the PVE console of CT 101: capture the productive `ghost` UID, pick a + free UID for `ghost-agent`, then run the shared baseline + `ghostnet-lxc-baseline.sh 101` with `GHOSTNET_AGENT_UID`/`GHOSTNET_ADMIN_UID` + (validates the unprivileged mapping, creates the locked `ghost-agent`, + prepares `.ssh`, adds `ghost` to `sudo`, disables `ssh.socket`). Then set + `passwd ghost` and paste the `ghost-agent` and `ghost` public keys into the + respective `authorized_keys`. +4. On the workstation: add the `ghostnet-ct101-agent` / `ghostnet-ct101-ghost` + ssh aliases and seed `~/.ssh/known_hosts_ghostnet_ct101` (verify the + fingerprint on the PVE console first, GNAI-011 section 3.3). +5. `preflight.yml` on the `agent` inventory; copy the reported values into all + three inventories (the 11 placeholders above). +6. `audit-production.yml` on the `bootstrap` inventory (`--ask-become-pass`). +7. `bootstrap.yml` on the `bootstrap` inventory (`--ask-become-pass`); keep a + second session or the PVE console open while the SSH policy changes. +8. `verify-deploy-access.yml` on the `production` inventory. +9. `backup.yml` on the `production` inventory (one-shot; the passdb must be + backed up before the first convergence, GNAI-018 R5). +10. `site.yml --check --diff` on the `production` inventory previews the + convergence (templated `smb.conf`, nftables, SSH drop-in, Ganesha Quadlet). +11. `site.yml` converges. The Samba passdb is preserved untouched. +12. `verify-production.yml` on the `production` inventory. +13. `site.yml` again: expect `changed=0` (idempotence). +14. Operator reboot test of CT 101 (GNAI-016 section 7, LXC-boot_id caveat). +15. NFSv4.2 mount test from `ghostnet-ki` (`10.0.0.90`) against the export; + `showmount` does not apply (NFSv4-only, GNAI-019). + +## Fresh-state rebuild runbook + +The playbooks rebuild CT 101 from a fresh container to exactly the declared +state. Every configuration file is a repository template that Ansible renders +and installs; the only state that cannot be declared is secret material. + +1. Prerequisites: the inventory is fully filled (all 11 placeholders carry the + recorded productive values), the three `.pub` files are committed, and the + five media host paths are known. +2. On the Proxmox host, run `scripts/provision-ct101-samba.sh` with + `GHOSTNET_AGENT_UID`, `GHOSTNET_ADMIN_UID` and `GHOSTNET_MP0_HOST` … + `GHOSTNET_MP4_HOST` (see `scripts/README.md`). This creates CT 101 from a + Debian 13 template with the recorded profile, attaches the five media + mounts (`backup=no`), disables the LXC seccomp filter and runs the baseline + hook, which creates `ghost-agent` and `ghost` with the recorded UIDs. +3. Operator: `pct exec 101 -- passwd ghost`, then install the `ghost-agent` and + `ghost` public keys from `files/ssh/` into the prepared `authorized_keys`. +4. On the workstation: ssh aliases plus a **fresh** `known_hosts_ghostnet_ct101` + entry — verify the new host-key fingerprint on the PVE console first + (GNAI-011 section 3.3). +5. First convergence on the `production` inventory: + + ```bash + ../../../../.venv/bin/ansible-playbook \ + --inventory inventories/production/hosts.yml \ + playbooks/site.yml \ + --private-key ~/.ssh/id_ed25519_ghostnet_ct101_ansible_deploy \ + -e samba_smb_preserve_passdb=false + ``` + + `samba_media` creates the missing media accounts with their recorded UIDs + (`ghost` already exists from the baseline and is only validated), `samba_smb` + installs Samba and the templated `smb.conf`. The passdb preservation guard + is disabled for this first run only, because a fresh passdb does not exist + yet. +6. Operator, on CT 101 (secrets never enter the repository): + + ```bash + smbpasswd -a ghost + smbpasswd -a daidalaw + smbpasswd -a debugofen + ``` + +7. Second `site.yml` run with the default `samba_smb_preserve_passdb=true`: + the passdb now exists, so the full preservation guardrails apply; expect + `changed=0`. +8. `verify-production.yml` with + `-e samba_verify_require_premigration_backup=false` (the pre-migration + backup check is adoption-only; a fresh system has nothing to back up). +9. NFSv4.2 mount test from `ghostnet-ki` (`10.0.0.90`). + +The rebuild reproduces the declared state: media accounts with the recorded +UIDs, `gn-media` GID 970 with the declared membership, the 14 shares from the +templated `smb.conf`, the nftables policy, and the Ganesha NFS export as a +rootful Quadlet container. The SMB passwords are the single deliberate +operator step — they exist only in the passdb on CT 101. + +## Secrets policy + +Do not store SMB passwords, the Samba passdb, the `ghost` password, private +SSH keys or Podman secret contents in this repository, the inventory or +Ansible output. `pdbedit -L` lists usernames only; `backup.yml` writes +root-only copies on the target under `/var/backups/ghostnet/samba/`. diff --git a/proxmox/lxc/ct-101-samba/ansible/ansible.cfg b/proxmox/lxc/ct-101-samba/ansible/ansible.cfg new file mode 100644 index 0000000..b0d9cb1 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +home = .ansible +host_key_checking = True +interpreter_python = auto_silent +local_tmp = .ansible/tmp +retry_files_enabled = False +roles_path = roles:../../../../shared/ansible/roles +timeout = 15 + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s diff --git a/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ansible-deploy.pub b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ansible-deploy.pub new file mode 100644 index 0000000..30d6195 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ansible-deploy.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4uaHiZpbqy1Gnp5+TrYLH1VfpUPbMNPW9IXLm/UFA5 ansible-deploy@ghostnet-ct101 diff --git a/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost-agent.pub b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost-agent.pub new file mode 100644 index 0000000..8385b46 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost-agent.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhjaSvQXVYWdBxEwZi4cD9iOB7R6teWKZvV5o3iOtiV ghost-agent@ghostnet-ct101 diff --git a/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost.pub b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost.pub new file mode 100644 index 0000000..c138742 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/files/ssh/samba-ghost.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICBlIsYEO2YSZVsWvLg891kh6JbMyS1eU+ypSVqXOId+ ghost@ghostnet-ct101 diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/agent/group_vars/samba_hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/group_vars/samba_hosts.yml new file mode 100644 index 0000000..0a377d2 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/group_vars/samba_hosts.yml @@ -0,0 +1,225 @@ +--- +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_ct101 + +samba_expected_distribution: Debian +samba_expected_distribution_major_version: "13" +# Live hostname of CT 101; copy from preflight.yml (SMB1 is disabled, so the +# live server does not answer NetBIOS names): +samba_expected_hostname: REPLACE-WITH-PREFLIGHT-HOSTNAME +samba_expected_virtualization_type: lxc +# Expected Podman version after the dist-upgrade (trixie), analog to CT 100/102: +samba_expected_podman_version: "5.4.2" +# Resource minimums; copy from preflight.yml: +samba_minimum_root_size_bytes: REPLACE-WITH-PREFLIGHT-ROOT-SIZE +samba_minimum_root_available_bytes: REPLACE-WITH-PREFLIGHT-ROOT-AVAIL + +samba_firewall_management_ipv4: + - 10.0.0.0/24 +samba_firewall_ssh_port: 22 +samba_firewall_smb_tcp_ports: + - 139 + - 445 +samba_firewall_smb_udp_ports: + - 137 + - 138 +samba_firewall_nfs_client_ipv4: 10.0.0.90 +samba_firewall_nfs_ports: + - 2049 + +samba_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +samba_media_group_name: gn-media +samba_media_group_gid: 970 +# Media accounts with their productive UIDs; copy from preflight.yml / +# audit-production.yml (fresh-state rebuild: missing accounts are created with +# exactly these UIDs; existing accounts are only validated on the UID): +samba_media_users: + - name: ghost + uid: REPLACE-WITH-AUDIT-UID-GHOST + shell: /bin/bash + home: /home/ghost + create_home: true + - name: daidalaw + uid: REPLACE-WITH-AUDIT-UID-DAIDALAW + shell: /usr/sbin/nologin + home: /home/daidalaw + create_home: false + - name: debugofen + uid: REPLACE-WITH-AUDIT-UID-DEBUGOFEN + shell: /usr/sbin/nologin + home: /home/debugofen + create_home: false +samba_media_group_members: + - ghost +samba_media_mount_points: + - /media/daten1 + - /media/daten2 + - /media/daten3 + - /media/datenhdd2 + - /media/datenhdd3 + +samba_smb_workgroup: Ghost.net +samba_smb_service_units: + - smbd.service + - nmbd.service +# Complete share target state from GNAI-017 section 3 plus [Encode] and +# [Encode-In] from GNAI-018 R2. Comments only where the live system shows them +# (Debian defaults and the new pipeline shares); the media shares had empty +# comments in the anonymous listing and stay without comments. +samba_smb_media_shares: + - name: Dokumente + path: /media/daten1/Dokumente + read_only: "yes" + valid_users: + - ghost + - name: Filme + path: /media/daten1/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Filme2 + path: /media/datenhdd3/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Serien + path: /media/daten2/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Serien2 + path: /media/daten3/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Animation + path: /media/daten1/Animation + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Anime + path: /media/daten3/Anime + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Extern + path: /media/datenhdd2/extern + read_only: "no" + valid_users: + - ghost + - name: Musik + path: /media/daten1/Musik + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Audiobooks + path: /media/daten1/Audiobooks + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Software + path: /media/datenhdd3/Software + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - daidalaw + - name: Downloads + path: /media/datenhdd3/Downloads + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + - name: Encode + comment: "Encoding-Pipeline: fertige Encodes (Abholung per mv)" + path: /media/datenhdd3/Encoding/output + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + - name: Encode-In + comment: "Encoding-Pipeline: .dlc-Drop (input)" + path: /media/datenhdd3/Encoding/input + read_only: "no" + valid_users: + - ghost + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + +samba_nfs_export_path: /media/datenhdd3/Encoding +samba_nfs_client_ipv4: 10.0.0.90 +samba_nfs_export_id: 101 +samba_nfs_container_name: ghostnet-samba-nfs +samba_nfs_service_unit: ghostnet-samba-nfs.service +# Digest-pinned Debian 13 base image (amd64), resolved on 2026-10-20: +samba_nfs_source_image: >- + docker.io/library/debian:trixie-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 +samba_nfs_runtime_tag: localhost/ghostnet/ganesha:trixie-ansible1 +samba_nfs_expected_architecture: amd64 +# Paths and Containerfile checksum mirrored from the role defaults so that +# verify-production.yml can render the templates via lookup and compare them +# with the live files (self-contained inventory, CT 100/102 convention): +samba_nfs_quadlet_directory: /etc/containers/systemd +samba_nfs_build_directory: /var/lib/ghostnet/samba/build +samba_nfs_config_directory: /etc/ghostnet/samba/ganesha +samba_nfs_state_directory: /var/lib/ghostnet/samba/ganesha +samba_nfs_containerfile_sha256: >- + 0fdbe6b5fe542f22ed291c4cabe505e0da472024f0186e2414fd002b751f784f diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/agent/host_vars/samba-ct101.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/host_vars/samba-ct101.yml new file mode 100644 index 0000000..ff98250 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/host_vars/samba-ct101.yml @@ -0,0 +1,16 @@ +--- +samba_ctid: 101 +samba_expected_ipv4: 10.0.0.9 +# ED25519 host key, captured via ssh-keyscan on 2026-10-19 from the admin +# workstation; before the first trust, verify it again on the PVE console +# (GNAI-011 section 3.3): +samba_expected_ssh_host_key_fingerprint: >- + SHA256:+MUO4J4x3TCYLc1iei95UcPTzkPwDPOzovfaIieuOKs +# Live values; copy from preflight.yml: +samba_expected_processor_vcpus: REPLACE-WITH-PREFLIGHT-VCPUS +samba_minimum_memory_mb: REPLACE-WITH-PREFLIGHT-MEMORY-MB +# UID of the existing ghost account; copy from audit-production.yml: +samba_access_admin_uid: REPLACE-WITH-AUDIT-GHOST-UID +# Free UIDs to pick from audit-production.yml (ghost-agent / ansible-deploy): +samba_access_agent_uid: REPLACE-WITH-FREE-AGENT-UID +samba_access_deploy_uid: REPLACE-WITH-FREE-DEPLOY-UID diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/agent/hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/hosts.yml new file mode 100644 index 0000000..84abf84 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/agent/hosts.yml @@ -0,0 +1,8 @@ +--- +all: + children: + samba_hosts: + hosts: + samba-ct101: + ansible_host: 10.0.0.9 + ansible_user: ghost-agent diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/group_vars/samba_hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/group_vars/samba_hosts.yml new file mode 100644 index 0000000..0a377d2 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/group_vars/samba_hosts.yml @@ -0,0 +1,225 @@ +--- +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_ct101 + +samba_expected_distribution: Debian +samba_expected_distribution_major_version: "13" +# Live hostname of CT 101; copy from preflight.yml (SMB1 is disabled, so the +# live server does not answer NetBIOS names): +samba_expected_hostname: REPLACE-WITH-PREFLIGHT-HOSTNAME +samba_expected_virtualization_type: lxc +# Expected Podman version after the dist-upgrade (trixie), analog to CT 100/102: +samba_expected_podman_version: "5.4.2" +# Resource minimums; copy from preflight.yml: +samba_minimum_root_size_bytes: REPLACE-WITH-PREFLIGHT-ROOT-SIZE +samba_minimum_root_available_bytes: REPLACE-WITH-PREFLIGHT-ROOT-AVAIL + +samba_firewall_management_ipv4: + - 10.0.0.0/24 +samba_firewall_ssh_port: 22 +samba_firewall_smb_tcp_ports: + - 139 + - 445 +samba_firewall_smb_udp_ports: + - 137 + - 138 +samba_firewall_nfs_client_ipv4: 10.0.0.90 +samba_firewall_nfs_ports: + - 2049 + +samba_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +samba_media_group_name: gn-media +samba_media_group_gid: 970 +# Media accounts with their productive UIDs; copy from preflight.yml / +# audit-production.yml (fresh-state rebuild: missing accounts are created with +# exactly these UIDs; existing accounts are only validated on the UID): +samba_media_users: + - name: ghost + uid: REPLACE-WITH-AUDIT-UID-GHOST + shell: /bin/bash + home: /home/ghost + create_home: true + - name: daidalaw + uid: REPLACE-WITH-AUDIT-UID-DAIDALAW + shell: /usr/sbin/nologin + home: /home/daidalaw + create_home: false + - name: debugofen + uid: REPLACE-WITH-AUDIT-UID-DEBUGOFEN + shell: /usr/sbin/nologin + home: /home/debugofen + create_home: false +samba_media_group_members: + - ghost +samba_media_mount_points: + - /media/daten1 + - /media/daten2 + - /media/daten3 + - /media/datenhdd2 + - /media/datenhdd3 + +samba_smb_workgroup: Ghost.net +samba_smb_service_units: + - smbd.service + - nmbd.service +# Complete share target state from GNAI-017 section 3 plus [Encode] and +# [Encode-In] from GNAI-018 R2. Comments only where the live system shows them +# (Debian defaults and the new pipeline shares); the media shares had empty +# comments in the anonymous listing and stay without comments. +samba_smb_media_shares: + - name: Dokumente + path: /media/daten1/Dokumente + read_only: "yes" + valid_users: + - ghost + - name: Filme + path: /media/daten1/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Filme2 + path: /media/datenhdd3/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Serien + path: /media/daten2/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Serien2 + path: /media/daten3/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Animation + path: /media/daten1/Animation + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Anime + path: /media/daten3/Anime + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Extern + path: /media/datenhdd2/extern + read_only: "no" + valid_users: + - ghost + - name: Musik + path: /media/daten1/Musik + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Audiobooks + path: /media/daten1/Audiobooks + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Software + path: /media/datenhdd3/Software + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - daidalaw + - name: Downloads + path: /media/datenhdd3/Downloads + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + - name: Encode + comment: "Encoding-Pipeline: fertige Encodes (Abholung per mv)" + path: /media/datenhdd3/Encoding/output + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + - name: Encode-In + comment: "Encoding-Pipeline: .dlc-Drop (input)" + path: /media/datenhdd3/Encoding/input + read_only: "no" + valid_users: + - ghost + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + +samba_nfs_export_path: /media/datenhdd3/Encoding +samba_nfs_client_ipv4: 10.0.0.90 +samba_nfs_export_id: 101 +samba_nfs_container_name: ghostnet-samba-nfs +samba_nfs_service_unit: ghostnet-samba-nfs.service +# Digest-pinned Debian 13 base image (amd64), resolved on 2026-10-20: +samba_nfs_source_image: >- + docker.io/library/debian:trixie-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 +samba_nfs_runtime_tag: localhost/ghostnet/ganesha:trixie-ansible1 +samba_nfs_expected_architecture: amd64 +# Paths and Containerfile checksum mirrored from the role defaults so that +# verify-production.yml can render the templates via lookup and compare them +# with the live files (self-contained inventory, CT 100/102 convention): +samba_nfs_quadlet_directory: /etc/containers/systemd +samba_nfs_build_directory: /var/lib/ghostnet/samba/build +samba_nfs_config_directory: /etc/ghostnet/samba/ganesha +samba_nfs_state_directory: /var/lib/ghostnet/samba/ganesha +samba_nfs_containerfile_sha256: >- + 0fdbe6b5fe542f22ed291c4cabe505e0da472024f0186e2414fd002b751f784f diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/host_vars/samba-ct101.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/host_vars/samba-ct101.yml new file mode 100644 index 0000000..ff98250 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/host_vars/samba-ct101.yml @@ -0,0 +1,16 @@ +--- +samba_ctid: 101 +samba_expected_ipv4: 10.0.0.9 +# ED25519 host key, captured via ssh-keyscan on 2026-10-19 from the admin +# workstation; before the first trust, verify it again on the PVE console +# (GNAI-011 section 3.3): +samba_expected_ssh_host_key_fingerprint: >- + SHA256:+MUO4J4x3TCYLc1iei95UcPTzkPwDPOzovfaIieuOKs +# Live values; copy from preflight.yml: +samba_expected_processor_vcpus: REPLACE-WITH-PREFLIGHT-VCPUS +samba_minimum_memory_mb: REPLACE-WITH-PREFLIGHT-MEMORY-MB +# UID of the existing ghost account; copy from audit-production.yml: +samba_access_admin_uid: REPLACE-WITH-AUDIT-GHOST-UID +# Free UIDs to pick from audit-production.yml (ghost-agent / ansible-deploy): +samba_access_agent_uid: REPLACE-WITH-FREE-AGENT-UID +samba_access_deploy_uid: REPLACE-WITH-FREE-DEPLOY-UID diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/hosts.yml new file mode 100644 index 0000000..0f57665 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/bootstrap/hosts.yml @@ -0,0 +1,8 @@ +--- +all: + children: + samba_hosts: + hosts: + samba-ct101: + ansible_host: 10.0.0.9 + ansible_user: ghost diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/production/group_vars/samba_hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/production/group_vars/samba_hosts.yml new file mode 100644 index 0000000..0a377d2 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/production/group_vars/samba_hosts.yml @@ -0,0 +1,225 @@ +--- +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_ct101 + +samba_expected_distribution: Debian +samba_expected_distribution_major_version: "13" +# Live hostname of CT 101; copy from preflight.yml (SMB1 is disabled, so the +# live server does not answer NetBIOS names): +samba_expected_hostname: REPLACE-WITH-PREFLIGHT-HOSTNAME +samba_expected_virtualization_type: lxc +# Expected Podman version after the dist-upgrade (trixie), analog to CT 100/102: +samba_expected_podman_version: "5.4.2" +# Resource minimums; copy from preflight.yml: +samba_minimum_root_size_bytes: REPLACE-WITH-PREFLIGHT-ROOT-SIZE +samba_minimum_root_available_bytes: REPLACE-WITH-PREFLIGHT-ROOT-AVAIL + +samba_firewall_management_ipv4: + - 10.0.0.0/24 +samba_firewall_ssh_port: 22 +samba_firewall_smb_tcp_ports: + - 139 + - 445 +samba_firewall_smb_udp_ports: + - 137 + - 138 +samba_firewall_nfs_client_ipv4: 10.0.0.90 +samba_firewall_nfs_ports: + - 2049 + +samba_access_source_networks: 10.0.0.0/24,10.8.0.0/24 + +samba_media_group_name: gn-media +samba_media_group_gid: 970 +# Media accounts with their productive UIDs; copy from preflight.yml / +# audit-production.yml (fresh-state rebuild: missing accounts are created with +# exactly these UIDs; existing accounts are only validated on the UID): +samba_media_users: + - name: ghost + uid: REPLACE-WITH-AUDIT-UID-GHOST + shell: /bin/bash + home: /home/ghost + create_home: true + - name: daidalaw + uid: REPLACE-WITH-AUDIT-UID-DAIDALAW + shell: /usr/sbin/nologin + home: /home/daidalaw + create_home: false + - name: debugofen + uid: REPLACE-WITH-AUDIT-UID-DEBUGOFEN + shell: /usr/sbin/nologin + home: /home/debugofen + create_home: false +samba_media_group_members: + - ghost +samba_media_mount_points: + - /media/daten1 + - /media/daten2 + - /media/daten3 + - /media/datenhdd2 + - /media/datenhdd3 + +samba_smb_workgroup: Ghost.net +samba_smb_service_units: + - smbd.service + - nmbd.service +# Complete share target state from GNAI-017 section 3 plus [Encode] and +# [Encode-In] from GNAI-018 R2. Comments only where the live system shows them +# (Debian defaults and the new pipeline shares); the media shares had empty +# comments in the anonymous listing and stay without comments. +samba_smb_media_shares: + - name: Dokumente + path: /media/daten1/Dokumente + read_only: "yes" + valid_users: + - ghost + - name: Filme + path: /media/daten1/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Filme2 + path: /media/datenhdd3/Filme + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Serien + path: /media/daten2/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Serien2 + path: /media/daten3/Serien + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + force_user: root + force_group: root + - name: Animation + path: /media/daten1/Animation + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Anime + path: /media/daten3/Anime + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Extern + path: /media/datenhdd2/extern + read_only: "no" + valid_users: + - ghost + - name: Musik + path: /media/daten1/Musik + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Audiobooks + path: /media/daten1/Audiobooks + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - name: Software + path: /media/datenhdd3/Software + read_only: "yes" + valid_users: + - ghost + - daidalaw + - debugofen + write_list: + - ghost + - daidalaw + - name: Downloads + path: /media/datenhdd3/Downloads + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + - name: Encode + comment: "Encoding-Pipeline: fertige Encodes (Abholung per mv)" + path: /media/datenhdd3/Encoding/output + read_only: "no" + valid_users: + - ghost + - daidalaw + - debugofen + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + - name: Encode-In + comment: "Encoding-Pipeline: .dlc-Drop (input)" + path: /media/datenhdd3/Encoding/input + read_only: "no" + valid_users: + - ghost + admin_users: + - ghost + write_list: + - ghost + force_group: gn-media + create_mask: "0664" + directory_mask: "0775" + +samba_nfs_export_path: /media/datenhdd3/Encoding +samba_nfs_client_ipv4: 10.0.0.90 +samba_nfs_export_id: 101 +samba_nfs_container_name: ghostnet-samba-nfs +samba_nfs_service_unit: ghostnet-samba-nfs.service +# Digest-pinned Debian 13 base image (amd64), resolved on 2026-10-20: +samba_nfs_source_image: >- + docker.io/library/debian:trixie-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 +samba_nfs_runtime_tag: localhost/ghostnet/ganesha:trixie-ansible1 +samba_nfs_expected_architecture: amd64 +# Paths and Containerfile checksum mirrored from the role defaults so that +# verify-production.yml can render the templates via lookup and compare them +# with the live files (self-contained inventory, CT 100/102 convention): +samba_nfs_quadlet_directory: /etc/containers/systemd +samba_nfs_build_directory: /var/lib/ghostnet/samba/build +samba_nfs_config_directory: /etc/ghostnet/samba/ganesha +samba_nfs_state_directory: /var/lib/ghostnet/samba/ganesha +samba_nfs_containerfile_sha256: >- + 0fdbe6b5fe542f22ed291c4cabe505e0da472024f0186e2414fd002b751f784f diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/production/host_vars/samba-ct101.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/production/host_vars/samba-ct101.yml new file mode 100644 index 0000000..ff98250 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/production/host_vars/samba-ct101.yml @@ -0,0 +1,16 @@ +--- +samba_ctid: 101 +samba_expected_ipv4: 10.0.0.9 +# ED25519 host key, captured via ssh-keyscan on 2026-10-19 from the admin +# workstation; before the first trust, verify it again on the PVE console +# (GNAI-011 section 3.3): +samba_expected_ssh_host_key_fingerprint: >- + SHA256:+MUO4J4x3TCYLc1iei95UcPTzkPwDPOzovfaIieuOKs +# Live values; copy from preflight.yml: +samba_expected_processor_vcpus: REPLACE-WITH-PREFLIGHT-VCPUS +samba_minimum_memory_mb: REPLACE-WITH-PREFLIGHT-MEMORY-MB +# UID of the existing ghost account; copy from audit-production.yml: +samba_access_admin_uid: REPLACE-WITH-AUDIT-GHOST-UID +# Free UIDs to pick from audit-production.yml (ghost-agent / ansible-deploy): +samba_access_agent_uid: REPLACE-WITH-FREE-AGENT-UID +samba_access_deploy_uid: REPLACE-WITH-FREE-DEPLOY-UID diff --git a/proxmox/lxc/ct-101-samba/ansible/inventories/production/hosts.yml b/proxmox/lxc/ct-101-samba/ansible/inventories/production/hosts.yml new file mode 100644 index 0000000..87ca89f --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/inventories/production/hosts.yml @@ -0,0 +1,8 @@ +--- +all: + children: + samba_hosts: + hosts: + samba-ct101: + ansible_host: 10.0.0.9 + ansible_user: ansible-deploy diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/audit-production.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/audit-production.yml new file mode 100644 index 0000000..f5e46d3 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/audit-production.yml @@ -0,0 +1,305 @@ +--- +- name: Read-only deep audit of the pre-migration CT 101 Samba state + hosts: samba_hosts + become: true + gather_facts: true + gather_subset: + - min + - network + - virtual + + tasks: + - name: Verify the expected guest identity + ansible.builtin.assert: + that: + - inventory_hostname == "samba-ct101" + - samba_ctid | int == 101 + - ansible_facts["distribution"] == samba_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + samba_expected_distribution_major_version + - ansible_facts["default_ipv4"]["address"] == samba_expected_ipv4 + - ansible_facts["virtualization_type"] == "lxc" + - ansible_facts["virtualization_role"] == "guest" + fail_msg: The connected machine is not the declared CT 101 Samba LXC. + + - name: Read the audit transport identity + become: false + ansible.builtin.command: + argv: + - /usr/bin/id + - -un + check_mode: false + changed_when: false + register: samba_audit_identity + + - name: Verify the bootstrap transport account + ansible.builtin.assert: + that: + - samba_audit_identity.stdout == "ghost" + fail_msg: >- + The audit must use the personal administrator account ghost with + password-gated sudo (bootstrap inventory, --ask-become-pass). + + - name: Stat the Samba and access state files + ansible.builtin.stat: + path: "{{ item }}" + loop: + - /etc/samba + - /etc/samba/smb.conf + - /etc/default/samba + - /var/lib/samba + - /var/lib/samba/private/passdb.tdb + - /etc/exports + - /etc/ssh/sshd_config + - /etc/ssh/sshd_config.d + - /etc/sudoers.d + - /etc/nftables.conf + - /home/ghost/.ssh/authorized_keys + - /home/ghost-agent/.ssh/authorized_keys + - /media/datenhdd3/Encoding + register: samba_audit_stats + + - name: Report the observed file states + ansible.builtin.debug: + msg: >- + {{ item.item }}: exists={{ item.stat.exists }} + mode={{ '%04o' % (item.stat.mode | default(0) | int(0, 8)) }} + owner={{ item.stat.uid | default('-') }}:{{ item.stat.gid | default('-') }} + size={{ item.stat.size | default('-') }} + loop: "{{ samba_audit_stats.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Read the live smb.conf checksum + ansible.builtin.command: + argv: + - /usr/bin/sha256sum + - /etc/samba/smb.conf + check_mode: false + changed_when: false + register: samba_audit_smb_conf + + - name: Read the live testparm dump for the pre-migration record + ansible.builtin.command: + argv: + - /usr/bin/testparm + - -s + - /etc/samba/smb.conf + check_mode: false + changed_when: false + register: samba_audit_testparm + + - name: Read the productive Samba user database + ansible.builtin.command: + argv: + - /usr/sbin/pdbedit + - -L + check_mode: false + changed_when: false + register: samba_audit_pdbedit + + - name: Require the productive Samba users to be present + ansible.builtin.assert: + that: + - samba_audit_pdbedit.rc == 0 + - >- + samba_media_users | map(attribute='name') | list is + subset(samba_audit_pdbedit.stdout_lines) + fail_msg: >- + The productive SMB users ghost, daidalaw and debugofen are missing + from the passdb. Stop the adoption; the passdb must not be at risk. + + - name: Validate the effective SSH policy + ansible.builtin.command: + argv: + - /usr/sbin/sshd + - -T + check_mode: false + changed_when: false + register: samba_audit_sshd + + - name: Report the legacy SSH drift against the GNAI-011 baseline + ansible.builtin.debug: + msg: + permitrootlogin: >- + {{ (samba_audit_sshd.stdout_lines | + select('match', '^permitrootlogin ') | list | first) + | default('permitrootlogin ') }} + passwordauthentication: >- + {{ (samba_audit_sshd.stdout_lines | + select('match', '^passwordauthentication ') | list | first) + | default('passwordauthentication ') }} + kbdinteractiveauthentication: >- + {{ (samba_audit_sshd.stdout_lines | + select('match', '^kbdinteractiveauthentication ') | list | first) + | default('kbdinteractiveauthentication ') }} + disableforwarding: >- + {{ (samba_audit_sshd.stdout_lines | + select('match', '^disableforwarding ') | list | first) + | default('disableforwarding ') }} + note: >- + The legacy policy is closed by samba_access during bootstrap.yml; + this report documents the pre-migration drift. + + - name: Read the live nftables ruleset + ansible.builtin.command: + argv: + - /usr/sbin/nft + - list + - ruleset + check_mode: false + changed_when: false + failed_when: false + register: samba_audit_nft + + - name: Require the NFS kernel server to be absent + ansible.builtin.command: + argv: + - /usr/bin/dpkg-query + - -W + - -f=${Status} + - nfs-kernel-server + check_mode: false + changed_when: false + failed_when: false + register: samba_audit_nfs_kernel + + - name: Verify the NFS kernel server is not installed + ansible.builtin.assert: + that: + - samba_audit_nfs_kernel.stdout is not search('install ok installed') + fail_msg: >- + nfs-kernel-server is installed. The export is implemented with + NFS-Ganesha (GNAI-019); review the package state before converging. + + - name: Read the media mount options + ansible.builtin.command: + argv: + - /usr/bin/findmnt + - --noheadings + - --output + - SOURCE,FSTYPE,OPTIONS + - --target + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_mount_points }}" + loop_control: + label: "{{ item }}" + register: samba_audit_mounts + + - name: Read the media account password states + ansible.builtin.command: + argv: + - /usr/bin/passwd + - -S + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - ghost + - daidalaw + - debugofen + register: samba_audit_password_states + + - name: Read the complete account database for UID slot picking + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + check_mode: false + changed_when: false + register: samba_audit_all_accounts + + - name: Read the complete group database for GID slot picking + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + check_mode: false + changed_when: false + register: samba_audit_all_groups + + - name: Stat the media account authorized keys + ansible.builtin.stat: + path: "/home/{{ item }}/.ssh/authorized_keys" + loop: + - ghost + - daidalaw + - debugofen + register: samba_audit_keys_stats + + - name: Read the existing authorized keys + ansible.builtin.slurp: + src: "/home/{{ item.item }}/.ssh/authorized_keys" + loop: "{{ samba_audit_keys_stats.results }}" + loop_control: + label: "{{ item.item }}" + when: item.stat.exists + register: samba_audit_keys + + - name: Read the sudoers drop-ins + ansible.builtin.command: + argv: + - /usr/bin/ls + - -la + - /etc/sudoers.d + check_mode: false + changed_when: false + register: samba_audit_sudoers + + - name: Read failed systemd units + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - --failed + - --no-legend + - --plain + check_mode: false + changed_when: false + register: samba_audit_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - samba_audit_failed_units.stdout | trim == "" + + - name: Read the service states + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - ssh.service + - smbd.service + - nmbd.service + - nftables.service + - unattended-upgrades.service + register: samba_audit_services + + - name: Report the complete pre-migration audit + ansible.builtin.debug: + msg: + ctid: "{{ samba_ctid }}" + smb_conf_sha256: "{{ samba_audit_smb_conf.stdout.split()[0] }}" + testparm_dump: "{{ samba_audit_testparm.stdout }}" + smb_users: "{{ samba_audit_pdbedit.stdout_lines }}" + nft_ruleset: "{{ samba_audit_nft.stdout }}" + media_mounts: >- + {{ samba_audit_mounts.results | map(attribute='stdout') | list }} + media_password_states: >- + {{ samba_audit_password_states.results | + map(attribute='stdout') | list }} + all_accounts: "{{ samba_audit_all_accounts.stdout }}" + all_groups: "{{ samba_audit_all_groups.stdout }}" + authorized_keys: >- + {{ samba_audit_keys.results | + selectattr('content', 'defined') | + map(attribute='content') | map('b64decode') | list }} + sudoers_d: "{{ samba_audit_sudoers.stdout }}" + service_states: >- + {{ samba_audit_services.results | map(attribute='stdout') | list }} diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/backup.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/backup.yml new file mode 100644 index 0000000..218d7e2 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/backup.yml @@ -0,0 +1,201 @@ +--- +- name: Create the one-shot pre-migration Samba backup on CT 101 + hosts: samba_hosts + become: true + gather_facts: false + + tasks: + - name: Require the productive Samba passdb before the backup + ansible.builtin.stat: + path: "{{ samba_smb_passdb_file }}" + register: samba_backup_passdb + + - name: Fail closed when the passdb is missing + ansible.builtin.assert: + that: + - samba_backup_passdb.stat.exists + fail_msg: >- + The productive passdb is missing; there is nothing safe to back up. + Investigate before running this one-shot playbook. + + - name: Read the current UTC timestamp + ansible.builtin.command: + argv: + - /usr/bin/date + - -u + - +%Y%m%dT%H%M%SZ + check_mode: false + changed_when: false + register: samba_backup_timestamp + + - name: Define the backup directory + ansible.builtin.set_fact: + samba_backup_directory: >- + /var/backups/ghostnet/samba/pre-migration-{{ samba_backup_timestamp.stdout }} + + - name: Require the backup directory to be absent + ansible.builtin.stat: + path: "{{ samba_backup_directory }}" + register: samba_backup_existing + + - name: Fail when the backup directory already exists + ansible.builtin.assert: + that: + - not samba_backup_existing.stat.exists + fail_msg: >- + The backup directory already exists; this playbook is one-shot and + never overwrites a previous pre-migration backup. + + - name: Create the protected backup directory + ansible.builtin.file: + path: "{{ samba_backup_directory }}" + state: directory + owner: root + group: root + mode: "0700" + + - name: Check for the Samba defaults file + ansible.builtin.stat: + path: /etc/default/samba + register: samba_backup_default_samba + + - name: Copy the Samba configuration and state with services stopped + block: + - name: Stop the Samba services for a consistent copy + ansible.builtin.systemd_service: + name: "{{ item }}" + state: stopped + loop: + - smbd.service + - nmbd.service + loop_control: + label: "{{ item }}" + + - name: Copy the Samba configuration tree + ansible.builtin.command: + argv: + - /usr/bin/cp + - -a + - /etc/samba + - "{{ samba_backup_directory }}/etc-samba" + changed_when: true + + - name: Copy the Samba state tree including the passdb + ansible.builtin.command: + argv: + - /usr/bin/cp + - -a + - /var/lib/samba + - "{{ samba_backup_directory }}/var-lib-samba" + changed_when: true + + - name: Copy the Samba defaults when present + ansible.builtin.copy: + src: /etc/default/samba + dest: "{{ samba_backup_directory }}/etc-default-samba" + remote_src: true + owner: root + group: root + mode: "0644" + when: samba_backup_default_samba.stat.exists + + - name: Read the testparm dump of the live configuration + ansible.builtin.command: + argv: + - /usr/bin/testparm + - -s + - /etc/samba/smb.conf + check_mode: false + changed_when: false + register: samba_backup_testparm + + - name: Write the testparm dump into the backup + ansible.builtin.copy: + content: "{{ samba_backup_testparm.stdout }}\n" + dest: "{{ samba_backup_directory }}/testparm-live.txt" + owner: root + group: root + mode: "0600" + + - name: Read the Samba user list + ansible.builtin.command: + argv: + - /usr/sbin/pdbedit + - -L + check_mode: false + changed_when: false + register: samba_backup_pdbedit + + - name: Write the Samba user list into the backup + ansible.builtin.copy: + content: "{{ samba_backup_pdbedit.stdout }}" + dest: "{{ samba_backup_directory }}/pdbedit-users.txt" + owner: root + group: root + mode: "0600" + + - name: Read the backup file checksums + ansible.builtin.command: + argv: + - /usr/bin/find + - "{{ samba_backup_directory }}" + - -type + - f + - -exec + - /usr/bin/sha256sum + - "{}" + - + + check_mode: false + changed_when: false + register: samba_backup_checksums + + - name: Write the checksum manifest into the backup + ansible.builtin.copy: + content: "{{ samba_backup_checksums.stdout }}" + dest: "{{ samba_backup_directory }}/SHA256SUMS" + owner: root + group: root + mode: "0600" + + - name: Write the backup manifest + ansible.builtin.copy: + content: | + # CT 101 Samba pre-migration backup (one-shot, playbooks/backup.yml) + created_utc={{ samba_backup_timestamp.stdout }} + host={{ inventory_hostname }} ({{ samba_expected_ipv4 }}) + source_etc_samba=/etc/samba + source_var_lib_samba=/var/lib/samba + source_etc_default_samba={{ '/etc/default/samba' if samba_backup_default_samba.stat.exists else 'absent' }} + passdb_file={{ samba_smb_passdb_file }} + smb_users={{ samba_backup_pdbedit.stdout_lines | join(',') }} + verify_checksums=sha256sum -c /var/backups/ghostnet/samba//SHA256SUMS + restore_hint=systemctl stop smbd nmbd && cp -a /etc-samba/. /etc/samba/ && cp -a /var-lib-samba/. /var/lib/samba/ && systemctl start smbd nmbd + dest: "{{ samba_backup_directory }}/MANIFEST" + owner: root + group: root + mode: "0600" + rescue: + - name: Report the failed backup + ansible.builtin.fail: + msg: >- + The pre-migration backup failed; the always section restarts the + Samba services. Investigate before retrying. + always: + - name: Ensure the Samba services are started + ansible.builtin.systemd_service: + name: "{{ item }}" + state: started + loop: + - smbd.service + - nmbd.service + loop_control: + label: "{{ item }}" + + - name: Report the created backup + ansible.builtin.debug: + msg: + backup_directory: "{{ samba_backup_directory }}" + created_utc: "{{ samba_backup_timestamp.stdout }}" + smb_users: "{{ samba_backup_pdbedit.stdout_lines }}" + next_step: >- + site.yml --check --diff, then site.yml, then verify-production.yml diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/bootstrap.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/bootstrap.yml new file mode 100644 index 0000000..69bba06 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/bootstrap.yml @@ -0,0 +1,12 @@ +--- +- name: Adopt the CT 101 access bootstrap declaratively + hosts: samba_hosts + become: true + gather_facts: true + gather_subset: + - min + - network + - virtual + + roles: + - role: samba_access diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/preflight.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/preflight.yml new file mode 100644 index 0000000..8ecdf9f --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/preflight.yml @@ -0,0 +1,400 @@ +--- +- name: Read-only preflight of the adopted CT 101 Samba LXC + hosts: samba_hosts + become: false + gather_facts: true + gather_subset: + - min + - hardware + - network + - virtual + + tasks: + - name: Verify required inventory safeguards are defined + ansible.builtin.assert: + that: + - samba_ctid is defined + - samba_expected_ipv4 is defined + - samba_expected_ssh_host_key_fingerprint is defined + - samba_expected_distribution is defined + - samba_expected_distribution_major_version is defined + - samba_expected_virtualization_type is defined + - samba_expected_podman_version is defined + - samba_expected_hostname is defined + - samba_minimum_root_size_bytes is defined + - samba_minimum_root_available_bytes is defined + - samba_expected_processor_vcpus is defined + - samba_minimum_memory_mb is defined + fail_msg: >- + The selected inventory lacks mandatory CT 101 identity safeguards. + Stop before running any configuration playbook. + + - name: Verify the expected post-upgrade guest identity + ansible.builtin.assert: + that: + - inventory_hostname == "samba-ct101" + - samba_ctid | int == 101 + - ansible_facts["distribution"] == samba_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + samba_expected_distribution_major_version + - ansible_facts["default_ipv4"]["address"] == samba_expected_ipv4 + - >- + ansible_facts["virtualization_type"] == + samba_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 101 Samba LXC after the + Debian 13 dist-upgrade. Run scripts/upgrade-ct101-to-debian13.sh on + the Proxmox host first. + success_msg: The expected Debian 13 CT 101 Samba LXC is connected. + + - name: Verify the hostname when the placeholder is already replaced + ansible.builtin.assert: + that: + - ansible_facts["hostname"] == samba_expected_hostname + when: samba_expected_hostname is not match('^REPLACE-') + + - name: Read the guest user-namespace ID mapping + ansible.builtin.command: + argv: + - /usr/bin/cat + - /proc/self/uid_map + check_mode: false + changed_when: false + register: samba_preflight_uid_map + + - name: Require the unprivileged LXC identity mapping + ansible.builtin.assert: + that: + - samba_preflight_uid_map.stdout_lines | length > 0 + - samba_preflight_uid_map.stdout_lines[0] != "0 0 4294967295" + fail_msg: >- + CT 101 must stay an unprivileged LXC (GNAI-015); the NFS-Ganesha + export and the gn-media alignment depend on the standard Proxmox ID + mapping. + + - name: Read the unprivileged SSH identity + ansible.builtin.command: + argv: + - /usr/bin/id + - -un + check_mode: false + changed_when: false + register: samba_preflight_ssh_identity + + - name: Verify the agent transport account + ansible.builtin.assert: + that: + - samba_preflight_ssh_identity.stdout == "ghost-agent" + fail_msg: The agent inventory must connect through ghost-agent. + + - name: Read the agent UID for the inventory placeholder + ansible.builtin.command: + argv: + - /usr/bin/id + - -u + check_mode: false + changed_when: false + register: samba_preflight_agent_uid + + - name: Read the ED25519 host key fingerprint + ansible.builtin.command: + argv: + - /usr/bin/ssh-keygen + - -lf + - /etc/ssh/ssh_host_ed25519_key.pub + check_mode: false + changed_when: false + register: samba_preflight_host_key + + - name: Verify the trusted SSH host key fingerprint + ansible.builtin.assert: + that: + - >- + samba_preflight_host_key.stdout is + search(samba_expected_ssh_host_key_fingerprint | regex_escape) + fail_msg: >- + The ED25519 host key does not match the trusted fingerprint. Verify + the fingerprint on the PVE console (GNAI-011 section 3.3) before + trusting this host. + + - name: Read failed systemd units + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - --failed + - --no-legend + - --plain + check_mode: false + changed_when: false + register: samba_preflight_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - samba_preflight_failed_units.stdout | trim == "" + + - name: Read the productive service states + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - "{{ item }}" + check_mode: false + changed_when: false + loop: + - ssh.service + - smbd.service + - nmbd.service + register: samba_preflight_services + + - name: Require the productive services to be active + ansible.builtin.assert: + that: + - >- + samba_preflight_services.results | + map(attribute='stdout') | unique | list == ['active'] + fail_msg: >- + ssh, smbd or nmbd is not active. The productive Samba file server + must be healthy before the adoption continues. + + - name: Read the service listeners + ansible.builtin.command: + argv: + - /usr/bin/ss + - -H + - -tuln + check_mode: false + changed_when: false + register: samba_preflight_listeners + + - name: Require the pre-migration listener set + ansible.builtin.assert: + that: + - samba_preflight_listeners.stdout is search(':22 ') + - samba_preflight_listeners.stdout is search(':139 ') + - samba_preflight_listeners.stdout is search(':445 ') + - samba_preflight_listeners.stdout is not search(':2049 ') + fail_msg: >- + The pre-migration listener set is unexpected: SSH and SMB must be + served and NFS (2049) must not be active yet. + + - name: Read the Samba server version + ansible.builtin.command: + argv: + - /usr/sbin/smbd + - --version + check_mode: false + changed_when: false + register: samba_preflight_samba_version + + - name: Read the live smb.conf checksum + ansible.builtin.command: + argv: + - /usr/bin/sha256sum + - /etc/samba/smb.conf + check_mode: false + changed_when: false + register: samba_preflight_smb_conf + + - name: Read the live anonymous Samba share listing + ansible.builtin.command: + argv: + - /usr/bin/smbclient + - -L + - localhost + - -N + check_mode: false + changed_when: false + register: samba_preflight_shares + + - name: Read the media account entries + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item.name }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + register: samba_preflight_media_accounts + + - name: Read the media account UIDs for the inventory placeholders + ansible.builtin.command: + argv: + - /usr/bin/id + - -u + - "{{ item.name }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + register: samba_preflight_media_uids + + - name: Read the media group entry for the inventory placeholder + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_name }}" + check_mode: false + changed_when: false + register: samba_preflight_media_group + + - name: Read the complete account database for UID slot picking + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + check_mode: false + changed_when: false + register: samba_preflight_all_accounts + + - name: Read the complete group database for GID slot picking + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + check_mode: false + changed_when: false + register: samba_preflight_all_groups + + - name: Read the root filesystem capacity + ansible.builtin.command: + argv: + - /usr/bin/findmnt + - --bytes + - --noheadings + - --output + - SIZE,AVAIL + - --target + - / + check_mode: false + changed_when: false + register: samba_preflight_root_capacity + + - name: Read the media mount capacities + ansible.builtin.command: + argv: + - /usr/bin/findmnt + - --bytes + - --noheadings + - --output + - SIZE,AVAIL + - --target + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_mount_points }}" + loop_control: + label: "{{ item }}" + register: samba_preflight_media_capacity + + - name: Read the media mount table + ansible.builtin.command: + argv: + - /usr/bin/findmnt + - --noheadings + - --output + - SOURCE,FSTYPE,OPTIONS + - --target + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_mount_points }}" + loop_control: + label: "{{ item }}" + register: samba_preflight_media_mounts + + - name: Read the effective cgroup CPU limit + ansible.builtin.command: + argv: + - /usr/bin/cat + - /sys/fs/cgroup/cpu.max + check_mode: false + changed_when: false + register: samba_preflight_cpu_max + + - name: Read the effective cgroup memory limit + ansible.builtin.command: + argv: + - /usr/bin/cat + - /sys/fs/cgroup/memory.max + check_mode: false + changed_when: false + register: samba_preflight_memory_max + + - name: Read the Podman presence + ansible.builtin.command: + argv: + - /usr/bin/podman + - --version + check_mode: false + changed_when: false + failed_when: false + register: samba_preflight_podman + + - name: Report the CT 101 preflight baseline + ansible.builtin.debug: + msg: + inventory_host: "{{ inventory_hostname }}" + ctid: "{{ samba_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'] }} + host_visible_processor_vcpus: "{{ ansible_facts['processor_vcpus'] }}" + memory_mib: "{{ ansible_facts['memtotal_mb'] }}" + cgroup_cpu_max: "{{ samba_preflight_cpu_max.stdout }}" + cgroup_memory_max: "{{ samba_preflight_memory_max.stdout }}" + root_size_bytes: "{{ samba_preflight_root_capacity.stdout.split()[0] }}" + root_available_bytes: "{{ samba_preflight_root_capacity.stdout.split()[1] }}" + media_mounts: >- + {{ samba_preflight_media_mounts.results | + map(attribute='stdout') | list }} + media_capacities_bytes: >- + {{ samba_preflight_media_capacity.results | + map(attribute='stdout') | list }} + samba_version: "{{ samba_preflight_samba_version.stdout }}" + smb_conf_sha256: "{{ samba_preflight_smb_conf.stdout.split()[0] }}" + smb_share_listing: "{{ samba_preflight_shares.stdout }}" + media_accounts: >- + {{ samba_preflight_media_accounts.results | + map(attribute='stdout') | list }} + media_user_uids: >- + {{ samba_preflight_media_uids.results | + map(attribute='item.name') | + zip(samba_preflight_media_uids.results | map(attribute='stdout')) | + list }} + media_group: "{{ samba_preflight_media_group.stdout }}" + agent_uid: "{{ samba_preflight_agent_uid.stdout }}" + all_accounts: "{{ samba_preflight_all_accounts.stdout }}" + all_groups: "{{ samba_preflight_all_groups.stdout }}" + podman_version: >- + {{ samba_preflight_podman.stdout + if samba_preflight_podman.rc == 0 else 'not installed' }} + trusted_ssh_fingerprint: >- + {{ samba_expected_ssh_host_key_fingerprint }} + placeholder_sources: >- + samba_expected_hostname=hostname, + samba_expected_processor_vcpus=host_visible_processor_vcpus, + samba_minimum_memory_mb=memory_mib, + samba_minimum_root_size_bytes=root_size_bytes, + samba_minimum_root_available_bytes=root_available_bytes, + samba_access_admin_uid=UID of ghost in media_accounts, + samba_access_agent_uid=agent_uid, + samba_access_deploy_uid=free UID from all_accounts, + samba_media_users[ghost].uid=uid of ghost in media_user_uids, + samba_media_users[daidalaw].uid=uid of daidalaw in media_user_uids, + samba_media_users[debugofen].uid=uid of debugofen in media_user_uids, + samba_media_group_gid=gid of gn-media in media_group diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/site.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/site.yml new file mode 100644 index 0000000..a44a9fb --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/site.yml @@ -0,0 +1,28 @@ +--- +- name: Converge the CT 101 Samba LXC on the declared state + hosts: samba_hosts + become: true + gather_facts: true + gather_subset: + - min + - hardware + - network + - virtual + + roles: + - role: samba_lxc_baseline + vars: + samba_lxc_baseline_expected_hostname: "{{ samba_expected_hostname }}" + samba_lxc_baseline_expected_distribution: >- + {{ samba_expected_distribution }} + samba_lxc_baseline_expected_distribution_major_version: >- + {{ samba_expected_distribution_major_version }} + samba_lxc_baseline_expected_ipv4: "{{ samba_expected_ipv4 }}" + samba_lxc_baseline_expected_virtualization_type: >- + {{ samba_expected_virtualization_type }} + - role: samba_access + - role: podman_runtime + - role: samba_firewall + - role: samba_media + - role: samba_smb + - role: samba_nfs diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-deploy-access.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-deploy-access.yml new file mode 100644 index 0000000..7e691aa --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-deploy-access.yml @@ -0,0 +1,43 @@ +--- +- name: Verify the CT 101 Ansible deployment boundary + hosts: samba_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: samba_deploy_account_identity + + - name: Verify the expected SSH account + ansible.builtin.assert: + that: + - samba_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: samba_deploy_root_identity + + - name: Verify non-interactive root escalation + ansible.builtin.assert: + that: + - samba_deploy_root_identity.stdout == "0" + fail_msg: Non-interactive sudo did not produce effective UID 0. + success_msg: >- + The CT 101 deployment account can connect and escalate privileges + non-interactively. diff --git a/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-production.yml b/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-production.yml new file mode 100644 index 0000000..ad9f9ba --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/playbooks/verify-production.yml @@ -0,0 +1,659 @@ +--- +- name: Read-only verification of the converged CT 101 production state + hosts: samba_hosts + become: true + gather_facts: true + gather_subset: + - min + - hardware + - network + - virtual + vars: + # Adoption: the one-shot pre-migration backup must exist. Fresh-state + # rebuilds have nothing to back up and pass + # -e samba_verify_require_premigration_backup=false. + samba_verify_require_premigration_backup: true + + tasks: + - name: Require the production inventory safeguards + ansible.builtin.assert: + that: + - samba_ctid | int == 101 + - inventory_hostname == "samba-ct101" + - >- + samba_expected_ssh_host_key_fingerprint is + match('^SHA256:[A-Za-z0-9+/]{43}$') + + - name: Require the inventory placeholders to be replaced + ansible.builtin.assert: + that: + - samba_expected_hostname is not match('^REPLACE-') + - samba_minimum_root_size_bytes is not match('^REPLACE-') + - samba_minimum_root_available_bytes is not match('^REPLACE-') + - samba_expected_processor_vcpus is not match('^REPLACE-') + - samba_minimum_memory_mb is not match('^REPLACE-') + - samba_access_admin_uid is not match('^REPLACE-') + - samba_access_agent_uid is not match('^REPLACE-') + - samba_access_deploy_uid is not match('^REPLACE-') + - >- + (samba_media_users | map(attribute='uid') | list | + select('number') | list | length) == samba_media_users | length + fail_msg: >- + The inventory still contains REPLACE-WITH placeholders. Run + playbooks/preflight.yml on the agent inventory and copy the reported + values into the selected inventory before verifying production. + + - name: Verify the expected guest identity + ansible.builtin.assert: + that: + - ansible_facts["hostname"] == samba_expected_hostname + - ansible_facts["distribution"] == samba_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + samba_expected_distribution_major_version + - ansible_facts["default_ipv4"]["address"] == samba_expected_ipv4 + - ansible_facts["virtualization_type"] == "lxc" + - ansible_facts["virtualization_role"] == "guest" + - ansible_facts["service_mgr"] == "systemd" + fail_msg: The connected machine is not the declared CT 101. + + - name: Read the ED25519 host key fingerprint + ansible.builtin.command: + argv: + - /usr/bin/ssh-keygen + - -lf + - /etc/ssh/ssh_host_ed25519_key.pub + check_mode: false + changed_when: false + register: samba_production_host_key + + - name: Verify the trusted SSH host key fingerprint + ansible.builtin.assert: + that: + - >- + samba_production_host_key.stdout is + search(samba_expected_ssh_host_key_fingerprint | regex_escape) + + - name: Read failed systemd units + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - --failed + - --no-legend + - --plain + check_mode: false + changed_when: false + register: samba_production_failed_units + + - name: Require no failed units + ansible.builtin.assert: + that: + - samba_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: samba_production_account + + - name: Require the ansible-deploy transport + ansible.builtin.assert: + that: + - samba_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: samba_production_password_states + + - name: Verify account password states + ansible.builtin.assert: + that: + - samba_production_password_states.results[0].stdout.split()[1] == "L" + - samba_production_password_states.results[1].stdout.split()[1] == "P" + - samba_production_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: Validate the effective SSH policy + ansible.builtin.command: + argv: + - /usr/sbin/sshd + - -T + check_mode: false + changed_when: false + register: samba_production_sshd + + - name: Verify the complete effective SSH baseline + ansible.builtin.assert: + that: + - "'permitrootlogin no' in samba_production_sshd.stdout_lines" + - "'passwordauthentication no' in samba_production_sshd.stdout_lines" + - "'kbdinteractiveauthentication no' in samba_production_sshd.stdout_lines" + - "'pubkeyauthentication yes' in samba_production_sshd.stdout_lines" + - "'authenticationmethods publickey' in samba_production_sshd.stdout_lines" + - "'permitemptypasswords no' in samba_production_sshd.stdout_lines" + - "'x11forwarding no' in samba_production_sshd.stdout_lines" + - "'allowagentforwarding no' in samba_production_sshd.stdout_lines" + - "'allowtcpforwarding no' in samba_production_sshd.stdout_lines" + - "'allowstreamlocalforwarding no' in samba_production_sshd.stdout_lines" + - "'gatewayports no' in samba_production_sshd.stdout_lines" + - "'permittunnel no' in samba_production_sshd.stdout_lines" + - "'disableforwarding yes' in samba_production_sshd.stdout_lines" + - "'maxauthtries 3' in samba_production_sshd.stdout_lines" + - "'logingracetime 30' in samba_production_sshd.stdout_lines" + - "'loglevel VERBOSE' in samba_production_sshd.stdout_lines" + fail_msg: >- + The effective SSH policy does not match the full GNAI-011 baseline. + + - name: Read the deployed SSH drop-in + ansible.builtin.slurp: + src: "{{ samba_access_ssh_dropin }}" + register: samba_production_sshd_dropin + + - name: Verify the deployed SSH drop-in matches the template + ansible.builtin.assert: + that: + - >- + samba_production_sshd_dropin['content'] | b64decode == + lookup('ansible.builtin.template', + playbook_dir ~ '/../roles/samba_access/templates/sshd.conf.j2') + fail_msg: The SSH drop-in differs from the declared template. + + - name: Read the active Ghostnet nftables table + ansible.builtin.command: + argv: + - /usr/sbin/nft + - list + - table + - inet + - ghostnet_filter + check_mode: false + changed_when: false + register: samba_production_nft_table + + - name: Verify the active firewall policy + ansible.builtin.assert: + that: + - samba_production_nft_table.stdout is search('policy drop') + - samba_production_nft_table.stdout is search('SSH management') + - >- + samba_production_nft_table.stdout is + search('SMB over TCP from the LAN') + - samba_production_nft_table.stdout is search('NFSv4 from ghostnet-ki') + - >- + samba_production_nft_table.stdout is + search(samba_firewall_nfs_client_ipv4) + fail_msg: >- + The active nftables policy does not match the declared CT 101 Samba + baseline. + + - name: Read the deployed nftables configuration + ansible.builtin.slurp: + src: /etc/nftables.conf + register: samba_production_nft_config + + - name: Verify the deployed nftables file matches the template + ansible.builtin.assert: + that: + - >- + samba_production_nft_config['content'] | b64decode == + lookup('ansible.builtin.template', + playbook_dir ~ '/../roles/samba_firewall/templates/nftables.conf.j2') + fail_msg: /etc/nftables.conf differs from the declared template. + + - name: Read the nftables service override + ansible.builtin.slurp: + src: /etc/systemd/system/nftables.service.d/10-ghostnet-preserve-netavark.conf + register: samba_production_nft_override + + - name: Verify the nftables service override + ansible.builtin.assert: + that: + - >- + samba_production_nft_override['content'] | b64decode is + search('ExecStop=/usr/sbin/nft destroy table inet ghostnet_filter') + fail_msg: The nftables service override is missing or incomplete. + + - name: Read the media mount table + ansible.builtin.command: + argv: + - /usr/bin/findmnt + - --noheadings + - --output + - SOURCE,FSTYPE,OPTIONS + - --target + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_mount_points }}" + loop_control: + label: "{{ item }}" + register: samba_production_mounts + + - name: Require every declared media mount to be mounted + ansible.builtin.assert: + that: + - samba_production_mounts.results | map(attribute='rc') | unique | list == [0] + fail_msg: Not all declared media mount points are mounted. + + - name: Read the declared media accounts + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item.name }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + index_var: account_index + register: samba_production_media_accounts + + - name: Require the declared media accounts to carry their recorded UIDs + ansible.builtin.assert: + that: + - samba_production_media_accounts.results[account_index].rc == 0 + - >- + samba_production_media_accounts.results[account_index].stdout.split(':')[0] + == item.name + - >- + samba_production_media_accounts.results[account_index].stdout.split(':')[2] + | int == item.uid | int + - >- + item.name != samba_access_admin_user or + item.uid | int == samba_access_admin_uid | int + fail_msg: >- + The media account {{ item.name }} is missing or does not carry the + recorded UID {{ item.uid }}. The encoding pipeline depends on stable + UIDs (GNAI-018 R1), and the administrator declaration must stay + consistent with samba_access_admin_uid. + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + index_var: account_index + + - name: Read the media group entry + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_name }}" + check_mode: false + changed_when: false + register: samba_production_media_group + + - name: Verify the media group identity + ansible.builtin.assert: + that: + - samba_production_media_group.rc == 0 + - >- + samba_production_media_group.stdout.split(':')[0] == + samba_media_group_name + - >- + samba_production_media_group.stdout.split(':')[2] | int == + samba_media_group_gid | int + fail_msg: The media group does not carry the pipeline GID 970. + + - name: Read the declared media group memberships + ansible.builtin.command: + argv: + - /usr/bin/id + - -Gn + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_media_group_members }}" + loop_control: + label: "{{ item }}" + register: samba_production_member_groups + + - name: Verify the declared media group memberships + ansible.builtin.assert: + that: + - samba_media_group_name in item.stdout.split() + fail_msg: >- + {{ item.item }} is not a member of {{ samba_media_group_name }}; the + mv pickup from [Encode] and the .dlc drop into [Encode-In] would + fail (GNAI-018 R3). + loop: "{{ samba_production_member_groups.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Read the deployed Samba configuration + ansible.builtin.slurp: + src: "{{ samba_smb_config_file }}" + register: samba_production_smb_conf + + - name: Verify the deployed Samba configuration matches the template + ansible.builtin.assert: + that: + - >- + samba_production_smb_conf['content'] | b64decode == + lookup('ansible.builtin.template', + playbook_dir ~ '/../roles/samba_smb/templates/smb.conf.j2') + fail_msg: The deployed smb.conf differs from the declared template. + + - name: Validate the deployed Samba configuration + ansible.builtin.command: + argv: + - /usr/bin/testparm + - -s + - "{{ samba_smb_config_file }}" + check_mode: false + changed_when: false + register: samba_production_testparm + + - name: Require a clean testparm result + ansible.builtin.assert: + that: + - samba_production_testparm.rc == 0 + fail_msg: testparm rejected the deployed smb.conf. + + - name: Read the Samba user database + ansible.builtin.command: + argv: + - /usr/sbin/pdbedit + - -L + check_mode: false + changed_when: false + register: samba_production_pdbedit + + - name: Verify the productive Samba users survived + ansible.builtin.assert: + that: + - >- + samba_media_users | map(attribute='name') | list is + subset(samba_production_pdbedit.stdout_lines) + fail_msg: >- + The productive SMB users ghost, daidalaw and debugofen are missing + from the passdb (GNAI-018 R5). On a fresh-state rebuild, set them + with smbpasswd -a after the first convergence. + + - name: Read the anonymous Samba share listing + ansible.builtin.command: + argv: + - /usr/bin/smbclient + - -L + - localhost + - -N + check_mode: false + changed_when: false + register: samba_production_shares + + - name: Verify the visible Samba shares + ansible.builtin.assert: + that: + - >- + samba_production_shares.stdout is + search('(?m)^\\s*' ~ (item | regex_escape) ~ '\\s+Disk') + fail_msg: >- + The share {{ item }} is not visible in the anonymous share listing. + loop: >- + {{ samba_smb_media_shares | map(attribute='name') | list + ['print$'] }} + loop_control: + label: "{{ item }}" + + - name: Read the deployed Ganesha configuration + ansible.builtin.slurp: + src: "{{ samba_nfs_config_directory }}/ganesha.conf" + register: samba_production_ganesha_conf + + - name: Verify the deployed Ganesha configuration matches the template + ansible.builtin.assert: + that: + - >- + samba_production_ganesha_conf['content'] | b64decode == + lookup('ansible.builtin.template', + playbook_dir ~ '/../roles/samba_nfs/templates/ganesha.conf.j2') + fail_msg: The deployed ganesha.conf differs from the declared template. + + - name: Read the runtime image identity + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - inspect + - --format=json + - "{{ samba_nfs_runtime_tag }}" + check_mode: false + changed_when: false + register: samba_production_image_inspect + + - name: Verify the optimized Ganesha image + ansible.builtin.assert: + that: + - >- + (samba_production_image_inspect.stdout | from_json)[0].Architecture == + samba_nfs_expected_architecture + - (samba_production_image_inspect.stdout | from_json)[0].Os == "linux" + - >- + ( + (samba_production_image_inspect.stdout | from_json)[0] + ).Config.Labels['eu.selfhost.ghostnet.containerfile-sha256'] == + samba_nfs_containerfile_sha256 + fail_msg: >- + The optimized Ganesha image does not match the reviewed Containerfile + checksum. + + - name: Read the deployed Ganesha Quadlet + ansible.builtin.slurp: + src: "{{ samba_nfs_quadlet_directory }}/ghostnet-samba-nfs.container" + register: samba_production_quadlet + + - name: Verify the deployed Ganesha Quadlet matches the template + ansible.builtin.assert: + that: + - >- + samba_production_quadlet['content'] | b64decode == + lookup('ansible.builtin.template', + playbook_dir ~ '/../roles/samba_nfs/templates/ghostnet-samba-nfs.container.j2') + fail_msg: The Ganesha Quadlet differs from the declared template. + vars: + samba_nfs_runtime_image_id: >- + {{ (samba_production_image_inspect.stdout | from_json)[0].Id }} + + - name: Check the generated Ganesha boot target link + ansible.builtin.stat: + path: >- + /run/systemd/generator/multi-user.target.wants/{{ samba_nfs_service_unit }} + follow: false + register: samba_production_boot_link + + - name: Verify the Ganesha boot policy + ansible.builtin.assert: + that: + - samba_production_boot_link.stat.exists + - samba_production_boot_link.stat.islnk + fail_msg: >- + The Ganesha service is not linked into the generated multi-user + target; the export would not survive a reboot. + + - name: Stat the NFS export root + ansible.builtin.stat: + path: "{{ samba_nfs_export_path }}" + register: samba_production_export_root + + - name: Verify the NFS export root ownership and mode + ansible.builtin.assert: + that: + - samba_production_export_root.stat.exists + - samba_production_export_root.stat.isdir + - samba_production_export_root.stat.pw_name == "root" + - >- + samba_production_export_root.stat.gr_name == + samba_media_group_name + - >- + '%04o' % (samba_production_export_root.stat.mode | int(0, 8)) == + '2775' + fail_msg: >- + The export root must be root:gn-media 2775 so the pipeline can create + its top-level directories via NFS (GNAI-019). + + - name: Read the Ganesha container state + ansible.builtin.command: + argv: + - /usr/bin/podman + - inspect + - --format=json + - "{{ samba_nfs_container_name }}" + check_mode: false + changed_when: false + register: samba_production_container + + - name: Verify the Ganesha container is healthy and rootful + ansible.builtin.assert: + that: + - >- + (samba_production_container.stdout | from_json)[0].State.Status == + "running" + - >- + (samba_production_container.stdout | from_json)[0].State.Health.Status == + "healthy" + - >- + (samba_production_container.stdout | from_json)[0].Config.User == + "0:0" + fail_msg: >- + The Ganesha container is not running and healthy as root; check + 'podman logs {{ samba_nfs_container_name }}'. + + - 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 + - smbd.service + - nmbd.service + - unattended-upgrades.service + - "{{ samba_nfs_service_unit }}" + register: samba_production_services + + - name: Require every production service to be active + ansible.builtin.assert: + that: + - >- + samba_production_services.results | + map(attribute='stdout') | unique | list == ['active'] + fail_msg: Not every CT 101 production service is active. + + - name: Read the service listeners + ansible.builtin.command: + argv: + - /usr/bin/ss + - -H + - -tuln + check_mode: false + changed_when: false + register: samba_production_listeners + + - name: Require the SSH, SMB and NFS listeners + ansible.builtin.assert: + that: + - samba_production_listeners.stdout is search(':22 ') + - samba_production_listeners.stdout is search(':139 ') + - samba_production_listeners.stdout is search(':445 ') + - samba_production_listeners.stdout is search(':2049 ') + fail_msg: >- + The CT 101 service listeners for SSH, SMB and NFS are incomplete. + + - name: Read the installed Podman version + ansible.builtin.command: + argv: + - /usr/bin/podman + - --version + check_mode: false + changed_when: false + register: samba_production_podman_version + + - name: Verify the expected Podman version + ansible.builtin.assert: + that: + - >- + samba_production_podman_version.stdout == + "podman version " ~ samba_expected_podman_version + fail_msg: >- + The installed Podman version differs from the expected Debian 13 + runtime. + + - name: Read the host cgroup filesystem type + ansible.builtin.command: + argv: + - /usr/bin/stat + - -fc + - "%T" + - /sys/fs/cgroup + check_mode: false + changed_when: false + register: samba_production_cgroup + + - name: Require cgroup v2 + ansible.builtin.assert: + that: + - samba_production_cgroup.stdout == "cgroup2fs" + fail_msg: The Podman runtime requires cgroup v2. + + - name: Read the pre-migration backup directories + ansible.builtin.command: + argv: + - /usr/bin/find + - /var/backups/ghostnet/samba + - -maxdepth + - "1" + - -type + - d + - -name + - pre-migration-* + check_mode: false + changed_when: false + register: samba_production_backups + + - name: Require the one-shot pre-migration backup to exist + ansible.builtin.assert: + that: + - samba_production_backups.stdout | trim | length > 0 + fail_msg: >- + No pre-migration backup exists under /var/backups/ghostnet/samba; + run playbooks/backup.yml before the first convergence. + when: samba_verify_require_premigration_backup | bool + + - name: Report the verified CT 101 production state + ansible.builtin.debug: + msg: + ctid: "{{ samba_ctid }}" + hostname: "{{ ansible_facts['hostname'] }}" + operating_system: >- + {{ ansible_facts['distribution'] }} + {{ ansible_facts['distribution_version'] }} + ipv4: "{{ ansible_facts['default_ipv4']['address'] }}" + services: >- + {{ samba_production_services.results | + map(attribute='stdout') | unique | list }} + smb_users: "{{ samba_production_pdbedit.stdout_lines }}" + pre_migration_backups: "{{ samba_production_backups.stdout_lines }}" + ganesha_container: >- + {{ (samba_production_container.stdout | from_json)[0].State.Status }} + / + {{ (samba_production_container.stdout | from_json)[0].State.Health.Status }} + trusted_ssh_fingerprint: >- + {{ samba_expected_ssh_host_key_fingerprint }} diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/README.md new file mode 100644 index 0000000..0e79ea6 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/README.md @@ -0,0 +1,24 @@ +# `samba_access` + +Declaratively adopts the CT 101 access and SSH boundary after the console +bootstrap: + +- `ghost` — the pre-existing personal administrator account of the legacy + LXC. The role only validates its productive UID against + `samba_access_admin_uid`, grants password-gated `sudo` and installs the + restricted personal key. The account is never recreated. +- `ghost-agent` — locked, without sudo, created with the fixed + `samba_access_agent_uid` after the slot was verified free. +- `ansible-deploy` — locked, source-restricted key, validated non-interactive + `NOPASSWD` sudo, created with the fixed `samba_access_deploy_uid` after the + slot was verified free. + +Each account receives a separate public key bound to the declared LAN and +administration-VPN source networks. The GNAI-011 SSH baseline is installed as +`/etc/ssh/sshd_config.d/99-security.conf` **without** `AllowUsers` (see the +template comment); SSH socket activation is disabled in favour of the classic +service. + +Private keys and the personal `ghost` sudo password are never managed by this +role or stored in the repository. Inputs are documented in +`meta/argument_specs.yml`. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/defaults/main.yml new file mode 100644 index 0000000..4eb6c8f --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/defaults/main.yml @@ -0,0 +1,27 @@ +--- +samba_access_source_networks: 10.0.0.0/24,10.8.0.0/24 +samba_access_public_key_root: "{{ playbook_dir }}/../files/ssh" + +samba_access_agent_user: ghost-agent +samba_access_agent_uid: 1000 +samba_access_agent_public_key_file: >- + {{ samba_access_public_key_root }}/samba-ghost-agent.pub + +samba_access_admin_user: ghost +samba_access_admin_uid: 1001 +samba_access_admin_public_key_file: >- + {{ samba_access_public_key_root }}/samba-ghost.pub + +samba_access_deploy_user: ansible-deploy +samba_access_deploy_uid: 1002 +samba_access_deploy_public_key_file: >- + {{ samba_access_public_key_root }}/samba-ansible-deploy.pub + +samba_access_ssh_dropin: /etc/ssh/sshd_config.d/99-security.conf +samba_access_sudoers_file: /etc/sudoers.d/90-ghostnet-ansible-deploy + +# CT 101 is an adopted legacy LXC: the personal administrator account ghost +# already exists with its productive UID and is only validated here, never +# recreated. ghost-agent and ansible-deploy are created with fixed UIDs that +# the operator picks from the audit output because the media accounts +# ghost/daidalaw/debugofen already occupy the low UID range. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/handlers/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/handlers/main.yml new file mode 100644 index 0000000..f67d8e5 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart the classic SSH service + ansible.builtin.systemd_service: + name: ssh.service + state: restarted diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/meta/argument_specs.yml new file mode 100644 index 0000000..37ee86c --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/meta/argument_specs.yml @@ -0,0 +1,44 @@ +--- +argument_specs: + main: + short_description: Manage the CT 101 Samba LXC access and SSH boundary + options: + samba_access_source_networks: + type: str + required: true + samba_access_public_key_root: + type: path + required: true + samba_access_agent_user: + type: str + required: true + samba_access_agent_uid: + type: int + required: true + samba_access_agent_public_key_file: + type: path + required: true + samba_access_admin_user: + type: str + required: true + samba_access_admin_uid: + type: int + required: true + samba_access_admin_public_key_file: + type: path + required: true + samba_access_deploy_user: + type: str + required: true + samba_access_deploy_uid: + type: int + required: true + samba_access_deploy_public_key_file: + type: path + required: true + samba_access_ssh_dropin: + type: path + required: true + samba_access_sudoers_file: + type: path + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/tasks/main.yml new file mode 100644 index 0000000..6481431 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/tasks/main.yml @@ -0,0 +1,351 @@ +--- +- name: Read the existing access accounts + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item }}" + check_mode: false + changed_when: false + failed_when: false + loop: + - "{{ samba_access_agent_user }}" + - "{{ samba_access_admin_user }}" + - "{{ samba_access_deploy_user }}" + loop_control: + label: "{{ item }}" + register: samba_access_account_probes + +- name: Probe the UID/GID slots for the automation accounts + ansible.builtin.command: + argv: + - /usr/bin/getent + - "{{ item.database }}" + - "{{ item.key | string }}" + loop: + - database: passwd + key: "{{ samba_access_agent_uid }}" + - database: group + key: "{{ samba_access_agent_uid }}" + - database: group + key: "{{ samba_access_agent_user }}" + - database: passwd + key: "{{ samba_access_deploy_uid }}" + - database: group + key: "{{ samba_access_deploy_uid }}" + - database: group + key: "{{ samba_access_deploy_user }}" + check_mode: false + changed_when: false + failed_when: false + register: samba_access_slot_probes + loop_control: + label: "{{ item.database }} {{ item.key }}" + +- name: Determine the existing access account states + ansible.builtin.set_fact: + samba_access_agent_exists: >- + {{ samba_access_account_probes.results[0].rc == 0 }} + samba_access_admin_exists: >- + {{ samba_access_account_probes.results[1].rc == 0 }} + samba_access_deploy_exists: >- + {{ samba_access_account_probes.results[2].rc == 0 }} + +- name: Require the existing administrator account to match the declared UID + ansible.builtin.assert: + that: + - samba_access_admin_exists | bool + - >- + samba_access_account_probes.results[1].stdout.split(':')[2] | int == + samba_access_admin_uid | int + fail_msg: >- + The existing account ghost must keep its productive UID. Copy the UID + reported by audit-production.yml into samba_access_admin_uid; the role + never recreates the administrator account. + +- name: Verify the agent account state against the declared UID + ansible.builtin.assert: + that: + - >- + samba_access_account_probes.results[0].stdout.split(':')[2] | int == + samba_access_agent_uid | int + fail_msg: >- + ghost-agent exists with an unexpected UID. Align samba_access_agent_uid + with the existing account or remove the account deliberately. + when: samba_access_agent_exists | bool + +- name: Verify the agent UID/GID slot is free when the account is missing + ansible.builtin.assert: + that: + - samba_access_slot_probes.results[0].rc == 2 + - samba_access_slot_probes.results[1].rc == 2 + - samba_access_slot_probes.results[2].rc == 2 + fail_msg: >- + The UID/GID slot for ghost-agent is occupied by another identity. Choose + a free UID from the audit output and update samba_access_agent_uid. + when: not samba_access_agent_exists | bool + +- name: Verify the deployment account state against the declared UID + ansible.builtin.assert: + that: + - >- + samba_access_account_probes.results[2].stdout.split(':')[2] | int == + samba_access_deploy_uid | int + fail_msg: >- + ansible-deploy exists with an unexpected UID. Align + samba_access_deploy_uid with the existing account or remove the account + deliberately. + when: samba_access_deploy_exists | bool + +- name: Verify the deployment UID/GID slot is free when the account is missing + ansible.builtin.assert: + that: + - samba_access_slot_probes.results[3].rc == 2 + - samba_access_slot_probes.results[4].rc == 2 + - samba_access_slot_probes.results[5].rc == 2 + fail_msg: >- + The UID/GID slot for ansible-deploy is occupied by another identity. + Choose a free UID from the audit output and update + samba_access_deploy_uid. + when: not samba_access_deploy_exists | bool + +- name: Create the fixed primary group for the read-only agent + ansible.builtin.group: + name: "{{ samba_access_agent_user }}" + gid: "{{ samba_access_agent_uid }}" + state: present + when: not samba_access_agent_exists | bool + +- name: Create the fixed primary group for the deployment account + ansible.builtin.group: + name: "{{ samba_access_deploy_user }}" + gid: "{{ samba_access_deploy_uid }}" + state: present + when: not samba_access_deploy_exists | bool + +- name: Create the fixed automation 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: "{{ samba_access_agent_user }}" + uid: "{{ samba_access_agent_uid }}" + missing: "{{ not samba_access_agent_exists | bool }}" + - name: "{{ samba_access_deploy_user }}" + uid: "{{ samba_access_deploy_uid }}" + missing: "{{ not samba_access_deploy_exists | bool }}" + loop_control: + label: "{{ item.name }}" + when: item.missing | bool + +- name: Keep the read-only agent outside supplementary groups + ansible.builtin.user: + name: "{{ samba_access_agent_user }}" + groups: "" + append: false + password_lock: true + +- name: Keep the deployment account outside supplementary groups + ansible.builtin.user: + name: "{{ samba_access_deploy_user }}" + groups: "" + append: false + password_lock: true + +- name: Grant password-gated sudo membership to the administrator + ansible.builtin.user: + name: "{{ samba_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: + - "{{ samba_access_agent_user }}" + - "{{ samba_access_admin_user }}" + - "{{ samba_access_deploy_user }}" + loop_control: + label: "{{ item }}" + +- name: Install the restricted read-only agent key + ansible.builtin.copy: + content: | + restrict,from="{{ samba_access_source_networks }}" {{ lookup('ansible.builtin.file', samba_access_agent_public_key_file) }} + dest: "/home/{{ samba_access_agent_user }}/.ssh/authorized_keys" + owner: "{{ samba_access_agent_user }}" + group: "{{ samba_access_agent_user }}" + mode: "0600" + +- name: Install the restricted personal administrator key + ansible.builtin.copy: + content: | + from="{{ samba_access_source_networks }}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc {{ lookup('ansible.builtin.file', samba_access_admin_public_key_file) }} + dest: "/home/{{ samba_access_admin_user }}/.ssh/authorized_keys" + owner: "{{ samba_access_admin_user }}" + group: "{{ samba_access_admin_user }}" + mode: "0600" + +- name: Install the restricted deployment key + ansible.builtin.copy: + content: | + restrict,from="{{ samba_access_source_networks }}" {{ lookup('ansible.builtin.file', samba_access_deploy_public_key_file) }} + dest: "/home/{{ samba_access_deploy_user }}/.ssh/authorized_keys" + owner: "{{ samba_access_deploy_user }}" + group: "{{ samba_access_deploy_user }}" + mode: "0600" + +- name: Install validated deployment sudo policy + ansible.builtin.copy: + content: | + {{ samba_access_deploy_user }} ALL=(root) NOPASSWD: ALL + dest: "{{ samba_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: "{{ samba_access_ssh_dropin }}" + owner: root + group: root + mode: "0600" + 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: + - "{{ samba_access_agent_user }}" + - "{{ samba_access_admin_user }}" + - "{{ samba_access_deploy_user }}" + register: samba_access_password_states + +- name: Verify account password states + ansible.builtin.assert: + that: + - samba_access_password_states.results[0].stdout.split()[1] == "L" + - samba_access_password_states.results[1].stdout.split()[1] == "P" + - samba_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: + - "{{ samba_access_agent_user }}" + - "{{ samba_access_deploy_user }}" + register: samba_access_restricted_groups + +- name: Verify restricted accounts have no supplementary groups + ansible.builtin.assert: + that: + - >- + samba_access_restricted_groups.results[0].stdout == + samba_access_agent_user + - >- + samba_access_restricted_groups.results[1].stdout == + samba_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: samba_access_deploy_root_identity + +- name: Require effective root through the deployment account + ansible.builtin.assert: + that: + - samba_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: samba_access_effective_sshd + when: not ansible_check_mode + +- name: Verify the complete effective SSH baseline + ansible.builtin.assert: + that: + - "'permitrootlogin no' in samba_access_effective_sshd.stdout_lines" + - "'passwordauthentication no' in samba_access_effective_sshd.stdout_lines" + - "'kbdinteractiveauthentication no' in samba_access_effective_sshd.stdout_lines" + - "'pubkeyauthentication yes' in samba_access_effective_sshd.stdout_lines" + - "'authenticationmethods publickey' in samba_access_effective_sshd.stdout_lines" + - "'permitemptypasswords no' in samba_access_effective_sshd.stdout_lines" + - "'x11forwarding no' in samba_access_effective_sshd.stdout_lines" + - "'allowagentforwarding no' in samba_access_effective_sshd.stdout_lines" + - "'allowtcpforwarding no' in samba_access_effective_sshd.stdout_lines" + - "'allowstreamlocalforwarding no' in samba_access_effective_sshd.stdout_lines" + - "'gatewayports no' in samba_access_effective_sshd.stdout_lines" + - "'permittunnel no' in samba_access_effective_sshd.stdout_lines" + - "'disableforwarding yes' in samba_access_effective_sshd.stdout_lines" + - "'maxauthtries 3' in samba_access_effective_sshd.stdout_lines" + - "'logingracetime 30' in samba_access_effective_sshd.stdout_lines" + - "'loglevel VERBOSE' in samba_access_effective_sshd.stdout_lines" + fail_msg: The effective SSH policy differs from the GNAI-011 baseline. + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/templates/sshd.conf.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/templates/sshd.conf.j2 new file mode 100644 index 0000000..105d8f3 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_access/templates/sshd.conf.j2 @@ -0,0 +1,24 @@ +# Managed by Ansible role samba_access. +# GNAI-011 baseline for CT 101. AllowUsers is deliberately not set: CT 101 is +# an adopted legacy LXC and the media accounts daidalaw/debugofen may hold +# existing SSH access; a blanket AllowUsers could lock out operational or +# recovery accounts (GNAI-011 section 4). Tightening is an explicit +# operator decision after the audit review. +PermitRootLogin no +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitEmptyPasswords no +AuthenticationMethods publickey + +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +GatewayPorts no +PermitTunnel no +DisableForwarding yes + +MaxAuthTries 3 +LoginGraceTime 30 +LogLevel VERBOSE diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/README.md new file mode 100644 index 0000000..3e21310 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/README.md @@ -0,0 +1,27 @@ +# `samba_firewall` + +Declaratively manages the CT 101 nftables boundary (GNAI-018 R7): + +- Input policy `drop`; loopback, established/related and ICMP diagnostics are + accepted. +- SSH (`22/tcp`) and SMB (`139,445/tcp` plus `137,138/udp` for nmbd) are + accepted from the management LAN only (`samba_firewall_management_ipv4`). +- NFS (`2049/tcp+udp`) is accepted exclusively from the encoding-pipeline host + `ghostnet-ki` (`samba_firewall_nfs_client_ipv4`), because the NFS-Ganesha + container runs with host networking inside CT 101. +- Forward policy `drop` without allow rules; output policy `accept`. + +The policy lives in the Ghostnet-owned table `inet ghostnet_filter`. The table +is replaced atomically (`destroy table` + re-add) and a systemd drop-in keeps +`nftables.service` from destroying Netavark's dynamic tables on stop, matching +the CT 102 Keycloak pattern. The rendered ruleset is validated with +`nft --check` before it is installed, and the active table is verified after +the service restart. + +Before the policy is installed, the role verifies that the Ansible SSH +connection originates from the management allowlist, so a converged firewall +can never lock out the controlling session. + +SMB is deliberately not opened towards the administration VPN +(`10.8.0.0/24`); media clients live in the LAN. Widening the SMB allowlist is +an explicit operator decision (see GNAI-019). diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/defaults/main.yml new file mode 100644 index 0000000..4389597 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/defaults/main.yml @@ -0,0 +1,13 @@ +--- +samba_firewall_management_ipv4: + - 10.0.0.0/24 +samba_firewall_ssh_port: 22 +samba_firewall_smb_tcp_ports: + - 139 + - 445 +samba_firewall_smb_udp_ports: + - 137 + - 138 +samba_firewall_nfs_client_ipv4: 10.0.0.90 +samba_firewall_nfs_ports: + - 2049 diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/handlers/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/handlers/main.yml new file mode 100644 index 0000000..e5029a0 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Reload systemd for the Samba firewall + ansible.builtin.systemd_service: + daemon_reload: true + +- name: Restart the Samba firewall + ansible.builtin.systemd_service: + name: nftables.service + enabled: true + state: restarted diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/meta/argument_specs.yml new file mode 100644 index 0000000..6937a18 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/meta/argument_specs.yml @@ -0,0 +1,27 @@ +--- +argument_specs: + main: + short_description: Manage the CT 101 Samba nftables firewall + options: + samba_firewall_management_ipv4: + type: list + elements: str + required: true + samba_firewall_ssh_port: + type: int + required: true + samba_firewall_smb_tcp_ports: + type: list + elements: int + required: true + samba_firewall_smb_udp_ports: + type: list + elements: int + required: true + samba_firewall_nfs_client_ipv4: + type: str + required: true + samba_firewall_nfs_ports: + type: list + elements: int + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/tasks/main.yml new file mode 100644 index 0000000..905dbd5 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/tasks/main.yml @@ -0,0 +1,95 @@ +--- +- name: Require explicit non-empty firewall allowlists + ansible.builtin.assert: + that: + - samba_firewall_management_ipv4 | length > 0 + - samba_firewall_smb_tcp_ports | length > 0 + - samba_firewall_smb_udp_ports | length > 0 + - samba_firewall_nfs_client_ipv4 | length > 0 + - samba_firewall_nfs_ports | length > 0 + fail_msg: The firewall allowlists must be explicit and non-empty. + +- name: Read the active Ansible SSH transport endpoints + become: false + ansible.builtin.command: + argv: + - /usr/bin/printenv + - SSH_CONNECTION + check_mode: false + changed_when: false + register: samba_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)', + samba_firewall_ssh_connection.stdout.split()[0] + ] + samba_firewall_management_ipv4 + }} + check_mode: false + changed_when: false + vars: + ansible_command_timeout: 5 + +- name: Create the nftables service override directory + ansible.builtin.file: + path: /etc/systemd/system/nftables.service.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Install the scoped nftables stop override + ansible.builtin.template: + src: nftables.service-override.conf.j2 + dest: /etc/systemd/system/nftables.service.d/10-ghostnet-preserve-netavark.conf + owner: root + group: root + mode: "0644" + notify: + - Reload systemd for the Samba firewall + - Restart the Samba firewall + +- name: Install and validate the Samba nftables policy + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: "0750" + validate: /usr/sbin/nft --check --file %s + notify: Restart the Samba firewall + +- name: Flush firewall handlers before verification + ansible.builtin.meta: flush_handlers + +- name: Read the active Ghostnet nftables table + ansible.builtin.command: + argv: + - /usr/sbin/nft + - list + - table + - inet + - ghostnet_filter + check_mode: false + changed_when: false + register: samba_firewall_active_table + when: not ansible_check_mode + +- name: Verify the active firewall policy and service + ansible.builtin.assert: + that: + - samba_firewall_active_table.stdout is search('policy drop') + - samba_firewall_active_table.stdout is search('SSH management') + - samba_firewall_active_table.stdout is search('SMB over TCP from the LAN') + - samba_firewall_active_table.stdout is search('NFSv4 from ghostnet-ki') + - samba_firewall_active_table.stdout is search(samba_firewall_nfs_client_ipv4) + fail_msg: >- + The active nftables policy does not match the declared CT 101 Samba + baseline (input/forward drop, SSH+SMB from the LAN, NFS only from + ghostnet-ki). + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.conf.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.conf.j2 new file mode 100644 index 0000000..6276030 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.conf.j2 @@ -0,0 +1,49 @@ +#!/usr/sbin/nft -f + +# Managed by Ansible role samba_firewall. +# Replace only the Ghostnet-owned table. Netavark manages separate tables. +destroy table inet ghostnet_filter + +table inet ghostnet_filter { + set management_ipv4 { + type ipv4_addr + flags interval + elements = { {{ samba_firewall_management_ipv4 | join(', ') }} } + } + + chain input { + type filter hook input priority 10; policy drop; + + iifname "lo" counter accept comment "local loopback" + ct state established,related counter accept + ct state invalid counter drop + + meta l4proto icmp counter accept comment "IPv4 diagnostics" + meta l4proto ipv6-icmp counter accept comment "IPv6 neighbor discovery" + + ip saddr @management_ipv4 tcp dport {{ samba_firewall_ssh_port }} ct state new counter accept comment "SSH management" + + ip saddr @management_ipv4 tcp dport { {{ samba_firewall_smb_tcp_ports | join(', ') }} } ct state new counter accept comment "SMB over TCP from the LAN" + ip saddr @management_ipv4 udp dport { {{ samba_firewall_smb_udp_ports | join(', ') }} } counter accept comment "SMB name service from the LAN" + + ip saddr {{ samba_firewall_nfs_client_ipv4 }} tcp dport { {{ samba_firewall_nfs_ports | join(', ') }} } ct state new counter accept comment "NFSv4 from ghostnet-ki" + ip saddr {{ samba_firewall_nfs_client_ipv4 }} udp dport { {{ samba_firewall_nfs_ports | join(', ') }} } counter accept comment "NFSv4 datagrams from ghostnet-ki" + + counter drop comment "default input deny" + } + + chain forward { + type filter hook forward priority 10; policy drop; + + ct state established,related counter accept + ct state invalid counter drop + + counter drop comment "default forward deny" + } + + chain output { + type filter hook output priority 10; policy accept; + } +} + +{{ '' }} diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.service-override.conf.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.service-override.conf.j2 new file mode 100644 index 0000000..ccc0e78 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_firewall/templates/nftables.service-override.conf.j2 @@ -0,0 +1,5 @@ +[Service] +# Preserve Netavark's dynamic tables when nftables.service is stopped. +ExecStop= +ExecStop=/usr/sbin/nft destroy table inet ghostnet_filter +{{ '\n' }} diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/README.md new file mode 100644 index 0000000..e838c3e --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/README.md @@ -0,0 +1,15 @@ +# `samba_lxc_baseline` + +Declaratively manages the CT 101 Samba LXC baseline: guest identity +(hostname, Debian 13, `10.0.0.9`, LXC guest, systemd), the unprivileged +ID-mapping premise, baseline administration packages, the `Europe/Berlin` +timezone and the protected Ghostnet configuration/data/backup directories. + +The role refuses to run while the inventory still contains +`REPLACE-WITH-*` placeholders; `playbooks/preflight.yml` on the `agent` +inventory reports the values to copy into the selected inventory. + +The unprivileged assertion is load-bearing: the NFS-Ganesha export and the +`gn-media` UID/GID alignment with `ghostnet-ki` (GNAI-019) assume the standard +Proxmox ID mapping. A privileged conversion of CT 101 changes the on-disk +UID/GID semantics of the media library and must not happen silently. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/defaults/main.yml new file mode 100644 index 0000000..ee226b8 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/defaults/main.yml @@ -0,0 +1,53 @@ +--- +samba_lxc_baseline_expected_hostname: samba +samba_lxc_baseline_expected_distribution: Debian +samba_lxc_baseline_expected_distribution_major_version: "13" +samba_lxc_baseline_expected_ipv4: 10.0.0.9 +samba_lxc_baseline_expected_virtualization_type: lxc +samba_lxc_baseline_timezone: Europe/Berlin + +samba_lxc_baseline_packages: + - ca-certificates + - curl + - jq + - nftables + - openssl + - unattended-upgrades + +samba_lxc_baseline_directories: + - path: /etc/ghostnet + owner: root + group: root + mode: "0755" + - path: /etc/ghostnet/samba + owner: root + group: root + mode: "0700" + - path: /etc/ghostnet/samba/ganesha + owner: root + group: root + mode: "0700" + - path: /var/lib/ghostnet + owner: root + group: root + mode: "0755" + - path: /var/lib/ghostnet/samba + owner: root + group: root + mode: "0700" + - path: /var/lib/ghostnet/samba/build + owner: root + group: root + mode: "0700" + - path: /var/lib/ghostnet/samba/ganesha + owner: root + group: root + mode: "0700" + - path: /var/backups/ghostnet + owner: root + group: root + mode: "0700" + - path: /var/backups/ghostnet/samba + owner: root + group: root + mode: "0700" diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/handlers/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/meta/argument_specs.yml new file mode 100644 index 0000000..c16e44b --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/meta/argument_specs.yml @@ -0,0 +1,31 @@ +--- +argument_specs: + main: + short_description: Manage the CT 101 Samba LXC baseline + options: + samba_lxc_baseline_expected_hostname: + type: str + required: true + samba_lxc_baseline_expected_distribution: + type: str + required: true + samba_lxc_baseline_expected_distribution_major_version: + type: str + required: true + samba_lxc_baseline_expected_ipv4: + type: str + required: true + samba_lxc_baseline_expected_virtualization_type: + type: str + required: true + samba_lxc_baseline_timezone: + type: str + required: true + samba_lxc_baseline_packages: + type: list + elements: str + required: true + samba_lxc_baseline_directories: + type: list + elements: dict + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/tasks/main.yml new file mode 100644 index 0000000..6a37d7c --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_lxc_baseline/tasks/main.yml @@ -0,0 +1,93 @@ +--- +- name: Require the inventory placeholders to be replaced before converging + ansible.builtin.assert: + that: + - samba_expected_hostname is not match('^REPLACE-') + - samba_minimum_root_size_bytes is not match('^REPLACE-') + - samba_minimum_root_available_bytes is not match('^REPLACE-') + - samba_expected_processor_vcpus is not match('^REPLACE-') + - samba_minimum_memory_mb is not match('^REPLACE-') + - samba_access_admin_uid is not match('^REPLACE-') + - samba_access_agent_uid is not match('^REPLACE-') + - samba_access_deploy_uid is not match('^REPLACE-') + fail_msg: >- + The inventory still contains REPLACE-WITH placeholders. Run + playbooks/preflight.yml on the agent inventory and copy the reported + values into the selected inventory before converging. + +- name: Verify the CT 101 Samba LXC identity before changing baseline state + ansible.builtin.assert: + that: + - ansible_facts["hostname"] == samba_lxc_baseline_expected_hostname + - >- + ansible_facts["distribution"] == + samba_lxc_baseline_expected_distribution + - >- + ansible_facts["distribution_major_version"] == + samba_lxc_baseline_expected_distribution_major_version + - >- + ansible_facts["default_ipv4"]["address"] == + samba_lxc_baseline_expected_ipv4 + - >- + ansible_facts["virtualization_type"] == + samba_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 101 Samba LXC. No baseline + changes may be applied. + +- name: Read the guest user-namespace ID mapping + ansible.builtin.command: + argv: + - /usr/bin/cat + - /proc/self/uid_map + check_mode: false + changed_when: false + register: samba_lxc_baseline_uid_map + +- name: Require the unprivileged LXC identity mapping + ansible.builtin.assert: + that: + - samba_lxc_baseline_uid_map.stdout_lines | length > 0 + - samba_lxc_baseline_uid_map.stdout_lines[0] != "0 0 4294967295" + fail_msg: >- + CT 101 must stay an unprivileged LXC. The NFS-Ganesha export design + (GNAI-019) depends on the standard Proxmox ID mapping; a privileged + conversion changes the UID/GID semantics of the media library. + +- name: Install baseline administration packages + ansible.builtin.apt: + name: "{{ samba_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/{{ samba_lxc_baseline_timezone }}" + dest: /etc/localtime + state: link + owner: root + group: root + force: true + +- name: Record the configured timezone + ansible.builtin.copy: + content: "{{ samba_lxc_baseline_timezone }}\n" + dest: /etc/timezone + owner: root + group: root + mode: "0644" + +- name: Create protected Samba configuration and data paths + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.group }}" + mode: "{{ item.mode }}" + loop: "{{ samba_lxc_baseline_directories }}" + loop_control: + label: "{{ item.path }}" diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/README.md new file mode 100644 index 0000000..52ce282 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/README.md @@ -0,0 +1,28 @@ +# `samba_media` + +Declaratively manages the CT 101 media prerequisites for the Samba shares and +the NFS export (GNAI-018 R1/R3): + +- Verifies that all declared media mount points (`/media/daten1`, + `/media/daten2`, `/media/daten3`, `/media/datenhdd2`, `/media/datenhdd3`) + are actually mounted. Mount facts are gathered on demand when the playbook + fact subset omits them. +- Validates the productive media accounts `ghost`, `daidalaw` and `debugofen` + against their recorded UIDs (`samba_media_users`; the `REPLACE-WITH-*` UID + placeholders fail closed in the argument spec before any task runs). + Existing accounts are only validated, never modified. On a fresh-state + rebuild, missing accounts are created with exactly the declared UIDs + (primary group `gid=uid`, `password_lock: true`; shell/home/create_home come + from the inventory), so the playbooks rebuild CT 101 from a fresh state to + exactly this state (GNAI-019 decisions 4 and 9). +- Creates the group `gn-media` with the pipeline GID **970** — the numeric + identity must match `ghostnet-ki` (GNENC-002). The role fails closed when + the GID slot or the group name is occupied by a different identity. +- Adds `ghost` to `gn-media` (required for the `mv` pickup from `[Encode]` and + the `.dlc` drop into `[Encode-In]`, because the pipeline directories carry + group `gn-media` with mode 2770). `daidalaw`/`debugofen` deliberately get no + membership (read-only world access is sufficient, GNAI-018 R3). + +Post-state verification (converged group entry, effective memberships) runs +on real executions and on check-mode reruns of an already converged host; +on a first check-mode run the mutations are only previewed. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/defaults/main.yml new file mode 100644 index 0000000..2badf25 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/defaults/main.yml @@ -0,0 +1,32 @@ +--- +samba_media_group_name: gn-media +samba_media_group_gid: 970 +# Media accounts with their productive UIDs (source: preflight.yml / +# audit-production.yml). shell/home/create_home are only used when creating +# missing accounts (fresh-state rebuild); existing accounts are only validated +# on the UID and never modified. The UID placeholders stop the role +# validation fail-closed until the live values are entered. +samba_media_users: + - name: ghost + uid: REPLACE-WITH-AUDIT-UID-GHOST + shell: /bin/bash + home: /home/ghost + create_home: true + - name: daidalaw + uid: REPLACE-WITH-AUDIT-UID-DAIDALAW + shell: /usr/sbin/nologin + home: /home/daidalaw + create_home: false + - name: debugofen + uid: REPLACE-WITH-AUDIT-UID-DEBUGOFEN + shell: /usr/sbin/nologin + home: /home/debugofen + create_home: false +samba_media_group_members: + - ghost +samba_media_mount_points: + - /media/daten1 + - /media/daten2 + - /media/daten3 + - /media/datenhdd2 + - /media/datenhdd3 diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/meta/argument_specs.yml new file mode 100644 index 0000000..b8a27ce --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/meta/argument_specs.yml @@ -0,0 +1,39 @@ +--- +argument_specs: + main: + short_description: Manage the CT 101 media accounts, group, members and mount prerequisites + options: + samba_media_group_name: + type: str + required: true + samba_media_group_gid: + type: int + required: true + samba_media_users: + type: list + elements: dict + required: true + options: + name: + type: str + required: true + uid: + type: int + required: true + shell: + type: str + required: true + home: + type: str + required: true + create_home: + type: bool + required: true + samba_media_group_members: + type: list + elements: str + required: true + samba_media_mount_points: + type: list + elements: str + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/tasks/main.yml new file mode 100644 index 0000000..f634f95 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_media/tasks/main.yml @@ -0,0 +1,316 @@ +--- +- name: Require explicit media group, user and mount point declarations + ansible.builtin.assert: + that: + - samba_media_group_name | length > 0 + - samba_media_users | length > 0 + - samba_media_group_members | length > 0 + - samba_media_mount_points | length > 0 + fail_msg: >- + The media group, member, user and mount point declarations must be + explicit and non-empty. + +- name: Gather the mount facts when the playbook subset omits them + ansible.builtin.setup: + gather_subset: + - "!all" + - "!min" + - mounts + when: ansible_facts["mounts"] is not defined + check_mode: false + changed_when: false + +- name: Verify the declared media mount points are mounted + ansible.builtin.assert: + that: + - >- + ansible_facts["mounts"] | map(attribute="mount") | list is + superset(samba_media_mount_points) + fail_msg: >- + Not all declared media mount points are mounted on CT 101. The Samba + shares and the NFS export depend on the complete media library. + +- name: Read the existing media accounts + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item.name }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + register: samba_media_account_probes + +- name: Probe the declared media UID slots in the account database + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item.uid | string }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }} ({{ item.uid }})" + register: samba_media_uid_slot_probes + +- name: Probe the declared media UID slots in the group database + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ item.uid | string }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }} ({{ item.uid }})" + register: samba_media_gid_slot_probes + +- name: Probe the declared media group names + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ item.name }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + register: samba_media_group_name_slot_probes + +- name: Determine the media account states + ansible.builtin.set_fact: + samba_media_account_states: >- + {{ samba_media_account_states | default([]) + [{ + 'name': item.name, + 'uid': item.uid | int, + 'shell': item.shell, + 'home': item.home, + 'create_home': item.create_home, + 'exists': samba_media_account_probes.results[account_index].rc == 0, + 'account_uid': (samba_media_account_probes.results[account_index].stdout.split(':')[2] | int) if samba_media_account_probes.results[account_index].rc == 0 else -1, + 'uid_slot_free': samba_media_uid_slot_probes.results[account_index].rc == 2, + 'gid_slot_free': samba_media_gid_slot_probes.results[account_index].rc == 2, + 'group_name_free': samba_media_group_name_slot_probes.results[account_index].rc == 2, + }] }} + loop: "{{ samba_media_users }}" + loop_control: + label: "{{ item.name }}" + index_var: account_index + +- name: Verify the declared media account identities + ansible.builtin.assert: + that: + - >- + (item.exists and item.account_uid == item.uid) or + (not item.exists and item.uid_slot_free and item.gid_slot_free and + item.group_name_free) + fail_msg: >- + The media account {{ item.name }} does not match its declaration. An + existing account must keep the recorded UID (copy the UID reported by + preflight.yml or audit-production.yml into the inventory); a missing + account needs the declared UID/GID slots to be free so that a rebuild + can create it with the recorded identity (GNAI-018 R1). + loop: "{{ samba_media_account_states }}" + loop_control: + label: "{{ item.name }}" + +- name: Create the fixed primary groups for missing media accounts + ansible.builtin.group: + name: "{{ item.name }}" + gid: "{{ item.uid }}" + state: present + loop: "{{ samba_media_account_states }}" + loop_control: + label: "{{ item.name }}" + when: not item.exists + register: samba_media_primary_group_tasks + +- name: Create missing media accounts with their recorded identities + ansible.builtin.user: + name: "{{ item.name }}" + uid: "{{ item.uid }}" + group: "{{ item.name }}" + home: "{{ item.home }}" + shell: "{{ item.shell }}" + create_home: "{{ item.create_home }}" + password_lock: true + state: present + loop: "{{ samba_media_account_states }}" + loop_control: + label: "{{ item.name }}" + when: not item.exists + register: samba_media_account_tasks + +- name: Record whether media account mutations were required + ansible.builtin.set_fact: + samba_media_account_creation_required: >- + {{ (samba_media_primary_group_tasks.results | selectattr('changed') | list | length > 0) or + (samba_media_account_tasks.results | selectattr('changed') | list | length > 0) }} + +- name: Read the converged media accounts + ansible.builtin.command: + argv: + - /usr/bin/getent + - passwd + - "{{ item.name }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_account_states }}" + loop_control: + label: "{{ item.name }}" + register: samba_media_converged_accounts + +- name: Verify the converged media accounts carry the recorded UIDs + ansible.builtin.assert: + that: + - samba_media_converged_accounts.results[account_index].rc == 0 + - >- + samba_media_converged_accounts.results[account_index].stdout.split(':')[2] + | int == item.uid + fail_msg: >- + The media account {{ item.name }} does not carry the recorded UID + {{ item.uid }} after the convergence. + loop: "{{ samba_media_account_states }}" + loop_control: + label: "{{ item.name }}" + index_var: account_index + when: >- + not ansible_check_mode or + not samba_media_account_creation_required | bool + +- name: Probe the declared media group by name + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_name }}" + check_mode: false + changed_when: false + failed_when: false + register: samba_media_group_name_probe + +- name: Probe the declared media GID slot + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_gid | string }}" + check_mode: false + changed_when: false + failed_when: false + register: samba_media_gid_slot + +- name: Determine the media group state + ansible.builtin.set_fact: + samba_media_group_exists: >- + {{ samba_media_group_name_probe.rc == 0 }} + samba_media_gid_occupied: >- + {{ samba_media_gid_slot.rc == 0 }} + samba_media_gid_slot_name: >- + {{ samba_media_gid_slot.stdout.split(':')[0] if samba_media_gid_slot.rc == 0 else '' }} + +- name: Require a consistent media group identity + ansible.builtin.assert: + that: + - >- + not samba_media_group_exists | bool or + samba_media_group_name_probe.stdout.split(':')[2] | int == + samba_media_group_gid | int + - >- + not samba_media_gid_occupied | bool or + samba_media_gid_slot_name == samba_media_group_name + fail_msg: >- + The GID {{ samba_media_group_gid }} or the group name + {{ samba_media_group_name }} is occupied by a different identity. The + encoding pipeline requires GID {{ samba_media_group_gid }} to mean + {{ samba_media_group_name }} on CT 101 and ghostnet-ki alike; resolve + the collision deliberately. + +- name: Create the media group with the pipeline GID + ansible.builtin.group: + name: "{{ samba_media_group_name }}" + gid: "{{ samba_media_group_gid }}" + state: present + register: samba_media_group_task + +- name: Add the declared members to the media group + ansible.builtin.user: + name: "{{ item }}" + groups: "{{ samba_media_group_name }}" + append: true + loop: "{{ samba_media_group_members }}" + loop_control: + label: "{{ item }}" + when: >- + not ansible_check_mode or not samba_media_group_task.changed | bool + register: samba_media_membership_task + +- name: Record whether the media group mutations were required + ansible.builtin.set_fact: + samba_media_mutation_required: >- + {{ samba_media_group_task.changed | bool or + (samba_media_membership_task.results | selectattr('changed') | list | length > 0) }} + +- name: Read the converged media group entry + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_name }}" + check_mode: false + changed_when: false + register: samba_media_group_entry + when: not ansible_check_mode + +- name: Verify the converged media group identity + ansible.builtin.assert: + that: + - samba_media_group_entry.rc == 0 + - >- + samba_media_group_entry.stdout.split(':')[0] == + samba_media_group_name + - >- + samba_media_group_entry.stdout.split(':')[2] | int == + samba_media_group_gid | int + fail_msg: The media group does not carry the pipeline GID. + when: not ansible_check_mode + +- name: Read the effective media group memberships + ansible.builtin.command: + argv: + - /usr/bin/id + - -Gn + - "{{ item }}" + check_mode: false + changed_when: false + failed_when: false + loop: "{{ samba_media_group_members }}" + loop_control: + label: "{{ item }}" + register: samba_media_memberships + +- name: Verify the declared media group memberships + ansible.builtin.assert: + that: + - samba_media_group_name in item.stdout.split() + fail_msg: >- + {{ item.item }} is not a member of {{ samba_media_group_name }}. The + membership is required for the mv pickup from [Encode] and the .dlc + drop into [Encode-In] (GNAI-018 R3). + loop: "{{ samba_media_memberships.results }}" + loop_control: + label: "{{ item.item }}" + when: >- + not ansible_check_mode or + (not samba_media_mutation_required | bool and + not samba_media_account_creation_required | bool) diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/README.md new file mode 100644 index 0000000..6d76327 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/README.md @@ -0,0 +1,44 @@ +# `samba_nfs` + +Declaratively manages the NFS export of `/media/datenhdd3/Encoding` for the +encoding pipeline on `ghostnet-ki` (GNAI-018 R1, implemented with NFS-Ganesha +instead of `nfs-kernel-server` — see GNAI-019 decision 2 for the evidence: +the kernel nfsd cannot run in an unprivileged LXC). + +- Creates the export root `/media/datenhdd3/Encoding` as `root:gn-media 2775` + (approved deviation from R1's `root:root 0755`: the pipeline creates the + top-level directories via NFS as UID 982/983 and needs group-write plus + setgid on the export root, GNAI-017 section 4.4). The `gn-media` group + (GID 970) must exist first — the role fails closed otherwise. +- Verifies the reviewed `Containerfile.ganesha` checksum on the control node + before anything is built, installs the build context + (`0600`) and the healthcheck helper (`0555`). +- Pulls the digest-pinned Debian 13 base image, builds + `localhost/ghostnet/ganesha:trixie-ansible1` with `--pull=never + --network=host --timestamp=0` and a `containerfile-sha256` label, and + rebuilds only when the image is missing or the label diverges. +- Installs the fully templated `ganesha.conf` (`0600`): NFSv4.2 only, + AUTH_SYS, `Squash = rootid` (standard squash stays, R1), client-restricted + to `10.0.0.90`, `Pseudo` identical to the export path so the documented + mount path `10.0.0.9:/media/datenhdd3/Encoding` is unchanged. +- Installs the Quadlet `ghostnet-samba-nfs.container`: **rootful** (the VFS + FSAL applies the AUTH_SYS credentials per request, which needs CAP_SETUID), + **host networking** (listener 2049 in the CT namespace, covered by the + `samba_firewall` input chain), `Requires=nftables.service` (fail closed), + read-only rootfs with tmpfs `/run`+`​/tmp`, journald logging, conmon notify, + TCP healthcheck against 2049. +- Enables and starts `ghostnet-samba-nfs.service`, verifies the generated + boot link, waits for the 2049 listener and asserts the container is + running, healthy and `Config.User == "0:0"`. + +Ganesha has no config-test flag that is safe to rely on, so the configuration +syntax is verified by the service start and the health/until-loop asserts; +on a crash-looping container, `podman logs ghostnet-samba-nfs` shows the +ganesha parser error. Ganesha without dbus in the container logs a non-fatal +admin-interface warning (documented in GNAI-019). + +Prerequisites on the Proxmox host (operator, before the first converge): +the LXC seccomp profile must allow `open_by_handle_at` — an empty +`lxc.seccomp.profile:` line in `/etc/pve/lxc/101.conf` plus a CT reboot. The +one-shot `scripts/upgrade-ct101-to-debian13.sh` performs both the Debian 13 +dist-upgrade and this prerequisite. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/defaults/main.yml new file mode 100644 index 0000000..faf327e --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/defaults/main.yml @@ -0,0 +1,18 @@ +--- +samba_nfs_artifact_source_root: "{{ playbook_dir }}/.." +samba_nfs_quadlet_directory: /etc/containers/systemd +samba_nfs_build_directory: /var/lib/ghostnet/samba/build +samba_nfs_config_directory: /etc/ghostnet/samba/ganesha +samba_nfs_state_directory: /var/lib/ghostnet/samba/ganesha +# Reviewed checksum of ../Containerfile.ganesha (sha256sum, 2026-10-20): +samba_nfs_containerfile_sha256: >- + 0fdbe6b5fe542f22ed291c4cabe505e0da472024f0186e2414fd002b751f784f +samba_nfs_source_image: >- + docker.io/library/debian:trixie-slim@sha256:918311b7b6c4c6f68b232ba516584925f6c78ad82b6fd534b98979df6438e483 +samba_nfs_runtime_tag: localhost/ghostnet/ganesha:trixie-ansible1 +samba_nfs_expected_architecture: amd64 +samba_nfs_container_name: ghostnet-samba-nfs +samba_nfs_service_unit: ghostnet-samba-nfs.service +samba_nfs_export_path: /media/datenhdd3/Encoding +samba_nfs_client_ipv4: 10.0.0.90 +samba_nfs_export_id: 101 diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/handlers/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/handlers/main.yml new file mode 100644 index 0000000..3e351c6 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd for the Ganesha Quadlet + ansible.builtin.systemd_service: + daemon_reload: true + +- name: Restart the Ganesha NFS export + ansible.builtin.systemd_service: + name: ghostnet-samba-nfs.service + state: restarted diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/meta/argument_specs.yml new file mode 100644 index 0000000..cb7d7bd --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/meta/argument_specs.yml @@ -0,0 +1,47 @@ +--- +argument_specs: + main: + short_description: Build and run the CT 101 NFS-Ganesha export container + options: + samba_nfs_artifact_source_root: + type: path + required: true + samba_nfs_quadlet_directory: + type: path + required: true + samba_nfs_build_directory: + type: path + required: true + samba_nfs_config_directory: + type: path + required: true + samba_nfs_state_directory: + type: path + required: true + samba_nfs_containerfile_sha256: + type: str + required: true + samba_nfs_source_image: + type: str + required: true + samba_nfs_runtime_tag: + type: str + required: true + samba_nfs_expected_architecture: + type: str + required: true + samba_nfs_container_name: + type: str + required: true + samba_nfs_service_unit: + type: str + required: true + samba_nfs_export_path: + type: path + required: true + samba_nfs_client_ipv4: + type: str + required: true + samba_nfs_export_id: + type: int + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/tasks/main.yml new file mode 100644 index 0000000..9298f63 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/tasks/main.yml @@ -0,0 +1,294 @@ +--- +- name: Require the NFS export declarations + ansible.builtin.assert: + that: + - samba_nfs_export_path | length > 0 + - samba_nfs_client_ipv4 | length > 0 + - samba_nfs_export_id | int > 0 + - samba_nfs_source_image | length > 0 + - samba_nfs_runtime_tag | length > 0 + - samba_nfs_container_name | length > 0 + - samba_nfs_service_unit | length > 0 + fail_msg: The NFS export declarations must be explicit and non-empty. + +- name: Require the media group before creating the export root + ansible.builtin.command: + argv: + - /usr/bin/getent + - group + - "{{ samba_media_group_name }}" + check_mode: false + changed_when: false + register: samba_nfs_media_group + +- name: Fail closed when the media group is missing + ansible.builtin.assert: + that: + - samba_nfs_media_group.rc == 0 + - >- + samba_nfs_media_group.stdout.split(':')[2] | int == + samba_media_group_gid | int + fail_msg: >- + The group {{ samba_media_group_name }} (GID {{ samba_media_group_gid }}) + is missing or carries a different GID. Run the samba_media role first; + the export root must be group-writable for the pipeline identities. + +- name: Create the NFS export root with the pipeline group + ansible.builtin.file: + path: "{{ samba_nfs_export_path }}" + state: directory + owner: root + group: "{{ samba_media_group_name }}" + mode: "2775" + +- name: Check the reviewed Containerfile checksum on the control node + become: false + ansible.builtin.stat: + path: "{{ samba_nfs_artifact_source_root }}/Containerfile.ganesha" + checksum_algorithm: sha256 + delegate_to: localhost + register: samba_nfs_source_containerfile + +- name: Require the pinned reviewed Containerfile + ansible.builtin.assert: + that: + - samba_nfs_source_containerfile.stat.exists + - >- + samba_nfs_source_containerfile.stat.checksum == + samba_nfs_containerfile_sha256 + fail_msg: The Ganesha Containerfile differs from its reviewed checksum. + +- name: Install the dedicated Ganesha build context + ansible.builtin.copy: + src: "{{ samba_nfs_artifact_source_root }}/Containerfile.ganesha" + dest: "{{ samba_nfs_build_directory }}/Containerfile.ganesha" + owner: root + group: root + mode: "0600" + +- name: Install the Ganesha healthcheck helper + ansible.builtin.copy: + src: "{{ samba_nfs_artifact_source_root }}/ghostnet-ganesha-healthcheck" + dest: "{{ samba_nfs_config_directory }}/ghostnet-ganesha-healthcheck" + owner: root + group: root + mode: "0555" + +- name: Check the pinned Ganesha source image + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - exists + - "{{ samba_nfs_source_image }}" + check_mode: false + changed_when: false + failed_when: false + register: samba_nfs_source_image_exists + +- name: Pull the pinned Ganesha source image + ansible.builtin.command: + argv: + - /usr/bin/podman + - pull + - --platform + - "linux/{{ samba_nfs_expected_architecture }}" + - "{{ samba_nfs_source_image }}" + changed_when: true + when: + - not ansible_check_mode + - samba_nfs_source_image_exists.rc != 0 + +- name: Check the local optimized Ganesha image + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - exists + - "{{ samba_nfs_runtime_tag }}" + check_mode: false + changed_when: false + failed_when: false + register: samba_nfs_runtime_image_exists + +- name: Inspect the existing optimized Ganesha image + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - inspect + - --format=json + - "{{ samba_nfs_runtime_tag }}" + check_mode: false + changed_when: false + register: samba_nfs_existing_runtime_inspect + when: samba_nfs_runtime_image_exists.rc == 0 + +- name: Decide whether the optimized Ganesha image needs rebuilding + ansible.builtin.set_fact: + samba_nfs_rebuild_required: >- + {{ + samba_nfs_runtime_image_exists.rc != 0 or + ( + ( + (samba_nfs_existing_runtime_inspect.stdout | from_json)[0] + ).Config.Labels['eu.selfhost.ghostnet.containerfile-sha256'] | + default('') + ) != samba_nfs_containerfile_sha256 + }} + +- name: Build the optimized Ganesha image from pinned local inputs + ansible.builtin.command: + argv: + - /usr/bin/podman + - build + - --pull=never + - --network=host + - --timestamp=0 + - --label + - >- + eu.selfhost.ghostnet.containerfile-sha256={{ samba_nfs_containerfile_sha256 }} + - --tag + - "{{ samba_nfs_runtime_tag }}" + - --file + - "{{ samba_nfs_build_directory }}/Containerfile.ganesha" + - "{{ samba_nfs_build_directory }}" + changed_when: true + when: + - not ansible_check_mode + - samba_nfs_rebuild_required + +- name: Inspect the selected optimized Ganesha image + ansible.builtin.command: + argv: + - /usr/bin/podman + - image + - inspect + - --format=json + - "{{ samba_nfs_runtime_tag }}" + check_mode: false + changed_when: false + register: samba_nfs_runtime_inspect + when: + - not ansible_check_mode or samba_nfs_runtime_image_exists.rc == 0 + +- name: Select the immutable runtime image ID + ansible.builtin.set_fact: + samba_nfs_runtime_image_id: >- + {{ (samba_nfs_runtime_inspect.stdout | from_json)[0].Id }} + when: + - not ansible_check_mode or samba_nfs_runtime_image_exists.rc == 0 + +- name: Use a non-runnable placeholder image ID only for an empty check-mode target + ansible.builtin.set_fact: + samba_nfs_runtime_image_id: >- + sha256:0000000000000000000000000000000000000000000000000000000000000000 + when: + - ansible_check_mode + - samba_nfs_runtime_image_exists.rc != 0 + +- name: Verify the optimized Ganesha image + ansible.builtin.assert: + that: + - >- + (samba_nfs_runtime_inspect.stdout | from_json)[0].Architecture == + samba_nfs_expected_architecture + - (samba_nfs_runtime_inspect.stdout | from_json)[0].Os == "linux" + - >- + ( + (samba_nfs_runtime_inspect.stdout | from_json)[0] + ).Config.Labels['eu.selfhost.ghostnet.containerfile-sha256'] == + samba_nfs_containerfile_sha256 + when: + - not ansible_check_mode or samba_nfs_runtime_image_exists.rc == 0 + +- name: Install the Ganesha export configuration + ansible.builtin.template: + src: ganesha.conf.j2 + dest: "{{ samba_nfs_config_directory }}/ganesha.conf" + owner: root + group: root + mode: "0600" + notify: Restart the Ganesha NFS export + +- name: Install the Ganesha NFS export Quadlet + ansible.builtin.template: + src: ghostnet-samba-nfs.container.j2 + dest: "{{ samba_nfs_quadlet_directory }}/ghostnet-samba-nfs.container" + owner: root + group: root + mode: "0644" + notify: Reload systemd for the Ganesha Quadlet + +- name: Flush the Ganesha handlers before state management + ansible.builtin.meta: flush_handlers + +- name: Enable and start the Ganesha NFS export service + ansible.builtin.systemd_service: + name: "{{ samba_nfs_service_unit }}" + enabled: true + state: started + when: not ansible_check_mode + +- name: Check the generated Ganesha boot target link + ansible.builtin.stat: + path: >- + /run/systemd/generator/multi-user.target.wants/{{ samba_nfs_service_unit }} + follow: false + register: samba_nfs_boot_link + when: not ansible_check_mode + +- name: Verify the declared Ganesha boot policy + ansible.builtin.assert: + that: + - samba_nfs_boot_link.stat.exists + - samba_nfs_boot_link.stat.islnk + fail_msg: >- + The Ganesha NFS export service is not linked into the generated + multi-user target; the export would not survive a reboot. + when: not ansible_check_mode + +- name: Wait for the NFSv4 listener + ansible.builtin.command: + argv: + - /usr/bin/ss + - -H + - -lnt + - ( sport = :2049 ) + check_mode: false + changed_when: false + register: samba_nfs_listener + until: samba_nfs_listener.stdout | length > 0 + retries: 12 + delay: 5 + when: not ansible_check_mode + +- name: Inspect the Ganesha NFS export container + ansible.builtin.command: + argv: + - /usr/bin/podman + - inspect + - --format=json + - "{{ samba_nfs_container_name }}" + check_mode: false + changed_when: false + register: samba_nfs_container_inspect + when: not ansible_check_mode + +- name: Verify the Ganesha NFS export container is healthy and rootful + ansible.builtin.assert: + that: + - >- + (samba_nfs_container_inspect.stdout | from_json)[0].State.Status == + "running" + - >- + (samba_nfs_container_inspect.stdout | from_json)[0].State.Health.Status == + "healthy" + - >- + (samba_nfs_container_inspect.stdout | from_json)[0].Config.User == + "0:0" + fail_msg: >- + The Ganesha container is not running and healthy as root. Check + 'podman logs {{ samba_nfs_container_name }}' for ganesha configuration + or startup errors. + when: not ansible_check_mode diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ganesha.conf.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ganesha.conf.j2 new file mode 100644 index 0000000..07c3dc7 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ganesha.conf.j2 @@ -0,0 +1,32 @@ +# Managed by Ansible role samba_nfs (CT 101, GNAI-017/GNAI-018, GNAI-019). +# Userspace NFSv4.2 export for the encoding pipeline on ghostnet-ki. +# NFSv4 only: no mountd/rpcbind, therefore no `showmount` (see GNAI-019); +# the authoritative check is a real NFSv4.2 mount from ghostnet-ki. +NFS_CORE_PARAM { + Protocols = 4; + Enable_NLM = false; + Enable_RQUOTA = false; +} + +EXPORT_DEFAULTS { + AccessType = None; +} + +EXPORT { + Export_Id = {{ samba_nfs_export_id }}; + Path = {{ samba_nfs_export_path }}; + Pseudo = {{ samba_nfs_export_path }}; + AccessType = RW; + Squash = rootid; + SecType = sys; + Protocols = 4; + Transports = TCP,UDP; + Clients = {{ samba_nfs_client_ipv4 }}; + FSAL { + Name = VFS; + } +} + +LOG { + Default_Log_Level = INFO; +} diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ghostnet-samba-nfs.container.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ghostnet-samba-nfs.container.j2 new file mode 100644 index 0000000..1b7aa84 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_nfs/templates/ghostnet-samba-nfs.container.j2 @@ -0,0 +1,50 @@ +[Unit] +Description=Ghostnet Samba NFS-Ganesha export for the encoding pipeline +Wants=network-online.target +After=network-online.target nftables.service +# Fail closed: without the nftables policy the export must not start. +Requires=nftables.service + +[Container] +Image={{ samba_nfs_runtime_image_id }} +Pull=never +ContainerName={{ samba_nfs_container_name }} + +# Host networking: the export listener (2049) is bound in the CT 101 network +# namespace and covered by the samba_firewall input chain. +Network=host + +Volume={{ samba_nfs_config_directory }}/ganesha.conf:/etc/ganesha/ganesha.conf:ro +Volume={{ samba_nfs_config_directory }}/ghostnet-ganesha-healthcheck:/usr/local/bin/ghostnet-ganesha-healthcheck:ro +Volume={{ samba_nfs_export_path }}:{{ samba_nfs_export_path }}:rw +Volume={{ samba_nfs_state_directory }}:/var/lib/nfs-ganesha:rw + +# Rootful on purpose: the VFS FSAL applies the AUTH_SYS credentials of the +# pipeline (UID 982/983) per request, which requires CAP_SETUID inside the +# unprivileged LXC user namespace. +User=0 +Group=0 +ReadOnly=true +Tmpfs=/run:rw +Tmpfs=/tmp:rw +LogDriver=journald + +HealthCmd=/usr/local/bin/ghostnet-ganesha-healthcheck +HealthInterval=10s +HealthTimeout=5s +HealthRetries=6 +HealthStartPeriod=30s +Notify=conmon +StopSignal=SIGTERM +StopTimeout=30 + +Label=eu.selfhost.ghostnet.stack=samba +Label=eu.selfhost.ghostnet.role=nfs-export + +[Service] +Restart=on-failure +RestartSec=5s +TimeoutStartSec=120s + +[Install] +WantedBy=multi-user.target diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/README.md b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/README.md new file mode 100644 index 0000000..c0672f5 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/README.md @@ -0,0 +1,34 @@ +# `samba_smb` + +Declaratively manages the **native** Samba server of CT 101 (GNAI-019 +decision 1: Debian package, no container — see there for the rationale). + +- Fail-closed guard (GNAI-018 R5, passdb strategy: preserve): with the default + `samba_smb_preserve_passdb: true`, `/var/lib/samba/private/passdb.tdb` must + exist before anything converges, the `pdbedit -L` user list is read before + and after the convergence and must be identical, and the productive users + `ghost`, `daidalaw`, `debugofen` must be present. On a fresh-state rebuild + the first `site.yml` run passes `-e samba_smb_preserve_passdb=false` (no + passdb exists yet); the operator then sets the SMB passwords interactively + (`smbpasswd -a`), and the second run validates the populated passdb with the + default (GNAI-019 decision 5). +- Installs `samba`, `samba-common`, `samba-common-bin`, `smbclient`. +- Installs the **fully templated** `smb.conf` (`templates/smb.conf.j2`) and + validates the staged file with `testparm -s` before it is installed + (`validate:` gate, GNAI-018 R6). The template contains the Debian default + globals including `usershare allow guests = yes` (documented, kept per + GNAI-018 R4), the `[homes]`, `[printers]` (once — the duplicate from the + live system is cleaned up, R4) and `[print$]` sections as well as all + 14 media shares from the inventory (12 existing shares without comments, + exactly as in the live system; `[Encode]`/`[Encode-In]` exactly per + GNAI-018 R2). +- `smbd` and `nmbd` are enabled and started; a configuration change restarts + both via handlers. +- Post-state verification: `testparm -s` on the installed file, `pdbedit -L` + comparison, anonymous `smbclient -L localhost -N` must list every declared + media share plus `print$`, and both services must be active. These checks + run on real executions and on check-mode reruns of a converged host; on a + first check-mode run the configuration change is only previewed. + +Secrets (SMB passwords, passdb) are never read into logs, inventory or Git — +`pdbedit -L` lists usernames only. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/defaults/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/defaults/main.yml new file mode 100644 index 0000000..bc0c0a7 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/defaults/main.yml @@ -0,0 +1,22 @@ +--- +samba_smb_packages: + - samba + - samba-common + - samba-common-bin + - smbclient +samba_smb_workgroup: Ghost.net +samba_smb_config_file: /etc/samba/smb.conf +samba_smb_passdb_file: /var/lib/samba/private/passdb.tdb +# Adoption (default): the productive passdb must exist and survive the +# convergence (GNAI-018 R5). Fresh-state rebuilds run site.yml once with +# -e samba_smb_preserve_passdb=false; afterwards the operator sets the SMB +# passwords via smbpasswd, and the second site.yml run validates the populated +# passdb with the default. +samba_smb_preserve_passdb: true +samba_smb_service_units: + - smbd.service + - nmbd.service +# Complete share target state; overridden from the inventory (group_vars). The +# role default list is intentionally empty so that a missing inventory stops +# fail-closed. +samba_smb_media_shares: [] diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/handlers/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/handlers/main.yml new file mode 100644 index 0000000..9b1cb55 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Restart the Samba smbd service + ansible.builtin.systemd_service: + name: smbd.service + state: restarted + +- name: Restart the Samba nmbd service + ansible.builtin.systemd_service: + name: nmbd.service + state: restarted diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/meta/argument_specs.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/meta/argument_specs.yml new file mode 100644 index 0000000..ecac432 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/meta/argument_specs.yml @@ -0,0 +1,29 @@ +--- +argument_specs: + main: + short_description: Manage the CT 101 native Samba server and its shares + options: + samba_smb_packages: + type: list + elements: str + required: true + samba_smb_workgroup: + type: str + required: true + samba_smb_config_file: + type: path + required: true + samba_smb_passdb_file: + type: path + required: true + samba_smb_preserve_passdb: + type: bool + required: true + samba_smb_service_units: + type: list + elements: str + required: true + samba_smb_media_shares: + type: list + elements: dict + required: true diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/tasks/main.yml b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/tasks/main.yml new file mode 100644 index 0000000..5587662 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/tasks/main.yml @@ -0,0 +1,166 @@ +--- +- name: Require the Samba service and share declarations + ansible.builtin.assert: + that: + - samba_smb_workgroup | length > 0 + - samba_smb_config_file | length > 0 + - samba_smb_service_units | length > 0 + - samba_smb_media_shares | length > 0 + fail_msg: The Samba workgroup, service and share declarations must be explicit. + +- name: Require the productive Samba passdb before any convergence + ansible.builtin.stat: + path: "{{ samba_smb_passdb_file }}" + register: samba_smb_passdb + +- name: Fail closed when the productive passdb is missing + ansible.builtin.assert: + that: + - samba_smb_passdb.stat.exists + fail_msg: >- + {{ samba_smb_passdb_file }} is missing. CT 101 is an adopted productive + file server; the passdb carries the SMB passwords of ghost, daidalaw + and debugofen and must be preserved (GNAI-018 R5). Run + playbooks/backup.yml first and investigate the missing state before + converging the shares. Fresh-state rebuilds only: run site.yml with + -e samba_smb_preserve_passdb=false and set the SMB passwords with + smbpasswd before the verification. + when: samba_smb_preserve_passdb | bool + +- name: Read the Samba user database before convergence + ansible.builtin.command: + argv: + - /usr/sbin/pdbedit + - -L + check_mode: false + changed_when: false + register: samba_smb_passdb_before + when: samba_smb_preserve_passdb | bool + +- name: Install the Samba server and client packages + ansible.builtin.apt: + name: "{{ samba_smb_packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + install_recommends: false + +- name: Install the fully templated Samba configuration + ansible.builtin.template: + src: smb.conf.j2 + dest: "{{ samba_smb_config_file }}" + owner: root + group: root + mode: "0644" + validate: /usr/bin/testparm -s %s + notify: + - Restart the Samba smbd service + - Restart the Samba nmbd service + register: samba_smb_config + +- name: Flush Samba handlers before service management + ansible.builtin.meta: flush_handlers + +- name: Enable and start the Samba services + ansible.builtin.systemd_service: + name: "{{ item }}" + enabled: true + state: started + loop: "{{ samba_smb_service_units }}" + loop_control: + label: "{{ item }}" + +- name: Validate the installed Samba configuration + ansible.builtin.command: + argv: + - /usr/bin/testparm + - -s + - "{{ samba_smb_config_file }}" + check_mode: false + changed_when: false + register: samba_smb_testparm + when: >- + not ansible_check_mode or not samba_smb_config.changed | bool + +- name: Require a clean testparm result for the installed configuration + ansible.builtin.assert: + that: + - samba_smb_testparm.rc == 0 + fail_msg: >- + testparm rejected the installed smb.conf. The template gate passed the + staged file, but the installed file differs; investigate before + continuing. + when: >- + not ansible_check_mode or not samba_smb_config.changed | bool + +- name: Read the Samba user database after convergence + ansible.builtin.command: + argv: + - /usr/sbin/pdbedit + - -L + check_mode: false + changed_when: false + register: samba_smb_passdb_after + when: samba_smb_preserve_passdb | bool + +- name: Verify the productive Samba users survived the convergence + ansible.builtin.assert: + that: + - >- + samba_smb_passdb_after.stdout_lines | sort == + samba_smb_passdb_before.stdout_lines | sort + - >- + samba_media_users | map(attribute='name') | list is + subset(samba_smb_passdb_after.stdout_lines) + fail_msg: >- + The Samba user database changed during convergence. The productive + users ghost, daidalaw and debugofen must be preserved (GNAI-018 R5). + when: samba_smb_preserve_passdb | bool + +- name: Read the anonymous Samba share listing + ansible.builtin.command: + argv: + - /usr/bin/smbclient + - -L + - localhost + - -N + check_mode: false + changed_when: false + register: samba_smb_share_listing + when: >- + not ansible_check_mode or not samba_smb_config.changed | bool + +- name: Verify the visible Samba shares + ansible.builtin.assert: + that: + - >- + samba_smb_share_listing.stdout is + search('(?m)^\\s*' ~ (item | regex_escape) ~ '\\s+Disk') + fail_msg: >- + The share {{ item }} is not visible in the anonymous share listing of + the converged smb.conf. + loop: >- + {{ samba_smb_media_shares | map(attribute='name') | list + ['print$'] }} + loop_control: + label: "{{ item }}" + when: >- + not ansible_check_mode or not samba_smb_config.changed | bool + +- name: Read the Samba service states + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - "{{ item }}" + check_mode: false + changed_when: false + loop: "{{ samba_smb_service_units }}" + loop_control: + label: "{{ item }}" + register: samba_smb_service_states + +- name: Require the Samba services to be active + ansible.builtin.assert: + that: + - samba_smb_service_states.results | map(attribute='stdout') | unique | list == ['active'] + fail_msg: The Samba services are not active after the convergence. diff --git a/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/templates/smb.conf.j2 b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/templates/smb.conf.j2 new file mode 100644 index 0000000..ff6b175 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ansible/roles/samba_smb/templates/smb.conf.j2 @@ -0,0 +1,77 @@ +# Managed by Ansible role samba_smb (CT 101, GNAI-017/GNAI-018). +# Complete target file; the documented live state is in GNAI-017 section 3, +# deviations and their rationale in GNAI-019. The file is validated with +# testparm before installation. +[global] + workgroup = {{ samba_smb_workgroup }} + server string = %h server (Samba, Debian) + dns proxy = no + log file = /var/log/samba/log.%m + max log size = 1000 + syslog = 0 + panic action = /usr/share/samba/panic-action %d + server role = standalone server + obey pam restrictions = yes + unix password sync = yes + passwd program = /usr/bin/passwd %u + passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* . + pam password change = yes + map to guest = bad user + usershare allow guests = yes + load printers = yes + printing = bsd + printcap name = /dev/null + disable spoolss = yes + +[homes] + comment = Home Directories + browseable = no + read only = yes + valid users = %S + create mask = 0700 + directory mask = 0700 + +[printers] + comment = All Printers + path = /var/tmp + printable = yes + guest ok = no + read only = yes + create mask = 0700 + +[print$] + comment = Printer Drivers + path = /var/lib/samba/printers + browseable = yes + read only = yes + guest ok = no + +{% for share in samba_smb_media_shares %} +[{{ share.name }}] +{% if share.comment is defined %} + comment = {{ share.comment }} +{% endif %} + path = {{ share.path }} + read only = {{ share.read_only }} + valid users = {{ share.valid_users | join(', ') }} +{% if share.write_list is defined %} + write list = {{ share.write_list | join(', ') }} +{% endif %} +{% if share.admin_users is defined %} + admin users = {{ share.admin_users | join(', ') }} +{% endif %} +{% if share.force_user is defined %} + force user = {{ share.force_user }} +{% endif %} +{% if share.force_group is defined %} + force group = {{ share.force_group }} +{% endif %} +{% if share.create_mask is defined %} + create mask = {{ share.create_mask }} +{% endif %} +{% if share.directory_mask is defined %} + directory mask = {{ share.directory_mask }} +{% endif %} + guest ok = no + +{% endfor %} diff --git a/proxmox/lxc/ct-101-samba/ghostnet-ganesha-healthcheck b/proxmox/lxc/ct-101-samba/ghostnet-ganesha-healthcheck new file mode 100755 index 0000000..553f09a --- /dev/null +++ b/proxmox/lxc/ct-101-samba/ghostnet-ganesha-healthcheck @@ -0,0 +1,9 @@ +#!/bin/bash + +# Liveness probe for the rootful NFS-Ganesha container (CT 101, GNAI-019). +# A bare TCP connect to the NFSv4 listener proves that ganesha.nfsd is +# serving; there is no HTTP endpoint to probe. + +set -euo pipefail + +exec 3<>/dev/tcp/127.0.0.1/2049 diff --git a/proxmox/lxc/ct-101-samba/scripts/README.md b/proxmox/lxc/ct-101-samba/scripts/README.md new file mode 100644 index 0000000..adf1bb6 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/scripts/README.md @@ -0,0 +1,136 @@ +# Proxmox automation (CT 101 Samba) + +Two PVE-side scripts for CT 101 (`10.0.0.9`, Samba file server, +GNAI-017/GNAI-018): the in-place dist-upgrade of the productive, adopted LXC to +Debian 13 (adoption, step 1) and the provisioning of a fresh CT 101 for the +documented fresh-state rebuild. Both scripts run as root on the Proxmox host, +contain no password, no private key and no SMB secret, and never destroy or +replace an existing CT 101. + +## `upgrade-ct101-to-debian13.sh` (adoption, step 1) + +Upgrades the productive CT 101 from Debian 12 (bookworm) to Debian 13 +(trixie) in place. The existing LXC is preserved; the Samba passdb and the +productive `smb.conf` survive the upgrade (`--force-confold` keeps the +productive `smb.conf`). Sequence: + +1. Preflight: CT 101 is running, is unprivileged (GNAI-015), all five media + mounts carry `backup=no` (otherwise `vzdump` would archive the media + library), and in the guest Debian 12, `ssh`/`smbd`/`nmbd` are active and the + IPv4 matches. +2. `vzdump 101 --mode stop` as a safety net (storage and dump directory are + configurable). +3. Add an empty `lxc.seccomp.profile:` to `/etc/pve/lxc/101.conf` — the rootful + NFS-Ganesha container needs `open_by_handle_at`, which the default seccomp + profile blocks (GNAI-019 decision 2). +4. In the guest: `bookworm` → `trixie` in the apt sources (deb822 and + one-line style), `apt-get update`, `dist-upgrade` with + `Dpkg::Options::=--force-confold`, `autoremove`, then the known dangling + `wtmpdb-rotate.timer` cleanup rule from `ghostnet-lxc-baseline.sh`. +5. Reboot and post-verify: Debian 13, `ssh`/`smbd`/`nmbd` active, no failed + units, listeners 22/139/445, anonymous `smbclient -L` query successful, + unprivileged uid_map intact, seccomp filter disabled, all five media + mounts mounted. + +Before the run, all media mounts in the productive `pct config 101` must carry +the `backup=no` option. Installation and invocation: + +```bash +install -o root -g root -m 0700 \ + upgrade-ct101-to-debian13.sh \ + /usr/local/sbin/upgrade-ct101-to-debian13.sh + +GHOSTNET_CT101_UPGRADE_CONFIRMATION=DIST-UPGRADE_CT101_TO_DEBIAN_13 \ + /usr/local/sbin/upgrade-ct101-to-debian13.sh +``` + +Environment variables: `GHOSTNET_BACKUP_STORAGE` (default `local`), +`GHOSTNET_BACKUP_DUMP_DIR` (default `/var/lib/vz/dump`, only needed for +non-local backup storage). The confirmed downtime affects only the +file-server service; the host key is unchanged, so the workstation does not +have to replace a known host key. + +The reviewed repository copy has SHA-256 +`f7adfbde7378708620975a64c30bb3de8727b96915ad8c58100893a52c2c2d6e`. + +## `provision-ct101-samba.sh` (fresh-state rebuild) + +Creates a fresh CT 101 from a Debian 13 template with the recorded profile, +attaches the five media mounts, disables the LXC seccomp filter and then runs +the reviewed baseline hook with the recorded account UIDs. The script refuses +to run when CT 101 already exists, and it names every material setting +explicitly. + +| Setting | Value | +| --- | --- | +| CT ID / hostname | `101` / `samba` (overridable via `GHOSTNET_CT101_HOSTNAME`) | +| OS | Debian 13, amd64 (newest `debian-13-*amd64*` template in the local cache, otherwise `GHOSTNET_OS_TEMPLATE`) | +| Container type | unprivileged LXC with `nesting=1,keyctl=1` | +| CPU / RAM / swap | 2 cores / 2048 MiB / 1024 MiB (adjustable via env) | +| Rootfs | 32 GiB on `local-lvm` (adjustable via env) | +| Network | `vmbr0`, `10.0.0.9/24`, gateway `10.0.0.1`, DNS `10.0.0.14`, no IPv6 | +| Boot / timezone | `onboot: 1`, `Europe/Berlin` | +| Seccomp | empty `lxc.seccomp.profile:` (Ganesha `open_by_handle_at`) | +| Media mounts | `mp0`–`mp4` → `/media/daten1`, `/media/daten2`, `/media/daten3`, `/media/datenhdd2`, `/media/datenhdd3`, each with `backup=no` | +| Baseline hook | `/usr/local/sbin/ghostnet-lxc-baseline.sh` (SHA-256 pinned) | + +The resource defaults are conservative starting values; for an exact rebuild of +the productive profile, set the values from `pct config 101` of the productive +CT 101 via the env variables. + +Mandatory environment variables (the recorded values from the Ansible inventory +or the productive `pct config 101`): + +| Variable | Meaning | +| --- | --- | +| `GHOSTNET_AGENT_UID` | UID of `ghost-agent` (must match `samba_access_agent_uid`) | +| `GHOSTNET_ADMIN_UID` | UID of `ghost` (must match `samba_access_admin_uid`) | +| `GHOSTNET_MP0_HOST` … `GHOSTNET_MP4_HOST` | Host paths of the five media mounts (must exist) | + +Optional variables: `GHOSTNET_CT101_HOSTNAME`, `GHOSTNET_CT101_CORES`, +`GHOSTNET_CT101_MEMORY_MB`, `GHOSTNET_CT101_ROOTFS_GB`, `GHOSTNET_CT101_SWAP_MB`, +`GHOSTNET_OS_TEMPLATE`, `GHOSTNET_BASELINE_HOOK`. + +Installation and invocation: + +```bash +install -o root -g root -m 0700 \ + provision-ct101-samba.sh \ + /usr/local/sbin/provision-ct101-samba.sh + +GHOSTNET_AGENT_UID= GHOSTNET_ADMIN_UID= \ +GHOSTNET_MP0_HOST=/path/daten1 GHOSTNET_MP1_HOST=/path/daten2 \ +GHOSTNET_MP2_HOST=/path/daten3 GHOSTNET_MP3_HOST=/path/datenhdd2 \ +GHOSTNET_MP4_HOST=/path/datenhdd3 \ + /usr/local/sbin/provision-ct101-samba.sh +``` + +The script sets neither passwords nor keys and configures neither Samba nor +the firewall — the Ansible roles do that. The final output lists the operator +steps (set the ghost password, install the public keys from +`ansible/files/ssh/`, configure the workstation SSH, fill the inventory +placeholders, rebuild sequence from `ansible/README.md`). + +The reviewed repository copy has SHA-256 +`ac5d613fb3c3526135c96f02efff9fd7d8dc01952e3d4701b9b5a4a511fff1cd`. + +## `ghostnet-lxc-baseline.sh` + +The versioned source lives under +`../../../shared/scripts/ghostnet-lxc-baseline.sh`. Both CT 101 scripts call +this reviewed baseline (adoption: manually after the upgrade; fresh rebuild: +automatically via `provision-ct101-samba.sh`). Installation on the Proxmox +host: + +```bash +install -o root -g root -m 0750 \ + ghostnet-lxc-baseline.sh \ + /usr/local/sbin/ghostnet-lxc-baseline.sh +``` + +The pinned SHA-256 of the repository copy is +`111f1b70e47e3a2f6a1bcce22cb0ec50aacd8fbcff37d87fbcf853a318381b10`; +the scripts verify the host copy before executing it. The baseline creates +`ghost-agent` and `ghost` with the passed UIDs (or validates existing +accounts), prepares the `.ssh` directories, adds `ghost` to `sudo` and ensures +classic `ssh.service` operation. It installs no keys and sets no passwords. diff --git a/proxmox/lxc/ct-101-samba/scripts/provision-ct101-samba.sh b/proxmox/lxc/ct-101-samba/scripts/provision-ct101-samba.sh new file mode 100644 index 0000000..03e0533 --- /dev/null +++ b/proxmox/lxc/ct-101-samba/scripts/provision-ct101-samba.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash + +# Fresh-state rebuild of CT 101 (Samba file server) on the Proxmox VE host. +# Creates the container from a Debian 13 template with the recorded profile, +# attaches the five media mounts, disables the LXC seccomp filter (required +# by the rootful NFS-Ganesha container, GNAI-019) and runs the reviewed +# generic LXC baseline hook with the recorded account UIDs. +# +# The script never replaces or destroys an existing CT 101. It contains no +# password, private key or SMB secret. The SMB passwords are set by the +# operator with smbpasswd after the first Ansible convergence. + +set -Eeuo pipefail +umask 077 + +readonly CTID='101' +readonly HOSTNAME="${GHOSTNET_CT101_HOSTNAME:-samba}" +readonly IPV4_CIDR='10.0.0.9/24' +readonly GATEWAY='10.0.0.1' +readonly NAMESERVER='10.0.0.14' +readonly BRIDGE='vmbr0' +readonly TEMPLATE_STORAGE='local' +readonly CONTAINER_STORAGE='local-lvm' +readonly BASELINE_HOOK="${GHOSTNET_BASELINE_HOOK:-/usr/local/sbin/ghostnet-lxc-baseline.sh}" +# Reviewed repository copy of proxmox/shared/scripts/ghostnet-lxc-baseline.sh: +readonly BASELINE_HOOK_SHA256='111f1b70e47e3a2f6a1bcce22cb0ec50aacd8fbcff37d87fbcf853a318381b10' +readonly CORES="${GHOSTNET_CT101_CORES:-2}" +readonly MEMORY_MB="${GHOSTNET_CT101_MEMORY_MB:-2048}" +readonly ROOTFS_GB="${GHOSTNET_CT101_ROOTFS_GB:-32}" +readonly SWAP_MB="${GHOSTNET_CT101_SWAP_MB:-1024}" +# Empty: auto-detect the newest debian-13 amd64 template in the local cache. +readonly OS_TEMPLATE="${GHOSTNET_OS_TEMPLATE:-}" +# The recorded account UIDs must match samba_access_agent_uid and +# samba_access_admin_uid in the Ansible inventory. +readonly AGENT_UID="${GHOSTNET_AGENT_UID:-}" +readonly ADMIN_UID="${GHOSTNET_ADMIN_UID:-}" + +# Guest mount points of the media library (GNAI-017 section 2); the host +# paths are supplied per environment variable and must already exist. +readonly -a MOUNT_GUEST_PATHS=( + '/media/daten1' + '/media/daten2' + '/media/daten3' + '/media/datenhdd2' + '/media/datenhdd3' +) +readonly -a MOUNT_HOST_PATHS=( + "${GHOSTNET_MP0_HOST:-}" + "${GHOSTNET_MP1_HOST:-}" + "${GHOSTNET_MP2_HOST:-}" + "${GHOSTNET_MP3_HOST:-}" + "${GHOSTNET_MP4_HOST:-}" +) + +log() { + printf '[ghostnet-ct101-provision] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-provision] ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +require_file_sha256() { + local expected="$1" + local path="$2" + + [[ -f "$path" ]] || die "required file is missing: ${path}" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --status - || + die "SHA-256 mismatch: ${path}" +} + +storage_is_active() { + local storage="$1" + pvesm status | awk -v storage="$storage" ' + $1 == storage && $3 == "active" { found = 1 } + END { exit(found ? 0 : 1) } + ' +} + +require_numeric() { + local name="$1" + local value="$2" + + [[ "$value" =~ ^[0-9]+$ ]] || die "${name} must be numeric (got: ${value:-})" +} + +[[ "${EUID}" -eq 0 ]] || die 'must run as root on the Proxmox VE host' +for command_name in awk grep ls pct pvesm pveversion seq sha256sum sort; do + require_command "$command_name" +done + +pveversion >/dev/null 2>&1 || die 'this does not appear to be a Proxmox VE host' +[[ ! -e "/etc/pve/lxc/${CTID}.conf" ]] || + die "CT ${CTID} still exists; this script never replaces or destroys a container" +if pct status "$CTID" >/dev/null 2>&1; then + die "CT ${CTID} is still registered" +fi + +[[ "$HOSTNAME" =~ ^[a-z0-9][a-z0-9-]*$ ]] || + die "invalid hostname: ${HOSTNAME}" +require_numeric GHOSTNET_CT101_CORES "$CORES" +require_numeric GHOSTNET_CT101_MEMORY_MB "$MEMORY_MB" +require_numeric GHOSTNET_CT101_ROOTFS_GB "$ROOTFS_GB" +require_numeric GHOSTNET_CT101_SWAP_MB "$SWAP_MB" +require_numeric GHOSTNET_AGENT_UID "$AGENT_UID" +require_numeric GHOSTNET_ADMIN_UID "$ADMIN_UID" +[[ "$AGENT_UID" != "$ADMIN_UID" ]] || + die 'GHOSTNET_AGENT_UID and GHOSTNET_ADMIN_UID must differ' + +for index in "${!MOUNT_HOST_PATHS[@]}"; do + [[ -n "${MOUNT_HOST_PATHS[$index]}" ]] || + die "GHOSTNET_MP${index}_HOST is not set; all five media mount host paths are required" + [[ -d "${MOUNT_HOST_PATHS[$index]}" ]] || + die "media mount host path does not exist: ${MOUNT_HOST_PATHS[$index]}" +done + +storage_is_active "$TEMPLATE_STORAGE" || + die "template storage ${TEMPLATE_STORAGE} is not active" +storage_is_active "$CONTAINER_STORAGE" || + die "container storage ${CONTAINER_STORAGE} is not active" + +[[ -x "$BASELINE_HOOK" ]] || die "baseline hook is not executable: ${BASELINE_HOOK}" +require_file_sha256 "$BASELINE_HOOK_SHA256" "$BASELINE_HOOK" + +resolved_template="$OS_TEMPLATE" +if [[ -z "$resolved_template" ]]; then + [[ "$TEMPLATE_STORAGE" == 'local' ]] || + die 'template auto-detection requires the local template storage; set GHOSTNET_OS_TEMPLATE' + template_cache='/var/lib/vz/template/cache' + resolved_template="$( + ls -1 "${template_cache}"/debian-13-*amd64*.tar.* 2>/dev/null | sort -V | tail -n 1 || true + )" + [[ -n "$resolved_template" ]] || + die "no debian-13 amd64 template in ${template_cache}; set GHOSTNET_OS_TEMPLATE" + resolved_template="${TEMPLATE_STORAGE}:vztmpl/$(basename "$resolved_template")" +fi +[[ "$resolved_template" =~ ^[A-Za-z0-9_.-]+:vztmpl/.+\.tar\.(zst|gz|xz)$ ]] || + die "GHOSTNET_OS_TEMPLATE must be a template volume ID like local:vztmpl/debian-13-standard_13.1-1_amd64.tar.zst (got: ${resolved_template})" +template_storage="${resolved_template%%:*}" +storage_is_active "$template_storage" || + die "template storage ${template_storage} is not active" +pvesm list "$template_storage" | awk '{print $1}' | + grep -qx "$resolved_template" || + die "template not found in storage ${template_storage}: ${resolved_template}" + +log "creating CT ${CTID} (${HOSTNAME}, ${IPV4_CIDR}) from ${resolved_template}" +log "profile: ${CORES} cores, ${MEMORY_MB} MiB RAM, ${SWAP_MB} MiB swap, ${ROOTFS_GB} GiB rootfs on ${CONTAINER_STORAGE}" +pct create "$CTID" "$resolved_template" \ + --hostname "$HOSTNAME" \ + --cores "$CORES" \ + --memory "$MEMORY_MB" \ + --swap "$SWAP_MB" \ + --rootfs "${CONTAINER_STORAGE}:${ROOTFS_GB}" \ + --net0 "name=eth0,bridge=${BRIDGE},gw=${GATEWAY},ip=${IPV4_CIDR},type=veth" \ + --nameserver "$NAMESERVER" \ + --timezone 'Europe/Berlin' \ + --ostype debian \ + --unprivileged 1 \ + --onboot 1 \ + --features 'nesting=1,keyctl=1' || + die 'pct create failed' + +for index in "${!MOUNT_GUEST_PATHS[@]}"; do + log "attaching media mount mp${index}: ${MOUNT_HOST_PATHS[$index]} -> ${MOUNT_GUEST_PATHS[$index]} (backup=no)" + pct set "$CTID" --mp"$index" \ + "${MOUNT_HOST_PATHS[$index]},mp=${MOUNT_GUEST_PATHS[$index]},backup=no" || + die "pct set --mp${index} failed" +done + +# An empty lxc.seccomp.profile disables the LXC seccomp filter for the +# container. The rootful NFS-Ganesha container needs open_by_handle_at, which +# the default profile blocks (GNAI-019 decision 2). Append after all pct set +# operations because pct rewrites the configuration file. +conf_file="/etc/pve/lxc/${CTID}.conf" +if grep -Eq '^lxc\.seccomp\.profile:.+' "$conf_file"; then + die "CT ${CTID} already has a non-empty lxc.seccomp.profile; review manually" +fi +grep -qx 'lxc.seccomp.profile:' "$conf_file" || + printf 'lxc.seccomp.profile:\n' >>"$conf_file" + +config="$(pct config "$CTID")" +grep -qx "cores: ${CORES}" <<<"$config" || die 'unexpected CPU allocation' +grep -qx "memory: ${MEMORY_MB}" <<<"$config" || die 'unexpected memory allocation' +grep -qx "swap: ${SWAP_MB}" <<<"$config" || die 'unexpected swap allocation' +grep -qx "hostname: ${HOSTNAME}" <<<"$config" || die 'unexpected hostname' +grep -qx "nameserver: ${NAMESERVER}" <<<"$config" || die 'unexpected nameserver' +grep -qx 'onboot: 1' <<<"$config" || die 'onboot is not enabled' +grep -qx 'unprivileged: 1' <<<"$config" || die 'container is not unprivileged' +grep -qx 'ostype: debian' <<<"$config" || die 'unexpected os type' +grep -qx 'timezone: Europe/Berlin' <<<"$config" || die 'unexpected timezone' +grep -Eq '^features: .*nesting=1' <<<"$config" || die 'nesting is not enabled' +grep -Eq '^features: .*keyctl=1' <<<"$config" || die 'keyctl is not enabled' +grep -Eq "^rootfs: ${CONTAINER_STORAGE}:.*,size=${ROOTFS_GB}G(,|\$)" <<<"$config" || + die 'unexpected root filesystem allocation' +grep -qx 'lxc.seccomp.profile:' <<<"$config" || + die 'the empty seccomp profile line is missing' +net0="$(grep '^net0: ' <<<"$config")" +for expected_net_field in \ + 'name=eth0' \ + "bridge=${BRIDGE}" \ + "gw=${GATEWAY}" \ + "ip=${IPV4_CIDR}" \ + 'type=veth'; do + [[ "$net0" == *"$expected_net_field"* ]] || + die "net0 is missing ${expected_net_field}" +done +for index in "${!MOUNT_GUEST_PATHS[@]}"; do + mp_line="$(grep "^mp${index}: " <<<"$config")" + [[ "$mp_line" == *"mp=${MOUNT_GUEST_PATHS[$index]}"* ]] || + die "mp${index} is missing mp=${MOUNT_GUEST_PATHS[$index]}" + [[ "$mp_line" == *'backup=no'* ]] || + die "mp${index} is missing backup=no" + [[ "$mp_line" == *"${MOUNT_HOST_PATHS[$index]}"* ]] || + die "mp${index} does not reference ${MOUNT_HOST_PATHS[$index]}" +done + +log 'starting CT 101' +pct start "$CTID" || die 'pct start failed' +responsive='no' +for _ in $(seq 1 60); do + if pct exec "$CTID" -- /bin/true 2>/dev/null; then + responsive='yes' + break + fi + sleep 2 +done +[[ "$responsive" == 'yes' ]] || die 'CT 101 did not become responsive after start' + +log 'running the reviewed generic LXC baseline with the recorded account UIDs' +GHOSTNET_AGENT_UID="$AGENT_UID" GHOSTNET_ADMIN_UID="$ADMIN_UID" \ + "$BASELINE_HOOK" "$CTID" || + die 'the baseline hook failed; inspect the output above before any rerun' + +pct exec "$CTID" -- /bin/bash -s -- \ + "$HOSTNAME" "${IPV4_CIDR%/*}" "$AGENT_UID" "$ADMIN_UID" \ + "${MOUNT_GUEST_PATHS[@]}" <<'GUEST_VERIFY' || + die 'guest verification failed' +set -Eeuo pipefail + +log() { + printf '[ghostnet-ct101-provision/guest] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-provision/guest] ERROR: %s\n' "$*" >&2 + exit 1 +} + +hostname="$1" +ipv4="$2" +agent_uid="$3" +admin_uid="$4" +shift 4 +mounts=("$@") + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == 'debian' ]] || die "unsupported guest OS: ${ID:-unknown}" +[[ "${VERSION_ID:-}" == '13' ]] || + die "guest is Debian ${VERSION_ID:-unknown}, expected 13 (trixie)" +test "$(hostname --short)" = "$hostname" +ip -o -4 address show scope global | grep -Eq "[[:space:]]inet[[:space:]]+${ipv4//./\\.}/" +test "$(id -u ghost-agent)" = "$agent_uid" +test "$(id -u ghost)" = "$admin_uid" +systemctl is-active --quiet ssh.service +for mount_point in "${mounts[@]}"; do + findmnt --noheadings --target "$mount_point" >/dev/null +done +# The empty lxc.seccomp.profile must disable the container seccomp filter. +test "$(awk '$1 == "Seccomp:" { print $2 }' /proc/self/status)" = '0' + +log "guest: $(hostname), Debian ${VERSION_ID}, $(systemd-detect-virt)" +log "ghost-agent UID ${agent_uid}, ghost UID ${admin_uid}, ssh.service active" +log "media mounts verified: ${mounts[*]}" +log 'seccomp filter disabled (open_by_handle_at available for Ganesha)' +GUEST_VERIFY + +log "CT ${CTID} fresh-state provisioning completed and verified" +log 'operator actions before the Ansible rebuild sequence:' +log " 1. set the ghost sudo password: pct exec ${CTID} -- passwd ghost" +log ' 2. install the agent and ghost public keys from ansible/files/ssh/ into' +log ' /home/ghost-agent/.ssh/authorized_keys and /home/ghost/.ssh/authorized_keys' +log ' (the baseline hook created both files empty and protected)' +log ' 3. on the workstation: add the ssh aliases and seed known_hosts_ghostnet_ct101' +log ' 4. fill every REPLACE-WITH placeholder in the Ansible inventory' +log ' (preflight.yml reports the missing values on the agent inventory)' +log ' 5. run the rebuild sequence from ansible/README.md:' +log ' site.yml -e samba_smb_preserve_passdb=false, then smbpasswd -a for' +log ' ghost, daidalaw and debugofen, then site.yml again and' +log ' verify-production.yml -e samba_verify_require_premigration_backup=false' diff --git a/proxmox/lxc/ct-101-samba/scripts/upgrade-ct101-to-debian13.sh b/proxmox/lxc/ct-101-samba/scripts/upgrade-ct101-to-debian13.sh new file mode 100644 index 0000000..896026a --- /dev/null +++ b/proxmox/lxc/ct-101-samba/scripts/upgrade-ct101-to-debian13.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +# In-place Debian 12 -> 13 dist-upgrade of the adopted, productive CT 101 +# (Samba file server) on the Proxmox VE host, as the first step of the +# Ansible adoption. The existing LXC is kept; this script never recreates it. +# +# Sequence: guest preflight, pre-upgrade vzdump (mode stop), empty +# lxc.seccomp.profile (required later by the rootful NFS-Ganesha container, +# GNAI-019), in-guest bookworm -> trixie dist-upgrade, reboot, post-verify. +# +# The script contains no password or private key. The SMB passdb and the +# productive smb.conf survive the upgrade (conffile kept via +# --force-confold); playbooks/backup.yml still runs before the first +# Ansible convergence. + +set -Eeuo pipefail +umask 077 + +readonly CTID='101' +readonly EXPECTED_IPV4='10.0.0.9' +readonly CONFIRMATION_PHRASE='DIST-UPGRADE_CT101_TO_DEBIAN_13' +readonly BACKUP_STORAGE="${GHOSTNET_BACKUP_STORAGE:-local}" +readonly BACKUP_DUMP_DIR="${GHOSTNET_BACKUP_DUMP_DIR:-/var/lib/vz/dump}" +readonly -a MEDIA_MOUNT_POINTS=( + '/media/daten1' + '/media/daten2' + '/media/daten3' + '/media/datenhdd2' + '/media/datenhdd3' +) + +log() { + printf '[ghostnet-ct101-upgrade] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-upgrade] ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +[[ "${EUID}" -eq 0 ]] || die 'must run as root on the Proxmox VE host' +[[ "${GHOSTNET_CT101_UPGRADE_CONFIRMATION:-}" == "$CONFIRMATION_PHRASE" ]] || + die "set GHOSTNET_CT101_UPGRADE_CONFIRMATION=${CONFIRMATION_PHRASE} for the reviewed dist-upgrade outage" + +for command_name in awk find grep pct pvesm pveversion seq sha256sum sort vzdump; do + require_command "$command_name" +done + +pveversion >/dev/null 2>&1 || die 'this does not appear to be a Proxmox VE host' +pct config "$CTID" >/dev/null 2>&1 || die "CT ${CTID} does not exist" +[[ "$(pct status "$CTID")" == 'status: running' ]] || + die "CT ${CTID} is not running" +pct config "$CTID" | grep -qx 'unprivileged: 1' || + die "CT ${CTID} is not unprivileged (GNAI-015)" +[[ -d "$BACKUP_DUMP_DIR" ]] || + die "backup dump directory does not exist: ${BACKUP_DUMP_DIR} (set GHOSTNET_BACKUP_DUMP_DIR for non-local storage)" + +log 'preflight of the productive CT 101 guest' +pct exec "$CTID" -- /bin/bash -s -- "$EXPECTED_IPV4" <<'GUEST_PREFLIGHT' || + die 'guest preflight failed; CT 101 is unchanged' +set -Eeuo pipefail + +log() { + printf '[ghostnet-ct101-upgrade/guest-preflight] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-upgrade/guest-preflight] ERROR: %s\n' "$*" >&2 + exit 1 +} + +ipv4="$1" + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == 'debian' ]] || die "unsupported guest OS: ${ID:-unknown}" +[[ "${VERSION_ID:-}" == '12' ]] || + die "guest is Debian ${VERSION_ID:-unknown}, expected 12 (bookworm) before the upgrade" +systemctl is-active --quiet ssh.service smbd.service nmbd.service || + die 'ssh, smbd or nmbd is not active on the productive file server' +ip -o -4 address show scope global | + grep -Eq "[[:space:]]inet[[:space:]]+${ipv4//./\\.}/" || + die "guest does not own the expected IPv4 ${ipv4}" +[[ "$(head -n 1 /proc/self/uid_map)" != '0 0 4294967295' ]] || + die 'guest lost its unprivileged user-namespace mapping' + +log "hostname: $(hostname)" +log "samba: $(/usr/sbin/smbd --version)" +log "smb.conf sha256: $(sha256sum /etc/samba/smb.conf | awk '{print $1}')" +log 'ssh, smbd and nmbd are active; the productive state is healthy' +GUEST_PREFLIGHT + +mapfile -t mount_lines < <(pct config "$CTID" | grep -E '^mp[0-9]+: ' || true) +[[ "${#mount_lines[@]}" -eq "${#MEDIA_MOUNT_POINTS[@]}" ]] || + die "expected ${#MEDIA_MOUNT_POINTS[@]} media mounts on CT ${CTID}, found ${#mount_lines[@]}" +for mp_line in "${mount_lines[@]}"; do + grep -q 'backup=no' <<<"$mp_line" || + die "media mount lacks backup=no (vzdump would archive the media library): ${mp_line}" +done +for mount_point in "${MEDIA_MOUNT_POINTS[@]}"; do + pct config "$CTID" | grep -Eq "^mp[0-9]+: .*mp=${mount_point}(,|\$)" || + die "media mount point is not configured on the PVE side: ${mount_point}" +done + +log "creating the pre-upgrade vzdump (mode stop) in storage ${BACKUP_STORAGE}" +vzdump "$CTID" --mode stop --storage "$BACKUP_STORAGE" --compress zstd || + die 'vzdump failed; CT 101 may be stopped - investigate and run pct start 101' +dump_archive="$( + find "$BACKUP_DUMP_DIR" -maxdepth 1 -name "vzdump-${CTID}-*.vma.*" -mmin -30 -printf '%T@ %p\n' 2>/dev/null | + sort -rn | head -n 1 | awk '{print $2}' || true +)" +[[ -n "$dump_archive" && -s "$dump_archive" ]] || + die "no fresh vzdump archive found in ${BACKUP_DUMP_DIR}" +log "pre-upgrade dump: ${dump_archive} ($(stat -c %s "$dump_archive") bytes)" + +# vzdump --mode stop may leave the container stopped depending on the PVE +# version; the dist-upgrade needs it running. +if [[ "$(pct status "$CTID")" != 'status: running' ]]; then + log 'starting CT 101 after the vzdump' + pct start "$CTID" || die 'pct start failed after the vzdump' +fi +responsive='no' +for _ in $(seq 1 60); do + if pct exec "$CTID" -- /bin/true 2>/dev/null; then + responsive='yes' + break + fi + sleep 2 +done +[[ "$responsive" == 'yes' ]] || die 'CT 101 did not become responsive after the vzdump' + +# An empty lxc.seccomp.profile disables the LXC seccomp filter for the +# container. The rootful NFS-Ganesha container needs open_by_handle_at, which +# the default profile blocks (GNAI-019 decision 2). The change applies at the +# reboot below. +conf_file="/etc/pve/lxc/${CTID}.conf" +if grep -Eq '^lxc\.seccomp\.profile:.+' "$conf_file"; then + die "CT ${CTID} already has a non-empty lxc.seccomp.profile; review manually" +fi +grep -qx 'lxc.seccomp.profile:' "$conf_file" || + printf 'lxc.seccomp.profile:\n' >>"$conf_file" +log 'empty lxc.seccomp.profile recorded in the container configuration' + +log 'running the in-guest bookworm -> trixie dist-upgrade (this takes a while)' +pct exec "$CTID" -- /bin/bash -s <<'GUEST_UPGRADE' || + die 'the in-guest dist-upgrade failed; CT 101 needs manual recovery from the vzdump' +set -Eeuo pipefail +export DEBIAN_FRONTEND=noninteractive + +log() { + printf '[ghostnet-ct101-upgrade/guest-upgrade] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-upgrade/guest-upgrade] ERROR: %s\n' "$*" >&2 + exit 1 +} + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == 'debian' && "${VERSION_ID:-}" == '12' ]] || + die "unexpected guest release before the upgrade: ${ID:-unknown} ${VERSION_ID:-unknown}" + +# bookworm -> trixie in deb822 and one-line source styles alike. +if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then + sed -i 's/\bbookworm\b/trixie/g' /etc/apt/sources.list.d/debian.sources +fi +if [[ -f /etc/apt/sources.list ]]; then + sed -i 's/\bbookworm\b/trixie/g' /etc/apt/sources.list +fi +grep -rq 'bookworm' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null && + die 'apt sources still reference bookworm after the rewrite' + +apt-get update +apt-get -y -o Dpkg::Options::='--force-confold' dist-upgrade +apt-get -y autoremove + +# Debian 13's wtmpdb package no longer ships wtmpdb-rotate.timer. Remove only +# the known dangling enable link (same rule as ghostnet-lxc-baseline.sh). +wtmpdb_timer_unit='/usr/lib/systemd/system/wtmpdb-rotate.timer' +wtmpdb_timer_link='/etc/systemd/system/timers.target.wants/wtmpdb-rotate.timer' +if [[ -L "$wtmpdb_timer_link" && ! -e "$wtmpdb_timer_unit" ]]; then + unlink -- "$wtmpdb_timer_link" + systemctl daemon-reload + systemctl reset-failed wtmpdb-rotate.timer >/dev/null 2>&1 || true +fi + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == 'debian' && "${VERSION_ID:-}" == '13' ]] || + die "the guest did not reach Debian 13 (got ${ID:-unknown} ${VERSION_ID:-unknown})" +log "dist-upgrade complete: Debian ${VERSION_ID}" +log "samba now: $(/usr/sbin/smbd --version)" +GUEST_UPGRADE + +log 'rebooting CT 101 to complete the transition and apply the seccomp change' +pct reboot "$CTID" 2>/dev/null || pct start "$CTID" || + die 'reboot failed; run pct start 101 manually' +responsive='no' +for _ in $(seq 1 90); do + if pct exec "$CTID" -- /bin/true 2>/dev/null; then + responsive='yes' + break + fi + sleep 2 +done +[[ "$responsive" == 'yes' ]] || + die 'CT 101 did not become responsive after the reboot' + +log 'post-upgrade verification of the guest' +pct exec "$CTID" -- /bin/bash -s -- "$EXPECTED_IPV4" \ + "${MEDIA_MOUNT_POINTS[@]}" <<'GUEST_POSTVERIFY' || + die 'post-upgrade verification failed' +set -Eeuo pipefail + +log() { + printf '[ghostnet-ct101-upgrade/guest-postverify] %s\n' "$*" +} + +die() { + printf '[ghostnet-ct101-upgrade/guest-postverify] ERROR: %s\n' "$*" >&2 + exit 1 +} + +ipv4="$1" +shift +mounts=("$@") + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == 'debian' ]] || die "unexpected OS: ${ID:-unknown}" +[[ "${VERSION_ID:-}" == '13' ]] || + die "unexpected Debian release: ${VERSION_ID:-unknown}" +systemctl is-active --quiet ssh.service smbd.service nmbd.service || + die 'ssh, smbd or nmbd is not active after the upgrade' +if systemctl --failed --no-legend --plain | grep -q .; then + die 'failed systemd units are present after the upgrade' +fi +ip -o -4 address show scope global | + grep -Eq "[[:space:]]inet[[:space:]]+${ipv4//./\\.}/" || + die "guest does not own the expected IPv4 ${ipv4}" +listeners="$(ss -H -tuln)" +grep -q ':22 ' <<<"$listeners" || die 'SSH listener is missing' +grep -q ':139 ' <<<"$listeners" || die 'SMB listener 139 is missing' +grep -q ':445 ' <<<"$listeners" || die 'SMB listener 445 is missing' +/usr/bin/smbclient -L localhost -N >/dev/null || + die 'the anonymous Samba share listing failed after the upgrade' +[[ "$(head -n 1 /proc/self/uid_map)" != '0 0 4294967295' ]] || + die 'guest lost its unprivileged user-namespace mapping' +test "$(awk '$1 == "Seccomp:" { print $2 }' /proc/self/status)" = '0' || + die 'the container seccomp filter is still active' +for mount_point in "${mounts[@]}"; do + findmnt --noheadings --target "$mount_point" >/dev/null || + die "media mount is not mounted: ${mount_point}" +done + +log "Debian ${VERSION_ID}, hostname $(hostname)" +log "samba: $(/usr/sbin/smbd --version)" +log 'ssh, smbd and nmbd are active; listeners 22/139/445 are present' +log 'unprivileged mapping and disabled seccomp filter verified' +log "media mounts verified: ${mounts[*]}" +GUEST_POSTVERIFY + +log "CT ${CTID} is now Debian 13 and the productive Samba service survived" +log "pre-upgrade dump: ${dump_archive}" +log 'next steps (ansible/README.md, adoption runbook):' +log ' 1. workstation: ssh aliases ghostnet-ct101-agent / ghostnet-ct101-ghost and' +log ' seed ~/.ssh/known_hosts_ghostnet_ct101 with the unchanged host key' +log ' 2. preflight.yml (agent inventory), then fill every REPLACE-WITH placeholder' +log ' 3. audit-production.yml, bootstrap.yml, verify-deploy-access.yml, backup.yml' +log ' 4. site.yml --check --diff, then site.yml, then verify-production.yml'