Files
2026-07-31 22:17:22 +02:00

378 lines
12 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# generate.sh - liest config/sites.yaml und erzeugt daraus:
# - generated/Caddyfile
# - generated/haproxy.cfg
# - generated/acl/<name>.lst (eine Datei je Domain/TCP-Service MIT
# allowed_ips)
# - generated/docker-compose.override.yml (Portfreigaben für tcp_services)
#
# Läuft direkt auf dem Host (oder wo auch immer `make` aufgerufen wird) -
# braucht nur bash + awk, kein Python, kein zusätzliches Docker-Image.
# =============================================================================
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_DIR"
CONFIG_FILE="config/sites.yaml"
AWK_SCRIPT="lib/parse-sites.awk"
OUT_DIR="generated"
ACL_DIR="$OUT_DIR/acl"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Fehler: $CONFIG_FILE nicht gefunden." >&2
exit 1
fi
if ! command -v awk >/dev/null 2>&1; then
echo "Fehler: awk wird benötigt, ist aber nicht installiert." >&2
exit 1
fi
mkdir -p "$OUT_DIR" "$ACL_DIR"
# -----------------------------------------------------------------------
# 1. sites.yaml per AWK in TSV-Zeilen übersetzen und in Bash-Arrays laden
# -----------------------------------------------------------------------
DOMAIN_NAME=(); DOMAIN_BACKEND=(); DOMAIN_SCHEME=(); DOMAIN_TLS_INSECURE=()
DOMAIN_TLS_MODE=(); DOMAIN_IPS=(); DOMAIN_SLUG=()
TCP_NAME=(); TCP_PORT=(); TCP_BACKEND=(); TCP_IPS=(); TCP_SLUG=()
ACME_EMAIL=""
slugify() {
# z.B. "git.abw-weiland.de" -> "git_abw_weiland_de"
printf '%s' "$1" | tr -c 'a-zA-Z0-9' '_' | tr '[:upper:]' '[:lower:]' | sed -E 's/_+/_/g; s/^_//; s/_$//'
}
while IFS=$'\t' read -r type c1 c2 c3 c4 c5 c6; do
case "$type" in
DOMAIN)
DOMAIN_NAME+=("$c1")
DOMAIN_BACKEND+=("$c2")
DOMAIN_SCHEME+=("$c3")
DOMAIN_TLS_INSECURE+=("$c4")
DOMAIN_TLS_MODE+=("$c5")
DOMAIN_IPS+=("$c6")
DOMAIN_SLUG+=("$(slugify "$c1")")
;;
TCP)
TCP_NAME+=("$c1")
TCP_PORT+=("$c2")
TCP_BACKEND+=("$c3")
TCP_IPS+=("$c4")
TCP_SLUG+=("$(slugify "$c1")")
;;
GLOBAL)
ACME_EMAIL="$c1"
;;
esac
done < <(awk -f "$AWK_SCRIPT" "$CONFIG_FILE")
# -----------------------------------------------------------------------
# 2. Validierung - bricht VOR jeder Dateiausgabe ab, wenn etwas nicht passt
# -----------------------------------------------------------------------
errors=0
err() { echo "Fehler: $*" >&2; errors=$((errors + 1)); }
[ -z "$ACME_EMAIL" ] && err "global.acme_email fehlt oder ist leer."
is_valid_cidr() {
local cidr="$1"
local ipv4_octet='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
if [[ "$cidr" =~ ^${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}\.${ipv4_octet}/(3[0-2]|[12]?[0-9])$ ]]; then
return 0
fi
# Grobe IPv6-Prüfung (keine vollständige Validierung wie eine echte IP-Bibliothek):
if [[ "$cidr" =~ ^[0-9A-Fa-f:]+/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ ]]; then
return 0
fi
return 1
}
validate_ips() {
local context="$1" ips="$2"
[ -z "$ips" ] && return 0
local IFS=','
local cidr
for cidr in $ips; do
is_valid_cidr "$cidr" || err "'$cidr' bei $context ist kein gültiges CIDR (z.B. 10.0.0.0/8 oder 203.0.113.10/32)."
done
}
declare -A seen_names
declare -A seen_ports
for i in "${!DOMAIN_NAME[@]}"; do
name="${DOMAIN_NAME[$i]}"
[ -z "$name" ] && err "Domain #$((i+1)) hat keinen Namen."
[ -z "${DOMAIN_BACKEND[$i]}" ] && err "Domain '$name' hat kein backend."
[ -z "${DOMAIN_SCHEME[$i]}" ] && err "Domain '$name' hat kein backend_scheme."
case "${DOMAIN_TLS_MODE[$i]}" in
auto|internal|off) ;;
*) err "Domain '$name' hat ungültigen tls_mode '${DOMAIN_TLS_MODE[$i]}' - erlaubt: auto, internal, off." ;;
esac
if [ -n "${seen_names[$name]:-}" ]; then
err "Name '$name' ist mehrfach definiert."
fi
seen_names[$name]=1
validate_ips "Domain '$name'" "${DOMAIN_IPS[$i]}"
done
for i in "${!TCP_NAME[@]}"; do
name="${TCP_NAME[$i]}"
port="${TCP_PORT[$i]}"
[ -z "$name" ] && err "tcp_service #$((i+1)) hat keinen Namen."
[ -z "$port" ] && err "tcp_service '$name' hat keinen listen_port."
[ -z "${TCP_BACKEND[$i]}" ] && err "tcp_service '$name' hat kein backend."
if [ "$port" = "80" ] || [ "$port" = "443" ]; then
err "tcp_service '$name' will Port $port belegen - der ist für Caddy/ACME reserviert."
fi
if [ -n "${seen_names[$name]:-}" ]; then
err "Name '$name' ist mehrfach definiert (Domain oder tcp_service)."
fi
seen_names[$name]=1
if [ -n "${seen_ports[$port]:-}" ]; then
err "Port $port wird von mehreren tcp_services verwendet."
fi
seen_ports[$port]=1
validate_ips "tcp_service '$name'" "${TCP_IPS[$i]}"
done
if [ "$errors" -gt 0 ]; then
echo "Abgebrochen: $errors Fehler gefunden, nichts wurde geschrieben." >&2
exit 1
fi
# -----------------------------------------------------------------------
# 3. ACL-Dateien schreiben (alte zuerst löschen, damit umbenannte/entfernte
# Domains keine verwaisten Dateien hinterlassen)
# -----------------------------------------------------------------------
rm -f "$ACL_DIR"/*.lst 2>/dev/null || true
write_acl() {
local slug="$1" ips="$2"
[ -z "$ips" ] && return 0
local IFS=','
: > "$ACL_DIR/$slug.lst"
local cidr
for cidr in $ips; do
echo "$cidr" >> "$ACL_DIR/$slug.lst"
done
}
for i in "${!DOMAIN_NAME[@]}"; do write_acl "${DOMAIN_SLUG[$i]}" "${DOMAIN_IPS[$i]}"; done
for i in "${!TCP_NAME[@]}"; do write_acl "${TCP_SLUG[$i]}" "${TCP_IPS[$i]}"; done
# -----------------------------------------------------------------------
# 4. Caddyfile schreiben
# -----------------------------------------------------------------------
{
cat <<EOF
{
email $ACME_EMAIL
admin off
servers {
trusted_proxies static private_ranges
}
}
EOF
for i in "${!DOMAIN_NAME[@]}"; do
name="${DOMAIN_NAME[$i]}"
backend="${DOMAIN_BACKEND[$i]}"
scheme="${DOMAIN_SCHEME[$i]}"
tls_insecure="${DOMAIN_TLS_INSECURE[$i]}"
tls_mode="${DOMAIN_TLS_MODE[$i]}"
if [ "$tls_mode" = "off" ]; then
echo "http://$name {"
else
echo "$name {"
fi
[ "$tls_mode" = "internal" ] && echo " tls internal"
echo " encode gzip zstd"
echo ""
echo " reverse_proxy $scheme://$backend {"
if [ "$tls_insecure" = "true" ]; then
echo " transport http {"
echo " tls"
echo " tls_insecure_skip_verify"
echo " }"
fi
echo " header_up X-Forwarded-Proto {scheme}"
echo " }"
echo ""
echo " log {"
echo " output stdout"
echo " format json"
echo " }"
echo "}"
echo ""
done
} > "$OUT_DIR/Caddyfile"
# -----------------------------------------------------------------------
# 5. haproxy.cfg schreiben
# -----------------------------------------------------------------------
{
cat <<'EOF'
# =============================================================================
# GENERIERTE DATEI - NICHT VON HAND BEARBEITEN
# Quelle: config/sites.yaml -> generate.sh -> diese Datei
# =============================================================================
global
log stdout format raw local0
maxconn 4096
tune.ssl.default-dh-param 2048
defaults
log global
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 10s
# -----------------------------------------------------------------------------
# Port 80: HTTP - ACME-Challenge + Weiterleitung an Caddy. IP-Filterung pro
# Domain, ACME-Pfad IMMER ausgenommen - sonst schlägt die Zertifikats-
# erneuerung fehl.
# -----------------------------------------------------------------------------
frontend fe_http
bind *:80
mode http
option httplog
EOF
for i in "${!DOMAIN_NAME[@]}"; do
if [ -n "${DOMAIN_IPS[$i]}" ]; then
name="${DOMAIN_NAME[$i]}"
slug="${DOMAIN_SLUG[$i]}"
echo " acl host_$slug hdr(host) -i $name"
echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst"
echo " http-request deny deny_status 403 if host_$slug !allowed_$slug !{ path_beg /.well-known/acme-challenge/ }"
echo ""
fi
done
cat <<'EOF'
default_backend be_caddy_http
# -----------------------------------------------------------------------------
# Port 443: TCP-Passthrough zu Caddy. IP-Filterung anhand SNI, VOR dem
# TLS-Handshake. Domains mit tls_mode "off" tauchen hier bewusst nicht auf.
# -----------------------------------------------------------------------------
frontend fe_https
bind *:443
mode tcp
option tcplog
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
EOF
for i in "${!DOMAIN_NAME[@]}"; do
if [ -n "${DOMAIN_IPS[$i]}" ] && [ "${DOMAIN_TLS_MODE[$i]}" != "off" ]; then
name="${DOMAIN_NAME[$i]}"
slug="${DOMAIN_SLUG[$i]}"
echo " acl sni_$slug req.ssl_sni -i $name"
echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst"
echo " tcp-request content reject if sni_$slug !allowed_$slug"
echo ""
fi
done
cat <<'EOF'
default_backend be_caddy_tcp
backend be_caddy_http
mode http
server caddy caddy:80 check
backend be_caddy_tcp
mode tcp
server caddy caddy:443 check
EOF
if [ "${#TCP_NAME[@]}" -gt 0 ]; then
cat <<'EOF'
# -----------------------------------------------------------------------------
# Zusätzliche rohe TCP-Ports (kein Caddy dazwischen)
# -----------------------------------------------------------------------------
EOF
for i in "${!TCP_NAME[@]}"; do
name="${TCP_NAME[$i]}"
slug="${TCP_SLUG[$i]}"
port="${TCP_PORT[$i]}"
backend="${TCP_BACKEND[$i]}"
echo "# $name"
echo "frontend fe_tcp_$slug"
echo " bind *:$port"
echo " mode tcp"
echo " option tcplog"
if [ -n "${TCP_IPS[$i]}" ]; then
echo " acl allowed_$slug src -f /usr/local/etc/haproxy/acl/$slug.lst"
echo " tcp-request connection reject if !allowed_$slug"
fi
echo " default_backend be_tcp_$slug"
echo ""
echo "backend be_tcp_$slug"
echo " mode tcp"
echo " server $slug $backend check"
echo ""
done
fi
} > "$OUT_DIR/haproxy.cfg"
# -----------------------------------------------------------------------
# 6. docker-compose.override.yml schreiben (Portfreigaben für tcp_services)
# -----------------------------------------------------------------------
{
cat <<'EOF'
# =============================================================================
# GENERIERTE DATEI - NICHT VON HAND BEARBEITEN
# Quelle: config/sites.yaml (tcp_services) -> generate.sh -> diese Datei
#
# Wird automatisch per COMPOSE_FILE (siehe .env) zusätzlich zur
# docker-compose.yml geladen und ergänzt die Portfreigaben für HAProxy.
# =============================================================================
services:
haproxy:
EOF
if [ "${#TCP_NAME[@]}" -gt 0 ]; then
echo " ports:"
for i in "${!TCP_NAME[@]}"; do
echo " - \"${TCP_PORT[$i]}:${TCP_PORT[$i]}\""
done
else
echo " ports: []"
fi
} > "$OUT_DIR/docker-compose.override.yml"
# -----------------------------------------------------------------------
# 7. Zusammenfassung
# -----------------------------------------------------------------------
filtered_domains=""
for i in "${!DOMAIN_NAME[@]}"; do
[ -n "${DOMAIN_IPS[$i]}" ] && filtered_domains="$filtered_domains ${DOMAIN_NAME[$i]}"
done
filtered_tcp=""
for i in "${!TCP_NAME[@]}"; do
[ -n "${TCP_IPS[$i]}" ] && filtered_tcp="$filtered_tcp ${TCP_NAME[$i]}"
done
non_auto_tls=""
for i in "${!DOMAIN_NAME[@]}"; do
[ "${DOMAIN_TLS_MODE[$i]}" != "auto" ] && non_auto_tls="$non_auto_tls ${DOMAIN_NAME[$i]}(${DOMAIN_TLS_MODE[$i]})"
done
echo "[ok] ${#DOMAIN_NAME[@]} Domain(s), ${#TCP_NAME[@]} TCP-Service(s) verarbeitet."
[ -n "$filtered_domains" ] && echo "[ok] IP-gefiltert:$filtered_domains"
[ -n "$filtered_tcp" ] && echo "[ok] IP-gefiltert (TCP):$filtered_tcp"
[ -n "$non_auto_tls" ] && echo "[ok] Abweichender TLS-Modus:$non_auto_tls"
echo "[ok] Geschrieben nach $OUT_DIR/: Caddyfile, haproxy.cfg, docker-compose.override.yml, acl/*.lst"