518 lines
22 KiB
Python
518 lines
22 KiB
Python
"""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 <out>/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 <out>/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=<path prefix>&offset=<n>&limit=<n> — 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()
|