Skip to content

Commit 120c4ab

Browse files
itcmsgrclaude
andcommitted
installer: validate systemd payload before commit
PR26.1 — generic install-validation hardening. Adds four new post-install assertions consumed by the existing AllPassed gate so StateCommitted is blocked on any failure (no phases.go change): SYSTEMD-EXECSTART-001 every nftban unit's ExecStart/Pre/Post local executable path exists on disk SYSTEMD-TIMER-PAIR-001 every nftban-*.timer has its target service installed (explicit Unit= or implicit basename.service) PAYLOAD-INVENTORY-001 nftban-owned paths referenced by nftban units belong to the staged payload inventory FAILED-UNIT-POSTINSTALL-001 no nftban-* unit is in failed state; fails closed if systemctl enumeration cannot complete Generic by design — no panel logic, no DirectAdmin specifics, no firewall-runtime mutation. Pure validator + host adapter; one gather call feeds all four assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7c9b409 commit 120c4ab

5 files changed

Lines changed: 1559 additions & 0 deletions

File tree

internal/installer/validate/assertions.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ func RunAssertions(exec executor.Executor, sshPort int, log *logging.Logger) []A
5050
results = append(results, assertPayloadInventory(exec, log))
5151
results = append(results, assertConfigIntegrity(exec, log))
5252

53+
// PR26.1: systemd-payload invariants. One gather call feeds four
54+
// assertions so we don't walk the unit dirs (or call systemctl)
55+
// four times. Inventory paths are derived from existing
56+
// payload.VerifyInventory's required-set; PAYLOAD-INVENTORY-001
57+
// fails closed when nothing is supplied (every nftban-owned
58+
// referenced path becomes "unknown"), so the gatherer SHOULD
59+
// pass a populated set in production.
60+
in, _ := GatherSystemdPayloadInputs(exec, log, defaultInventoryPaths())
61+
spr := ValidateInstalledSystemdPayload(in)
62+
results = append(results,
63+
assertSystemdExecStartPaths(spr, log),
64+
assertSystemdTimerPair(spr, log),
65+
assertSystemdPayloadInventory(spr, log),
66+
assertFailedUnitsPostInstall(spr, log),
67+
)
68+
5369
passed := 0
5470
for _, r := range results {
5571
if r.Passed {
@@ -225,3 +241,115 @@ func assertConfigIntegrity(exec executor.Executor, log *logging.Logger) Assertio
225241
}
226242
return r
227243
}
244+
245+
// PR26.1 assertions ----------------------------------------------------------
246+
//
247+
// These four assertions implement, respectively:
248+
//
249+
// SYSTEMD-EXECSTART-001 systemd_execstart_paths_ok
250+
// SYSTEMD-TIMER-PAIR-001 systemd_timer_pair_ok
251+
// PAYLOAD-INVENTORY-001 systemd_payload_inventory_ok
252+
// FAILED-UNIT-POSTINSTALL-001 failed_units_postinstall_ok
253+
//
254+
// All four are derived from a single SystemdPayloadValidationResult
255+
// computed once per RunAssertions pass. Each contributes a distinct
256+
// AssertionResult so FailedNames pinpoints which invariant fired.
257+
258+
func assertSystemdExecStartPaths(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult {
259+
r := AssertionResult{Name: "systemd_execstart_paths_ok", Passed: spr.ExecStartOK()}
260+
if r.Passed {
261+
log.Debug("ASSERT systemd_execstart_paths_ok: PASS")
262+
return r
263+
}
264+
parts := make([]string, 0, len(spr.MissingExecPaths))
265+
for _, m := range spr.MissingExecPaths {
266+
parts = append(parts, m.UnitFile+":"+m.Directive+"="+m.Path)
267+
}
268+
r.Detail = "missing ExecStart paths: " + strings.Join(parts, "; ")
269+
log.Warn("ASSERT systemd_execstart_paths_ok: FAIL — %d missing: %s",
270+
len(spr.MissingExecPaths), strings.Join(parts, "; "))
271+
return r
272+
}
273+
274+
func assertSystemdTimerPair(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult {
275+
r := AssertionResult{Name: "systemd_timer_pair_ok", Passed: spr.TimerPairOK()}
276+
if r.Passed {
277+
log.Debug("ASSERT systemd_timer_pair_ok: PASS")
278+
return r
279+
}
280+
parts := make([]string, 0, len(spr.MissingTimerTargets))
281+
for _, m := range spr.MissingTimerTargets {
282+
parts = append(parts, m.TimerUnit+"->"+m.TargetUnit)
283+
}
284+
r.Detail = "timers activate missing services: " + strings.Join(parts, "; ")
285+
log.Warn("ASSERT systemd_timer_pair_ok: FAIL — %d unpaired: %s",
286+
len(spr.MissingTimerTargets), strings.Join(parts, "; "))
287+
return r
288+
}
289+
290+
func assertSystemdPayloadInventory(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult {
291+
r := AssertionResult{Name: "systemd_payload_inventory_ok", Passed: spr.PayloadInventoryOK()}
292+
if r.Passed {
293+
log.Debug("ASSERT systemd_payload_inventory_ok: PASS")
294+
return r
295+
}
296+
parts := make([]string, 0, len(spr.UnknownPayloadRefs))
297+
for _, m := range spr.UnknownPayloadRefs {
298+
parts = append(parts, m.UnitFile+":"+m.Path)
299+
}
300+
r.Detail = "nftban-owned paths not in payload inventory: " + strings.Join(parts, "; ")
301+
log.Warn("ASSERT systemd_payload_inventory_ok: FAIL — %d unknown: %s",
302+
len(spr.UnknownPayloadRefs), strings.Join(parts, "; "))
303+
return r
304+
}
305+
306+
func assertFailedUnitsPostInstall(spr SystemdPayloadValidationResult, log *logging.Logger) AssertionResult {
307+
r := AssertionResult{Name: "failed_units_postinstall_ok", Passed: spr.FailedUnitsOK()}
308+
if r.Passed {
309+
log.Debug("ASSERT failed_units_postinstall_ok: PASS")
310+
return r
311+
}
312+
// Fail-closed query error takes precedence — surfaces the
313+
// enumeration failure rather than misreporting "no failed units".
314+
if spr.FailedUnitQueryError != "" {
315+
r.Detail = "failed-unit enumeration error: " + spr.FailedUnitQueryError
316+
log.Warn("ASSERT failed_units_postinstall_ok: FAIL — %s", spr.FailedUnitQueryError)
317+
return r
318+
}
319+
parts := make([]string, 0, len(spr.FailedUnits))
320+
for _, f := range spr.FailedUnits {
321+
parts = append(parts, f.Unit+"("+f.Detail+")")
322+
}
323+
r.Detail = "nftban units in failed state: " + strings.Join(parts, "; ")
324+
log.Warn("ASSERT failed_units_postinstall_ok: FAIL — %d failed: %s",
325+
len(spr.FailedUnits), strings.Join(parts, "; "))
326+
return r
327+
}
328+
329+
// defaultInventoryPaths returns the set of nftban-owned paths the
330+
// staged install is known to populate. Mirrors payload.VerifyInventory's
331+
// canonical required set, expressed as a set instead of a slice.
332+
//
333+
// Kept local to validate/ so the systemd-payload assertion does not
334+
// import payload.buildEntries (which would couple validation against
335+
// the full destination table). The set is intentionally narrow: a
336+
// missing path here surfaces as a PAYLOAD-INVENTORY-001 finding with
337+
// actionable detail (the unit file + path), prompting the operator
338+
// to either expand this set or stop the unit from referencing the
339+
// path. Either is a deliberate decision — not a silent pass.
340+
func defaultInventoryPaths() map[string]bool {
341+
return map[string]bool{
342+
"/usr/sbin/nftban": true,
343+
"/usr/lib/nftban/bin/nftban-core": true,
344+
"/usr/lib/nftban/bin/nftband": true,
345+
"/usr/lib/nftban/bin/nftban-validate": true,
346+
"/usr/lib/nftban/bin/nftban-installer": true,
347+
"/usr/lib/nftban/sbin/nftban-apply": true,
348+
"/usr/lib/nftban/sbin/nftban-confirm": true,
349+
"/usr/lib/nftban/sbin/nftban-panelctl": true,
350+
"/usr/lib/nftban/sbin/nftban-queue-processor": true,
351+
"/usr/lib/nftban/sbin/nftban-rollback": true,
352+
"/usr/lib/nftban/sbin/nftban-service-alert": true,
353+
"/usr/lib/nftban/sbin/nftban-botscan-processor": true,
354+
}
355+
}

0 commit comments

Comments
 (0)