CT-101 Samba + PVE-Host: NFS auf den Host verlagert, Host per Ansible verwaltet

- CT 101 kennt NFS nicht mehr: samba_nfs-Rolle und Ganesha-Artefakte entfernt,
  kein Podman, keine 2049-Firewall-Regel, kein seccomp-Sondereintrag; live
  restlos bereinigt und deckungsgleich mit dem Playbook (verify ok=47,
  Idempotenz changed=0). Dafür neue SMB-Freigabe [Encoding] (Export-Root) für
  die Kontrolle von anderen Geräten als ghostnet-ki.
- Grund: Weder kernel-nfsd noch Ganesha (VFS-FSAL) laufen in einem
  unprivileged LXC — open_by_handle_at braucht CAP_DAC_READ_SEARCH in der
  init-userns und liefert dort immer EPERM (verifiziert auf Kernel
  7.0.14-14-pve; Web-Recherche: NFS direkt auf dem PVE-Host ist gängige Praxis
  und anerkannte Ausnahme vom Host-Grundsatz).
- Neuer Account ansible-deploy (UID 1001) auf dem PVE-Host 10.0.0.2 nach
  LXC-Muster: gelocktes Passwort, source-restrictierter passphrase-loser Key
  (nur pub im Repo), NOPASSWD-Sudoers; Host-Key-Fingerprint verifiziert.
- Neues Host-Projekt proxmox/pve-host/ansible (Schema wie die CT-Projekte):
  preflight/site.yml/verify-production + Rollen pve_nfs_export (Kernel-NFS,
  /etc/exports nur 10.0.0.90/32, NFSv4-only, Export-Root 100000:970 2775,
  nft 2049 nur von 10.0.0.90) und pve_lxc_conf (seccomp-Leerzeile aus
  101.conf entfernt). Auf dem Host grün: check changed=0, verify ok=15,
  Idempotenz changed=0.
- Abnahme bestanden: NFSv4.2-Mount von ghostnet-ki gegen
  10.0.0.2:/media/datenhdd3/Encoding (Export-Root 100000:970 2775 sichtbar),
  sauberer Unmount. CT 101 nach Reboot: Seccomp 2, keine failed units, 17
  Shares, 10 CIFS-Mounts der Workstation hängen.
- Doku aktualisiert: GNAI-017 §4.1 und GNAI-019 E2/E3/§15 revidiert,
  Bereichsindex ergänzt, READMEs angepasst; PVE-Lifecycle-Skripte ohne
  seccomp-Ausnahme (Hashes aktualisiert).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-10-30 18:49:10 +01:00
parent 2753677c7c
commit 6a1eff7392
46 changed files with 768 additions and 1015 deletions
+1
View File
@@ -18,6 +18,7 @@ Hypervisors selbst.
| [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 |
| [PVE-Host-Automation](../../../proxmox/pve-host/README.md) | Ansible-Verwaltung des PVE-Hosts selbst (`10.0.0.2`): Deploy-Account, Kernel-NFS-Export, LXC-Conf |
Die versionierte Quelle für den optionalen, secretfreien
Community-Scripts-Post-install-Hook liegt unter
@@ -59,29 +59,37 @@ guests = yes`, `obey pam restrictions = yes`.
### 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).
**Korrektur 30.10.2026:** Der Export läuft als **Kernel-NFS
(`nfs-kernel-server`) auf dem PVE-Host** — umgesetzt via
`Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)` (idempotent, versioniert im Repo).
Zuvor war Ganesha-in-CT-101 geplant; das ist in einem unprivileged LXC nicht
möglich (`open_by_handle_at` braucht CAP_DAC_READ_SEARCH in der init-userns
und liefert dort immer EPERM — verifiziert 30.10.2026 auf Kernel
7.0.14-14-pve; der leere `lxc.seccomp.profile:`-Eintrag war dafür nicht
ausreichend). Web-Recherche zu Proxmox-Best-Practices (30.10.2026): NFS direkt
auf dem Host ist gängige Praxis zum Teilen von Host-Storage und eine
anerkannte Ausnahme vom Grundsatz „keine Extra-Services auf dem Host";
Reproduzierbarkeit bleibt durch das versionierte Script gewährleistet.
- 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).
- Export ausschließlich für `ghostnet-ki` (`10.0.0.90/32`) via `/etc/exports`
(`rw,sync,no_subtree_check,root_squash`), **NFSv4-only** (NFSv2/v3 auf dem
Host abgeschaltet). `showmount` gilt nicht; Abnahme ist ein realer
NFSv4.2-Mount von `ghostnet-ki` gegen die PVE-Host-IP.
- Mount auf `ghostnet-ki`: `/var/lib/ghostnet-encoding`
(`mount -t nfs -o nfsvers=4.2,hard,noatime <pve-host-ip>:/media/datenhdd3/Encoding …`)
— verwaltet vom Encoding-Ansible (GNENC-001 Abschnitt 6; die Mount-Quelle
ändert sich von `10.0.0.9` auf die PVE-Host-IP).
- Export-Root `/media/datenhdd3/Encoding` auf dem Host: `100000:970 2775`
(100000 = CT-101-Gast-root, damit die SMB-Freigabe `[Encoding]` aus CT 101
schreiben kann; GID 970 = `gn-media`, group-write + setgid für die Pipeline-
Identitäten via AUTH_SYS). Die Pipeline-Unterverzeichnisse entstehen via NFS
von `ghostnet-ki` (Owner/Group gemäß GNENC-002 Abschnitt 4).
- Host-Firewall: 2049 tcp+udp nur von 10.0.0.90 (nft-Tabelle
`ghostnet_host_nfs`, sofern pve-firewall den Host nicht verwaltet).
- CT 101 kennt NFS nicht mehr: keine `samba_nfs`-Rolle, kein Podman, keine
2049-Regel in der CT-Firewall, kein seccomp-Eintrag in `/etc/pve/lxc/101.conf`.
Dafür SMB-Freigaben `[Encode]`, `[Encode-In]` und `[Encoding]` (Root) für die
Pipeline und die Kontrolle von anderen Geräten.
### 4.2 Gruppen- und UID-Abgleich
@@ -66,59 +66,48 @@ ist als Jinja2-Template im Repository gepflegt (Entscheidung 8).
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`)
## 4. Entscheidung 2: NFS-Export als Kernel-NFS auf dem PVE-Host (revidiert 30.10.2026)
**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).
**Entscheidung (revidiert):** Der NFS-Export für die Encoding-Pipeline
(GNAI-018 R1) läuft als **Kernel-NFS (`nfs-kernel-server`) auf dem
Proxmox-Host**, exportiert `/media/datenhdd3/Encoding` exklusiv für
`ghostnet-ki` (`10.0.0.90/32`), NFSv4-only — umgesetzt durch das idempotente,
versionierte Script `Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)` (siehe
`scripts/README.md`). CT 101 kennt NFS nicht mehr: keine `samba_nfs`-Rolle,
kein Podman, keine 2049-Firewall-Regel, kein `lxc.seccomp.profile:`-Eintrag.
Dafür stellt CT 101 die SMB-Freigaben `[Encode]`, `[Encode-In]` und
`[Encoding]` (Root) für die Pipeline bzw. die Kontrolle von anderen Geräten.
**Begründung:**
**Begründung:** Weder Kernel-nfsd noch NFS-Ganesha (VFS-FSAL) können in einem
unprivileged LXC laufen: `open_by_handle_at` — für Ganesha zwingend nötig —
erfordert `CAP_DAC_READ_SEARCH` in der init-userns und liefert im unprivileged
LXC immer EPERM (verifiziert 30.10.2026 auf Kernel 7.0.14-14-pve; der leere
Seccomp-Eintrag der ursprünglichen Planung war dafür nicht ausreichend).
Web-Recherche zu Proxmox-Best-Practices (30.10.2026): NFS direkt auf dem
PVE-Host ist gängige Praxis zum Teilen von Host-Storage und eine anerkannte
Ausnahme vom Grundsatz „keine Extra-Services auf dem Host“; die
Reproduzierbarkeit des Host-Änderung ist durch das versionierte Script im
Repository gegeben. virtiofs/9p scheiden aus, weil `ghostnet-ki` eine eigene
physikalische Maschine im LAN ist.
- 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`).
**Konsequenz:** Host-seitige Änderung (Paket `nfs-kernel-server`,
`/etc/exports`, ggf. Host-Firewall 2049 nur von 10.0.0.90), abgesichert durch
eine Confirmation-Env-Variable; CT-Upgrades bleiben durch `vzdump` gesichert.
Export-Root auf dem Host: `100000:970 2775` (100000 = CT-101-Gast-root für den
SMB-Zugriff aus CT 101; GID 970 = `gn-media` group-write + setgid für die
Pipeline-Identitäten via AUTH_SYS). Der Ganesha-Ära-Eintrag
`lxc.seccomp.profile:` in `/etc/pve/lxc/101.conf` wird entfernt — CT 101 läuft
wieder mit Default-Seccomp wie CT 100/102/103. Mount auf `ghostnet-ki`:
`/var/lib/ghostnet-encoding` via `<pve-host-ip>:/media/datenhdd3/Encoding`
(NFSv4.2; die Mount-Quelle wechselt von `10.0.0.9` auf die Host-IP).
## 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).
**Entscheidung:** Der Export ist NFSv4-only (NFSv2/v3 auf dem Host
abgeschaltet, kein mountd/rpcbind), Transport TCP+UDP, `root_squash` (Default),
client-beschränkt auf `10.0.0.90/32` in `/etc/exports`. Mount-Pfad auf
`ghostnet-ki`: `<pve-host-ip>:/media/datenhdd3/Encoding` (host-seitig seit der
Revision 30.10.2026, nicht mehr `10.0.0.9`).
**Begründung:** NFSv4 benötigt keinen Portmapper und kein mountd — weniger
Angriffsfläche und keine Legacy-Protokolle. Die Pipeline mountet NFSv4.2
@@ -126,8 +115,9 @@ Angriffsfläche und keine Legacy-Protokolle. Die Pipeline mountet NFSv4.2
**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`).
von `ghostnet-ki` gegen den PVE-Host** interpretiert; dasselbe gilt für Punkt 8
(Dienste nach Reboot: `nfs-kernel-server` auf dem PVE-Host statt
`ghostnet-samba-nfs.service` in CT 101).
## 6. Entscheidung 4: Identität — `gn-media` GID 970, aufgezeichnete Medien-UIDs, Mitgliedschaft nur `ghost`
@@ -342,6 +332,12 @@ dieselbe Person auf Medien-/SMB-Ebene ist nicht erforderlich.
`smb.conf` (inkl. `admin users` auf elf Shares, `write list` auf
`[Dokumente]`/`[Extern]`; `[Serien2]` ohne `force user/group`), damit die
Konvergenz keine produktiven Share-Rechte ändert.
- **Revision 30.10.2026:** Entscheidung 2/3 revidiert — der NFS-Export läuft
als Kernel-NFS auf dem PVE-Host (`Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)`),
nicht als Ganesha-Quadlet in CT 101 (`open_by_handle_at` → EPERM im
unprivileged LXC, verifiziert). GNAI-017 Abschnitt 4.1 entsprechend
aktualisiert; `samba_nfs`-Rolle, Podman, 2049-Regel und seccomp-Eintrag aus
CT 101 entfernt; SMB-Freigabe `[Encoding]` (Root) ergänzt.
## 16. Offene Punkte
@@ -1,26 +0,0 @@
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"
# -p /run/ganesha.pid: the container runs ReadOnly with /run as a rw tmpfs, so
# the compiled-in default /var/run/ganesha/ganesha.pid is not writable there
# (the directory does not exist -> ENOENT at startup). /run itself exists and
# is writable. Verified with ganesha.nfsd -p on 2026-10-30.
CMD ["/usr/bin/ganesha.nfsd", "-F", "-f", "/etc/ganesha/ganesha.conf", "-L", "/dev/stderr", "-p", "/run/ganesha.pid"]
+28 -24
View File
@@ -2,17 +2,18 @@
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.
repository-wide pinned control environment; 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
templated `smb.conf` with a `testparm` gate), 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.
is declared in the inventory. The encoding-pipeline NFS export is served by
the **PVE host** (kernel NFS, `Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)`), not by
this CT — NFS (kernel or Ganesha) cannot run in an unprivileged LXC because
`open_by_handle_at` requires CAP_DAC_READ_SEARCH in the init user namespace.
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:
@@ -71,7 +72,7 @@ role argument specs reject them before any task runs). `preflight.yml` on the
| `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`) |
| `site.yml` | `production` | converges the full declared state (roles in order: `samba_lxc_baseline`, `samba_access`, `samba_firewall`, `samba_media`, `samba_smb`) |
| `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
@@ -86,11 +87,10 @@ 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`.
When the dist-upgrade was performed manually instead of via the script,
ensure the empty `lxc.seccomp.profile:` line is present in
`/etc/pve/lxc/101.conf` and reboot the CT — the Ganesha container needs it
(GNAI-019 decision 2).
CT 101 with a pre-upgrade `vzdump`. No `lxc.seccomp.profile` change is
needed anymore — that exception existed only for the Ganesha NFS container
(GNAI-019 decision 2, reverted 2026-10-30: the NFS export now runs on the
PVE host).
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`
@@ -114,13 +114,14 @@ keeps the healthy file server and its Samba passdb intact:
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).
convergence (templated `smb.conf`, nftables, SSH drop-in).
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).
15. NFSv4.2 mount test from `ghostnet-ki` (`10.0.0.90`) against the PVE-host
export (`Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)`); `showmount` does not
apply (NFSv4-only, GNAI-019).
## Fresh-state rebuild runbook
@@ -171,17 +172,20 @@ and installs; the only state that cannot be declared is secret material.
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`).
9. NFSv4.2 mount test from `ghostnet-ki` (`10.0.0.90`) against the PVE-host
export.
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.
UIDs, `gn-media` GID 970 with the declared membership, the 15 shares from the
templated `smb.conf` (incl. `[Encode]`/`[Encode-In]`/`[Encoding]` for the
pipeline), and the nftables policy. The pipeline's NFS export is served by the
PVE host (`Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)`), not by this CT. 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
Do not store SMB passwords, the Samba passdb, the `ghost` password or private
SSH keys 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/`.
@@ -12,8 +12,6 @@ samba_expected_distribution_major_version: "13"
# disabled, so the live server does not answer NetBIOS names):
samba_expected_hostname: Samba
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 (recorded 2026-10-27; round values at/below the live
# capacity, CT 100/102 convention):
samba_minimum_root_size_bytes: 4000000000
@@ -28,10 +26,6 @@ samba_firewall_smb_tcp_ports:
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
# Mirrored from the samba_access role defaults so that verify-production.yml
# runs with a self-contained inventory (CT 100/102 convention):
@@ -252,23 +246,16 @@ samba_smb_media_shares:
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: >-
54cf9cae60b3a3849d61213e5892f8ec14a2c8bdffeed2c8586ff727c73aef73
- name: Encoding
comment: "Encoding-Pipeline: Root (Verwaltung/Kontrolle per SMB, z.B. von anderen Geraeten)"
path: /media/datenhdd3/Encoding
read_only: "no"
valid_users:
- ghost
write_list:
- ghost
admin_users:
- ghost
force_group: gn-media
create_mask: "0664"
directory_mask: "0775"
@@ -12,8 +12,6 @@ samba_expected_distribution_major_version: "13"
# disabled, so the live server does not answer NetBIOS names):
samba_expected_hostname: Samba
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 (recorded 2026-10-27; round values at/below the live
# capacity, CT 100/102 convention):
samba_minimum_root_size_bytes: 4000000000
@@ -28,10 +26,6 @@ samba_firewall_smb_tcp_ports:
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
# Mirrored from the samba_access role defaults so that verify-production.yml
# runs with a self-contained inventory (CT 100/102 convention):
@@ -252,23 +246,16 @@ samba_smb_media_shares:
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: >-
54cf9cae60b3a3849d61213e5892f8ec14a2c8bdffeed2c8586ff727c73aef73
- name: Encoding
comment: "Encoding-Pipeline: Root (Verwaltung/Kontrolle per SMB, z.B. von anderen Geraeten)"
path: /media/datenhdd3/Encoding
read_only: "no"
valid_users:
- ghost
write_list:
- ghost
admin_users:
- ghost
force_group: gn-media
create_mask: "0664"
directory_mask: "0775"
@@ -12,8 +12,6 @@ samba_expected_distribution_major_version: "13"
# disabled, so the live server does not answer NetBIOS names):
samba_expected_hostname: Samba
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 (recorded 2026-10-27; round values at/below the live
# capacity, CT 100/102 convention):
samba_minimum_root_size_bytes: 4000000000
@@ -28,10 +26,6 @@ samba_firewall_smb_tcp_ports:
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
# Mirrored from the samba_access role defaults so that verify-production.yml
# runs with a self-contained inventory (CT 100/102 convention):
@@ -252,23 +246,16 @@ samba_smb_media_shares:
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: >-
54cf9cae60b3a3849d61213e5892f8ec14a2c8bdffeed2c8586ff727c73aef73
- name: Encoding
comment: "Encoding-Pipeline: Root (Verwaltung/Kontrolle per SMB, z.B. von anderen Geraeten)"
path: /media/datenhdd3/Encoding
read_only: "no"
valid_users:
- ghost
write_list:
- ghost
admin_users:
- ghost
force_group: gn-media
create_mask: "0664"
directory_mask: "0775"
@@ -50,7 +50,6 @@
- /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
@@ -154,26 +153,6 @@
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:
@@ -20,7 +20,6 @@
- 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
@@ -99,9 +98,8 @@
- 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.
CT 101 must stay an unprivileged LXC (GNAI-015); the gn-media
alignment depends on the standard Proxmox ID mapping.
- name: Read the unprivileged SSH identity
ansible.builtin.command:
@@ -205,10 +203,9 @@
- 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.
served.
- name: Verify the declared media mount points are mounted
ansible.builtin.assert:
@@ -218,8 +215,8 @@
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; stop
the adoption until the storage topology is complete.
shares depend on the complete media library; stop the adoption until
the storage topology is complete.
success_msg: All declared media mount points are mounted.
- name: Read the Samba server version
@@ -389,16 +386,6 @@
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:
@@ -446,9 +433,6 @@
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: >-
@@ -21,8 +21,6 @@
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
@@ -194,10 +194,6 @@
- >-
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.
@@ -415,129 +411,6 @@
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:
@@ -553,7 +426,6 @@
- smbd.service
- nmbd.service
- unattended-upgrades.service
- "{{ samba_nfs_service_unit }}"
register: samba_production_services
- name: Require every production service to be active
@@ -574,34 +446,14 @@
changed_when: false
register: samba_production_listeners
- name: Require the SSH, SMB and NFS listeners
- name: Require the SSH and SMB 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.
The CT 101 service listeners for SSH and SMB are incomplete.
- name: Read the host cgroup filesystem type
ansible.builtin.command:
@@ -618,7 +470,7 @@
ansible.builtin.assert:
that:
- samba_production_cgroup.stdout == "cgroup2fs"
fail_msg: The Podman runtime requires cgroup v2.
fail_msg: The cgroup filesystem must be cgroup v2 (cgroup2fs).
- name: Read the pre-migration backup directories
ansible.builtin.command:
@@ -658,9 +510,5 @@
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 }}
@@ -6,9 +6,9 @@ Declaratively manages the CT 101 nftables boundary (GNAI-018 R7):
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.
- NFS is not opened on CT 101 at all: the encoding-pipeline export
(`/media/datenhdd3/Encoding`) is served by the PVE host itself (kernel NFS,
see `Host-Projekt `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)`), so no 2049 rule exists here.
- Forward policy `drop` without allow rules; output policy `accept`.
The policy lives in the Ghostnet-owned table `inet ghostnet_filter`. The table
@@ -8,6 +8,3 @@ samba_firewall_smb_tcp_ports:
samba_firewall_smb_udp_ports:
- 137
- 138
samba_firewall_nfs_client_ipv4: 10.0.0.90
samba_firewall_nfs_ports:
- 2049
@@ -18,10 +18,3 @@ argument_specs:
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
@@ -5,8 +5,6 @@
- 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
@@ -86,10 +84,7 @@
- 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).
baseline (input/forward drop, SSH+SMB from the LAN).
when: not ansible_check_mode
@@ -26,9 +26,6 @@ table inet ghostnet_filter {
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"
}
@@ -14,7 +14,7 @@ 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
The unprivileged assertion is load-bearing: the `gn-media` UID/GID alignment
with `ghostnet-ki` (GNAI-019) assumes 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.
@@ -23,26 +23,10 @@ samba_lxc_baseline_directories:
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
@@ -52,9 +52,10 @@
- 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.
CT 101 must stay an unprivileged LXC. The gn-media UID/GID alignment
with ghostnet-ki depends on the standard Proxmox ID mapping; a
privileged conversion changes the UID/GID semantics of the media
library.
- name: Mask the configfs mount unit (configfs is unavailable in unprivileged LXC)
ansible.builtin.systemd_service:
@@ -1,44 +0,0 @@
# `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.
@@ -1,18 +0,0 @@
---
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-30):
samba_nfs_containerfile_sha256: >-
54cf9cae60b3a3849d61213e5892f8ec14a2c8bdffeed2c8586ff727c73aef73
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
@@ -1,10 +0,0 @@
---
- 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
when: not ansible_check_mode
@@ -1,47 +0,0 @@
---
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
@@ -1,296 +0,0 @@
---
- 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
failed_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.
when: not ansible_check_mode or not samba_media_group_task.changed | default(false) | bool
- 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
@@ -1,52 +0,0 @@
# 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;
# Unprivileged LXC: PR_SET_IO_FLUSHER needs CAP_SYS_ADMIN in the init user
# namespace, which a guest root does not have; allow the failure (verified
# with ganesha.nfsd in the image on 2026-10-30).
Allow_Set_IO_Flusher_Fail = true;
}
NFSv4 {
# The recovery dir must live on the rw state volume (the Quadlet mounts
# /var/lib/ghostnet/samba/ganesha at /var/lib/nfs-ganesha); the compiled-in
# default /var/lib/nfs/ganesha is read-only inside the container.
RecoveryRoot = /var/lib/nfs-ganesha;
}
EXPORT_DEFAULTS {
Access_Type = None;
}
# Client allowlist: only ghostnet-ki gets RW (GNAI-017 section 4.1). Ganesha
# 6.5 syntax: CLIENT block with lowercase `clients` parameter, and it must
# appear BEFORE the EXPORT block (parser is order-sensitive; verified against
# the 6.5 parser on 2026-10-30). Additionally the nftables policy only admits
# 2049 from 10.0.0.90 (samba_firewall, asserted by verify-production.yml).
CLIENT {
clients = {{ samba_nfs_client_ipv4 }};
Access_Type = RW;
}
EXPORT {
Export_Id = {{ samba_nfs_export_id }};
Path = {{ samba_nfs_export_path }};
Pseudo = {{ samba_nfs_export_path }};
Access_Type = None;
Squash = rootid;
SecType = sys;
Protocols = 4;
Transports = TCP,UDP;
FSAL {
Name = VFS;
}
}
LOG {
Default_Log_Level = INFO;
}
@@ -1,50 +0,0 @@
[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
@@ -1,9 +0,0 @@
#!/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
+18 -10
View File
@@ -20,17 +20,19 @@ productive `smb.conf`). Sequence:
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).
3. No `lxc.seccomp.profile` change is needed anymore: that exception existed
only for the Ganesha NFS container inside CT 101. Since 2026-10-30 the NFS
export runs on the PVE host (see `proxmox/pve-host/ansible/` (Rolle `pve_nfs_export`)); if the
empty line is still present from the earlier Ganesha setup, the script
removes it again (the NFS export can no longer run in an unprivileged LXC:
`open_by_handle_at` needs CAP_DAC_READ_SEARCH in the init user namespace).
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 six media
mounts mounted.
unprivileged uid_map intact, all six media mounts mounted.
Before the run, all media mounts in the productive `pct config 101` must carry
the `backup=no` option. Installation and invocation:
@@ -51,13 +53,13 @@ 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
`99f34cd991143d7d11dc1f64a49d29e966dee9db23737d081b06b67c1e409aac`.
`7671722cbd5107b0453ec5d016d8ffed82f36ec34d959f50507c21dc953c00a4`.
## `provision-ct101-samba.sh` (fresh-state rebuild)
Creates a fresh CT 101 from a Debian 13 template with the recorded profile,
attaches the six media mounts, disables the LXC seccomp filter and then runs
the reviewed baseline hook with the recorded account UIDs. After the hook it
attaches the six media mounts and then runs the reviewed baseline hook with
the recorded account UIDs (no seccomp change — see above). After the hook it
masks `sys-kernel-config.mount` (configfs is unavailable in unprivileged LXC;
CT 100/102/103 convention) and clears a stale failed state, so the fresh CT
passes the no-failed-units gate of `playbooks/preflight.yml`. The script
@@ -73,7 +75,6 @@ setting explicitly.
| Rootfs | 4 GiB on `local-lvm` (recorded profile, 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`–`mp5` → `/media/daten1`, `/media/daten2`, `/media/daten3`, `/media/datenhdd1`, `/media/datenhdd2/extern`, `/media/datenhdd3`, each with `backup=no` |
| Baseline hook | `/usr/local/sbin/ghostnet-lxc-baseline.sh` (SHA-256 pinned) |
@@ -115,8 +116,15 @@ steps (set the ghost password, install the public keys from
placeholders, rebuild sequence from `ansible/README.md`).
The reviewed repository copy has SHA-256
`f40f3da836d3b0b31773c8293fa245eb970fae079dfe6189c91c699a48c21306`.
`74447ae18ab294fae08845490a214959e8e2a629c6aaf798351b0669e2f91823`.
## NFS-Export auf dem PVE-Host
Der Kernel-NFS-Export von `/media/datenhdd3/Encoding` läuft nicht mehr als
manuelles Script, sondern deklarativ über das Host-Projekt
[`proxmox/pve-host/ansible/`](../../../pve-host/README.md) (Rolle `pve_nfs_export`,
Playbook `site.yml` auf dem PVE-Host via `ansible-deploy`). Siehe dort für
Account-Prep und Runbook.
## `ghostnet-lxc-baseline.sh`
The versioned source lives under
@@ -2,11 +2,11 @@
# 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 six 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. It then masks the
# configfs mount unit (configfs is unavailable in unprivileged LXC; CT
# 100/102/103 convention) and clears a stale failed state.
# attaches the six media mounts and runs the reviewed generic LXC baseline
# hook with the recorded account UIDs. It then masks the configfs mount unit
# (configfs is unavailable in unprivileged LXC; CT 100/102/103 convention)
# and clears a stale failed state. No lxc.seccomp.profile change: the NFS
# export runs on the PVE host since 2026-10-30 (GNAI-019 decision 2 reverted).
#
# 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
@@ -177,17 +177,6 @@ for index in "${!MOUNT_GUEST_PATHS[@]}"; do
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'
@@ -202,8 +191,6 @@ 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' \
@@ -284,13 +271,9 @@ for mount_point in "${mounts[@]}"; do
awk -v target="$mount_point" '$2 == target { found = 1 } END { exit(found ? 0 : 1) }' /proc/mounts ||
die "media mount point is not mounted: $mount_point"
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"
@@ -4,9 +4,10 @@
# (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.
# Sequence: guest preflight, pre-upgrade vzdump (mode stop), in-guest
# bookworm -> trixie dist-upgrade, reboot, post-verify. If the Ganesha-era
# empty lxc.seccomp.profile line is present, it is removed again (the NFS
# export runs on the PVE host since 2026-10-30; GNAI-019 decision 2 reverted).
#
# The script contains no password or private key. The SMB passdb and the
# productive smb.conf survive the upgrade (conffile kept via
@@ -136,17 +137,14 @@ for _ in $(seq 1 60); do
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.
# Revert the Ganesha-era seccomp exception: the NFS export now runs on the PVE
# host, so CT 101 uses the default seccomp profile again (like CT 100/102/103).
# 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"
if grep -qx 'lxc.seccomp.profile:' "$conf_file"; then
sed -i '/^lxc\.seccomp\.profile:$/d' "$conf_file"
log 'empty lxc.seccomp.profile removed (Ganesha exception reverted)'
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' ||
@@ -200,7 +198,7 @@ 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'
log 'rebooting CT 101 to complete the transition'
pct reboot "$CTID" 2>/dev/null || pct start "$CTID" ||
die 'reboot failed; run pct start 101 manually'
responsive='no'
@@ -254,8 +252,6 @@ grep -q ':445 ' <<<"$listeners" || die 'SMB listener 445 is missing'
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
awk -v target="$mount_point" '$2 == target { found = 1 } END { exit(found ? 0 : 1) }' /proc/mounts ||
die "media mount is not mounted: ${mount_point}"
@@ -264,7 +260,7 @@ 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 'unprivileged mapping verified'
log "media mounts verified: ${mounts[*]}"
GUEST_POSTVERIFY
+88
View File
@@ -0,0 +1,88 @@
# PVE-Host (`10.0.0.2`) — Ansible-Verwaltung
Projekt zur deklarativen Verwaltung des **Proxmox-VE-Hosts selbst**
(`pve`, `10.0.0.2`). Bis 2026-10-30 liefen Host-Änderungen als manuelle
Root-Skripte auf der Konsole; seit der NFS-Umstellung (GNAI-019 Entscheidung 2,
revidiert) wird persistente Host-State über dieses Ansible-Projekt gepflegt —
reproduzierbar und versioniert im Repo. GNAI-011 §1 sieht ein eigenes
Host-Verfahren vor („Proxmox-Hostzugang … benötigen ein eigenes, späteres
Verfahren“); dieses Projekt ist der Anfang davon. Die **SSH-Härtung des Hosts
selbst** bleibt davon unberührt (GNAI-011 gilt nur für die LXCs) und ist ein
eigenes, späteres Vorhaben.
Aktuell verwaltete Host-State:
- **Kernel-NFS-Export** `/media/datenhdd3/Encoding` → `ghostnet-ki`
(`10.0.0.90/32`), NFSv4-only — Rolle `pve_nfs_export` (Ersatz für das
frühere manuelle Script; CT 101 kann NFS nicht selbst serve, siehe GNAI-019).
- **LXC-Configs**: Entfernen der Ganesha-Ära-`lxc.seccomp.profile:`-Leerzeile
aus `/etc/pve/lxc/101.conf` — Rolle `pve_lxc_conf` (wirksam beim nächsten
CT-Start/Reboot).
Die CT-Lebenszyklus-Skripte (`proxmox/lxc/<ct>/scripts/`: `pct create`,
Dist-Upgrade) bleiben Bootstrap-Skripte und laufen einmalig manuell als
root@pve — sie erzeugen die CTs, bevor Ansible sie erreichen kann.
## Zugang: dedizierter Deploy-Account auf dem Host
Muster wie auf den LXCs/ghostnet-ki: eigener Account, gelocktes Passwort,
passphrase-loser Ed25519-Key mit Source-Restrictions, NOPASSWD-Sudoers. Der
Key ist **root-äquivalent** — nur der public Key liegt im Repo
(`ansible/files/ssh/pve-ansible-deploy.pub`), der private Key nur in `~/.ssh`
(mode 0600).
Einmalig auf der PVE-Konsole (root@pve) ausführen:
```bash
# freie UID >= 1000 wählen, z.B. 1000:
getent passwd | awk -F: '$3>=1000 {print $1, $3}' | sort -k2 -n | head
useradd -m -u <UID> -s /bin/bash ansible-deploy
usermod -L ansible-deploy # Passwort sperren
mkdir -p /home/ansible-deploy/.ssh && chmod 700 /home/ansible-deploy/.ssh
# public Key aus ansible/files/ssh/pve-ansible-deploy.pub eintragen:
cat >> /home/ansible-deploy/.ssh/authorized_keys <<'EOF'
from="10.0.0.0/24,10.8.0.0/24",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFtneJFtlwj7D284Yar7ROTflZqKxi/IEyMcIoqazIDD ansible-deploy@ghostnet-pve
EOF
chown -R ansible-deploy:ansible-deploy /home/ansible-deploy/.ssh
chmod 600 /home/ansible-deploy/.ssh/authorized_keys
cat > /etc/sudoers.d/90-ghostnet-ansible-deploy <<'EOF'
ansible-deploy ALL=(ALL) NOPASSWD: ALL
EOF
chmod 0440 /etc/sudoers.d/90-ghostnet-ansible-deploy
visudo -c
# Host-Key-Fingerprint für die Workstation notieren (siehe unten):
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```
Auf der Workstation: Host-Key-Fingerprint gegen die PVE-Konsole prüfen
(GNAI-011 Abschnitt 3.3), dann `known_hosts` befüllen und testen:
```bash
ssh-keyscan -t ed25519 10.0.0.2 >> ~/.ssh/known_hosts_ghostnet_pve # erst nach Fingerprint-Prüfung!
ssh -o BatchMode=yes ghostnet-pve-ansible-deploy 'id; hostname; sudo -n id -u'
```
ssh-Alias auf der Workstation (bereits in `~/.ssh/config`):
`ghostnet-pve-ansible-deploy` → `ansible-deploy@10.0.0.2` mit
`~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy` (passphrase-los) und
`UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_pve`.
## Runbook
Alle Befehle aus `proxmox/pve-host/ansible/` mit dem venv der Repo-Wurzel:
```bash
cd proxmox/pve-host/ansible
../../../.venv/bin/ansible-playbook -i inventories/production/hosts.yml playbooks/preflight.yml --private-key ~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy
../../../.venv/bin/ansible-playbook -i inventories/production/hosts.yml playbooks/site.yml --check --diff --private-key ~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy
../../../.venv/bin/ansible-playbook -i inventories/production/hosts.yml playbooks/site.yml --private-key ~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy
../../../.venv/bin/ansible-playbook -i inventories/production/hosts.yml playbooks/verify-production.yml --private-key ~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy
../../../.venv/bin/ansible-playbook -i inventories/production/hosts.yml playbooks/site.yml --private-key ~/.ssh/id_ed25519_ghostnet_pve_ansible_deploy # changed=0
```
## Secrets-Policy
Keine Passwörter, privaten Schlüssel oder Secrets in Repo/Inventory/Logs. Der
private Deploy-Key liegt nur in `~/.ssh` (0600). Der Account hat NOPASSWD
sudo — der Key ist root-äquivalent und wird nur für autorisierte
Infrastruktur-Änderungen genutzt.
+12
View File
@@ -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 AAAAC3NzaC1lZDI1NTE5AAAAIFtneJFtlwj7D284Yar7ROTflZqKxi/IEyMcIoqazIDD ansible-deploy@ghostnet-pve
@@ -0,0 +1,16 @@
---
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_pve
# Mirrored from the pve_nfs_export role defaults so that verify-production.yml
# runs with a self-contained inventory (CT 100/102 convention):
pve_nfs_export_path: /media/datenhdd3/Encoding
pve_nfs_client_ipv4: 10.0.0.90
pve_nfs_export_owner_uid: 100000
pve_nfs_export_owner_gid: 970
pve_nfs_export_mode: "2775"
pve_nfs_service_unit: nfs-kernel-server.service
@@ -0,0 +1,12 @@
---
pve_expected_ipv4: 10.0.0.2
pve_expected_hostname: pve
pve_expected_distribution: Debian
pve_expected_distribution_major_version: "13"
# ED25519 host key, captured on the PVE console 2026-10-30 (GNAI-011 3.3) and
# verified against ssh-keyscan from the admin workstation:
pve_expected_ssh_host_key_fingerprint: >-
SHA256:0oMpG9ik9JkThVwI1OE7MlMv1yYCwob6/w45RRd+Asg
# ansible-deploy account on the host (prepared 2026-10-30, locked password,
# NOPASSWD sudo, source-restricted key):
pve_deploy_uid: 1001
@@ -0,0 +1,8 @@
---
all:
children:
pve_hosts:
hosts:
pve-host:
ansible_host: 10.0.0.2
ansible_user: ansible-deploy
@@ -0,0 +1,108 @@
---
- name: Read-only preflight of the Proxmox VE host
hosts: pve_hosts
become: false
gather_facts: true
gather_subset:
- min
- hardware
- network
tasks:
- name: Verify the expected host identity
ansible.builtin.assert:
that:
- ansible_facts["hostname"] == pve_expected_hostname
- ansible_facts["distribution"] == pve_expected_distribution
- >-
ansible_facts["default_ipv4"]["address"] == pve_expected_ipv4
fail_msg: >-
The connected system is not the declared Proxmox VE host
({{ pve_expected_hostname }}, {{ pve_expected_ipv4 }}).
- name: Read the PVE version
ansible.builtin.command:
argv:
- /usr/bin/pveversion
check_mode: false
changed_when: false
register: pve_preflight_version
- name: Read the deployment account identity
ansible.builtin.command:
argv:
- /usr/bin/id
check_mode: false
changed_when: false
register: pve_preflight_id
- name: Read the kernel NFS server package state
ansible.builtin.command:
argv:
- /usr/bin/dpkg-query
- -W
- -f=${Status}
- nfs-kernel-server
check_mode: false
changed_when: false
failed_when: false
register: pve_preflight_nfs_pkg
- name: Read the current exports file
ansible.builtin.slurp:
src: /etc/exports
register: pve_preflight_exports
failed_when: false
- name: Read the PVE firewall status
ansible.builtin.command:
argv:
- /usr/sbin/pve-firewall
- status
check_mode: false
changed_when: false
failed_when: false
register: pve_preflight_fw
- name: Read the CT 101 config seccomp lines
ansible.builtin.command:
argv:
- /usr/bin/grep
- -n
- lxc.seccomp.profile
- /etc/pve/lxc/101.conf
check_mode: false
changed_when: false
failed_when: false
register: pve_preflight_seccomp
- name: Read the NFS listener state
ansible.builtin.command:
argv:
- /usr/bin/ss
- -tln
check_mode: false
changed_when: false
register: pve_preflight_listeners
- name: Report the PVE host preflight baseline
ansible.builtin.debug:
msg:
inventory_host: "{{ inventory_hostname }}"
hostname: "{{ ansible_facts['hostname'] }}"
distribution: >-
{{ ansible_facts['distribution'] }}
{{ ansible_facts['distribution_version'] }}
ipv4: "{{ ansible_facts['default_ipv4']['address'] }}"
pveversion: "{{ pve_preflight_version.stdout }}"
deploy_identity: "{{ pve_preflight_id.stdout }}"
nfs_kernel_server_pkg: >-
{{ pve_preflight_nfs_pkg.stdout | default('not installed') }}
exports_file: >-
{{ (pve_preflight_exports.content | default('') | b64decode)
if pve_preflight_exports.content is defined else '<missing>' }}
pve_firewall_status: "{{ pve_preflight_fw.stdout | default('<none>') }}"
ct101_seccomp_lines: >-
{{ pve_preflight_seccomp.stdout_lines | default([]) }}
nfs_listener_2049: >-
{{ ':2049 ' in pve_preflight_listeners.stdout }}
@@ -0,0 +1,9 @@
---
- name: Converge the Proxmox VE host on the declared state
hosts: pve_hosts
become: true
gather_facts: true
roles:
- role: pve_lxc_conf
- role: pve_nfs_export
@@ -0,0 +1,140 @@
---
- name: Read-only verification of the converged Proxmox VE host state
hosts: pve_hosts
become: true
gather_facts: true
gather_subset:
- min
- hardware
- network
tasks:
- name: Verify the expected host identity
ansible.builtin.assert:
that:
- ansible_facts["hostname"] == pve_expected_hostname
- ansible_facts["distribution"] == pve_expected_distribution
- >-
ansible_facts["default_ipv4"]["address"] == pve_expected_ipv4
fail_msg: The connected system is not the declared Proxmox VE host.
- name: Read the kernel NFS server package state
ansible.builtin.command:
argv:
- /usr/bin/dpkg-query
- -W
- -f=${Status}
- nfs-kernel-server
check_mode: false
changed_when: false
register: pve_verify_nfs_pkg
- name: Require the kernel NFS server to be installed
ansible.builtin.assert:
that:
- pve_verify_nfs_pkg.stdout is search('install ok installed')
fail_msg: nfs-kernel-server is not installed on the PVE host.
- name: Read the NFS service state
ansible.builtin.command:
argv:
- /usr/bin/systemctl
- is-active
- "{{ pve_nfs_service_unit }}"
check_mode: false
changed_when: false
failed_when: false
register: pve_verify_nfs_service
- name: Require the kernel NFS server to be active
ansible.builtin.assert:
that:
- pve_verify_nfs_service.stdout == 'active'
fail_msg: nfs-kernel-server is not active on the PVE host.
- name: Read the applied export table
ansible.builtin.command:
argv:
- /usr/sbin/exportfs
- -s
check_mode: false
changed_when: false
register: pve_verify_exportfs
- name: Require the pipeline export in the applied export table
ansible.builtin.assert:
that:
- pve_verify_exportfs.stdout is
search(pve_nfs_export_path ~ '\\s+' ~ pve_nfs_client_ipv4)
fail_msg: >-
The encoding export is missing from exportfs -s; expected
{{ pve_nfs_export_path }} for {{ pve_nfs_client_ipv4 }}.
- name: Stat the export root
ansible.builtin.stat:
path: "{{ pve_nfs_export_path }}"
register: pve_verify_export_root
- name: Verify the export root ownership and mode
ansible.builtin.assert:
that:
- pve_verify_export_root.stat.exists
- pve_verify_export_root.stat.isdir
- pve_verify_export_root.stat.uid == pve_nfs_export_owner_uid | int
- pve_verify_export_root.stat.gid == pve_nfs_export_owner_gid | int
- >-
'%04o' % (pve_verify_export_root.stat.mode | int(0, 8)) ==
pve_nfs_export_mode
fail_msg: >-
The export root must be
{{ pve_nfs_export_owner_uid }}:{{ pve_nfs_export_owner_gid }}
{{ pve_nfs_export_mode }} (CT 101 guest root for the SMB share,
gn-media GID for the pipeline via AUTH_SYS).
- name: Read the NFSv4 listener
ansible.builtin.command:
argv:
- /usr/bin/ss
- -tln
check_mode: false
changed_when: false
register: pve_verify_listeners
- name: Require the NFSv4 listener on 2049
ansible.builtin.assert:
that:
- pve_verify_listeners.stdout is search(':2049 ')
fail_msg: The PVE host does not listen on 2049 (NFSv4) yet.
- name: Read the CT 101 config seccomp lines
ansible.builtin.command:
argv:
- /usr/bin/grep
- -c
- '^lxc\.seccomp\.profile:\s*$'
- /etc/pve/lxc/101.conf
check_mode: false
changed_when: false
failed_when: false
register: pve_verify_seccomp
- name: Require no empty seccomp profile line in the CT 101 config
ansible.builtin.assert:
that:
- (pve_verify_seccomp.stdout | trim) == '0'
fail_msg: >-
/etc/pve/lxc/101.conf still carries the empty Ganesha-era
lxc.seccomp.profile line (applies at the next CT start).
- name: Report the verified PVE host production state
ansible.builtin.debug:
msg:
hostname: "{{ ansible_facts['hostname'] }}"
ipv4: "{{ ansible_facts['default_ipv4']['address'] }}"
nfs_service: "{{ pve_verify_nfs_service.stdout }}"
export: "{{ pve_nfs_export_path }} -> {{ pve_nfs_client_ipv4 }}/32"
export_root: >-
{{ pve_verify_export_root.stat.uid }}:{{ pve_verify_export_root.stat.gid }}
{{ '%04o' % (pve_verify_export_root.stat.mode | int(0, 8)) }}
{{ pve_nfs_export_path }}
listener_2049: true
@@ -0,0 +1,12 @@
# `pve_lxc_conf`
Manages the LXC container configs in `/etc/pve/lxc/` on the PVE host that this
project owns: currently it removes the **empty `lxc.seccomp.profile:` line**
from the listed CT configs. That line was added for the Ganesha NFS container
inside CT 101 (GNAI-019 decision 2, revised 2026-10-30); with the export on the
host, the CTs run with the default seccomp profile again, like CT 100/102/103.
The change applies at the next CT start/reboot (`pct reboot <ctid>`), not
live. `pct set` rewrites the config file, so direct edits here are safe for
lines pct does not manage; the CT-101 PVE scripts handle the same line during
provisioning/upgrade.
@@ -0,0 +1,6 @@
---
# LXC configs on the PVE host whose Ganesha-era empty seccomp profile line is
# removed again (the NFS export now runs on the host). Takes effect at the
# next CT start/reboot.
pve_lxc_conf_ctids:
- 101
@@ -0,0 +1,9 @@
---
- name: Remove the Ganesha-era empty seccomp profile line from the CT configs
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ item }}.conf"
regexp: '^lxc\.seccomp\.profile:\s*$'
state: absent
loop: "{{ pve_lxc_conf_ctids }}"
loop_control:
label: "CT {{ item }}"
@@ -0,0 +1,24 @@
# `pve_nfs_export`
Serves the encoding-pipeline directory `/media/datenhdd3/Encoding` from the
**Proxmox VE host** via kernel NFS (NFSv4-only) to `ghostnet-ki`
(`10.0.0.90/32`), replacing the reverted Ganesha-in-CT-101 design (GNAI-019
decision 2, revised 2026-10-30: NFS cannot run in an unprivileged LXC —
`open_by_handle_at` needs CAP_DAC_READ_SEARCH in the init user namespace).
- Installs `nfs-kernel-server`.
- Disables NFSv2/v3 in `/etc/default/nfs-kernel-server` (`RPCNFSDARGS`).
- Manages a marked block in `/etc/exports` (idempotent `blockinfile`):
`<path> <client>/32(rw,sync,no_subtree_check,root_squash)`.
- Sets the export root to `100000:970 2775`: UID 100000 is the CT 101 guest
root (so the SMB `[Encoding]` share inside CT 101 can write), GID 970 is
`gn-media` (group-write + setgid for the pipeline identities arriving via
AUTH_SYS from ghostnet-ki; `root_squash` stays active).
- Applies `exportfs -ra` and enables/starts `nfs-kernel-server.service`.
- Firewall: when `pve-firewall` is **not** active on the host, allows
2049/tcp+udp from the client only, in a dedicated nftables table
`inet ghostnet_host_nfs`; when pve-firewall **is** active, the role only
prints the required PVE-firewall rule instead of touching nftables.
Post-state verification lives in `playbooks/verify-production.yml`
(package, service, `exportfs -s`, export-root ownership/mode, listener 2049).
@@ -0,0 +1,17 @@
---
# Kernel-NFS export of the encoding-pipeline directory on the PVE host.
# CT 101 cannot serve NFS itself (unprivileged LXC: open_by_handle_at needs
# CAP_DAC_READ_SEARCH in the init user namespace -> always EPERM there).
pve_nfs_export_path: /media/datenhdd3/Encoding
pve_nfs_client_ipv4: 10.0.0.90
# Export-root ownership on the host: 100000 = CT 101 guest root (so the SMB
# [Encoding] share inside CT 101 can write), 970 = gn-media (group-write for
# the pipeline identities arriving via AUTH_SYS from ghostnet-ki).
pve_nfs_export_owner_uid: 100000
pve_nfs_export_owner_gid: 970
pve_nfs_export_mode: "2775"
pve_nfs_packages:
- nfs-kernel-server
pve_nfs_service_unit: nfs-kernel-server.service
pve_nfs_firewall_table: ghostnet_host_nfs
pve_nfs_firewall_port: 2049
@@ -0,0 +1,110 @@
---
- name: Require the NFS export declarations
ansible.builtin.assert:
that:
- pve_nfs_export_path | length > 0
- pve_nfs_client_ipv4 | length > 0
- pve_nfs_service_unit | length > 0
fail_msg: The NFS export path, client and service unit must be declared.
- name: Require the export root to exist
ansible.builtin.stat:
path: "{{ pve_nfs_export_path }}"
register: pve_nfs_export_root_probe
- name: Fail closed when the export root is missing
ansible.builtin.assert:
that:
- pve_nfs_export_root_probe.stat.exists
- pve_nfs_export_root_probe.stat.isdir
fail_msg: >-
{{ pve_nfs_export_path }} does not exist on the PVE host. The media
mount must be present before the export converges.
- name: Install the kernel NFS server
ansible.builtin.apt:
name: "{{ pve_nfs_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
install_recommends: false
- name: Disable NFSv2/v3 in the kernel server defaults (NFSv4-only)
ansible.builtin.lineinfile:
path: /etc/default/nfs-kernel-server
create: true
owner: root
group: root
mode: "0644"
regexp: '^RPCNFSDARGS='
line: 'RPCNFSDARGS="--no-nfs-version 2 --no-nfs-version 3"'
- name: Manage the marked export block in /etc/exports
ansible.builtin.blockinfile:
path: /etc/exports
create: true
owner: root
group: root
mode: "0644"
marker: "# {mark} ghostnet CT-101 encoding export (managed by role pve_nfs_export)"
block: |
{{ pve_nfs_export_path }} {{ pve_nfs_client_ipv4 }}/32(rw,sync,no_subtree_check,root_squash)
- name: Set the export root ownership and mode
ansible.builtin.file:
path: "{{ pve_nfs_export_path }}"
state: directory
owner: "{{ pve_nfs_export_owner_uid }}"
group: "{{ pve_nfs_export_owner_gid }}"
mode: "{{ pve_nfs_export_mode }}"
- name: Apply the export table
ansible.builtin.command:
argv:
- /usr/sbin/exportfs
- -ra
changed_when: false
- name: Enable and start the kernel NFS server
ansible.builtin.systemd_service:
name: "{{ pve_nfs_service_unit }}"
enabled: true
state: started
- name: Read the PVE firewall status
ansible.builtin.command:
argv:
- /usr/sbin/pve-firewall
- status
check_mode: false
changed_when: false
failed_when: false
register: pve_nfs_firewall_status
- name: Allow 2049 from the pipeline client in a dedicated nftables table
ansible.builtin.shell: |
set -euo pipefail
changed=0
nft list table inet {{ pve_nfs_firewall_table }} >/dev/null 2>&1 ||
{ nft add table inet {{ pve_nfs_firewall_table }}; changed=1; }
nft list chain inet {{ pve_nfs_firewall_table }} input >/dev/null 2>&1 ||
{ nft add chain inet {{ pve_nfs_firewall_table }} input '{ type filter hook input priority 10; policy accept; }'; changed=1; }
nft list ruleset | grep -q 'ip saddr {{ pve_nfs_client_ipv4 }} tcp dport {{ pve_nfs_firewall_port }}' ||
{ nft add rule inet {{ pve_nfs_firewall_table }} input ip saddr {{ pve_nfs_client_ipv4 }} tcp dport {{ pve_nfs_firewall_port }} accept; changed=1; }
nft list ruleset | grep -q 'ip saddr {{ pve_nfs_client_ipv4 }} udp dport {{ pve_nfs_firewall_port }}' ||
{ nft add rule inet {{ pve_nfs_firewall_table }} input ip saddr {{ pve_nfs_client_ipv4 }} udp dport {{ pve_nfs_firewall_port }} accept; changed=1; }
if [ "$changed" = 1 ]; then echo CHANGED; fi
args:
executable: /bin/bash
when: not (pve_nfs_firewall_status.stdout | lower is search('enabled'))
register: pve_nfs_nft_rules
changed_when: "'CHANGED' in pve_nfs_nft_rules.stdout"
- name: Note when pve-firewall manages the host
ansible.builtin.debug:
msg: >-
pve-firewall is active on the host — add the allow rule there instead
(host/cluster firewall: input, source {{ pve_nfs_client_ipv4 }}, ports
{{ pve_nfs_firewall_port }}/tcp+udp). The dedicated nft table
{{ pve_nfs_firewall_table }} was not touched.
when: pve_nfs_firewall_status.stdout | lower is search('enabled')