From 58b6f3feda3662c2004bdfaa1e7300f14dabd931 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 18:12:33 +0100 Subject: feat: HTTP server with trust, auth, module routing --- deckit/__main__.py | 24 ++++++++ deckit/module.py | 4 +- deckit/server.py | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 deckit/__main__.py create mode 100644 deckit/server.py (limited to 'deckit') diff --git a/deckit/__main__.py b/deckit/__main__.py new file mode 100644 index 0000000..735836c --- /dev/null +++ b/deckit/__main__.py @@ -0,0 +1,24 @@ +"""`python3 -m deckit [setup-display | env]`.""" + +import sys + +from . import config + + +def main(): + args = sys.argv[1:] + if args == ["env"]: + cfg = config.load_config() + d, p = cfg.get("display", {}), cfg.get("ports", {}) + print(f"MON={d.get('name', 'TAB')} PORT={p.get('command_center', 8765)} " + f"VNC_PORT={p.get('display', 5900)}") + elif args == ["setup-display"]: + from .display import setup_display + setup_display(config.load_config()) + else: + from .server import serve + serve() + + +if __name__ == "__main__": + main() diff --git a/deckit/module.py b/deckit/module.py index b30fbf5..fd5997c 100644 --- a/deckit/module.py +++ b/deckit/module.py @@ -30,11 +30,13 @@ class Registry: def names(self) -> list[dict]: return [{"name": m.name, "title": m.title} for m in self._modules.values()] - def _get(self, name: str) -> Module: + def get(self, name: str) -> Module: if name not in self._modules: raise ModuleError(f"unknown module: {name}") return self._modules[name] + _get = get + def state(self, name: str) -> dict: return self._get(name).state() diff --git a/deckit/server.py b/deckit/server.py new file mode 100644 index 0000000..f51621e --- /dev/null +++ b/deckit/server.py @@ -0,0 +1,163 @@ +"""The HTTP server: static assets, the theme, and the module API. + +It listens only on a Unix socket in a 0700 directory, which `adb reverse` +exposes on the tablet's localhost. A token cookie unlocks the API; a Host and +Origin check keeps arbitrary web pages and DNS rebinding out. +""" + +import hmac +import json +import mimetypes +import os +import secrets +import socketserver +import threading +from http.cookies import CookieError, SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from . import config, module, theme + +ROOT = Path(__file__).resolve().parent.parent +STATIC = ROOT / "static" +RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "deck-it" +SOCKET = RUNDIR / "http.sock" + +CFG = config.load_config() +MONITOR = CFG.get("display", {}).get("name", "TAB") +PORT = CFG.get("ports", {}).get("command_center", 8765) +VNC_PORT = CFG.get("ports", {}).get("display", 5900) +ORIGINS = {f"http://127.0.0.1:{PORT}", f"http://localhost:{PORT}"} +HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} + +TOKEN = None +REGISTRY = None + + +def token_path() -> Path: + return config.user_config_path().parent / "token" + + +def load_token() -> str: + """A per-machine secret the tablet's browser holds as a cookie.""" + path = token_path() + if not path.is_file(): + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(secrets.token_urlsafe(32)) + return path.read_text().strip() + + +class Handler(BaseHTTPRequestHandler): + def _authed(self): + try: + c = SimpleCookie(self.headers.get("Cookie", "")).get("deckit") + except CookieError: + return False + return bool(c and TOKEN and hmac.compare_digest(c.value, TOKEN)) + + def _trusted(self, write): + if self.headers.get("Host") not in HOSTS: + return False + origin = self.headers.get("Origin") + return origin in ORIGINS if write else origin in (None, *ORIGINS) + + def _send(self, code, body=b"", ctype="text/plain", cache=False): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "max-age=86400" if cache else "no-store") + self.end_headers() + self.wfile.write(body) + + def _json(self, data): + self._send(200, json.dumps(data).encode(), "application/json") + + def _unlock(self, given): + if not (TOKEN and hmac.compare_digest(given, TOKEN)): + return False + self.send_response(303) + self.send_header("Location", "/") + self.send_header("Set-Cookie", f"deckit={TOKEN}; Path=/; Max-Age=34560000; HttpOnly; SameSite=Strict") + self.send_header("Content-Length", "0") + self.end_headers() + return True + + def do_GET(self): + if not self._trusted(write=False): + return self._send(403) + url = urlsplit(self.path) + path = url.path + if path == "/" and (t := parse_qs(url.query).get("t")) and self._unlock(t[0]): + return + if path == "/theme.css": + return self._send(200, theme.load().encode(), "text/css") + if path.startswith("/api/"): + if not self._authed(): + return self._send(401) + parts = path.strip("/").split("/") + if len(parts) == 2 and parts[1] == "modules": + return self._json(REGISTRY.names()) + if len(parts) == 2: + try: + return self._json(REGISTRY.state(parts[1])) + except module.ModuleError as e: + return self._send(404, str(e).encode()) + return self._send(404) + if path == "/": + path = "/index.html" + file = (STATIC / path.lstrip("/")).resolve() + if file.is_file() and file.is_relative_to(STATIC.resolve()): + ctype = mimetypes.guess_type(file)[0] or "application/octet-stream" + return self._send(200, file.read_bytes(), ctype, cache=file.suffix in (".woff2", ".svg")) + self._send(404) + + def do_POST(self): + if not self._trusted(write=True): + return self._send(403) + if not self._authed(): + return self._send(401) + parts = urlsplit(self.path).path.strip("/").split("/") + if len(parts) != 3 or parts[0] != "api": + return self._send(404) + _, name, action = parts + try: + fn = REGISTRY.get(name).actions.get(action) + except module.ModuleError as e: + return self._send(404, str(e).encode()) + if fn is None: + return self._send(404, b"unknown action") + length = int(self.headers.get("Content-Length", 0) or 0) + if self.headers.get("Content-Type") != "application/json" or not 0 < length <= 4096: + return self._send(400, b"Expected a small JSON body") + try: + result = fn(json.loads(self.rfile.read(length))) + except module.ModuleError as e: + return self._send(400, str(e).encode()) + except (ValueError, AttributeError): + return self._send(400, b"Invalid request") + if result is None: + return self._send(204) + return self._json(result) + + def log_message(self, *args): + pass + + +class UnixHTTPServer(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + + +def serve(): + """Serve the page and API on the Unix socket until killed.""" + global TOKEN, REGISTRY + from .modules import build + + TOKEN = load_token() + REGISTRY = build(CFG) + RUNDIR.mkdir(mode=0o700, parents=True, exist_ok=True) + RUNDIR.chmod(0o700) + SOCKET.unlink(missing_ok=True) + UnixHTTPServer(str(SOCKET), Handler).serve_forever() -- cgit v1.2.3