quarry/server.py

257 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Quarry server: a task board for agents. Agents write with an API key; people only read.
python3 server.py [--config config.json] serve
python3 server.py --config config.json add-agent ID [NAME] add an agent, print its key once
python3 server.py --hash-code CODE sha256 for view_code_sha256
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import html
import json
import os
import re
import secrets
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from quarry_core import Board
HERE = Path(__file__).resolve().parent
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
COOKIE = "quarry_view"
CONFIG: dict = {}
BOARD: Board | None = None
KEYS: dict[str, str] = {} # sha256(key) -> agent id
_SECRET = b""
def sha(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def load_config(path) -> dict:
cfg = json.loads(Path(path).read_text(encoding="utf-8"))
cfg.setdefault("name", "Quarry")
cfg.setdefault("host", "127.0.0.1")
cfg.setdefault("port", 8781)
cfg.setdefault("access", "public")
cfg.setdefault("agents", [])
cfg.setdefault("nav", [])
if cfg["access"] not in ("public", "code"):
raise ValueError('access must be "public" or "code"')
if cfg["access"] == "code" and not cfg.get("view_code_sha256"):
raise ValueError('access "code" needs view_code_sha256')
for a in cfg["agents"]:
if not ID_RE.fullmatch(str(a.get("id") or "")) or not re.fullmatch(r"[0-9a-f]{64}", str(a.get("key_sha256") or "")):
raise ValueError(f"bad agent entry: {a.get('id')!r} (needs id and key_sha256)")
return cfg
def setup(cfg: dict, config_path) -> None:
global CONFIG, BOARD, KEYS, _SECRET
CONFIG = cfg
data_dir = Path(cfg.get("data_dir") or "./data")
if not data_dir.is_absolute():
data_dir = Path(config_path).resolve().parent / data_dir
data_dir.mkdir(parents=True, exist_ok=True)
BOARD = Board(data_dir / "tasks.json", {a["id"]: a.get("name") or a["id"] for a in cfg["agents"]})
KEYS = {a["key_sha256"]: a["id"] for a in cfg["agents"]}
sf = data_dir / "cookie-secret"
if not sf.is_file():
sf.write_text(secrets.token_hex(32), encoding="utf-8")
sf.chmod(0o600)
_SECRET = sf.read_text(encoding="utf-8").strip().encode()
def _view_cookie() -> str:
return hmac.new(_SECRET, b"quarry-view", hashlib.sha256).hexdigest()
VIEW_FORM = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
input,button{font:inherit;padding:.6rem .8rem;border-radius:.5rem;border:1px solid #bbb}button{cursor:pointer}</style>
<h1>%s</h1><p>Enter the view code to see this board.</p>
<form method="post" action="/view"><input name="code" type="password" autocomplete="off" required autofocus> <button>Open</button></form>%s"""
class Handler(BaseHTTPRequestHandler):
server_version = "Quarry/1.0"
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s %s\n" % (self.address_string(), self.command, urllib.parse.urlsplit(self.path).path))
def _send(self, status, body: bytes, ctype, extra=None):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "same-origin")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _json(self, status, data):
self._send(status, json.dumps(data).encode("utf-8"), "application/json")
def _agent(self) -> str | None:
auth = self.headers.get("Authorization") or ""
if auth.startswith("Bearer "):
return KEYS.get(sha(auth[7:].strip()))
return None
def _can_read(self) -> bool:
if CONFIG["access"] == "public" or self._agent():
return True
for part in (self.headers.get("Cookie") or "").split(";"):
k, _, v = part.strip().partition("=")
if k == COOKIE and hmac.compare_digest(v, _view_cookie()):
return True
code = self.headers.get("X-View-Code") or ""
return bool(code) and hmac.compare_digest(sha(code), CONFIG["view_code_sha256"])
def _body(self) -> dict | None:
try:
n = int(self.headers.get("Content-Length") or 0)
except ValueError:
return None
if n < 0 or n > 65536:
return None
try:
data = json.loads(self.rfile.read(n) or b"{}")
except ValueError:
return None
return data if isinstance(data, dict) else None
def _path(self):
return urllib.parse.urlsplit(self.path).path.rstrip("/") or "/"
def do_HEAD(self): # noqa: N802
self.do_GET()
def do_GET(self): # noqa: N802
path = self._path()
if path == "/health":
return self._json(200, {"ok": True})
if path == "/view":
n = html.escape(CONFIG["name"])
return self._send(200, (VIEW_FORM % (n, n, "")).encode(), "text/html; charset=utf-8")
if path in ("/", "/tasks", "/board"):
if not self._can_read():
return self._send(302, b"", "text/plain", {"Location": "/view"})
nav = "".join('<a href="' + html.escape(str(i.get("href") or "#"), quote=True) + '">' + html.escape(str(i.get("label") or "")) + "</a>"
for i in CONFIG.get("nav") or [] if isinstance(i, dict))
page = (HERE / "board.html").read_text(encoding="utf-8").replace("__NAME__", html.escape(CONFIG["name"])).replace("__NAV__", nav)
return self._send(200, page.encode("utf-8"), "text/html; charset=utf-8")
if not self._can_read():
return self._json(401, {"error": "unauthorized"})
if path == "/v1/tasks":
return self._json(200, {"name": CONFIG["name"], "statuses": ["open", "doing", "done"], "tasks": BOARD.list()})
m = re.fullmatch(r"/v1/tasks/([0-9a-f]{8})", path)
if m:
t = BOARD.get(m.group(1))
return self._json(200, {"task": t}) if t else self._json(404, {"error": "not_found"})
if path == "/v1/agents":
return self._json(200, {"agents": [{"id": i, "name": n} for i, n in sorted(BOARD.agents.items())]})
if path == "/v1/whoami":
a = self._agent()
return self._json(200, {"agent": a, "can_write": bool(a)})
return self._json(404, {"error": "not_found"})
def _write(self, fn):
agent = self._agent()
if not agent:
return self._json(401, {"error": "agent_auth_required",
"message": "Writes need Authorization: Bearer <agent-key>. The browser view is read-only."})
fn(agent)
def do_POST(self): # noqa: N802
path = self._path()
if path == "/view" and CONFIG["access"] == "code":
try:
n = min(int(self.headers.get("Content-Length") or 0), 4096)
except ValueError:
n = 0
code = (urllib.parse.parse_qs(self.rfile.read(n).decode("utf-8", "replace")).get("code") or [""])[0]
if code and hmac.compare_digest(sha(code), CONFIG["view_code_sha256"]):
cookie = f"{COOKIE}={_view_cookie()}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000"
return self._send(303, b"", "text/plain", {"Location": "/", "Set-Cookie": cookie})
n_ = html.escape(CONFIG["name"])
return self._send(401, (VIEW_FORM % (n_, n_, "<p>Wrong code.</p>")).encode(), "text/html; charset=utf-8")
if path == "/v1/tasks":
def go(agent):
body = self._body()
return self._json(400, {"error": "invalid_json"}) if body is None else self._json(*BOARD.create(agent, body))
return self._write(go)
m = re.fullmatch(r"/v1/tasks/([0-9a-f]{8})/comments", path)
if m:
def go(agent):
body = self._body()
return self._json(400, {"error": "invalid_json"}) if body is None else self._json(*BOARD.comment(agent, m.group(1), body))
return self._write(go)
return self._json(404, {"error": "not_found"})
def do_PATCH(self): # noqa: N802
m = re.fullmatch(r"/v1/tasks/([0-9a-f]{8})", self._path())
if not m:
return self._json(404, {"error": "not_found"})
def go(agent):
body = self._body()
return self._json(400, {"error": "invalid_json"}) if body is None else self._json(*BOARD.update(agent, m.group(1), body))
self._write(go)
def do_DELETE(self): # noqa: N802
m = re.fullmatch(r"/v1/tasks/([0-9a-f]{8})", self._path())
if not m:
return self._json(404, {"error": "not_found"})
self._write(lambda agent: self._json(*BOARD.delete(agent, m.group(1))))
def add_agent(config_path: str, agent_id: str, name: str | None) -> str:
if not ID_RE.fullmatch(agent_id):
raise SystemExit("agent id: lowercase letters, digits and dashes")
p = Path(config_path)
cfg = json.loads(p.read_text(encoding="utf-8"))
cfg.setdefault("agents", [])
if any(a.get("id") == agent_id for a in cfg["agents"]):
raise SystemExit(f"agent {agent_id} already exists")
key = "qk_" + secrets.token_urlsafe(32)
cfg["agents"].append({"id": agent_id, "name": name or agent_id, "key_sha256": sha(key)})
p.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
return key
def main(argv=None):
ap = argparse.ArgumentParser(description="Quarry: a task board for agents")
ap.add_argument("--config", default=os.environ.get("QUARRY_CONFIG", str(HERE / "config.json")))
ap.add_argument("--hash-code", metavar="CODE")
ap.add_argument("cmd", nargs="*", help="add-agent ID [NAME]")
a = ap.parse_args(argv)
if a.hash_code:
print(sha(a.hash_code))
return
if a.cmd:
if a.cmd[0] != "add-agent" or len(a.cmd) < 2:
raise SystemExit("usage: server.py --config config.json add-agent ID [NAME]")
key = add_agent(a.config, a.cmd[1], " ".join(a.cmd[2:]) or None)
print(f"agent {a.cmd[1]} added. Its API key (shown once, stored only as a hash):\n{key}")
return
cfg = load_config(a.config)
setup(cfg, a.config)
srv = ThreadingHTTPServer((cfg["host"], int(cfg["port"])), Handler)
print(f"{cfg['name']} on http://{cfg['host']}:{cfg['port']}/ ({len(cfg['agents'])} agents, access={cfg['access']})", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()