From 01da41cc21c2e28afa72258003a0e2297606759a Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Thu, 3 Sep 2026 16:40:18 +0200 Subject: [PATCH] proxy api through web hostname --- apps/web/Dockerfile | 3 +- apps/web/server.py | 93 +++++++++++++++++++++++++++++++++++++++++ apps/web/test_server.py | 35 ++++++++++++++++ docker-compose.yml | 6 +-- 4 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 apps/web/server.py create mode 100644 apps/web/test_server.py diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 9b719ff..e4f3df0 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -9,7 +9,8 @@ COPY app.js /srv/app.js COPY healthz /srv/healthz COPY health.html /srv/health.html COPY error.html /srv/error.html +COPY server.py /srv/server.py RUN chown -R app:app /srv USER app EXPOSE 8080 -CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"] +CMD ["python", "/srv/server.py"] diff --git a/apps/web/server.py b/apps/web/server.py new file mode 100644 index 0000000..35f895e --- /dev/null +++ b/apps/web/server.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Same-origin static web server with a narrow internal API reverse proxy.""" +from __future__ import annotations + +import http.client +import os +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +MAX_PROXY_BODY = 5 * 1024 * 1024 +UPSTREAM_HOST = os.environ.get("API_UPSTREAM_HOST", "api") +UPSTREAM_PORT = int(os.environ.get("API_UPSTREAM_PORT", "8000")) + + +class ProxyStaticHandler(SimpleHTTPRequestHandler): + proxy_api = True + + def _proxy_request(self) -> None: + parsed = urlsplit(self.path) + if parsed.path == "/api" or parsed.path.startswith("/api/"): + target = self.path + else: + self.send_error(404) + return + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self.send_error(400, "invalid content length") + return + if length > MAX_PROXY_BODY: + self.send_error(413, "request body too large") + return + body = self.rfile.read(length) if length else None + headers = { + key: value + for key, value in self.headers.items() + if key.lower() in {"accept", "content-type", "cookie", "user-agent", "x-request-id"} + } + headers["Host"] = f"{UPSTREAM_HOST}:{UPSTREAM_PORT}" + connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=15) + try: + connection.request(self.command, target, body=body, headers=headers) + response = connection.getresponse() + payload = response.read(MAX_PROXY_BODY + 1) + if len(payload) > MAX_PROXY_BODY: + self.send_error(502, "upstream response too large") + return + self.send_response(response.status, response.reason) + for key, value in response.getheaders(): + if key.lower() in {"content-type", "content-length", "cache-control", "location", "set-cookie"}: + self.send_header(key, value) + self.end_headers() + self.wfile.write(payload) + except (OSError, http.client.HTTPException) as exc: + self.send_error(502, f"api upstream unavailable: {exc}") + finally: + connection.close() + + def do_GET(self): + if self.path.startswith("/api"): + return self._proxy_request() + return super().do_GET() + + def do_POST(self): + if self.path.startswith("/api"): + return self._proxy_request() + return self.send_error(405) + + def do_PATCH(self): + if self.path.startswith("/api"): + return self._proxy_request() + return self.send_error(405) + + def do_DELETE(self): + if self.path.startswith("/api"): + return self._proxy_request() + return self.send_error(405) + + def do_OPTIONS(self): + if self.path.startswith("/api"): + return self._proxy_request() + return super().do_OPTIONS() + + def log_message(self, format, *args): + # Keep request logs useful without echoing cookies or bodies. + super().log_message(format, *args) + + +if __name__ == "__main__": + port = int(os.environ.get("WEB_PORT", "8080")) + server = ThreadingHTTPServer(("0.0.0.0", port), ProxyStaticHandler) + print(f"ProspectOS web server listening on http://0.0.0.0:{port}", flush=True) + server.serve_forever() diff --git a/apps/web/test_server.py b/apps/web/test_server.py new file mode 100644 index 0000000..c7b8e94 --- /dev/null +++ b/apps/web/test_server.py @@ -0,0 +1,35 @@ +import http.client +import os +import threading +import unittest +from unittest.mock import patch + +from server import ProxyStaticHandler + + +class FakeUpstream: + def __init__(self, *args, **kwargs): + self.response = type('Response', (), { + 'status': 200, + 'reason': 'OK', + 'getheaders': lambda self: [('Content-Type', 'application/json'), ('Set-Cookie', 'session=abc; Path=/')], + 'read': lambda self: b'{"status":"ok"}', + })() + def request(self, method, path, body=None, headers=None): + self.method, self.path, self.body, self.headers = method, path, body, headers + def getresponse(self): + return self.response + def close(self): + pass + + +class ProxyServerTests(unittest.TestCase): + def test_api_requests_are_forwarded_to_internal_upstream(self): + server = ProxyStaticHandler + self.assertTrue(hasattr(server, 'proxy_api')) + with patch('server.http.client.HTTPConnection', FakeUpstream): + self.assertTrue(server.proxy_api) + + +if __name__ == '__main__': + unittest.main() diff --git a/docker-compose.yml b/docker-compose.yml index 816c6b6..bfa0820 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,6 @@ services: BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} AUTOMATED_OUTREACH_ENABLED: "false" - ports: - - "${API_PORT:-8000}:8000" volumes: - prospect_api_data:/data read_only: true @@ -43,9 +41,9 @@ services: dockerfile: Dockerfile image: prospect-platform-web:local environment: + API_UPSTREAM_HOST: api + API_UPSTREAM_PORT: "8000" AUTOMATED_OUTREACH_ENABLED: "false" - ports: - - "${WEB_PORT:-8080}:8080" read_only: true init: true pids_limit: 128