#!/usr/bin/perl
###############################################################################
# CSF 2.0 - cPanel Shield Firewall
# csf2 - command-line interface (CSF-compatible).
#
# Preserves familiar CSF flags (-a -d -dr -td -tr -tf -r -l -g -x -e ...) and
# adds modern long forms (inspect, health, --safe-reload). Every mutating
# command goes through CSF2::Engine, so single-IP operations touch one nftables
# set element and never rebuild the ruleset.
#
# Environment overrides (for testing / non-standard installs):
#   CSF2_ETC, CSF2_VAR, CSF2_NFT, CSF2_DRYRUN=1
#
# Copyright (C) 2026 CSF - cPanel Shield Firewall (GNU GPL v3).
# Next-generation fork of ConfigServer Security & Firewall (C) 2006-2025
# Jonathan Michaelson.
###############################################################################

use strict;
use warnings;
use FindBin qw($Bin);
# $Bin/../lib works when run from the source tree; the absolute path works when
# invoked via the /usr/sbin/csf symlink (FindBin does not resolve symlinks, so
# $Bin would otherwise be /usr/sbin and miss the installed modules).
use lib "$Bin/../lib", '/usr/local/csf/lib';
use CSF2::Config;
use CSF2::State;
use CSF2::Nft;
use CSF2::Engine;
use CSF2::Health;
use CSF2::Ruleset;
use CSF2::Cpanel;
use CSF2::Migrate;

my $VERSION = '2.1.5';

my $etc = $ENV{CSF2_ETC} || '/etc/csf';
my $var = $ENV{CSF2_VAR} || '/var/lib/csf';

my $config = CSF2::Config->loadconfig(file => "$etc/csf.conf");
my %cfg    = $config->config;
my $state  = CSF2::State->new(etc => $etc, var => $var);
my $nft    = CSF2::Nft->new(
    dry_run => ($ENV{CSF2_DRYRUN} ? 1 : 0),
    nft     => ($ENV{CSF2_NFT} || $cfg{NFT} || '/usr/sbin/nft'),
);
my $eng = CSF2::Engine->new(config => $config, state => $state, nft => $nft);

my $cmd = shift @ARGV;
$cmd = '--help' unless defined $cmd;

# remaining args: first token is usually the IP, rest is comment / options
my $arg  = join(' ', @ARGV);
my ($ip, $rest) = split(/\s+/, $arg, 2);
$rest = '' unless defined $rest;

# Parse a timeout token like 3600, 30m, 2h, 1d -> seconds
sub parse_ttl {
    my ($t) = @_;
    return 0 unless defined $t;
    if ($t =~ /^(\d+)([smhd])$/i) {
        my ($n, $u) = ($1, lc $2);
        return $n            if $u eq 's';
        return $n * 60       if $u eq 'm';
        return $n * 3600     if $u eq 'h';
        return $n * 86400    if $u eq 'd';
    }
    return $t =~ /^\d+$/ ? $t : 0;
}

my $rc = 0;

if ($cmd eq '--add' or $cmd eq '-a') {
    my ($ok, $msg) = $eng->allow($ip, $rest);
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--addrm' or $cmd eq '-ar') {
    my ($ok, $msg) = $eng->unallow($ip);
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--deny' or $cmd eq '-d') {
    my ($ok, $msg) = $eng->deny($ip, $rest);
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--denyrm' or $cmd eq '-dr') {
    my ($ok, $msg) = $eng->undeny($ip);
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--tempdeny' or $cmd eq '-td') {
    my ($ttl_tok, $comment) = split(/\s+/, $rest, 2);
    my $ttl = parse_ttl($ttl_tok);
    if (!$ttl) { $comment = $rest; $ttl = 3600 }   # no ttl given
    my ($ok, $msg) = $eng->tempdeny($ip, $ttl, comment => ($comment // ''));
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--tempallow' or $cmd eq '-ta') {
    my ($ttl_tok, $comment) = split(/\s+/, $rest, 2);
    my $ttl = parse_ttl($ttl_tok) || 3600;
    my ($ok, $msg) = $eng->tempdeny($ip, $ttl, kind => 'allow', comment => ($comment // ''));
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--temprm' or $cmd eq '-tr') {
    my ($ok, $msg) = $eng->temprm($ip);
    print "$msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--tempf' or $cmd eq '-tf') {
    my ($ok, $msg) = $state->temp_flush();
    print "$msg\n";
}
elsif ($cmd eq '--restart' or $cmd eq '-r' or $cmd eq '--start' or $cmd eq '-s' or $cmd eq '--safe-reload') {
    my $safe = ($cmd eq '--safe-reload');
    my %opts;
    $opts{force} = 1 if grep { $_ eq '--force' } @ARGV;

    # Safe-reload: snapshot the current ruleset first so a watchdog can revert.
    my $rollback = "$var/csf.rollback";
    $eng->snapshot_save($rollback) if $safe;

    my $r = $eng->reload(%opts);
    for my $w (@{ $r->{guard}{warnings} }) { print "*WARNING* $w\n" }
    if ($r->{ok} && $r->{applied}) {
        print "csf: firewall ruleset applied atomically (table inet csf)\n";
        if ($safe) {
            my $timeout = $cfg{SAFE_RELOAD_TIMEOUT} || 30;
            unlink "$var/csf.confirmed" if -e "$var/csf.confirmed";
            spawn_watchdog($eng, $rollback, "$var/csf.confirmed", $timeout) unless $ENV{CSF2_DRYRUN};
            print "csf: SAFE RELOAD active - run 'csf2 --confirm' within ${timeout}s or the firewall auto-reverts.\n";
        }
    } elsif ($r->{ok}) {
        print "csf: ruleset generated and validated (not applied)\n";
    } else {
        print "*ERROR* $r->{error}\n";
        print "       (re-run with --force to override, or fix the issue above)\n" if @{ $r->{guard}{blocks} };
        $rc = 1;
    }
}
elsif ($cmd eq '--confirm') {
    open my $fh, '>', "$var/csf.confirmed" or do { print "*ERROR* cannot write confirm file: $!\n"; exit 1 };
    print {$fh} time . "\n"; close $fh;
    print "csf: safe-reload confirmed - the new ruleset will be kept.\n";
}
elsif ($cmd eq '--revert') {
    my ($ok, $msg) = $eng->revert_from("$var/csf.rollback");
    print "csf: $msg\n"; $rc = $ok ? 0 : 1;
}
elsif ($cmd eq '--status' or $cmd eq '-l') {
    my $deny  = $state->load_list('deny');
    my $allow = $state->load_list('allow');
    my $temp  = $state->temp_load();
    my @live  = grep { !$_->{expired} } @$temp;
    my $table = $nft->table_exists() ? 'loaded' : 'NOT loaded';
    print "CSF 2.0 status\n";
    print "  nftables table inet csf : $table\n";
    print "  enforcing               : " . ($config->is_enforcing ? 'yes' : 'no (TESTING)') . "\n";
    print "  allow entries           : " . scalar(@$allow) . "\n";
    print "  deny entries            : " . scalar(@$deny)  . "\n";
    print "  temporary entries       : " . scalar(@live)   . "\n";
}
elsif ($cmd eq '--grep' or $cmd eq '-g' or $cmd eq 'inspect' or $cmd eq '--inspect') {
    my $i = $eng->inspect($ip);
    if ($i->{error}) { print "csf: $i->{error}\n"; $rc = 1 }
    else {
        print "IP        : $i->{ip}\n";
        print "State     : \U$i->{state}\n";
        print "Found in  : " . (@{$i->{where}} ? join(', ', @{$i->{where}}) : 'nothing') . "\n";
        print "Comment   : $i->{comment}\n" if $i->{comment};
    }
}
elsif ($cmd eq 'health' or $cmd eq '--health') {
    my ($results, $worst) = CSF2::Health::check(\%cfg, $nft, $state);
    print "CSF 2.0 health check\n";
    my %mark = (ok => '[ OK ]', warn => '[WARN]', fail => '[FAIL]');
    for my $c (@$results) {
        printf "  %-6s %-20s %s\n", $mark{$c->{status}}, $c->{name}, $c->{detail};
    }
    print "Overall: \U$worst\n";
    $rc = $worst eq 'fail' ? 2 : ($worst eq 'warn' ? 1 : 0);
}
elsif ($cmd eq '--generate' or $cmd eq '--gen') {
    print CSF2::Ruleset::build(\%cfg, $state->state_for_ruleset());
}
elsif ($cmd eq '--cpanel-check') {
    my $cp = CSF2::Cpanel->new(root => ($ENV{CSF2_ROOT} || ''));
    my $s = $cp->summary;
    my $nftver = `$nft->{nft} --version 2>/dev/null`; $nftver =~ s/\s+$//;
    print "CSF 2.0 cPanel compatibility check\n";
    printf "  %-22s %s\n", 'cPanel detected', ($s->{cpanel} ? 'yes' : 'NO');
    printf "  %-22s %s\n", 'cPanel version', ($s->{version} // 'unknown');
    printf "  %-22s %s\n", 'Firewall backend', ($nft->available ? "nftables ($nftver)" : 'nftables (nft not found)');
    printf "  %-22s %s\n", 'IPv6', ($cfg{IPV6} ? 'enabled' : 'disabled');
    printf "  %-22s %s\n", 'cPHulk', ($s->{cphulk} ? 'enabled (respected)' : 'not enabled');
    printf "  %-22s %s\n", 'ModSecurity', ($s->{modsecurity} ? 'installed' : 'not detected');
    my ($hres, $worst) = CSF2::Health::check(\%cfg, $nft, $state);
    my $warn = grep { $_->{status} ne 'ok' } @$hres;
    printf "  %-22s %s\n", 'Compatibility', ($worst eq 'fail' ? 'FAIL' : 'PASS');
    printf "  %-22s %d\n", 'Warnings', $warn;
    $rc = $worst eq 'fail' ? 2 : 0;
}
elsif ($cmd eq '--initup') {
    # boot / systemd start: build + apply (guarded unless forced from unit)
    my $r = $eng->reload(force => 1);   # boot path trusts on-disk config
    if ($r->{ok} && $r->{applied}) { print "csf: firewall loaded at boot\n" }
    elsif ($r->{ok})               { print "csf: firewall generated (dry-run)\n" }
    else { print "*ERROR* $r->{error}\n"; $rc = 1 }
}
elsif ($cmd eq '--initdown' or $cmd eq '--flush') {
    my ($ok, $out) = $nft->delete_table();
    print $ok == 0 ? "csf: table inet csf removed\n" : "csf: nothing to remove\n";
}
elsif ($cmd eq '--selftest') {
    # end-to-end functional check: is CSF actually protecting this server?
    my @r;   # [name, status(PASS/WARN/FAIL), detail]
    my $nftbin = $nft->{nft};

    my $nftok = $nft->available;
    push @r, ['nft binary', $nftok ? 'PASS' : 'FAIL', $nftbin];

    my $tbl = $nft->table_exists;
    push @r, ['kernel table inet csf', $tbl ? 'PASS' : 'FAIL',
              $tbl ? 'loaded in kernel' : 'NOT loaded - run: csf -r'];

    push @r, ['enforcement', $config->is_enforcing ? 'PASS' : 'WARN',
              $config->is_enforcing ? 'enforcing (drop policy)' : 'TESTING - not enforcing'];

    # functional block test: add a TEST-NET-3 IP straight to the kernel set,
    # confirm it landed, then remove it. Does not touch csf.deny.
    if ($nftok && $tbl && !$ENV{CSF2_DRYRUN}) {
        my $testip = '203.0.113.222';
        $nft->add_element('csf_deny4', $testip);
        my $out = `$nftbin list set inet csf csf_deny4 2>/dev/null`;
        my $inset = ($out =~ /\Q$testip\E/) ? 1 : 0;
        $nft->del_element('csf_deny4', $testip);
        push @r, ['block reaches kernel', $inset ? 'PASS' : 'FAIL',
                  $inset ? 'a blocked IP lands in the nftables set' : 'test IP not found in set'];
    }

    for my $svc (qw(csf lfd)) {
        my $st = `systemctl is-active $svc 2>/dev/null`; chomp $st;
        my $en = `systemctl is-enabled $svc 2>/dev/null`; chomp $en;
        push @r, ["$svc.service", ($st eq 'active' ? 'PASS' : 'WARN'),
                  ($st || 'unknown') . ($en eq 'enabled' ? ' + boot-persistent' : '')];
    }

    my $log = "$var/lfd.log";
    my $act = (-e $log && -s $log) ? 1 : 0;
    push @r, ['LFD detection', $act ? 'PASS' : 'WARN',
              $act ? 'lfd.log shows detection activity' : 'no lfd.log yet (no attacks recorded)'];

    if ($nftok && $tbl) {
        my $chain = `$nftbin list chain inet csf input 2>/dev/null`;
        my ($pkts) = $chain =~ /counter packets (\d+)/;
        push @r, ['packets dropped', (defined $pkts ? 'PASS' : 'WARN'),
                  (defined $pkts ? "$pkts packets dropped by the firewall" : 'no drop counter found')];
    }

    my $deny = scalar @{ $state->load_list('deny') };
    my $temp = scalar grep { !$_->{expired} } @{ $state->temp_load() };
    push @r, ['active blocks', 'PASS', "$deny permanent, $temp temporary"];

    print "CSF 2.0 self-test (v$VERSION)\n";
    for my $x (@r) { printf "  [%-4s] %-24s %s\n", $x->[1], $x->[0], $x->[2] }
    my $fails = grep { $_->[1] eq 'FAIL' } @r;
    my $warns = grep { $_->[1] eq 'WARN' } @r;
    if ($fails) {
        print "\nVERDICT: PROBLEM - $fails critical check(s) failed above.\n";
        $rc = 2;
    } elsif ($warns) {
        print "\nVERDICT: CSF IS WORKING (with $warns advisory warning(s)).\n";
        $rc = 0;
    } else {
        print "\nVERDICT: CSF IS WORKING - all checks passed.\n";
        $rc = 0;
    }
}
elsif ($cmd eq '--self-update') {
    # Download the latest build from the release channel, verify its sha256,
    # extract and install it. Progress is written to $var/update.progress so the
    # WHM UI can render a live progress bar. Safe: aborts on checksum mismatch.
    my $prog = "$var/update.progress";
    my $w = sub { my ($pct,$msg) = @_; if (open my $f,'>',$prog){ print {$f} "$pct|$msg\n"; close $f } };
    my $fail = sub { $w->(-1, "ERROR: $_[0]"); print "*ERROR* $_[0]\n"; exit 1 };
    my $curl = (-x '/usr/bin/curl') ? '/usr/bin/curl' : 'curl';
    my $base = $ENV{CSF2_UPDATE_URL} || 'https://shield.underhost.com/firewall/releases/latest.json';
    # cache-buster so an intermediate CDN (Cloudflare) can't serve a stale manifest
    my $url  = $base . (($base =~ /\?/) ? '&' : '?') . '_=' . time;
    $url =~ s/[^A-Za-z0-9:\/._?=&%-]//g;

    $w->(5, 'Checking for updates');
    my $json = `$curl -fsSL -H 'Cache-Control: no-cache' --max-time 20 "$url" 2>/dev/null` || '';
    my ($ver) = $json =~ /"version"\s*:\s*"([^"]+)"/;
    my ($dl)  = $json =~ /"download"\s*:\s*"([^"]+)"/;
    my ($sum) = $json =~ /"value"\s*:\s*"([a-f0-9]{64})"/;
    $fail->('could not read the update manifest') unless $ver && $dl;

    # semver compare with installed
    my @a = ($ver =~ /(\d+)/g); my @b = ($VERSION =~ /(\d+)/g); my $newer = 0;
    for my $i (0..3) { my $x=$a[$i]//0; my $y=$b[$i]//0; if ($x>$y){$newer=1;last} if ($x<$y){last} }
    unless ($newer || (grep { $_ eq '--force' } @ARGV)) {
        $w->(100, "Already up to date (v$VERSION)");
        print "csf: already up to date (v$VERSION)\n"; exit 0;
    }

    # Work under $var (/var/lib/csf), NOT /tmp: /tmp is frequently mounted
    # noexec on hardened cPanel servers, which blocks running the installer.
    my $tmp = "$var/update.$$"; system('mkdir','-p',$tmp);
    $w->(20, "Downloading v$ver");
    system("$curl -fsSL --max-time 180 -o '$tmp/pkg.tgz' \"$dl\"") == 0 or $fail->('download failed');
    $w->(55, 'Verifying checksum');
    my $got = `sha256sum '$tmp/pkg.tgz' 2>/dev/null | awk '{print \$1}'`; chomp $got;
    if ($sum && $got ne $sum) { system("rm -rf '$tmp'"); $fail->('checksum mismatch - update aborted') }
    $w->(65, 'Extracting package');
    system("tar xzf '$tmp/pkg.tgz' -C '$tmp'") == 0 or $fail->('extract failed');
    $fail->('package layout unexpected') unless -f "$tmp/csf2/install.sh";
    $w->(78, "Installing v$ver");
    # Invoke via `sh` (script read as input) so it runs even if the extract dir
    # or /tmp is noexec, and regardless of the file's execute bit.
    my $rc2 = system("cd '$tmp/csf2' && sh ./install.sh > '$var/update.log' 2>&1");
    system("rm -rf '$tmp'");
    if ($rc2 != 0) { $fail->("install failed - see $var/update.log") }
    unlink "$var/update.json";   # invalidate the WHM update-check cache
    $w->(100, "Updated to v$ver");
    print "csf: updated to v$ver\n";
}
elsif ($cmd eq '--ti-update') {
    require CSF2::ThreatIntel;
    # Single-instance lock: the hourly cron and the WHM button both call this;
    # if one run is slow (or ever hangs), a second must NOT start and stack up.
    require Fcntl;
    open(my $lock, '>', "$var/ti-update.lock") or do { print "*ERROR* cannot open lock\n"; exit 1 };
    unless (flock($lock, Fcntl::LOCK_EX() | Fcntl::LOCK_NB())) {
        print "csf: a threat-intel update is already running - skipping\n";
        exit 0;
    }
    print "csf: updating threat-intelligence feeds...\n";
    unless ($cfg{TI_ENABLE}) { print "*NOTE* Threat Intelligence is disabled (set TI_ENABLE=1). Downloading anyway.\n" }
    # Hard ceiling so a run can never live forever even if a child ever wedges.
    my $timeout = ($cfg{TI_UPDATE_TIMEOUT} && $cfg{TI_UPDATE_TIMEOUT} =~ /^\d+$/) ? $cfg{TI_UPDATE_TIMEOUT} : 300;
    my $rep;
    eval {
        local $SIG{ALRM} = sub { die "ti-update timed out after ${timeout}s\n" };
        alarm($timeout);
        $rep = CSF2::ThreatIntel::update(nft => $nft, cfg => { %cfg, TI_ENABLE => 1 }, var => $var);
        alarm(0);
        1;
    } or do { alarm(0); print "*ERROR* threat-intel update aborted: $@"; exit 1 };
    for my $f (@{ $rep->{feeds} }) {
        printf "  %-6s %-18s %s\n", ($f->{ok} ? 'ok' : 'FAIL'), $f->{id},
            ($f->{ok} ? "$f->{count} entries" : ($f->{error} || 'error'));
    }
    printf "csf: loaded %d indicators into threat-intel sets\n", $rep->{total};
}
elsif ($cmd eq '--ti-status') {
    require CSF2::ThreatIntel;
    my $st = CSF2::ThreatIntel::status($var);
    print "CSF 2.0 Threat Intelligence\n";
    print "  enabled     : " . ($cfg{TI_ENABLE} ? 'yes' : 'no (TI_ENABLE=0)') . "\n";
    print "  last update : " . ($st->{updated} ? scalar(localtime($st->{updated})) : 'never') . "\n";
    print "  indicators  : $st->{total}\n";
    my %cnt = map { $_->{id} => $_ } @{ $st->{feeds} };
    for my $f (CSF2::ThreatIntel::feeds()) {
        my $on = CSF2::ThreatIntel::feed_enabled(\%cfg, $f);
        my $c  = $cnt{$f->{id}} ? $cnt{$f->{id}}{count} : 0;
        printf "  [%s] %-16s %-13s %s\n", ($on ? 'x' : ' '), $f->{id}, "($f->{category})",
            ($on ? "$c entries" : 'disabled');
    }
}
elsif ($cmd eq '--cf-enable' or $cmd eq '--cf-update') {
    require CSF2::Cloudflare;
    print "csf: fetching Cloudflare edge IP ranges...\n";
    if ($cmd eq '--cf-update') { CSF2::Cloudflare::disable(eng => $eng, state => $state) }
    my ($ok, $msg, $n) = CSF2::Cloudflare::enable(eng => $eng);
    unless ($ok) { print "*ERROR* $msg\n"; $rc = 1 }
    else {
        CSF2::Config->save_keys("$etc/csf.conf", { CF_ENABLE => '1' });
        print "csf: $msg (allowlisted)\n";
    }
}
elsif ($cmd eq '--cf-disable') {
    require CSF2::Cloudflare;
    my ($ok, $msg, $n) = CSF2::Cloudflare::disable(eng => $eng, state => $state);
    CSF2::Config->save_keys("$etc/csf.conf", { CF_ENABLE => '0' });
    print "csf: $msg\n";
}
elsif ($cmd eq '--cpanel-setup') {
    # auto-configure ports for this cPanel server (detect SSH + service ports)
    require CSF2::Cpanel;
    my $cp = CSF2::Cpanel->new(root => ($ENV{CSF2_ROOT} || ''));
    my ($live_ssh) = ($ENV{SSH_CONNECTION} || '') =~ /(\d+)\s*$/;   # server-side SSH port
    my $rec = $cp->recommended_config(extra_ssh => ($live_ssh || ()));
    unless ($rec && $rec->{TCP_IN}) { print "*ERROR* could not build cPanel port set\n"; exit 1 }
    my %kv = (TCP_IN => $rec->{TCP_IN}, UDP_IN => $rec->{UDP_IN}, MGMT_PORTS => $rec->{MGMT_PORTS});
    my ($ok, $m) = CSF2::Config->save_keys("$etc/csf.conf", \%kv);
    if ($ok) {
        print "csf: cPanel ports configured for this server\n";
        print "     SSH port(s) kept open : " . join(',', @{ $rec->{ssh_ports} }) . "\n";
        print "     TCP_IN                : $rec->{TCP_IN}\n";
        print "     MGMT_PORTS            : $rec->{MGMT_PORTS}\n";
        print "     (run 'csf -r' to apply)\n";
    } else { print "*ERROR* $m\n"; $rc = 1 }
}
elsif ($cmd eq 'migrate' or $cmd eq '--migrate') {
    my $dry = grep { $_ eq '--dry-run' } @ARGV;
    my $legacy = $ENV{CSF2_LEGACY_ETC} || $etc;
    my $mig = CSF2::Migrate->new(legacy_etc => $legacy, target_etc => $etc);
    my ($newcfg, $rep) = $mig->analyze;
    print "CSF 2.0 migration " . ($dry ? "(dry run)\n" : "from legacy CSF\n");
    printf "  Migrated:      %d settings\n", $rep->{count_migrated};
    printf "  Converted:     %d settings\n", $rep->{count_converted};
    printf "  Deprecated:    %d settings\n", $rep->{count_deprecated};
    printf "  Manual review: %d settings\n", $rep->{count_manual};
    printf "  Errors:        %d\n", $rep->{count_errors};
    # deprecated list is short + important - show it inline
    for my $d (@{ $rep->{deprecated} }) { print "    [deprecated] $d\n" }
    # the manual-review list can be large (legacy LF_* tuning); write the full
    # report to a file and print only a pointer to keep the console readable.
    my $report = "$etc/migration-report.txt";
    if (!$dry && open(my $rf, '>', $report)) {
        print {$rf} "CSF 2.0 migration report - " . localtime() . "\n\n";
        for my $sec (qw(migrated converted deprecated manual errors)) {
            print {$rf} "== \U$sec\E (" . scalar(@{$rep->{$sec}}) . ") ==\n";
            print {$rf} "  $_\n" for @{ $rep->{$sec} };
            print {$rf} "\n";
        }
        close $rf;
    }
    if ($rep->{count_manual}) {
        printf "  -> %d settings flagged for review under CSF 2.0 risk scoring; full report: %s\n",
            $rep->{count_manual}, ($dry ? '(dry run - not written)' : $report);
    }
    unless ($dry) {
        my ($bok, $bpath) = $mig->backup;
        print "  Backup:        $bpath" . ($bok ? "\n" : " (WARNING: incomplete)\n");
        my $counts = $mig->import_lists;
        printf "  Lists:         allow=%d deny=%d ignore=%d\n",
            ($counts->{allow}//0), ($counts->{deny}//0), ($counts->{ignore}//0);
        my ($wok, $wpath) = $mig->write_config($newcfg);
        print "  Config:        " . ($wok ? "$wpath\n" : "FAILED\n");
        print "  Next: review $etc/csf.conf, then 'csf2 health' and 'csf2 --safe-reload'\n";
    }
}
elsif ($cmd eq '--version' or $cmd eq '-v') {
    print "csf: v$VERSION (cPanel Shield Firewall)\n";
}
elsif ($cmd eq '--help' or $cmd eq '-h') {
    print_help();
}
else {
    print "csf: unknown command '$cmd'\n\n";
    print_help();
    $rc = 1;
}

exit $rc;

###############################################################################
# spawn_watchdog: detached child that auto-reverts the firewall unless the
# admin confirms in time. This is what makes --safe-reload safe on a remote box:
# if the reload cut off the admin, they simply never run --confirm and the
# previous working ruleset is restored automatically.
sub spawn_watchdog {
    my ($eng, $rollback, $confirm, $timeout) = @_;
    my $pid = fork();
    return unless defined $pid;   # fork failed - caller already applied ruleset
    return if $pid;               # parent continues

    # child: detach from the controlling terminal and wait out the window.
    eval { require POSIX; POSIX::setsid(); };
    open(STDIN,  '<', '/dev/null');
    open(STDOUT, '>', '/dev/null');
    open(STDERR, '>', '/dev/null');
    sleep $timeout;
    unless (-e $confirm) {
        $eng->revert_from($rollback);
    }
    exit 0;
}

sub print_help {
    print <<"HELP";
CSF 2.0 - cPanel Shield Firewall - v$VERSION

Usage: csf [option] [value]

  -a,  --add    ip [comment]     Allow an IP/CIDR and add to the allow list
  -ar, --addrm  ip               Remove an IP/CIDR from the allow list
  -d,  --deny   ip [comment]     Block an IP/CIDR and add to the deny list
  -dr, --denyrm ip               Unblock an IP/CIDR from the deny list
  -td, --tempdeny  ip [ttl] [c]  Temporarily block (ttl e.g. 3600, 30m, 2h, 1d)
  -ta, --tempallow ip [ttl]      Temporarily allow
  -tr, --temprm ip               Remove a temporary entry
  -tf, --tempf                   Flush all temporary entries
  -r,  --restart                 Rebuild + apply the firewall (atomic, guarded)
       --safe-reload             Reload with lockout protection (same as -r)
       --force                   Override lockout guard on reload (dangerous)
  -l,  --status                  Show firewall status summary
  -g,  --grep ip / inspect ip    Inspect the state of an IP
  -v,  --version                 Show version
  -h,  --help                    This help
       --selftest               Verify CSF is actually protecting the server
       --ti-update               Refresh Threat Intelligence feeds
       --ti-status               Show Threat Intelligence feed status
       --cf-enable / --cf-update Allowlist all Cloudflare edge IP ranges
       --cf-disable              Remove Cloudflare ranges from the allow list
       --self-update [--force]   Download + install the latest release from
                                 shield.underhost.com (SHA-256 verified)

Single-IP operations update one nftables set element and never rebuild the
ruleset. Temporary blocks use native nftables set timeouts.
HELP
}
