1022 lines
33 KiB
Bash
Executable File
1022 lines
33 KiB
Bash
Executable File
#!/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 <IP>}"
|
|
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 <command>
|
|
|
|
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 <IP> 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 <IP>"
|
|
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 "$@" |