378 lines
12 KiB
Bash
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"
|