"""Bounded HTTPS proxy composition, separate from installation and health.""" import re from pathlib import Path from urllib.parse import urlsplit from . import configuration, manifest from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 def validate_profile(value: dict) -> dict: value = fields( value, {"schema_version", "origin", "certificate_sha256", "key_sha256"}, "proxy profile", ) if type(value["schema_version"]) is not int or value["schema_version"] != 1: raise DeliveryError("unsupported proxy profile") origin = value["origin"] if not isinstance(origin, str) or len(origin) > 253: raise DeliveryError("invalid proxy origin") parts = urlsplit(origin) if ( parts.scheme != "https" or not parts.hostname or parts.path or parts.query or parts.fragment or parts.username or parts.password or parts.port not in (None, 443) or parts.hostname != parts.hostname.lower() or origin != "https://" + parts.hostname or len(parts.hostname) > 253 or not all( re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in parts.hostname.split(".") ) ): raise DeliveryError("canonical HTTPS DNS origin required") sha256(value["certificate_sha256"]) sha256(value["key_sha256"]) if value["certificate_sha256"] == value["key_sha256"]: raise DeliveryError("certificate and private-key identities overlap") return value def render(release_id: str, value: dict) -> bytes: """No credentials, arbitrary includes, upstreams, paths or limit overrides.""" value = validate_profile(value) if not re.fullmatch(r"sd-[0-9a-f]{12}", release_id): raise DeliveryError("invalid proxy release identity") host = urlsplit(value["origin"]).hostname # Only the canonical ZIP route gets the existing 64-MiB upload allowance. upload = ( r"^/api/v1/admin/problem-imports/" r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/archive$" ) proxy = """ proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host HOST; proxy_set_header Forwarded ""; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Host ""; proxy_set_header X-Forwarded-Proto https; proxy_connect_timeout 3s; proxy_send_timeout 10s; proxy_read_timeout 60s; proxy_buffering off; proxy_request_buffering off; proxy_cache off; proxy_hide_header Cache-Control; add_header Cache-Control "no-store" always; """.replace("HOST", host) return ( """user kf-proxy; worker_processes 1; pid /run/kf-proxy/nginx.pid; error_log /dev/null crit; events { worker_connections 128; } http { types { text/html html; text/css css; application/javascript js mjs; application/json json; image/svg+xml svg; image/png png; image/jpeg jpg jpeg; image/x-icon ico; font/woff2 woff2; } default_type application/octet-stream; log_format bounded '$request_method $status $body_bytes_sent $request_time'; access_log /dev/stdout bounded; server_tokens off; limit_conn_zone $binary_remote_addr zone=per_client:64k; limit_conn_zone $server_name zone=per_site:64k; limit_conn_status 429; client_header_timeout 10s; client_body_timeout 10s; client_body_buffer_size 16k; client_body_temp_path /var/lib/kf-onlinejudge/proxy/body 1 2; large_client_header_buffers 2 8k; send_timeout 10s; keepalive_timeout 15s; keepalive_requests 100; lingering_time 10s; lingering_timeout 2s; reset_timedout_connection on; server { listen 443 ssl default_server; ssl_reject_handshake on; } server { listen 443 ssl; server_name HOST; ssl_certificate /etc/kf-onlinejudge/tls/fullchain.pem; ssl_certificate_key /etc/kf-onlinejudge/tls/key.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:TLS:1m; ssl_session_timeout 5m; ssl_session_tickets off; if ($host != "HOST") { return 421; } root /opt/kf-onlinejudge/releases/RELEASE/payload/frontend; autoindex off; client_max_body_size 1m; limit_conn per_client 8; limit_conn per_site 32; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; location ~ "UPLOAD" { client_max_body_size 64m; limit_except PUT { deny all; } PROXY } location ~ ^/api/v1/(auth|problems|submissions|admin)(/|$) { PROXY } location ~ ^/(api|internal)(/|$) { return 404; } location ~ /\\. { return 404; } location ~ \\.(js|mjs|css|woff2|svg|png|jpg|jpeg|ico|json|map)$ { try_files $uri =404; add_header Cache-Control "public, max-age=31536000, immutable"; } location = /admin { return 308 /admin/; } location /admin/ { try_files $uri $uri/ /admin/index.html; add_header Cache-Control "no-cache" always; } location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache" always; } } } """.replace("HOST", host) .replace("RELEASE", release_id) .replace("UPLOAD", upload) .replace("PROXY", proxy) ).encode() def generate(bundle: Path, expected: str, profile: Path, output: Path) -> dict: data = manifest.verify(bundle, expected) configuration.private(profile) value = validate_profile(read_json(profile, 4096)) if output.exists() or output.is_symlink(): raise DeliveryError("proxy output must be new") contents = render(data["release_id"], value) output.mkdir(mode=0o700) member = output / "nginx.conf" member.write_bytes(contents) member.chmod(0o600) receipt = { "schema_version": 1, "source_sha": data["source_sha"], "manifest_sha256": expected, "origin": value["origin"], "tls": {name: value[name] for name in ("certificate_sha256", "key_sha256")}, "configuration_sha256": digest(member), "installed": False, "tls_verified": False, "services_started": False, "execution_verified": False, } (output / "receipt.json").write_bytes(canonical(receipt)) (output / "receipt.json").chmod(0o600) return receipt