#!/usr/bin/env bash # ============================================================================= # generate.sh - liest config/sites.yaml und erzeugt daraus: # - generated/Caddyfile # - generated/haproxy.cfg # - generated/acl/.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 < "$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"