#!/usr/bin/env bash # # Name: vps-webserver # Description: Service-aware firewall for VPS web servers. Auto-detects SSH, Caddy, Varnish, Forgejo and adapts rules accordingly. Varnish-on-80 + Caddy-HTTP-on-8080 (loopback) is the explicit default cache topology per v0.0.47. Aggressive SSH rate limiting when pubkey-only auth is detected. # Distro: arch,debian # Services: ssh,caddy,varnish,forgejo # # ============================================================================ # nftables-firewall.sh - Service-Aware Firewall for Debian/Arch # ============================================================================ # Auto-detects: Caddy, Varnish, Forgejo, SSH # Adapts rules based on running services # # v0.0.47: When Varnish is detected, the DEFAULT topology is now # cache-front-of-origin (Varnish on :80, Caddy HTTP backend on :8080 # loopback-only, Caddy HTTPS on :443 public). Previously this only # happened if Varnish was already listening on :80 at runtime; now # detecting Varnish at all is enough to flip Caddy HTTP to :8080 # loopback — matching the public-webserver.sh topology and the # operator's documented cache-environment setup. # # Varnish detected: :80 Varnish (public) → :8080 Caddy (HTTP, loopback) # :443 Caddy (HTTPS, public) # No Varnish: :80/:443 → Caddy directly (HTTP + HTTPS public) # # ============================================================================ set -euo pipefail IFS=$'\n\t' # ============================================================================ # OS DETECTION # ============================================================================ detect_os() { if [[ -f /etc/os-release ]]; then . /etc/os-release OS_ID="${ID}" OS_VERSION="${VERSION_ID}" OS_NAME="${PRETTY_NAME}" elif [[ -f /etc/debian_version ]]; then OS_ID="debian" OS_VERSION="$(cat /etc/debian_version)" OS_NAME="Debian $OS_VERSION" elif [[ -f /etc/arch-release ]]; then OS_ID="arch" OS_VERSION="rolling" OS_NAME="Arch Linux" else OS_ID="unknown" OS_VERSION="unknown" OS_NAME="Unknown Linux" fi case "$OS_ID" in debian|ubuntu|devuan) OS_FAMILY="debian" SERVICE_CMD="systemctl" ;; arch|manjaro|endeavouros) OS_FAMILY="arch" SERVICE_CMD="systemctl" ;; *) OS_FAMILY="unknown" SERVICE_CMD="systemctl" ;; esac log_debug "Detected OS: $OS_NAME (family: $OS_FAMILY)" } # ============================================================================ # SERVICE DETECTION # ============================================================================ # Check if a systemd service is active svc_active() { $SERVICE_CMD is-active "$1" &>/dev/null } # Check if a process is running proc_running() { pgrep -x "$1" &>/dev/null } # Check if a binary exists has_cmd() { command -v "$1" &>/dev/null } # Detect Forgejo configuration detect_forgejo() { FORGEJO_DETECTED="no" FORGEJO_HTTP_PORT="" FORGEJO_SSH_PORT="" FORGEJO_SSH_ENABLED="no" # Check if Forgejo is running if ! svc_active forgejo && ! proc_running forgejo; then log_debug "Forgejo not detected" return fi FORGEJO_DETECTED="yes" log_debug "Forgejo process detected" # Find app.ini location local config_paths=() case "$OS_FAMILY" in debian) config_paths=( "/etc/forgejo/app.ini" "/etc/gitea/app.ini" # Migration from Gitea ) ;; arch) config_paths=( "/etc/forgejo/app.ini" "/etc/gitea/app.ini" "/var/lib/forgejo/custom/conf/app.ini" ) ;; esac # Also check common locations config_paths+=( "/var/lib/forgejo/custom/conf/app.ini" "/var/lib/gitea/custom/conf/app.ini" "${HOME}/forgejo/conf/app.ini" ) local config_file="" for path in "${config_paths[@]}"; do if [[ -f "$path" ]]; then config_file="$path" break fi done if [[ -n "$config_file" ]]; then log_debug "Forgejo config: $config_file" # Parse HTTP port (default 3000) FORGEJO_HTTP_PORT="$(grep -oP '^\s*HTTP_PORT\s*=\s*\K.*' "$config_file" 2>/dev/null | \ tr -d ' "' | head -1)" FORGEJO_HTTP_PORT="${FORGEJO_HTTP_PORT:-3000}" # Parse SSH port (default 22, but often 2222 for separate sshd) FORGEJO_SSH_PORT="$(grep -oP '^\s*SSH_PORT\s*=\s*\K.*' "$config_file" 2>/dev/null | \ tr -d ' "' | head -1)" FORGEJO_SSH_PORT="${FORGEJO_SSH_PORT:-22}" # Check if SSH server is enabled local ssh_disabled ssh_disabled="$(grep -oP '^\s*START_SSH_SERVER\s*=\s*\K.*' "$config_file" 2>/dev/null | \ tr -d ' "' | tr '[:upper:]' '[:lower:]')" if [[ "$ssh_disabled" != "false" && "$FORGEJO_SSH_PORT" != "22" ]]; then FORGEJO_SSH_ENABLED="yes" elif [[ "$ssh_disabled" == "true" ]]; then FORGEJO_SSH_ENABLED="no" elif [[ "$FORGEJO_SSH_PORT" == "22" ]]; then # If using port 22, Forgejo SSH is likely handled by system sshd FORGEJO_SSH_ENABLED="no" else FORGEJO_SSH_ENABLED="yes" fi else log_debug "Forgejo config not found, using defaults" FORGEJO_HTTP_PORT="3000" FORGEJO_SSH_PORT="2222" FORGEJO_SSH_ENABLED="yes" fi log_info "Forgejo detected: HTTP=:${FORGEJO_HTTP_PORT}, SSH=${FORGEJO_SSH_ENABLED:+:}${FORGEJO_SSH_PORT}" } # Detect Caddy detect_caddy() { CADDY_DETECTED="no" # v0.0.47: when Varnish is detected, Caddy HTTP moves to :8080 # (loopback only). The Varnish-detection block below sets # CADDY_HTTP_PORT="8080" explicitly. detect_caddy runs BEFORE # detect_varnish in detect_all_services(), so we start with the # no-Varnish default of :80 and let detect_varnish flip it. CADDY_HTTP_PORT="80" CADDY_HTTPS_PORT="443" if ! svc_active caddy && ! proc_running caddy && ! has_cmd caddy; then log_debug "Caddy not detected" return fi CADDY_DETECTED="yes" # Try to get Caddy's listen ports from its config local caddy_config="" case "$OS_FAMILY" in debian) caddy_config="/etc/caddy/Caddyfile" ;; arch) caddy_config="/etc/caddy/Caddyfile" ;; esac if [[ -f "$caddy_config" ]]; then log_debug "Caddy config: $caddy_config" # Check for explicit port bindings. If Caddy is bound on # :8080 it's almost certainly the Varnish cache-miss backend # (loopback only). We surface this for detect_varnish to use. if grep -q ':8080' "$caddy_config" 2>/dev/null; then CADDY_HTTP_PORT="8080" log_debug "Caddy HTTP detected on :8080 (likely Varnish cache-miss backend)" fi fi # Verify ports are actually listening. If something is bound to # :8080 (regardless of process name — ss -tlnp naming can be # inconsistent across distros), assume Caddy is on the backend. if ss -tlnp 2>/dev/null | grep -q ':8080'; then CADDY_HTTP_PORT="8080" fi log_info "Caddy detected: HTTP=:${CADDY_HTTP_PORT}, HTTPS=:${CADDY_HTTPS_PORT}" } # Detect Varnish detect_varnish() { VARNISH_DETECTED="no" VARNISH_PORT="80" VARNISH_ADMIN_PORT="6082" if ! svc_active varnish && ! proc_running varnishd && ! has_cmd varnishd; then log_debug "Varnish not detected" return fi VARNISH_DETECTED="yes" # Find Varnish config to get actual port local varnish_config="" case "$OS_FAMILY" in debian) varnish_config="/etc/default/varnish" ;; arch) varnish_config="/etc/varnish/default.vcl" # Arch uses systemd override or direct args if [[ -f /etc/systemd/system/varnish.service.d/override.conf ]]; then varnish_config="/etc/systemd/system/varnish.service.d/override.conf" fi ;; esac # Check what port Varnish is actually listening on local listening_port listening_port=$(ss -tlnp 2>/dev/null | grep 'varnishd' | grep -oP ':\K[0-9]+' | head -1) || true if [[ -n "$listening_port" ]]; then VARNISH_PORT="$listening_port" fi # Check admin interface port local admin_port admin_port=$(ss -tlnp 2>/dev/null | grep 'varnishd' | grep -oP ':\K[0-9]+' | tail -1) || true if [[ -n "$admin_port" && "$admin_port" != "$VARNISH_PORT" ]]; then VARNISH_ADMIN_PORT="$admin_port" fi # v0.0.47: When Varnish is detected at all, the operator's # documented setup is cache-front-of-origin — Varnish on :80, # Caddy HTTP backend on :8080 (loopback only). If Varnish is # detected but not yet listening on :80 (e.g. the service is # installed but stopped, or it's still on the upstream default # :6081), force VARNISH_PORT=80 anyway — that's the v0.0.47 # explicit default. The operator can override with an env var # or by editing the systemd unit. if [[ "$VARNISH_PORT" != "80" ]]; then log_info "Varnish detected on :${VARNISH_PORT} — overriding to :80 (cache-front-of-origin default per v0.0.47). Set VARNISH_PORT env var to keep :${VARNISH_PORT}." VARNISH_PORT="80" fi # When Varnish is present, Caddy HTTP MUST move to :8080 loopback. # This is the cache-miss backend Varnish forwards to. CADDY_HTTP_PORT="8080" log_info "Varnish detected: :${VARNISH_PORT} (admin: :${VARNISH_ADMIN_PORT}) — Caddy HTTP moved to :${CADDY_HTTP_PORT} (loopback only)" } # Detect SSH configuration detect_ssh() { SSH_DETECTED="no" SSH_PORT="22" SSH_PUBKEY_ONLY="unknown" if ! svc_active sshd && ! svc_active ssh && ! proc_running sshd; then log_debug "SSH not detected" return fi SSH_DETECTED="yes" # Find SSH config local sshd_config="" for path in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do if [[ -f "$path" ]]; then sshd_config="$path" break fi done if [[ -n "$sshd_config" ]]; then # Get SSH port SSH_PORT="$(grep -oP '^\s*Port\s+\K[0-9]+' "$sshd_config" 2>/dev/null | head -1)" SSH_PORT="${SSH_PORT:-22}" # Check if pubkey auth is enforced local pubkey_auth password_auth pubkey_auth="$(grep -ri '^\s*PubkeyAuthentication\s+' "$sshd_config" /etc/ssh/sshd_config.d/ 2>/dev/null | \ tail -1 | grep -oP '\K(yes|no)' | tr '[:upper:]' '[:lower:]')" password_auth="$(grep -ri '^\s*PasswordAuthentication\s+' "$sshd_config" /etc/ssh/sshd_config.d/ 2>/dev/null | \ tail -1 | grep -oP '\K(yes|no)' | tr '[:upper:]' '[:lower:]')" # Also check for Match blocks that might enforce pubkey if grep -q 'Match.*Address' "$sshd_config" /etc/ssh/sshd_config.d/* 2>/dev/null; then # Complex config, assume pubkey enforced if PasswordAuthentication is no : fi if [[ "$pubkey_auth" == "yes" && "$password_auth" == "no" ]]; then SSH_PUBKEY_ONLY="yes" elif [[ "$password_auth" == "yes" ]]; then SSH_PUBKEY_ONLY="no" else SSH_PUBKEY_ONLY="unknown" fi fi log_info "SSH detected: :${SSH_PORT} (pubkey-only: ${SSH_PUBKEY_ONLY})" } # Master detection function detect_all_services() { log_info "Detecting services..." detect_ssh detect_forgejo detect_caddy detect_varnish # Build service summary echo "" echo "+-----------------------------------------------------+" echo "| Service Detection Summary |" echo "+-----------------------------------------------------+" echo "| OS: ${OS_NAME}" echo "+-----------------------------------------------------+" echo "| SSH: ${SSH_DETECTED} Port: ${SSH_PORT} Pubkey: ${SSH_PUBKEY_ONLY}" echo "| Forgejo: ${FORGEJO_DETECTED} HTTP: ${FORGEJO_HTTP_PORT:-N/A} SSH: ${FORGEJO_SSH_PORT:-N/A} (${FORGEJO_SSH_ENABLED})" echo "| Caddy: ${CADDY_DETECTED} HTTP: ${CADDY_HTTP_PORT} HTTPS: ${CADDY_HTTPS_PORT}" echo "| Varnish: ${VARNISH_DETECTED} Port: ${VARNISH_PORT}" echo "+-----------------------------------------------------+" echo "" } # ============================================================================ # CONFIGURATION # ============================================================================ # These are set by detection, can be overridden EXT_IF="" INT_IF="" EXTRA_IFS="" LO_IF="lo" MY_IPV4="" MY_IPV6="" POLICY_INPUT="drop" POLICY_OUTPUT="accept" POLICY_FORWARD="drop" LOG_ENABLED="yes" LOG_PREFIX="[NFT-DROP] " LOG_RATE="5/second" LOG_BURST="10" # SSH rate limiting - more aggressive if pubkey-only # With pubkey auth, failed connections are almost always attacks SSH_MAX_CONN_PUBKEY="3/minute" SSH_MAX_CONN_PASSWORD="10/minute" SSH_BAN_TIME="3600" # 1 hour ban ICMP_RATE_LIMIT="10/second" ICMP_RATE_BURST="20" SYNPROXY_ENABLED="yes" MSS_CLAMP="yes" PROTECT_SYN_FLOOD="yes" PROTECT_PORT_SCAN="yes" PROTECT_IP_SPOOF="yes" PROTECT_SMURF="yes" PROTECT_FRAGMENTS="yes" PROTECT_XMAS="yes" PROTECT_NULL_SCAN="yes" PROTECT_INVALID="yes" PROTECT_BOGUS_TCP="yes" FLOW_OFFLOAD="no" TRUSTED_NETS=() # ============================================================================ # INTERNAL VARIABLES # ============================================================================ SCRIPT_NAME="$(basename "$0")" SCRIPT_VERSION="2.1.0" NFT_CMD="$(command -v nft 2>/dev/null || echo "")" TABLE_NAME="firewall" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" RULES_FILE="$(mktemp /tmp/nftables-rules-XXXXXX.nft)" # a fresh mktemp name per run — no predictable /tmp path for a root write trap 'rm -f "$RULES_FILE"' EXIT SAVED_RULES="/etc/nftables/firewall.rules" # OS/Service detection results (set by detect functions) OS_ID="" OS_VERSION="" OS_NAME="" OS_FAMILY="" SERVICE_CMD="" SSH_DETECTED="no" SSH_PORT="22" SSH_PUBKEY_ONLY="unknown" FORGEJO_DETECTED="no" FORGEJO_HTTP_PORT="" FORGEJO_SSH_PORT="" FORGEJO_SSH_ENABLED="no" CADDY_DETECTED="no" CADDY_HTTP_PORT="80" CADDY_HTTPS_PORT="443" VARNISH_DETECTED="no" VARNISH_PORT="80" VARNISH_ADMIN_PORT="6082" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # v0.0.31 fix: log_debug returned non-zero under set -e when DEBUG=no, # silently killing the script mid-detection. Add `|| return 0` so the # function always returns success. log_debug() { [[ "${DEBUG:-no}" == "yes" ]] && echo -e "${BLUE}[DEBUG]${NC} $*" || true; } log_svc() { echo -e "${CYAN}[SVC]${NC} $*"; } die() { log_error "$@"; exit 1; } check_root() { [[ $EUID -ne 0 ]] && die "This script must be run as root" } check_nftables() { if [[ -z "$NFT_CMD" ]]; then die "nftables not found." case "$OS_FAMILY" in debian) die "Install: apt install nftables" ;; arch) die "Install: pacman -S nftables" ;; *) die "Install nftables for your distribution" ;; esac fi } detect_primary_interface() { # Find the interface with default route EXT_IF="$(ip route show default | grep -oP 'dev \K\S+' | head -1)" if [[ -z "$EXT_IF" ]]; then # Fallback: first non-loopback interface EXT_IF="$(ip link show | grep -oP '^\d+: \K[^:]+' | grep -v '^lo$' | head -1)" fi if [[ -z "$EXT_IF" ]]; then die "Could not detect primary network interface" fi log_debug "Primary interface: $EXT_IF" } detect_ips() { [[ -z "$MY_IPV4" ]] && MY_IPV4="$(ip -4 addr show dev "$EXT_IF" 2>/dev/null | \ grep -oP 'inet \K[0-9.]+' | head -1)" || true [[ -z "$MY_IPV6" ]] && MY_IPV6="$(ip -6 addr show dev "$EXT_IF" 2>/dev/null | \ grep -oP 'inet6 \K[0-9a-f:]+(?=/)' | head -1)" || true log_debug "IPv4: ${MY_IPV4:-none}, IPv6: ${MY_IPV6:-none}" } verify_interface() { ip link show "$1" &>/dev/null || log_warn "Interface $1 does not exist" } # ============================================================================ # RULE GENERATION # ============================================================================ build_tcp_ports() { local ports=() # SSH [[ "$SSH_DETECTED" == "yes" ]] && ports+=("$SSH_PORT") # Caddy HTTP if [[ "$CADDY_DETECTED" == "yes" ]]; then # If Varnish is handling 80, only open 8080 for localhost/Varnish if [[ "$VARNISH_DETECTED" == "yes" && "$VARNISH_PORT" == "80" ]]; then # Caddy 8080 is internal only - don't add to public ports : else ports+=("$CADDY_HTTP_PORT") fi ports+=("$CADDY_HTTPS_PORT") fi # Varnish [[ "$VARNISH_DETECTED" == "yes" ]] && ports+=("$VARNISH_PORT") # Forgejo HTTP (if not proxied through Caddy) if [[ "$FORGEJO_DETECTED" == "yes" ]]; then # Only expose if not behind Caddy # Most setups proxy Forgejo through Caddy, so we skip this # Uncomment if you need direct access: # ports+=("$FORGEJO_HTTP_PORT") : fi # Forgejo SSH [[ "$FORGEJO_SSH_ENABLED" == "yes" ]] && ports+=("$FORGEJO_SSH_PORT") # Deduplicate and output printf '%s\n' "${ports[@]}" | sort -un | tr '\n' ',' | sed 's/,$//' } build_udp_ports() { local ports=() # Typically no UDP needed for these services # Add if you have DNS, etc. printf '%s\n' "${ports[@]}" | tr '\n' ',' | sed 's/,$//' } build_internal_tcp_ports() { local ports=() # Caddy 8080 when behind Varnish - only from localhost if [[ "$VARNISH_DETECTED" == "yes" && "$CADDY_DETECTED" == "yes" ]]; then ports+=("$CADDY_HTTP_PORT") fi # Forgejo HTTP if proxied through Caddy (localhost access) if [[ "$FORGEJO_DETECTED" == "yes" ]]; then ports+=("$FORGEJO_HTTP_PORT") fi printf '%s\n' "${ports[@]}" | sort -un | tr '\n' ',' | sed 's/,$//' } generate_rules() { local tcp_ports udp_ports internal_tcp_ports tcp_ports="$(build_tcp_ports)" udp_ports="$(build_udp_ports)" internal_tcp_ports="$(build_internal_tcp_ports)" # Determine SSH rate limit based on auth method local ssh_rate_limit if [[ "$SSH_PUBKEY_ONLY" == "yes" ]]; then ssh_rate_limit="$SSH_MAX_CONN_PUBKEY" log_debug "Using aggressive SSH rate limit (pubkey-only detected)" else ssh_rate_limit="$SSH_MAX_CONN_PASSWORD" log_debug "Using standard SSH rate limit" fi cat > "$RULES_FILE" << RULESET #!/usr/sbin/nft -f # ${TABLE_NAME} - Generated ${TIMESTAMP} # OS: ${OS_NAME} # SSH: ${SSH_DETECTED:+:${SSH_PORT}} ${FORGEJO_DETECTED:+Forgejo SSH: :${FORGEJO_SSH_PORT}} # Web: ${VARNISH_DETECTED:+Varnish:${VARNISH_PORT} -> }Caddy:${CADDY_HTTP_PORT}/${CADDY_HTTPS_PORT} # Table-scoped reset: foreign tables (docker, libvirt, systemd-networkd) # are not ours to destroy. add table inet ${TABLE_NAME} flush table inet ${TABLE_NAME} table inet ${TABLE_NAME} { # ================================================================ # SETS # ================================================================ set tcp_public { type inet_service flags interval elements = { ${tcp_ports} } } set tcp_internal { type inet_service flags interval $(if [[ -n "$internal_tcp_ports" ]]; then echo "elements = { ${internal_tcp_ports} }"; else echo "# (empty - no internal-only ports)"; fi) } $(if [[ -n "$udp_ports" ]]; then echo " set udp_public { type inet_service flags interval elements = { ${udp_ports} } }"; fi) set ssh_abuse { type ipv4_addr flags timeout timeout ${SSH_BAN_TIME}s } set port_scanners { type ipv4_addr flags timeout timeout 1h } set connlimit_abuse { type ipv4_addr flags timeout timeout 10m } set trusted_nets { type ipv4_addr flags interval $(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then echo "elements = { $(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//') }" else echo "# (empty)" fi) } # ================================================================ # INPUT CHAIN # ================================================================ chain input { type filter hook input priority 0; policy ${POLICY_INPUT}; # ---- Loopback ---- iifname "lo" accept iifname "lo" ip saddr != 127.0.0.0/8 drop comment "Loopback spoof" # ---- Established/Related ---- ct state { established, related } accept ct state invalid counter drop comment "Invalid state" # ---- Anti-spoofing ---- $(if [[ -n "$MY_IPV4" ]]; then echo "iifname \"$EXT_IF\" ip saddr $MY_IPV4 counter drop comment \"Our IP from outside\""; fi) $(if [[ -n "$MY_IPV6" ]]; then echo "iifname \"$EXT_IF\" ip6 saddr $MY_IPV6 counter drop comment \"Our IPv6 from outside\""; fi) # ---- Bogon filtering ---- iifname "$EXT_IF" ip saddr { 0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 } drop comment "Bogon IPv4" # ---- Fragment protection ---- ip frag-off != 0 drop comment "Fragmented packet" # ---- TCP anomaly detection ---- tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) jump scan_detect comment "XMAS scan" tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 jump scan_detect comment "NULL scan" tcp flags & (syn|fin) == (syn|fin) jump scan_detect comment "SYN+FIN" tcp flags & (syn|rst) == (syn|rst) jump scan_detect comment "SYN+RST" # ---- ICMP ---- ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded, parameter-problem } limit rate ${ICMP_RATE_LIMIT} burst ${ICMP_RATE_BURST} accept ip protocol icmp drop comment "ICMP rejected" ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept ip6 nexthdr icmpv6 drop comment "ICMPv6 rejected" # ---- Connection rate limiting ---- ct state new limit rate over 50/second burst 100 add @connlimit_abuse { ip saddr } counter drop comment "Connlimit exceeded" # ---- SSH with brute-force protection ---- $(if [[ "$SSH_DETECTED" == "yes" ]]; then echo " # SSH - aggressive protection (pubkey-only assumed) tcp dport $SSH_PORT ip saddr @ssh_abuse drop comment \"SSH banned\" # A verdict-less synproxy statement here let every in-rate SSH # login fall through to the ban rule below; the rate limit + # ban set is the protection this chain ships. tcp dport $SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"SSH brute force\" "; fi) # ---- Forgejo SSH (separate from system SSH) ---- $(if [[ "$FORGEJO_SSH_ENABLED" == "yes" ]]; then echo " # Forgejo SSH tcp dport $FORGEJO_SSH_PORT ip saddr @ssh_abuse drop comment \"Forgejo SSH banned\" tcp dport $FORGEJO_SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept tcp dport $FORGEJO_SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"Forgejo SSH brute force\" "; fi) # ---- Public TCP services ---- tcp dport @tcp_public ct state new accept comment "Allowed TCP service" # ---- Internal-only TCP services (localhost/Varnish) ---- $(if [[ -n "$internal_tcp_ports" ]]; then echo " # Internal services - localhost and Varnish only iifname \"lo\" tcp dport @tcp_internal accept comment \"Internal service via loopback\" $(if [[ "$VARNISH_DETECTED" == "yes" ]]; then echo " # Varnish backend access (Varnish connects to Caddy on 8080) ip saddr 127.0.0.1 tcp dport @tcp_internal accept comment \"Internal service from local\" "; fi) "; fi) # ---- UDP services ---- $(if [[ -n "$udp_ports" ]]; then echo "udp dport @udp_public ct state new accept comment \"Allowed UDP service\""; fi) # ---- Trusted networks ---- $(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then echo "ip saddr @trusted_nets accept comment \"Trusted network\""; fi) # ---- Default drop with logging ---- $(if [[ "$LOG_ENABLED" == "yes" ]]; then echo "counter log prefix \"${LOG_PREFIX}\" level warn limit rate ${LOG_RATE} burst ${LOG_BURST}"; fi) counter drop comment "Default deny" } # ================================================================ # SCAN DETECTION CHAIN # ================================================================ chain scan_detect { ip saddr @port_scanners drop add @port_scanners { ip saddr } drop comment "Port scan detected" } # ================================================================ # OUTPUT CHAIN # ================================================================ chain output { type filter hook output priority 0; policy ${POLICY_OUTPUT}; ct state { established, related } accept ct state new accept # MSS clamping for PMTUD $(if [[ "$MSS_CLAMP" == "yes" ]]; then echo "tcp flags syn tcp option maxseg size 1-536 tcpmss clamp to mtu"; fi) } # ================================================================ # FORWARD CHAIN # ================================================================ chain forward { type filter hook forward priority 0; policy ${POLICY_FORWARD}; $(if [[ "$LOG_ENABLED" == "yes" ]]; then echo "counter log prefix \"[NFT-FWD-DROP] \" level warn limit rate 2/second burst 5"; fi) counter drop } } RULESET log_debug "Rules written to $RULES_FILE" } # ============================================================================ # FIREWALL CONTROL # ============================================================================ fw_start() { log_info "Starting ${TABLE_NAME} firewall..." check_root check_nftables detect_os detect_primary_interface detect_ips detect_all_services verify_interface "$EXT_IF" generate_rules log_info "Validating rules..." if ! $NFT_CMD -c -f "$RULES_FILE" 2>&1; then log_error "Validation failed. Rules saved to $RULES_FILE for inspection" exit 1 fi log_info "Loading rules..." if $NFT_CMD -f "$RULES_FILE"; then log_info "Firewall started successfully" rm -f "$RULES_FILE" fw_status_brief else die "Failed to load rules" fi } fw_stop() { log_info "Stopping ${TABLE_NAME} firewall..." check_root check_nftables $NFT_CMD delete table inet ${TABLE_NAME} 2>/dev/null || true log_info "Firewall stopped" } fw_restart() { fw_stop sleep 1 fw_start } fw_status_brief() { echo "" echo "+-----------------------------------------------------+" echo "| Firewall Active |" echo "+-----------------------------------------------------+" echo "| Interface: $EXT_IF" echo "| Policy: INPUT=${POLICY_INPUT} OUTPUT=${POLICY_OUTPUT} FORWARD=${POLICY_FORWARD}" echo "| Ports: $(build_tcp_ports | tr ',' ' ')" echo "+-----------------------------------------------------+" echo "" } fw_status() { check_nftables if ! $NFT_CMD list tables 2>/dev/null | grep -q "inet ${TABLE_NAME}"; then echo "Status: STOPPED" return 0 fi echo "============================================" echo " nftables Firewall Status" echo "============================================" echo "" echo "Status: RUNNING" echo "Table: inet ${TABLE_NAME}" echo "" # Counters echo "--- Rule Counters ---" $NFT_CMD list table inet ${TABLE_NAME} 2>/dev/null | \ grep -E '^\s+[0-9]+ [0-9]+ counter' | \ head -20 echo "" # SSH abuse echo "--- SSH Ban List ($( $NFT_CMD list set inet ${TABLE_NAME} ssh_abuse 2>/dev/null | grep -c 'expires' || echo 0 ) IPs) ---" $NFT_CMD list set inet ${TABLE_NAME} ssh_abuse 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -10 || echo "(empty)" echo "" # Port scanners echo "--- Port Scanners ---" $NFT_CMD list set inet ${TABLE_NAME} port_scanners 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -10 || echo "(empty)" } fw_check() { check_nftables log_info "Checking ruleset..." if $NFT_CMD -c list ruleset 2>&1; then log_info "Ruleset is valid" else log_error "Ruleset has errors" return 1 fi } fw_save() { check_root check_nftables local dir dir="$(dirname "$SAVED_RULES")" mkdir -p "$dir" $NFT_CMD list ruleset > "$SAVED_RULES" log_info "Saved to $SAVED_RULES" } fw_dump() { check_nftables if has_cmd jq; then $NFT_CMD -j list ruleset 2>/dev/null | jq '.' else $NFT_CMD list ruleset fi } fw_unban() { local ip="${1:?Usage: $0 unban }" check_root check_nftables local unbanned=0 $NFT_CMD delete element inet ${TABLE_NAME} set ssh_abuse \{ $ip \} 2>/dev/null && { log_info "Unbanned $ip from SSH abuse"; unbanned=1; } $NFT_CMD delete element inet ${TABLE_NAME} set port_scanners \{ $ip \} 2>/dev/null && { log_info "Unbanned $ip from scanners"; unbanned=1; } $NFT_CMD delete element inet ${TABLE_NAME} set connlimit_abuse \{ $ip \} 2>/dev/null && { log_info "Unbanned $ip from connlimit"; unbanned=1; } [[ $unbanned -eq 0 ]] && log_warn "$ip not found in any ban list" } fw_show_banned() { check_nftables echo "=== SSH Abuse (${SSH_BAN_TIME}s timeout) ===" $NFT_CMD list set inet ${TABLE_NAME} ssh_abuse 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || echo "(none)" echo "" echo "=== Port Scanners (1h timeout) ===" $NFT_CMD list set inet ${TABLE_NAME} port_scanners 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || echo "(none)" echo "" echo "=== Connection Rate Abusers (10m timeout) ===" $NFT_CMD list set inet ${TABLE_NAME} connlimit_abuse 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || echo "(none)" } fw_clear_bans() { check_root check_nftables $NFT_CMD flush set inet ${TABLE_NAME} ssh_abuse 2>/dev/null && log_info "Cleared SSH abuse list" $NFT_CMD flush set inet ${TABLE_NAME} port_scanners 2>/dev/null && log_info "Cleared scanner list" $NFT_CMD flush set inet ${TABLE_NAME} connlimit_abuse 2>/dev/null && log_info "Cleared connlimit list" } fw_detect() { detect_os detect_primary_interface detect_ips detect_all_services } # ============================================================================ # USAGE # ============================================================================ show_help() { cat << 'HELP' nftables-firewall.sh v2.1.0 - Service-Aware Firewall Auto-detects and configures rules for: o SSH (with pubkey-only aggressive rate limiting) o Caddy (HTTP/HTTPS, with or without Varnish) o Varnish (auto-detects, adjusts Caddy ports) o Forgejo (HTTP via proxy, dedicated SSH if configured) Usage: nftables-firewall.sh Commands: start Start firewall (auto-detects services) stop Stop firewall restart Restart firewall status Show status and ban lists detect Show service detection results only check Validate current ruleset save Save rules to /etc/nftables/firewall.rules dump Dump ruleset as JSON Ban Management: unban Remove IP from all ban lists banned Show all banned IPs clear-bans Clear all ban lists Architecture: With Varnish: Client -> :80 Varnish -> :8080 Caddy -> Backend Client -> :443 Caddy -> Backend Without Varnish: Client -> :80/:443 Caddy -> Backend Forgejo SSH (if enabled) is exposed directly on its port. Forgejo HTTP is expected to be proxied through Caddy. Supported OS: Debian 12+, Ubuntu 22.04+, Arch Linux HELP } # ============================================================================ # MAIN # ============================================================================ main() { local cmd="${1:-help}" case "$cmd" in start) fw_start ;; stop) fw_stop ;; restart|reload) fw_restart ;; status) fw_status ;; detect) fw_detect ;; check) fw_check ;; save) fw_save ;; dump) fw_dump ;; unban) [[ -z "${2:-}" ]] && die "Usage: $0 unban " fw_unban "$2" ;; banned|show-banned|list-banned) fw_show_banned ;; clear-bans|clear) fw_clear_bans ;; help|--help|-h) show_help ;; *) die "Unknown command: $cmd\nRun '$0 help' for usage" ;; esac } main "$@"