{server_name}
Configured reverse-proxy routes:
-
{items}
Edit config/proxy.yaml, regenerate, then restart the proxy container.
#!/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'
Configured reverse-proxy routes:
Edit config/proxy.yaml, regenerate, then restart the proxy container.