Initial commit

This commit is contained in:
Mickey
2026-07-03 19:06:49 +02:00
commit 32056c356f
16 changed files with 1067 additions and 0 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
letsencrypt/
.idea/
+99
View File
@@ -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
+80
View File
@@ -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.
+46
View File
@@ -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
+24
View File
@@ -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
+140
View File
@@ -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;
}
}
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mnmsoft.hopto.org 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>mnmsoft.hopto.org</h1>
<p>Configured reverse-proxy routes:</p>
<ul>
<li><a href="/biogena-pdf-generator/">/biogena-pdf-generator/</a> <span>/opt/docker/apps/biogena-pdf-generator</span></li>
<li><a href="/pdf-generator-demo/">/pdf-generator-demo/</a> <span>/opt/docker/apps/pdf-generator-demo</span></li>
<li><a href="/git/">/git/</a> <span>/opt/docker/apps/my-git</span></li>
<li><a href="/jellyfin/">/jellyfin/</a> <span>/opt/docker/apps/jellyfin</span></li>
</ul>
<p>Edit <code>config/proxy.yaml</code>, regenerate, then restart the proxy container.</p>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
miroslav.vlajnic:$apr1$AXbpQ.v4$S1e9cDhJmjhG9e/8WNWh00
+21
View File
@@ -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;
}
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <domain> <email>" >&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
+12
View File
@@ -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
+7
View File
@@ -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"
+497
View File
@@ -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())
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <domain>" >&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}"