Skip to content

Commit 6373950

Browse files
itcmsgrclaude
andcommitted
installer: panel-survival framework + FakePanelAdapter (PR26.2)
PR26.2 — generic hosting-panel adapter framework. Adds the PANEL-SURVIVAL-001 invariant: a detected panel whose adapter integration fails must not reach StateCommitted unless the operator opts out via --no-panel. internal/installer/panelfw/ (new package): - PanelAdapter interface (read-only contract) - PanelDetection / PanelPolicy / PanelResult types - Register / RegisteredAdapters (registry; empty in PR26.2) - DefaultPolicy (RequirePanelSuccess=true, AllowPanelAbsent=true) - Evaluate / EvaluateAdapters (test-callable variant) - FakePanelAdapter (test-only via _test.go) + 14 spec tests internal/installer/validate: - new assertion `panel_survival_ok` consuming opts.PanelPolicy - new RunAssertionsWithOpts(...) entry point + AssertionOpts.WithPanelPolicy - existing RunAssertions delegates to RunAssertionsWithOpts with default opts; callers stay source-compatible - integration tests prove the assertion blocks StateCommitted via AllPassed cmd/nftban-installer: - new --no-panel flag - phaseData.noPanel propagates from cfg - phaseValidate (VALIDATE_1 and VALIDATE_2) calls RunAssertionsWithOpts with the operator-derived policy Hard exclusions: - no DirectAdmin / cPanel / Plesk adapter (those land in PR26.3+) - no firewall mutation - no runtime nft surgery - no host destructive testing - no restore changes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cdf8f77 commit 6373950

7 files changed

Lines changed: 983 additions & 4 deletions

File tree

cmd/nftban-installer/flags.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ type config struct {
7878
// any regression that reintroduces an auto-elevate path while the
7979
// mutation code is present.
8080
confirmMutation bool // --confirm-mutation: authorize uninstall authority release (PR-23)
81+
// v1.100 PR26.2: --no-panel — operator opt-out of panel-survival
82+
// enforcement (PANEL-SURVIVAL-001). Default off. When set, the
83+
// panel-survival assertion still runs registered adapters for
84+
// diagnostic logging but never reports Fatal=true. The flag
85+
// exists so an operator on a host that legitimately runs without
86+
// nftban-panel integration (e.g., panel managed externally) can
87+
// proceed past install without an assertion failure they cannot
88+
// resolve. PR26.2 ships with an empty adapter registry — the
89+
// flag is a no-op until PR26.3 lands the first real adapter.
90+
noPanel bool // --no-panel: opt out of panel-survival enforcement
8191
}
8292

8393
func parseFlags() *config {
@@ -113,6 +123,8 @@ func parseFlags() *config {
113123
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).")
114124
// v1.100 PR-23: --confirm-mutation — explicit uninstall mutation entry.
115125
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.")
126+
// v1.100 PR26.2: PANEL-SURVIVAL-001 opt-out.
127+
flag.BoolVar(&cfg.noPanel, "no-panel", false, "Opt out of PANEL-SURVIVAL-001 enforcement. Adapters still run for diagnostic logging but a detected-panel integration failure will NOT block StateCommitted on this run. Default OFF.")
116128

117129
flag.Parse()
118130

cmd/nftban-installer/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ func main() {
105105
// authority classifier honours the operator's explicit opt-in. Default
106106
// false — panel presence alone no longer implicitly approves takeover.
107107
globalPhaseData.panelAutoApprove = cfg.panelAutoTakeover
108+
// PR26.2: propagate --no-panel so the panel-survival assertion's
109+
// policy can opt out when the operator explicitly disables it.
110+
globalPhaseData.noPanel = cfg.noPanel
108111

109112
exitCode := run(ctx, exec, sf, cfg, log)
110113

cmd/nftban-installer/phases.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/itcmsgr/nftban/internal/installer/executor"
3030
"github.com/itcmsgr/nftban/internal/installer/fhs"
3131
"github.com/itcmsgr/nftban/internal/installer/logging"
32+
"github.com/itcmsgr/nftban/internal/installer/panelfw"
3233
"github.com/itcmsgr/nftban/internal/installer/payload"
3334
"github.com/itcmsgr/nftban/internal/installer/render"
3435
"github.com/itcmsgr/nftban/internal/installer/safety"
@@ -59,6 +60,11 @@ type phaseData struct {
5960
// cfg.panelAutoTakeover in main() before phases run. Default false —
6061
// panel detection alone no longer auto-approves takeover.
6162
panelAutoApprove bool
63+
// v1.100 PR26.2: PANEL-SURVIVAL-001 opt-out. Propagated from
64+
// cfg.noPanel in main(). When true, the panel-survival assertion
65+
// returns Fatal=false even on adapter failure (operator has
66+
// explicitly accepted the risk).
67+
noPanel bool
6268
}
6369

6470
// globalPhaseData is set by phaseDetect and consumed by later phases.
@@ -345,7 +351,13 @@ func phaseValidate(_ context.Context, exec executor.Executor, sf *state.StateFil
345351
validate.RunPermissionsEnforce(exec, log)
346352

347353
// 3. Run assertions (VALIDATE_1)
348-
results := validate.RunAssertions(exec, pd.sshPort, log)
354+
// PR26.2: derive panel-survival policy from operator flags. The
355+
// adapter registry is consulted by panelfw (empty in PR26.2 —
356+
// effectively a no-op until PR26.3 lands the first adapter).
357+
policy := panelfw.DefaultPolicy()
358+
policy.OperatorDisabled = pd.noPanel
359+
opts := validate.AssertionOpts{}.WithPanelPolicy(policy)
360+
results := validate.RunAssertionsWithOpts(exec, pd.sshPort, log, opts)
349361

350362
// 4. Set immutable flags on security-critical files (G8)
351363
validate.SetImmutableFlags(exec, log)
@@ -373,7 +385,7 @@ func phaseValidate(_ context.Context, exec executor.Executor, sf *state.StateFil
373385
}
374386

375387
// v1.98 INV-I-013: Re-run assertions (VALIDATE_2) — only this result counts
376-
results2 := validate.RunAssertions(exec, pd.sshPort, log)
388+
results2 := validate.RunAssertionsWithOpts(exec, pd.sshPort, log, opts)
377389

378390
if validate.AllPassed(results2) {
379391
log.Info("VALIDATE_2: all assertions passed after safe auto-fix — COMMITTED")
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// =============================================================================
2+
// NFTBan v1.100.x PR26.2 - Reusable Panel Adapter Framework
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="installer-panelfw"
6+
// meta:type="lib"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-29"
9+
// meta:description="Generic hosting-panel adapter framework — survival policy + registry"
10+
// meta:inventory.files="internal/installer/panelfw/panelfw.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+
// PR26.2 — generic panel framework. PANEL-SURVIVAL-001 invariant:
20+
//
21+
// If a hosting panel is detected before lifecycle mutation,
22+
// then nftban must either preserve the panel's required service ports
23+
// through the canonical ports model and validate integration success,
24+
// OR abort before StateCommitted with a clear panel-safety error.
25+
// Panel detected + integration failure must never reach StateCommitted,
26+
// unless the operator explicitly disables panel integration with a
27+
// supported flag.
28+
//
29+
// Hard exclusions in this PR (DirectAdmin/cPanel/Plesk live in PR26.3+):
30+
// - no real adapter implementations (RegisteredAdapters returns nil)
31+
// - no firewall mutation
32+
// - no host destructive testing
33+
// - no restore semantics
34+
//
35+
// Adapters are required to be read-only by interface contract:
36+
// Detect, RequiredPorts, and ValidateReachability MUST NOT mutate the
37+
// host. The framework calls each in sequence and converts results into
38+
// a PanelResult; the caller (validate.assertion) decides whether to
39+
// block StateCommitted.
40+
//
41+
// =============================================================================
42+
43+
package panelfw
44+
45+
import (
46+
"context"
47+
"fmt"
48+
"strings"
49+
50+
"github.com/itcmsgr/nftban/internal/installer/executor"
51+
"github.com/itcmsgr/nftban/internal/installer/logging"
52+
)
53+
54+
// PanelID is the canonical identifier for a hosting panel adapter.
55+
// Matches the lower-case panel-name convention used throughout the
56+
// installer (e.g., "directadmin", "cpanel", "plesk", "fakepanel").
57+
type PanelID string
58+
59+
// PanelDetection is the structured outcome of an adapter's Detect()
60+
// call. Adapters set Detected=true only when their evidence list is
61+
// non-empty; Confidence is "strong" when the adapter is sure (e.g.,
62+
// canonical install dir + listening port + active service) and "weak"
63+
// when only one indicator was found.
64+
type PanelDetection struct {
65+
ID PanelID
66+
Detected bool
67+
Confidence string
68+
Evidence []string
69+
RequiredTCP []int
70+
RequiredUDP []int
71+
Warnings []string
72+
}
73+
74+
// PanelAdapter is the contract every panel plugin implements. All
75+
// methods MUST be read-only — the framework guarantees that calling
76+
// Detect, RequiredPorts, or ValidateReachability never mutates host
77+
// state. Adapters that need to write configuration (e.g., to apply
78+
// ports to the canonical ports model) do that through a separate
79+
// host-controlled write path; the framework only consults the adapter
80+
// for facts and validates outcomes.
81+
type PanelAdapter interface {
82+
// ID returns the canonical adapter identifier.
83+
ID() PanelID
84+
85+
// Detect inspects the host and returns whether this adapter's
86+
// panel is present, with evidence and required ports.
87+
Detect(ctx context.Context, exec executor.Executor) PanelDetection
88+
89+
// RequiredPorts returns the TCP and UDP port lists the panel
90+
// must keep reachable for its control surface to survive an
91+
// nftban lifecycle operation. Returns an error if the adapter
92+
// cannot enumerate them (e.g., panel config unreadable).
93+
RequiredPorts(ctx context.Context, exec executor.Executor) (tcp []int, udp []int, err error)
94+
95+
// ValidateReachability confirms the panel's control surface is
96+
// reachable in the current ruleset. Read-only: it MUST NOT
97+
// mutate ports, services, or rules. Returns nil on success or
98+
// a structured error describing the missing-port/timeout/etc.
99+
// condition.
100+
ValidateReachability(ctx context.Context, exec executor.Executor) error
101+
}
102+
103+
// PanelPolicy controls how the framework converts adapter outcomes
104+
// into a Fatal/non-fatal verdict.
105+
type PanelPolicy struct {
106+
// RequirePanelSuccess: when an adapter detects a panel and either
107+
// RequiredPorts or ValidateReachability errors, the result is
108+
// Fatal. Default true. The "non-fatal warning" path that let
109+
// dns2's panel-enable failure slip through StateCommitted is
110+
// removed by setting this to true.
111+
RequirePanelSuccess bool
112+
113+
// AllowPanelAbsent: when no adapter detects a panel, treat as
114+
// healthy (the common no-panel case). Default true.
115+
AllowPanelAbsent bool
116+
117+
// OperatorDisabled: operator passed --no-panel (or equivalent
118+
// supported flag) to opt out of panel-survival enforcement on
119+
// this run. The framework still runs adapters for diagnostic
120+
// purposes but always returns Fatal=false.
121+
OperatorDisabled bool
122+
}
123+
124+
// DefaultPolicy returns the production-default policy:
125+
// RequirePanelSuccess=true, AllowPanelAbsent=true, OperatorDisabled=false.
126+
func DefaultPolicy() PanelPolicy {
127+
return PanelPolicy{
128+
RequirePanelSuccess: true,
129+
AllowPanelAbsent: true,
130+
OperatorDisabled: false,
131+
}
132+
}
133+
134+
// PanelResult is the structured outcome of Evaluate.
135+
type PanelResult struct {
136+
// Detection is the first adapter's detection record (or a
137+
// zero-value detection with ID="" when no adapter detected
138+
// anything).
139+
Detection PanelDetection
140+
141+
// PortsTCP / PortsUDP mirror the RequiredPorts result for the
142+
// detected panel (nil when no panel detected or RequiredPorts
143+
// errored).
144+
PortsTCP []int
145+
PortsUDP []int
146+
147+
// PortsApplied indicates whether the adapter's RequiredPorts
148+
// call succeeded. It does NOT confirm ports were written to the
149+
// canonical ports model — that is a separate framework
150+
// responsibility outside PR26.2 scope. The flag exists so the
151+
// assertion message can distinguish "couldn't determine ports"
152+
// from "ports known and reachability validated".
153+
PortsApplied bool
154+
155+
// ReachableAfter is true when ValidateReachability returned nil.
156+
// False on adapter error or when the framework did not reach
157+
// the reachability check (e.g., RequiredPorts errored first).
158+
ReachableAfter bool
159+
160+
// Fatal is the policy-derived verdict. When true, the caller
161+
// (validate.assertion) MUST block StateCommitted.
162+
Fatal bool
163+
164+
// Reason carries a human-readable summary suitable for the
165+
// AssertionResult.Detail field. Empty when not Fatal.
166+
Reason string
167+
}
168+
169+
// registry holds adapters in package-private state. Production code
170+
// path: filled by adapter packages' init() (PR26.3 onwards). Tests
171+
// pass an explicit slice to EvaluateAdapters and bypass the registry.
172+
var registry []PanelAdapter
173+
174+
// Register adds a PanelAdapter to the package registry. Adapter
175+
// packages call this from init(); the framework iterates the registry
176+
// in registration order during Evaluate.
177+
//
178+
// Duplicate IDs are not enforced — the first detected panel wins
179+
// regardless. Adapter authors are responsible for non-overlapping
180+
// detection criteria.
181+
func Register(a PanelAdapter) {
182+
if a == nil {
183+
return
184+
}
185+
registry = append(registry, a)
186+
}
187+
188+
// RegisteredAdapters returns a copy of the current registry. Stable
189+
// in registration order. Returns an empty slice when nothing has
190+
// been registered (the PR26.2 default — no adapters compiled in).
191+
func RegisteredAdapters() []PanelAdapter {
192+
out := make([]PanelAdapter, len(registry))
193+
copy(out, registry)
194+
return out
195+
}
196+
197+
// resetRegistryForTest is exported via _test.go helpers to allow
198+
// per-test isolation. Production code never calls it.
199+
func resetRegistryForTest() {
200+
registry = nil
201+
}
202+
203+
// Evaluate runs the registered adapters and returns the first detected
204+
// panel's PanelResult. Pure framework call — relies entirely on the
205+
// adapter contract; no panel-specific knowledge in this function.
206+
func Evaluate(ctx context.Context, exec executor.Executor, log *logging.Logger, policy PanelPolicy) PanelResult {
207+
return EvaluateAdapters(ctx, exec, log, RegisteredAdapters(), policy)
208+
}
209+
210+
// EvaluateAdapters is the test-callable variant of Evaluate. Callers
211+
// pass an explicit adapter slice so they can inject FakePanelAdapter
212+
// without touching the global registry.
213+
func EvaluateAdapters(ctx context.Context, exec executor.Executor, log *logging.Logger, adapters []PanelAdapter, policy PanelPolicy) PanelResult {
214+
if log == nil {
215+
log = logging.New("/dev/null", false)
216+
}
217+
218+
// No adapter detected anything → policy decides. Default
219+
// AllowPanelAbsent=true means non-fatal pass.
220+
if len(adapters) == 0 {
221+
log.Debug("panelfw: no adapters registered — treating as no-panel host")
222+
return PanelResult{
223+
Detection: PanelDetection{},
224+
Fatal: !policy.AllowPanelAbsent,
225+
Reason: panelAbsentReason(policy),
226+
}
227+
}
228+
229+
for _, a := range adapters {
230+
det := a.Detect(ctx, exec)
231+
if !det.Detected {
232+
continue
233+
}
234+
log.Info("panelfw: detected panel id=%s confidence=%s",
235+
string(det.ID), det.Confidence)
236+
return finalizeDetected(ctx, exec, log, a, det, policy)
237+
}
238+
239+
log.Debug("panelfw: no adapter reported a detected panel")
240+
return PanelResult{
241+
Detection: PanelDetection{},
242+
Fatal: !policy.AllowPanelAbsent,
243+
Reason: panelAbsentReason(policy),
244+
}
245+
}
246+
247+
func panelAbsentReason(policy PanelPolicy) string {
248+
if policy.AllowPanelAbsent {
249+
return ""
250+
}
251+
return "no hosting panel detected, but policy.AllowPanelAbsent=false requires one"
252+
}
253+
254+
// finalizeDetected runs RequiredPorts + ValidateReachability and
255+
// converts the outcomes into a policy-derived PanelResult.
256+
func finalizeDetected(ctx context.Context, exec executor.Executor, log *logging.Logger, a PanelAdapter, det PanelDetection, policy PanelPolicy) PanelResult {
257+
res := PanelResult{Detection: det}
258+
259+
tcp, udp, err := a.RequiredPorts(ctx, exec)
260+
if err != nil {
261+
res.PortsApplied = false
262+
res.Reason = fmt.Sprintf("panel %s RequiredPorts failed: %v", string(det.ID), err)
263+
log.Warn("panelfw: %s", res.Reason)
264+
res.Fatal = computeFatal(policy)
265+
return res
266+
}
267+
res.PortsTCP = tcp
268+
res.PortsUDP = udp
269+
res.PortsApplied = true
270+
271+
if rerr := a.ValidateReachability(ctx, exec); rerr != nil {
272+
res.ReachableAfter = false
273+
res.Reason = fmt.Sprintf("panel %s ValidateReachability failed: %v", string(det.ID), rerr)
274+
log.Warn("panelfw: %s", res.Reason)
275+
res.Fatal = computeFatal(policy)
276+
return res
277+
}
278+
res.ReachableAfter = true
279+
log.Info("panelfw: panel %s integration validated tcp=%s udp=%s",
280+
string(det.ID), portsToString(tcp), portsToString(udp))
281+
return res
282+
}
283+
284+
// computeFatal applies the policy to a failed integration outcome.
285+
// The OperatorDisabled override turns any failure non-fatal — the
286+
// operator has explicitly accepted the risk via --no-panel.
287+
func computeFatal(policy PanelPolicy) bool {
288+
if policy.OperatorDisabled {
289+
return false
290+
}
291+
return policy.RequirePanelSuccess
292+
}
293+
294+
// portsToString renders a port slice as a stable comma-separated
295+
// string for log output.
296+
func portsToString(ps []int) string {
297+
if len(ps) == 0 {
298+
return "[]"
299+
}
300+
parts := make([]string, len(ps))
301+
for i, p := range ps {
302+
parts[i] = fmt.Sprintf("%d", p)
303+
}
304+
return "[" + strings.Join(parts, ",") + "]"
305+
}

0 commit comments

Comments
 (0)