Skip to content

feat(v1.100 PR-P2-2): unify external-firewall detection (internal/installer/extfw) — Option A resolution#486

Merged
itcmsgr merged 2 commits intomainfrom
fix/v1.100-pr-p2-2-external-firewall-unification
Apr 20, 2026
Merged

feat(v1.100 PR-P2-2): unify external-firewall detection (internal/installer/extfw) — Option A resolution#486
itcmsgr merged 2 commits intomainfrom
fix/v1.100-pr-p2-2-external-firewall-unification

Conversation

@itcmsgr
Copy link
Copy Markdown
Owner

@itcmsgr itcmsgr commented Apr 20, 2026

Pre-PR-23 blocker #2 of 5 remaining. Replaces two divergent detection surfaces with one canonical `extfw.Detect(exec, log)` shared across install, update, and uninstall lifecycle paths.

Option A resolution (locked per authorization 2026-04-20)

This unification intentionally makes install-side detection stricter for hosts with CSF config remnants, so lifecycle surfaces no longer disagree on whether CSF is present.

Unified CSF signal (used by ALL callers): `csf.service` active OR `lfd.service` active OR `/etc/csf/csf.conf` present.

Before PR-P2-2: install side ignored `/etc/csf/csf.conf`; uninstall side used it. Two lifecycle modes saw different ground truth for the same host — the exact drift this PR exists to eliminate.

Locked precedence

ufw → firewalld → iptables → csf (frozen). Used ONLY when collapsing to a single Authoritative answer. Multiple active firewalls → Ambiguous, never silent collapse.

New package

`internal/installer/extfw/detect.go` — canonical detector with `Name` enum, `Precedence` constant, `Observation` / `DetectionResult` structs, `Detect(exec, log)` function.

Signal set (union of pre-unification signals — no new heuristics)

Firewall Signals
UFW `ufw.service` active
firewalld `firewalld.service` OR ghost nft table containing "firewalld"
iptables `iptables.service` OR iptables-save ≥3 non-comment rule lines OR ghost nft table named filter/nat/mangle
CSF `csf.service` OR `lfd.service` OR `/etc/csf/csf.conf`

Migration

  • `internal/installer/detect/conflicts.go` → thin adapter. `DetectConflicts` / `ConflictNames` / `Conflict` struct API preserved. CSF still emits two conflict entries (one per unit) for takeover-time stop+disable+mask.
  • `internal/installer/uninstall/authority.go` → removed local `detectExternalAuthority` + `hasActiveIptables` + `countNonCommentLines` helpers. `Classify` now routes ambiguous multi-external hosts through `AuthorityAmbiguous` rather than silently picking one via precedence. Test fixtures updated: bare `mock.Services["ufw"]` → `ufw.service` (canonical systemd unit name).
  • Existing consumers (`phases.go`, `switchop.DisableConflicts`, `lifecycle_bridge`) unchanged — same `[]detect.Conflict` shape.

Tests

`extfw/detect_test.go` — single-firewall cases (4), no-firewall, multi-firewall ambiguous with "no silent collapse" assertion, all-four-active, same-firewall-multi-signal-not-ambiguous, precedence-frozen regression guard, DisplayName legacy-shape.

`extfw/consistency_test.go` — PR-P2-2 cross-caller regression guard. For each fixture (clean / ufw / firewalld / iptables / csf-services / csf-config-only / ufw+csf-ambiguous), asserts `extfw.Detect`, `detect.DetectConflicts`, and `uninstall.Classify` all see the same external-firewall truth. One dedicated test (`TestConsistency_OptionA_CSFConfigFileOnly`) locks the Option A resolution: config-file-only CSF remnant must be seen by install side as well.

Reviewer trap checklist

Trap Status
Precedence changes unintentionally ✅ Frozen + tested (`TestDetect_PrecedenceOrder_Frozen`)
Detection becomes more permissive ✅ Stricter (Option A), not permissive — install side now detects leftover CSF config that it previously silently ignored
Ambiguous case disappears ✅ Preserved + strengthened (new case for multi-external on uninstall side + explicit `TestDetect_MultipleActive_IsAmbiguous_NoSilentCollapse`)

Explicit non-goals

  • NO new firewall types
  • NO heuristics beyond current signals
  • NO behavior expansion
  • NO silent conflict resolution

Test plan

  • `Build & Test` green — new extfw unit tests + consistency tests pass
  • `ci-install-canonization` + `ci-update-canonization` + `ci-uninstall-canonization` matrices green
  • `TestConsistency_InstallAndUninstallAgree` passes all 7 fixtures
  • `TestConsistency_OptionA_CSFConfigFileOnly` passes (locked regression guard)

🤖 Generated with Claude Code


DirectAdmin-specific commands — reviewed, intentionally out of scope

During PR-P2-2 design the following DA-specific CSF commands surfaced and were explicitly reviewed:

  • /usr/local/directadmin/custombuild/build set csf no
  • nftban_remove_csf (shell function in cli/lib/nftban/core/nftban_firewall_conflicts.sh)
  • DA custom-script CSF/iptables reference audit (takeover.go:156-167)
  • CustomBuild options.conf csf= post-takeover verification

All four are mutation / remediation surfaces that change system state. PR-P2-2 is detection-only — it unifies how install / update / uninstall lifecycle paths observe external-firewall presence. It does NOT perform CSF removal or any DirectAdmin panel action.

These DA-specific commands therefore stay in their existing location (internal/installer/switchop/takeover.go, gated on pd.panel == PanelDirectAdmin) and will be revisited by the later PR that owns takeover / removal semantics (PR-25 artifact-removal, or a future DA-panel-aware uninstall path if PR-24 restore logic needs it).

Separation rule going forward:

  • Detection unification (this PR): extfw.Detect answers "is CSF present?" with one canonical signal set, shared across all lifecycle callers.
  • Mutation / remediation (later PRs): "should we run DA-specific CSF commands on this host, and when?" is decided by the PR that owns the specific mutation path, not here.

…/installer/extfw

Pre-PR-23 blocker #2 of 5 remaining. Replaces two divergent detection
surfaces with one canonical `extfw.Detect(exec, log)` shared across
install, update, and uninstall lifecycle paths.

**Option A resolution locked** (per authorization 2026-04-20):
`/etc/csf/csf.conf` is a valid CSF signal for ALL callers — not just
uninstall. This intentionally makes install-side detection stricter
for hosts with CSF config remnants so install/update/uninstall share
one external-firewall truth surface.

**Locked precedence** (frozen): ufw → firewalld → iptables → csf. This
order is used ONLY when collapsing to a single Authoritative answer;
when multiple firewalls are observed active simultaneously, the result
is Ambiguous and the caller is responsible for handling that state
explicitly. No silent collapse.

## New package: internal/installer/extfw

- `Name` enum (ufw / firewalld / iptables / csf + none sentinel)
- `Precedence` frozen order constant
- `Observation` (Name / Source / Unit / Detail) — one signal per entry
- `DetectionResult` (Observations / Active / Authoritative / Ambiguous)
- `Detect(exec, log)` canonical shared detection
- `Observation.DisplayName()` preserves legacy "UFW"/"CSF"/"iptables-nft"
  display strings so existing consumers don't drift

## Signal set (union of pre-unification signals — no new heuristics)

| Firewall | Signals |
|---|---|
| UFW | `ufw.service` active |
| firewalld | `firewalld.service` OR ghost nft table containing "firewalld" |
| iptables | `iptables.service` OR iptables-save ≥3 non-comment rule lines OR ghost nft table named filter/nat/mangle |
| CSF | `csf.service` OR `lfd.service` OR `/etc/csf/csf.conf` present |

Nothing added. Nothing removed. Union.

## Migration

- `internal/installer/detect/conflicts.go` → thin adapter over
  `extfw.Detect`. `DetectConflicts()` / `ConflictNames()` /
  `Conflict` struct API preserved; CSF still emits two conflict
  entries (one per unit) for takeover-time stop+disable+mask.
- `internal/installer/uninstall/authority.go` → removed local
  `detectExternalAuthority` + `hasActiveIptables` +
  `countNonCommentLines` helpers. `Classify` now calls
  `extfw.Detect` and routes ambiguous multi-external hosts through
  the new `AuthorityAmbiguous` branch rather than silently picking
  one via precedence. Test fixtures updated: bare `mock.Services["ufw"]`
  → `ufw.service` (canonical systemd unit name).
- Existing consumers (`phases.go`, `switchop.DisableConflicts`,
  `lifecycle_bridge`) unchanged — they see the same `[]detect.Conflict`
  shape.

## Tests

- `extfw/detect_test.go` — single-firewall cases (4), no-firewall
  case, multi-firewall ambiguous case with "no silent collapse"
  assertion, all-four-active case, same-firewall-multi-signal-not-
  ambiguous case, precedence-order-frozen regression guard,
  DisplayName legacy-shape tests.
- `extfw/consistency_test.go` — PR-P2-2 cross-caller regression
  guard. For each fixture (clean / ufw / firewalld / iptables /
  csf-services / csf-config-only / ufw+csf-ambiguous), asserts
  `extfw.Detect`, `detect.DetectConflicts`, and `uninstall.Classify`
  all see the same external-firewall truth. One dedicated test
  locks the Option A resolution specifically — csf-config-only
  remnant must be seen by install side as well.

## Explicit non-goals (scope-lock)

- NO new firewall types
- NO heuristics beyond current signals
- NO behavior expansion
- NO silent conflict resolution

## Test plan

- `Build & Test` green — new extfw unit tests + consistency tests pass;
  existing detect/conflicts_test.go + uninstall tests pass under
  adapter semantics
- Install / Update / Uninstall Canonization matrices green — no
  CI-gate regressions
- Cross-caller consistency proven by `extfw.consistency_test.go`

Refs: internal/installer/uninstall/contract.md §"Pre-PR-23 blockers"
Authorization: locked Option A resolution (2026-04-20)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…iling colon

MockExecutor.lookupResult builds its key as `name + ":" + strings.Join(args, ":")`.
For exec.Run("iptables-save") with no args, that's "iptables-save:"
(with trailing colon). My fixtures used "iptables-save" without the
colon — lookups missed, fell through to the default
`Result{ExitCode: 0, Stdout: ""}`, so hasActiveIptablesRules returned
false and the detector reported Authoritative="".

TestDetect_None happened to pass because "no firewall" is the default
anyway; TestDetect_Iptables_AllThreeSignals (rule-count case) and
TestConsistency_InstallAndUninstallAgree/iptables-save_rules_present
both failed against empty stdout.

Fix: update every `RunResults["iptables-save"]` → `RunResults["iptables-save:"]`
in detect_test.go + consistency_test.go. No production code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@itcmsgr itcmsgr merged commit 49d98fc into main Apr 20, 2026
57 checks passed
@itcmsgr itcmsgr deleted the fix/v1.100-pr-p2-2-external-firewall-unification branch April 20, 2026 06:47
itcmsgr added a commit that referenced this pull request Apr 20, 2026
Pre-PR-23 assurance blocker #3 of 5 remaining. Adds system-level
before/after truth checks to every dry-run CI path so a future
regression that mutates nftables tables or firewall service states
without touching tracked files is caught at gate time.

## Scope (locked per authorization 2026-04-20)

- Before/after `nft list tables` sorted diff — hard-assert equal
- Before/after `systemctl is-active` for the 6 lifecycle-relevant
  units (nftband + 5 external firewalls) — hard-assert equal
- No `|| true` on the diff checks
- Covered paths: install dry-run refusal, update dry-run, uninstall
  dry-run (explicit + implicit)

## Implementation

- NEW: scripts/ci-snapshot-kernel-service.sh — reusable helper that
  emits a stable, sorted snapshot. Degrades gracefully (both sides
  return the same placeholder) when nft or systemctl aren't available
  (e.g. almalinux-9 container without systemd). Contract is:
    * purely read-only probes
    * never invokes nft/systemctl with mutation verbs
    * never writes to the filesystem
    * exit 0 always — caller decides whether differences fail
- EXTENDED: all 3 canonization workflows
    * ci-install-canonization.yml / G3-IN-REFUSE-DRY-RUN
    * ci-update-canonization.yml / G3-U3
    * ci-uninstall-canonization.yml / G3-UN-PLAN-RENDERS
  Each takes a snapshot before the dry-run invocation and hard-
  asserts byte-identical equality after.

## Monitored units (must match extfw.Detect's signal set)

  nftband.service
  ufw.service
  firewalld.service
  csf.service
  lfd.service
  iptables.service

Kept in lockstep with internal/installer/extfw/detect.go so CI and
production code agree on "what counts as a firewall service."

## Non-goals (scope-lock)

- NO code-path redesign
- NO strace/exec tracing yet (deferred to PR-P2-4)
- NO mutation behavior changes
- NO new firewall-unit additions to the signal set

## Also: tracking update

Marks blocker #2 (external-firewall detection unification, PR #486 /
49d98fc) as LANDED in the contract blocker table. Remaining: 4
Phase 2 PRs before PR-23.

Refs: internal/installer/uninstall/contract.md §"Pre-PR-23 blockers"
Authorization: locked Phase 2 sequencing (2026-04-20)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
itcmsgr added a commit that referenced this pull request Apr 20, 2026
…OT) (#487)

Pre-PR-23 assurance blocker #3 of 5 remaining. Adds system-level
before/after truth checks to every dry-run CI path so a future
regression that mutates nftables tables or firewall service states
without touching tracked files is caught at gate time.

## Scope (locked per authorization 2026-04-20)

- Before/after `nft list tables` sorted diff — hard-assert equal
- Before/after `systemctl is-active` for the 6 lifecycle-relevant
  units (nftband + 5 external firewalls) — hard-assert equal
- No `|| true` on the diff checks
- Covered paths: install dry-run refusal, update dry-run, uninstall
  dry-run (explicit + implicit)

## Implementation

- NEW: scripts/ci-snapshot-kernel-service.sh — reusable helper that
  emits a stable, sorted snapshot. Degrades gracefully (both sides
  return the same placeholder) when nft or systemctl aren't available
  (e.g. almalinux-9 container without systemd). Contract is:
    * purely read-only probes
    * never invokes nft/systemctl with mutation verbs
    * never writes to the filesystem
    * exit 0 always — caller decides whether differences fail
- EXTENDED: all 3 canonization workflows
    * ci-install-canonization.yml / G3-IN-REFUSE-DRY-RUN
    * ci-update-canonization.yml / G3-U3
    * ci-uninstall-canonization.yml / G3-UN-PLAN-RENDERS
  Each takes a snapshot before the dry-run invocation and hard-
  asserts byte-identical equality after.

## Monitored units (must match extfw.Detect's signal set)

  nftband.service
  ufw.service
  firewalld.service
  csf.service
  lfd.service
  iptables.service

Kept in lockstep with internal/installer/extfw/detect.go so CI and
production code agree on "what counts as a firewall service."

## Non-goals (scope-lock)

- NO code-path redesign
- NO strace/exec tracing yet (deferred to PR-P2-4)
- NO mutation behavior changes
- NO new firewall-unit additions to the signal set

## Also: tracking update

Marks blocker #2 (external-firewall detection unification, PR #486 /
49d98fc) as LANDED in the contract blocker table. Remaining: 4
Phase 2 PRs before PR-23.

Refs: internal/installer/uninstall/contract.md §"Pre-PR-23 blockers"
Authorization: locked Phase 2 sequencing (2026-04-20)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant