@@ -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