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