498 lines
17 KiB
Python
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())
|