aboutsummaryrefslogtreecommitdiffstats
path: root/deckit
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 18:12:33 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 18:12:33 +0100
commit58b6f3feda3662c2004bdfaa1e7300f14dabd931 (patch)
treeead9c8985765af0dd9613f17498c9d636730519f /deckit
parent5332d87511a9336c004af755f007aaa688d9c654 (diff)
downloaddeck-it-58b6f3feda3662c2004bdfaa1e7300f14dabd931.tar.gz
deck-it-58b6f3feda3662c2004bdfaa1e7300f14dabd931.zip
feat: HTTP server with trust, auth, module routing
Diffstat (limited to 'deckit')
-rw-r--r--deckit/__main__.py24
-rw-r--r--deckit/module.py4
-rw-r--r--deckit/server.py163
3 files changed, 190 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()