"""bastion agent query endpoint — the container on the mesh. Serves the owner's host and the manifest's grantees over MUTUAL TLS; nothing here ever reads the extract store, the vault, or the vectors' raw table for a client. What it returns is sanitized chunks, catalog cards, the run state and health. (The bastion's own `read` op is not this server: it reads files in the granted folder as they are on disk.) Identity is the presented client certificate: - the TLS layer requires a client cert that chains to FREEAGENT_CA_ROOT (ssl.CERT_REQUIRED); no cert or a foreign CA never reaches a handler - the cert's CN is the caller. CN == manifest owner.host → owner. CN in manifest.grants (and today <= until) → grantee with that entry's ops. Anyone else who chained → 403, audited. Ops (JSON over HTTPS): POST /search {query, k?} search (owner, grantee w/ op) POST /read_range {doc, start, end} read_range (owner, grantee w/ op) from the SANITIZED store only, <= READ_RANGE_MAX characters Both answer with `sanitize_mode`, the mode the sanitize stage recorded for the text in the reply ("flag" or "sterilize"; the weakest across a search's hits; "unknown" when the record does not vouch for that text; a search with no hits names the manifest's mode, or "none"), so a caller can say what the text has had done to it: in flag mode names, street addresses and dates of birth remain. GET /catalog[?prefix=&offset=&limit=] owner only: the file cards (paged) GET /catalog/dirs[?...] owner only: the directory cards (paged) GET /status owner only: run.state.json GET /health any chained cert: {ok, id} The manifest is re-read and re-gated (signature verified) whenever its file changes: revoke a grant = drop the entry, re-sign, and the next request is refused. A manifest that no longer passes the gate makes the server refuse everything but /health until it does. Every request is audited to /serve.audit.jsonl: CN, op, status, bytes, a sha256 of the query (never the query), doc id for read_range. Bind: --bind or FREEAGENT_BIND (host:port, default [::]:8470). The bastion binds 127.0.0.1:0 and reads the port from --port-file, so it is the only client; bind another interface only for another machine to reach. Server cert and key: FREEAGENT_TLS_CERT / FREEAGENT_TLS_KEY. Stdlib only; the embedding endpoint is the same sensor `search` already uses. """ import datetime as _dt import hashlib import json import os import ssl import sys import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import embed as _embed from .manifest import ManifestError, load_manifest from .run import GateError, verify_signature from .sanitize import store_path as sanitized_store_path DEFAULT_BIND = "[::]:8470" MAX_BODY = 64 * 1024 READ_RANGE_MAX = 64 * 1024 SEARCH_K_MAX = 50 CATALOG_PAGE_DEFAULT = 500 CATALOG_PAGE_MAX = 2000 OWNER_OPS = ("search", "read_range", "catalog", "status") class ServeError(Exception): pass def sanitize_mode(manifest): """The sanitize mode the manifest sets: "flag", "sterilize", or "none" when it names no sanitize stage. A reply with no text in it (a search with no hits) carries this; a reply with text carries served_mode.""" s = getattr(manifest, "sanitize", None) return getattr(s, "mode", None) or "none" # Weakest first: a reply that mixes texts names the weakest mode among them. _MODE_RANK = {"unknown": 0, "flag": 1, "sterilize": 2} _index_lock = threading.Lock() _index_cache = {} # sanitize.jsonl path -> ((ino, mtime_ns, size), {doc: mode}) def _sanitize_index(out_dir): """({doc sha: mode} for every document the last sanitize run wrote with status ok, the index's mtime_ns), from /sanitize.jsonl; ({}, None) when there is none. Re-read only when the file changes.""" path = os.path.join(out_dir, "sanitize.jsonl") try: st = os.stat(path) except OSError: return {}, None sig = (st.st_ino, st.st_mtime_ns, st.st_size) with _index_lock: hit = _index_cache.get(path) if hit and hit[0] == sig: return hit[1], st.st_mtime_ns modes = {} try: with open(path, encoding="utf-8") as fh: for line in fh: try: rec = json.loads(line) except ValueError: continue if isinstance(rec, dict) and rec.get("status") == "ok" and rec.get("sha256"): modes[rec["sha256"]] = rec.get("mode") except OSError: return {}, None _index_cache[path] = (sig, modes) return modes, st.st_mtime_ns def served_mode(out_dir, doc, start=None, text=None): """The sanitize mode that wrote the text served for doc, as the sanitize stage recorded it, or "unknown" when that record does not vouch for the text. The manifest's mode says what the NEXT sweep will do; the store can still hold an earlier sweep's text (the manifest switched to sterilize and the sweep has not run, or stopped; a sweep is rewriting the store now; the chunks a search reads were cut before the latest sanitize run). Unknown when: no status-ok record for doc, the file is newer than the index (a run that has not finished rewrote it), or, for a search hit, `text` at `start` is not what the store holds now.""" modes, index_mtime = _sanitize_index(out_dir) mode = modes.get(doc) if mode not in ("flag", "sterilize"): return "unknown" path = sanitized_store_path(out_dir, doc) try: if os.stat(path).st_mtime_ns > index_mtime: return "unknown" if text is not None: with open(path, encoding="utf-8") as fh: stored = fh.read() if stored[start:start + len(text)] != text: return "unknown" except (OSError, TypeError, ValueError): return "unknown" return mode def _weakest(modes): return min(modes, key=lambda m: _MODE_RANK.get(m, 0)) def parse_bind(bind): bind = bind.strip() if bind.startswith("["): host, _, port = bind[1:].partition("]") port = port.lstrip(":") else: host, _, port = bind.rpartition(":") if not host or not port.isdigit(): raise ServeError("FREEAGENT_BIND must be host:port or [v6]:port, got %r" % bind) return host, int(port) def peer_cn(cert): for rdn in (cert or {}).get("subject", ()): for k, v in rdn: if k == "commonName": return v return None class Policy: """Who may do what, from a gated manifest. Reloads on file change.""" def __init__(self, manifest_path, env, allow_unsigned=False, today=None): self.path = manifest_path self.env = env self.allow_unsigned = allow_unsigned self.today = today self._mtime = None self.manifest = None self.gate_error = None self._lock = threading.Lock() self.reload() def reload(self): with self._lock: try: mtime = os.stat(self.path).st_mtime_ns except OSError as e: self.manifest, self.gate_error = None, "manifest unreadable: %s" % e return if mtime == self._mtime and (self.manifest or self.gate_error): return self._mtime = mtime try: m = load_manifest(self.path) except ManifestError as e: self.manifest, self.gate_error = None, "manifest invalid: %s" % "; ".join(e.errors) return except Exception as e: # noqa: BLE001 # FAIL CLOSED here too: a manifest that cannot be read at all # (permissions, bad encoding) must not leave the previous one # in force once _mtime has advanced. self.manifest, self.gate_error = None, "manifest unreadable: %s" % e return try: verify_signature(m, self.env, manifest_dir=os.path.dirname(os.path.abspath(self.path))) except GateError as e: if not self.allow_unsigned: self.manifest, self.gate_error = None, "gate: %s" % e return except Exception as e: # noqa: BLE001 # FAIL CLOSED: an unexpected error in the gate must never leave # the previous manifest in force (_mtime is already advanced). self.manifest, self.gate_error = None, "gate: %s" % e return self.manifest, self.gate_error = m, None def allowed_ops(self, cn): """Ops for this CN, or an empty tuple. Owner gets everything the mask permits; a grantee gets its entry's ops while unexpired.""" self.reload() m = self.manifest if m is None or not cn: return () if cn == m.owner_host: ops = list(OWNER_OPS) if "search" not in m.mask: ops.remove("search") return tuple(ops) today = self.today or _dt.datetime.now(_dt.timezone.utc).date() for g in m.grants: if g.host == cn: if today > g.until: return () return tuple(g.ops) return () HANDSHAKE_TIMEOUT = 10 class Handler(BaseHTTPRequestHandler): server_version = "freeagent" protocol_version = "HTTP/1.1" # keep-alive is one thread per connection here; an idle client must not # pin that thread forever (a bastion review measured exactly that) timeout = 60 def setup(self): # The TLS handshake happens HERE, in this connection's own thread — # never in the accept loop. Wrapping the listening socket (the first # cut) ran every handshake synchronously in accept(), so one idle # pre-auth TCP connection stalled every client. # The raw socket arrives with a short timeout from get_request, so a # peer that connects and says nothing is dropped after # HANDSHAKE_TIMEOUT, and only its own thread waited. self.request = self.server.ssl_ctx.wrap_socket( self.request, server_side=True, do_handshake_on_connect=False) self.request.do_handshake() self.connection = self.request super().setup() policy = None # injected by build_server out_dir = None env = None embed_endpoint = None audit_lock = threading.Lock() # ── plumbing ──────────────────────────────────────────────────────── def _cn(self): try: return peer_cn(self.connection.getpeercert()) except (AttributeError, ValueError): return None def _send(self, code, obj, cn=None, op=None, extra=None): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) self._audit(cn, op, code, len(body), extra) def _audit(self, cn, op, status, nbytes, extra): rec = {"ts": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "cn": cn, "op": op, "path": self.path, "status": status, "bytes": nbytes} if extra: rec.update(extra) with self.audit_lock: with open(os.path.join(self.out_dir, "serve.audit.jsonl"), "a", encoding="utf-8") as fh: fh.write(json.dumps(rec, sort_keys=True) + "\n") def _body(self): n = int(self.headers.get("Content-Length", "0") or 0) if n > MAX_BODY: raise ValueError("body too large") raw = self.rfile.read(n) if n else b"{}" obj = json.loads(raw.decode("utf-8")) if not isinstance(obj, dict): raise ValueError("body must be a JSON object") return obj def _authorize(self, op): cn = self._cn() allowed = self.policy.allowed_ops(cn) if op in allowed: return cn, None if self.policy.gate_error: return cn, (503, {"error": "manifest not serving: %s" % self.policy.gate_error}) return cn, (403, {"error": "not permitted for %s: %s" % (cn or "no-cn", op)}) def log_message(self, fmt, *args): pass # the audit file is the log; no request lines on stderr # ── routes ─────────────────────────────────────────────────────────── def do_GET(self): cn = self._cn() if self.path == "/health": m = self.policy.manifest self.policy.reload() self._send(200, {"ok": True, "id": m.id if m else None, "serving": self.policy.gate_error is None}, cn, "health") return route, _, qs = self.path.partition("?") if route in ("/catalog", "/catalog/dirs"): # /catalog = file cards; /catalog/dirs = directory cards — the MAP # (sizes, types, newest change, keywords, entity counts per folder) # a host plans a cleanup or an archive from without reading a file. # PAGED: ?prefix=&offset=&limit= — a big store's # file cards are megabytes and a client's reply cap is finite. cn, denied = self._authorize("catalog") if denied: self._send(denied[0], denied[1], cn, "catalog") return q = urllib.parse.parse_qs(qs) prefix = (q.get("prefix") or [""])[0] try: offset = max(0, int((q.get("offset") or ["0"])[0])) limit = max(1, min(CATALOG_PAGE_MAX, int((q.get("limit") or [str(CATALOG_PAGE_DEFAULT)])[0]))) except ValueError: self._send(400, {"error": "offset/limit must be integers"}, cn, "catalog") return name = "catalog.dirs.jsonl" if route.endswith("/dirs") else "catalog.jsonl" path = os.path.join(self.out_dir, name) if not os.path.exists(path): self._send(404, {"error": "no catalog yet — the first sweep has not finished"}, cn, "catalog") return with open(path, encoding="utf-8") as fh: cards = [json.loads(l) for l in fh] if prefix: cards = [c for c in cards if str(c.get("path", "")).startswith(prefix)] total = len(cards) page = cards[offset:offset + limit] nxt = offset + limit if offset + limit < total else None self._send(200, {"cards": page, "kind": "dirs" if name.endswith("dirs.jsonl") else "files", "total": total, "offset": offset, "limit": limit, "next_offset": nxt, "prefix": prefix}, cn, "catalog", {"kind": name, "n": len(page), "total": total}) return if self.path == "/status": cn, denied = self._authorize("status") if denied: self._send(denied[0], denied[1], cn, "status") return path = os.path.join(self.out_dir, "run.state.json") state = {} if os.path.exists(path): with open(path, encoding="utf-8") as fh: state = json.load(fh) self._send(200, {"state": state}, cn, "status") return self._send(404, {"error": "not found"}, cn, None) def do_POST(self): if self.path == "/search": cn, denied = self._authorize("search") if denied: self._send(denied[0], denied[1], cn, "search") return try: req = self._body() query = req["query"] k = int(req.get("k", 5)) if not isinstance(query, str) or not query.strip(): raise ValueError("query must be a non-empty string") k = max(1, min(SEARCH_K_MAX, k)) except (ValueError, KeyError, TypeError) as e: self._send(400, {"error": "bad request: %s" % e}, cn, "search") return qhash = hashlib.sha256(query.encode("utf-8")).hexdigest()[:16] try: hits = _embed.search(self.policy.manifest, self.out_dir, query, k=k, env=self.env, endpoint=self.embed_endpoint) except (_embed.EmbedError, OSError) as e: self._send(502, {"error": "search unavailable: %s" % e}, cn, "search", {"q": qhash}) return if hits: mode = _weakest([served_mode(self.out_dir, h.get("doc"), h.get("start"), h.get("text")) for h in hits]) else: mode = sanitize_mode(self.policy.manifest) self._send(200, {"hits": hits, "k": k, "sanitize_mode": mode}, cn, "search", {"q": qhash, "hits": len(hits)}) return if self.path == "/read_range": cn, denied = self._authorize("read_range") if denied: self._send(denied[0], denied[1], cn, "read_range") return try: req = self._body() doc = req["doc"] start = int(req.get("start", 0)) end = int(req["end"]) if not isinstance(doc, str) or len(doc) != 64 or not all(c in "0123456789abcdef" for c in doc): raise ValueError("doc must be a document id: the original file's sha256, " "64 lowercase hex") if start < 0 or end <= start: raise ValueError("start/end out of order") end = min(end, start + READ_RANGE_MAX) except (ValueError, KeyError, TypeError) as e: self._send(400, {"error": "bad request: %s" % e}, cn, "read_range") return path = sanitized_store_path(self.out_dir, doc) # the ONLY store with a route if not os.path.exists(path): self._send(404, {"error": "no such document"}, cn, "read_range", {"doc": doc[:12]}) return with open(path, encoding="utf-8") as fh: text = fh.read() piece = text[start:end] self._send(200, {"doc": doc, "start": start, "end": start + len(piece), "total": len(text), "text": piece, "sanitize_mode": served_mode(self.out_dir, doc)}, cn, "read_range", {"doc": doc[:12], "chars": len(piece)}) return self._send(404, {"error": "not found"}, self._cn(), None) class _Server(ThreadingHTTPServer): daemon_threads = True ssl_ctx = None # set by build_server; used by Handler.setup per connection def get_request(self): # Plain TCP accept; the handshake is the handler thread's job (Handler.setup). sock, addr = self.socket.accept() sock.settimeout(HANDSHAKE_TIMEOUT) return sock, addr def handle_error(self, request, client_address): # a client that fails the TLS handshake (no cert, foreign CA) is the # expected case, not a bug: one line, no traceback, no audit row # (it never became a request) exc = sys.exc_info()[1] if isinstance(exc, (ssl.SSLError, ConnectionError, OSError)): sys.stderr.write("freeagent serve: rejected %s: %s\n" % (client_address[0], exc)) return super().handle_error(request, client_address) def build_ssl_context(cert, key, ca_root): for label, p in (("FREEAGENT_TLS_CERT", cert), ("FREEAGENT_TLS_KEY", key), ("FREEAGENT_CA_ROOT", ca_root)): if not p: raise ServeError("%s is not set — the endpoint is mTLS-only; refusing to serve" % label) if not os.path.exists(p): raise ServeError("%s=%s does not exist" % (label, p)) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.verify_mode = ssl.CERT_REQUIRED ctx.load_verify_locations(ca_root) ctx.load_cert_chain(cert, key) return ctx def build_server(manifest_path, out_dir, env=None, allow_unsigned=False, bind=None, embed_endpoint=None, today=None, port_file=None): env = dict(os.environ) if env is None else env ctx = build_ssl_context(env.get("FREEAGENT_TLS_CERT", ""), env.get("FREEAGENT_TLS_KEY", ""), env.get("FREEAGENT_CA_ROOT", "")) policy = Policy(manifest_path, env, allow_unsigned=allow_unsigned, today=today) if policy.manifest is None and not allow_unsigned: raise ServeError("refusing to start: %s" % policy.gate_error) os.makedirs(out_dir, exist_ok=True) handler = type("BoundHandler", (Handler,), { "policy": policy, "out_dir": out_dir, "env": env, "embed_endpoint": embed_endpoint}) host, port = parse_bind(bind or env.get("FREEAGENT_BIND", DEFAULT_BIND)) srv = _Server((host, port), handler, bind_and_activate=False) if ":" in host: import socket srv.address_family = socket.AF_INET6 srv.socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) srv.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.server_bind() srv.server_activate() srv.ssl_ctx = ctx # per-connection wrap in Handler.setup, not on the listener srv.policy = policy if port_file: # a supervisor (the bastion) binds port 0 and reads the real port here tmp = port_file + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: fh.write("%d\n" % srv.server_address[1]) os.replace(tmp, port_file) return srv def serve(manifest_path, out_dir, env=None, allow_unsigned=False, bind=None, log=None, port_file=None): log = log or (lambda s: sys.stderr.write(s + "\n")) srv = build_server(manifest_path, out_dir, env, allow_unsigned=allow_unsigned, bind=bind, port_file=port_file) m = srv.policy.manifest log("freeagent serve: %s on %s (owner %s, %d grant(s), mTLS)" % ( m.id if m else "?", srv.server_address[:2], m.owner_host if m else "?", len(m.grants) if m else 0)) try: srv.serve_forever() finally: srv.server_close()