bastion agent 0.1.14
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"""bastion agent runtime: the `sweep` scheduler and the manifest gate.
|
||||
|
||||
This is what turns seven commands into an agent. Each cycle:
|
||||
|
||||
1. re-read the manifest (a re-signed manifest takes effect next sweep)
|
||||
2. GATE: verify its signature — ECDSA P-256 over the canonical payload,
|
||||
the leaf cert's CN must equal owner.host, and the cert must chain to
|
||||
FREEAGENT_CA_ROOT, which is required. Unsigned or unverifiable → the
|
||||
sweep does nothing and says so. (--allow-unsigned exists for
|
||||
development and is recorded in every run it touches.)
|
||||
3. run the stages in manifest order, each with the module already
|
||||
proven alone; the first failure stops the run (fail closed — a
|
||||
later stage never consumes a half-finished earlier one)
|
||||
4. append one line to run.jsonl, rewrite run.state.json, sleep until
|
||||
the next due time (manifest `sweep`), or exit for `manual`.
|
||||
|
||||
A lock file (atomic create, pid + time inside) keeps two sweeps off one
|
||||
output directory; a stale lock (dead pid on POSIX, or older than
|
||||
LOCK_STALE_HOURS anywhere) is reclaimed and logged.
|
||||
|
||||
Stdlib only. The gate's certificate and signature checks are in x509gate
|
||||
(the stdlib has no ECDSA, so it carries its own P-256 verifier); openssl is
|
||||
needed only by the developer helper that SIGNS a manifest. Python 3.8 floor.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from . import catalog as _catalog
|
||||
from . import embed as _embed
|
||||
from . import extract as _extract
|
||||
from . import sanitize as _sanitize
|
||||
from . import seal as _seal
|
||||
from . import walk as _walk
|
||||
from . import x509gate as _x509
|
||||
from .manifest import AGENT_DIR, ManifestError, load_manifest, signing_payload
|
||||
|
||||
RUN_VERSION = 1
|
||||
SWEEP_SECONDS = {"15m": 15 * 60, "hourly": 60 * 60, "daily": 24 * 60 * 60, "manual": None}
|
||||
LOCK_STALE_HOURS = 12
|
||||
MIN_RETRY_SECONDS = 60
|
||||
|
||||
|
||||
class RunError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GateError(RunError):
|
||||
"""The manifest did not pass the gate; nothing ran."""
|
||||
|
||||
|
||||
# ── the gate ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _openssl():
|
||||
p = shutil.which("openssl")
|
||||
if not p:
|
||||
raise GateError("openssl is not on PATH — cannot verify the manifest signature")
|
||||
return p
|
||||
|
||||
|
||||
def _run(argv, data=None):
|
||||
try:
|
||||
proc = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
timeout=60, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise GateError("%s: %s" % (argv[0], exc))
|
||||
return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def cert_cn(cert_path):
|
||||
try:
|
||||
return _x509.load_cert_file(cert_path).cn()
|
||||
except (OSError, _x509.CertError, ValueError, TypeError, IndexError, OverflowError) as exc:
|
||||
raise GateError("cannot read signing cert %s: %s" % (cert_path, exc))
|
||||
|
||||
|
||||
def verify_signature(manifest, env, manifest_dir="."):
|
||||
"""Raise GateError unless the manifest carries a valid signature.
|
||||
|
||||
Standard library only (x509gate): the gate used to shell out to openssl,
|
||||
which Windows does not ship, so on a Windows machine every manifest was
|
||||
refused and the agent never ran. The checks
|
||||
are the ones openssl made: exactly one CN, equal to the owner; the cert
|
||||
chains to FREEAGENT_CA_ROOT and is inside its validity window; the
|
||||
ECDSA P-256 / SHA-256 signature verifies over the canonical payload."""
|
||||
sig = manifest.signature
|
||||
if sig is None:
|
||||
raise GateError("manifest is unsigned — the runtime refuses to execute it")
|
||||
cert = sig.cert if os.path.isabs(sig.cert) else os.path.join(manifest_dir, sig.cert)
|
||||
if not os.path.exists(cert):
|
||||
raise GateError("signing cert %s not found" % cert)
|
||||
try:
|
||||
leaf = _x509.load_cert_file(cert)
|
||||
cn = leaf.cn()
|
||||
except (OSError, _x509.CertError, ValueError, TypeError, IndexError, OverflowError) as exc:
|
||||
# Anything a malformed cert raises is a refusal, never a crash.
|
||||
raise GateError("cannot read signing cert %s: %s" % (cert, exc))
|
||||
if cn != manifest.owner_host:
|
||||
raise GateError("signing cert CN %r is not the manifest owner %r" % (cn, manifest.owner_host))
|
||||
ca_root = env.get("FREEAGENT_CA_ROOT", "").strip()
|
||||
if not ca_root:
|
||||
# without a root the gate would only prove "signed by the key in the
|
||||
# cert next to the manifest" — that authenticates nothing
|
||||
raise GateError("FREEAGENT_CA_ROOT is not set — the gate cannot verify who signed this manifest")
|
||||
try:
|
||||
with open(ca_root, "rb") as fh:
|
||||
roots = _x509.load_root_file(fh.read())
|
||||
_x509.verify_chain(leaf, roots)
|
||||
except OSError as exc:
|
||||
raise GateError("cannot read FREEAGENT_CA_ROOT %s: %s" % (ca_root, exc))
|
||||
except (_x509.CertError, ValueError, TypeError, IndexError, OverflowError) as exc:
|
||||
raise GateError("signing cert does not chain to FREEAGENT_CA_ROOT: %s" % exc)
|
||||
import base64
|
||||
try:
|
||||
raw = base64.b64decode(sig.value, validate=True)
|
||||
except Exception:
|
||||
raise GateError("signature value is not base64")
|
||||
if not _x509.ecdsa_p256_sha256_verify(leaf.public_key, signing_payload(manifest.raw), raw):
|
||||
raise GateError("manifest signature does not verify (payload changed or wrong key)")
|
||||
return {"cn": cn, "cert": cert, "chained": True}
|
||||
|
||||
|
||||
def manifest_payload(path):
|
||||
"""The exact bytes a manifest's signature covers (the canonical payload).
|
||||
A signer that holds the key elsewhere — the Go bastion, which mints the
|
||||
managed agent's identity with its own crypto — signs these and hands the
|
||||
result back through write_signature."""
|
||||
return signing_payload(load_manifest(path).raw)
|
||||
|
||||
|
||||
def write_signature(path, der_signature, cert_path):
|
||||
"""Write the signature block (a DER ECDSA signature over manifest_payload)
|
||||
into the manifest, replacing any earlier one. Checked before it is kept:
|
||||
the manifest must still load as signed."""
|
||||
import base64
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
# simpler and safe: drop an existing top-level signature block, then append
|
||||
body = _strip_signature_block(text)
|
||||
block = "signature:\n alg: ecdsa-p256-sha256\n cert: %s\n value: %s\n" % (
|
||||
cert_path, base64.b64encode(der_signature).decode("ascii"))
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(body.rstrip("\n") + "\n" + block)
|
||||
load_manifest(path, require_signed=True) # must still validate
|
||||
return block
|
||||
|
||||
|
||||
def sign_manifest_file(path, key_path, cert_path):
|
||||
"""Developer/host helper: sign the canonical payload with a PEM key via
|
||||
openssl and write the signature block. The managed install does not use
|
||||
this — the bastion signs with its own key and calls write_signature — so
|
||||
no machine needs openssl to run the agent."""
|
||||
rc, out, err = _run([_openssl(), "dgst", "-sha256", "-sign", key_path],
|
||||
data=manifest_payload(path))
|
||||
if rc != 0:
|
||||
raise GateError("signing failed: %s" % err.strip())
|
||||
return write_signature(path, out, cert_path)
|
||||
|
||||
|
||||
def _strip_signature_block(text):
|
||||
out, skipping = [], False
|
||||
for line in text.splitlines(keepends=True):
|
||||
if line.startswith("signature:"):
|
||||
skipping = True
|
||||
continue
|
||||
if skipping and (line.startswith(" ") or line.startswith("\t")):
|
||||
continue
|
||||
skipping = False
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ── the lock ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pid_alive(pid):
|
||||
if os.name == "nt":
|
||||
return True # cannot cheaply tell; the age rule decides
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
class Lock:
|
||||
def __init__(self, out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
self.path = os.path.join(out_dir, "sweep.lock")
|
||||
self.reclaimed = None
|
||||
|
||||
def __enter__(self):
|
||||
for attempt in range(2):
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
if attempt == 0 and self._stale():
|
||||
self.reclaimed = self._read()
|
||||
try:
|
||||
os.unlink(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
raise RunError("another sweep holds %s (%s)" % (self.path, self._read()))
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump({"pid": os.getpid(), "since": time.time()}, fh)
|
||||
return self
|
||||
raise RunError("could not acquire %s" % self.path)
|
||||
|
||||
def _read(self):
|
||||
try:
|
||||
with open(self.path) as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def _stale(self):
|
||||
"""A lock is stale when its holder is provably dead. On POSIX that is
|
||||
the pid check and nothing else — a live pid keeps its lock however
|
||||
long the sweep runs (age alone stole live locks). The
|
||||
age rule applies only where the pid cannot be checked (Windows) or the
|
||||
file is unreadable, and then by the FILE's mtime, so a truncated lock
|
||||
is not instantly stale."""
|
||||
info = self._read()
|
||||
pid = info.get("pid")
|
||||
if os.name != "nt" and isinstance(pid, int):
|
||||
return not _pid_alive(pid)
|
||||
try:
|
||||
age = time.time() - os.stat(self.path).st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
return age > LOCK_STALE_HOURS * 3600
|
||||
|
||||
def __exit__(self, *exc):
|
||||
try:
|
||||
os.unlink(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── one run ─────────────────────────────────────────────────────────────────
|
||||
|
||||
STAGES = {
|
||||
"walk": lambda m, out, env: _walk.walk(m, out),
|
||||
"extract": lambda m, out, env: _extract.extract(m, out, use_external=env.get("FREEAGENT_NO_EXTERNAL") != "1"),
|
||||
"sanitize": lambda m, out, env: _sanitize.sanitize(m, out, env=env),
|
||||
"chunk": lambda m, out, env: _embed.chunk(m, out),
|
||||
"embed": lambda m, out, env: _embed.embed(m, out, env=env),
|
||||
"catalog": lambda m, out, env: _catalog.catalog(m, out, env=env),
|
||||
"seal": lambda m, out, env: _seal.seal(m, out, env=env),
|
||||
}
|
||||
STAGE_ERRORS = (FileNotFoundError, _sanitize.SanitizeError, _embed.EmbedError,
|
||||
_catalog.CatalogError, _seal.SealError, OSError)
|
||||
|
||||
|
||||
def _stage_failed(name, summary):
|
||||
"""A stage that completed but reports it stopped is a failure too."""
|
||||
if summary.get("stopped"):
|
||||
return summary["stopped"]
|
||||
return None
|
||||
|
||||
|
||||
def _brief(name, summary):
|
||||
c = summary.get("counts", {})
|
||||
keys = {"walk": ("files", "errors"), "extract": ("ok", "reused", "error"),
|
||||
"sanitize": ("ok", "reused", "not_run"), "chunk": ("chunks",),
|
||||
"embed": ("embedded", "reused"), "catalog": ("files", "dirs", "summaries"),
|
||||
"seal": ()}.get(name, ())
|
||||
out = {k: c[k] for k in keys if k in c}
|
||||
if name == "seal":
|
||||
out = {"bytes_sealed": summary.get("bytes_sealed"), "recipients": len(summary.get("recipients", []))}
|
||||
return out
|
||||
|
||||
|
||||
def default_out(m):
|
||||
"""Where an agent keeps itself: <root>/.agent for a single-root manifest
|
||||
(files-and-folders style: copy the folder, the agent comes with it);
|
||||
build/freeagent/<id> when there are several roots and no obvious home.
|
||||
Every command uses this default, so `sweep` and `serve` run without
|
||||
--out share one folder.
|
||||
|
||||
The directory keeps the old name deliberately: it is on-disk output a user
|
||||
may already have, and renaming it would orphan it."""
|
||||
roots = m.scope.roots
|
||||
if len(roots) == 1:
|
||||
return os.path.join(os.path.abspath(os.path.expanduser(roots[0])), AGENT_DIR)
|
||||
return os.path.join("build", "freeagent", m.id)
|
||||
|
||||
|
||||
def run_once(manifest_path, out_dir=None, env=None, allow_unsigned=False, now=None, clock=time.time):
|
||||
env = dict(os.environ) if env is None else env
|
||||
started = now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
t0 = clock()
|
||||
record = {"v": RUN_VERSION, "started": started, "manifest": manifest_path, "stages": [],
|
||||
"ok": False, "allow_unsigned": bool(allow_unsigned)}
|
||||
try:
|
||||
m = load_manifest(manifest_path)
|
||||
except ManifestError as exc:
|
||||
record.update(error="manifest: %s" % "; ".join(exc.errors), finished=_now(now), seconds=round(clock() - t0, 3))
|
||||
# a refused manifest is a run that happened; the append-only log must
|
||||
# say so. With no id to derive a directory from, the
|
||||
# caller's --out or a fixed 'refused' directory holds the record.
|
||||
_append_run(out_dir or os.path.join("build", "freeagent", "refused"), record)
|
||||
return record, None
|
||||
out_dir = out_dir or default_out(m)
|
||||
record["out"] = out_dir
|
||||
record["manifest_id"] = m.id
|
||||
from .manifest import signing_digest
|
||||
record["manifest_digest"] = "sha256:" + signing_digest(m.raw)
|
||||
try:
|
||||
gate = verify_signature(m, env, manifest_dir=os.path.dirname(os.path.abspath(manifest_path)))
|
||||
record["gate"] = gate
|
||||
except GateError as exc:
|
||||
if not allow_unsigned:
|
||||
record.update(error="gate: %s" % exc, finished=_now(now), seconds=round(clock() - t0, 3))
|
||||
_append_run(out_dir, record) # a gate refusal is on the record too
|
||||
return record, m
|
||||
record["gate"] = {"bypassed": str(exc)}
|
||||
try:
|
||||
with Lock(out_dir) as lock:
|
||||
if lock.reclaimed:
|
||||
record["lock_reclaimed"] = lock.reclaimed
|
||||
for name in m.stages:
|
||||
s0 = clock()
|
||||
entry = {"stage": name}
|
||||
try:
|
||||
summary = STAGES[name](m, out_dir, env)
|
||||
err = _stage_failed(name, summary)
|
||||
entry.update(status="failed" if err else "ok", seconds=round(clock() - s0, 3),
|
||||
**_brief(name, summary))
|
||||
if err:
|
||||
entry["error"] = err
|
||||
except STAGE_ERRORS as exc:
|
||||
entry.update(status="failed", seconds=round(clock() - s0, 3),
|
||||
error="%s: %s" % (type(exc).__name__, exc))
|
||||
record["stages"].append(entry)
|
||||
if entry["status"] != "ok":
|
||||
record["error"] = "stage %s: %s" % (name, entry["error"])
|
||||
break
|
||||
else:
|
||||
record["ok"] = True
|
||||
except RunError as exc:
|
||||
record["error"] = str(exc)
|
||||
record["finished"] = _now(now)
|
||||
record["seconds"] = round(clock() - t0, 3)
|
||||
_append_run(out_dir, record)
|
||||
return record, m
|
||||
|
||||
|
||||
def _now(fixed=None):
|
||||
return fixed or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _append_run(out_dir, record):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "run.jsonl"), "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _write_state(out_dir, state):
|
||||
path = os.path.join(out_dir, "run.state.json")
|
||||
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=out_dir)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(state, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_state(out_dir):
|
||||
try:
|
||||
with open(os.path.join(out_dir, "run.state.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def next_due(sweep, last_started_ts, ok, failures):
|
||||
"""Seconds-since-epoch of the next run. Failures retry with backoff
|
||||
(1 min, 2, 4 … capped at the cadence); manual never schedules."""
|
||||
period = SWEEP_SECONDS.get(sweep)
|
||||
if period is None:
|
||||
return None
|
||||
if ok:
|
||||
return last_started_ts + period
|
||||
backoff = min(period, MIN_RETRY_SECONDS * (2 ** min(failures - 1, 10)))
|
||||
return last_started_ts + backoff
|
||||
|
||||
|
||||
# ── the loop ────────────────────────────────────────────────────────────────
|
||||
|
||||
def sweep(manifest_path, out_dir=None, env=None, allow_unsigned=False, once=False,
|
||||
sleep=time.sleep, clock=time.time, max_runs=None, log=None):
|
||||
"""Run on the manifest's cadence until stopped. Returns the last record."""
|
||||
env = dict(os.environ) if env is None else env
|
||||
log = log or (lambda s: sys.stderr.write(s + "\n"))
|
||||
stop = {"flag": False}
|
||||
|
||||
def _stop(signum, frame):
|
||||
stop["flag"] = True
|
||||
log("sweep: stop requested (signal %d); finishing the current run" % signum)
|
||||
|
||||
for sig in (getattr(signal, "SIGTERM", None), getattr(signal, "SIGINT", None)):
|
||||
if sig is not None:
|
||||
try:
|
||||
signal.signal(sig, _stop)
|
||||
except (ValueError, OSError):
|
||||
pass # not the main thread (tests) — run without handlers
|
||||
|
||||
runs = 0
|
||||
last = None
|
||||
while not stop["flag"]:
|
||||
t_start = clock()
|
||||
record, m = run_once(manifest_path, out_dir, env, allow_unsigned=allow_unsigned, clock=clock)
|
||||
runs += 1
|
||||
last = record
|
||||
out = record.get("out") or out_dir or "."
|
||||
state = load_state(out) if os.path.isdir(out) else {}
|
||||
failures = 0 if record["ok"] else state.get("consecutive_failures", 0) + 1
|
||||
sweep_kind = m.sweep if m is not None else "manual"
|
||||
due = next_due(sweep_kind, t_start, record["ok"], failures)
|
||||
state = {"last_started": record["started"], "last_finished": record.get("finished"),
|
||||
"last_ok": record["ok"], "last_error": record.get("error"),
|
||||
"consecutive_failures": failures, "sweep": sweep_kind,
|
||||
"next_due": _dt.datetime.fromtimestamp(due, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if due else None,
|
||||
"pid": os.getpid(), "runs_this_process": runs}
|
||||
if os.path.isdir(out):
|
||||
_write_state(out, state)
|
||||
stages = ", ".join("%s:%s" % (s["stage"], s["status"]) for s in record["stages"]) or "-"
|
||||
log("sweep: %s %s in %.1fs [%s]%s" % (
|
||||
record.get("manifest_id", "?"), "ok" if record["ok"] else "FAILED",
|
||||
record.get("seconds", 0), stages,
|
||||
"" if record["ok"] else " — " + str(record.get("error"))))
|
||||
if once or due is None or (max_runs and runs >= max_runs):
|
||||
break
|
||||
while not stop["flag"]:
|
||||
wait = due - clock()
|
||||
if wait <= 0:
|
||||
break
|
||||
sleep(min(wait, 30))
|
||||
return last
|
||||
Reference in New Issue
Block a user