|
| 1 | +// ============================================================================= |
| 2 | +// NFTBan v1.100 PR-23 — Uninstall Apply Dispatcher |
| 3 | +// ============================================================================= |
| 4 | +// SPDX-License-Identifier: MPL-2.0 |
| 5 | +// meta:name="nftban-installer-uninstall-apply" |
| 6 | +// meta:type="cmd" |
| 7 | +// meta:owner="Antonios Voulvoulis <contact@nftban.com>" |
| 8 | +// meta:created_date="2026-04-20" |
| 9 | +// meta:description="--mode=uninstall --confirm-mutation dispatcher: preflight + Apply + state transition" |
| 10 | +// meta:inventory.files="cmd/nftban-installer/uninstall_apply.go" |
| 11 | +// meta:inventory.binaries="" |
| 12 | +// meta:inventory.env_vars="" |
| 13 | +// meta:inventory.config_files="" |
| 14 | +// meta:inventory.systemd_units="nftband.service" |
| 15 | +// meta:inventory.network="" |
| 16 | +// meta:inventory.privileges="root" |
| 17 | +// ============================================================================= |
| 18 | +// |
| 19 | +// This dispatcher is the ONLY entry into uninstall mutation. Reached |
| 20 | +// only when: |
| 21 | +// |
| 22 | +// cfg.mode == "uninstall" AND |
| 23 | +// cfg.confirmMutation == true AND |
| 24 | +// cfg.dryRun == false |
| 25 | +// |
| 26 | +// (flags.go rejects any other combination at parse time.) |
| 27 | +// |
| 28 | +// Responsibilities: |
| 29 | +// |
| 30 | +// 1. Detect SSH port (reused from install-side detect package). |
| 31 | +// 2. Classify current authority (via uninstall.Classify). |
| 32 | +// 3. Preflight refusal for non-recoverable states; proceed for |
| 33 | +// AuthorityNFTBan or recoverable AuthorityAmbiguous+OrphanNFTBan. |
| 34 | +// 4. Invoke uninstall.Apply for the mutation sequence. |
| 35 | +// 5. Transition the state file to the Apply result's terminal state. |
| 36 | +// |
| 37 | +// Emergency SSH: Apply handles the entire inject/validate/remove cycle |
| 38 | +// internally. The dispatcher never touches the kernel directly. |
| 39 | +// |
| 40 | +// History: intentionally NOT written for uninstall mode. main.go's |
| 41 | +// writeHistory guard excludes cfg.mode=="uninstall" (Option A locked |
| 42 | +// 2026-04-20). Uninstall events are forensically visible only in the |
| 43 | +// installer log until a dedicated uninstall-history schema lands in a |
| 44 | +// later PR. |
| 45 | +// |
| 46 | +// ============================================================================= |
| 47 | +package main |
| 48 | + |
| 49 | +import ( |
| 50 | + "context" |
| 51 | + "fmt" |
| 52 | + "os" |
| 53 | + |
| 54 | + "github.com/itcmsgr/nftban/internal/installer/detect" |
| 55 | + "github.com/itcmsgr/nftban/internal/installer/executor" |
| 56 | + "github.com/itcmsgr/nftban/internal/installer/logging" |
| 57 | + "github.com/itcmsgr/nftban/internal/installer/state" |
| 58 | + "github.com/itcmsgr/nftban/internal/installer/uninstall" |
| 59 | +) |
| 60 | + |
| 61 | +// runUninstallApply orchestrates the PR-23 authority release path. |
| 62 | +// Returns the process exit code derived from the final state. |
| 63 | +func runUninstallApply(_ context.Context, exec executor.Executor, sf *state.StateFile, _ *config, log *logging.Logger) int { |
| 64 | + log.Info("uninstall apply starting (mode=uninstall, confirm-mutation=true)") |
| 65 | + |
| 66 | + // 1. SSH port — needed for the emergency SSH safety table. |
| 67 | + sshPort, sshErr := detect.SSHPort(exec, log) |
| 68 | + if sshErr != nil { |
| 69 | + log.Error("uninstall apply: SSH port detection failed: %v", sshErr) |
| 70 | + _ = sf.Transition(state.StateFailedSSH, state.PhaseDetect, |
| 71 | + "SSH port detection failed: "+sshErr.Error()) |
| 72 | + return sf.State.ExitCode() |
| 73 | + } |
| 74 | + log.Detect("ssh", "port", fmt.Sprintf("%d", sshPort)) |
| 75 | + sf.SSHPort = sshPort |
| 76 | + |
| 77 | + // 2. Classify authority. |
| 78 | + auth := uninstall.Classify(exec, log) |
| 79 | + log.Info("uninstall apply: authority=%s ambiguity=%s external=%s", |
| 80 | + auth.State, auth.Ambiguity, auth.External) |
| 81 | + |
| 82 | + // 3. Preflight decision table. |
| 83 | + proceed := false |
| 84 | + var refuseReason string |
| 85 | + switch { |
| 86 | + case auth.State == uninstall.AuthorityNFTBan: |
| 87 | + proceed = true |
| 88 | + case auth.State == uninstall.AuthorityAmbiguous && auth.Ambiguity == uninstall.AmbiguityOrphanNFTBan: |
| 89 | + // PR-23 correction 1 (locked 2026-04-20): recoverable ambiguity. |
| 90 | + // Orphan nftban kernel/service artifacts with no external |
| 91 | + // authority observable — Apply proceeds via the emergency-SSH- |
| 92 | + // injected cleanup path. |
| 93 | + log.Info("uninstall apply: recoverable ambiguity (orphan_nftban) — proceeding with cleanup") |
| 94 | + proceed = true |
| 95 | + case auth.State == uninstall.AuthorityAmbiguous && auth.Ambiguity == uninstall.AmbiguityConflictExternal: |
| 96 | + refuseReason = "authority is ambiguous (external firewall conflict); operator must resolve before uninstall mutation can proceed" |
| 97 | + case auth.State == uninstall.AuthorityExternal: |
| 98 | + refuseReason = "nftban is not authoritative (" + auth.External + " appears to own the firewall); nothing to release" |
| 99 | + case auth.State == uninstall.AuthorityNone: |
| 100 | + refuseReason = "no firewall authority detected; nothing to release" |
| 101 | + default: |
| 102 | + refuseReason = "unknown authority state: " + string(auth.State) |
| 103 | + } |
| 104 | + |
| 105 | + if !proceed { |
| 106 | + log.Error("uninstall apply: preflight REFUSED — %s", refuseReason) |
| 107 | + fmt.Fprintln(os.Stderr, "uninstall apply: preflight refused — "+refuseReason) |
| 108 | + _ = sf.Transition(state.StateFailedAbort, state.PhaseDetect, refuseReason) |
| 109 | + return sf.State.ExitCode() |
| 110 | + } |
| 111 | + |
| 112 | + // 4. Apply the mutation sequence. |
| 113 | + result := uninstall.Apply(exec, &uninstall.ApplyConfig{SSHPort: sshPort}, log) |
| 114 | + |
| 115 | + // 5. Persist terminal state. sf.Transition returns a non-nil error |
| 116 | + // for failure states (so phase runners halt); here we ignore that |
| 117 | + // signal because the dispatcher IS the halt point. |
| 118 | + _ = sf.Transition(result.State, state.PhaseSwitch, result.Reason) |
| 119 | + |
| 120 | + // Step-by-step evidence log for operator forensics + CI real-host |
| 121 | + // evidence capture. Every step is logged regardless of outcome. |
| 122 | + log.Info("uninstall apply: %d steps executed:", len(result.Steps)) |
| 123 | + for i, s := range result.Steps { |
| 124 | + verdict := "ok" |
| 125 | + if !s.Success { |
| 126 | + verdict = "FAIL" |
| 127 | + } |
| 128 | + log.Info(" step %d %-28s %s %s", i+1, s.Name, verdict, s.Detail) |
| 129 | + } |
| 130 | + |
| 131 | + if result.State == state.StateUninstallReleased { |
| 132 | + log.Result("[NFTBan] uninstall: authority released; nftban is no longer authoritative on this host") |
| 133 | + } else { |
| 134 | + log.Result("[NFTBan] uninstall: %s — %s", result.State, result.Reason) |
| 135 | + } |
| 136 | + |
| 137 | + return sf.State.ExitCode() |
| 138 | +} |
0 commit comments