436 lines
18 KiB
Bash
Executable File
436 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Name: public-webserver
|
|
# Description: Public server variant for a web stack. Exposes Varnish (80, cache front) and Caddy HTTPS (443) publicly; Caddy HTTP backend (8080) and MariaDB (3306) are bound to loopback only and never exposed. SSH rate-limited with auto-ban. Optional Caddy admin API (2019) is loopback-only. Designed for VPS web servers running a reverse-proxy/cache stack with a database.
|
|
# Distro: arch,debian
|
|
# Services: ssh,caddy,varnish,mariadb
|
|
#
|
|
# ============================================================================
|
|
# public-webserver.sh - SysDeck public-server variant: web stack
|
|
# ============================================================================
|
|
#
|
|
# v0.0.47 PORT-TOPOLOGY FIX. Per user directive: "the web server
|
|
# template, and vps template i setup the webserver on 8080 and
|
|
# varnish on 80 for an automatic cache environment." The v0.0.44
|
|
# template had the topology backwards — it exposed Caddy on :80 and
|
|
# Varnish on :8080. v0.0.47 flips it: Varnish is the public cache
|
|
# front on :80, Caddy HTTP backend lives on :8080 (loopback only),
|
|
# Caddy HTTPS lives on :443 (public, terminates TLS). The :8080
|
|
# backend is now ALWAYS loopback-only — the VARNISH_PUBLIC toggle
|
|
# has been removed because it was a footgun (the previous default
|
|
# exposed the backend cache-miss path to the internet, bypassing
|
|
# Varnish entirely).
|
|
#
|
|
# Topology:
|
|
# Client → :80 (Varnish cache frontend, public — HTTP redirect to :443)
|
|
# Client → :443 (Caddy HTTPS, public — terminates TLS)
|
|
# Varnish → :8080 (Caddy backend, loopback only — Varnish cache miss)
|
|
#
|
|
# Public TCP ports:
|
|
# 22 SSH (rate-limited, auto-ban brute force)
|
|
# 80 HTTP (Varnish frontend — must be public for ACME http-01
|
|
# challenge + redirect to HTTPS)
|
|
# 443 HTTPS (Caddy — terminates TLS)
|
|
#
|
|
# Loopback-only TCP ports (NEVER exposed — firewall enforces):
|
|
# 8080 Caddy HTTP backend (Varnish cache-miss target)
|
|
# 3306 MariaDB
|
|
# 2019 Caddy admin API
|
|
#
|
|
# The firewall enforces the loopback-only policy at the kernel level:
|
|
# even if Caddy or MariaDB is misconfigured to listen on 0.0.0.0, the
|
|
# firewall drops the packet before it reaches the daemon. This is
|
|
# defense in depth — the daemon's own bind is the primary control.
|
|
#
|
|
# Standard template interface (start/stop/restart/detect/status/check).
|
|
#
|
|
# ============================================================================
|
|
|
|
set -euo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
SCRIPT_NAME="public-webserver"
|
|
SCRIPT_VERSION="0.0.47"
|
|
|
|
TABLE_NAME="firewall"
|
|
NFT_CMD="${NFT_CMD:-nft}"
|
|
RULES_FILE="${RULES_FILE:-$(mktemp /tmp/sysdeck-firewall-public-webserver-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) ──────────────────────────────
|
|
# v0.0.47: Varnish is the public cache front on :80; Caddy HTTP backend
|
|
# is loopback-only on :8080. Caddy HTTPS terminates TLS on :443.
|
|
SSH_PORT="${SYSDECK_PUBLIC_WEBSERVER_SSH_PORT:-22}"
|
|
VARNISH_PORT="${SYSDECK_PUBLIC_WEBSERVER_VARNISH:-80}"
|
|
CADDY_HTTP_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTP:-8080}"
|
|
CADDY_HTTPS_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTPS:-443}"
|
|
CADDY_ADMIN_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_ADMIN:-2019}"
|
|
MARIADB_PORT="${SYSDECK_PUBLIC_WEBSERVER_MARIADB:-3306}"
|
|
|
|
# ── Rate limits ─────────────────────────────────────────────────────
|
|
SSH_RATE_LIMIT="4/minute"
|
|
SSH_BURST="8"
|
|
SSH_BAN_TIMEOUT="3600s"
|
|
HTTP_RATE_LIMIT="100/second"
|
|
HTTP_BURST="200"
|
|
HTTPS_RATE_LIMIT="100/second"
|
|
HTTPS_BURST="200"
|
|
|
|
# ── Log ─────────────────────────────────────────────────────────────
|
|
LOG_PREFIX="[NFT-DROP] "
|
|
LOG_RATE="5/second"
|
|
LOG_BURST="10"
|
|
|
|
# ── Bogons ──────────────────────────────────────────────────────────
|
|
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 ───────────────────────────────────────────────
|
|
|
|
detect_ssh_port() {
|
|
local cfg="/etc/ssh/sshd_config"
|
|
if [[ -f "$cfg" ]]; then
|
|
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"
|
|
}
|
|
|
|
# Parse Caddy's Caddyfile for explicit port bindings. Caddy's HTTPS
|
|
# default is 443 — the operator can override with `:8443` or similar
|
|
# in the site block. The HTTP backend port (8080) is what Varnish
|
|
# connects to on loopback; Caddy should bind it to 127.0.0.1:8080
|
|
# (or 0.0.0.0:8080 — the firewall enforces loopback-only access).
|
|
# We surface any non-default bindings but do NOT change the firewall
|
|
# ports — the operator is expected to set
|
|
# SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTP / _HTTPS env vars or edit the
|
|
# script if they want the firewall to match.
|
|
detect_caddy_ports() {
|
|
local cfg="/etc/caddy/Caddyfile"
|
|
if [[ -f "$cfg" ]]; then
|
|
# Just log the bindings — don't override the env vars.
|
|
local bindings
|
|
bindings=$(grep -E '^\s*:?[a-z]*://|^[[:space:]]*:[0-9]+' "$cfg" 2>/dev/null | head -5 || true)
|
|
if [[ -n "${bindings:-}" ]]; then
|
|
log_debug "Caddyfile site bindings:\n${bindings}"
|
|
fi
|
|
fi
|
|
# Caddy admin API port — defaults to 2019 (loopback only).
|
|
local admin_cfg="/etc/caddy/caddy-api.json"
|
|
if [[ -f "$admin_cfg" ]]; then
|
|
local port
|
|
port=$(grep -oE '"listen"[[:space:]]*:[[:space:]]*"localhost:[0-9]+"|"listen"[[:space:]]*:[[:space:]]*":?[0-9]+"' "$admin_cfg" 2>/dev/null \
|
|
| grep -oE '[0-9]+' | head -1 || true)
|
|
if [[ -n "${port:-}" ]]; then CADDY_ADMIN_PORT="$port"; fi
|
|
fi
|
|
}
|
|
|
|
# Parse Varnish's listen port. Varnish's systemd unit usually passes
|
|
# -a :80 on the command line for a cache-front-of-Caddy setup; the
|
|
# VCL itself just defines the backend. We check the systemd unit
|
|
# override first, then /etc/default/varnish (VARNISH_LISTEN_PORT),
|
|
# then fall back to 80 — the standard production topology where
|
|
# Varnish sits in front of Caddy on :80 and Caddy backend listens
|
|
# on :8080 (loopback only).
|
|
detect_varnish_port() {
|
|
# Check /etc/systemd/system/varnish.service.d/*.conf for -a :PORT
|
|
local unit_dir="/etc/systemd/system/varnish.service.d"
|
|
if [[ -d "$unit_dir" ]]; then
|
|
local port
|
|
port=$(grep -rhoE '\-a[[:space:]]*:?[a-z0-9.]*:([0-9]+)' "$unit_dir" 2>/dev/null \
|
|
| grep -oE '[0-9]+$' | head -1 || true)
|
|
if [[ -n "${port:-}" ]]; then VARNISH_PORT="$port"; return; fi
|
|
fi
|
|
# Debian/Ubuntu: /etc/default/varnish VARNISH_LISTEN_PORT=...
|
|
if [[ -f /etc/default/varnish ]]; then
|
|
local port
|
|
port=$(awk -F= '/^VARNISH_LISTEN_PORT=/ { gsub(/["'\'' \t]/, "", $2); print $2; exit }' /etc/default/varnish 2>/dev/null || true)
|
|
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then VARNISH_PORT="$port"; return; fi
|
|
fi
|
|
# Default for the public-webserver template is 80 (per v0.0.47
|
|
# user directive — Varnish is the public cache front).
|
|
: "${VARNISH_PORT:=80}"
|
|
}
|
|
|
|
# Parse MariaDB config for the listen port. The config is split across
|
|
# multiple files in /etc/mysql/mariadb.conf.d/ on Debian. We look for
|
|
# [mysqld] port = ... in any *.cnf file there.
|
|
detect_mariadb_port() {
|
|
local cfg_dirs=("/etc/mysql/mariadb.conf.d" "/etc/mysql" "/etc")
|
|
local cfg_files=(
|
|
"$cfg_dirs[0]/50-server.cnf"
|
|
"$cfg_dirs[0]/60-galera.cnf"
|
|
"/etc/my.cnf"
|
|
"/etc/mysql/my.cnf"
|
|
)
|
|
# Also check the .d directories.
|
|
while IFS= read -r -d '' f; do cfg_files+=("$f"); done < <(
|
|
find "${cfg_dirs[@]}" -name '*.cnf' -print0 2>/dev/null || true
|
|
)
|
|
local f
|
|
for f in "${cfg_files[@]}"; do
|
|
[[ -f "$f" ]] || continue
|
|
local port
|
|
port=$(awk '
|
|
/^\[mysqld\]/ { in_m=1; next }
|
|
/^\[/ { in_m=0 }
|
|
in_m && /^[[:space:]]*port[[:space:]]*=/ { gsub(/[^0-9]/, "", $3); print $3; exit }
|
|
' "$f" 2>/dev/null || true)
|
|
if [[ -n "${port:-}" ]]; then MARIADB_PORT="$port"; return; fi
|
|
done
|
|
MARIADB_PORT="3306"
|
|
}
|
|
|
|
# ── 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 ──────────────────────────────────────────────────────
|
|
|
|
set ssh_abuse {
|
|
type ipv4_addr
|
|
flags timeout
|
|
timeout ${SSH_BAN_TIMEOUT}
|
|
}
|
|
set ssh_abuse6 {
|
|
type ipv6_addr
|
|
flags timeout
|
|
timeout ${SSH_BAN_TIMEOUT}
|
|
}
|
|
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;
|
|
|
|
ip saddr @bogons_v4 drop
|
|
ip6 saddr @bogons_v6 drop
|
|
|
|
ct state vmap { established: accept, related: accept }
|
|
iifname "lo" accept
|
|
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}) ─────────────────────────────────
|
|
ip saddr @ssh_abuse drop
|
|
ip6 saddr @ssh_abuse6 drop
|
|
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
|
|
|
|
# ── HTTP (Varnish cache front ${VARNISH_PORT}) ─────────────
|
|
# Varnish is the public cache frontend on :80 — handles ACME
|
|
# http-01 challenge + redirects to :443 for HTTPS traffic.
|
|
tcp dport ${VARNISH_PORT} ct state new \
|
|
limit rate ${HTTP_RATE_LIMIT} burst ${HTTP_BURST} packets accept
|
|
tcp dport ${VARNISH_PORT} ct state new \
|
|
log prefix "${LOG_PREFIX}http-rate " drop
|
|
|
|
# ── HTTPS (Caddy ${CADDY_HTTPS_PORT}) ──────────────────────
|
|
# Caddy terminates TLS on :443. Varnish also forwards pinned
|
|
# HTTPS traffic here via PROXY protocol (operator configures
|
|
# that in the VCL — the firewall doesn't need to know).
|
|
tcp dport ${CADDY_HTTPS_PORT} ct state new \
|
|
limit rate ${HTTPS_RATE_LIMIT} burst ${HTTPS_BURST} packets accept
|
|
tcp dport ${CADDY_HTTPS_PORT} ct state new \
|
|
log prefix "${LOG_PREFIX}https-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
|
|
|
|
# ── DEFENSE IN DEPTH ───────────────────────────────────────
|
|
# Even if Caddy HTTP backend, MariaDB, or Caddy admin is
|
|
# misconfigured to bind 0.0.0.0, these rules DROP the packet
|
|
# before it reaches the daemon. The default policy is drop —
|
|
# these explicit drops just LOG the attempt so the operator
|
|
# sees misconfiguration. v0.0.47: :8080 (Caddy HTTP backend)
|
|
# is now in this defense-in-depth block — it must NEVER be
|
|
# reachable from outside loopback.
|
|
tcp dport ${CADDY_HTTP_PORT} ip saddr != 127.0.0.0/8 \
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
|
|
log prefix "${LOG_PREFIX}caddy-http-backend-blocked " drop
|
|
tcp dport ${CADDY_HTTP_PORT} ip6 saddr != ::1 \
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
|
|
log prefix "${LOG_PREFIX}caddy-http-backend6-blocked " drop
|
|
tcp dport ${MARIADB_PORT} ip saddr != 127.0.0.0/8 \
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
|
|
log prefix "${LOG_PREFIX}mariadb-blocked " drop
|
|
tcp dport ${MARIADB_PORT} ip6 saddr != ::1 \
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
|
|
log prefix "${LOG_PREFIX}mariadb6-blocked " drop
|
|
tcp dport ${CADDY_ADMIN_PORT} ip saddr != 127.0.0.0/8 \
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
|
|
log prefix "${LOG_PREFIX}caddy-admin-blocked " drop
|
|
|
|
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}input "
|
|
drop
|
|
}
|
|
|
|
chain forward {
|
|
type filter hook forward priority 0; policy drop;
|
|
}
|
|
|
|
chain output {
|
|
type filter hook output priority 0; policy accept;
|
|
}
|
|
}
|
|
RULESET
|
|
}
|
|
|
|
# ── Actions ─────────────────────────────────────────────────────────
|
|
|
|
fw_start() {
|
|
check_nftables
|
|
detect_ssh_port
|
|
detect_caddy_ports
|
|
detect_varnish_port
|
|
detect_mariadb_port
|
|
log_info "Starting public-webserver firewall:"
|
|
log_info " ssh=${SSH_PORT} (public, rate-limited)"
|
|
log_info " varnish=${VARNISH_PORT} (public cache front, HTTP redirect to HTTPS)"
|
|
log_info " caddy-https=${CADDY_HTTPS_PORT} (public, terminates TLS)"
|
|
log_info " caddy-http-backend=${CADDY_HTTP_PORT} (loopback only — Varnish cache-miss target)"
|
|
log_info " caddy-admin=${CADDY_ADMIN_PORT} (loopback only)"
|
|
log_info " mariadb=${MARIADB_PORT} (loopback only, defense-in-depth drop)"
|
|
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 public-webserver 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_caddy_ports
|
|
detect_varnish_port
|
|
detect_mariadb_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: public-webserver (Varnish → Caddy + MariaDB)
|
|
|
|
|
| Public ports:
|
|
| ${SSH_PORT}/tcp SSH (rate-limited, auto-ban)
|
|
| ${VARNISH_PORT}/tcp Varnish cache front (HTTP, redirect to HTTPS)
|
|
| ${CADDY_HTTPS_PORT}/tcp Caddy HTTPS (terminates TLS)
|
|
|
|
|
| Loopback-only ports (defense-in-depth drop at firewall):
|
|
| ${CADDY_HTTP_PORT}/tcp Caddy HTTP backend (Varnish cache-miss target)
|
|
| ${MARIADB_PORT}/tcp MariaDB
|
|
| ${CADDY_ADMIN_PORT}/tcp Caddy admin API
|
|
|
|
|
| Topology (v0.0.47 — cache-front-of-origin):
|
|
| Client → :${VARNISH_PORT} (Varnish cache front, public)
|
|
| Client → :${CADDY_HTTPS_PORT} (Caddy HTTPS, public)
|
|
| Varnish → :${CADDY_HTTP_PORT} (Caddy HTTP backend, loopback only)
|
|
|
|
|
| Config files:
|
|
| SSH: /etc/ssh/sshd_config
|
|
| Caddy: /etc/caddy/Caddyfile
|
|
| Varnish: /etc/systemd/system/varnish.service.d/*.conf
|
|
| /etc/default/varnish (VARNISH_LISTEN_PORT=)
|
|
| MariaDB: /etc/mysql/mariadb.conf.d/*.cnf
|
|
+-----------------------------------------------------+
|
|
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_caddy_ports
|
|
detect_varnish_port
|
|
detect_mariadb_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 "$@"
|