|
| 1 | +// ============================================================================= |
| 2 | +// NFTBan v1.97 - CLI Lifecycle Command |
| 3 | +// ============================================================================= |
| 4 | +// SPDX-License-Identifier: MPL-2.0 |
| 5 | +// meta:name="cmd_lifecycle" |
| 6 | +// meta:type="command" |
| 7 | +// meta:version="1.97.0" |
| 8 | +// meta:owner="Antonios Voulvoulis <contact@nftban.com>" |
| 9 | +// meta:created_date="2026-04-17" |
| 10 | +// meta:description="Lifecycle planning engine CLI — nftban lifecycle run --mode=X --dry-run" |
| 11 | +// meta:inventory.files="" |
| 12 | +// meta:inventory.binaries="" |
| 13 | +// meta:inventory.env_vars="" |
| 14 | +// meta:inventory.config_files="" |
| 15 | +// meta:inventory.systemd_units="" |
| 16 | +// meta:inventory.network="" |
| 17 | +// meta:inventory.privileges="none" |
| 18 | +// |
| 19 | +// Contract: V197_LIFECYCLE_CONTRACT.md §6 PR-05 |
| 20 | +// INV-LC-001: Engine is side-effect free. DRY-RUN ONLY in v1.97. |
| 21 | +// INV-LC-005: Dry-run uses same classification logic as real execution. |
| 22 | +// INV-LC-007: No lifecycle command in v1.97 may mutate the system. |
| 23 | +// ============================================================================= |
| 24 | + |
| 25 | +package main |
| 26 | + |
| 27 | +import ( |
| 28 | + "fmt" |
| 29 | + "os" |
| 30 | + "os/exec" |
| 31 | + |
| 32 | + "github.com/itcmsgr/nftban/internal/lifecycle" |
| 33 | + "github.com/itcmsgr/nftban/internal/rebuild" |
| 34 | +) |
| 35 | + |
| 36 | +func cmdLifecycle(args []string) int { |
| 37 | + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { |
| 38 | + printLifecycleUsage() |
| 39 | + return 0 |
| 40 | + } |
| 41 | + |
| 42 | + subcmd := args[0] |
| 43 | + subargs := args[1:] |
| 44 | + |
| 45 | + switch subcmd { |
| 46 | + case "run": |
| 47 | + return cmdLifecycleRun(subargs) |
| 48 | + default: |
| 49 | + fmt.Fprintf(os.Stderr, "Unknown lifecycle subcommand: %s\n", subcmd) |
| 50 | + printLifecycleUsage() |
| 51 | + return 1 |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +func cmdLifecycleRun(args []string) int { |
| 56 | + var mode string |
| 57 | + var jsonOutput bool |
| 58 | + dryRun := true // v1.97: ALWAYS dry-run (INV-LC-001) |
| 59 | + |
| 60 | + for _, arg := range args { |
| 61 | + switch { |
| 62 | + case arg == "--json": |
| 63 | + jsonOutput = true |
| 64 | + case len(arg) > 7 && arg[:7] == "--mode=": |
| 65 | + mode = arg[7:] |
| 66 | + case arg == "--no-dry-run": |
| 67 | + // v1.97: refuse real execution (INV-LC-007) |
| 68 | + fmt.Fprintf(os.Stderr, "Error: real execution is not available in v1.97\n") |
| 69 | + fmt.Fprintf(os.Stderr, " Lifecycle execution will be enabled in v1.98+\n") |
| 70 | + fmt.Fprintf(os.Stderr, " Use --dry-run (default) for planning\n") |
| 71 | + return 1 |
| 72 | + case arg == "--dry-run": |
| 73 | + dryRun = true // explicit, same as default |
| 74 | + case arg == "--help" || arg == "-h": |
| 75 | + printLifecycleRunUsage() |
| 76 | + return 0 |
| 77 | + default: |
| 78 | + fmt.Fprintf(os.Stderr, "Unknown option: %s\n", arg) |
| 79 | + return 1 |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + if mode == "" { |
| 84 | + fmt.Fprintf(os.Stderr, "Error: --mode is required\n") |
| 85 | + fmt.Fprintf(os.Stderr, " Valid modes: install, update, uninstall, maintenance\n") |
| 86 | + return 1 |
| 87 | + } |
| 88 | + |
| 89 | + lcMode := lifecycle.Mode(mode) |
| 90 | + if !lifecycle.ValidMode(lcMode) { |
| 91 | + fmt.Fprintf(os.Stderr, "Error: invalid mode: %s\n", mode) |
| 92 | + fmt.Fprintf(os.Stderr, " Valid modes: install, update, uninstall, maintenance\n") |
| 93 | + return 1 |
| 94 | + } |
| 95 | + |
| 96 | + // Build snapshot from real system state |
| 97 | + snap := buildSnapshot(lcMode, dryRun) |
| 98 | + |
| 99 | + // Run lifecycle engine (pure, no side effects) |
| 100 | + result := lifecycle.Run(snap) |
| 101 | + |
| 102 | + // Log evidence |
| 103 | + logger := lifecycle.NewLogger(os.Stderr, lcMode, dryRun) |
| 104 | + logger.LogDetect(snap.CurrentAuthority, snap.Detection) |
| 105 | + logger.LogPlan(result.Plan, result.LastOperation) |
| 106 | + logger.LogResult(result) |
| 107 | + |
| 108 | + // Render output |
| 109 | + if jsonOutput { |
| 110 | + if err := lifecycle.RenderJSON(os.Stdout, result); err != nil { |
| 111 | + fmt.Fprintf(os.Stderr, "Error rendering JSON: %v\n", err) |
| 112 | + return 1 |
| 113 | + } |
| 114 | + } else { |
| 115 | + if err := lifecycle.RenderText(os.Stdout, result); err != nil { |
| 116 | + fmt.Fprintf(os.Stderr, "Error rendering output: %v\n", err) |
| 117 | + return 1 |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + // Exit code: 0 for success/plan-valid, non-zero for failures |
| 122 | + if result.Outcome == lifecycle.OutcomeSuccess { |
| 123 | + return 0 |
| 124 | + } |
| 125 | + return 1 |
| 126 | +} |
| 127 | + |
| 128 | +// buildSnapshot creates a lifecycle Snapshot from real system state. |
| 129 | +// This reads actual system observations — it does NOT guess or assume. |
| 130 | +func buildSnapshot(mode lifecycle.Mode, dryRun bool) lifecycle.Snapshot { |
| 131 | + snap := lifecycle.Snapshot{ |
| 132 | + Mode: mode, |
| 133 | + DryRun: dryRun, |
| 134 | + } |
| 135 | + |
| 136 | + // Detect current authority from validator |
| 137 | + snap.CurrentAuthority = detectAuthority() |
| 138 | + |
| 139 | + // Read v1.96 recovery marker |
| 140 | + snap.LastOperation = readLastOperation() |
| 141 | + |
| 142 | + // Detect system state |
| 143 | + snap.Detection = detectSystem() |
| 144 | + |
| 145 | + return snap |
| 146 | +} |
| 147 | + |
| 148 | +// detectAuthority reads current firewall authority from validator/nft state. |
| 149 | +func detectAuthority() lifecycle.AuthorityState { |
| 150 | + auth := lifecycle.AuthorityState{ |
| 151 | + Owner: lifecycle.AuthorityNone, |
| 152 | + Health: lifecycle.HealthDown, |
| 153 | + } |
| 154 | + |
| 155 | + // Check if nftban tables exist in kernel |
| 156 | + // Use nft list tables to detect presence (read-only, no mutation) |
| 157 | + if fileExists("/usr/lib/nftban/bin/nftban-validate") || fileExists("/usr/sbin/nftban") { |
| 158 | + // NFTBan is installed — check if it owns the firewall |
| 159 | + auth.Owner = lifecycle.AuthorityNFTBan |
| 160 | + |
| 161 | + // Try to get health from validator |
| 162 | + health := getValidatorHealth() |
| 163 | + switch health { |
| 164 | + case "protected": |
| 165 | + auth.Health = lifecycle.HealthProtected |
| 166 | + case "idle": |
| 167 | + auth.Health = lifecycle.HealthIdle |
| 168 | + case "degraded": |
| 169 | + auth.Health = lifecycle.HealthDegraded |
| 170 | + case "down": |
| 171 | + auth.Health = lifecycle.HealthDown |
| 172 | + default: |
| 173 | + auth.Health = lifecycle.HealthDown |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + return auth |
| 178 | +} |
| 179 | + |
| 180 | +// readLastOperation reads v1.96 recovery marker for last operation truth. |
| 181 | +func readLastOperation() lifecycle.LastOperation { |
| 182 | + lo := lifecycle.LastOperation{} |
| 183 | + |
| 184 | + marker, err := rebuild.ReadMarker() |
| 185 | + if err != nil || marker == nil { |
| 186 | + lo.Result = "SUCCESS" |
| 187 | + return lo |
| 188 | + } |
| 189 | + |
| 190 | + lo.Result = string(marker.OperationResult) |
| 191 | + lo.FailureClass = string(marker.FailureClass) |
| 192 | + lo.RecoveryPending = marker.ShouldDeferRetry() |
| 193 | + lo.LastRebuildFailed = marker.OperationResult != rebuild.ResultSuccess |
| 194 | + |
| 195 | + return lo |
| 196 | +} |
| 197 | + |
| 198 | +// detectSystem checks for conflicting firewalls, kernel validity, etc. |
| 199 | +func detectSystem() lifecycle.Detection { |
| 200 | + det := lifecycle.Detection{ |
| 201 | + SSHSafe: true, // default safe unless proven otherwise |
| 202 | + } |
| 203 | + |
| 204 | + // Check for conflicting firewalls |
| 205 | + det.ConflictingFirewall = isServiceActive("firewalld") || |
| 206 | + isServiceActive("ufw") |
| 207 | + |
| 208 | + // Check kernel validity (nftban tables exist and are parseable) |
| 209 | + det.KernelValid = nftTablesExist() |
| 210 | + |
| 211 | + // Validator consistency (tables match expected schema) |
| 212 | + det.ValidatorConsistent = det.KernelValid // simplified for v1.97 |
| 213 | + |
| 214 | + return det |
| 215 | +} |
| 216 | + |
| 217 | +// Helper functions for system detection |
| 218 | + |
| 219 | +func fileExists(path string) bool { |
| 220 | + _, err := os.Stat(path) |
| 221 | + return err == nil |
| 222 | +} |
| 223 | + |
| 224 | +func isServiceActive(service string) bool { |
| 225 | + // Read-only check via systemctl (no mutation) |
| 226 | + cmd := fmt.Sprintf("systemctl is-active --quiet %s 2>/dev/null", service) |
| 227 | + return runQuietCmd(cmd) == nil |
| 228 | +} |
| 229 | + |
| 230 | +func nftTablesExist() bool { |
| 231 | + return runQuietCmd("nft list table ip nftban 2>/dev/null") == nil |
| 232 | +} |
| 233 | + |
| 234 | +func getValidatorHealth() string { |
| 235 | + // Try nftban-validate if available |
| 236 | + out, err := runCmdOutput("nftban-validate", "status", "--quiet") |
| 237 | + if err != nil { |
| 238 | + return "down" |
| 239 | + } |
| 240 | + // Output is the status string |
| 241 | + for _, status := range []string{"protected", "idle", "degraded", "down"} { |
| 242 | + if len(out) >= len(status) && out[:len(status)] == status { |
| 243 | + return status |
| 244 | + } |
| 245 | + } |
| 246 | + return "down" |
| 247 | +} |
| 248 | + |
| 249 | +func runQuietCmd(cmdStr string) error { |
| 250 | + c := exec.Command("bash", "-c", cmdStr) // #nosec G204 — controlled input |
| 251 | + return c.Run() |
| 252 | +} |
| 253 | + |
| 254 | +func runCmdOutput(name string, args ...string) (string, error) { |
| 255 | + c := exec.Command(name, args...) // #nosec G204 — controlled input |
| 256 | + out, err := c.Output() |
| 257 | + if err != nil { |
| 258 | + return "", err |
| 259 | + } |
| 260 | + return string(out), nil |
| 261 | +} |
| 262 | + |
| 263 | +func printLifecycleUsage() { |
| 264 | + fmt.Println("Usage: nftban lifecycle <subcommand> [options]") |
| 265 | + fmt.Println("") |
| 266 | + fmt.Println("Subcommands:") |
| 267 | + fmt.Println(" run Plan a lifecycle operation (dry-run by default)") |
| 268 | + fmt.Println("") |
| 269 | + fmt.Println("Examples:") |
| 270 | + fmt.Println(" nftban lifecycle run --mode=install --dry-run") |
| 271 | + fmt.Println(" nftban lifecycle run --mode=update --json") |
| 272 | +} |
| 273 | + |
| 274 | +func printLifecycleRunUsage() { |
| 275 | + fmt.Println("Usage: nftban lifecycle run --mode=MODE [options]") |
| 276 | + fmt.Println("") |
| 277 | + fmt.Println("Plan a lifecycle operation without executing it.") |
| 278 | + fmt.Println("") |
| 279 | + fmt.Println("Modes:") |
| 280 | + fmt.Println(" install Plan fresh installation") |
| 281 | + fmt.Println(" update Plan upgrade of existing installation") |
| 282 | + fmt.Println(" uninstall Plan removal") |
| 283 | + fmt.Println(" maintenance Plan maintenance operations") |
| 284 | + fmt.Println("") |
| 285 | + fmt.Println("Options:") |
| 286 | + fmt.Println(" --dry-run Plan only, no changes (default)") |
| 287 | + fmt.Println(" --json Output as JSON") |
| 288 | + fmt.Println(" --help Show this help") |
| 289 | + fmt.Println("") |
| 290 | + fmt.Println("Note: Real execution is not available in v1.97.") |
| 291 | + fmt.Println(" Lifecycle execution will be enabled in v1.98+.") |
| 292 | +} |
0 commit comments