Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.95.0
1.98.0
4 changes: 2 additions & 2 deletions cli/lib/nftban/core/nftban_fhs_spec.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
# =============================================================================
# NFTBan v1.95.0 - FHS Specification (GENERATED)
# NFTBan v1.98.0 - FHS Specification (GENERATED)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
#
# meta:name="nftban_fhs_spec"
# meta:type="core"
# meta:header="FHS Specification"
# meta:version="1.95.0"
# meta:version="1.98.0"
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
# meta:homepage="https://nftban.com"
#
Expand Down
8 changes: 8 additions & 0 deletions cli/lib/nftban/core/nftban_health_fixes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ nftban_health_fix_permissions() {
! -perm 640 -exec chmod 640 {} \; 2>/dev/null || true
fi
echo " ✓ Config file permissions enforced"

# ======================================================================
# CLI BINARY PERMISSIONS (DEB-PERM-001)
# ======================================================================
# Handled by nftban_permissions_enforce_all() → perms_enforce_sbin()
# which uses $PERMS_SBIN (from NFTBAN_SBIN_DIR or distro config).
# Legacy fallback below also covers this via nftban_fhs_load_spec.
# See: nftban_permissions.sh:230 (perms_enforce_sbin)
fi

# ==========================================================================
Expand Down
182 changes: 182 additions & 0 deletions cmd/nftban-installer/lifecycle_bridge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// =============================================================================
// NFTBan v1.98 - Installer ↔ Lifecycle Bridge
// =============================================================================
// SPDX-License-Identifier: MPL-2.0
// meta:name="installer-lifecycle-bridge"
// meta:type="cmd"
// meta:version="1.98.0"
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
// meta:created_date="2026-04-17"
// meta:description="Bridge between installer phases and lifecycle reporting — observational only"
// meta:inventory.files="cmd/nftban-installer/lifecycle_bridge.go"
// meta:inventory.binaries=""
// meta:inventory.env_vars=""
// meta:inventory.config_files=""
// meta:inventory.systemd_units=""
// meta:inventory.network=""
// meta:inventory.privileges="none"
//
// Contract: V198_INSTALL_CANONIZATION_CONTRACT.md
// INV-I-004: Lifecycle is OBSERVATIONAL ONLY in v1.98.
// It mirrors installer decisions for reporting. It does NOT drive them.
// Installer logic remains the source of execution truth.
// =============================================================================

package main

import (
"os"
"time"

"github.com/itcmsgr/nftban/internal/installer/logging"
"github.com/itcmsgr/nftban/internal/installer/state"
"github.com/itcmsgr/nftban/internal/lifecycle"
"github.com/itcmsgr/nftban/internal/rebuild"
)

// lifecycleBridge observes installer phases and emits lifecycle events.
// INV-I-004: This bridge is OBSERVATIONAL ONLY — it does not influence
// installer execution decisions. Installer remains the execution authority.
type lifecycleBridge struct {
logger *lifecycle.Logger
mode lifecycle.Mode
}

// newLifecycleBridge creates a bridge that writes lifecycle events to stderr.
func newLifecycleBridge(installerMode string, log *logging.Logger) *lifecycleBridge {
mode := mapInstallerMode(installerMode)

lb := &lifecycleBridge{
logger: lifecycle.NewLogger(os.Stderr, mode, false),
mode: mode,
}

log.Info("lifecycle bridge initialized (mode=%s, observational only)", mode)
return lb
}

// observeDetect records the DETECT stage from installer phase data.
func (lb *lifecycleBridge) observeDetect(pd *phaseData, sf *state.StateFile) {
auth := lifecycle.AuthorityState{
Owner: mapAuthority(string(pd.decision)),
Health: lifecycle.HealthDown, // pre-install, health unknown
}

det := lifecycle.Detection{
ConflictingFirewall: len(pd.conflicts) > 0,
KernelValid: false, // pre-install
ValidatorConsistent: false, // pre-install
SSHSafe: pd.sshPort > 0,
}

lb.logger.LogDetect(auth, det)
}

// observePlan records the PLAN stage from authority decision.
func (lb *lifecycleBridge) observePlan(pd *phaseData) {
var actions []lifecycle.Action
switch pd.decision {
case "takeover":
actions = []lifecycle.Action{lifecycle.ActionTakeAuthority}
case "fresh":
actions = []lifecycle.Action{lifecycle.ActionTakeAuthority}
case "update":
actions = []lifecycle.Action{lifecycle.ActionPreserveAuthority}
case "abort":
actions = []lifecycle.Action{lifecycle.ActionAbort}
default:
actions = []lifecycle.Action{lifecycle.ActionPreserveAuthority}
}

plan := lifecycle.Plan{
Actions: actions,
}

// Read v1.96 recovery state
lastOp := readRecoveryState()

lb.logger.LogPlan(plan, lastOp)
}

// observeResult records the FINAL stage from installer outcome.
func (lb *lifecycleBridge) observeResult(sf *state.StateFile) {
result := lifecycle.RunResult{
SchemaVersion: lifecycle.OutputSchemaVersion,
Mode: lb.mode,
DryRun: false,
Timestamp: time.Now(),
}

// Map installer state to lifecycle outcome
switch sf.State {
case state.StateCommitted:
result.Outcome = lifecycle.OutcomeSuccess
result.Stage = lifecycle.StageFinal
result.Authority.Resulting = lifecycle.AuthorityState{
Owner: lifecycle.AuthorityNFTBan, Health: lifecycle.HealthProtected,
}
case state.StateDegraded:
result.Outcome = lifecycle.OutcomeFailed
result.Stage = lifecycle.StageVerify
result.Authority.Resulting = lifecycle.AuthorityState{
Owner: lifecycle.AuthorityNFTBan, Health: lifecycle.HealthDegraded,
}
default:
result.Outcome = lifecycle.OutcomeFailed
result.Stage = lifecycle.StageApply
result.Authority.Resulting = lifecycle.AuthorityState{
Owner: lifecycle.AuthorityNFTBan, Health: lifecycle.HealthDown,
}
}

result.LastOperation = readRecoveryState()

lb.logger.LogResult(result)
}

// mapInstallerMode converts installer mode string to lifecycle mode.
func mapInstallerMode(mode string) lifecycle.Mode {
switch mode {
case "install", "source":
return lifecycle.ModeInstall
case "upgrade":
return lifecycle.ModeUpdate
case "remove", "purge":
return lifecycle.ModeUninstall
default:
return lifecycle.ModeInstall
}
}

// mapAuthority converts installer authority decision to lifecycle owner.
func mapAuthority(decision string) lifecycle.AuthorityOwner {
switch decision {
case "takeover":
return lifecycle.AuthorityExternal // was external, taking over
case "fresh":
return lifecycle.AuthorityNone
case "update":
return lifecycle.AuthorityNFTBan
case "abort":
return lifecycle.AuthorityExternal
default:
return lifecycle.AuthorityNone
}
}

// readRecoveryState reads v1.96 recovery marker for lifecycle output.
func readRecoveryState() lifecycle.LastOperation {
lo := lifecycle.LastOperation{Result: "SUCCESS"}

marker, err := rebuild.ReadMarker()
if err != nil || marker == nil {
return lo
}

lo.Result = string(marker.OperationResult)
lo.FailureClass = string(marker.FailureClass)
lo.RecoveryPending = marker.ShouldDeferRetry()
lo.LastRebuildFailed = marker.OperationResult != rebuild.ResultSuccess

return lo
}
22 changes: 21 additions & 1 deletion cmd/nftban-installer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func run(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *

// runInstall runs all phases in order for a fresh install or upgrade.
func runInstall(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *config, log *logging.Logger) int {
// v1.98: Initialize lifecycle bridge (observational only — INV-I-004)
lb := newLifecycleBridge(cfg.mode, log)

phases := []struct {
phase state.Phase
name string
Expand All @@ -148,18 +151,35 @@ func runInstall(ctx context.Context, exec executor.Executor, sf *state.StateFile
if ctx.Err() != nil {
log.Error("installer timed out or cancelled during phase %s", p.name)
sf.Transition(state.StateFailedRebuild, p.phase, "timeout")
lb.observeResult(sf) // v1.98: record failure
return report(sf, log)
}

log.Phase(p.name)
if err := p.fn(ctx, exec, sf, log); err != nil {
log.Error("phase %s failed: %v", p.name, err)
log.PhaseEnd(p.name)
// State file already updated by the phase function

// v1.98: Emit lifecycle observations for the phase that completed before failure
if p.phase == state.PhaseDetect {
lb.observeDetect(&globalPhaseData, sf)
lb.observePlan(&globalPhaseData)
}
lb.observeResult(sf) // record failure outcome
return report(sf, log)
}

// v1.98: Lifecycle observations at phase boundaries
switch p.phase {
case state.PhaseDetect:
lb.observeDetect(&globalPhaseData, sf)
lb.observePlan(&globalPhaseData)
}
}

// v1.98: Record final lifecycle result
lb.observeResult(sf)

log.PhaseEnd("Validate")
return report(sf, log)
}
Expand Down
44 changes: 39 additions & 5 deletions cmd/nftban-installer/phases.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/itcmsgr/nftban/internal/installer/authority"
"github.com/itcmsgr/nftban/internal/installer/deps"
Expand Down Expand Up @@ -255,6 +256,15 @@ func phaseConfigure(_ context.Context, exec executor.Executor, sf *state.StateFi
}

// phaseValidate runs post-install assertions, writes authority files, sets immutable flags.
//
// v1.98 flow (INV-I-010 through INV-I-013):
// 1. Write authority files
// 2. Run permissions enforce (safe auto-fix for FHS drift)
// 3. Run assertions (VALIDATE_1)
// 4. If assertions fail → try health fix once (safe auto-fix)
// 5. Re-run assertions (VALIDATE_2) — only post-fix result counts
// 6. Set immutable flags
// 7. Final result from VALIDATE_2 (or VALIDATE_1 if no fix needed)
func phaseValidate(_ context.Context, exec executor.Executor, sf *state.StateFile, log *logging.Logger) error {
pd := &globalPhaseData

Expand All @@ -264,22 +274,46 @@ func phaseValidate(_ context.Context, exec executor.Executor, sf *state.StateFil
// 2. Run permissions enforce (G10 — full FHS permissions fix)
validate.RunPermissionsEnforce(exec, log)

// 3. Run assertions
// 3. Run assertions (VALIDATE_1)
results := validate.RunAssertions(exec, pd.sshPort, log)

// 4. Set immutable flags on security-critical files (G8)
validate.SetImmutableFlags(exec, log)

if validate.AllPassed(results) {
log.Info("all post-install assertions passed — COMMITTED")
// Clean up install-failed marker if present
_ = exec.Remove(fhs.InstallFailedMarker)
return sf.Transition(state.StateCommitted, state.PhaseValidate, "")
}

// Some assertions failed → DEGRADED
// v1.98 INV-I-010: Some assertions failed → try safe auto-fix ONCE
failed := validate.FailedNames(results)
reason := "failed assertions: " + strings.Join(failed, ", ")
log.Warn("some assertions failed — DEGRADED: %s", reason)
log.Warn("VALIDATE_1: %d assertions failed: %s", len(failed), strings.Join(failed, ", "))
log.Info("attempting bounded safe auto-fix (permissions enforce only, INV-I-011/012)...")

// Run ONLY permissions enforce — bounded, safe, idempotent (INV-I-011).
// This fixes ownership/mode on NFTBan-managed paths only.
// Does NOT run 'health fix all' which would cross authority boundaries
// (disabling UFW/firewalld/fail2ban, triggering rebuild, GeoIP download, etc.)
fixRes := exec.RunTimeout(30*time.Second, fhs.NftbanCLI, "permissions", "enforce")
if fixRes.ExitCode == 0 {
log.Info("permissions enforce completed — re-validating (INV-I-013)")
} else {
log.Warn("permissions enforce returned exit %d — re-validating anyway", fixRes.ExitCode)
}

// v1.98 INV-I-013: Re-run assertions (VALIDATE_2) — only this result counts
results2 := validate.RunAssertions(exec, pd.sshPort, log)

if validate.AllPassed(results2) {
log.Info("VALIDATE_2: all assertions passed after safe auto-fix — COMMITTED")
_ = exec.Remove(fhs.InstallFailedMarker)
return sf.Transition(state.StateCommitted, state.PhaseValidate, "")
}

// Still failing after auto-fix → DEGRADED (INV-I-008)
failed2 := validate.FailedNames(results2)
reason := "failed assertions after safe auto-fix: " + strings.Join(failed2, ", ")
log.Warn("VALIDATE_2: %d assertions still failed — DEGRADED: %s", len(failed2), reason)
return sf.Transition(state.StateDegraded, state.PhaseValidate, reason)
}
9 changes: 9 additions & 0 deletions install/systemd/nftban-health.service
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ Group=nftban
# to avoid chicken-and-egg: health check can't run if /run/nftban perms broken
ExecStart=/usr/bin/flock -n /var/cache/nftban/health.lock /usr/sbin/nftban health check --auto-heal --cache-status

# NOTE: Root-level permission fixes (nftban-health-fix.service) are NOT
# auto-triggered from the periodic health check. The fix-all service runs
# 9 steps including firewall conflict removal, rebuild, and GeoIP download
# which violates bounded auto-fix scope (INV-I-011).
# Root permission fixes run only during:
# 1. Install/update (phaseValidate → permissions enforce)
# 2. Manual operator invocation (systemctl start nftban-health-fix)
# Future: introduce a bounded 'nftban health fix safe' target if needed.

# CAP_NET_ADMIN required for nft list/add operations (health checks + auto-heal)
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN
Expand Down
Loading
Loading