Initial commit
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user