Skip to content

Commit 867f047

Browse files
itcmsgrclaude
andauthored
PR26.7: Plesk adapter via conf.d/panel_loader (panelfw) (#534)
* PR26.7: Plesk adapter via conf.d/panel_loader (panelfw) Second adapter under the PR26.2 panelfw contract, mirroring the PR26.3+PR26.4 DirectAdmin shape. Plesk-first sequence (cPanel deferred to PR26.8 because the cPanel host is currently down / incident-tied; clean Plesk path is available). ADAPTER internal/installer/panelfw/adapters/plesk/plesk.go Detection (4 signals → strong/weak/absent): E1 /usr/local/psa (canonical install dir) E2 /usr/local/psa/admin/bin/httpdmng (panel binary marker — from conf.d MARKER_BIN) E3 plesk.service active (systemd-managed run) E4 TCP 8443 in LISTEN state (control plane serving) Required ports: RequiredPorts() loads the canonical conf.d-declared TCP_IN / UDP_IN surface via internal/ports/panel_loader.LoadPanelConfig("plesk"). Conf.d is the single source of truth; the adapter does NOT invent port lists. Fail-closed on missing/empty/nil. Validate reachability: ValidateReachability() probes TCP 8443 ONLY (the Plesk HTTPS control plane). 8447 (Plesk Updater) is in the conf.d full surface but is NOT control-plane and may be closed on a healthy host. Differences from DirectAdmin: - No per-host control-port override. Plesk has no canonical config file analogous to /usr/local/directadmin/conf/directadmin.conf `port=N`. The adapter returns the constant default 8443. If a future Plesk version exposes an override, the seam to add it is here (mirror DA's readConfiguredPort). - 8447 is intentionally part of the conf.d surface but not the control plane. A dedicated test guards against the adapter "passing" a Plesk host where only 8447 listens. TESTS internal/installer/panelfw/adapters/plesk/plesk_test.go - 5 Detect tests (incl. negative-coupling guard: a DirectAdmin-shape mock must NOT trigger Plesk detection) - 11 RequiredPorts tests: stub-loader pass-through, fail-closed branches (missing/empty/nil), defensive copy, real-loader against shipped conf.d (no hardcoded port lists; structural assertions only — control-port presence, SSH absence, surface size floor). FUTURE-AUDITOR DIRECTIVE block included. - 6 ValidateReachability tests including the 8447-not-8443 guard. - 5 framework-integration tests (detected+reachable, detected+blocked, error-message scope discipline, absent host, --no-panel override). - init() registration verified. - Read-only discipline verified (no WrittenFiles, only ss + systemctl is-active commands recorded). REGISTRATION cmd/nftban-installer/main.go Blank-import alongside DirectAdmin so init() registers the adapter before phaseValidate runs. LAB PROOF (lab2, Ubuntu 24.04, go1.22.2): - go vet ./internal/installer/panelfw/... ./cmd/nftban-installer/...: clean - go test -count=1 -v ./internal/installer/panelfw/adapters/plesk/...: 25 / 25 PASS - go test ./internal/installer/... ./cmd/nftban-installer/...: 18 / 18 packages PASS (no regression in DirectAdmin or framework) - go build ./cmd/nftban-installer: builds clean with both adapters OUT OF SCOPE - cPanel adapter (PR26.8 — gated on Plesk evidence + clean cPanel host) - DirectAdmin changes - Shell decommission of nftban_panel_plesk.sh - Plesk Firewall extension conflict detection (informational warn — future PR like PANEL-ENABLE-LEGACY-WARNING-001) - Plesk takeover lifecycle (no equivalent of `da build set csf no` yet wired here; Plesk uses systemd mask + Plesk-internal config) - Restore redesign - Real-host Plesk install evidence — gated on PR26.7 merge + green CI + Tier 1 rebuild + clean non-build Plesk host (lab2 is the build host; using it destructively would weaken isolation) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PR26.7: rephrase 4 error strings to clear staticcheck ST1005 Auditor CONDITIONAL-GO blocker: error strings should not be capitalized (staticcheck ST1005). Four error strings in plesk.go led with the proper noun "Plesk", which staticcheck flags despite the capitalization being correct English. Behavior unchanged. Strings rephrased to keep "Plesk" capitalized inside the message, just not at the leading position: - "Plesk conf.d load failed: ..." → "conf.d load failed for Plesk panel: ..." - "Plesk conf.d load returned nil PanelConfig" → "conf.d load returned nil PanelConfig for Plesk panel" - "Plesk conf.d declares no TCP_IN ports ..." → "conf.d for Plesk panel declares no TCP_IN ports ..." - "Plesk control-plane port %d not in LISTEN state ..." → "control-plane port %d (Plesk) not in LISTEN state ..." One test (TestRequiredPorts_MissingConfD_FailsClosed) asserted strings.Contains(err, "Plesk conf.d") — that exact substring no longer appears. Adjusted to assert the error references both "Plesk" and "conf.d" independently, preserving the test's intent without weakening it. Lab proof on lab2 (Ubuntu 24.04, go1.22.2): - go vet ./internal/installer/panelfw/adapters/plesk/... ./cmd/nftban-installer/...: clean - go test -v ./internal/installer/panelfw/adapters/plesk/...: 25/25 PASS - go test ./internal/installer/... ./cmd/nftban-installer/...: 18/18 packages PASS - staticcheck ./internal/installer/panelfw/adapters/plesk/...: exit 0 (ST1005 cleared) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e48b79 commit 867f047

3 files changed

Lines changed: 1107 additions & 0 deletions

File tree

cmd/nftban-installer/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import (
3737
// before phaseValidate runs. Future panel adapters land alongside
3838
// this import.
3939
_ "github.com/itcmsgr/nftban/internal/installer/panelfw/adapters/directadmin"
40+
// PR26.7: Plesk adapter — same registration shape as DirectAdmin.
41+
_ "github.com/itcmsgr/nftban/internal/installer/panelfw/adapters/plesk"
4042
)
4143

4244
// globalTimeout is the maximum wall-clock time for the entire installer run.
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// =============================================================================
2+
// NFTBan v1.100.x PR26.7 - Plesk Panel Adapter
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="installer-panelfw-plesk"
6+
// meta:type="lib"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-30"
9+
// meta:description="Plesk adapter for the panelfw framework — control-plane reachability + canonical conf.d port surface"
10+
// meta:inventory.files="internal/installer/panelfw/adapters/plesk/plesk.go"
11+
// meta:inventory.binaries=""
12+
// meta:inventory.env_vars=""
13+
// meta:inventory.config_files="/etc/nftban/conf.d/panels/plesk/main.conf"
14+
// meta:inventory.systemd_units="plesk.service"
15+
// meta:inventory.network="tcp/8443 (Plesk control plane HTTPS, fixed)"
16+
// meta:inventory.privileges="root"
17+
// =============================================================================
18+
//
19+
// PR26.7 — Plesk adapter under the PR26.2 panelfw contract. Second
20+
// adapter shipped (after PR26.3+PR26.4 DirectAdmin). cPanel is PR26.8;
21+
// CyberPanel/CWP/InterWorx/Vesta land later under the same framework.
22+
//
23+
// SCOPE
24+
// -----
25+
// CONTROL PLANE — ValidateReachability tests the Plesk control port
26+
// only (TCP 8443, HTTPS). Failure ⇒ PANEL-SURVIVAL-001 fires unless
27+
// --no-panel.
28+
//
29+
// FULL PORT SURFACE — RequiredPorts consults the canonical
30+
// /etc/nftban/conf.d/panels/plesk/main.conf via
31+
// internal/ports/panel_loader.LoadPanelConfig("plesk") and returns the
32+
// conf.d-declared TCP_IN / UDP_IN port set. The adapter does NOT
33+
// invent a port list. Conf.d is the single source of truth.
34+
//
35+
// Differences from DirectAdmin (PR26.3+26.4):
36+
//
37+
// 1. No per-host control-port override. DirectAdmin reads `port=N`
38+
// from /usr/local/directadmin/conf/directadmin.conf to support
39+
// non-default panel ports. Plesk ships TCP 8443 fixed at install
40+
// time and does not expose a comparable per-host override path.
41+
// The adapter therefore returns the constant default 8443; if a
42+
// future Plesk version exposes an override, this is the place to
43+
// add it (mirroring DA's readConfiguredPort path).
44+
//
45+
// 2. TCP 8447 (Plesk Updater) is intentionally NOT control-plane.
46+
// 8443 is the panel surface; 8447 is the auto-installer/repository
47+
// channel and may be absent on a panel that has been updated and
48+
// since stopped serving 8447. Conf.d declares 8447 as part of the
49+
// full TCP_IN surface; the adapter probes 8443 only.
50+
//
51+
// Fail-closed contract (matches DA):
52+
// - Missing conf.d main.conf → RequiredPorts returns error →
53+
// PanelResult.Fatal=true via panelfw.finalizeDetected.
54+
// - Loaded conf.d with empty TCP_IN → returns error (a Plesk panel
55+
// host with zero declared inbound ports is malformed).
56+
//
57+
// Read-only by interface contract:
58+
// - Detect: filesystem stat + service-active query + ss listener parse
59+
// - RequiredPorts: canonical conf.d load via panel_loader (read-only)
60+
// - ValidateReachability: ss -lnt output parse for the control port
61+
//
62+
// =============================================================================
63+
64+
package plesk
65+
66+
import (
67+
"context"
68+
"fmt"
69+
"strconv"
70+
"strings"
71+
72+
"github.com/itcmsgr/nftban/internal/installer/executor"
73+
"github.com/itcmsgr/nftban/internal/installer/fhs"
74+
"github.com/itcmsgr/nftban/internal/installer/panelfw"
75+
"github.com/itcmsgr/nftban/internal/ports"
76+
)
77+
78+
// adapterID is the canonical PanelID for this adapter. Matches the
79+
// detect.PanelPlesk / nftban panel-name convention and the conf.d
80+
// directory name (etc/nftban/conf.d/panels/plesk/).
81+
const adapterID panelfw.PanelID = "plesk"
82+
83+
// defaultPort is Plesk's control-panel HTTPS port. Plesk ships 8443
84+
// fixed at install time. Unlike DirectAdmin (which exposes a per-host
85+
// override at /usr/local/directadmin/conf/directadmin.conf `port=N`),
86+
// Plesk has no canonical per-host override path. Operators who retune
87+
// Plesk's panel port do so via Plesk-internal admin actions that this
88+
// adapter does not parse; the adapter probes 8443 only.
89+
const defaultPort = 8443
90+
91+
// Filesystem evidence paths. Constants kept here so test fixtures
92+
// reference the same canonical names. Marker bin path comes from
93+
// etc/nftban/conf.d/panels/plesk/main.conf NFTBAN_PLESK_MARKER_BIN.
94+
const (
95+
installDir = "/usr/local/psa"
96+
binaryPath = "/usr/local/psa/admin/bin/httpdmng"
97+
systemdUnit = "plesk.service"
98+
)
99+
100+
// panelConfDLoader is the function the adapter calls to load the
101+
// canonical conf.d panel config. Defaults to the package-public
102+
// internal/ports/panel_loader.LoadPanelConfig at process startup.
103+
//
104+
// Tests inject a fixture loader by writing to this var; production
105+
// code never reassigns it. The seam is here (rather than passing the
106+
// loader down through every call site) because PanelAdapter's
107+
// interface is fixed by panelfw.
108+
var panelConfDLoader func(configDir, panelName string) (*ports.PanelConfig, error) = ports.LoadPanelConfig
109+
110+
// panelConfDDir is the configDir argument supplied to the loader.
111+
// Default: fhs.EtcDir ("/etc/nftban"). Tests override to a tempdir
112+
// containing fixture conf.d/panels/plesk/main.conf.
113+
var panelConfDDir = fhs.EtcDir
114+
115+
// adapter is the package-private Plesk adapter type. The framework
116+
// receives it via Register(); callers should not construct or reach
117+
// into it directly.
118+
type adapter struct{}
119+
120+
// New returns a fresh adapter instance.
121+
func New() panelfw.PanelAdapter { return &adapter{} }
122+
123+
func init() {
124+
panelfw.Register(New())
125+
}
126+
127+
// ID implements panelfw.PanelAdapter.
128+
func (a *adapter) ID() panelfw.PanelID { return adapterID }
129+
130+
// Detect implements panelfw.PanelAdapter. Read-only.
131+
//
132+
// Evidence collected (in order):
133+
//
134+
// E1 — /usr/local/psa/ (canonical install dir)
135+
// E2 — /usr/local/psa/admin/bin/httpdmng (panel binary marker)
136+
// E3 — plesk.service active (systemd-managed run)
137+
// E4 — TCP 8443 in LISTEN state (panel actually serving)
138+
//
139+
// Confidence rule: 3+ indicators ⇒ "strong"; 1–2 ⇒ "weak"; 0 ⇒
140+
// Detected=false. The required port is always declared (8443) so the
141+
// framework's downstream consumers have a port to reason about even
142+
// when the panel is not yet running.
143+
func (a *adapter) Detect(ctx context.Context, exec executor.Executor) panelfw.PanelDetection {
144+
det := panelfw.PanelDetection{ID: adapterID}
145+
146+
port := defaultPort
147+
det.RequiredTCP = []int{port}
148+
149+
signals := 0
150+
151+
if exec.FileExists(installDir) {
152+
det.Evidence = append(det.Evidence, "install-dir-present:"+installDir)
153+
signals++
154+
}
155+
if exec.FileExists(binaryPath) {
156+
det.Evidence = append(det.Evidence, "binary-present:"+binaryPath)
157+
signals++
158+
}
159+
if exec.ServiceActive(systemdUnit) {
160+
det.Evidence = append(det.Evidence, "service-active:"+systemdUnit)
161+
signals++
162+
}
163+
if portInListenState(exec, port) {
164+
det.Evidence = append(det.Evidence, fmt.Sprintf("listener-tcp:%d", port))
165+
signals++
166+
}
167+
168+
if signals == 0 {
169+
det.Detected = false
170+
return det
171+
}
172+
det.Detected = true
173+
if signals >= 3 {
174+
det.Confidence = "strong"
175+
} else {
176+
det.Confidence = "weak"
177+
det.Warnings = append(det.Warnings,
178+
fmt.Sprintf("only %d of 4 indicators present; partial install or stopped panel", signals))
179+
}
180+
return det
181+
}
182+
183+
// RequiredPorts implements panelfw.PanelAdapter. Read-only.
184+
//
185+
// Returns the canonical conf.d-declared Plesk TCP_IN / UDP_IN port
186+
// surface, loaded via internal/ports/panel_loader.LoadPanelConfig.
187+
//
188+
// The adapter does NOT invent a port list — it reports exactly what
189+
// /etc/nftban/conf.d/panels/plesk/main.conf declares. Conf.d wins
190+
// over the legacy shell library; SSH port 22 is intentionally absent
191+
// because /etc/nftban/ports.d/00-ssh.conf manages it separately.
192+
//
193+
// Fail-closed contract:
194+
// - Missing main.conf → returns error.
195+
// - Loaded with empty TCP_IN → returns error (malformed for a real
196+
// Plesk host; an empty inbound list cannot serve the panel).
197+
//
198+
// On error, panelfw.finalizeDetected sets PanelResult.Fatal=true per
199+
// PANEL-SURVIVAL-001 unless --no-panel.
200+
func (a *adapter) RequiredPorts(ctx context.Context, exec executor.Executor) ([]int, []int, error) {
201+
cfg, err := panelConfDLoader(panelConfDDir, string(adapterID))
202+
if err != nil {
203+
return nil, nil, fmt.Errorf("conf.d load failed for Plesk panel: %w (path: %s/conf.d/panels/%s/main.conf)",
204+
err, panelConfDDir, string(adapterID))
205+
}
206+
if cfg == nil {
207+
return nil, nil, fmt.Errorf("conf.d load returned nil PanelConfig for Plesk panel")
208+
}
209+
if len(cfg.TCPIn) == 0 {
210+
return nil, nil, fmt.Errorf(
211+
"conf.d for Plesk panel declares no TCP_IN ports (malformed: %s) — "+
212+
"a real Plesk host must declare its inbound port surface",
213+
cfg.ConfigFile)
214+
}
215+
// Conf.d is the authoritative source; copy slices defensively so
216+
// the caller cannot mutate the loader's cached values.
217+
tcp := append([]int(nil), cfg.TCPIn...)
218+
udp := append([]int(nil), cfg.UDPIn...)
219+
return tcp, udp, nil
220+
}
221+
222+
// ValidateReachability implements panelfw.PanelAdapter. Read-only.
223+
//
224+
// Confirms the Plesk **control-plane** TCP port (8443 HTTPS) is in
225+
// LISTEN state. Returns nil on success; a structured error otherwise.
226+
// Does NOT mutate ports, services, or rules.
227+
//
228+
// Scope is the control plane only. RequiredPorts loads the full Plesk
229+
// service-port surface from the canonical conf.d via panel_loader, but
230+
// this method deliberately does NOT probe each conf.d-declared port —
231+
// full-surface reachability probing is intentionally out of scope here
232+
// and remains the broader rebuild/validate path's responsibility.
233+
//
234+
// Note: TCP 8447 (Plesk Updater / auto-installer) is part of the
235+
// conf.d-declared full surface but is NOT the control plane; a
236+
// well-functioning Plesk host may have 8447 closed without affecting
237+
// panel reachability. Probing 8443 only is the correct scope.
238+
func (a *adapter) ValidateReachability(ctx context.Context, exec executor.Executor) error {
239+
port := defaultPort
240+
if portInListenState(exec, port) {
241+
return nil
242+
}
243+
return fmt.Errorf(
244+
"control-plane port %d (Plesk) not in LISTEN state — control-plane unreachable "+
245+
"(this assertion probes the control plane only; the full Plesk port surface is loaded "+
246+
"from conf.d via RequiredPorts but not probed here)",
247+
port)
248+
}
249+
250+
// portInListenState reports whether tcpPort is in LISTEN state on the
251+
// host. Uses `ss -lnt` (no name resolution, no remote info, TCP
252+
// listeners only). Read-only.
253+
//
254+
// Identical to the DA adapter's helper — kept package-local rather
255+
// than refactored into a shared util because the panelfw contract is
256+
// per-adapter and a future Plesk-specific tweak (e.g., probe the
257+
// HTTPS handshake instead of just LISTEN state) belongs here, not in
258+
// a shared file edited from multiple adapters.
259+
func portInListenState(exec executor.Executor, tcpPort int) bool {
260+
res := exec.Run("ss", "-lnt")
261+
if res.ExitCode != 0 {
262+
return false
263+
}
264+
want := ":" + strconv.Itoa(tcpPort)
265+
for _, line := range strings.Split(res.Stdout, "\n") {
266+
fields := strings.Fields(line)
267+
if len(fields) < 4 {
268+
continue
269+
}
270+
if !strings.EqualFold(fields[0], "LISTEN") {
271+
continue
272+
}
273+
local := fields[3]
274+
if !strings.HasSuffix(local, want) {
275+
continue
276+
}
277+
idx := strings.LastIndexByte(local, ':')
278+
if idx < 0 {
279+
continue
280+
}
281+
if local[idx:] == want {
282+
return true
283+
}
284+
}
285+
return false
286+
}

0 commit comments

Comments
 (0)