diff --git a/internal/installer/validate/assertions.go b/internal/installer/validate/assertions.go index 9b4433568..7cbb23ce2 100644 --- a/internal/installer/validate/assertions.go +++ b/internal/installer/validate/assertions.go @@ -50,6 +50,22 @@ func RunAssertions(exec executor.Executor, sshPort int, log *logging.Logger) []A results = append(results, assertPayloadInventory(exec, log)) results = append(results, assertConfigIntegrity(exec, log)) + // PR26.1: systemd-payload invariants. One gather call feeds four + // assertions so we don't walk the unit dirs (or call systemctl) + // four times. Inventory paths are derived from existing + // payload.VerifyInventory's required-set; PAYLOAD-INVENTORY-001 + // fails closed when nothing is supplied (every nftban-owned + // referenced path becomes "unknown"), so the gatherer SHOULD + // pass a populated set in production. + in, _ := GatherSystemdPayloadInputs(exec, log, defaultInventoryPaths()) + spr := ValidateInstalledSystemdPayload(in) + results = append(results, + assertSystemdExecStartPaths(spr, log), + assertSystemdTimerPair(spr, log), + assertSystemdPayloadInventory(spr, log), + assertFailedUnitsPostInstall(spr, log), + ) + passed := 0 for _, r := range results { if r.Passed { @@ -225,3 +241,115 @@ func assertConfigIntegrity(exec executor.Executor, log *logging.Logger) Assertio } return r } + +// PR26.1 assertions ---------------------------------------------------------- +// +// These four assertions implement, respectively: +// +// SYSTEMD-EXECSTART-001 systemd_execstart_paths_ok +// SYSTEMD-TIMER-PAIR-001 systemd_timer_pair_ok +// PAYLOAD-INVENTORY-001 systemd_payload_inventory_ok +// FAILED-UNIT-POSTINSTALL-001 failed_units_postinstall_ok +// +// All four are derived from a single SystemdPayloadValidationResult +// computed once per RunAssertions pass. Each contributes a distinct +// AssertionResult so FailedNames pinpoints which invariant fired. + +func assertSystemdExecStartPaths(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult { + r := AssertionResult{Name: "systemd_execstart_paths_ok", Passed: spr.ExecStartOK()} + if r.Passed { + log.Debug("ASSERT systemd_execstart_paths_ok: PASS") + return r + } + parts := make([]string, 0, len(spr.MissingExecPaths)) + for _, m := range spr.MissingExecPaths { + parts = append(parts, m.UnitFile+":"+m.Directive+"="+m.Path) + } + r.Detail = "missing ExecStart paths: " + strings.Join(parts, "; ") + log.Warn("ASSERT systemd_execstart_paths_ok: FAIL — %d missing: %s", + len(spr.MissingExecPaths), strings.Join(parts, "; ")) + return r +} + +func assertSystemdTimerPair(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult { + r := AssertionResult{Name: "systemd_timer_pair_ok", Passed: spr.TimerPairOK()} + if r.Passed { + log.Debug("ASSERT systemd_timer_pair_ok: PASS") + return r + } + parts := make([]string, 0, len(spr.MissingTimerTargets)) + for _, m := range spr.MissingTimerTargets { + parts = append(parts, m.TimerUnit+"->"+m.TargetUnit) + } + r.Detail = "timers activate missing services: " + strings.Join(parts, "; ") + log.Warn("ASSERT systemd_timer_pair_ok: FAIL — %d unpaired: %s", + len(spr.MissingTimerTargets), strings.Join(parts, "; ")) + return r +} + +func assertSystemdPayloadInventory(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult { + r := AssertionResult{Name: "systemd_payload_inventory_ok", Passed: spr.PayloadInventoryOK()} + if r.Passed { + log.Debug("ASSERT systemd_payload_inventory_ok: PASS") + return r + } + parts := make([]string, 0, len(spr.UnknownPayloadRefs)) + for _, m := range spr.UnknownPayloadRefs { + parts = append(parts, m.UnitFile+":"+m.Path) + } + r.Detail = "nftban-owned paths not in payload inventory: " + strings.Join(parts, "; ") + log.Warn("ASSERT systemd_payload_inventory_ok: FAIL — %d unknown: %s", + len(spr.UnknownPayloadRefs), strings.Join(parts, "; ")) + return r +} + +func assertFailedUnitsPostInstall(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult { + r := AssertionResult{Name: "failed_units_postinstall_ok", Passed: spr.FailedUnitsOK()} + if r.Passed { + log.Debug("ASSERT failed_units_postinstall_ok: PASS") + return r + } + // Fail-closed query error takes precedence — surfaces the + // enumeration failure rather than misreporting "no failed units". + if spr.FailedUnitQueryError != "" { + r.Detail = "failed-unit enumeration error: " + spr.FailedUnitQueryError + log.Warn("ASSERT failed_units_postinstall_ok: FAIL — %s", spr.FailedUnitQueryError) + return r + } + parts := make([]string, 0, len(spr.FailedUnits)) + for _, f := range spr.FailedUnits { + parts = append(parts, f.Unit+"("+f.Detail+")") + } + r.Detail = "nftban units in failed state: " + strings.Join(parts, "; ") + log.Warn("ASSERT failed_units_postinstall_ok: FAIL — %d failed: %s", + len(spr.FailedUnits), strings.Join(parts, "; ")) + return r +} + +// defaultInventoryPaths returns the set of nftban-owned paths the +// staged install is known to populate. Mirrors payload.VerifyInventory's +// canonical required set, expressed as a set instead of a slice. +// +// Kept local to validate/ so the systemd-payload assertion does not +// import payload.buildEntries (which would couple validation against +// the full destination table). The set is intentionally narrow: a +// missing path here surfaces as a PAYLOAD-INVENTORY-001 finding with +// actionable detail (the unit file + path), prompting the operator +// to either expand this set or stop the unit from referencing the +// path. Either is a deliberate decision — not a silent pass. +func defaultInventoryPaths() map[string]bool { + return map[string]bool{ + "/usr/sbin/nftban": true, + "/usr/lib/nftban/bin/nftban-core": true, + "/usr/lib/nftban/bin/nftband": true, + "/usr/lib/nftban/bin/nftban-validate": true, + "/usr/lib/nftban/bin/nftban-installer": true, + "/usr/lib/nftban/sbin/nftban-apply": true, + "/usr/lib/nftban/sbin/nftban-confirm": true, + "/usr/lib/nftban/sbin/nftban-panelctl": true, + "/usr/lib/nftban/sbin/nftban-queue-processor": true, + "/usr/lib/nftban/sbin/nftban-rollback": true, + "/usr/lib/nftban/sbin/nftban-service-alert": true, + "/usr/lib/nftban/sbin/nftban-botscan-processor": true, + } +} diff --git a/internal/installer/validate/systemd_payload.go b/internal/installer/validate/systemd_payload.go new file mode 100644 index 000000000..3878b0dfc --- /dev/null +++ b/internal/installer/validate/systemd_payload.go @@ -0,0 +1,622 @@ +// ============================================================================= +// NFTBan v1.100.x PR26.1 - Reusable Install Validation: Systemd Payload +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-validate-systemd-payload" +// meta:type="lib" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-29" +// meta:description="Generic systemd-payload validation: execstart paths, timer pairing, payload inventory, failed nftban units" +// meta:inventory.files="internal/installer/validate/systemd_payload.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="root" +// ============================================================================= +// +// PR26.1 invariants enforced here: +// +// SYSTEMD-EXECSTART-001 every nftban-* unit's ExecStart/Pre/Post +// local executable path must exist on disk +// after payload staging. +// SYSTEMD-TIMER-PAIR-001 every installed nftban-*.timer must +// activate an installed nftban service unit. +// PAYLOAD-INVENTORY-001 every nftban-owned local path referenced +// by an nftban systemd unit must belong to +// the staged payload inventory. +// FAILED-UNIT-POSTINSTALL-001 no nftban-* unit may be in failed state +// after install/update apply. +// +// Generic by design — no panel logic, no DirectAdmin specifics, no +// firewall-runtime mutation. Decisions are file/parse only. The host- +// side wrapper that feeds this function lives in +// systemd_payload_gather.go; tests pass synthetic inputs directly. +// +// ============================================================================= + +package validate + +import ( + "strings" +) + +// nftbanUnitExtensions lists the systemd unit suffixes considered in +// scope for PR26.1 invariants. Anything outside this set (e.g., +// .conf in tmpfiles.d, .preset) is ignored. +var nftbanUnitExtensions = map[string]bool{ + "service": true, + "timer": true, + "socket": true, + "target": true, + "path": true, + "mount": true, +} + +// IsNftbanUnit returns true if a unit basename is owned by nftban +// for the purposes of these invariants. Recognized shapes: +// +// nftban. — singular "nftban" unit (legacy) +// nftband. — Go daemon unit (note: no hyphen between +// "nftban" and "d" — distinct from +// nftban-* units; both are owned) +// nftban-. — any hyphenated nftban-prefixed unit +// nftband-.— any hyphenated nftband-prefixed unit +// +// where is one of the suffixes in nftbanUnitExtensions. +// +// The matcher is structural rather than enumerated so that future unit +// shapes (sockets, targets, paths) are covered without a code change. +// "fake-nftban.service" / "nftbanfake.service" / "sshd.service" do +// NOT match — the prefix must be a complete identifier separated by +// '-' or by the unit-extension dot. +func IsNftbanUnit(basename string) bool { + dot := strings.LastIndexByte(basename, '.') + if dot <= 0 || dot == len(basename)-1 { + return false + } + ext := basename[dot+1:] + if !nftbanUnitExtensions[ext] { + return false + } + stem := basename[:dot] + if stem == "nftban" || stem == "nftband" { + return true + } + if strings.HasPrefix(stem, "nftban-") || strings.HasPrefix(stem, "nftband-") { + return true + } + return false +} + +// ParsedUnit is the minimum subset of a systemd unit file needed for +// PR26.1 validation. Constructed by the host-side gatherer or by +// tests directly. +type ParsedUnit struct { + // Name is the unit basename (e.g., "nftban-unified-exporter.service"). + Name string + + // Path is the absolute on-disk path of the unit file. + Path string + + // IsTimer is true for *.timer units. + IsTimer bool + + // Execs is the list of ExecStart/Pre/Post/Stop directives extracted + // from [Service]. Order preserved for diagnostic output. + Execs []ExecDirective + + // TimerUnit is the [Timer] Unit= value (or implicit basename.service + // when the directive is absent). Empty for non-timer units. + TimerUnit string +} + +// ExecDirective records one Exec* line from a unit file. +type ExecDirective struct { + // Directive is one of ExecStart, ExecStartPre, ExecStartPost, + // ExecStop, ExecStopPost, ExecReload. + Directive string + + // Raw is the full RHS as written in the unit file (after the "="). + Raw string + + // Binary is the resolved primary executable absolute path. + // For wrapper invocations like "/bin/sh -c '...'", this is "/bin/sh" + // and EmbeddedNftbanPaths captures any nftban-owned absolute paths + // found inside the quoted argument. + Binary string + + // EmbeddedNftbanPaths lists any nftban-owned absolute paths + // discovered inside Raw that are NOT the primary Binary. Caught + // from common shell-wrapper patterns ("/bin/sh -c '/usr/lib/...sh'", + // "/usr/bin/env BAR=1 /usr/lib/...bin"). + EmbeddedNftbanPaths []string +} + +// FailedUnitFinding describes one nftban-* unit currently in failed +// state per `systemctl list-units --state=failed`. +type FailedUnitFinding struct { + Unit string + Active string // e.g., "failed" + Sub string // e.g., "failed", "exit-code" + Detail string // best-effort short description, may be empty +} + +// PayloadInventory describes which paths are considered known after +// install staging. +type PayloadInventory struct { + // Paths is the set of absolute paths the staged install knows + // about. Population strategy: populate from the install's payload + // destination table (see internal/installer/payload). + Paths map[string]bool + + // NftbanOwnedPrefixes lists absolute path prefixes that, if a unit + // references a path under one of them, that path MUST be in Paths + // or referenced via a system-binary wrapper. Typical: + // "/usr/lib/nftban/", "/usr/sbin/nftban", "/etc/nftban/". + NftbanOwnedPrefixes []string + + // SystemBinaryPrefixes lists absolute path prefixes whose contents + // are NOT required to be in Paths. Typical: "/usr/bin/", "/bin/", + // "/usr/sbin/" (excluding the singular "/usr/sbin/nftban"), "/sbin/". + SystemBinaryPrefixes []string +} + +// SystemdPayloadInputs aggregates everything ValidateInstalledSystemdPayload +// needs. Decoupled from executor.Executor and the live filesystem so +// the validator can be exercised by fixture tests. +type SystemdPayloadInputs struct { + // Units is the parsed nftban-owned unit set in the install scope. + // Non-nftban units are filtered upstream by the gatherer; this + // validator does NOT enforce invariants on third-party units. + Units []ParsedUnit + + // PathExists reports whether an absolute path exists on disk. + // Tests stub with a closure over a map; production wires through + // the executor. + PathExists func(absPath string) bool + + // Inventory holds payload-known paths and prefix policy. + Inventory PayloadInventory + + // FailedNftbanUnits is the set of currently-failed nftban units. + // Populated upstream from `systemctl list-units --state=failed`. + FailedNftbanUnits []FailedUnitFinding + + // FailedUnitQueryError is non-empty when the upstream gatherer + // could not enumerate failed units (e.g., systemctl missing, + // non-zero exit, dbus unavailable). When set, + // FAILED-UNIT-POSTINSTALL-001 fails closed: the absence of + // findings cannot be distinguished from a healthy install. + // Operator must resolve the enumeration error before + // StateCommitted is permitted. + FailedUnitQueryError string + + // AllUnitNames is the set of every unit basename present in the + // install's unit directories (services + timers + sockets). Used + // by SYSTEMD-TIMER-PAIR-001 to confirm a timer's target service + // is installed alongside it. + AllUnitNames map[string]bool +} + +// MissingExecPath records one SYSTEMD-EXECSTART-001 hit. +type MissingExecPath struct { + UnitFile string + Directive string + Path string + RawLine string +} + +// MissingTimerTarget records one SYSTEMD-TIMER-PAIR-001 hit. +type MissingTimerTarget struct { + TimerUnit string + TargetUnit string +} + +// UnknownPayloadRef records one PAYLOAD-INVENTORY-001 hit: a unit +// references an nftban-owned path that is not known to the staged +// payload inventory. +type UnknownPayloadRef struct { + UnitFile string + Path string +} + +// FailedUnitPostInstall records one FAILED-UNIT-POSTINSTALL-001 hit. +type FailedUnitPostInstall struct { + Unit string + Detail string +} + +// SystemdPayloadValidationResult is the structured outcome. +type SystemdPayloadValidationResult struct { + // OK is true iff every invariant passed. + OK bool + + // MissingExecPaths is the SYSTEMD-EXECSTART-001 finding set. + MissingExecPaths []MissingExecPath + + // MissingTimerTargets is the SYSTEMD-TIMER-PAIR-001 finding set. + MissingTimerTargets []MissingTimerTarget + + // UnknownPayloadRefs is the PAYLOAD-INVENTORY-001 finding set. + UnknownPayloadRefs []UnknownPayloadRef + + // FailedUnits is the FAILED-UNIT-POSTINSTALL-001 finding set. + FailedUnits []FailedUnitPostInstall + + // FailedUnitQueryError mirrors SystemdPayloadInputs.FailedUnitQueryError + // for the assertion-side detail message. When non-empty, + // FAILED-UNIT-POSTINSTALL-001 fails closed regardless of + // FailedUnits content. + FailedUnitQueryError string +} + +// ExecStartOK reports whether SYSTEMD-EXECSTART-001 passed. +func (r SystemdPayloadValidationResult) ExecStartOK() bool { return len(r.MissingExecPaths) == 0 } + +// TimerPairOK reports whether SYSTEMD-TIMER-PAIR-001 passed. +func (r SystemdPayloadValidationResult) TimerPairOK() bool { return len(r.MissingTimerTargets) == 0 } + +// PayloadInventoryOK reports whether PAYLOAD-INVENTORY-001 passed. +func (r SystemdPayloadValidationResult) PayloadInventoryOK() bool { + return len(r.UnknownPayloadRefs) == 0 +} + +// FailedUnitsOK reports whether FAILED-UNIT-POSTINSTALL-001 passed. +// Fails closed if the failed-unit query itself errored: an empty +// FailedUnits slice cannot prove a healthy install when the +// enumeration source was unreachable. +func (r SystemdPayloadValidationResult) FailedUnitsOK() bool { + return r.FailedUnitQueryError == "" && len(r.FailedUnits) == 0 +} + +// ValidateInstalledSystemdPayload runs all four PR26.1 invariants and +// returns the aggregate result. Pure function — no I/O. Caller assembles +// SystemdPayloadInputs from the live host (gatherer) or test fixtures. +func ValidateInstalledSystemdPayload(in SystemdPayloadInputs) SystemdPayloadValidationResult { + var res SystemdPayloadValidationResult + + for _, u := range in.Units { + if !IsNftbanUnit(u.Name) { + continue + } + + // SYSTEMD-EXECSTART-001 + PAYLOAD-INVENTORY-001 + for _, e := range u.Execs { + checkExecPath(&res, in, u, e, e.Binary) + for _, p := range e.EmbeddedNftbanPaths { + checkExecPath(&res, in, u, e, p) + } + } + + // SYSTEMD-TIMER-PAIR-001 + if u.IsTimer { + target := u.TimerUnit + if target == "" { + target = strings.TrimSuffix(u.Name, ".timer") + ".service" + } + if !IsNftbanUnit(target) { + // Timer pointing at a non-nftban unit — outside scope. + continue + } + if !in.AllUnitNames[target] { + res.MissingTimerTargets = append(res.MissingTimerTargets, MissingTimerTarget{ + TimerUnit: u.Name, + TargetUnit: target, + }) + } + } + } + + // FAILED-UNIT-POSTINSTALL-001 + res.FailedUnitQueryError = in.FailedUnitQueryError + for _, f := range in.FailedNftbanUnits { + if !IsNftbanUnit(f.Unit) { + continue + } + detail := f.Detail + if detail == "" { + detail = strings.TrimSpace(f.Active + " " + f.Sub) + } + res.FailedUnits = append(res.FailedUnits, FailedUnitPostInstall{ + Unit: f.Unit, + Detail: detail, + }) + } + + res.OK = res.ExecStartOK() && res.TimerPairOK() && res.PayloadInventoryOK() && res.FailedUnitsOK() + return res +} + +// checkExecPath applies SYSTEMD-EXECSTART-001 and PAYLOAD-INVENTORY-001 +// to a single absolute path referenced by a unit's Exec* directive. +func checkExecPath(res *SystemdPayloadValidationResult, in SystemdPayloadInputs, u ParsedUnit, e ExecDirective, p string) { + if p == "" { + return + } + if !strings.HasPrefix(p, "/") { + // Not an absolute path — the parser only emits absolute paths, + // but guard anyway. + return + } + + // Decide ownership category. + nftbanOwned := hasAnyPrefix(p, in.Inventory.NftbanOwnedPrefixes) + systemBinary := hasAnyPrefix(p, in.Inventory.SystemBinaryPrefixes) + + // SYSTEMD-EXECSTART-001 — the file must exist on disk. System + // binary wrappers are still required to exist; missing /bin/sh + // is a real bug. + if !in.PathExists(p) { + res.MissingExecPaths = append(res.MissingExecPaths, MissingExecPath{ + UnitFile: u.Path, + Directive: e.Directive, + Path: p, + RawLine: e.Raw, + }) + } + + // PAYLOAD-INVENTORY-001 — nftban-owned paths must be in the + // staged inventory. System binaries are exempt by definition. + if nftbanOwned && !systemBinary { + if in.Inventory.Paths == nil || !in.Inventory.Paths[p] { + res.UnknownPayloadRefs = append(res.UnknownPayloadRefs, UnknownPayloadRef{ + UnitFile: u.Path, + Path: p, + }) + } + } +} + +// hasAnyPrefix returns true if p starts with any prefix in prefixes. +func hasAnyPrefix(p string, prefixes []string) bool { + for _, pre := range prefixes { + if pre == "" { + continue + } + if strings.HasPrefix(p, pre) { + return true + } + } + return false +} + +// ParseUnitFile parses raw systemd unit content and returns a ParsedUnit. +// path is the absolute on-disk path; name is the unit basename. +// +// Parsing scope (intentionally narrow): +// - section detection by [Section] header +// - Exec* directives in [Service] +// - Unit= directive in [Timer] +// - leading systemd prefixes ('-', '+', '!', '!!', '@') are stripped +// +// Continuation lines (trailing backslash) are joined. Comments (#, ;) +// and blank lines are ignored. Quoting is respected: a quoted token is +// treated as a single argument; absolute-path scan inside the +// shell-wrapper case looks for tokens starting with '/'. +func ParseUnitFile(name, path, content string) ParsedUnit { + pu := ParsedUnit{ + Name: name, + Path: path, + IsTimer: strings.HasSuffix(name, ".timer"), + } + + section := "" + logical := joinContinuations(content) + for _, line := range strings.Split(logical, "\n") { + raw := strings.TrimSpace(line) + if raw == "" || strings.HasPrefix(raw, "#") || strings.HasPrefix(raw, ";") { + continue + } + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + section = strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]") + continue + } + + eq := strings.IndexByte(raw, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(raw[:eq]) + val := strings.TrimSpace(raw[eq+1:]) + + switch section { + case "Service": + if isExecDirective(key) { + bin, embedded := parseExecValue(val) + pu.Execs = append(pu.Execs, ExecDirective{ + Directive: key, + Raw: val, + Binary: bin, + EmbeddedNftbanPaths: embedded, + }) + } + case "Timer": + if key == "Unit" { + pu.TimerUnit = val + } + } + } + return pu +} + +func isExecDirective(key string) bool { + switch key { + case "ExecStart", "ExecStartPre", "ExecStartPost", + "ExecStop", "ExecStopPost", "ExecReload": + return true + } + return false +} + +// joinContinuations folds backslash-continued logical lines into a single +// physical line per logical entry. +func joinContinuations(s string) string { + lines := strings.Split(s, "\n") + var out []string + var buf strings.Builder + for _, ln := range lines { + trimmedRight := strings.TrimRight(ln, " \t") + if strings.HasSuffix(trimmedRight, "\\") { + buf.WriteString(strings.TrimSuffix(trimmedRight, "\\")) + buf.WriteString(" ") + continue + } + buf.WriteString(ln) + out = append(out, buf.String()) + buf.Reset() + } + if buf.Len() > 0 { + out = append(out, buf.String()) + } + return strings.Join(out, "\n") +} + +// parseExecValue extracts the primary binary and any embedded nftban-owned +// absolute paths from a systemd Exec* RHS. +// +// Strips leading systemd prefixes ('-', '+', '!', '!!', '@') from the +// first token. For wrapper invocations like +// +// /bin/sh -c '/usr/lib/nftban/exporters/x.sh' +// /usr/bin/env FOO=bar /usr/lib/nftban/sbin/y +// +// Binary is the first absolute-path token, and EmbeddedNftbanPaths +// captures any nftban-owned absolute paths found in the remaining tokens +// (after stripping a single layer of single/double quotes). +func parseExecValue(rhs string) (binary string, embedded []string) { + rhs = strings.TrimSpace(rhs) + rhs = stripSystemdPrefixes(rhs) + + tokens := tokenizeShellish(rhs) + if len(tokens) == 0 { + return "", nil + } + + binary = tokens[0] + + for _, tok := range tokens[1:] { + // Look inside the token for absolute paths under nftban-owned + // prefixes. We don't want to flag every "-c" or "--config". + for _, frag := range scanAbsolutePaths(tok) { + if isNftbanOwnedPath(frag) { + embedded = append(embedded, frag) + } + } + } + return binary, embedded +} + +// stripSystemdPrefixes removes the leading control characters systemd +// allows on Exec* values: '-' (ignore failure), '+' (full privilege), +// '!' / '!!' (run with raised privileges), '@' (custom argv[0]). +// They may be combined; strip iteratively until the next byte is a path +// or non-prefix character. +func stripSystemdPrefixes(s string) string { + for { + s = strings.TrimLeft(s, " \t") + if s == "" { + return s + } + switch s[0] { + case '-', '+', '!', '@': + s = s[1:] + default: + return s + } + } +} + +// tokenizeShellish splits a systemd Exec line on whitespace, respecting +// single and double quotes. Not a full shell parser — adequate for +// argv-style ExecStart RHS values. +func tokenizeShellish(s string) []string { + var tokens []string + var cur strings.Builder + inSingle, inDouble := false, false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case inSingle: + if c == '\'' { + inSingle = false + continue + } + cur.WriteByte(c) + case inDouble: + if c == '"' { + inDouble = false + continue + } + cur.WriteByte(c) + case c == '\'': + inSingle = true + case c == '"': + inDouble = true + case c == ' ' || c == '\t': + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + default: + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens +} + +// scanAbsolutePaths returns every contiguous run of [/A-Za-z0-9_.-] that +// starts with '/' inside tok. Catches paths embedded in shell command +// strings like '/bin/sh -c "/usr/lib/nftban/exporters/x.sh"' even when +// the token itself is the whole quoted command. +func scanAbsolutePaths(tok string) []string { + var out []string + i := 0 + for i < len(tok) { + if tok[i] != '/' { + i++ + continue + } + j := i + for j < len(tok) && isPathByte(tok[j]) { + j++ + } + if j > i+1 { + out = append(out, tok[i:j]) + } + i = j + if i == j { + i++ + } + } + return out +} + +func isPathByte(b byte) bool { + switch { + case b >= 'a' && b <= 'z': + return true + case b >= 'A' && b <= 'Z': + return true + case b >= '0' && b <= '9': + return true + case b == '/' || b == '_' || b == '-' || b == '.': + return true + } + return false +} + +// isNftbanOwnedPath returns true for paths matching the canonical +// nftban-owned prefix set used by this validator's default policy. +// The full prefix policy is configurable via PayloadInventory; this +// helper is for the embedded-path scan inside shell wrappers, where +// the inventory prefix list is not yet visible. +func isNftbanOwnedPath(p string) bool { + return strings.HasPrefix(p, "/usr/lib/nftban/") || + strings.HasPrefix(p, "/etc/nftban/") || + p == "/usr/sbin/nftban" +} diff --git a/internal/installer/validate/systemd_payload_gather.go b/internal/installer/validate/systemd_payload_gather.go new file mode 100644 index 000000000..f409185c1 --- /dev/null +++ b/internal/installer/validate/systemd_payload_gather.go @@ -0,0 +1,179 @@ +// ============================================================================= +// NFTBan v1.100.x PR26.1 - Systemd Payload Gather (host-side adapter) +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-validate-systemd-payload-gather" +// meta:type="lib" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-29" +// meta:description="Adapter that builds SystemdPayloadInputs from a live host" +// meta:inventory.files="internal/installer/validate/systemd_payload_gather.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="root" +// ============================================================================= +// +// Pure validator + tests live in systemd_payload.go. This file holds +// the adapter that pulls live host data through executor.Executor and +// the on-disk filesystem. +// +// ============================================================================= + +package validate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/itcmsgr/nftban/internal/installer/executor" + "github.com/itcmsgr/nftban/internal/installer/logging" +) + +// DefaultUnitDirs is the canonical systemd unit search path for +// nftban-owned units. Both vendor and operator-owned dirs are scanned +// so a unit dropped under /etc/systemd/system is still validated. +var DefaultUnitDirs = []string{ + "/usr/lib/systemd/system", + "/etc/systemd/system", +} + +// DefaultNftbanOwnedPrefixes lists path prefixes considered +// nftban-owned for PAYLOAD-INVENTORY-001. +var DefaultNftbanOwnedPrefixes = []string{ + "/usr/lib/nftban/", + "/etc/nftban/", +} + +// DefaultSystemBinaryPrefixes lists path prefixes that are exempt +// from PAYLOAD-INVENTORY-001 (legitimate to call from a unit even +// though they are not part of the nftban payload). +var DefaultSystemBinaryPrefixes = []string{ + "/usr/bin/", + "/bin/", + "/usr/sbin/", + "/sbin/", + "/usr/local/bin/", + "/usr/local/sbin/", +} + +// GatherSystemdPayloadInputs scans the host for nftban-owned systemd +// units, parses them, and assembles the input set used by +// ValidateInstalledSystemdPayload. +// +// inventoryPaths is the staged install's known-path set (typically +// produced by the payload-staging layer). When nil/empty, the +// PAYLOAD-INVENTORY-001 check still functions but its `Paths` set is +// empty — every nftban-owned referenced path will be flagged. Callers +// SHOULD pass a populated set. +func GatherSystemdPayloadInputs(exec executor.Executor, log *logging.Logger, inventoryPaths map[string]bool) (SystemdPayloadInputs, error) { + in := SystemdPayloadInputs{ + PathExists: func(p string) bool { return exec.FileExists(p) }, + Inventory: PayloadInventory{ + Paths: inventoryPaths, + NftbanOwnedPrefixes: DefaultNftbanOwnedPrefixes, + SystemBinaryPrefixes: DefaultSystemBinaryPrefixes, + }, + AllUnitNames: map[string]bool{}, + } + + for _, dir := range DefaultUnitDirs { + if !exec.FileExists(dir) { + continue + } + entries, err := os.ReadDir(filepath.Clean(dir)) // #nosec G304 -- canonical systemd dirs + if err != nil { + if log != nil { + log.Warn("systemd_payload: read %s: %v", dir, err) + } + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !isUnitFilename(name) { + continue + } + in.AllUnitNames[name] = true + if !IsNftbanUnit(name) { + continue + } + full := filepath.Join(dir, name) + data, err := exec.ReadFile(full) + if err != nil { + if log != nil { + log.Warn("systemd_payload: read %s: %v", full, err) + } + continue + } + in.Units = append(in.Units, ParseUnitFile(name, full, string(data))) + } + } + + in.FailedNftbanUnits, in.FailedUnitQueryError = listFailedNftbanUnits(exec, log) + + return in, nil +} + +// isUnitFilename returns true for systemd unit filenames we care about. +func isUnitFilename(name string) bool { + switch filepath.Ext(name) { + case ".service", ".timer", ".socket": + return true + } + return false +} + +// listFailedNftbanUnits queries systemctl for failed units and returns +// only the nftban-owned ones plus a query-error string. +// +// Fails closed: a non-zero exit, missing systemctl, or unreadable +// output produces a non-empty queryErr so FAILED-UNIT-POSTINSTALL-001 +// surfaces the enumeration failure as an assertion failure instead of +// silently returning an empty list and false-passing. +func listFailedNftbanUnits(exec executor.Executor, log *logging.Logger) (findings []FailedUnitFinding, queryErr string) { + if !exec.CommandExists("systemctl") { + queryErr = "systemctl binary not available — cannot enumerate failed units" + if log != nil { + log.Warn("systemd_payload: %s", queryErr) + } + return nil, queryErr + } + res := exec.Run("systemctl", "list-units", "--state=failed", "--no-legend", "--plain", "--all") + if res.ExitCode != 0 { + queryErr = fmt.Sprintf("systemctl list-units --state=failed exit=%d stderr=%q", + res.ExitCode, strings.TrimSpace(res.Stderr)) + if log != nil { + log.Warn("systemd_payload: %s", queryErr) + } + return nil, queryErr + } + for _, line := range strings.Split(strings.TrimSpace(res.Stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + unit := fields[0] + if !IsNftbanUnit(unit) { + continue + } + findings = append(findings, FailedUnitFinding{ + Unit: unit, + Active: fields[2], + Sub: fields[3], + Detail: strings.Join(fields[4:], " "), + }) + } + return findings, "" +} diff --git a/internal/installer/validate/systemd_payload_test.go b/internal/installer/validate/systemd_payload_test.go new file mode 100644 index 000000000..c020e9d42 --- /dev/null +++ b/internal/installer/validate/systemd_payload_test.go @@ -0,0 +1,624 @@ +// ============================================================================= +// NFTBan v1.100.x PR26.1 - Systemd Payload Validator Tests +// ============================================================================= +// SPDX-License-Identifier: MPL-2.0 +// meta:name="installer-validate-systemd-payload-test" +// meta:type="test" +// meta:owner="Antonios Voulvoulis " +// meta:created_date="2026-04-29" +// meta:description="Fixture tests covering SYSTEMD-EXECSTART-001, SYSTEMD-TIMER-PAIR-001, PAYLOAD-INVENTORY-001, FAILED-UNIT-POSTINSTALL-001" +// meta:inventory.files="internal/installer/validate/systemd_payload_test.go" +// meta:inventory.binaries="" +// meta:inventory.env_vars="" +// meta:inventory.config_files="" +// meta:inventory.systemd_units="" +// meta:inventory.network="" +// meta:inventory.privileges="none" +// ============================================================================= +package validate + +import ( + "strings" + "testing" +) + +// pathSet returns a PathExists closure backed by a map. +func pathSet(paths ...string) func(string) bool { + m := make(map[string]bool, len(paths)) + for _, p := range paths { + m[p] = true + } + return func(p string) bool { return m[p] } +} + +// inv returns a PayloadInventory pre-populated with default prefixes +// and the given known-paths. +func inv(paths ...string) PayloadInventory { + m := make(map[string]bool, len(paths)) + for _, p := range paths { + m[p] = true + } + return PayloadInventory{ + Paths: m, + NftbanOwnedPrefixes: DefaultNftbanOwnedPrefixes, + SystemBinaryPrefixes: DefaultSystemBinaryPrefixes, + } +} + +// ---------------------------------------------------------------------------- +// (a) Valid service+timer pair +// ---------------------------------------------------------------------------- +func TestSystemdPayload_ValidPair(t *testing.T) { + svc := ParseUnitFile("nftban-unified-exporter.service", + "/usr/lib/systemd/system/nftban-unified-exporter.service", + `[Unit] +Description=NFTBan unified exporter + +[Service] +Type=oneshot +ExecStart=/usr/lib/nftban/exporters/nftban_unified_exporter.sh +`) + timer := ParseUnitFile("nftban-unified-exporter.timer", + "/usr/lib/systemd/system/nftban-unified-exporter.timer", + `[Unit] +Description=Run unified exporter + +[Timer] +OnUnitActiveSec=60s +Unit=nftban-unified-exporter.service +`) + + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc, timer}, + PathExists: pathSet( + "/usr/lib/nftban/exporters/nftban_unified_exporter.sh", + ), + Inventory: inv("/usr/lib/nftban/exporters/nftban_unified_exporter.sh"), + AllUnitNames: map[string]bool{ + "nftban-unified-exporter.service": true, + "nftban-unified-exporter.timer": true, + }, + }) + + if !res.OK { + t.Fatalf("expected OK; got %#v", res) + } +} + +// ---------------------------------------------------------------------------- +// (b) Missing ExecStart path → SYSTEMD-EXECSTART-001 fails +// +// Regression shape from dns2 (2026-04-29): nftban-unified-exporter.service +// referenced /usr/lib/nftban/exporters/nftban_unified_exporter.sh, but the +// file was not staged. Test name structural; comment carries history. +// ---------------------------------------------------------------------------- +func TestSystemdPayload_MissingExecStart_FilesystemAbsent(t *testing.T) { + svc := ParseUnitFile("nftban-unified-exporter.service", + "/usr/lib/systemd/system/nftban-unified-exporter.service", + `[Service] +ExecStart=/usr/lib/nftban/exporters/nftban_unified_exporter.sh +`) + + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet(), // empty: file is missing on disk + Inventory: inv(), // and not in payload inventory either + AllUnitNames: map[string]bool{"nftban-unified-exporter.service": true}, + }) + + if res.OK { + t.Fatalf("expected NOT OK") + } + if len(res.MissingExecPaths) != 1 { + t.Fatalf("expected 1 MissingExecPath; got %d", len(res.MissingExecPaths)) + } + if res.MissingExecPaths[0].Path != "/usr/lib/nftban/exporters/nftban_unified_exporter.sh" { + t.Errorf("unexpected path: %s", res.MissingExecPaths[0].Path) + } + if len(res.UnknownPayloadRefs) != 1 { + t.Fatalf("expected 1 UnknownPayloadRef (PAYLOAD-INVENTORY-001); got %d", len(res.UnknownPayloadRefs)) + } +} + +// ---------------------------------------------------------------------------- +// (c) Timer activates missing service → SYSTEMD-TIMER-PAIR-001 fails +// +// Regression shape from dns2 (2026-04-29): nftban-metrics-exporter.timer +// remained installed while the paired service was absent. Test name +// structural; comment carries history. +// ---------------------------------------------------------------------------- +func TestSystemdPayload_TimerOrphan_NoServicePair(t *testing.T) { + timer := ParseUnitFile("nftban-metrics-exporter.timer", + "/usr/lib/systemd/system/nftban-metrics-exporter.timer", + `[Timer] +OnUnitActiveSec=60s +Unit=nftban-metrics-exporter.service +`) + + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{timer}, + PathExists: pathSet(), + Inventory: inv(), + AllUnitNames: map[string]bool{ + // note: only the timer is installed; service is missing + "nftban-metrics-exporter.timer": true, + }, + }) + + if res.OK { + t.Fatalf("expected NOT OK") + } + if len(res.MissingTimerTargets) != 1 { + t.Fatalf("expected 1 MissingTimerTarget; got %d", len(res.MissingTimerTargets)) + } + mt := res.MissingTimerTargets[0] + if mt.TimerUnit != "nftban-metrics-exporter.timer" || mt.TargetUnit != "nftban-metrics-exporter.service" { + t.Errorf("unexpected pair: %+v", mt) + } +} + +// Implicit Unit= inference: if [Timer] omits Unit=, the target is the +// timer's basename with .service. +func TestSystemdPayload_TimerImplicitUnit(t *testing.T) { + timer := ParseUnitFile("nftban-connector-exporter.timer", + "/usr/lib/systemd/system/nftban-connector-exporter.timer", + `[Timer] +OnUnitActiveSec=60s +`) + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{timer}, + PathExists: pathSet(), + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-connector-exporter.timer": true}, + }) + if res.OK { + t.Fatalf("expected NOT OK") + } + if len(res.MissingTimerTargets) != 1 { + t.Fatalf("expected 1 MissingTimerTarget; got %d", len(res.MissingTimerTargets)) + } + if res.MissingTimerTargets[0].TargetUnit != "nftban-connector-exporter.service" { + t.Errorf("expected implicit .service target; got %s", res.MissingTimerTargets[0].TargetUnit) + } +} + +// ---------------------------------------------------------------------------- +// (d) nftban-owned path outside inventory → PAYLOAD-INVENTORY-001 fails +// ---------------------------------------------------------------------------- +func TestSystemdPayload_NftbanPathNotInInventory(t *testing.T) { + svc := ParseUnitFile("nftban-rogue.service", + "/usr/lib/systemd/system/nftban-rogue.service", + `[Service] +ExecStart=/usr/lib/nftban/sbin/rogue-helper +`) + + // Path EXISTS on disk (so SYSTEMD-EXECSTART-001 passes) but is + // NOT in inventory — SYSTEMD-PAYLOAD-INVENTORY-001 must fire. + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/usr/lib/nftban/sbin/rogue-helper"), + Inventory: inv(), // empty inventory + AllUnitNames: map[string]bool{"nftban-rogue.service": true}, + }) + + if res.OK { + t.Fatalf("expected NOT OK") + } + if !res.ExecStartOK() { + t.Errorf("ExecStartOK should pass when file exists; got %v", res.MissingExecPaths) + } + if len(res.UnknownPayloadRefs) != 1 { + t.Fatalf("expected 1 UnknownPayloadRef; got %d", len(res.UnknownPayloadRefs)) + } +} + +// ---------------------------------------------------------------------------- +// (e) Failed unit injected → FAILED-UNIT-POSTINSTALL-001 fails +// ---------------------------------------------------------------------------- +func TestSystemdPayload_FailedUnit(t *testing.T) { + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + PathExists: pathSet(), + Inventory: inv(), + FailedNftbanUnits: []FailedUnitFinding{ + {Unit: "nftban-unified-exporter.service", Active: "failed", Sub: "failed", Detail: "exit-code 203/EXEC"}, + }, + }) + + if res.OK { + t.Fatalf("expected NOT OK") + } + if len(res.FailedUnits) != 1 { + t.Fatalf("expected 1 FailedUnit; got %d", len(res.FailedUnits)) + } + if !strings.Contains(res.FailedUnits[0].Detail, "203/EXEC") { + t.Errorf("detail should preserve systemctl reason; got %q", res.FailedUnits[0].Detail) + } +} + +// Non-nftban failed units are ignored (FAILED-UNIT-POSTINSTALL-001 is +// scoped to nftban-* only). +func TestSystemdPayload_FailedNonNftbanIgnored(t *testing.T) { + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + PathExists: pathSet(), + Inventory: inv(), + FailedNftbanUnits: []FailedUnitFinding{ + {Unit: "maldet.service", Active: "failed", Sub: "failed"}, + }, + }) + if !res.OK { + t.Fatalf("expected OK (non-nftban failure ignored); got %#v", res) + } +} + +// ---------------------------------------------------------------------------- +// (f) Shell-wrapper ExecStart with nftban-owned path inside the command +// ---------------------------------------------------------------------------- +func TestSystemdPayload_ShellWrapper_EmbeddedNftbanPath(t *testing.T) { + svc := ParseUnitFile("nftban-wrapped.service", + "/usr/lib/systemd/system/nftban-wrapped.service", + `[Service] +ExecStart=/bin/sh -c '/usr/lib/nftban/sbin/wrapped-helper --flag' +`) + + if len(svc.Execs) != 1 { + t.Fatalf("expected 1 Exec directive; got %d", len(svc.Execs)) + } + if svc.Execs[0].Binary != "/bin/sh" { + t.Errorf("expected /bin/sh wrapper; got %q", svc.Execs[0].Binary) + } + if len(svc.Execs[0].EmbeddedNftbanPaths) != 1 || + svc.Execs[0].EmbeddedNftbanPaths[0] != "/usr/lib/nftban/sbin/wrapped-helper" { + t.Errorf("expected embedded nftban path; got %v", svc.Execs[0].EmbeddedNftbanPaths) + } + + // Wrapper present, embedded path missing → both invariants fire on the embedded path. + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/bin/sh"), // wrapper exists; helper does not + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-wrapped.service": true}, + }) + + if res.OK { + t.Fatalf("expected NOT OK") + } + if len(res.MissingExecPaths) != 1 { + t.Fatalf("expected 1 missing path (the embedded helper); got %d", len(res.MissingExecPaths)) + } + if res.MissingExecPaths[0].Path != "/usr/lib/nftban/sbin/wrapped-helper" { + t.Errorf("unexpected missing path: %s", res.MissingExecPaths[0].Path) + } +} + +// /usr/bin/env wrapper variant. +func TestSystemdPayload_EnvWrapper_EmbeddedNftbanPath(t *testing.T) { + svc := ParseUnitFile("nftban-envwrapped.service", + "/usr/lib/systemd/system/nftban-envwrapped.service", + `[Service] +ExecStart=/usr/bin/env FOO=bar /usr/lib/nftban/sbin/wrapped-helper +`) + if svc.Execs[0].Binary != "/usr/bin/env" { + t.Errorf("expected /usr/bin/env wrapper; got %q", svc.Execs[0].Binary) + } + if len(svc.Execs[0].EmbeddedNftbanPaths) != 1 { + t.Fatalf("expected one embedded nftban path; got %v", svc.Execs[0].EmbeddedNftbanPaths) + } +} + +// ---------------------------------------------------------------------------- +// (g) System binary path is allowed (not an inventory hit) +// ---------------------------------------------------------------------------- +func TestSystemdPayload_SystemBinaryAllowed(t *testing.T) { + svc := ParseUnitFile("nftban-syswrap.service", + "/usr/lib/systemd/system/nftban-syswrap.service", + `[Service] +ExecStart=/usr/bin/systemctl status +`) + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/usr/bin/systemctl"), + Inventory: inv(), // empty — but /usr/bin is system-binary territory + AllUnitNames: map[string]bool{"nftban-syswrap.service": true}, + }) + if !res.OK { + t.Fatalf("expected OK; system binary should be exempt: %#v", res) + } +} + +// ---------------------------------------------------------------------------- +// Systemd Exec prefixes ('-', '+', '!') are stripped before path resolution. +// ---------------------------------------------------------------------------- +func TestSystemdPayload_ExecPrefixesStripped(t *testing.T) { + cases := []struct{ name, line string }{ + {"dash", "[Service]\nExecStartPre=-/usr/lib/nftban/sbin/nftban-apply"}, + {"plus", "[Service]\nExecStart=+/usr/lib/nftban/sbin/nftban-apply"}, + {"bang", "[Service]\nExecStart=!/usr/lib/nftban/sbin/nftban-apply"}, + {"combo", "[Service]\nExecStart=-+/usr/lib/nftban/sbin/nftban-apply"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u := ParseUnitFile("nftban-prefixed.service", "/p", c.line+"\n") + if len(u.Execs) != 1 { + t.Fatalf("expected 1 directive; got %d", len(u.Execs)) + } + if u.Execs[0].Binary != "/usr/lib/nftban/sbin/nftban-apply" { + t.Errorf("prefix not stripped: %q", u.Execs[0].Binary) + } + }) + } +} + +// Continuation lines (trailing backslash) are joined. +func TestSystemdPayload_ContinuationLine(t *testing.T) { + u := ParseUnitFile("nftban-continued.service", "/p", + "[Service]\nExecStart=/usr/lib/nftban/sbin/nftban-apply \\\n --flag-a \\\n --flag-b\n") + if len(u.Execs) != 1 { + t.Fatalf("expected 1 directive; got %d", len(u.Execs)) + } + if !strings.Contains(u.Execs[0].Raw, "--flag-a") || !strings.Contains(u.Execs[0].Raw, "--flag-b") { + t.Errorf("continuation not joined: %q", u.Execs[0].Raw) + } +} + +// Non-nftban units are filtered: a third-party unit referencing a +// missing path does NOT fire the validator. +func TestSystemdPayload_NonNftbanUnitsIgnored(t *testing.T) { + svc := ParseUnitFile("foo.service", "/usr/lib/systemd/system/foo.service", + `[Service] +ExecStart=/opt/foo/bin/foo +`) + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet(), + Inventory: inv(), + AllUnitNames: map[string]bool{"foo.service": true}, + }) + if !res.OK { + t.Fatalf("expected OK; third-party units are out of scope: %#v", res) + } +} + +// IsNftbanUnit naming matrix (P1-A): covers nftban-*, nftband*, and +// every supported unit suffix, plus deliberate negatives. +func TestSystemdPayload_NftbanUnitNaming(t *testing.T) { + cases := map[string]bool{ + // singular / daemon + "nftban.service": true, + "nftband.service": true, + "nftban.timer": true, + "nftband.socket": true, + "nftband.timer": true, + "nftban.target": true, + + // hyphenated nftban-* + "nftban-unified-exporter.service": true, + "nftban-metrics-exporter.timer": true, + "nftban-control.socket": true, + "nftban-maintenance.target": true, + + // hyphenated nftband-* + "nftband-rpc.socket": true, + "nftband-watch.service": true, + + // negatives — name not a complete identifier prefix + "foo-nftban.service": false, + "sshd.service": false, + "nftbanfake.service": false, + "fake-nftban.service": false, + + // negatives — non-systemd unit suffix + "nftban.conf": false, + "nftban.txt": false, + "nftban": false, + "nftban.": false, + "nftban-foo": false, // missing extension entirely + } + for name, want := range cases { + if got := IsNftbanUnit(name); got != want { + t.Errorf("IsNftbanUnit(%q) = %v; want %v", name, got, want) + } + } +} + +// ---------------------------------------------------------------------------- +// P0-B: system-binary exemption table. +// +// Verifies every wrapper/system binary path commonly referenced by +// nftban units is exempt from PAYLOAD-INVENTORY-001 even when the +// inventory is empty. Companion test below proves the exemption does +// NOT extend to nftban-owned paths embedded inside the wrapper's +// command argument. +// ---------------------------------------------------------------------------- +func TestSystemdPayload_SystemBinaryExemption_Table(t *testing.T) { + exemptBinaries := []string{ + "/bin/sh", "/usr/bin/sh", + "/bin/bash", "/usr/bin/bash", + "/bin/env", "/usr/bin/env", + "/bin/systemctl", "/usr/bin/systemctl", + "/usr/bin/journalctl", + } + for _, bin := range exemptBinaries { + t.Run(bin, func(t *testing.T) { + svc := ParseUnitFile("nftban-syswrap.service", + "/usr/lib/systemd/system/nftban-syswrap.service", + "[Service]\nExecStart="+bin+" --version\n") + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet(bin), // wrapper exists; inventory is empty + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-syswrap.service": true}, + }) + if !res.OK { + t.Errorf("expected OK for system binary %s; got %#v", bin, res) + } + }) + } +} + +// Exemption boundary: /bin/sh -c '/usr/lib/nftban/missing.sh' MUST still +// fail when the embedded nftban-owned path is missing/uninventoried. +// The wrapper alone is exempt; the embedded payload reference is not. +func TestSystemdPayload_SystemBinaryExemption_DoesNotMaskEmbeddedNftbanPath(t *testing.T) { + svc := ParseUnitFile("nftban-wrapped-fail.service", + "/usr/lib/systemd/system/nftban-wrapped-fail.service", + `[Service] +ExecStart=/bin/sh -c '/usr/lib/nftban/exporters/nftban_unified_exporter.sh' +`) + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/bin/sh"), // wrapper present, helper missing + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-wrapped-fail.service": true}, + }) + if res.OK { + t.Fatalf("expected NOT OK — embedded nftban path missing must still fire") + } + if len(res.MissingExecPaths) != 1 || + res.MissingExecPaths[0].Path != "/usr/lib/nftban/exporters/nftban_unified_exporter.sh" { + t.Errorf("expected missing-path on embedded helper; got %v", res.MissingExecPaths) + } + // PAYLOAD-INVENTORY-001 should also fire (helper is nftban-owned, not in inventory). + if len(res.UnknownPayloadRefs) != 1 { + t.Errorf("expected UnknownPayloadRef on embedded helper; got %v", res.UnknownPayloadRefs) + } +} + +// Exemption boundary: pure-shell ExecStart that does not reference +// any nftban-owned path passes even with empty inventory. +func TestSystemdPayload_SystemBinaryExemption_PureShellPasses(t *testing.T) { + svc := ParseUnitFile("nftban-shell-only.service", + "/usr/lib/systemd/system/nftban-shell-only.service", + `[Service] +ExecStart=/bin/sh -c 'echo ok' +`) + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/bin/sh"), + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-shell-only.service": true}, + }) + if !res.OK { + t.Fatalf("expected OK — pure shell ExecStart with no embedded nftban paths: %#v", res) + } +} + +// ---------------------------------------------------------------------------- +// P0-C (upgraded P1-C): FAILED-UNIT-POSTINSTALL-001 fails closed when +// the failed-unit enumeration source itself errors. Empty FailedUnits + +// non-empty FailedUnitQueryError must NOT pass. +// ---------------------------------------------------------------------------- +func TestSystemdPayload_FailedUnitQueryError_FailsClosed(t *testing.T) { + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + PathExists: pathSet(), + Inventory: inv(), + FailedNftbanUnits: nil, // no findings + FailedUnitQueryError: "systemctl list-units --state=failed exit=1 stderr=\"Failed to connect to bus\"", + }) + if res.OK { + t.Fatalf("expected NOT OK — query error must fail closed") + } + if res.FailedUnitsOK() { + t.Errorf("FailedUnitsOK should be false when query errored") + } + if !res.ExecStartOK() || !res.TimerPairOK() || !res.PayloadInventoryOK() { + t.Errorf("only FAILED-UNIT-POSTINSTALL-001 should fail; others should pass") + } +} + +// Healthy enumeration with zero findings still passes (the +// fail-closed semantics only kick in when the query errored). +func TestSystemdPayload_FailedUnits_ZeroFindings_NoQueryError_Passes(t *testing.T) { + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + PathExists: pathSet(), + Inventory: inv(), + FailedNftbanUnits: nil, + FailedUnitQueryError: "", + }) + if !res.OK { + t.Fatalf("expected OK — zero findings + no query error is healthy: %#v", res) + } +} + +// ---------------------------------------------------------------------------- +// §9 cheap additions: empty unit file, multi-line ExecStart, symlink shape +// ---------------------------------------------------------------------------- + +// Empty / malformed unit file does not panic; it produces zero Execs +// and contributes nothing to the validator (no ExecStart to check). +func TestSystemdPayload_EmptyUnitFile_NoPanic(t *testing.T) { + cases := []struct{ name, content string }{ + {"empty", ""}, + {"only-comments", "# nothing\n; nothing else\n"}, + {"only-section", "[Service]\n"}, + {"missing-equals", "[Service]\nExecStart\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u := ParseUnitFile("nftban-empty.service", "/p/nftban-empty.service", c.content) + if len(u.Execs) != 0 { + t.Errorf("expected 0 execs for %s; got %d", c.name, len(u.Execs)) + } + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{u}, + PathExists: pathSet(), + Inventory: inv(), + AllUnitNames: map[string]bool{"nftban-empty.service": true}, + }) + if !res.OK { + t.Errorf("empty/malformed unit should not produce findings: %#v", res) + } + }) + } +} + +// Multi-line ExecStart with backslash continuations resolves to a +// single directive whose Raw concatenates the joined logical line. +// Already covered by TestSystemdPayload_ContinuationLine but this +// adds end-to-end validation: the joined RHS still resolves to a +// real binary that exists on disk. +func TestSystemdPayload_MultiLineExecStart_EndToEnd(t *testing.T) { + svc := ParseUnitFile("nftban-multiline.service", + "/usr/lib/systemd/system/nftban-multiline.service", + "[Service]\nExecStart=/usr/lib/nftban/sbin/nftban-apply \\\n --foo \\\n --bar\n") + res := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/usr/lib/nftban/sbin/nftban-apply"), + Inventory: inv("/usr/lib/nftban/sbin/nftban-apply"), + AllUnitNames: map[string]bool{"nftban-multiline.service": true}, + }) + if !res.OK { + t.Fatalf("multi-line ExecStart should pass when binary exists: %#v", res) + } +} + +// Symlink shape: the validator treats path-existence as the callback's +// boolean answer. A live host's PathExists returns true for a symlink +// pointing at a real file, false for a broken symlink. We assert both +// shapes via the closure. +func TestSystemdPayload_SymlinkShape(t *testing.T) { + svc := ParseUnitFile("nftban-symlink.service", + "/usr/lib/systemd/system/nftban-symlink.service", + "[Service]\nExecStart=/usr/lib/nftban/sbin/nftban-apply\n") + + // Resolved symlink — callback says present. + resOK := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet("/usr/lib/nftban/sbin/nftban-apply"), + Inventory: inv("/usr/lib/nftban/sbin/nftban-apply"), + AllUnitNames: map[string]bool{"nftban-symlink.service": true}, + }) + if !resOK.OK { + t.Errorf("resolved symlink should pass; got %#v", resOK) + } + + // Broken symlink — callback says absent. + resBroken := ValidateInstalledSystemdPayload(SystemdPayloadInputs{ + Units: []ParsedUnit{svc}, + PathExists: pathSet(), // empty — broken/dangling + Inventory: inv("/usr/lib/nftban/sbin/nftban-apply"), + AllUnitNames: map[string]bool{"nftban-symlink.service": true}, + }) + if resBroken.OK { + t.Errorf("broken symlink must fail SYSTEMD-EXECSTART-001") + } +} diff --git a/internal/installer/validate/validate_test.go b/internal/installer/validate/validate_test.go index 95f425a96..0077c5bb1 100644 --- a/internal/installer/validate/validate_test.go +++ b/internal/installer/validate/validate_test.go @@ -81,6 +81,12 @@ func seedCompletePayloadInventory(mock *executor.MockExecutor) { } { mock.Dirs[d] = true } + // PR26.1: gatherer requires systemctl for FAILED-UNIT-POSTINSTALL-001 + // enumeration. Default exit-0 + empty stdout from MockExecutor means + // "no failed nftban units" — happy path. Without this seeding the + // gatherer fails closed (correct production behavior, but not the + // scenario these existing happy-path tests exercise). + mock.ExistingCommands["systemctl"] = true } func TestRunAssertions_AllPass(t *testing.T) {