From 32056c356f57f9b2a42d1b22cd1e2cc349664a51 Mon Sep 17 00:00:00 2001 From: Mickey Date: Fri, 3 Jul 2026 19:06:49 +0200 Subject: [PATCH] Initial commit --- .codex | 0 .gitignore | 2 + PROXY_REMINDER_RACE_MANAGEMENT.md | 99 ++++ README.md | 80 +++ config/proxy.yaml | 46 ++ docker-compose.yml | 24 + generated/default.conf | 140 +++++ generated/index.html | 70 +++ nginx/.htpasswd | 1 + nginx/nginx.conf | 21 + .../__pycache__/render_config.cpython-313.pyc | Bin 0 -> 13138 bytes scripts/certbot-init.sh | 41 ++ scripts/certbot-renew.sh | 12 + scripts/render.sh | 7 + scripts/render_config.py | 497 ++++++++++++++++++ scripts/update-active-cert-links.sh | 27 + 16 files changed, 1067 insertions(+) create mode 100644 .codex create mode 100644 .gitignore create mode 100644 PROXY_REMINDER_RACE_MANAGEMENT.md create mode 100644 README.md create mode 100644 config/proxy.yaml create mode 100644 docker-compose.yml create mode 100644 generated/default.conf create mode 100644 generated/index.html create mode 100644 nginx/.htpasswd create mode 100644 nginx/nginx.conf create mode 100644 scripts/__pycache__/render_config.cpython-313.pyc create mode 100755 scripts/certbot-init.sh create mode 100755 scripts/certbot-renew.sh create mode 100755 scripts/render.sh create mode 100644 scripts/render_config.py create mode 100755 scripts/update-active-cert-links.sh diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aceeee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +letsencrypt/ +.idea/ diff --git a/PROXY_REMINDER_RACE_MANAGEMENT.md b/PROXY_REMINDER_RACE_MANAGEMENT.md new file mode 100644 index 0000000..41a67cc --- /dev/null +++ b/PROXY_REMINDER_RACE_MANAGEMENT.md @@ -0,0 +1,99 @@ +# Proxy Reminder: Race Management Upstream Resilience + +## Problem + +`mnmsoft_proxy` currently fails hard at nginx startup if the upstream hostname +`race-management-web` is not resolvable at config-parse time. + +Observed failure: + +```text +host not found in upstream "race-management-web" in /etc/nginx/conf.d/default.conf +``` + +This caused the proxy container to restart in a loop whenever the +`race-management-web` container was down or not yet attached to the shared +Docker network. + +## What Was Already Fixed + +In `/opt/docker/apps/race-management/docker-compose.yml`: + +- `db` now uses `restart: unless-stopped` +- `web` now uses `restart: unless-stopped` + +That reduces the chance of the backend staying down, but the proxy should still +be made tolerant of temporary upstream absence. + +## What Needs To Be Done In Proxy + +Relevant proxy files: + +- `/opt/docker/apps/proxy/generated/default.conf` +- possibly `/opt/docker/apps/proxy/nginx/nginx.conf` + +### Goal + +Avoid nginx resolving `race-management-web` only once at startup. +Instead, make nginx resolve through Docker DNS at request time or with a +short-lived resolver cache. + +### Recommended Approach + +Use Docker's internal DNS resolver and proxy through a variable. + +Typical pattern: + +```nginx +resolver 127.0.0.11 valid=10s; + +location / { + set $race_management_upstream http://race-management-web:8000; + proxy_pass $race_management_upstream; +} +``` + +### Why + +With a static upstream like: + +```nginx +proxy_pass http://race-management-web:8000; +``` + +nginx may try to resolve the hostname during startup/config load. If the +container is absent at that exact moment, nginx exits and the proxy restarts. + +Using `resolver 127.0.0.11` plus a variable makes nginx rely on Docker DNS more +safely at runtime. + +## Also Clean Up + +The proxy logs also show this warning: + +```text +the "listen ... http2" directive is deprecated +``` + +If present, replace patterns like: + +```nginx +listen 443 ssl http2; +``` + +with the newer form appropriate for the installed nginx version. + +## After Editing + +Reload/recreate the proxy stack and verify: + +```bash +cd /opt/docker/apps/proxy +docker compose up -d +docker logs --tail 100 mnmsoft_proxy +``` + +Expected result: + +- proxy stays up even if `race-management-web` is briefly unavailable during startup +- no more `host not found in upstream "race-management-web"` fatal error diff --git a/README.md b/README.md new file mode 100644 index 0000000..09d2c1a --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# HTTPS Reverse Proxy + +This directory hosts the single public HTTPS entry point for apps under `/opt/docker/apps`. + +## Route config + +Edit [config/proxy.yaml](/opt/docker/apps/proxy/config/proxy.yaml) and add routes like: + +```yaml +server_name: mnmsoft.hopto.org +certificate_name: mnmsoft.hopto.org + +routes: + - project_dir: /opt/docker/apps/turnir2024 + path_suffix: odzaci-open-2026 + port: 32834 + upstream_scheme: https +``` + +Fields: + +- `project_dir`: informational only, kept in the generated config and landing page. +- `path_suffix`: public path after the domain. `odzaci-open-2026` becomes `https://mnmsoft.hopto.org/odzaci-open-2026/`. +- `port`: port exposed on the Docker host by the target app. +- `upstream_scheme`: optional, defaults to `http`. Use `https` if the backend app still terminates TLS itself. +- `upstream_host`: optional, defaults to `host.docker.internal`. + +## Start + +```bash +./scripts/render.sh +docker compose up -d +``` + +After changing routes: + +```bash +./scripts/render.sh +docker compose restart proxy +``` + +## TLS certificates + +Issue a Let's Encrypt certificate after the proxy is reachable on public port `80`: + +```bash +./scripts/certbot-init.sh mnmsoft.hopto.org you@example.com +``` + +Renew manually: + +```bash +./scripts/certbot-renew.sh +``` + +Typical cron entry: + +```cron +0 3 * * * cd /opt/docker/apps/proxy && ./scripts/certbot-renew.sh >/var/log/proxy-certbot-renew.log 2>&1 +``` + +The proxy serves certificates from a stable path: + +```text +./letsencrypt/live/current/fullchain.pem +./letsencrypt/live/current/privkey.pem +``` + +Certbot may create versioned lineages such as `mnmsoft.hopto.org-0001`; the helper scripts automatically repoint `live/current/` to the latest real lineage after issuance or renewal. + +## Important behavior + +This proxy strips the configured prefix before forwarding. Example: + +- public request: `/odzaci-open-2026/api/matches` +- upstream request: `/api/matches` + +It also sends `X-Forwarded-Prefix: /odzaci-open-2026`. + +Apps that generate absolute URLs from `/` instead of respecting the forwarded prefix may still need app-level configuration changes. diff --git a/config/proxy.yaml b/config/proxy.yaml new file mode 100644 index 0000000..8668864 --- /dev/null +++ b/config/proxy.yaml @@ -0,0 +1,46 @@ +server_name: mnmsoft.hopto.org +certificate_name: current + +routes: +# - project_dir: /opt/docker/apps/turnir2024 +# path_suffix: odzaci-open-2026 +# port: 32834 +# upstream_scheme: http + - project_dir: /opt/docker/apps/biogena-pdf-generator + path_suffix: biogena-pdf-generator + port: 8092 + upstream_scheme: http + - project_dir: /opt/docker/apps/pdf-generator-demo + path_suffix: pdf-generator-demo + port: 8093 + upstream_scheme: http +# - project_dir: /opt/docker/apps/digiped +# path_suffix: digiped +# port: 32420 +# upstream_scheme: http +# - project_dir: /opt/docker/apps/file-server +# path_suffix: file-server +# port: 32500 +# upstream_scheme: http +# - project_dir: /opt/docker/apps/invoice-control +# path_suffix: invoice-control +# port: 32443 +# upstream_scheme: http +# - project_dir: /opt/docker/apps/jkpu-pog +# path_suffix: jkpu-pog +# port: 32444 +# upstream_scheme: http + - project_dir: /opt/docker/apps/my-git + path_suffix: git + port: 3000 + upstream_scheme: http + - project_dir: /opt/docker/apps/jellyfin + path_suffix: jellyfin + port: 8096 + upstream_scheme: http +# - project_dir: /opt/docker/apps/race-management +# path_suffix: odzacka-humanitarna-petica +# port: 8000 +# upstream_scheme: http +# upstream_host: race-management-web + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a0de97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + proxy: + image: nginx:1.29-alpine + container_name: mnmsoft_proxy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro + - ./generated/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./generated/index.html:/usr/share/nginx/html/index.html:ro + - ./nginx/html:/var/www/certbot:rw + - ./letsencrypt:/etc/letsencrypt:ro + certbot: + image: certbot/certbot:latest + profiles: + - certbot + volumes: + - ./nginx/html:/var/www/certbot:rw + - ./letsencrypt:/etc/letsencrypt:rw diff --git a/generated/default.conf b/generated/default.conf new file mode 100644 index 0000000..92bce6b --- /dev/null +++ b/generated/default.conf @@ -0,0 +1,140 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name mnmsoft.hopto.org; + + 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 mnmsoft.hopto.org; + + client_max_body_size 256M; + ssl_certificate /etc/letsencrypt/live/current/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/current/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + 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; + } + + + # /opt/docker/apps/biogena-pdf-generator + location = /biogena-pdf-generator { + return 301 /biogena-pdf-generator/; + } + + location /biogena-pdf-generator/ { + proxy_pass http://host.docker.internal:8092/; + 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 /biogena-pdf-generator; + 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; + } + + # /opt/docker/apps/pdf-generator-demo + location = /pdf-generator-demo { + return 301 /pdf-generator-demo/; + } + + location /pdf-generator-demo/ { + proxy_pass http://host.docker.internal:8093/; + 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 /pdf-generator-demo; + 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; + } + + # /opt/docker/apps/my-git + location = /git { + return 301 /git/; + } + + location /git/ { + proxy_pass http://host.docker.internal:3000/; + 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 /git; + 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; + } + + # /opt/docker/apps/jellyfin + location = /jellyfin { + return 301 /jellyfin/; + } + + location /jellyfin/ { + proxy_pass http://host.docker.internal:8096; + 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 /jellyfin; + 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; + } + + location = / { + auth_basic "Restricted"; + auth_basic_user_file /etc/nginx/.htpasswd; + root /usr/share/nginx/html; + try_files /index.html =404; + } + + location / { + return 404; + } +} diff --git a/generated/index.html b/generated/index.html new file mode 100644 index 0000000..654710f --- /dev/null +++ b/generated/index.html @@ -0,0 +1,70 @@ + + + + + + mnmsoft.hopto.org proxy + + + +
+

mnmsoft.hopto.org

+

Configured reverse-proxy routes:

+ +

Edit config/proxy.yaml, regenerate, then restart the proxy container.

+
+ + diff --git a/nginx/.htpasswd b/nginx/.htpasswd new file mode 100644 index 0000000..8716f6d --- /dev/null +++ b/nginx/.htpasswd @@ -0,0 +1 @@ +miroslav.vlajnic:$apr1$AXbpQ.v4$S1e9cDhJmjhG9e/8WNWh00 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..f7cab6e --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,21 @@ +user nginx; +worker_processes auto; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/scripts/__pycache__/render_config.cpython-313.pyc b/scripts/__pycache__/render_config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e45039a7a75885d448e6799082f112b35daefe1 GIT binary patch literal 13138 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 literal 0 HcmV?d00001 diff --git a/scripts/certbot-init.sh b/scripts/certbot-init.sh new file mode 100755 index 0000000..b1b76c1 --- /dev/null +++ b/scripts/certbot-init.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DOMAIN="$1" +EMAIL="$2" +LE_LIVE_DIR="${PROJECT_DIR}/letsencrypt/live" +BOOTSTRAP_DIR="${LE_LIVE_DIR}/current" +LE_RENEWAL_DIR="${PROJECT_DIR}/letsencrypt/renewal" +LE_RENEWAL_CONF="${LE_RENEWAL_DIR}/${DOMAIN}.conf" + +cd "${PROJECT_DIR}" + +mkdir -p "${BOOTSTRAP_DIR}" "${LE_RENEWAL_DIR}" + +if [[ ! -f "${BOOTSTRAP_DIR}/fullchain.pem" || ! -f "${BOOTSTRAP_DIR}/privkey.pem" ]]; then + openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ + -keyout "${BOOTSTRAP_DIR}/privkey.pem" \ + -out "${BOOTSTRAP_DIR}/fullchain.pem" \ + -subj "/CN=${DOMAIN}" +fi + +docker compose up -d proxy + +docker compose --profile certbot run --rm certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + --domain "${DOMAIN}" \ + --email "${EMAIL}" \ + --agree-tos \ + --no-eff-email + +rm -f "${BOOTSTRAP_DIR}/fullchain.pem" "${BOOTSTRAP_DIR}/privkey.pem" +"${PROJECT_DIR}/scripts/update-active-cert-links.sh" "${DOMAIN}" +docker compose exec proxy nginx -s reload diff --git a/scripts/certbot-renew.sh b/scripts/certbot-renew.sh new file mode 100755 index 0000000..3e5bcb9 --- /dev/null +++ b/scripts/certbot-renew.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DOMAIN="${1:-mnmsoft.hopto.org}" + +cd "${PROJECT_DIR}" + +docker compose --profile certbot run --rm certbot renew +"${PROJECT_DIR}/scripts/update-active-cert-links.sh" "${DOMAIN}" +docker compose exec proxy nginx -s reload diff --git a/scripts/render.sh b/scripts/render.sh new file mode 100755 index 0000000..60f8204 --- /dev/null +++ b/scripts/render.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +python3 "${PROJECT_DIR}/scripts/render_config.py" diff --git a/scripts/render_config.py b/scripts/render_config.py new file mode 100644 index 0000000..d6c45d3 --- /dev/null +++ b/scripts/render_config.py @@ -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'
  • ' + f'/{html.escape(str(route["path_suffix"]))}/' + f' {html.escape(str(route["project_dir"]))}
  • ' + ) + for route in config["routes"] + ) + return f""" + + + + + {server_name} proxy + + + +
    +

    {server_name}

    +

    Configured reverse-proxy routes:

    +
      +{items} +
    +

    Edit config/proxy.yaml, regenerate, then restart the proxy container.

    +
    + + +""" + + +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()) diff --git a/scripts/update-active-cert-links.sh b/scripts/update-active-cert-links.sh new file mode 100755 index 0000000..643d699 --- /dev/null +++ b/scripts/update-active-cert-links.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DOMAIN="$1" +LIVE_DIR="${PROJECT_DIR}/letsencrypt/live" +ACTIVE_DIR="${LIVE_DIR}/current" + +latest_lineage="$(find "${LIVE_DIR}" -maxdepth 1 -mindepth 1 -type d -name "${DOMAIN}*" | sort | tail -n 1)" + +if [[ -z "${latest_lineage}" ]]; then + echo "No certificate lineage found for ${DOMAIN}" >&2 + exit 1 +fi + +mkdir -p "${ACTIVE_DIR}" +latest_lineage_name="$(basename "${latest_lineage}")" +ln -sfn "../${latest_lineage_name}/fullchain.pem" "${ACTIVE_DIR}/fullchain.pem" +ln -sfn "../${latest_lineage_name}/privkey.pem" "${ACTIVE_DIR}/privkey.pem" + +printf 'Active certificate path updated to %s\n' "${latest_lineage}"