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
246 changes: 246 additions & 0 deletions .github/workflows/ci-uninstall-canonization.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
# =============================================================================
# NFTBan — CI: Uninstall Canonization Gate (v1.100 PR-22 slice)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
# Purpose: Enforce v1.100 PR-22 scope lock:
# G3-UN-PLAN-RENDERS — dry-run output contains mandatory contract
# language (headers + fields + scope-boundary block)
# G3-UN-NO-MUTATION — structural grep on the uninstall package +
# dispatcher rejecting any mutation-flavored call
#
# Future sub-gates (G3-UN-5..17 + G3-UN-VERIFY) land in PR-23..PR-26.
# PR-22 ships only these two gates because that is the entire claim
# surface of the PR.
#
# Authorization basis: V1100_LIFECYCLE_COMPLETION_CONTRACT.md §13 frozen
# 2026-04-19.
# =============================================================================

name: Uninstall Canonization Gate

on:
pull_request:
branches: [main, master]
paths:
- 'cmd/nftban-installer/**'
- 'internal/installer/uninstall/**'
- 'internal/installer/state/**'
- '.github/workflows/ci-uninstall-canonization.yml'
push:
branches: [main]
workflow_dispatch:

concurrency:
group: ci-uninstall-canonization-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
uninstall-canonization:
name: Uninstall Canonization (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- label: ubuntu-24.04
runner: ubuntu-24.04
container: ''
- label: almalinux-9
runner: ubuntu-24.04
container: almalinux:9
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Install system dependencies (DEB)
if: matrix.label == 'ubuntu-24.04'
run: |
sudo apt-get update -qq
sudo apt-get install -y nftables jq

- name: Install system dependencies (RPM)
if: matrix.label == 'almalinux-9'
run: |
dnf -y install epel-release
dnf -y install nftables jq git tar gzip procps-ng findutils sudo golang

- name: Set up Go (DEB only)
if: matrix.label == 'ubuntu-24.04'
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
with:
go-version: '1.25'

# ------------------------------------------------------------------
# G3-UN-NO-MUTATION — structural grep on the uninstall package +
# dispatcher rejecting mutation-flavored Go calls. This is the PR-22
# scope-lock enforcement: if any commit introduces kernel mutation,
# service lifecycle, filesystem writes under protected paths, or
# external-firewall touches into the uninstall code, CI fails BEFORE
# the unit tests even run.
# ------------------------------------------------------------------
- name: G3-UN-NO-MUTATION — structural audit of uninstall package
shell: bash
run: |
set -Eeuo pipefail
# Narrow search scope to PR-22's claim surface.
files=$(find \
internal/installer/uninstall \
cmd/nftban-installer/uninstall_dryrun.go \
-type f -name '*.go' 2>/dev/null || true)
if [[ -z "$files" ]]; then
echo "::error::G3-UN-NO-MUTATION: uninstall scope files not found"
exit 1
fi
fail=0
for pat in \
'exec\.Run\("nft"[^)]*add' \
'exec\.Run\("nft"[^)]*flush' \
'exec\.Run\("nft"[^)]*delete' \
'exec\.Run\("systemctl"[^)]*stop' \
'exec\.Run\("systemctl"[^)]*start' \
'exec\.Run\("systemctl"[^)]*restart' \
'exec\.Run\("systemctl"[^)]*reload' \
'exec\.Run\("systemctl"[^)]*enable' \
'exec\.Run\("systemctl"[^)]*disable' \
'exec\.Run\("systemctl"[^)]*mask' \
'exec\.Run\("systemctl"[^)]*unmask' \
'exec\.Run\("ufw"' \
'exec\.Run\("iptables"[^-]' \
'exec\.Run\("csf"' \
'exec\.Run\("apt-get"[^)]*remove' \
'exec\.Run\("dnf"[^)]*remove' \
'exec\.WriteFileAtomic\(' \
'os\.Remove\(' \
'os\.RemoveAll\('; do
# Note: ".conf.local" is NOT in this list — the contract
# language in plan.go + contract.md LEGITIMATELY mentions
# .conf.local when describing the §4.4 artifact policy.
# The mutation danger is write/delete operations, which
# the os.Remove + WriteFileAtomic patterns above already
# cover regardless of which file they target.
if grep -nE "$pat" $files 2>/dev/null; then
echo "::error::G3-UN-NO-MUTATION FAIL: forbidden pattern '$pat' in PR-22 uninstall scope"
fail=1
fi
done
if (( fail > 0 )); then
echo "::error::PR-22 contract violation — uninstall code contains mutation-flavored calls"
exit 1
fi
echo "G3-UN-NO-MUTATION PASS — no mutation-flavored call detected in PR-22 scope"

# ------------------------------------------------------------------
# Unit tests — exercise BuildPlan rendering + classifier + probe.
# The structural no-mutation grep above already ran, so if Go code
# somehow bypassed the grep (unlikely), unit tests are the second
# line of defence.
# ------------------------------------------------------------------
- name: Unit tests — internal/installer/uninstall
shell: bash
run: |
set -Eeuo pipefail
go test -v ./internal/installer/uninstall/...

# ------------------------------------------------------------------
# Build the installer binary so we can exercise end-to-end.
# ------------------------------------------------------------------
- name: Build nftban-installer
shell: bash
run: |
set -Eeuo pipefail
mkdir -p bin
go build -trimpath -o bin/nftban-installer ./cmd/nftban-installer/
test -x bin/nftban-installer

- name: Prepare minimal host state
shell: bash
run: |
set -Eeuo pipefail
sudo install -d /var/lib/nftban/state

# ------------------------------------------------------------------
# G3-UN-PLAN-RENDERS — end-to-end dry-run produces the mandatory
# contract-language output. The unit tests already verified the
# Render function in isolation; this confirms the dispatcher path
# wires it up correctly.
# ------------------------------------------------------------------
- name: G3-UN-PLAN-RENDERS — dry-run emits contract language
shell: bash
run: |
set -Eeuo pipefail
set +e
sudo ./bin/nftban-installer --mode=uninstall --dry-run \
--state-dir=/var/lib/nftban/state 2>&1 | tee /tmp/uninstall-dryrun.out
rc=${PIPESTATUS[0]}
set -e
echo "dry-run exit code: $rc"
if [[ "$rc" -ne 0 ]]; then
echo "::error::uninstall dry-run must exit 0 even on a host with no firewall authority"
exit 1
fi
# Mandatory contract-language elements — each one corresponds
# to a row PR-22 promised to render.
for needle in \
"NFTBan Uninstall — Plan" \
"Requested mode" \
"Artifact policy" \
"Current authority" \
"Target authority" \
"Restore requested" \
"Restore authorized" \
"Prior-authority record" \
"Phases that would mutate (NOT IMPLEMENTED in PR-22):" \
"Scope boundary:" \
"Running this command does NOT uninstall nftban"; do
if ! grep -q "$needle" /tmp/uninstall-dryrun.out; then
echo "::error::G3-UN-PLAN-RENDERS FAIL: missing mandatory contract-language element: $needle"
exit 1
fi
done
echo "G3-UN-PLAN-RENDERS PASS — all mandatory contract-language elements present"

# ------------------------------------------------------------------
# G3-UN-PLAN-RENDERS negative path — invoking --mode=uninstall
# WITHOUT --dry-run must still not mutate. The flags validator
# forces --dry-run on in PR-22; this confirms that elevation works.
# ------------------------------------------------------------------
- name: G3-UN-PLAN-RENDERS — no-mutation even without explicit dry-run
shell: bash
run: |
set -Eeuo pipefail
# Snapshot nothing should change outside /var/lib/nftban/state.
before=$(find /etc/nftban /usr/lib/nftban /usr/sbin/nftban* -type f 2>/dev/null | sort | xargs -r sha256sum 2>/dev/null | sort || true)
set +e
sudo ./bin/nftban-installer --mode=uninstall \
--state-dir=/var/lib/nftban/state 2>&1 | tee /tmp/uninstall-implicit.out
rc=${PIPESTATUS[0]}
set -e
after=$(find /etc/nftban /usr/lib/nftban /usr/sbin/nftban* -type f 2>/dev/null | sort | xargs -r sha256sum 2>/dev/null | sort || true)
if [[ "$before" != "$after" ]]; then
echo "::error::PR-22 contract violation — --mode=uninstall touched files outside /var/lib/nftban/state"
diff <(echo "$before") <(echo "$after") || true
exit 1
fi
if [[ "$rc" -ne 0 ]]; then
echo "::error::--mode=uninstall (no explicit dry-run) must exit 0 in PR-22 scope"
exit 1
fi
echo "G3-UN-PLAN-RENDERS no-mutation PASS — no files changed under protected paths"

summary:
name: Uninstall Canonization summary
needs: uninstall-canonization
if: always()
runs-on: ubuntu-latest
steps:
- name: Verdict
run: |
if [[ "${{ needs.uninstall-canonization.result }}" != "success" ]]; then
echo "::error::Uninstall Canonization Gate FAILED — see matrix job output"
exit 1
fi
echo "Uninstall Canonization Gate PASSED on all matrix targets"
40 changes: 40 additions & 0 deletions cmd/nftban-installer/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ type config struct {
lifecycle bool // v1.98: use canonized lifecycle flow (feature flag)
source bool // v1.98.x PR-14-pre: source install (stage payload + users from repo tree)
sourceDir string // v1.98.x PR-14-pre: source tree root for --source (resolved in parseFlags)
// v1.100 PR-22: uninstall scaffold flags. All plan-only in PR-22 —
// no mutation code consumes these; they only influence the rendered
// release plan.
purge bool // --purge: stronger uninstall mode (preserves .conf.local by default)
forceDeleteOperatorConfig bool // --force-delete-operator-config: with --purge, also delete .conf.local
restorePriorAuthority bool // --restore-prior-authority: opt into restoring pre-install external firewall (requires recorded prior state)
}

func parseFlags() *config {
Expand All @@ -68,6 +74,10 @@ func parseFlags() *config {
// from a repo/tarball tree, and safety-whitelist seeding during Prepare/Configure.
flag.BoolVar(&cfg.source, "source", false, "Source install from repo/tarball (stages payload from --source-dir). Mutually exclusive with --rpm and --deb.")
flag.StringVar(&cfg.sourceDir, "source-dir", "", "Source tree root (repo clone or extracted tarball). Falls back to $NFTBAN_SOURCE_DIR then binary-relative discovery.")
// v1.100 PR-22 uninstall flags (plan-only; no mutation in PR-22).
flag.BoolVar(&cfg.purge, "purge", false, "Uninstall in purge mode (stronger deletion; preserves .conf.local unless --force-delete-operator-config is also set). Plan-only in PR-22.")
flag.BoolVar(&cfg.forceDeleteOperatorConfig, "force-delete-operator-config", false, "With --purge, also delete .conf.local. Explicit destructive-intent flag. Plan-only in PR-22.")
flag.BoolVar(&cfg.restorePriorAuthority, "restore-prior-authority", false, "Restore pre-install external firewall authority. Requires recorded prior-authority record. Plan-only in PR-22.")

flag.Parse()

Expand Down Expand Up @@ -96,6 +106,36 @@ func parseFlags() *config {

// Validate
if !cfg.showVersion && !cfg.repair {
if cfg.mode == "uninstall" {
// v1.100 PR-22: uninstall mode is accepted; current release
// is detect + dry-run plan only. Mutation phases land in
// PR-23+.
//
// Audit C regression guard: when PR-23+ adds real mutation,
// this auto-elevation block MUST be removed or changed to
// REFUSE rather than silently elevate. Leaving it in place
// would teach operators that --mode=uninstall is "safe by
// default" — then PR-23 would change that meaning without
// an audit prompt. Tracked in the PR-22 contract doc:
// internal/installer/uninstall/contract.md (audit C regression
// note).
if !cfg.dryRun {
fmt.Fprintln(os.Stderr, "╔══════════════════════════════════════════════════════════════════════╗")
fmt.Fprintln(os.Stderr, "║ --mode=uninstall: NO MUTATION WILL OCCUR (v1.100 PR-22 scope) ║")
fmt.Fprintln(os.Stderr, "║ ║")
fmt.Fprintln(os.Stderr, "║ PR-22 ships detect + dry-run plan only. This invocation is being ║")
fmt.Fprintln(os.Stderr, "║ auto-elevated to --dry-run. Nothing will be removed, no authority ║")
fmt.Fprintln(os.Stderr, "║ released, no service disabled, no file deleted. ║")
fmt.Fprintln(os.Stderr, "║ ║")
fmt.Fprintln(os.Stderr, "║ When PR-23+ adds mutation, this auto-elevation will be removed. ║")
fmt.Fprintln(os.Stderr, "║ At that point, --mode=uninstall will mutate unless --dry-run is ║")
fmt.Fprintln(os.Stderr, "║ explicitly passed. Do not build operational habits around this ║")
fmt.Fprintln(os.Stderr, "║ PR-22 safety-by-default behaviour. ║")
fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════════╝")
cfg.dryRun = true
}
return cfg
}
if cfg.mode != "install" && cfg.mode != "upgrade" {
fmt.Fprintf(os.Stderr, "error: --mode must be 'install' or 'upgrade' (got %q)\n", cfg.mode)
fmt.Fprintf(os.Stderr, "usage: nftban-installer --mode=install|upgrade [flags]\n")
Expand Down
7 changes: 7 additions & 0 deletions cmd/nftban-installer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ func run(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *
if cfg.repair {
return runRepair(ctx, exec, sf, log)
}
// v1.100 PR-22 (uninstall scaffold): uninstall-mode short-circuits
// to authority classify + prior-record probe + plan render. No
// mutation code exists in this release; mutation phases land in
// PR-23+. flags.go forces --dry-run for --mode=uninstall in PR-22.
if cfg.mode == "uninstall" {
return runUninstallDryRun(ctx, exec, sf, cfg, log)
}
// v1.99 PR-16 (G3-U1/U2/U3/U4): update-mode dry-run short-circuits to
// preflight + version-detect + plan render. No mutation — all apply
// logic is deferred to PR-18 and reuses the rebuild pipeline per
Expand Down
Loading
Loading