bastion agent 0.1.14
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
"""bastion agent query endpoint — the container on the mesh.
|
||||
|
||||
The other half of "access moves, data does not". 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 — the
|
||||
only bytes that leave are sanitized chunks and catalog cards.
|
||||
|
||||
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 bytes
|
||||
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 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
|
||||
self._send(200, {"hits": hits, "k": k}, 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 sanitized-document sha256")
|
||||
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},
|
||||
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()
|
||||
Reference in New Issue
Block a user