SysDeck/firewall/templates/remote-admin.sh

380 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Name: remote-admin
# Description: Public server variant for remote administration. Exposes SSH (22) and Cockpit (9090) with aggressive rate limiting, port-scan detection, and SSH brute-force auto-ban. Designed for VPS / cloud hosts where the operator needs remote shell + web admin access from anywhere, but wants the surface protected against brute-force and scan traffic.
# Distro: arch,debian
# Services: ssh,cockpit
#
# ============================================================================
# remote-admin.sh - SysDeck public-server variant: remote admin
# ============================================================================
#
# v0.0.44 NEW. Per user directive: "another thing the firewall module
# needs is a few public server variants. like: remote admin enabled
# ssh and cockpit ...". This template targets the remote-admin use
# case — the box is reachable from the internet on two admin ports
# only: SSH (22) and Cockpit (9090). Everything else is dropped.
#
# What this template does:
# - inet table "firewall" (unified IPv4/IPv6)
# - input default policy: drop
# - forward default policy: drop
# - output default policy: accept
# - accept established + related (ct state)
# - accept loopback
# - accept ICMP echo-request + echo-reply + needed ICMPv6 (NDP)
# - accept TCP 22 (SSH) with rate limit (4 new conns / minute / source)
# — sources exceeding the rate are added to the ssh_abuse set
# (1 hour timeout) and dropped
# - accept TCP 9090 (Cockpit web UI) with rate limit
# (10 new conns / minute / source)
# - drop invalid TCP flag combos (NULL / XMAS / SYN+FIN / SYN+RST)
# - drop fragments
# - drop bogons on input (martian IPv4 + IPv6 docs)
# - log dropped packets at 5/second burst 10
#
# Detection:
# - Reads SSH_PORT from /etc/ssh/sshd_config (falls back to 22)
# - Reads Cockpit port from /etc/cockpit/cockpit.conf
# (Listen = ... directive, falls back to 9090)
# - Both ports are surfaced in the detect output so the panel
# can show them in the Service/Port editor.
#
# Hardening notes:
# - No 0.0.0.0 listener assumption (per v0.0.43 directive — this
# is a FIREWALL template, not a listener config; the firewall
# itself never binds any address).
# - SYNPROXY NOT enabled here — the box only listens on 2 ports,
# and SYNPROXY adds complexity for little benefit when the rate
# limiter already handles SYN floods.
# - Bogon list is small (just RFC 1918 + 169.254 + 127.0.0.0/8 +
# 0.0.0.0/8 + IPv6 ::1 + fc00::/7 + fe80::/10) — keeps the ruleset
# short for easy review.
#
# Standard template interface (start/stop/restart/detect/status/check)
# implemented so the existing bridge.firewall.apply/stop/restart/detect/
# check subcommands work unchanged.
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
SCRIPT_NAME="remote-admin"
SCRIPT_VERSION="0.0.44"
TABLE_NAME="firewall"
NFT_CMD="${NFT_CMD:-nft}"
RULES_FILE="${RULES_FILE:-$(mktemp /tmp/sysdeck-firewall-remote-admin-XXXXXX.rules)}"
# a fresh mktemp name per run — no predictable /tmp path for a root write
trap 'rm -f "$RULES_FILE"' EXIT
# ── Configurable ports (auto-detected, can be overridden) ───────────
# These are read by the detect / start actions. The operator can also
# override by editing this script's variables directly.
SSH_PORT="${SYSDECK_REMOTE_ADMIN_SSH_PORT:-22}"
COCKPIT_PORT="${SYSDECK_REMOTE_ADMIN_COCKPIT_PORT:-9090}"
# ── Rate limits ─────────────────────────────────────────────────────
SSH_RATE_LIMIT="4/minute"
SSH_BURST="8"
SSH_BAN_TIMEOUT="3600s" # 1 hour
COCKPIT_RATE_LIMIT="10/minute"
COCKPIT_BURST="20"
COCKPIT_BAN_TIMEOUT="600s" # 10 minutes
# ── Log ─────────────────────────────────────────────────────────────
LOG_PREFIX="[NFT-DROP] "
LOG_RATE="5/second"
LOG_BURST="10"
# ── Bogons (small list — keep short for reviewability) ──────────────
BOGONS_V4="0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4"
BOGONS_V6="::1/128, fc00::/7, fe80::/10, ff00::/8"
log_info() { printf '[%s] [INFO] %s\n' "$SCRIPT_NAME" "$*" >&2; }
log_debug() { printf '[%s] [DEBUG] %s\n' "$SCRIPT_NAME" "$*" >&2 || true; }
die() { printf '[%s] [FATAL] %s\n' "$SCRIPT_NAME" "$*" >&2; exit 1; }
check_nftables() {
command -v "$NFT_CMD" &>/dev/null || die "nftables not installed. Install with: pacman -S nftables / apt install nftables"
}
# ── Service detection ───────────────────────────────────────────────
#
# Auto-detect SSH port from /etc/ssh/sshd_config. Falls back to 22 if
# the file is absent or the Port directive is missing/commented out.
detect_ssh_port() {
local cfg="/etc/ssh/sshd_config"
if [[ -f "$cfg" ]]; then
# Match: Port 2222 (ignoring commented #Port lines)
local port
port=$(awk '
/^[[:space:]]*Port[[:space:]]+/ { print $2; exit }
' "$cfg" 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
SSH_PORT="$port"
return
fi
fi
SSH_PORT="22"
}
# Auto-detect Cockpit port from /etc/cockpit/cockpit.conf. Cockpit's
# listener configuration uses a [Listen] section with a `Port = ...`
# key, OR a `Listen = addr:port` directive in [WebService]. We try
# both. Falls back to 9090.
detect_cockpit_port() {
local cfg="/etc/cockpit/cockpit.conf"
if [[ -f "$cfg" ]]; then
local port
# Try [WebService] Listen = 9090 OR Listen = 0.0.0.0:9090
# (Note: we explicitly do NOT support 0.0.0.0 binds per the
# v0.0.43 directive — but if the operator has already set
# one, we extract just the port.)
port=$(awk '
/^\[WebService\]/ { in_ws=1; next }
/^\[/ { in_ws=0 }
in_ws && /^[[:space:]]*Listen[[:space:]]*=/ {
v=$3
# Strip "addr:" prefix if present
sub(/^.*:/, "", v)
print v
exit
}
' "$cfg" 2>/dev/null || true)
# Try [Socket] section as well
if [[ -z "${port:-}" ]]; then
port=$(awk '
/^\[Socket\]/ { in_s=1; next }
/^\[/ { in_s=0 }
in_s && /^[[:space:]]*Port[[:space:]]*=/ { print $3; exit }
' "$cfg" 2>/dev/null || true)
fi
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
COCKPIT_PORT="$port"
return
fi
fi
COCKPIT_PORT="9090"
}
# ── Build ruleset ───────────────────────────────────────────────────
build_ruleset() {
cat <<RULESET
#!/usr/sbin/nft -f
# 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 ──────────────────────────────────────────────────────
# SSH brute-force ban list. Populated by the rate-limit rule
# when a source exceeds ${SSH_RATE_LIMIT}. Entries expire after
# ${SSH_BAN_TIMEOUT}.
set ssh_abuse {
type ipv4_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set ssh_abuse6 {
type ipv6_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
# Cockpit rate-limit ban list.
set cockpit_abuse {
type ipv4_addr
flags timeout
timeout ${COCKPIT_BAN_TIMEOUT}
}
set cockpit_abuse6 {
type ipv6_addr
flags timeout
timeout ${COCKPIT_BAN_TIMEOUT}
}
# Bogon source addresses (martian / RFC 1918 / etc).
set bogons_v4 {
type ipv4_addr
flags interval
elements = { ${BOGONS_V4} }
}
set bogons_v6 {
type ipv6_addr
flags interval
elements = { ${BOGONS_V6} }
}
# ── Chains ────────────────────────────────────────────────────
chain input {
type filter hook input priority 0; policy drop;
# Drop bogons first.
ip saddr @bogons_v4 drop
ip6 saddr @bogons_v6 drop
# Established / related — accept.
ct state vmap { established: accept, related: accept }
# Loopback.
iifname "lo" accept
# Invalid.
ct state invalid drop
# ICMP / ICMPv6 essentials.
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, packet-too-big, destination-unreachable, time-exceeded } accept
# ── SSH (port ${SSH_PORT}) ─────────────────────────────────
# If source is in the ban set, drop.
ip saddr @ssh_abuse drop
ip6 saddr @ssh_abuse6 drop
# Rate-limit new SSH connections. Sources exceeding
# ${SSH_RATE_LIMIT} burst ${SSH_BURST} get added to ssh_abuse.
tcp dport ${SSH_PORT} ct state new \
limit rate ${SSH_RATE_LIMIT} burst ${SSH_BURST} packets accept
tcp dport ${SSH_PORT} ct state new \
add @ssh_abuse { ip saddr } \
add @ssh_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}ssh-rate " drop
# ── Cockpit (port ${COCKPIT_PORT}) ────────────────────────
ip saddr @cockpit_abuse drop
ip6 saddr @cockpit_abuse6 drop
tcp dport ${COCKPIT_PORT} ct state new \
limit rate ${COCKPIT_RATE_LIMIT} burst ${COCKPIT_BURST} packets accept
tcp dport ${COCKPIT_PORT} ct state new \
add @cockpit_abuse { ip saddr } \
add @cockpit_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}cockpit-rate " drop
# Drop invalid TCP flag combinations.
tcp flags & (fin|syn|rst|psh|ack|urg) == 0 drop
tcp flags & (fin|syn) == (fin|syn) drop
tcp flags & (syn|rst) == (syn|rst) drop
tcp flags & (fin|rst) == (fin|rst) drop
tcp flags & (psh|fin) == (psh|fin) drop
tcp flags & (urg|psh|ack|fin|rst|syn) == (urg|psh|ack|fin|rst|syn) drop
# Log + drop everything else.
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}input "
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}forward "
drop
}
chain output {
type filter hook output priority 0; policy accept;
}
}
RULESET
}
# ── Actions ─────────────────────────────────────────────────────────
fw_start() {
check_nftables
detect_ssh_port
detect_cockpit_port
log_info "Starting remote-admin firewall: ssh=${SSH_PORT}, cockpit=${COCKPIT_PORT}"
build_ruleset > "$RULES_FILE"
# Root check + validate-then-load: a ruleset that cannot parse must
# never reach the kernel, and only the polkit bridge runs us.
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
exit 1
fi
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
echo "ERROR: ruleset failed validation — nothing was loaded" >&2
exit 1
fi
"$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded."
}
fw_stop() {
check_nftables
log_info "Stopping remote-admin firewall"
"$NFT_CMD" delete table inet "$TABLE_NAME" 2>/dev/null || true
rm -f "$RULES_FILE"
}
fw_restart() {
fw_stop
fw_start
}
fw_detect() {
detect_ssh_port
detect_cockpit_port
local os_name="unknown"
if [[ -f /etc/os-release ]]; then . /etc/os-release; os_name="${PRETTY_NAME:-${NAME:-unknown}}"; fi
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Template: remote-admin (SSH + Cockpit)
| SSH port: ${SSH_PORT} (from /etc/ssh/sshd_config)
| Cockpit port: ${COCKPIT_PORT} (from /etc/cockpit/cockpit.conf)
| Rate limits:
| SSH: ${SSH_RATE_LIMIT} burst ${SSH_BURST} (ban ${SSH_BAN_TIMEOUT})
| Cockpit: ${COCKPIT_RATE_LIMIT} burst ${COCKPIT_BURST} (ban ${COCKPIT_BAN_TIMEOUT})
+-----------------------------------------------------+
EOF
}
fw_status() {
check_nftables
"$NFT_CMD" list table inet "$TABLE_NAME" 2>&1 || echo "table not loaded."
}
fw_check() {
check_nftables
detect_ssh_port
detect_cockpit_port
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -c -f "$RULES_FILE"
}
# ── Dispatch ────────────────────────────────────────────────────────
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 ;;
help|--help|-h)
sed -n '1,60p' "$0"
;;
*)
die "Unknown command: $command\nRun '$0 help' for usage."
;;
esac
}
main "$@"