bc4eda926b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
177 lines
8.7 KiB
Go
177 lines
8.7 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// Network-mount guest-reboot / boot reassert (RCA AUDIT-nas-cwa-rca-2026-07-11 fix 1, hardened by
|
||
// CAMPAIGN-3 F9/F10/F11).
|
||
//
|
||
// THE BUG IT FIXES: a fresh guest namespace inherits REAL submounts of the shared parent (ext4, or an
|
||
// actively-mounted nfs4/cifs) but NOT an idle autofs trigger — so after any guest reboot an idle NAS
|
||
// share silently degrades to a local stub directory inside the guest. The heal is host-side and
|
||
// host-global: re-creating the .automount unit emits a FRESH trigger-mount event, which propagates live
|
||
// into every running guest's slave bind (live-proven in the RCA remediation, 2026-07-11).
|
||
//
|
||
// CAMPAIGN-3 hardening:
|
||
// - F11 (read the right unit): the decision is driven ONLY by the host `/proc/mounts` fstype AT the
|
||
// mountpoint — an ACTIVE real mount (nfs4/cifs) is inherited and left alone; anything else is
|
||
// re-armed. The `.automount` unit's own state is NEVER consulted (an armed trigger always reports
|
||
// "active", which is exactly why a state-of-the-automount check mis-skips idle triggers).
|
||
// - F10 (re-arm for real): a `.mount`/`.automount` left in `failed`/start-limit-hit state (the
|
||
// campaign's unexport→idle-timeout→access×5 sequence) is `reset-failed` FIRST — without it the
|
||
// `enable --now` below is refused by the start limit and the share stays dead across every boot.
|
||
// - F9 (say what you did): the pass enumerates by the marker-owned unit files on disk (not by
|
||
// enablement or runtime state) and logs an INFO verdict line for EVERY share — an empty-looking
|
||
// sweep over N shares is structurally impossible.
|
||
//
|
||
// The action uses the sudoers-granted verbs only (`systemctl reset-failed -- *`, `systemctl stop -- *`,
|
||
// `systemctl enable --now -- *`). Idempotent: re-arming an already-armed trigger recreates it — same end
|
||
// state, and the fresh mount event is harmless. An ACTIVE real mount is never touched.
|
||
|
||
// Reassert actions (the §8 decision table, encoded — CAMPAIGN-3 F11).
|
||
const (
|
||
// NetReassertRearmed: the trigger was re-created (stop + enable --now) — the propagation heal.
|
||
NetReassertRearmed = "rearmed"
|
||
// NetReassertResetRearmed: the unit was failed/start-limited, reset-failed, THEN re-armed (F10).
|
||
NetReassertResetRearmed = "reset-failed+rearmed"
|
||
// NetReassertSkipActive: a real network fs is mounted at the path — inherited by fresh namespaces,
|
||
// nothing to do (verify only).
|
||
NetReassertSkipActive = "skip-active"
|
||
// NetReassertSkipForeign: a non-network, non-autofs filesystem occupies the path (ext4/tmpfs/…) —
|
||
// not a state this reconcile owns, and re-arming over it would fail (mountpoint busy).
|
||
NetReassertSkipForeign = "skip-foreign"
|
||
)
|
||
|
||
// NetReassertResult is one share's outcome in a reassert pass.
|
||
type NetReassertResult struct {
|
||
Name string // share name (mount dir basename)
|
||
Where string // guest-visible mountpoint (/mnt/felhom-drives/<name>)
|
||
Action string // NetReassert* constant
|
||
Err error // set when the rearm action failed (skip rows never error)
|
||
}
|
||
|
||
// Remediates reports whether an action expects the share to become visible in running guests (drives
|
||
// the caller's guest-visibility verify). Skip-active also expects visibility (an inherited live mount);
|
||
// only foreign-fs and errored rows expect nothing.
|
||
func (r NetReassertResult) Remediates() bool {
|
||
return r.Err == nil && r.Action != NetReassertSkipForeign
|
||
}
|
||
|
||
// netReassertActive reports whether the fstype at a share's mountpoint is a live network mount — the
|
||
// ONLY input to the skip-active decision (F11: never the automount unit's state). "" (a failed/disarmed
|
||
// automount leaves NO /proc/mounts entry) and "autofs" (an armed-but-idle trigger) are BOTH not-active
|
||
// and therefore re-arm targets; a foreign local fs is left alone.
|
||
func netReassertClassify(fstype string) string {
|
||
switch {
|
||
case isNetworkMounted(fstype):
|
||
return NetReassertSkipActive
|
||
case fstype == "" || fstype == "autofs":
|
||
// Not actively mounted, but a marker unit exists for this path: idle-armed, disarmed, OR
|
||
// failed/start-limited — all of them must be re-armed so a fresh trigger event propagates.
|
||
return NetReassertRearmed
|
||
default:
|
||
return NetReassertSkipForeign // ext4/tmpfs/… — foreign, not ours to churn
|
||
}
|
||
}
|
||
|
||
// ReassertNetworkAutomounts runs the reassert pass over every configured network mount: for each
|
||
// installed pair, decide per netReassertClassify and re-arm every not-active trigger (reset-failed first
|
||
// if the unit is stuck). Returns one result per share so callers (agent startup / guest-hook post-start)
|
||
// can verify guest visibility. Errors on one share never stop the pass. Callers MUST NOT invoke this
|
||
// from periodic health paths — an idle trigger is healthy, and the pass is only needed after a guest
|
||
// (re)start or at agent startup.
|
||
func (h *SudoHostOps) ReassertNetworkAutomounts(ctx context.Context) []NetReassertResult {
|
||
entries, err := h.networkUnitEntries()
|
||
if err != nil {
|
||
h.logger.Warn("netreassert: unit enumeration failed", "err", err)
|
||
return nil
|
||
}
|
||
fstypes := map[string]string{}
|
||
if h.host != nil {
|
||
if mounts, merr := h.host.Mounts(); merr == nil {
|
||
for _, m := range mounts {
|
||
fstypes[m.MountPoint] = m.FSType
|
||
}
|
||
} else {
|
||
h.logger.Warn("netreassert: mount-table read failed — treating all shares as unmounted", "err", merr)
|
||
}
|
||
}
|
||
var out []NetReassertResult
|
||
for _, e := range entries {
|
||
res := NetReassertResult{Name: e.name, Where: e.where, Action: netReassertClassify(fstypes[e.where])}
|
||
switch res.Action {
|
||
case NetReassertSkipActive:
|
||
h.logger.Info("netreassert: share actively mounted — skip (fresh namespaces inherit real mounts)",
|
||
"name", e.name, "where", e.where, "verdict", res.Action)
|
||
case NetReassertSkipForeign:
|
||
h.logger.Info("netreassert: foreign filesystem at mountpoint — skip (not a network state we own)",
|
||
"name", e.name, "where", e.where, "verdict", res.Action)
|
||
case NetReassertRearmed:
|
||
// F10: clear a failed/start-limit lockout FIRST or the enable --now is refused; the verdict
|
||
// records whether a reset was actually needed.
|
||
if h.resetNetworkAutomountIfFailed(ctx, e.where) {
|
||
res.Action = NetReassertResetRearmed
|
||
}
|
||
if err := h.rearmNetworkAutomount(ctx, e.where); err != nil {
|
||
res.Err = err
|
||
h.logger.Warn("netreassert: trigger re-arm failed", "name", e.name, "where", e.where,
|
||
"verdict", "error", "err", err)
|
||
} else {
|
||
h.logger.Info("netreassert: automount trigger re-armed (fresh mount event propagates into running guests)",
|
||
"name", e.name, "where", e.where, "verdict", res.Action)
|
||
}
|
||
}
|
||
out = append(out, res)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// resetNetworkAutomountIfFailed clears a failed/start-limit-hit lockout on the share's unit pair so the
|
||
// subsequent `enable --now` is not refused (F10 — the campaign's start-limited automount that no
|
||
// platform path re-armed). Returns true when either unit was in the failed state (so the caller can
|
||
// report the reset-failed+rearmed verdict). The failed-state read is unprivileged (`systemctl
|
||
// is-failed`, seam-injected); the reset-failed is the new sudoers verb.
|
||
func (h *SudoHostOps) resetNetworkAutomountIfFailed(ctx context.Context, where string) bool {
|
||
mountUnit, err := UnitNameForMount(where)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"
|
||
reset := false
|
||
for _, unit := range []string{automountUnit, mountUnit} {
|
||
if !h.unitFailed(ctx, unit) {
|
||
continue
|
||
}
|
||
reset = true
|
||
if err := h.run(ctx, h.bins.Systemctl, "reset-failed", "--", unit); err != nil {
|
||
// Tolerated: a reset-failed that itself fails still lets the enable --now try; log it.
|
||
h.logger.Warn("netreassert: reset-failed tolerated failure", "unit", unit, "err", err)
|
||
} else {
|
||
h.logger.Warn("netreassert: cleared failed/start-limit lockout before re-arm (F10)", "unit", unit)
|
||
}
|
||
}
|
||
return reset
|
||
}
|
||
|
||
// rearmNetworkAutomount stops then re-enables+starts the .automount for a mountpoint. The stop is
|
||
// tolerated failing (unit not loaded); the enable --now is the action that must succeed. Both verbs
|
||
// are the existing FELHOM_NETMOUNT sudoers grants.
|
||
func (h *SudoHostOps) rearmNetworkAutomount(ctx context.Context, where string) error {
|
||
mountUnit, err := UnitNameForMount(where)
|
||
if err != nil {
|
||
return fmt.Errorf("netreassert: unit name for %s: %w", where, err)
|
||
}
|
||
automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"
|
||
if err := h.run(ctx, h.bins.Systemctl, "stop", "--", automountUnit); err != nil {
|
||
// Tolerated: a not-loaded unit still enables cleanly below.
|
||
h.logger.Debug("netreassert: automount stop tolerated failure", "unit", automountUnit, "err", err)
|
||
}
|
||
if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", automountUnit); err != nil {
|
||
return fmt.Errorf("netreassert: enabling automount %s: %w", automountUnit, err)
|
||
}
|
||
return nil
|
||
}
|