aboutsummaryrefslogtreecommitdiffstats
path: root/deckit/server.py
diff options
context:
space:
mode:
Diffstat (limited to 'deckit/server.py')
-rw-r--r--deckit/server.py163
1 files changed, 163 insertions, 0 deletions
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()