Skip to content

Commit ed7fe8a

Browse files
itcmsgrclaude
andcommitted
feat(v1.100 Amendment 2): code-A — G1/AuthorityNFTBan split + §54 evidence predicate + --accept-orphan-nftban
Decision-layer implementation of Amendment 2 (contract.md §§52–61). Splits the existing G1/AuthorityNFTBan row into two evaluated-within- Group-1 sub-rules: - G1/AuthorityNFTBan/default — REFUSE, unchanged behavior for all flag patterns outside the candidate triple. - G1/AuthorityNFTBan/orphan-intent-candidate — delegates to the §54 evidence predicate; G1/AuthorityNFTBan/OrphanProceed on all-true, G1/EvidenceMismatch on any-false. Split is ENTIRELY within Group 1; no later group ever defeats a Group 1 outcome and §5 precedence is preserved. Implementation surface ====================== - Add CLI flag --accept-orphan-nftban (cmd/nftban-installer/flags.go). Restore-mode only. CLI argv only — no env-var fallback per §55. Help text contains zero "force"/"override" tokens. - Extend restore.Flags + restore.DecisionInput with: - AcceptOrphanNFTBan bool - Panel detect.PanelType - OrphanEvidence *OrphanEvidence - New OrphanEvidence struct with 13 boolean rows + AllTrue() + FailedRowID(). - New decideAuthorityNFTBan in restore/engine.go: candidate-triple gate → §54 evidence predicate → PROCEED PanelNative/csf or REFUSE. - New cmd/nftban-installer/restore_decide_evidence.go: read-only §54 reader. Uses existing typed surfaces (FileExists, ServiceActive, NftTableExists). E.6 csf.service state uses raw Run("systemctl", "status"|"is-enabled","csf.service") per §43.3 (read-only probes authorized). NO mutating systemctl verbs. Zero new typed mutation surfaces. - Dispatcher (restore_decide.go) gathers evidence ONLY when candidate triple is otherwise present, avoiding unnecessary live reads on every restore-mode invocation. Tests ===== - engine_amendment2_test.go: §56.1 unit fixtures (rows 01–20 + 7d acceptable-fallback variant), §56.2 regression (R1–R6), §56.4 mutation-surface AST scan, FailedRowID per-row coverage. - restore_decide_evidence_test.go: gatherOrphanEvidence per-row coverage (15 cases including E.6 sub-variants: active forbidden, not-found forbidden, enabled forbidden, static forbidden, disabled-acceptable). NFTBAN_ACCEPT_ORPHAN env-var-fallback source-scan invariant. - engine_test.go: existing G1/AuthorityNFTBan fixture re-pinned to G1/AuthorityNFTBan/default; declaredRules now lists three sub-rule consts; two new allFixtures entries pin OrphanProceed and EvidenceMismatch. CI gates (§56.3) implemented as in-process tests, NOT as GitHub Actions edits — per operator direction. Source-scan tests in engine_amendment2_test.go (force/override scan) and restore_decide_evidence_test.go (env-var fallback scan). Invariants preserved ==================== - §5 precedence: split is entirely within Group 1. - INV-PR25-AUTHORITY-IMMUTABILITY: §54 evidence read once at decision time, never re-read. - §19.2 layer 4 / main.go:132 history gate: untouched. - §20.1 panel mapping: PROCEED resolves PanelNative/csf via the existing static map. - §22 four terminals + §19.4 exit codes: unchanged. - §32 11-step ordering: unchanged. - §43.3 read-only Run policy: §54 E.6 uses status/is-enabled only. - §51.3 Option B: zero iptables introspection added. - INV-PR26-NEW-MUTATION-SURFACES-BOUNDED: zero new mutation surfaces. - INV-AMD2-EXPLICIT-INTENT-IS-NARROW: §53.4 13 explicit-REFUSE rows test-pinned in §56.1. Test results ============ - go test ./internal/installer/restore/... PASS - go test ./cmd/nftban-installer/... PASS - go test ./... 64 packages PASS, 0 FAIL Grep gates (scoped to Amendment 2 changed files) ================================================ - iptables-(save|restore|nft) 0 hits → PASS - build[ -]set[ ]+csf 0 hits → PASS - WriteFileAtomic.*update-history|... 0 hits → PASS - force|override in --accept-orphan-nftban 0 hits → PASS Out of scope (deferred) ======================= - §59 Q3/Q4 — typed read-only ServiceListed/ServiceEnabled/NftListTables not added; raw Run for E.6 + existing NftTableExists used instead. - §59 Q5 — final --help text wording locked here; further refinement optional. - amendment-2-code-E real-host srv3 destructive evidence — separate PR. No srv3 action. No CI workflow edit. No §32 mutation path change. No new state terminals. No new exit codes. No update-history writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7080096 commit ed7fe8a

9 files changed

Lines changed: 1418 additions & 12 deletions

File tree

cmd/nftban-installer/flags.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ type config struct {
5858
// behind an explicit default-off flag. Operators that relied on the
5959
// prior behaviour must now pass --panel-auto-takeover.
6060
panelAutoTakeover bool // --panel-auto-takeover: allow panel presence to auto-approve takeover (default OFF)
61+
// v1.100 Amendment 2: --accept-orphan-nftban — explicit operator
62+
// intent for the narrow restore-from-orphan-NFTBan-on-DirectAdmin
63+
// path (contract.md §52–61). MUST be CLI argv only — no env-var
64+
// fallback, no config-file key, no implicit default. Activates the
65+
// G1/AuthorityNFTBan split's orphan-intent candidate row only when
66+
// combined with --panel-auto-takeover, Panel=DirectAdmin,
67+
// Authority=AuthorityNFTBan, Prior=NoRecord, and ALL §54.1
68+
// evidence rows true. Standalone or under any other classifier the
69+
// flag has zero effect — REFUSE remains.
70+
acceptOrphanNFTBan bool // --accept-orphan-nftban: explicit-intent CSF restore from orphan NFTBan on DirectAdmin (Amendment 2)
6171
// v1.100 PR-23: --confirm-mutation replaces the PR-22 auto-elevate
6272
// shim. --mode=uninstall now requires exactly one of:
6373
// --dry-run (observational plan)
@@ -97,6 +107,10 @@ func parseFlags() *config {
97107
flag.BoolVar(&cfg.restorePriorAuthority, "restore-prior-authority", false, "Restore pre-install external firewall authority. Requires recorded prior-authority record. Plan-only in PR-22.")
98108
// v1.100 PR-22B: explicit panel-auto-takeover gate (see config field doc).
99109
flag.BoolVar(&cfg.panelAutoTakeover, "panel-auto-takeover", false, "Allow control-panel presence to auto-approve takeover of conflicting firewalls. Default OFF. Set explicitly to preserve pre-PR-22B behaviour.")
110+
// v1.100 Amendment 2: --accept-orphan-nftban — explicit-intent
111+
// orphan restore. CLI argv only; no env mirror; help text MUST NOT
112+
// include "force" or "override" (Amendment 2 §55).
113+
flag.BoolVar(&cfg.acceptOrphanNFTBan, "accept-orphan-nftban", false, "Explicit-intent CSF restore on a DirectAdmin host where NFTBan is the current authority and no prior-authority record exists. Requires --mode=restore AND --panel-auto-takeover AND DirectAdmin AND on-disk evidence that NFTBan previously took over from CSF. Without all preconditions the restore refuses (Amendment 2).")
100114
// v1.100 PR-23: --confirm-mutation — explicit uninstall mutation entry.
101115
flag.BoolVar(&cfg.confirmMutation, "confirm-mutation", false, "Authorize uninstall authority release (real kernel + service mutation). Required for --mode=uninstall without --dry-run. Mutually exclusive with --dry-run.")
102116

@@ -161,6 +175,11 @@ func parseFlags() *config {
161175
fmt.Fprintln(os.Stderr, " --mode=restore invokes the PR-24 decision engine; it performs NO mutation.")
162176
os.Exit(state.ExitFatal)
163177
}
178+
// Amendment 2 §55 — `--accept-orphan-nftban` is a
179+
// restore-mode-only flag. Reject early in any other mode.
180+
// (`cfg.mode == "restore"` here, but ANY mode other than
181+
// restore that received the flag must refuse before the
182+
// fall-through return below.)
164183
if cfg.dryRun {
165184
fmt.Fprintln(os.Stderr, "error: --dry-run is not valid with --mode=restore")
166185
fmt.Fprintln(os.Stderr, " --mode=restore is ALWAYS pure policy decision — no mutation path exists.")
@@ -270,6 +289,14 @@ func parseFlags() *config {
270289
os.Exit(state.ExitFatal)
271290
}
272291

292+
// Amendment 2 §55: --accept-orphan-nftban is restore-mode only.
293+
// Reject explicitly so operators don't get a silent no-op in
294+
// install / upgrade / uninstall.
295+
if cfg.acceptOrphanNFTBan && cfg.mode != "restore" {
296+
fmt.Fprintln(os.Stderr, "error: --accept-orphan-nftban is only valid with --mode=restore")
297+
os.Exit(state.ExitFatal)
298+
}
299+
273300
// --source is mutually exclusive with packaging-origin flags.
274301
if cfg.source && (cfg.rpm || cfg.deb) {
275302
fmt.Fprintln(os.Stderr, "error: --source cannot be combined with --rpm or --deb")

cmd/nftban-installer/restore_decide.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,28 @@ func runRestoreDecide(ctx context.Context, exec executor.Executor, sf *state.Sta
128128
Ambiguity: auth.Ambiguity,
129129
Prior: priorState,
130130
Flags: restore.Flags{
131-
Restore: cfg.restorePriorAuthority,
132-
PanelAutoTakeover: cfg.panelAutoTakeover,
131+
Restore: cfg.restorePriorAuthority,
132+
PanelAutoTakeover: cfg.panelAutoTakeover,
133+
AcceptOrphanNFTBan: cfg.acceptOrphanNFTBan,
133134
},
134135
PanelPresent: panelPresent,
136+
Panel: panel,
137+
}
138+
139+
// 6b. Amendment 2 §54.3: gather orphan-restore evidence ONLY when
140+
// the candidate triple is otherwise present. This avoids
141+
// unnecessary live reads on every restore-mode invocation, and
142+
// preserves the §51.3 Option B boundary (no iptables introspection)
143+
// by only running the predicate where it matters.
144+
if auth.State == uninstall.AuthorityNFTBan &&
145+
priorState == restore.PriorStateNoRecord &&
146+
panel == detect.PanelDirectAdmin &&
147+
cfg.panelAutoTakeover &&
148+
cfg.acceptOrphanNFTBan {
149+
ev := gatherOrphanEvidence(exec, log, panel, auth, probe, cfg)
150+
input.OrphanEvidence = &ev
151+
log.Info("restore decide: orphan-evidence gathered all_true=%v failed_row=%q",
152+
ev.AllTrue(), ev.FailedRowID())
135153
}
136154

137155
// 7. Evaluate — pure, deterministic, no side effects.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// =============================================================================
2+
// NFTBan v1.100 Amendment 2 — Orphan-NFTBan restore evidence reader
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="nftban-installer-restore-decide-evidence"
6+
// meta:type="cmd"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-28"
9+
// meta:description="§54.1 read-only evidence predicate for the G1 orphan-intent split"
10+
// meta:inventory.files="cmd/nftban-installer/restore_decide_evidence.go"
11+
// meta:inventory.binaries=""
12+
// meta:inventory.env_vars=""
13+
// meta:inventory.config_files=""
14+
// meta:inventory.systemd_units=""
15+
// meta:inventory.network=""
16+
// meta:inventory.privileges="root"
17+
// =============================================================================
18+
//
19+
// Amendment 2 (contract.md §§52–61) defines a 13-row evidence predicate
20+
// (§54.1) that gates the G1/AuthorityNFTBan/OrphanProceed sub-rule. This
21+
// file implements the predicate as a pure read-only reader against the
22+
// live host state.
23+
//
24+
// Discipline:
25+
// - Read-only only. NO mutating systemctl verbs (start/stop/enable/
26+
// disable/mask/unmask/restart/daemon-reload). NO file writes. NO
27+
// nft mutation. NO iptables introspection (preserves §51.3 Option B).
28+
// - Read failures map to false on the failing row, NOT to
29+
// REQUIRE_EXPLICIT_INTENT (§54.2 final bullet).
30+
// - Caller (`runRestoreDecide`) invokes this only when the candidate
31+
// triple is present, to avoid unnecessary live reads.
32+
//
33+
// =============================================================================
34+
package main
35+
36+
import (
37+
"strings"
38+
39+
"github.com/itcmsgr/nftban/internal/installer/detect"
40+
"github.com/itcmsgr/nftban/internal/installer/executor"
41+
"github.com/itcmsgr/nftban/internal/installer/logging"
42+
"github.com/itcmsgr/nftban/internal/installer/restore"
43+
"github.com/itcmsgr/nftban/internal/installer/uninstall"
44+
)
45+
46+
const (
47+
csfServiceUnitForEvidence = "csf.service"
48+
nftbandServiceUnit = "nftband.service"
49+
csfBinaryPath = "/usr/sbin/csf"
50+
csfDisabledBinaryPath = "/usr/sbin/csf.disabled"
51+
)
52+
53+
// gatherOrphanEvidence reads the 13 §54.1 rows from the live host
54+
// using only read-only executor surfaces. Returns a populated
55+
// restore.OrphanEvidence struct; AllTrue() reports whether every row
56+
// holds.
57+
//
58+
// Rows E.1, E.2, E.3, E.4, E.5 are derived from inputs the dispatcher
59+
// already gathered (`detect.DetectPanel`, `uninstall.Classify`,
60+
// `uninstall.Probe`, CLI flags). Rows E.6–E.13 are read from the
61+
// kernel/service/file surfaces.
62+
func gatherOrphanEvidence(
63+
exec executor.Executor,
64+
log *logging.Logger,
65+
panel detect.PanelType,
66+
auth *uninstall.ClassifyResult,
67+
probe *uninstall.ProbeResult,
68+
cfg *config,
69+
) restore.OrphanEvidence {
70+
ev := restore.OrphanEvidence{}
71+
72+
// E.1 panel = DirectAdmin
73+
ev.E1PanelDirectAdmin = panel == detect.PanelDirectAdmin
74+
75+
// E.2 authority = AuthorityNFTBan
76+
ev.E2AuthorityNFTBan = auth.State == uninstall.AuthorityNFTBan
77+
78+
// E.3 prior = NoRecord
79+
ev.E3PriorNoRecord = probe.State == uninstall.PriorNoRecord
80+
81+
// E.4 --panel-auto-takeover present
82+
ev.E4PanelAutoTakeover = cfg.panelAutoTakeover
83+
84+
// E.5 --accept-orphan-nftban present (CLI argv only — env-var
85+
// fallback is rejected at flag-parse time per §55).
86+
ev.E5AcceptOrphanNFTBan = cfg.acceptOrphanNFTBan
87+
88+
// E.6 csf.service exists AND not active AND is-enabled in {masked, disabled}.
89+
// Three sub-checks. Any failure → E6 false.
90+
ev.E6CSFServiceDisabled = csfServiceIsDisabledOrMasked(exec, log)
91+
92+
// E.7 /usr/sbin/csf.disabled exists
93+
ev.E7CSFDisabledExists = exec.FileExists(csfDisabledBinaryPath)
94+
95+
// E.8 /usr/sbin/csf does NOT exist
96+
ev.E8CSFAbsent = !exec.FileExists(csfBinaryPath)
97+
98+
// E.9 ip:nftban table present
99+
ev.E9NftIPNftbanPresent = exec.NftTableExists("ip", "nftban")
100+
101+
// E.10 ip6:nftban table present
102+
ev.E10NftIP6NftbanPres = exec.NftTableExists("ip6", "nftban")
103+
104+
// E.11 nftband.service active
105+
ev.E11NftbandActive = exec.ServiceActive(nftbandServiceUnit)
106+
107+
// E.12 no AuthorityExternal / no AmbiguityConflictExternal.
108+
// Classifier output already established AuthorityNFTBan (caller),
109+
// so this is implicit; recorded explicitly per §54.1 to avoid
110+
// inferential gaps.
111+
ev.E12NoConflictExternal = auth.State != uninstall.AuthorityExternal &&
112+
auth.Ambiguity != uninstall.AmbiguityConflictExternal
113+
114+
// E.13 no Ambiguous classification
115+
ev.E13NoAmbiguous = auth.State != uninstall.AuthorityAmbiguous
116+
117+
return ev
118+
}
119+
120+
// csfServiceIsDisabledOrMasked returns true iff csf.service:
121+
// - exists (systemctl status is parseable, not "could not be found")
122+
// - is NOT active (is-active returns "inactive" or "failed")
123+
// - is-enabled is one of {"masked", "disabled"}; "enabled" or
124+
// "static" or anything else returns false.
125+
//
126+
// Routes through the executor's Run abstraction for read-only
127+
// systemctl probes (§43.3 — raw Run permitted in restore deps for
128+
// read-only probes only). NO mutating systemctl verb is invoked.
129+
func csfServiceIsDisabledOrMasked(exec executor.Executor, log *logging.Logger) bool {
130+
// Sub-check 1: existence via `systemctl status csf.service`.
131+
statusRes := exec.Run("systemctl", "status", "--no-pager", "--lines=0", csfServiceUnitForEvidence)
132+
combined := statusRes.Stdout + statusRes.Stderr
133+
if strings.Contains(combined, "could not be found") || strings.Contains(combined, "not-found") {
134+
log.Info("amd2-evidence: E.6 csf.service not present on host")
135+
return false
136+
}
137+
138+
// Sub-check 2: must NOT be active. Use the typed read-only seam.
139+
if exec.ServiceActive(csfServiceUnitForEvidence) {
140+
log.Info("amd2-evidence: E.6 csf.service is active — orphan precondition violated")
141+
return false
142+
}
143+
144+
// Sub-check 3: is-enabled must be in {masked, disabled}. Reject
145+
// {enabled, static, anything else}. Forbidden values:
146+
// - "enabled" — csf would return at next boot
147+
// - "static" — csf has no install dependency relations; would
148+
// re-arm via Wants= at boot
149+
enabledRes := exec.Run("systemctl", "is-enabled", csfServiceUnitForEvidence)
150+
enabledState := strings.TrimSpace(enabledRes.Stdout)
151+
switch enabledState {
152+
case "masked", "disabled":
153+
// Acceptable. (masked = strongest, matches install-time
154+
// switchop.DisableConflicts; disabled = acceptable fallback.)
155+
return true
156+
default:
157+
log.Info("amd2-evidence: E.6 csf.service is-enabled=%q (forbidden; want masked or disabled)", enabledState)
158+
return false
159+
}
160+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// =============================================================================
2+
// NFTBan v1.100 Amendment 2 — Test helper: read source files for invariants
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="nftban-installer-restore-decide-evidence-test-helper"
6+
// meta:type="test"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-28"
9+
// meta:description="Filesystem reader for source-scan invariants (§56.3 / §56.4)"
10+
// meta:inventory.files="cmd/nftban-installer/restore_decide_evidence_test_helper.go"
11+
// meta:inventory.binaries=""
12+
// meta:inventory.env_vars=""
13+
// meta:inventory.config_files=""
14+
// meta:inventory.systemd_units=""
15+
// meta:inventory.network=""
16+
// meta:inventory.privileges="none"
17+
// =============================================================================
18+
package main
19+
20+
import "os"
21+
22+
// readFileImpl reads a file path relative to the test working
23+
// directory. Used by the Amendment 2 source-scan invariant tests.
24+
func readFileImpl(path string) ([]byte, error) {
25+
return os.ReadFile(path)
26+
}

0 commit comments

Comments
 (0)