Files
Schulcloud-MCP/local-instance/scripts/gen-proxy-conf.py
MechaCat02 a3b17a680c local-instance: raise the proxy's header buffers, or topic pages 502
A topic page makes the legacy client set several large cookies at once —
jwt plus the Etherpad session it asks for — which overflows nginx's default
4k header buffer. The upstream answered 200 and the proxy still returned 502
with "upstream sent too big header".

It went unnoticed because the MCP server's topic-page scrape degrades to an
empty list by design: the task ids it recovers from that page silently
vanished instead of failing. Changed in the generator and in the generated
config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

202 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Regenerate proxy/nginx.conf from the real deployment's ingress table.
The live instance is a Kubernetes ingress that splits one origin across the
legacy client, the new SPA and several APIs. Which path goes where is not
documented in prose — it is the table in
dof_app_deploy/ansible/group_vars/all/x_ingress.yml plus a per-path ingress in
each service repo. Transcribing 46 rules by hand invites exactly the drift that
would make this instance lie about the real one, so we generate them.
Usage: python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
Needs the upstream clones in ../vendor (see README.md).
"""
import pathlib
import sys
try:
import yaml
except ImportError:
sys.exit('needs PyYAML: pip install pyyaml')
VENDOR = pathlib.Path(__file__).resolve().parents[2] / 'vendor'
INGRESS = VENDOR / 'dof_app_deploy/ansible/group_vars/all/x_ingress.yml'
# Kubernetes service name -> compose upstream. None means the real deployment
# deliberately 404s that path.
UPSTREAM = {
'client-svc': 'client:3100',
'nuxtclient-svc': 'nuxt:4000',
'api-svc': 'api:3030',
'default-backend-404-svc': None,
'version-aggregator-svc': None, # replaced by our own /version below
None: None,
}
# Routes that live in the individual service repos' own ingress templates
# rather than the shared table, plus the two websocket endpoints.
# (path, upstream, websocket, rewrite-or-None)
EXTRA = [
('/api/v3/file/', 'file-storage:4444', False, None),
('/api/v3/wopi/', 'file-storage:4444', False, None),
('/api/v3/h5p-editor/h5pstatics/', 'h5p-staticfiles:8080', False,
'^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1'),
('/api/v3/h5p-editor/', 'h5p-editor:4448', False, None),
('/api/v3/', 'api:3030', False, None),
('/admin/api/v1', 'admin-api:4030', False, None),
('/board-collaboration', 'board-collaboration:4450', True, None),
('/tldraw-server', 'tldraw-server:3345', True, None),
('/api/tldraw', 'tldraw-server:3345', False, None),
]
PREAMBLE = '''# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand.
#
# One origin in front of the whole stack, the way the real instance is fronted
# by its Kubernetes ingress. The path split between the legacy client and the
# new SPA is not cosmetic: get it wrong and you are testing a different
# application from the one the students use.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 4400;
server_name localhost;
# Docker's embedded DNS. Every proxy_pass below goes through a variable so
# that names resolve per request rather than at startup — otherwise this
# container refuses to boot whenever an optional profile (tools, av) is
# down, which is the normal case.
resolver 127.0.0.11 valid=10s ipv6=off;
# Course files and H5P uploads are large; the ingress allows the same.
client_max_body_size 2600m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# The legacy client sets several large cookies at once (jwt plus the
# Etherpad session it requests on every topic page), which overflows
# nginx's default 4k header buffer: the upstream answers 200 and the proxy
# still returns 502 with "upstream sent too big header". Topic pages are
# the visible casualty, and because the MCP server's topic-page scrape
# degrades to an empty list by design, the ids it recovers just silently
# vanish rather than erroring.
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
# real per-app endpoints and are routed below.
location = /version {
default_type application/json;
return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}';
}
'''
COMMON = ''' 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;
'''
WS = ''' proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
'''
GONE = '''
location {path} {{
return 404;
}}
'''
# Etherpad is mounted under a prefix it knows nothing about, so the deployment
# runs a dedicated nginx in front of it that rewrites the prefix away and
# proxies socket.io separately. Copied from
# dof_app_deploy/ansible/roles/dof_etherpad/templates/nginx-configmap-files.yml.j2
# — a plain proxy_pass gets you a pad that loads and then never syncs.
ETHERPAD = '''
location ^~ /etherpad/admin { return 404; }
location ^~ /etherpad/stats { return 404; }
location /etherpad/socket.io {
set $up_etherpad etherpad:9001;
rewrite /etherpad/socket.io/(.*) /socket.io/$1 break;
proxy_pass http://$up_etherpad;
proxy_redirect / /etherpad/;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location /etherpad {
set $up_etherpad etherpad:9001;
rewrite ^/etherpad$ /etherpad/ permanent;
rewrite /etherpad/(.*) /$1 break;
proxy_pass http://$up_etherpad;
proxy_pass_header Server;
proxy_redirect / /etherpad/;
proxy_set_header Host $host;
proxy_buffering off;
}
'''
def block(path: str, upstream: str, ws: bool, rewrite: str | None, var: str) -> str:
out = [f'\n\tlocation {path} {{\n', f'\t\tset ${var} {upstream};\n']
if rewrite:
out.append(f'\t\trewrite {rewrite} break;\n')
out.append(f'\t\tproxy_pass http://${var};\n')
out.append(COMMON)
if ws:
out.append(WS)
out.append('\t}\n')
return ''.join(out)
def varname(path: str) -> str:
safe = ''.join(c if c.isalnum() else '_' for c in path.strip('/')) or 'root'
return f'up_{safe}'
def main() -> None:
table = yaml.safe_load(INGRESS.read_text())['default_ingress']
seen: set[str] = set()
out = [PREAMBLE]
out.append('\n\t# --- service-owned ingresses and websockets ---\n')
for path, upstream, ws, rewrite in EXTRA:
seen.add(path)
out.append(block(path, upstream, ws, rewrite, varname(path)))
out.append('\n\t# --- from dof_app_deploy x_ingress.yml ---\n')
for name, entry in table.items():
path = entry.get('path')
if path is None or path in seen:
continue
seen.add(path)
if path == '/etherpad':
out.append(ETHERPAD)
continue
upstream = UPSTREAM.get(entry.get('serviceName'), 'MISSING')
if upstream == 'MISSING':
sys.exit(f'unknown serviceName for {name}: {entry.get("serviceName")}')
out.append(f'\n\t# {name}')
out.append(GONE.format(path=path) if upstream is None
else block(path, upstream, False, None, varname(path)))
out.append('}\n')
sys.stdout.write(''.join(out))
if __name__ == '__main__':
main()