diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-09 18:12:33 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-09 18:12:33 +0100 |
| commit | 58b6f3feda3662c2004bdfaa1e7300f14dabd931 (patch) | |
| tree | ead9c8985765af0dd9613f17498c9d636730519f | |
| parent | 5332d87511a9336c004af755f007aaa688d9c654 (diff) | |
| download | deck-it-58b6f3feda3662c2004bdfaa1e7300f14dabd931.tar.gz deck-it-58b6f3feda3662c2004bdfaa1e7300f14dabd931.zip | |
feat: HTTP server with trust, auth, module routing
| -rw-r--r-- | deckit/__main__.py | 24 | ||||
| -rw-r--r-- | deckit/module.py | 4 | ||||
| -rw-r--r-- | deckit/server.py | 163 | ||||
| -rw-r--r-- | static/index.html | 3 | ||||
| -rw-r--r-- | tests/test_server.py | 91 |
5 files changed, 284 insertions, 1 deletions
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() diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4afa18f --- /dev/null +++ b/static/index.html @@ -0,0 +1,3 @@ +<!doctype html> +<html lang="en"><head><meta charset="utf-8"><title>deck-it</title></head> +<body><p>deck-it</p></body></html> diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..1583ad2 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,91 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import json +import os +import socket +import tempfile +import threading +import unittest + +import http.client + +import deckit.module as mod +import deckit.server as srv + +TOKEN = "test-token" +HOST = {"Host": "127.0.0.1:8765", "Cookie": f"deckit={TOKEN}"} +ORIGIN = {"Origin": "http://127.0.0.1:8765"} + + +class UnixConnection(http.client.HTTPConnection): + def __init__(self, path): + super().__init__("localhost") + self.path = path + + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self.path) + + +class Server(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dir = tempfile.TemporaryDirectory() + cls.sock = os.path.join(cls.dir.name, "http.sock") + srv.TOKEN = TOKEN + srv.REGISTRY = mod.Registry([ + mod.Module("demo", "Demo", lambda: {"ok": True}, + {"ping": lambda body: {"got": body.get("n")}}), + ]) + cls.server = srv.UnixHTTPServer(cls.sock, srv.Handler) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.dir.cleanup() + + def req(self, method, path, headers=None, body=None): + conn = UnixConnection(self.sock) + conn.request(method, path, body=body, headers=headers or {}) + r = conn.getresponse() + data = r.read() + conn.close() + return r.status, data + + def test_assets_and_api(self): + self.assertEqual(self.req("GET", "/", HOST)[0], 200) + self.assertEqual(self.req("GET", "/theme.css", HOST)[0], 200) + self.assertEqual(json.loads(self.req("GET", "/api/modules", HOST)[1]), + [{"name": "demo", "title": "Demo"}]) + self.assertEqual(json.loads(self.req("GET", "/api/demo", HOST)[1]), {"ok": True}) + + def test_trust_and_auth(self): + self.assertEqual(self.req("GET", "/api/demo", {"Host": "evil.com"})[0], 403) + self.assertEqual(self.req("POST", "/api/demo/x", {**HOST, "Origin": "https://evil.com"})[0], 403) + self.assertEqual(self.req("GET", "/api/demo", {"Host": "127.0.0.1:8765"})[0], 401) + + def test_unlock_and_bad_action(self): + conn = UnixConnection(self.sock) + conn.request("GET", f"/?t={TOKEN}", headers={"Host": "127.0.0.1:8765"}) + r = conn.getresponse() + r.read() + conn.close() + self.assertEqual(r.status, 303) + self.assertIn("deckit=", r.getheader("Set-Cookie")) + self.assertIn("SameSite=Strict", r.getheader("Set-Cookie")) + self.assertEqual(self.req("POST", "/api/demo/nope", {**HOST, **ORIGIN})[0], 404) + + def test_rejects_oversized_body(self): + big = "x" * 5000 + status, _ = self.req("POST", "/api/demo/ping", + {**HOST, **ORIGIN, "Content-Type": "application/json"}, big) + self.assertEqual(status, 400) + + +if __name__ == "__main__": + unittest.main() |
