1025 lines
30 KiB
Bash
Executable File
1025 lines
30 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Name: no-services
|
|
# Description: Locked-down host firewall with no public services except SSH. Modern nftables syntax with synproxy, scan detection, bogon filtering, fragment protection. Designed for eBPF-capable kernels.
|
|
# Distro: arch,debian
|
|
# Services: ssh
|
|
#
|
|
# ============================================================================
|
|
# nftables-firewall.sh - Modern Linux Firewall for Kernel 7.x
|
|
# ============================================================================
|
|
# Replaces legacy iptables scripts with nftables native syntax
|
|
# Designed for eBPF-capable kernels with hardened configurations
|
|
#
|
|
# Features:
|
|
# - Unified IPv4/IPv6 via inet family (no separate ip/ip6 tables)
|
|
# - Sets for efficient O(1) lookups instead of linear rule chains
|
|
# - Verdict maps for service routing
|
|
# - Named counters for per-rule statistics
|
|
# - Stateful connection tracking with modern helpers
|
|
# - Synproxy for TCP SYN flood protection
|
|
# - Rate limiting with dynamic set population
|
|
# - Flow offload support for hardware acceleration
|
|
# - XDP integration points (load separately via bpftool)
|
|
# - No legacy iptables/arptables/ebtables compatibility layer
|
|
#
|
|
# Requirements:
|
|
# - Linux kernel 6.6+ / 7.x with CONFIG_NF_TABLES=y
|
|
# - nftables >= 1.0.0
|
|
# - libnftables with JSON support
|
|
#
|
|
# Usage:
|
|
# ./nftables-firewall.sh [start|stop|restart|status|check|save|dump]
|
|
#
|
|
# ============================================================================
|
|
|
|
set -euo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION - Edit these variables for your environment
|
|
# ============================================================================
|
|
|
|
# --- Network Interfaces ---
|
|
# Primary external interface (WAN)
|
|
EXT_IF="eth0"
|
|
# Internal/LAN interface (set empty if single-homed)
|
|
INT_IF="eth1"
|
|
# Additional interfaces (space-separated)
|
|
EXTRA_IFS=""
|
|
# Loopback (do not change unless you know what you're doing)
|
|
LO_IF="lo"
|
|
|
|
# --- Host Identity ---
|
|
# Set to your server's primary IPs to prevent spoofing
|
|
# Leave empty to auto-detect
|
|
MY_IPV4=""
|
|
MY_IPV6=""
|
|
|
|
# --- Default Policies ---
|
|
# Options: accept, drop, reject
|
|
POLICY_INPUT="drop"
|
|
POLICY_OUTPUT="accept"
|
|
POLICY_FORWARD="drop"
|
|
|
|
# --- Logging ---
|
|
# Enable detailed logging (adds performance overhead)
|
|
LOG_ENABLED="yes"
|
|
# Log prefix for dropped packets
|
|
LOG_PREFIX="[NFT-DROP] "
|
|
# Log prefix for accepted packets (debug mode)
|
|
LOG_ACCEPT_PREFIX="[NFT-ACCEPT] "
|
|
# Rate limit for logs (per second, 0 = unlimited)
|
|
LOG_RATE="5/second"
|
|
# Log burst
|
|
LOG_BURST="10"
|
|
|
|
# --- SSH Configuration ---
|
|
SSH_PORT="22"
|
|
SSH_ENABLED="yes"
|
|
# Max connection attempts per minute before temporary ban
|
|
SSH_MAX_CONN="4/minute"
|
|
# Ban duration in seconds (0 = permanent until restart)
|
|
SSH_BAN_TIME="300"
|
|
|
|
# --- Allowed TCP Services ---
|
|
# Format: "port" or "port:interface" for interface-specific rules
|
|
# Contract: no public services except SSH. Operators who need a
|
|
# web tier use the public-webserver or vps-webserver template.
|
|
TCP_SERVICES=(
|
|
"${SSH_PORT}"
|
|
)
|
|
|
|
# --- Allowed UDP Services ---
|
|
UDP_SERVICES=(
|
|
"53" # DNS (if running nameserver)
|
|
)
|
|
|
|
# --- Allowed ICMP Types ---
|
|
# Numeric types for inet family
|
|
ICMP_ALLOWED=(
|
|
"echo-request" # ping
|
|
"echo-reply" # pong
|
|
"destination-unreachable"
|
|
"time-exceeded" # traceroute
|
|
"parameter-problem"
|
|
)
|
|
|
|
# --- ICMPv6 Specific (additional to above) ---
|
|
ICMPV6_ALLOWED=(
|
|
"nd-router-solicit"
|
|
"nd-router-advert"
|
|
"nd-neighbor-solicit"
|
|
"nd-neighbor-advert"
|
|
"packet-too-big"
|
|
)
|
|
|
|
# --- Rate Limiting ---
|
|
# General new connection rate limit
|
|
CONN_RATE_LIMIT="50/second"
|
|
CONN_RATE_BURST="100"
|
|
# ICMP rate limit
|
|
ICMP_RATE_LIMIT="10/second"
|
|
ICMP_RATE_BURST="20"
|
|
# DNS rate limit (prevent amplification abuse)
|
|
DNS_RATE_LIMIT="20/second"
|
|
DNS_RATE_BURST="40"
|
|
|
|
# --- TCP Hardening ---
|
|
# Enable TCP SYNPROXY (requires SYNPROXY target)
|
|
SYNPROXY_ENABLED="yes"
|
|
# MSS clamping for Path MTU Discovery
|
|
MSS_CLAMP="yes"
|
|
# MSS value (0 = auto-detect from interface MTU)
|
|
MSS_VALUE="0"
|
|
# Drop invalid TCP flag combinations
|
|
TCP_FLAGS_STRICT="yes"
|
|
# Enable window tracking
|
|
TCP_WINDOW_TRACK="yes"
|
|
|
|
# --- Protection Toggles ---
|
|
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"
|
|
|
|
# --- NAT/Masquerade ---
|
|
# Enable NAT masquerade for outbound traffic (router mode)
|
|
NAT_ENABLED="no"
|
|
# NAT source interface
|
|
NAT_SRC_IF="$INT_IF"
|
|
# NAT outbound interface
|
|
NAT_OUT_IF="$EXT_IF"
|
|
|
|
# --- Port Forwarding ---
|
|
# Format: "ext_port:proto:int_ip:int_port"
|
|
# Example: "8080:tcp:192.168.1.10:80"
|
|
PORT_FORWARDS=()
|
|
|
|
# --- Flow Offload ---
|
|
# Enable hardware flow offloading (if supported)
|
|
FLOW_OFFLOAD="no"
|
|
|
|
# --- Trusted Networks ---
|
|
# These bypass most restrictions
|
|
TRUSTED_NETS=(
|
|
# "192.168.1.0/24"
|
|
# "10.0.0.0/8"
|
|
)
|
|
|
|
# --- Blocked Networks ---
|
|
# Known bad networks, bogons, etc.
|
|
BLOCKED_NETS=(
|
|
"0.0.0.0/8" # Current network
|
|
"10.0.0.0/8" # Private (allow if you use it)
|
|
"127.0.0.0/8" # Loopback
|
|
"169.254.0.0/16" # Link-local
|
|
"172.16.0.0/12" # Private
|
|
"192.0.0.0/24" # IETF Protocol Assignments
|
|
"192.0.2.0/24" # Documentation
|
|
"192.88.99.0/24" # IPv6 to IPv4 relay
|
|
"192.168.0.0/16" # Private (allow if you use it)
|
|
"198.18.0.0/15" # Benchmark testing
|
|
"198.51.100.0/24" # Documentation
|
|
"203.0.113.0/24" # Documentation
|
|
"224.0.0.0/4" # Multicast
|
|
"240.0.0.0/4" # Reserved
|
|
"255.255.255.255/32" # Broadcast
|
|
)
|
|
|
|
# ============================================================================
|
|
# INTERNAL VARIABLES - Do not modify
|
|
# ============================================================================
|
|
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
SCRIPT_VERSION="2.0.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"
|
|
|
|
# Colors for output
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# ============================================================================
|
|
# 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
|
|
}
|
|
|
|
log_debug() {
|
|
if [[ "${DEBUG:-no}" == "yes" ]]; then
|
|
echo -e "${BLUE}[DEBUG]${NC} $*"
|
|
fi
|
|
}
|
|
|
|
die() {
|
|
log_error "$@"
|
|
exit 1
|
|
}
|
|
|
|
# Check if running as root
|
|
check_root() {
|
|
if [[ $EUID -ne 0 ]]; then
|
|
die "This script must be run as root"
|
|
fi
|
|
}
|
|
|
|
# Check for nftables
|
|
check_nftables() {
|
|
if [[ -z "$NFT_CMD" ]]; then
|
|
die "nftables not found. Install with: apt install nftables / dnf install nftables"
|
|
fi
|
|
|
|
local nft_ver
|
|
nft_ver=$($NFT_CMD -v | grep -oP 'nftables v\K[0-9.]+')
|
|
log_debug "nftables version: $nft_ver"
|
|
}
|
|
|
|
# Verify interface exists
|
|
verify_interface() {
|
|
local iface="$1"
|
|
if ! ip link show "$iface" &>/dev/null; then
|
|
log_warn "Interface $iface does not exist - rules will still be created"
|
|
fi
|
|
}
|
|
|
|
# Auto-detect IPs if not set
|
|
detect_ips() {
|
|
if [[ -z "$MY_IPV4" ]]; then
|
|
MY_IPV4=$(ip -4 addr show dev "$EXT_IF" 2>/dev/null | \
|
|
grep -oP 'inet \K[0-9.]+' | head -1) || true
|
|
fi
|
|
if [[ -z "$MY_IPV6" ]]; then
|
|
MY_IPV6=$(ip -6 addr show dev "$EXT_IF" 2>/dev/null | \
|
|
grep -oP 'inet6 \K[0-9a-f:]+(?=/)' | head -1) || true
|
|
fi
|
|
log_debug "Detected IPv4: ${MY_IPV4:-none}"
|
|
log_debug "Detected IPv6: ${MY_IPV6:-none}"
|
|
}
|
|
|
|
# Build interface list
|
|
get_all_interfaces() {
|
|
local ifs="$EXT_IF"
|
|
[[ -n "$INT_IF" ]] && ifs="$ifs $INT_IF"
|
|
[[ -n "$EXTRA_IFS" ]] && ifs="$ifs $EXTRA_IFS"
|
|
echo "$ifs"
|
|
}
|
|
|
|
# ============================================================================
|
|
# RULE GENERATION
|
|
# ============================================================================
|
|
|
|
generate_rules() {
|
|
local all_ifs
|
|
all_ifs=$(get_all_interfaces)
|
|
|
|
# Start with table definition
|
|
cat > "$RULES_FILE" << TABLE_HEADER
|
|
#!/usr/sbin/nft -f
|
|
|
|
# ============================================================================
|
|
# Auto-generated nftables rules - DO NOT EDIT MANUALLY
|
|
# Generated by: nftables-firewall.sh
|
|
# ============================================================================
|
|
|
|
# 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_HEADER
|
|
|
|
# --- Main inet table (IPv4 + IPv6 unified) ---
|
|
cat >> "$RULES_FILE" << INET_TABLE
|
|
table inet ${TABLE_NAME} {
|
|
|
|
# ----------------------------------------------------------------
|
|
# SETS - Efficient O(1) lookups
|
|
# ----------------------------------------------------------------
|
|
|
|
# Blocked networks (bogons, reserved, etc.)
|
|
set blocked_nets {
|
|
type ipv4_addr
|
|
flags interval, timeout
|
|
elements = { $(printf '%s, ' "${BLOCKED_NETS[@]}" | sed 's/, $//') }
|
|
}
|
|
|
|
# Trusted networks
|
|
$(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then
|
|
printf 'set trusted_nets {\n'
|
|
printf ' type ipv4_addr\n'
|
|
printf ' flags interval\n'
|
|
printf ' elements = { %s }\n' "$(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//')"
|
|
printf '}\n'
|
|
else
|
|
printf 'set trusted_nets { type ipv4_addr; }\n'
|
|
fi)
|
|
|
|
# TCP ports to allow
|
|
set tcp_allowed {
|
|
type inet_service
|
|
flags interval
|
|
elements = { $(parse_tcp_services) }
|
|
}
|
|
|
|
# UDP ports to allow
|
|
set udp_allowed {
|
|
type inet_service
|
|
flags interval
|
|
elements = { $(printf '%s, ' "${UDP_SERVICES[@]}" | sed 's/, $//') }
|
|
}
|
|
|
|
# Allowed ICMP types
|
|
set icmp_allowed {
|
|
type icmp_type
|
|
elements = { $(printf '%s, ' "${ICMP_ALLOWED[@]}" | sed 's/, $//') }
|
|
}
|
|
|
|
# Allowed ICMPv6 types
|
|
set icmpv6_allowed {
|
|
type icmpv6_type
|
|
elements = { $(printf '%s, ' "${ICMPV6_ALLOWED[@]}" | sed 's/, $//') }
|
|
}
|
|
|
|
# SSH brute force attackers (dynamic, with timeout)
|
|
set ssh_abuse {
|
|
type ipv4_addr
|
|
flags timeout
|
|
timeout ${SSH_BAN_TIME}s
|
|
}
|
|
|
|
# Port scanners (dynamic)
|
|
set port_scanners {
|
|
type ipv4_addr
|
|
flags timeout
|
|
timeout 1h
|
|
}
|
|
|
|
# Rate-limited connections
|
|
set connlimit_abuse {
|
|
type ipv4_addr
|
|
flags timeout
|
|
timeout 10m
|
|
}
|
|
|
|
INET_TABLE
|
|
|
|
# --- Verdict map for interface-based policies ---
|
|
cat >> "$RULES_FILE" << VERDICT_MAP
|
|
# Verdict map: interface -> policy (allows per-interface overrides)
|
|
map iface_input_policy {
|
|
type ifname : verdict
|
|
elements = {
|
|
VERDICT_MAP
|
|
|
|
# Add policies for each interface
|
|
for iface in $all_ifs; do
|
|
cat >> "$RULES_FILE" << IFACE_POLICY
|
|
"$iface" : jump input_${iface},
|
|
IFACE_POLICY
|
|
done
|
|
|
|
cat >> "$RULES_FILE" << VERDICT_MAP_END
|
|
}
|
|
}
|
|
VERDICT_MAP_END
|
|
|
|
# --- Counter map for statistics ---
|
|
cat >> "$RULES_FILE" << COUNTER_MAP
|
|
# Per-interface packet counters
|
|
map iface_counters {
|
|
type ifname : counter
|
|
}
|
|
|
|
# Per-service counters
|
|
map service_counters {
|
|
type inet_service : counter
|
|
}
|
|
COUNTER_MAP
|
|
|
|
# ----------------------------------------------------------------
|
|
# CHAINS
|
|
# ----------------------------------------------------------------
|
|
|
|
# --- Input Chain (main entry point) ---
|
|
cat >> "$RULES_FILE" << INPUT_CHAIN
|
|
chain input {
|
|
type filter hook input priority 0; policy ${POLICY_INPUT};
|
|
|
|
# Loopback first, unconditionally: local IPC (DNS, databases,
|
|
# graphical sessions) must never ride the interface verdict map.
|
|
iifname "$LO_IF" accept
|
|
|
|
# Update interface counters
|
|
meta iifname @iface_input_policy counter name @iface_counters
|
|
|
|
# Jump to interface-specific handling
|
|
meta iifname @iface_input_policy
|
|
}
|
|
INPUT_CHAIN
|
|
|
|
# --- Loopback Chain ---
|
|
cat >> "$RULES_FILE" << LO_CHAIN
|
|
chain input_lo {
|
|
# Allow all loopback traffic
|
|
iifname "$LO_IF" accept
|
|
}
|
|
LO_CHAIN
|
|
|
|
# --- Global Pre-filter Chain ---
|
|
cat >> "$RULES_FILE" << PREFILTER
|
|
chain prefilter {
|
|
# Drop packets with invalid connection state
|
|
$(if [[ "$PROTECT_INVALID" == "yes" ]]; then echo "ct state invalid counter drop"; fi)
|
|
|
|
# Allow established/related connections
|
|
ct state { established, related } accept
|
|
|
|
# Drop packets to our own IP on external interface (anti-spoof)
|
|
$(if [[ "$PROTECT_IP_SPOOF" == "yes" && -n "$MY_IPV4" ]]; then echo "iifname \"$EXT_IF\" ip saddr $MY_IPV4 counter drop comment \"Anti-spoof: our IP from outside\""; fi)
|
|
$(if [[ "$PROTECT_IP_SPOOF" == "yes" && -n "$MY_IPV6" ]]; then echo "iifname \"$EXT_IF\" ip6 saddr $MY_IPV6 counter drop comment \"Anti-spoof: our IP from outside\""; fi)
|
|
|
|
# Drop bogon networks from external interface
|
|
$(if [[ "$PROTECT_SMURF" == "yes" ]]; then echo "iifname \"$EXT_IF\" ip saddr @blocked_nets counter drop comment \"Bogon network\""; fi)
|
|
|
|
# Drop fragments (modern kernels handle this, but defense in depth)
|
|
$(if [[ "$PROTECT_FRAGMENTS" == "yes" ]]; then echo "ip frag-off != 0 counter drop comment \"Fragmented packet\""; fi)
|
|
|
|
# Drop XMAS tree scans (all flags set)
|
|
$(if [[ "$PROTECT_XMAS" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) counter jump port_scan_detect comment \"XMAS scan\""; fi)
|
|
|
|
# Drop NULL scans (no flags set)
|
|
$(if [[ "$PROTECT_NULL_SCAN" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter jump port_scan_detect comment \"NULL scan\""; fi)
|
|
|
|
# Drop bogus TCP flag combinations
|
|
$(if [[ "$PROTECT_BOGUS_TCP" == "yes" ]]; then generate_bogus_tcp_rules; fi)
|
|
|
|
# ICMP handling
|
|
jump icmp_handling
|
|
|
|
# Connection rate limiting
|
|
$(generate_connlimit_rules)
|
|
|
|
jump service_handling
|
|
}
|
|
PREFILTER
|
|
|
|
# --- External Interface Chain ---
|
|
cat >> "$RULES_FILE" << EXT_CHAIN
|
|
chain input_${EXT_IF} {
|
|
jump prefilter
|
|
}
|
|
EXT_CHAIN
|
|
|
|
# --- Internal Interface Chain ---
|
|
if [[ -n "$INT_IF" ]]; then
|
|
cat >> "$RULES_FILE" << INT_CHAIN
|
|
chain input_${INT_IF} {
|
|
# Internal network - more permissive
|
|
ct state { established, related } accept
|
|
ct state invalid drop
|
|
|
|
# Allow all from trusted networks
|
|
ip saddr @trusted_nets accept
|
|
|
|
# Allow DHCP
|
|
udp sport { 67, 68 } udp dport { 67, 68 } accept
|
|
|
|
jump prefilter
|
|
}
|
|
INT_CHAIN
|
|
fi
|
|
|
|
# --- Extra Interfaces ---
|
|
for iface in $EXTRA_IFS; do
|
|
cat >> "$RULES_FILE" << EXTRA_CHAIN
|
|
chain input_${iface} {
|
|
jump prefilter
|
|
}
|
|
EXTRA_CHAIN
|
|
done
|
|
|
|
# --- Port Scan Detection Chain ---
|
|
cat >> "$RULES_FILE" << SCAN_CHAIN
|
|
chain port_scan_detect {
|
|
add @port_scanners { ip saddr } counter drop comment \"Port scan detected\"
|
|
}
|
|
SCAN_CHAIN
|
|
|
|
# --- ICMP Handling Chain ---
|
|
cat >> "$RULES_FILE" << ICMP_CHAIN
|
|
chain icmp_handling {
|
|
# ICMPv4
|
|
ip protocol icmp icmp type @icmp_allowed limit rate ${ICMP_RATE_LIMIT} burst ${ICMP_RATE_BURST} accept
|
|
ip protocol icmp icmp type @icmp_allowed counter drop comment \"ICMP rate limit exceeded\"
|
|
ip protocol icmp drop comment \"ICMP type not allowed\"
|
|
|
|
# ICMPv6 - allow more types for IPv6 to function
|
|
ip6 nexthdr icmpv6 icmpv6 type @icmpv6_allowed accept
|
|
ip6 nexthdr icmpv6 icmpv6 type @icmp_allowed limit rate ${ICMP_RATE_LIMIT} burst ${ICMP_RATE_BURST} accept
|
|
ip6 nexthdr icmpv6 drop comment \"ICMPv6 type not allowed\"
|
|
}
|
|
ICMP_CHAIN
|
|
|
|
# --- Service Handling Chain ---
|
|
cat >> "$RULES_FILE" << SERVICE_CHAIN
|
|
chain service_handling {
|
|
# SSH with brute force protection
|
|
$(if [[ "$SSH_ENABLED" == "yes" ]]; then generate_ssh_rules; fi)
|
|
|
|
# TCP services
|
|
tcp dport @tcp_allowed ct state new counter name @service_counters accept
|
|
|
|
# UDP services
|
|
udp dport @udp_allowed ct state new counter name @service_counters accept
|
|
|
|
# DNS rate limiting (prevent amplification)
|
|
$(generate_dns_ratelimit)
|
|
|
|
$(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 drop - no matching rule\"
|
|
}
|
|
SERVICE_CHAIN
|
|
|
|
# --- SYNPROXY Chain (TCP SYN Flood Protection) ---
|
|
if [[ "$SYNPROXY_ENABLED" == "yes" ]]; then
|
|
cat >> "$RULES_FILE" << SYNPROXY_CHAIN
|
|
chain synproxy {
|
|
# Only for TCP SYN packets to allowed ports
|
|
tcp flags & (fin|syn|rst|ack) == syn ct state new \
|
|
synproxy mss ${MSS_VALUE} wscale 7 timestamp sack-perm
|
|
}
|
|
SYNPROXY_CHAIN
|
|
fi
|
|
|
|
# ----------------------------------------------------------------
|
|
# OUTPUT CHAIN
|
|
# ----------------------------------------------------------------
|
|
cat >> "$RULES_FILE" << OUTPUT_CHAIN
|
|
chain output {
|
|
type filter hook output priority 0; policy ${POLICY_OUTPUT};
|
|
|
|
# Allow all outgoing established connections
|
|
ct state { established, related } accept
|
|
|
|
# Allow outgoing new connections
|
|
ct state new accept
|
|
|
|
# MSS clamping for outgoing (PMTU discovery)
|
|
$(if [[ "$MSS_CLAMP" == "yes" ]]; then echo "tcp flags syn tcp option maxseg size 1-536 tcpmss clamp to mtu"; fi)
|
|
}
|
|
OUTPUT_CHAIN
|
|
|
|
# ----------------------------------------------------------------
|
|
# FORWARD CHAIN
|
|
# ----------------------------------------------------------------
|
|
cat >> "$RULES_FILE" << FORWARD_CHAIN
|
|
chain forward {
|
|
type filter hook forward priority 0; policy ${POLICY_FORWARD};
|
|
|
|
# Flow offloading for hardware acceleration
|
|
$(if [[ "$FLOW_OFFLOAD" == "yes" ]]; then echo "meta l4proto { tcp, udp } flow offload @fwd"; fi)
|
|
|
|
ct state { established, related } accept
|
|
ct state invalid drop
|
|
|
|
# Allow forwarding from internal to external
|
|
$(if [[ -n "$INT_IF" ]]; then echo "iifname \"$INT_IF\" oifname \"$EXT_IF\" ct state new accept"; fi)
|
|
|
|
# Allow port forwards
|
|
$(generate_forward_rules)
|
|
|
|
$(if [[ "$LOG_ENABLED" == "yes" ]]; then echo "counter log prefix \"$LOG_PREFIX\" level warn limit rate ${LOG_RATE} burst ${LOG_BURST}"; fi)
|
|
counter drop
|
|
}
|
|
FORWARD_CHAIN
|
|
|
|
# ----------------------------------------------------------------
|
|
# NAT (if enabled)
|
|
# ----------------------------------------------------------------
|
|
if [[ "$NAT_ENABLED" == "yes" ]]; then
|
|
cat >> "$RULES_FILE" << NAT_CHAIN
|
|
# NAT chain
|
|
chain postrouting {
|
|
type nat hook postrouting priority srcnat;
|
|
|
|
# Masquerade outbound traffic
|
|
iifname "$NAT_SRC_IF" oifname "$NAT_OUT_IF" masquerade
|
|
}
|
|
|
|
chain prerouting {
|
|
type nat hook prerouting priority dstnat;
|
|
|
|
# Port forwards
|
|
$(generate_nat_prerouting)
|
|
}
|
|
NAT_CHAIN
|
|
fi
|
|
|
|
# --- Close table ---
|
|
echo "}" >> "$RULES_FILE"
|
|
|
|
# Add flowtable if offload enabled
|
|
if [[ "$FLOW_OFFLOAD" == "yes" ]]; then
|
|
cat >> "$RULES_FILE" << FLOWTABLE
|
|
table inet ${TABLE_NAME}_offload {
|
|
flowtable fwd {
|
|
hook ingress priority 0
|
|
devices = { ${EXT_IF}${INT_IF:+, $INT_IF}${EXTRA_IFS:+, $EXTRA_IFS} }
|
|
}
|
|
}
|
|
FLOWTABLE
|
|
fi
|
|
|
|
log_debug "Rules written to $RULES_FILE"
|
|
}
|
|
|
|
# Parse TCP services (handle interface-specific format)
|
|
parse_tcp_services() {
|
|
local ports=()
|
|
for svc in "${TCP_SERVICES[@]}"; do
|
|
ports+=("${svc%%:*}")
|
|
done
|
|
printf '%s, ' "${ports[@]}" | sed 's/, $//'
|
|
}
|
|
|
|
# Generate SSH-specific rules
|
|
generate_ssh_rules() {
|
|
cat << SSH_RULES
|
|
# SSH brute force protection
|
|
tcp dport $SSH_PORT ip saddr @ssh_abuse counter drop comment \"SSH brute force ban\"
|
|
tcp dport $SSH_PORT ct state new limit rate ${SSH_MAX_CONN} burst 5 accept
|
|
tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } counter drop comment \"SSH rate limit exceeded\"
|
|
SSH_RULES
|
|
}
|
|
|
|
# Generate DNS rate limiting rules
|
|
generate_dns_ratelimit() {
|
|
cat << DNS_RULES
|
|
# DNS rate limiting (prevent amplification abuse if running DNS)
|
|
udp dport 53 ct state new limit rate ${DNS_RATE_LIMIT} burst ${DNS_RATE_BURST} accept
|
|
udp dport 53 ct state new counter drop comment \"DNS rate limit\"
|
|
DNS_RULES
|
|
}
|
|
|
|
# Generate connection rate limiting
|
|
generate_connlimit_rules() {
|
|
cat << CONNLIMIT_RULES
|
|
# Global connection rate limit: drop only the excess — the
|
|
# accept decisions stay with the service rules.
|
|
ct state new limit rate over ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} add @connlimit_abuse { ip saddr } counter drop comment \"Connection rate limit exceeded\"
|
|
CONNLIMIT_RULES
|
|
}
|
|
|
|
# Generate bogus TCP flag rules
|
|
generate_bogus_tcp_rules() {
|
|
cat << BOGUS_TCP
|
|
# SYN+FIN (scan)
|
|
tcp flags & (syn|fin) == (syn|fin) counter jump port_scan_detect
|
|
# SYN+RST (scan)
|
|
tcp flags & (syn|rst) == (syn|rst) counter jump port_scan_detect
|
|
# FIN+RST (scan)
|
|
tcp flags & (fin|rst) == (fin|rst) counter jump port_scan_detect
|
|
# PSH+FIN without ACK (scan)
|
|
tcp flags & (psh|fin|ack) == (psh|fin) counter jump port_scan_detect
|
|
BOGUS_TCP
|
|
}
|
|
|
|
# Generate NAT prerouting rules (port forwarding)
|
|
generate_nat_prerouting() {
|
|
for fwd in "${PORT_FORWARDS[@]}"; do
|
|
IFS=':' read -r ext_port proto int_ip int_port <<< "$fwd"
|
|
echo "iifname \"$EXT_IF\" $proto dport $ext_port dnat to $int_ip:$int_port"
|
|
done
|
|
}
|
|
|
|
# Generate forward rules for port forwards
|
|
generate_forward_rules() {
|
|
for fwd in "${PORT_FORWARDS[@]}"; do
|
|
IFS=':' read -r ext_port proto int_ip int_port <<< "$fwd"
|
|
echo "iifname \"$EXT_IF\" $proto dport $ext_port daddr $int_ip $proto dport $int_port accept"
|
|
done
|
|
}
|
|
|
|
# ============================================================================
|
|
# FIREWALL CONTROL FUNCTIONS
|
|
# ============================================================================
|
|
|
|
fw_start() {
|
|
log_info "Starting ${TABLE_NAME} firewall..."
|
|
|
|
check_root
|
|
check_nftables
|
|
detect_ips
|
|
|
|
# Verify interfaces exist
|
|
for iface in $(get_all_interfaces); do
|
|
verify_interface "$iface"
|
|
done
|
|
|
|
# Generate rules
|
|
generate_rules
|
|
|
|
# Validate syntax
|
|
log_info "Validating rules..."
|
|
if ! $NFT_CMD -c -f "$RULES_FILE"; then
|
|
die "Rule validation failed. Check $RULES_FILE"
|
|
fi
|
|
|
|
# Load rules
|
|
log_info "Loading rules into kernel..."
|
|
if $NFT_CMD -f "$RULES_FILE"; then
|
|
log_info "Firewall started successfully"
|
|
rm -f "$RULES_FILE"
|
|
else
|
|
die "Failed to load rules"
|
|
fi
|
|
}
|
|
|
|
# v0.0.31: detect action — print service inventory + OS info, no rule changes.
|
|
# SysDeck bridge firewall.py `detect` subcommand invokes this.
|
|
fw_detect() {
|
|
# OS detection (this template doesn't have detect_os at top-level — inline here)
|
|
local os_name="unknown"
|
|
if [[ -f /etc/os-release ]]; then
|
|
. /etc/os-release
|
|
os_name="${PRETTY_NAME:-${NAME:-unknown}}"
|
|
fi
|
|
# Auto-detect primary interface if EXT_IF is the default "eth0"
|
|
local ext_if="${EXT_IF:-eth0}"
|
|
if ! ip link show "$ext_if" &>/dev/null 2>&1; then
|
|
ext_if="$(ip route show default 2>/dev/null | grep -oP 'dev \K\S+' | head -1)"
|
|
ext_if="${ext_if:-unknown}"
|
|
fi
|
|
# Auto-detect IPv4/IPv6 on the interface
|
|
local my_ipv4="" my_ipv6=""
|
|
my_ipv4="$(ip -4 addr show dev "$ext_if" 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1)" || true
|
|
my_ipv6="$(ip -6 addr show dev "$ext_if" 2>/dev/null | grep -oP 'inet6 \K[0-9a-f:]+(?=/)' | head -1)" || true
|
|
echo "+-----------------------------------------------------+"
|
|
echo "| Service Detection Summary |"
|
|
echo "+-----------------------------------------------------+"
|
|
echo "| OS: ${os_name}"
|
|
echo "| Interface: ${ext_if}"
|
|
echo "| IPv4: ${my_ipv4:-none}"
|
|
echo "| IPv6: ${my_ipv6:-none}"
|
|
echo "| Template: no-services (locked-down)"
|
|
echo "| Public TCP: ${SSH_PORT:-22}"
|
|
echo "| Public UDP: (none)"
|
|
echo "+-----------------------------------------------------+"
|
|
}
|
|
|
|
fw_stop() {
|
|
log_info "Stopping ${TABLE_NAME} firewall..."
|
|
check_root
|
|
check_nftables
|
|
|
|
# Flush our table only, not the entire ruleset
|
|
$NFT_CMD delete table inet ${TABLE_NAME} 2>/dev/null || true
|
|
$NFT_CMD delete table inet ${TABLE_NAME}_offload 2>/dev/null || true
|
|
|
|
log_info "Firewall stopped"
|
|
}
|
|
|
|
fw_restart() {
|
|
fw_stop
|
|
sleep 1
|
|
fw_start
|
|
}
|
|
|
|
fw_status() {
|
|
check_nftables
|
|
|
|
echo "============================================"
|
|
echo " nftables Firewall Status"
|
|
echo "============================================"
|
|
echo ""
|
|
|
|
# Check if table exists
|
|
if ! $NFT_CMD list tables 2>/dev/null | grep -q "inet ${TABLE_NAME}"; then
|
|
echo "Status: STOPPED"
|
|
return 0
|
|
fi
|
|
|
|
echo "Status: RUNNING"
|
|
echo ""
|
|
|
|
# Show counters
|
|
echo "--- Packet Counters ---"
|
|
$NFT_CMD list table inet ${TABLE_NAME} 2>/dev/null | grep -E '^\s+[0-9]+ [0-9]+ counter' || echo "(no counters)"
|
|
echo ""
|
|
|
|
# Show set sizes
|
|
echo "--- Set Statistics ---"
|
|
$NFT_CMD list table inet ${TABLE_NAME} 2>/dev/null | grep -A1 'set ' | grep 'size' || echo "(no sets)"
|
|
echo ""
|
|
|
|
# Show SSH abuse list
|
|
echo "--- SSH Abuse List ---"
|
|
local abuse_count
|
|
abuse_count=$($NFT_CMD get element inet ${TABLE_NAME} set ssh_abuse \{ \} 2>/dev/null | wc -l)
|
|
if [[ $abuse_count -gt 0 ]]; then
|
|
$NFT_CMD list set inet ${TABLE_NAME} ssh_abuse 2>/dev/null | tail -n +2
|
|
else
|
|
echo "(empty)"
|
|
fi
|
|
echo ""
|
|
|
|
# Show port scanners
|
|
echo "--- Port Scanners ---"
|
|
local scanner_count
|
|
scanner_count=$($NFT_CMD get element inet ${TABLE_NAME} set port_scanners \{ \} 2>/dev/null | wc -l)
|
|
if [[ $scanner_count -gt 0 ]]; then
|
|
$NFT_CMD list set inet ${TABLE_NAME} port_scanners 2>/dev/null | tail -n +2
|
|
else
|
|
echo "(empty)"
|
|
fi
|
|
}
|
|
|
|
fw_check() {
|
|
check_nftables
|
|
log_info "Checking current 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 "Rules saved to $SAVED_RULES"
|
|
}
|
|
|
|
fw_dump() {
|
|
check_nftables
|
|
$NFT_CMD -j list ruleset 2>/dev/null | jq '.' 2>/dev/null || $NFT_CMD list ruleset
|
|
}
|
|
|
|
fw_clear_ssh_abuse() {
|
|
check_root
|
|
check_nftables
|
|
$NFT_CMD flush set inet ${TABLE_NAME} ssh_abuse 2>/dev/null
|
|
log_info "SSH abuse list cleared"
|
|
}
|
|
|
|
fw_clear_scanners() {
|
|
check_root
|
|
check_nftables
|
|
$NFT_CMD flush set inet ${TABLE_NAME} port_scanners 2>/dev/null
|
|
log_info "Port scanner list cleared"
|
|
}
|
|
|
|
fw_unban_ip() {
|
|
local ip="$1"
|
|
check_root
|
|
check_nftables
|
|
|
|
$NFT_CMD delete element inet ${TABLE_NAME} set ssh_abuse \{ $ip \} 2>/dev/null && \
|
|
log_info "Unbanned $ip from SSH abuse" || true
|
|
$NFT_CMD delete element inet ${TABLE_NAME} set port_scanners \{ $ip \} 2>/dev/null && \
|
|
log_info "Unbanned $ip from port scanners" || true
|
|
}
|
|
|
|
fw_show_banned() {
|
|
check_nftables
|
|
echo "=== SSH Abuse ==="
|
|
$NFT_CMD list set inet ${TABLE_NAME} ssh_abuse 2>/dev/null | tail -n +2 || echo "(none)"
|
|
echo ""
|
|
echo "=== Port Scanners ==="
|
|
$NFT_CMD list set inet ${TABLE_NAME} port_scanners 2>/dev/null | tail -n +2 || echo "(none)"
|
|
echo ""
|
|
echo "=== Connection Rate Abusers ==="
|
|
$NFT_CMD list set inet ${TABLE_NAME} connlimit_abuse 2>/dev/null | tail -n +2 || echo "(none)"
|
|
}
|
|
|
|
# ============================================================================
|
|
# USAGE / HELP
|
|
# ============================================================================
|
|
|
|
show_help() {
|
|
cat << HELPTEXT
|
|
${SCRIPT_NAME} v${SCRIPT_VERSION} - Modern nftables Firewall
|
|
|
|
Usage: $0 [COMMAND] [OPTIONS]
|
|
|
|
Commands:
|
|
start Start the firewall
|
|
stop Stop the firewall (remove rules only)
|
|
restart Restart the firewall
|
|
status Show firewall status and statistics
|
|
check Validate current ruleset syntax
|
|
save Save current rules to ${SAVED_RULES}
|
|
dump Dump ruleset as JSON
|
|
|
|
Management:
|
|
clear-ssh Clear SSH brute-force ban list
|
|
clear-scanners Clear port scanner ban list
|
|
unban <IP> Unban an IP from all lists
|
|
show-banned Show all banned IPs
|
|
help Show this help message
|
|
|
|
Configuration:
|
|
Edit the variables at the top of this script to customize:
|
|
- Network interfaces
|
|
- Allowed services/ports
|
|
- Rate limits
|
|
- Protection toggles
|
|
- NAT/port forwarding
|
|
|
|
Notes:
|
|
- Uses nftables inet family (unified IPv4/IPv6)
|
|
- Sets provide O(1) lookups vs linear chain traversal
|
|
- Dynamic sets auto-expire banned IPs
|
|
- No iptables compatibility layer used
|
|
- Compatible with kernel 6.6+ / 7.x with eBPF support
|
|
|
|
HELPTEXT
|
|
}
|
|
|
|
# ============================================================================
|
|
# MAIN
|
|
# ============================================================================
|
|
|
|
main() {
|
|
local command="${1:-help}"
|
|
|
|
case "$command" in
|
|
start)
|
|
fw_start
|
|
;;
|
|
stop)
|
|
fw_stop
|
|
;;
|
|
restart|reload)
|
|
fw_restart
|
|
;;
|
|
detect)
|
|
fw_detect
|
|
;;
|
|
status)
|
|
fw_status
|
|
;;
|
|
check|validate)
|
|
fw_check
|
|
;;
|
|
save)
|
|
fw_save
|
|
;;
|
|
dump)
|
|
fw_dump
|
|
;;
|
|
clear-ssh|clear-ssh-abuse)
|
|
fw_clear_ssh_abuse
|
|
;;
|
|
clear-scanners|clear-portscanners)
|
|
fw_clear_scanners
|
|
;;
|
|
unban)
|
|
[[ -z "${2:-}" ]] && die "Usage: $0 unban <IP>"
|
|
fw_unban_ip "$2"
|
|
;;
|
|
show-banned|banned|list-banned)
|
|
fw_show_banned
|
|
;;
|
|
help|--help|-h)
|
|
show_help
|
|
;;
|
|
*)
|
|
die "Unknown command: $command\nRun '$0 help' for usage"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@" |