server.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Stdlib HTTP read-only server for hosted governance dashboard (§HGD.5, §HGD.10).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import mimetypes |
| 7 | import threading |
| 8 | from dataclasses import dataclass |
| 9 | from http import HTTPStatus |
| 10 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 11 | from pathlib import Path |
| 12 | from typing import Callable |
| 13 | from urllib.parse import parse_qs, urlparse |
| 14 | |
| 15 | from tools.hosted_dashboard.auth import constant_time_equal |
| 16 | from tools.hosted_dashboard.bind import is_loopback_peer |
| 17 | from tools.hosted_dashboard.config import HostedDashboardConfig |
| 18 | from tools.hosted_dashboard.cors import origin_allowed |
| 19 | from tools.hosted_dashboard.envelope import ApiEnvelope, failure |
| 20 | from tools.hosted_dashboard.handlers import DashboardService, is_track_q_act_path, match_repo_route |
| 21 | |
| 22 | STATIC_ROOT = Path(__file__).resolve().parent / "static" |
| 23 | MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) |
| 24 | ALLOWED_API_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) |
| 25 | |
| 26 | |
| 27 | @dataclass(frozen=True) |
| 28 | class HostedServerConfig: |
| 29 | """Runtime configuration for the hosted dashboard server.""" |
| 30 | |
| 31 | bind: str |
| 32 | port: int |
| 33 | viewer_token: str |
| 34 | upstream_token: str | None |
| 35 | dashboard: HostedDashboardConfig |
| 36 | service: DashboardService |
| 37 | require_loopback_peer: bool = True |
| 38 | write_scope_refused: bool = False |
| 39 | |
| 40 | |
| 41 | class HostedHTTPServer(ThreadingHTTPServer): |
| 42 | """Threading HTTP server carrying hosted dashboard configuration.""" |
| 43 | |
| 44 | daemon_threads = True |
| 45 | allow_reuse_address = True |
| 46 | |
| 47 | def __init__(self, config: HostedServerConfig) -> None: |
| 48 | self.hosted_config = config |
| 49 | super().__init__((config.bind, config.port), HostedRequestHandler) |
| 50 | |
| 51 | |
| 52 | class HostedRequestHandler(BaseHTTPRequestHandler): |
| 53 | """Serve static UI and closed GET-only ``api/*`` surface.""" |
| 54 | |
| 55 | server_version = "OverseerHostedDashboard/1.0" |
| 56 | |
| 57 | def log_message(self, format: str, *args) -> None: # noqa: A003 |
| 58 | return |
| 59 | |
| 60 | def do_GET(self) -> None: # noqa: N802 |
| 61 | self._dispatch() |
| 62 | |
| 63 | def do_HEAD(self) -> None: # noqa: N802 |
| 64 | self._dispatch(head_only=True) |
| 65 | |
| 66 | def do_POST(self) -> None: # noqa: N802 |
| 67 | self._reject_mutating() |
| 68 | |
| 69 | def do_PUT(self) -> None: # noqa: N802 |
| 70 | self._reject_mutating() |
| 71 | |
| 72 | def do_PATCH(self) -> None: # noqa: N802 |
| 73 | self._reject_mutating() |
| 74 | |
| 75 | def do_DELETE(self) -> None: # noqa: N802 |
| 76 | self._reject_mutating() |
| 77 | |
| 78 | def do_OPTIONS(self) -> None: # noqa: N802 |
| 79 | if not self._peer_ok(): |
| 80 | self._send_envelope(failure(error="peer", http_status=403)) |
| 81 | return |
| 82 | self.send_response(HTTPStatus.NO_CONTENT) |
| 83 | self._write_cors_headers() |
| 84 | self.end_headers() |
| 85 | |
| 86 | def _reject_mutating(self) -> None: |
| 87 | parsed = urlparse(self.path) |
| 88 | if parsed.path.startswith("/api/"): |
| 89 | self._send_envelope(failure(error="method_not_allowed", http_status=405)) |
| 90 | return |
| 91 | self.send_error(HTTPStatus.METHOD_NOT_ALLOWED) |
| 92 | |
| 93 | def _dispatch(self, *, head_only: bool = False) -> None: |
| 94 | config = self.server.hosted_config |
| 95 | if not self._peer_ok(): |
| 96 | self._send_envelope(failure(error="peer", http_status=403)) |
| 97 | return |
| 98 | |
| 99 | parsed = urlparse(self.path) |
| 100 | path = parsed.path |
| 101 | query = parse_qs(parsed.query) |
| 102 | |
| 103 | if path.startswith("/api/"): |
| 104 | if not self._origin_ok(): |
| 105 | self._send_envelope(failure(error="origin", http_status=403)) |
| 106 | return |
| 107 | if path != "/api/health" and not self._viewer_auth_ok(): |
| 108 | return |
| 109 | if config.write_scope_refused and path != "/api/health": |
| 110 | self._send_envelope(failure(error="write_scope_refused", http_status=403)) |
| 111 | return |
| 112 | envelope = self._route_api(path, query) |
| 113 | self._send_envelope(envelope, head_only=head_only) |
| 114 | return |
| 115 | |
| 116 | if self.command not in {"GET", "HEAD"}: |
| 117 | self.send_error(HTTPStatus.METHOD_NOT_ALLOWED) |
| 118 | return |
| 119 | |
| 120 | if path in {"/", "/index.html"}: |
| 121 | self._serve_static(STATIC_ROOT / "index.html", head_only=head_only) |
| 122 | return |
| 123 | if path.startswith("/assets/"): |
| 124 | rel = path.removeprefix("/assets/") |
| 125 | self._serve_static(STATIC_ROOT / "assets" / rel, head_only=head_only) |
| 126 | return |
| 127 | |
| 128 | self.send_error(HTTPStatus.NOT_FOUND) |
| 129 | |
| 130 | def _route_api(self, path: str, query: dict[str, list[str]]) -> ApiEnvelope: |
| 131 | if is_track_q_act_path(path): |
| 132 | return failure(error="not_found", http_status=404) |
| 133 | |
| 134 | service = self.server.hosted_config.service |
| 135 | if path == "/api/health": |
| 136 | return service.health() |
| 137 | if path == "/api/org/summary": |
| 138 | return service.org_summary(query) |
| 139 | |
| 140 | matched = match_repo_route(path) |
| 141 | if matched is not None: |
| 142 | owner, repo, action = matched |
| 143 | if action == "roadmap": |
| 144 | return service.roadmap(owner, repo, query) |
| 145 | if action == "handover": |
| 146 | return service.handover(owner, repo, query) |
| 147 | if action == "gates": |
| 148 | return service.gates(owner, repo, query) |
| 149 | if action == "config-marker": |
| 150 | return service.config_marker(owner, repo, query) |
| 151 | |
| 152 | return failure(error="not_found", http_status=404) |
| 153 | |
| 154 | def _peer_ok(self) -> bool: |
| 155 | config = self.server.hosted_config |
| 156 | if not config.require_loopback_peer: |
| 157 | return True |
| 158 | return is_loopback_peer(self.client_address[0]) |
| 159 | |
| 160 | def _viewer_auth_ok(self) -> bool: |
| 161 | auth_header = self.headers.get("Authorization", "") |
| 162 | if not auth_header.startswith("Bearer "): |
| 163 | self._send_envelope(failure(error="auth", http_status=401)) |
| 164 | return False |
| 165 | token = auth_header.removeprefix("Bearer ").strip() |
| 166 | if not constant_time_equal(token, self.server.hosted_config.viewer_token): |
| 167 | self._send_envelope(failure(error="auth", http_status=401)) |
| 168 | return False |
| 169 | return True |
| 170 | |
| 171 | def _origin_ok(self) -> bool: |
| 172 | return origin_allowed( |
| 173 | self.headers.get("Origin"), |
| 174 | self.server.hosted_config.dashboard.cors_origins, |
| 175 | ) |
| 176 | |
| 177 | def _send_envelope(self, envelope: ApiEnvelope, *, head_only: bool = False) -> None: |
| 178 | self.send_response(envelope.http_status) |
| 179 | self.send_header("Content-Type", "application/json; charset=utf-8") |
| 180 | body = envelope.to_json_bytes() |
| 181 | self.send_header("Content-Length", str(len(body))) |
| 182 | self._write_cors_headers() |
| 183 | self.end_headers() |
| 184 | if not head_only: |
| 185 | self.wfile.write(body) |
| 186 | |
| 187 | def _write_cors_headers(self) -> None: |
| 188 | origin = self.headers.get("Origin") |
| 189 | allowed = self.server.hosted_config.dashboard.cors_origins |
| 190 | if origin and origin in allowed: |
| 191 | self.send_header("Access-Control-Allow-Origin", origin) |
| 192 | self.send_header("Vary", "Origin") |
| 193 | self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") |
| 194 | self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") |
| 195 | |
| 196 | def _serve_static(self, path: Path, *, head_only: bool = False) -> None: |
| 197 | try: |
| 198 | resolved = path.resolve() |
| 199 | if not str(resolved).startswith(str(STATIC_ROOT.resolve())): |
| 200 | self.send_error(HTTPStatus.FORBIDDEN) |
| 201 | return |
| 202 | except OSError: |
| 203 | self.send_error(HTTPStatus.NOT_FOUND) |
| 204 | return |
| 205 | if not resolved.is_file(): |
| 206 | self.send_error(HTTPStatus.NOT_FOUND) |
| 207 | return |
| 208 | content_type, _ = mimetypes.guess_type(str(resolved)) |
| 209 | content = resolved.read_bytes() |
| 210 | self.send_response(HTTPStatus.OK) |
| 211 | self.send_header("Content-Type", content_type or "application/octet-stream") |
| 212 | self.send_header("Content-Length", str(len(content))) |
| 213 | self.end_headers() |
| 214 | if not head_only: |
| 215 | self.wfile.write(content) |
| 216 | |
| 217 | |
| 218 | @dataclass |
| 219 | class HostedServerHandle: |
| 220 | """Running server handle for tests and CLI.""" |
| 221 | |
| 222 | config: HostedServerConfig |
| 223 | httpd: HostedHTTPServer |
| 224 | thread: threading.Thread |
| 225 | |
| 226 | @property |
| 227 | def base_url(self) -> str: |
| 228 | host = "127.0.0.1" if self.config.bind in {"127.0.0.1", "localhost"} else self.config.bind |
| 229 | if host == "::1": |
| 230 | host = "[::1]" |
| 231 | return f"http://{host}:{self.config.port}" |
| 232 | |
| 233 | def shutdown(self) -> None: |
| 234 | self.httpd.shutdown() |
| 235 | self.thread.join(timeout=5) |
| 236 | |
| 237 | |
| 238 | def start_hosted_server(config: HostedServerConfig) -> HostedServerHandle: |
| 239 | """Bind and start the hosted dashboard server in a background thread.""" |
| 240 | httpd = HostedHTTPServer(config) |
| 241 | thread = threading.Thread(target=httpd.serve_forever, name="overseer-hosted-dashboard", daemon=True) |
| 242 | thread.start() |
| 243 | return HostedServerHandle(config=config, httpd=httpd, thread=thread) |
| 244 | |
| 245 | |
| 246 | def run_hosted_server( |
| 247 | config: HostedServerConfig, |
| 248 | *, |
| 249 | on_ready: Callable[[HostedServerConfig, str], None] | None = None, |
| 250 | ) -> int: |
| 251 | """Start the server and block until shutdown. Returns process exit code ``0``.""" |
| 252 | if on_ready is not None: |
| 253 | on_ready(config, f"{config.bind}:{config.port}") |
| 254 | handle = start_hosted_server(config) |
| 255 | try: |
| 256 | handle.thread.join() |
| 257 | except KeyboardInterrupt: |
| 258 | handle.shutdown() |
| 259 | return 0 |