The fleet has ~700 devices and no way to see them: the device console manages one machine from its own keyboard, and the question "which devices are installed, and which wave is each in" had no surface at all. This adds it as the first screen. Python standard library only -- http.server, html.escape, string.Template. No framework and no third-party import, because the fleet installs offline from a committed package pool that contains no web framework, so fastapi/uvicorn/flask would have made the panel uninstallable on the devices it manages. That was decided with the user over the FastAPI they first picked, and a test asserts it: the module imports only stdlib plus its own siblings. What it shows: the dark header with the target and the privilege level; a summary of counts by state with one loud figure; the device list with a state badge per row, monospace ids and hostnames so columns line up, non-ready devices sorted first, and a filter bar; the selected device's detail with its four-channel verification, since READY is refused until every channel is verified (docs/27). Nothing is colour-only: every badge carries its Turkish state word. Data has no live source yet, so a missing inventory file falls back to synthetic records that the page labels as demo in two places -- the panel never presents invented devices as real ones. Two defects found by reviewing a real render, both fixed here with a test that fails when the bug returns (proven by mutation: each assertion goes red against the reintroduced bug, green after): - --state-provisioned resolved to var(--info), and --info is --accent, so a KURULDU badge and the selected row were the same blue. The state now resolves to its own colour, and the test resolves var() aliases before comparing -- comparing the raw declarations does not detect this, which is how the first version of the test passed against the bug. - the wave cell printed the bare code and then a label that already contains it, so one device read "P2 P2 — PILOT-2". Each wave is named once, now. Also fixes two entries in docs/27 that had become untrue: it claimed a clone carries the packages and the installer downloads nothing, which stopped being the case when the pool moved to Git LFS and bf-bootstrap began cloning without smudge. The real flow -- light clone, payload from a local mirror or a targeted `git lfs pull` -- is documented instead. Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh (12/12 PASS overall).
162 lines
5.2 KiB
Python
162 lines
5.2 KiB
Python
"""Request routing and the privilege gate for the central panel (stdlib only).
|
|
|
|
``route`` is a pure function: it takes the panel context, an HTTP method and a
|
|
request target, and returns a ``Response``. Nothing in this module starts a
|
|
process, writes a file or opens an outbound connection -- the panel's read-only
|
|
claim is structural, not a promise in a comment:
|
|
|
|
* only ``GET`` and ``HEAD`` are served; every other method is answered with 405
|
|
and a Turkish explanation, never by an action;
|
|
* static assets are served from an explicit allow-list, so a request can never
|
|
walk the filesystem (``/static/../server.py`` is a 404);
|
|
* an unknown path is a Turkish 404, not a redirect anywhere.
|
|
|
|
Because ``route`` is pure, tests exercise the whole HTTP surface without binding
|
|
a port.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, unquote
|
|
|
|
from . import render
|
|
from .fleet_data import FleetSnapshot
|
|
from .render import Privilege, ServerInfo
|
|
|
|
#: Local assets only. Adding a name here is the only way to serve a new file.
|
|
STATIC_FILES: dict[str, str] = {
|
|
"tokens.css": "text/css; charset=utf-8",
|
|
"app.css": "text/css; charset=utf-8",
|
|
}
|
|
|
|
READ_METHODS: tuple[str, ...] = ("GET", "HEAD")
|
|
|
|
#: Response headers that keep an air-gapped panel inert: no script runs, no
|
|
#: remote origin is reachable, and the browser is told not to sniff types.
|
|
SECURITY_HEADERS: tuple[tuple[str, str], ...] = (
|
|
("X-Content-Type-Options", "nosniff"),
|
|
("Referrer-Policy", "no-referrer"),
|
|
(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self'; "
|
|
"script-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Response:
|
|
"""One HTTP answer, fully decided before the socket sees it."""
|
|
|
|
status: int
|
|
content_type: str
|
|
body: bytes
|
|
content_length: int
|
|
headers: tuple[tuple[str, str], ...] = ()
|
|
|
|
@property
|
|
def is_html(self) -> bool:
|
|
return self.content_type.startswith("text/html")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PanelContext:
|
|
"""Everything a request needs: the snapshot, the privilege level, the facts."""
|
|
|
|
snapshot: FleetSnapshot
|
|
privilege: Privilege
|
|
server_info: ServerInfo
|
|
static_dir: Path = render.STATIC_DIR
|
|
|
|
|
|
def route(context: PanelContext, method: str, target: str) -> Response:
|
|
"""Decide one request. Pure: same inputs, same bytes, no side effects."""
|
|
verb = (method or "").upper()
|
|
raw_path, _, query_string = target.partition("?")
|
|
|
|
if verb not in READ_METHODS:
|
|
return _html(
|
|
405,
|
|
render.render_refused_page(
|
|
verb or "BİLİNMİYOR",
|
|
snapshot=context.snapshot,
|
|
privilege=context.privilege,
|
|
server_info=context.server_info,
|
|
),
|
|
verb,
|
|
extra_headers=(("Allow", ", ".join(READ_METHODS)),),
|
|
)
|
|
|
|
decoded = unquote(raw_path)
|
|
if ".." in decoded or "\x00" in decoded:
|
|
return _not_found(context, decoded, verb)
|
|
|
|
params = parse_qs(query_string, keep_blank_values=True)
|
|
|
|
if decoded in ("/", "/filo", "/fleet"):
|
|
page = render.render_fleet_page(
|
|
context.snapshot,
|
|
params,
|
|
privilege=context.privilege,
|
|
server_info=context.server_info,
|
|
)
|
|
return _html(200, page, verb)
|
|
|
|
if decoded in ("/hakkinda", "/hakkinda/", "/about"):
|
|
page = render.render_about_page(
|
|
context.snapshot,
|
|
privilege=context.privilege,
|
|
server_info=context.server_info,
|
|
)
|
|
return _html(200, page, verb)
|
|
|
|
if decoded.startswith("/static/"):
|
|
return _static(context, decoded[len("/static/"):], verb)
|
|
|
|
return _not_found(context, decoded, verb)
|
|
|
|
|
|
def _html(
|
|
status: int,
|
|
page: str,
|
|
verb: str,
|
|
*,
|
|
extra_headers: tuple[tuple[str, str], ...] = (),
|
|
) -> Response:
|
|
body = page.encode("utf-8")
|
|
headers = SECURITY_HEADERS + (("Cache-Control", "no-store"),) + extra_headers
|
|
if verb == "HEAD":
|
|
return Response(status, "text/html; charset=utf-8", b"", len(body), headers)
|
|
return Response(status, "text/html; charset=utf-8", body, len(body), headers)
|
|
|
|
|
|
def _not_found(context: PanelContext, request_path: str, verb: str) -> Response:
|
|
page = render.render_not_found_page(
|
|
request_path,
|
|
snapshot=context.snapshot,
|
|
privilege=context.privilege,
|
|
server_info=context.server_info,
|
|
)
|
|
return _html(404, page, verb)
|
|
|
|
|
|
def _static(context: PanelContext, name: str, verb: str) -> Response:
|
|
# Allow-list first: anything not named here is a 404, so no path trick can
|
|
# reach a file this panel did not decide to publish.
|
|
content_type = STATIC_FILES.get(name)
|
|
if content_type is None:
|
|
return _not_found(context, f"/static/{name}", verb)
|
|
|
|
path = context.static_dir / name
|
|
if not path.is_file():
|
|
return _not_found(context, f"/static/{name}", verb)
|
|
|
|
body = path.read_bytes()
|
|
headers = SECURITY_HEADERS + (
|
|
("Cache-Control", "no-cache"),
|
|
)
|
|
if verb == "HEAD":
|
|
return Response(200, content_type, b"", len(body), headers)
|
|
return Response(200, content_type, body, len(body), headers)
|