diff --git a/install/systemd/nftban-api.service b/install/systemd/nftban-api.service deleted file mode 100644 index 6e91c1e9e..000000000 --- a/install/systemd/nftban-api.service +++ /dev/null @@ -1,65 +0,0 @@ -# ============================================================================= -# NFTBan v1.0.0 - Web API Service -# ============================================================================= -# SPDX-License-Identifier: MPL-2.0 -# meta:name="nftban-api.service" -# meta:type="systemd" -# meta:tier="PRO" -# meta:owner="Antonios Voulvoulis " -# meta:created_date="2025-10-26" -# meta:description="REST API server for NFTBan operations" -# meta:input="None" -# meta:output="Systemd unit" -# meta:depends="" -# meta:inventory.files="" -# meta:inventory.binaries="/usr/sbin/nftban-api" -# meta:inventory.env_vars="" -# meta:inventory.config_files="" -# meta:inventory.systemd_units="nftband.service" -# meta:inventory.network="inbound" -# meta:inventory.privileges="nftban-www" -# ============================================================================= - -[Unit] -Description=NFTBan Web API -After=network-online.target nftband.service -Wants=network-online.target -Documentation=man:nftban(8) - -[Service] -Type=simple -User=nftban-www -Group=nftban-web -ExecStart=/usr/sbin/nftban-api --listen=:3940 -ExecReload=/bin/kill -HUP $MAINPID -Restart=on-failure -RestartSec=5s - -# Security -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -ProtectKernelLogs=true -PrivateDevices=true -RestrictNamespaces=true -LockPersonality=true -RestrictSUIDSGID=true -RemoveIPC=true -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -ReadWritePaths=/run/nftban - -# Limits -LimitNOFILE=65536 -MemoryMax=512M - -# Logging -StandardOutput=journal -StandardError=journal -SyslogIdentifier=nftban-api - -[Install] -WantedBy=multi-user.target diff --git a/install/systemd/nftban-ui-auth.service b/install/systemd/nftban-ui-auth.service deleted file mode 100644 index cb02a563d..000000000 --- a/install/systemd/nftban-ui-auth.service +++ /dev/null @@ -1,109 +0,0 @@ -# ============================================================================= -# NFTBan v1.0.0 - UI Authentication Daemon Service -# ============================================================================= -# SPDX-License-Identifier: MPL-2.0 -# meta:name="nftban-ui-auth.service" -# meta:type="systemd" -# meta:owner="Antonios Voulvoulis " -# meta:created_date="2025-10-26" -# meta:description="PAM authentication daemon for web UI" -# meta:input="None" -# meta:output="Systemd unit" -# meta:depends="" -# meta:inventory.files="" -# meta:inventory.binaries="/usr/libexec/nftban-ui-auth" -# meta:inventory.env_vars="" -# meta:inventory.config_files="" -# meta:inventory.systemd_units="nftban-ui-auth.socket" -# meta:inventory.network="" -# meta:inventory.privileges="root" -# ============================================================================= - -[Unit] -Description=NFTBan UI Authentication Daemon -Documentation=man:nftban(8) -Requires=nftban-ui-auth.socket -After=nftban-ui-auth.socket - -[Service] -Type=simple -ExecStart=/usr/libexec/nftban-ui-auth -Restart=on-failure -RestartSec=5s - -# SECURITY: Run as root for PAM authentication -# The auth daemon MUST run as root to perform PAM authentication. -# Socket permissions (root:nftban 0770) restrict access to nftban group. -# See: SYSTEMD-SERVICE-CLASSIFICATION.md -User=root -Group=root -UMask=0077 - -# Runtime directory is created by nftban-ui-auth.socket with correct permissions -# (root:nftban 0750). ExecStartPre ensures ownership is correct as a safety net. -ExecStartPre=/bin/chown root:nftban /run/nftban-ui -ExecStartPre=/bin/chmod 0750 /run/nftban-ui - -# ============================================================================= -# SECURITY HARDENING -# ============================================================================= - -# Core hardening -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -RestrictSUIDSGID=true -LockPersonality=true - -# Minimal filesystem access -ReadWritePaths=/run/nftban-ui - -# Network for socket activation -RestrictAddressFamilies=AF_UNIX - -# Resource limits -MemoryMax=128M -TasksMax=10 - -# ============================================================================= -# ⚠️ SECURITY WARNING: MemoryDenyWriteExecute Trade-off -# ============================================================================= -# MemoryDenyWriteExecute=true is intentionally DISABLED for Go services. -# -# REASON: Breaks Go runtime on Ubuntu 24.04+ (AppArmor + Landlock interaction) -# and potentially other SELinux-enforcing distributions. -# -# RISK: Without this directive, memory pages can be both writable AND executable, -# increasing risk from memory corruption vulnerabilities (buffer overflows, -# ROP attacks, etc.). -# -# MITIGATION: -# - Other systemd hardening compensates: SystemCallFilter, ProtectSystem, -# RestrictAddressFamilies, NoNewPrivileges, ProtectKernelModules, etc. -# - Go's memory-safe runtime reduces exploitation risk -# - ASLR (Address Space Layout Randomization) enabled system-wide -# -# ALTERNATIVES INVESTIGATED: -# - Custom AppArmor profiles (complex, distribution-specific) -# - Seccomp-bpf filters (insufficient for this issue) -# - Landlock sandboxing (conflicts with Go runtime) -# -# TODO: Re-test on Go 1.23+ / Ubuntu 26.04 for potential enablement -# -# See: SECURITY.md § "Systemd Hardening Trade-offs" -# ============================================================================= -# MemoryDenyWriteExecute=true # DISABLED (see warning above) - -SystemCallFilter=@system-service - -# Logging -StandardOutput=journal -StandardError=journal -SyslogIdentifier=nftban-ui-auth - -[Install] -WantedBy=multi-user.target diff --git a/install/systemd/nftban-ui-auth.socket b/install/systemd/nftban-ui-auth.socket deleted file mode 100644 index 3bf1db6d3..000000000 --- a/install/systemd/nftban-ui-auth.socket +++ /dev/null @@ -1,44 +0,0 @@ -# ============================================================================= -# NFTBan v1.1.0 - UI Authentication Socket -# ============================================================================= -# SPDX-License-Identifier: MPL-2.0 -# meta:name="nftban-ui-auth.socket" -# meta:type="systemd" -# meta:tier="PRO" -# meta:owner="Antonios Voulvoulis " -# meta:created_date="2026-01-15" -# meta:description="Socket activation for PAM authentication daemon" -# meta:input="None" -# meta:output="Systemd socket unit" -# meta:depends="" -# meta:inventory.files="/run/nftban-ui/auth.sock" -# meta:inventory.binaries="" -# meta:inventory.env_vars="" -# meta:inventory.config_files="" -# meta:inventory.systemd_units="nftban-ui-auth.service" -# meta:inventory.network="unix:/run/nftban-ui/auth.sock" -# meta:inventory.privileges="root" -# -# Directory/Socket Permissions: -# /run/nftban-ui/ root:nftban 0750 (directory) -# /run/nftban-ui/auth.sock root:nftban 0770 (socket) -# -# The nftban-ui service runs as 'nftban' user and must connect to this socket. -# The 'nftban' group ownership allows the service to traverse the directory -# and connect to the socket. -# ============================================================================= - -[Unit] -Description=NFTBan UI Authentication Socket -Documentation=man:nftban-ui(8) -PartOf=nftban-ui.service - -[Socket] -ListenStream=/run/nftban-ui/auth.sock -SocketMode=0770 -SocketUser=root -SocketGroup=nftban -DirectoryMode=0750 - -[Install] -WantedBy=sockets.target diff --git a/install/systemd/nftban-ui.service b/install/systemd/nftban-ui.service deleted file mode 100644 index 8e745c209..000000000 --- a/install/systemd/nftban-ui.service +++ /dev/null @@ -1,106 +0,0 @@ -# ============================================================================= -# NFTBan v1.0.0 - Web GUI Service -# ============================================================================= -# SPDX-License-Identifier: MPL-2.0 -# meta:name="nftban-ui.service" -# meta:type="systemd" -# meta:owner="Antonios Voulvoulis " -# meta:created_date="2025-10-26" -# meta:description="Web-based graphical user interface server" -# meta:input="None" -# meta:output="Systemd unit" -# meta:depends="" -# meta:inventory.files="" -# meta:inventory.binaries="/usr/sbin/nftban-ui" -# meta:inventory.env_vars="" -# meta:inventory.config_files="/etc/nftban/ui.conf" -# meta:inventory.systemd_units="" -# meta:inventory.network="inbound" -# meta:inventory.privileges="nftban" -# ============================================================================= - -[Unit] -Description=NFTBan Web GUI Server -Documentation=https://github.com/itcmsgr/nftban -After=network-online.target nftables.service polkit.service -Wants=network-online.target - -[Service] -Type=simple -User=nftban -Group=nftban -WorkingDirectory=/var/lib/nftban -ExecStart=/usr/sbin/nftban-ui --config /etc/nftban/ui.conf -ExecReload=/bin/kill -HUP $MAINPID -Restart=on-failure -RestartSec=10 - -# Security hardening -# NoNewPrivileges=yes prevents gaining additional privileges at runtime -# Capabilities granted by systemd (AmbientCapabilities) are not affected -NoNewPrivileges=yes - -# Grant CAP_NET_ADMIN for nftables operations -# Capability is granted by systemd at exec time, not gained dynamically -# Security: unprivileged nftban user + NoNewPrivileges + capability bounding -AmbientCapabilities=CAP_NET_ADMIN -CapabilityBoundingSet=CAP_NET_ADMIN -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/var/log/nftban /var/lib/nftban -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -RestrictRealtime=true -RestrictNamespaces=true -LockPersonality=true - -# ============================================================================= -# ⚠️ SECURITY WARNING: MemoryDenyWriteExecute Trade-off -# ============================================================================= -# MemoryDenyWriteExecute=true is intentionally DISABLED for Go services. -# -# REASON: Breaks Go runtime on Ubuntu 24.04+ (AppArmor + Landlock interaction) -# and potentially other SELinux-enforcing distributions. -# -# RISK: Without this directive, memory pages can be both writable AND executable, -# increasing risk from memory corruption vulnerabilities (buffer overflows, -# ROP attacks, etc.). -# -# MITIGATION: -# - Other systemd hardening compensates: SystemCallFilter, ProtectSystem, -# RestrictAddressFamilies, NoNewPrivileges, ProtectKernelModules, etc. -# - Go's memory-safe runtime reduces exploitation risk -# - ASLR (Address Space Layout Randomization) enabled system-wide -# -# ALTERNATIVES INVESTIGATED: -# - Custom AppArmor profiles (complex, distribution-specific) -# - Seccomp-bpf filters (insufficient for this issue) -# - Landlock sandboxing (conflicts with Go runtime) -# -# TODO: Re-test on Go 1.23+ / Ubuntu 26.04 for potential enablement -# -# See: SECURITY.md § "Systemd Hardening Trade-offs" -# ============================================================================= -# MemoryDenyWriteExecute=true # DISABLED (see warning above) - -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK -SystemCallFilter=@system-service -SystemCallArchitectures=native - -# Resource limits (matching safety package defaults) -CPUQuota=200% -MemoryMax=512M -TasksMax=100 - -# Environment -Environment="NFTBAN_GOMAXPROCS=2" -Environment="NFTBAN_MAX_CONCURRENT_CONNS=100" -Environment="NFTBAN_REQUEST_TIMEOUT_SEC=30" -Environment="NFTBAN_MAX_REQUEST_BODY_MB=10" -Environment="NFTBAN_MAX_MEMORY_BYTES=536870912" -Environment="NFTBAN_MAX_MEMORY_PERCENT=20" - -[Install] -WantedBy=multi-user.target diff --git a/internal/installer/payload/payload.go b/internal/installer/payload/payload.go index 04e2276bc..91d9ced8c 100644 --- a/internal/installer/payload/payload.go +++ b/internal/installer/payload/payload.go @@ -396,6 +396,20 @@ func buildEntries(distro *detect.DistroInfo) []entry { {category: "data", srcRel: "cli/lib/nftban/data", srcGlob: "*", dstGlob: "/usr/lib/nftban/data", mode: 0644, policy: policyAlways, isDir: true}, {category: "shell", srcRel: "cli/lib/nftban/health", srcGlob: "*.sh", dstGlob: "/usr/lib/nftban/health", mode: 0755, policy: policyAlways, isDir: true}, + // PR26.5: source-install payload completeness — close the gaps surfaced + // by the dns2 evidence run (2026-04-30). systemd unit ExecStart paths + // referenced these destinations; pre-PR26.5 source install did not stage + // them, causing PR26.1 systemd_execstart_paths_ok to fail. + // G-14-C continued: shell payload destinations referenced by units. + {category: "shell", srcRel: "cli/lib/nftban/exporters", srcGlob: "*.sh", dstGlob: "/usr/lib/nftban/exporters", mode: 0755, policy: policyAlways, isDir: true}, + {category: "shell", srcRel: "cli/lib/nftban/cron", srcGlob: "*.sh", dstGlob: "/usr/lib/nftban/cron", mode: 0755, policy: policyAlways, isDir: true}, + // Top-level scripts/ — referenced by nftban-soak.service. + {category: "shell", srcRel: "scripts", srcGlob: "*.sh", dstGlob: "/usr/lib/nftban/scripts", mode: 0755, policy: policyAlways, isDir: true}, + // install/helpers/ ships the firewall-init-with-delay.sh helper which is + // distinct from the cli/lib/nftban/helpers/ tree above. Both flatten into + // /usr/lib/nftban/helpers/. + {category: "shell", srcRel: "install/helpers", srcGlob: "*.sh", dstGlob: "/usr/lib/nftban/helpers", mode: 0755, policy: policyAlways, isDir: true, optional: true}, + // Shipped nftables template (always overwrite — installer-managed, // never operator-edited here). {category: "templates", srcRel: "cli/lib/nftban/templates/nftables.conf.tpl", dstGlob: "/usr/lib/nftban/templates/nftables.conf.tpl", mode: 0644, policy: policyAlways, optional: true}, @@ -424,6 +438,21 @@ func buildEntries(distro *detect.DistroInfo) []entry { // Distro-aware path registry (always overwrite — installer-owned). {category: "configs", srcRel: "etc/nftban/distros", srcGlob: "*.conf", dstGlob: "/etc/nftban/distros", mode: 0640, policy: policyAlways, isDir: true}, + // PR26.5: panel canonical port-declaration configs. Source-of-truth for + // PR26.4's DirectAdmin adapter (and future PR26.7 cPanel / PR26.8 Plesk + // adapters) via internal/ports/panel_loader.LoadPanelConfig. Per the + // V190_PANELS audit there are 8 first-class panels; staging is a static + // set of 8 single-file entries (one per panel) so future panel removals + // require an explicit edit to this list. + {category: "panels", srcRel: "etc/nftban/conf.d/panels/directadmin/main.conf", dstGlob: "/etc/nftban/conf.d/panels/directadmin/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/cpanel/main.conf", dstGlob: "/etc/nftban/conf.d/panels/cpanel/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/plesk/main.conf", dstGlob: "/etc/nftban/conf.d/panels/plesk/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/cyberpanel/main.conf", dstGlob: "/etc/nftban/conf.d/panels/cyberpanel/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/cwp/main.conf", dstGlob: "/etc/nftban/conf.d/panels/cwp/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/interworx/main.conf", dstGlob: "/etc/nftban/conf.d/panels/interworx/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/vesta/main.conf", dstGlob: "/etc/nftban/conf.d/panels/vesta/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + {category: "panels", srcRel: "etc/nftban/conf.d/panels/generic/main.conf", dstGlob: "/etc/nftban/conf.d/panels/generic/main.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, + // Manual whitelist/blacklist templates (%config(noreplace)). // safety.SeedManualWhitelist runs in phaseConfigure after these land. {category: "configs", srcRel: "etc/nftban/whitelist.d/99-manual.conf", dstGlob: "/etc/nftban/whitelist.d/99-manual.conf", mode: 0640, policy: policyConfigNoReplace, optional: true}, diff --git a/internal/installer/payload/payload_test.go b/internal/installer/payload/payload_test.go index d7f55aa6f..a86589c98 100644 --- a/internal/installer/payload/payload_test.go +++ b/internal/installer/payload/payload_test.go @@ -18,8 +18,10 @@ package payload import ( + "bufio" "os" "path/filepath" + "runtime" "strings" "testing" @@ -546,3 +548,291 @@ func TestCriticalConfigs_FrozenTwoFileSet(t *testing.T) { } } } + +// ============================================================================= +// PR26.5: source-install payload completeness — integration test +// ============================================================================= +// Walks the real repo source tree on disk, runs payload.StageAll into a mock, +// and asserts that: +// 1. Every nftban-owned ExecStart path declared by units in install/systemd/ +// (after the v1.100.1b.A GOTH retirements) is present in mock.WrittenFiles. +// 2. Every panel conf.d main.conf (8 panels) lands at its canonical +// /etc/nftban/conf.d/panels//main.conf destination. +// These two checks are the dns2 evidence reproducer — they would have failed +// pre-PR26.5 (missing exporters/cron/scripts/helpers categories + missing +// panels category). Together they pin the staging-table contract. + +// locatePayloadRepoRoot climbs from this test file's location until it finds +// the repo's go.mod, and returns that absolute path. Used by the source-tree +// integration tests below so they read the real shipped source files instead +// of a synthetic minimal fixture. +func locatePayloadRepoRoot(t *testing.T) string { + t.Helper() + _, this, _, ok := runtime.Caller(0) + if !ok { + t.Fatalf("runtime.Caller failed") + } + dir := filepath.Dir(this) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not locate go.mod above %s", filepath.Dir(this)) + } + dir = parent + } +} + +// preloadAllRepoFilesIntoMock walks the repo and pre-populates mock.Files so +// that exec.FileExists / exec.ReadFile succeed for every source path StageAll +// might consult. Skips the test cache + .git + obvious build artifacts. +func preloadAllRepoFilesIntoMock(t *testing.T, mock *executor.MockExecutor, repoRoot string) { + t.Helper() + mock.Dirs[repoRoot] = true + skip := map[string]bool{ + ".git": true, + "node_modules": true, + "build-tmp": true, + "gocache": true, + "build": true, + } + err := filepath.Walk(repoRoot, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil // tolerate transient access errors during walk + } + base := filepath.Base(p) + if info.IsDir() { + if skip[base] { + return filepath.SkipDir + } + mock.Dirs[p] = true + return nil + } + // Cap file size to avoid loading binaries unnecessarily; staging + // only needs content for files actually copied. + if info.Size() > 50*1024*1024 { + return nil + } + data, rerr := os.ReadFile(p) + if rerr != nil { + return nil + } + mock.Files[p] = data + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", repoRoot, err) + } +} + +// extractNftbanOwnedExecStartPaths parses one .service unit file and returns +// every ExecStart/Pre/Post path that lives under an nftban-owned prefix. Used +// by the integration test to derive expectations directly from shipped units. +func extractNftbanOwnedExecStartPaths(t *testing.T, unitFile string) []string { + t.Helper() + f, err := os.Open(unitFile) + if err != nil { + t.Fatalf("open %s: %v", unitFile, err) + } + defer f.Close() + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + // Match ExecStart / ExecStartPre / ExecStartPost + var rhs string + switch { + case strings.HasPrefix(line, "ExecStart="): + rhs = strings.TrimPrefix(line, "ExecStart=") + case strings.HasPrefix(line, "ExecStartPre="): + rhs = strings.TrimPrefix(line, "ExecStartPre=") + case strings.HasPrefix(line, "ExecStartPost="): + rhs = strings.TrimPrefix(line, "ExecStartPost=") + default: + continue + } + // Strip systemd Exec prefixes + rhs = strings.TrimLeft(rhs, "-+!@ \t") + fields := strings.Fields(rhs) + if len(fields) == 0 { + continue + } + bin := fields[0] + if !strings.HasPrefix(bin, "/") { + continue + } + // Filter to nftban-owned only + if isNftbanOwnedPath(bin) { + out = append(out, bin) + } + // Also scan remaining tokens for embedded nftban paths inside + // shell wrappers (matches systemd_payload's parser policy). + for _, tok := range fields[1:] { + tok = strings.Trim(tok, "'\"") + if strings.HasPrefix(tok, "/") && isNftbanOwnedPath(tok) { + out = append(out, tok) + } + } + } + return out +} + +// isNftbanOwnedPath mirrors the canonical nftban path-ownership prefixes used +// by the systemd_payload validator. Kept private to this test file to avoid +// pulling the validate package (which imports payload — would create a cycle +// if reversed). +func isNftbanOwnedPath(p string) bool { + return strings.HasPrefix(p, "/usr/lib/nftban/") || + strings.HasPrefix(p, "/etc/nftban/") || + p == "/usr/sbin/nftban" +} + +// seedStubBuiltBinaries seeds stub source files for the four Go binaries +// that StageAll expects under bin/. CI runners produce a clean source +// checkout without prebuilt binaries — preloadAllRepoFilesIntoMock therefore +// cannot pre-load them. This helper closes that test-environment gap so the +// integration tests don't depend on a prior `./build.sh` invocation. +// +// Production correctness is unaffected: payload.StageAll's binary entries +// remain category=binaries, srcRel=bin/. On real installs the build +// step produces these files; in tests we pretend they exist (with arbitrary +// content) so the subsequent staging copy-or-skip succeeds. +func seedStubBuiltBinaries(t *testing.T, mock *executor.MockExecutor, repoRoot string) { + t.Helper() + for _, name := range []string{"nftban-core", "nftband", "nftban-validate", "nftban-installer"} { + p := filepath.Join(repoRoot, "bin", name) + mock.Files[p] = []byte("stub-binary") + mock.Dirs[filepath.Dir(p)] = true + } +} + +// PR26.5 R1: every nftban-owned ExecStart path declared by the shipped +// install/systemd/*.service unit files MUST be staged by payload.StageAll. +// dns2 evidence (2026-04-30) failed exactly this — exporter/cron/scripts/ +// helper destinations referenced by units were not in the staging table. +func TestStageAll_AllUnitNftbanOwnedExecStartPathsStaged_PR26_5(t *testing.T) { + repoRoot := locatePayloadRepoRoot(t) + + mock := executor.NewMockExecutor() + preloadAllRepoFilesIntoMock(t, mock, repoRoot) + // CI runners ship a clean checkout without prebuilt binaries; seed + // stubs so the binary staging entries succeed and we can verify the + // end-state-on-mock invariant the test was written to enforce. + seedStubBuiltBinaries(t, mock, repoRoot) + + if err := StageAll(mock, repoRoot, &detect.DistroInfo{ID: "rocky"}, newTestLogger()); err != nil { + t.Fatalf("StageAll: %v", err) + } + + // Build the set of expected destination paths from every shipped unit. + unitFiles, err := filepath.Glob(filepath.Join(repoRoot, "install/systemd/*.service")) + if err != nil { + t.Fatalf("glob units: %v", err) + } + if len(unitFiles) == 0 { + t.Fatalf("no unit files found under install/systemd/") + } + + missing := map[string][]string{} // path -> []unit + for _, unit := range unitFiles { + paths := extractNftbanOwnedExecStartPaths(t, unit) + for _, p := range paths { + // Skip /usr/sbin/nftban — staged via cli-bin, not under + // /usr/lib/nftban/ — covered by other tests. + if p == "/usr/sbin/nftban" { + if _, ok := mock.WrittenFiles[p]; ok { + continue + } + missing[p] = append(missing[p], filepath.Base(unit)) + continue + } + if _, ok := mock.WrittenFiles[p]; !ok { + missing[p] = append(missing[p], filepath.Base(unit)) + } + } + } + if len(missing) > 0 { + for path, units := range missing { + t.Errorf("ExecStart destination not staged: %s (referenced by: %s)", + path, strings.Join(units, ", ")) + } + } +} + +// PR26.5 R2: every panel's canonical conf.d main.conf must be staged. +// PR26.4's panelfw adapters (DirectAdmin currently, cPanel/Plesk/etc. in +// PR26.7+) consume these via internal/ports/panel_loader.LoadPanelConfig. +// dns2 evidence failed `panel_survival_ok` because the DirectAdmin entry +// was missing from the staging table. +func TestStageAll_AllPanelConfDStaged_PR26_5(t *testing.T) { + repoRoot := locatePayloadRepoRoot(t) + + mock := executor.NewMockExecutor() + preloadAllRepoFilesIntoMock(t, mock, repoRoot) + // Seed stub binaries so StageAll's binary entries succeed on CI runners + // that don't have prebuilt binaries in bin/. Doesn't affect this test's + // assertions (they check /etc/nftban/conf.d/panels/* destinations, not + // /usr/lib/nftban/bin/*) but keeps StageAll's overall completeness behavior + // consistent across tests. + seedStubBuiltBinaries(t, mock, repoRoot) + + if err := StageAll(mock, repoRoot, &detect.DistroInfo{ID: "rocky"}, newTestLogger()); err != nil { + t.Fatalf("StageAll: %v", err) + } + + // 8 first-class panels per V190_PANELS audit. + panels := []string{ + "directadmin", + "cpanel", + "plesk", + "cyberpanel", + "cwp", + "interworx", + "vesta", + "generic", + } + for _, p := range panels { + dst := "/etc/nftban/conf.d/panels/" + p + "/main.conf" + // Only assert if the source file exists in the repo (skip + // gracefully on test forks that prune some panels). + src := filepath.Join(repoRoot, "etc/nftban/conf.d/panels", p, "main.conf") + if _, err := os.Stat(src); err != nil { + t.Logf("source absent for panel %s — skipping (%v)", p, err) + continue + } + if _, ok := mock.WrittenFiles[dst]; !ok { + t.Errorf("panel conf.d not staged: %s (source: %s)", dst, src) + } + } +} + +// PR26.5 R3: regression guard for the original dns2 ExecStart-staging +// failures. After PR26.5, the four destinations below MUST be present in +// mock.WrittenFiles. They map to the four "shell payload" categories added +// to buildEntries: exporters, cron, scripts, install/helpers. +func TestStageAll_PR26_5_NewShellCategoriesStaged(t *testing.T) { + repoRoot := locatePayloadRepoRoot(t) + + mock := executor.NewMockExecutor() + preloadAllRepoFilesIntoMock(t, mock, repoRoot) + // Stub binaries (CI-runner consistency). See helper doc. + seedStubBuiltBinaries(t, mock, repoRoot) + + if err := StageAll(mock, repoRoot, &detect.DistroInfo{ID: "rocky"}, newTestLogger()); err != nil { + t.Fatalf("StageAll: %v", err) + } + + mustHave := []string{ + "/usr/lib/nftban/exporters/nftban_unified_exporter.sh", + "/usr/lib/nftban/cron/maintenance.sh", + "/usr/lib/nftban/scripts/nftban-soak-check.sh", + "/usr/lib/nftban/helpers/firewall-init-with-delay.sh", + } + for _, dst := range mustHave { + if _, ok := mock.WrittenFiles[dst]; !ok { + t.Errorf("PR26.5 staging gap not closed: %s missing from WrittenFiles", dst) + } + } +} diff --git a/internal/installer/validate/assertions.go b/internal/installer/validate/assertions.go index 58356b62f..2a5e11031 100644 --- a/internal/installer/validate/assertions.go +++ b/internal/installer/validate/assertions.go @@ -395,18 +395,31 @@ func assertFailedUnitsPostInstall(spr SystemdPayloadValidationResult, log *loggi // path. Either is a deliberate decision — not a silent pass. func defaultInventoryPaths() map[string]bool { return map[string]bool{ - "/usr/sbin/nftban": true, - "/usr/lib/nftban/bin/nftban-core": true, - "/usr/lib/nftban/bin/nftband": true, - "/usr/lib/nftban/bin/nftban-validate": true, - "/usr/lib/nftban/bin/nftban-installer": true, - "/usr/lib/nftban/sbin/nftban-apply": true, - "/usr/lib/nftban/sbin/nftban-confirm": true, - "/usr/lib/nftban/sbin/nftban-panelctl": true, + // CLI shim + Go binaries + "/usr/sbin/nftban": true, + "/usr/lib/nftban/bin/nftban-core": true, + "/usr/lib/nftban/bin/nftband": true, + "/usr/lib/nftban/bin/nftban-validate": true, + "/usr/lib/nftban/bin/nftban-installer": true, + // Shell-side privileged helpers (cli/sbin/* → /usr/lib/nftban/sbin/) + "/usr/lib/nftban/sbin/nftban-apply": true, + "/usr/lib/nftban/sbin/nftban-confirm": true, + "/usr/lib/nftban/sbin/nftban-panelctl": true, "/usr/lib/nftban/sbin/nftban-queue-processor": true, - "/usr/lib/nftban/sbin/nftban-rollback": true, + "/usr/lib/nftban/sbin/nftban-rollback": true, "/usr/lib/nftban/sbin/nftban-service-alert": true, "/usr/lib/nftban/sbin/nftban-botscan-processor": true, + // PR26.5: shell payload destinations referenced by installed + // systemd units. Each lives at the corresponding source-side + // path under cli/lib/nftban/{core,cron,exporters} or scripts/ + // or install/helpers/, and is staged by payload.StageAll. + // Without these entries `systemd_payload_inventory_ok` falsely + // flags legitimate staged shell payload as "unknown". + "/usr/lib/nftban/core/nftban_rebuild_recovery.sh": true, + "/usr/lib/nftban/cron/maintenance.sh": true, + "/usr/lib/nftban/exporters/nftban_unified_exporter.sh": true, + "/usr/lib/nftban/helpers/firewall-init-with-delay.sh": true, + "/usr/lib/nftban/scripts/nftban-soak-check.sh": true, } }