Files
proxy/scripts/render_config.py
2026-07-03 19:06:49 +02:00

498 lines
17 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import html
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = ROOT / "config" / "proxy.yaml"
GENERATED_DIR = ROOT / "generated"
NGINX_OUTPUT_PATH = GENERATED_DIR / "default.conf"
INDEX_OUTPUT_PATH = GENERATED_DIR / "index.html"
class ConfigError(ValueError):
pass
def parse_scalar(raw: str):
value = raw.strip()
if not value:
return ""
if value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
if re.fullmatch(r"-?\d+", value):
return int(value)
return value
def parse_config(path: Path) -> dict:
config: dict[str, object] = {}
routes: list[dict[str, object]] = []
current_route: dict[str, object] | None = None
in_routes = False
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.split("#", 1)[0].rstrip()
if not line.strip():
continue
indent = len(line) - len(line.lstrip(" "))
stripped = line.strip()
if indent == 0:
current_route = None
if stripped == "routes:":
in_routes = True
continue
in_routes = False
if ":" not in stripped:
raise ConfigError(f"{path}:{line_number}: expected key: value pair")
key, value = stripped.split(":", 1)
config[key.strip()] = parse_scalar(value)
continue
if not in_routes:
raise ConfigError(f"{path}:{line_number}: unexpected indentation outside routes")
if indent == 2 and stripped.startswith("- "):
payload = stripped[2:].strip()
current_route = {}
routes.append(current_route)
if payload:
if ":" not in payload:
raise ConfigError(f"{path}:{line_number}: expected key: value after '-'")
key, value = payload.split(":", 1)
current_route[key.strip()] = parse_scalar(value)
continue
if indent == 4 and current_route is not None:
if ":" not in stripped:
raise ConfigError(f"{path}:{line_number}: expected key: value in route entry")
key, value = stripped.split(":", 1)
current_route[key.strip()] = parse_scalar(value)
continue
raise ConfigError(f"{path}:{line_number}: unsupported YAML structure")
config["routes"] = routes
return config
def validate_config(config: dict) -> dict:
server_name = config.get("server_name")
certificate_name = config.get("certificate_name")
routes = config.get("routes")
if not isinstance(server_name, str) or not server_name:
raise ConfigError("server_name must be a non-empty string")
if not isinstance(certificate_name, str) or not certificate_name:
raise ConfigError("certificate_name must be a non-empty string")
if not isinstance(routes, list) or not routes:
raise ConfigError("routes must contain at least one route")
normalized_routes = []
seen_suffixes = set()
for index, route in enumerate(routes, start=1):
if not isinstance(route, dict):
raise ConfigError(f"route #{index} must be a mapping")
project_dir = route.get("project_dir")
path_suffix = route.get("path_suffix")
port = route.get("port")
upstream_scheme = route.get("upstream_scheme", "http")
upstream_host = route.get("upstream_host", "host.docker.internal")
if not isinstance(project_dir, str) or not project_dir:
raise ConfigError(f"route #{index}: project_dir must be a non-empty string")
if not isinstance(path_suffix, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", path_suffix):
raise ConfigError(
f"route #{index}: path_suffix must match [A-Za-z0-9][A-Za-z0-9._/-]*"
)
if path_suffix.startswith("/") or path_suffix.endswith("/"):
raise ConfigError(f"route #{index}: path_suffix must not start or end with '/'")
if path_suffix in seen_suffixes:
raise ConfigError(f"route #{index}: duplicate path_suffix '{path_suffix}'")
seen_suffixes.add(path_suffix)
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ConfigError(f"route #{index}: port must be an integer between 1 and 65535")
if upstream_scheme not in {"http", "https"}:
raise ConfigError(f"route #{index}: upstream_scheme must be http or https")
if not isinstance(upstream_host, str) or not upstream_host:
raise ConfigError(f"route #{index}: upstream_host must be a non-empty string")
normalized_routes.append(
{
"project_dir": project_dir,
"path_suffix": path_suffix,
"port": port,
"upstream_scheme": upstream_scheme,
"upstream_host": upstream_host,
}
)
return {
"server_name": server_name,
"certificate_name": certificate_name,
"routes": normalized_routes,
}
def _is_docker_host(upstream_host: str) -> bool:
return upstream_host != "host.docker.internal"
def _upstream_var(suffix: str) -> str:
return f"upstream_{suffix.replace('-', '_')}"
def _proxy_pass_directive(upstream_host: str, port: int, upstream_scheme: str, suffix: str, upstream_path: str = "/") -> str:
target = f"{upstream_scheme}://{upstream_host}:{port}{upstream_path}"
if _is_docker_host(upstream_host):
var = _upstream_var(suffix)
return f" set ${var} {target};\n proxy_pass ${var};"
return f" proxy_pass {target};"
def _render_static_location(host: str, port: int, suffix: str) -> str:
target = f"http://{host}:{port}/static/"
if _is_docker_host(host):
var = f"$upstream_{suffix.replace('-', '_')}_static"
return f" set {var} {target};\n proxy_pass {var};"
return f" proxy_pass {target};"
def render_location(route: dict[str, object]) -> str:
suffix = str(route["path_suffix"])
upstream_scheme = str(route["upstream_scheme"])
upstream_host = str(route["upstream_host"])
port = int(route["port"])
project_dir = str(route["project_dir"])
upstream_path = "/"
if suffix == "jellyfin":
upstream_path = ""
ssl_directives = ""
if upstream_scheme == "https":
ssl_directives = "\n proxy_ssl_server_name on;\n proxy_ssl_verify off;"
proxy_pass_block = _proxy_pass_directive(upstream_host, port, upstream_scheme, suffix, upstream_path)
return f""" # {project_dir}
location = /{suffix} {{
return 301 /{suffix}/;
}}
location /{suffix}/ {{
{proxy_pass_block}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Prefix /{suffix};
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_http_version 1.1;
proxy_redirect off;
proxy_buffering off;{ssl_directives}
}}
"""
def render_nginx(config: dict) -> str:
server_name = config["server_name"]
certificate_name = config["certificate_name"]
routes: list[dict] = config["routes"]
route_blocks = "\n".join(render_location(route) for route in routes)
has_docker_upstream = any(_is_docker_host(str(route["upstream_host"])) for route in routes)
resolver_block = ""
if has_docker_upstream:
resolver_block = "\n resolver 127.0.0.11 valid=10s ipv6=off;\n"
extra_locations = ""
race_management_route = next((route for route in routes if route["project_dir"] == "/opt/docker/apps/race-management"), None)
if race_management_route is not None:
race_management_host = str(race_management_route["upstream_host"])
race_management_port = int(race_management_route["port"])
race_management_suffix = str(race_management_route["path_suffix"])
static_proxy_line = _render_static_location(race_management_host, race_management_port, race_management_suffix)
extra_locations += f"""
location /{race_management_suffix}/static/ {{
{static_proxy_line}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_redirect off;
proxy_buffering off;
}}
"""
digiped_route = next((route for route in routes if route["path_suffix"] == "digiped"), None)
if digiped_route is not None:
digiped_host = str(digiped_route["upstream_host"])
digiped_port = int(digiped_route["port"])
extra_locations += f"""
# digiped uses Angular's Vite dev server, which still emits a few root-level requests.
location /@vite/ {{
proxy_pass http://{digiped_host}:{digiped_port}/@vite/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
proxy_buffering off;
}}
location /@fs/ {{
proxy_pass http://{digiped_host}:{digiped_port}/@fs/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
proxy_buffering off;
}}
location ~ ^/(main|polyfills|styles)\\.js$ {{
proxy_pass http://{digiped_host}:{digiped_port}$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
proxy_buffering off;
}}
location = /styles.css {{
proxy_pass http://{digiped_host}:{digiped_port}/styles.css;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_redirect off;
proxy_buffering off;
}}
location ~ ^/chunk-[A-Z0-9]+\\.js$ {{
proxy_pass http://{digiped_host}:{digiped_port}$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
proxy_buffering off;
}}
location = /favicon.ico {{
proxy_pass http://{digiped_host}:{digiped_port}/favicon.ico;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_redirect off;
proxy_buffering off;
}}
location = /digiped.png {{
proxy_pass http://{digiped_host}:{digiped_port}/digiped.png;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1;
proxy_redirect off;
proxy_buffering off;
}}
"""
return f"""map $http_upgrade $connection_upgrade {{
default upgrade;
'' close;
}}
server {{
listen 80;
listen [::]:80;
server_name {server_name};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
server {{
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {server_name};
client_max_body_size 256M;
ssl_certificate /etc/letsencrypt/live/{certificate_name}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{certificate_name}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;{resolver_block}
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
{extra_locations}
{route_blocks}
location = / {{
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
root /usr/share/nginx/html;
try_files /index.html =404;
}}
location / {{
return 404;
}}
}}
"""
def render_index(config: dict) -> str:
server_name = html.escape(str(config["server_name"]))
items = "\n".join(
(
f' <li><a href="/{html.escape(str(route["path_suffix"]))}/">'
f'/{html.escape(str(route["path_suffix"]))}/</a>'
f' <span>{html.escape(str(route["project_dir"]))}</span></li>'
)
for route in config["routes"]
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{server_name} proxy</title>
<style>
:root {{
color-scheme: light;
font-family: "Segoe UI", sans-serif;
background: #f4f1ea;
color: #1f2933;
}}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(192, 132, 84, 0.18), transparent 35%),
linear-gradient(135deg, #f7f3ec, #efe5d5);
}}
main {{
width: min(760px, calc(100vw - 2rem));
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(31, 41, 51, 0.08);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 24px 60px rgba(31, 41, 51, 0.12);
}}
h1 {{
margin-top: 0;
font-size: clamp(2rem, 5vw, 3rem);
}}
ul {{
padding-left: 1.25rem;
}}
li {{
margin: 0.8rem 0;
}}
a {{
color: #8f3b1b;
font-weight: 700;
text-decoration: none;
}}
span {{
color: #52606d;
font-size: 0.95rem;
}}
code {{
background: #f3ede3;
padding: 0.15rem 0.35rem;
border-radius: 6px;
}}
</style>
</head>
<body>
<main>
<h1>{server_name}</h1>
<p>Configured reverse-proxy routes:</p>
<ul>
{items}
</ul>
<p>Edit <code>config/proxy.yaml</code>, regenerate, then restart the proxy container.</p>
</main>
</body>
</html>
"""
def main() -> int:
try:
config = validate_config(parse_config(CONFIG_PATH))
except (OSError, ConfigError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
NGINX_OUTPUT_PATH.write_text(render_nginx(config), encoding="utf-8")
INDEX_OUTPUT_PATH.write_text(render_index(config), encoding="utf-8")
print(f"Rendered {NGINX_OUTPUT_PATH}")
print(f"Rendered {INDEX_OUTPUT_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())