257 lines
11 KiB
Python
Executable File
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()
|