commit 32056c356f57f9b2a42d1b22cd1e2cc349664a51 Author: Mickey Date: Fri Jul 3 19:06:49 2026 +0200 Initial commit 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 0000000..9e45039 Binary files /dev/null and b/scripts/__pycache__/render_config.cpython-313.pyc differ 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}"