CT-101 Samba: Ansible-Migration (GNAI-017/018/019) mit PVE-Skripten, Runbooks und Fresh-State-Rebuild aufgenommen

- 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 <vibe@mistral.ai>
This commit is contained in:
2026-10-26 19:20:36 +01:00
parent 26fb86781f
commit 220d921e09
67 changed files with 6210 additions and 4 deletions
+12 -4
View File
@@ -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.
+4
View File
@@ -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
@@ -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\<Profilname>\` 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).
@@ -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/<Profil>/`,
`jd/`, `queue/<Profil>/<Release>/`, `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/<env>/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).
@@ -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.
@@ -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"]
+32
View File
@@ -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.
+179
View File
@@ -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 <user>`); 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/`.
@@ -0,0 +1,12 @@
[defaults]
home = .ansible
host_key_checking = True
interpreter_python = auto_silent
local_tmp = .ansible/tmp
retry_files_enabled = False
roles_path = roles:../../../../shared/ansible/roles
timeout = 15
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4uaHiZpbqy1Gnp5+TrYLH1VfpUPbMNPW9IXLm/UFA5 ansible-deploy@ghostnet-ct101
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGhjaSvQXVYWdBxEwZi4cD9iOB7R6teWKZvV5o3iOtiV ghost-agent@ghostnet-ct101
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICBlIsYEO2YSZVsWvLg891kh6JbMyS1eU+ypSVqXOId+ ghost@ghostnet-ct101
@@ -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
@@ -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
@@ -0,0 +1,8 @@
---
all:
children:
samba_hosts:
hosts:
samba-ct101:
ansible_host: 10.0.0.9
ansible_user: ghost-agent
@@ -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
@@ -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
@@ -0,0 +1,8 @@
---
all:
children:
samba_hosts:
hosts:
samba-ct101:
ansible_host: 10.0.0.9
ansible_user: ghost
@@ -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
@@ -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
@@ -0,0 +1,8 @@
---
all:
children:
samba_hosts:
hosts:
samba-ct101:
ansible_host: 10.0.0.9
ansible_user: ansible-deploy
@@ -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 <unset>') }}
passwordauthentication: >-
{{ (samba_audit_sshd.stdout_lines |
select('match', '^passwordauthentication ') | list | first)
| default('passwordauthentication <unset>') }}
kbdinteractiveauthentication: >-
{{ (samba_audit_sshd.stdout_lines |
select('match', '^kbdinteractiveauthentication ') | list | first)
| default('kbdinteractiveauthentication <unset>') }}
disableforwarding: >-
{{ (samba_audit_sshd.stdout_lines |
select('match', '^disableforwarding ') | list | first)
| default('disableforwarding <unset>') }}
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 }}
@@ -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/<this-directory>/SHA256SUMS
restore_hint=systemctl stop smbd nmbd && cp -a <this-directory>/etc-samba/. /etc/samba/ && cp -a <this-directory>/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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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 <user> 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 }}
@@ -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`.
@@ -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.
@@ -0,0 +1,5 @@
---
- name: Restart the classic SSH service
ansible.builtin.systemd_service:
name: ssh.service
state: restarted
@@ -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
@@ -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
@@ -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
@@ -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).
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
}
}
{{ '' }}
@@ -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' }}
@@ -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.
@@ -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"
@@ -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
@@ -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 }}"
@@ -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.
@@ -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
@@ -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
@@ -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)
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
}
@@ -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
@@ -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.
@@ -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: []
@@ -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
@@ -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
@@ -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.
@@ -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 %}
+9
View File
@@ -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
+136
View File
@@ -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=<uid> GHOSTNET_ADMIN_UID=<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.
@@ -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:-<unset>})"
}
[[ "${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'
@@ -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'