server.py python
259 lines 9.2 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 1 day ago
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
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 1 day ago