package web import ( "crypto/rand" "encoding/hex" "os" "path/filepath" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // In-guest uid-1000 write probe (NAS verify-before-commit, SPIKE-nas-verify Q2/Q3). The agent's // verify proves the MOUNT is real; this probe proves a uid/gid-1000 app can actually WRITE through // it — the squash trap (Q3 row c: an export that mounts fine but denies every write). The probe is // a RE-EXEC of this binary (`felhom-controller --netprobe `) dropped to uid/gid 1000 via // SysProcAttr.Credential (spike Q2: SETUID/SETGID caps present in the container) — no shell, no // in-process setuid (which would poison the whole Go runtime's credentials). // Probe child exit codes (the parent maps them to verify categories). const ( netProbeExitOK = 0 // write + readback + delete all fine netProbeExitNoWrite = 2 // create/write failed → not_writable (the squash trap) netProbeExitMismatch = 3 // readback failed or differed → probe_io netProbeExitCleanup = 4 // wrote fine but delete failed → OK + warn (never a failure) netProbeExitNotNetFS = 5 // dir is not a mounted network fs → not_network_fs (the stub trap, RCA fix 2) ) // netProbeReadBack is the child's readback seam (package var — the child is a re-exec'd process in // production, so a struct seam can't reach it; tests override in-process). var netProbeReadBack = os.ReadFile // netProbeFSClass is the child's namespace-classification seam (RCA fix 2): the real value is // system.ClassifyPathFS (statfs f_type) on linux, a vacuous FSClassNetwork off-linux (the child // only ever runs in the linux container). Tests override to model a stub without a real mount. var netProbeFSClass = platformNetProbeFSClass // NetProbeChild is the --netprobe body, run AS uid/gid 1000 by the re-exec parent: create a // dot-file with a random name + nonce in dir — the create legitimately triggers the automount — // THEN require the dir to be a MOUNTED network filesystem (after a create, an autofs or local // answer means the mount did not materialize: the RCA's silent-stub trap), then read back, // compare, remove. Pure file logic — unit-tested directly in t.TempDir(). Exposed for // cmd/controller's hidden mode. func NetProbeChild(dir string) int { name := filepath.Join(dir, ".felhom-proba-"+randHexToken(8)) nonce := randHexToken(32) if err := os.WriteFile(name, []byte(nonce), 0o644); err != nil { return netProbeExitNoWrite } if class := netProbeFSClass(dir); class != system.FSClassNetwork { _ = os.Remove(name) // best-effort — the verdict is already not-network-fs return netProbeExitNotNetFS } back, err := netProbeReadBack(name) if err != nil || string(back) != nonce { _ = os.Remove(name) // best-effort — the verdict is already mismatch return netProbeExitMismatch } if err := os.Remove(name); err != nil { return netProbeExitCleanup } return netProbeExitOK } // probeOutcome is the parent-side verdict of one probe run. type probeOutcome struct { OK bool Category string // failure category (not_writable | probe_io) when !OK Detail string Warn string // set on OK when cleanup failed (§8: a failed delete is a WARN, not a failure) } // netProbeVerdict maps the child's exit code to the outcome (pure — unit-tested). func netProbeVerdict(exitCode int, output string) probeOutcome { switch exitCode { case netProbeExitOK: return probeOutcome{OK: true} case netProbeExitCleanup: return probeOutcome{OK: true, Warn: "a próbafájl törlése nem sikerült a megosztáson"} case netProbeExitNoWrite: return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused | " + output} case netProbeExitMismatch: return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: readback failed or differed | " + output} case netProbeExitNotNetFS: return probeOutcome{OK: false, Category: "not_network_fs", Detail: "uid-1000 write probe: dir is not a mounted network filesystem in the controller namespace (stub) | " + output} default: return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: unexpected exit | " + output} } } // randHexToken returns n random bytes hex-encoded (2n chars); crypto/rand, panics never — a rand // failure degrades to a constant (the probe still functions, names just stop being random). func randHexToken(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "felhom-static" } return hex.EncodeToString(b) }