New kernel modules, all wired into `homelab` CLI + tested against live
production where reachable:
- oikos/scheduler.py — Observe stage: HTTP health probes for every
service, disk-usage probes on hubris/strong, writes oikos/state.json
(gitignored — regenerates every run). `homelab service <name> health`
is now cache-first; `--live` forces a fresh probe. Deploys via
oikos/systemd/oikos-scheduler.{timer,service} on LXC 105.
- oikos/drift.py — SOPS-recipient-vs-inventory and lifecycle-consistency
detectors (fully local, no SSH) plus pct-list and Caddy-backend
detectors (best-effort SSH, degrade to an info finding when
unreachable rather than a false drift alarm). Found real, currently-
true drift on first run: republic-laptop's age key granted on every
secret but missing from inventory.yaml, grimmory missing from
hello.yaml's recipients, and an undocumented pve_id 131 on hubris —
recorded in OIKOS.md for the operator, not auto-fixed (each is a
config_mutation/destructive decision).
- oikos/signal.py — the attention layer: raised -> acknowledged ->
acting -> resolved|muted lifecycle, severity-based routing, dedup via
open_signal_for(). `homelab signal list|raise|ack|resolve|mute`.
- oikos/decide.py — the Decide-stage classifier: risk class x blast
radius x ledger-history confidence -> auto-act/escalate. Adds an
action-alias layer (oikos/policy.py ACTION_ALIASES) and auto-infers
service_name from the entity for per-service policy overrides.
`homelab decide <action> <entity>`.
- oikos/approve.py — the escalate route. No dedicated Matrix bot exists
in this homelab, so this is the repo-side half only: request/reply/
grant lifecycle with short-TTL HMAC-signed tokens (new secret
secrets/oikos-approval-hmac.yaml, recipients apps+hubris). Matrix
delivery is Hermes's existing @dtoro:avispero send path (documented
integration contract in the module docstring), not a new bot.
`homelab restart` now mechanically refuses config_mutation/destructive
services without a valid --approval-id, regardless of -y/interactivity.
- oikos/report.py — daily brief + weekly report from signal/approval/
ledger state (no Prometheus yet, so point-in-time counts only).
- plans/2026-07-05-oikos-prometheus-lxc.md — Prometheus is `planned`,
not provisioned: no pve_id is guessed here since Proxmox assigns real
IDs at creation time, and drift already found an unclaimed ID (131) to
investigate first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
303 lines
14 KiB
Python
303 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""oikos/drift.py — drift detectors (Week 3 reliability layer).
|
|
|
|
Each detector returns a list of finding dicts:
|
|
{kind, severity, entity, evidence, likely_cause, recommended_action,
|
|
verification}
|
|
matching the Signal schema in oikos/signal.py. `run_all()` runs every
|
|
detector and raises a Signal for each finding, skipping ones that already
|
|
have an open Signal for the same (entity, kind) — see
|
|
oikos/signal.py open_signal_for(). SSH-based detectors degrade to a
|
|
"probe-unreachable" info finding instead of a false drift signal when the
|
|
target can't be reached (no ssh, no network) — drift means "state
|
|
disagrees," not "couldn't check."
|
|
|
|
Two detectors are fully local (no SSH, always safe to run from anywhere
|
|
with the repo checked out): SOPS-recipient-vs-inventory and lifecycle
|
|
consistency. Two need live access to hubris (pct list, caddy backend
|
|
config) and gracefully no-op when unreachable.
|
|
|
|
Not yet implemented — needs data this repo doesn't structure yet:
|
|
- DNS records vs inventory services (no Technitium API wiring here)
|
|
- Generic tracked-config-repo cleanliness for every service (needs the
|
|
`mutation_path`/local-checkout-path field per service; only caddy's
|
|
path is documented today, so that's the one config-cleanliness check
|
|
implemented below)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO))
|
|
from oikos import relations as oikos_relations # noqa: E402
|
|
from oikos import signal as oikos_signal # noqa: E402
|
|
|
|
import yaml # noqa: E402
|
|
|
|
SOPS_FILE = REPO / ".sops.yaml"
|
|
INVENTORY = REPO / "inventory.yaml"
|
|
|
|
_AGE_RE = re.compile(r"age1[a-z0-9]+")
|
|
|
|
|
|
def _load_inventory() -> dict:
|
|
return yaml.safe_load(INVENTORY.read_text())
|
|
|
|
|
|
def _sops_rules() -> list[dict]:
|
|
"""Parse .sops.yaml's creation_rules: [{path_regex, recipients: [...]}]."""
|
|
doc = yaml.safe_load(SOPS_FILE.read_text())
|
|
return [
|
|
{"path_regex": r.get("path_regex", ""),
|
|
"recipients": _AGE_RE.findall(r.get("age", "") or "")}
|
|
for r in doc.get("creation_rules", [])
|
|
]
|
|
|
|
|
|
def check_sops_recipients(inv: dict | None = None) -> list[dict]:
|
|
"""Two directions of drift:
|
|
1. A recipient pubkey in .sops.yaml that matches no host's age_pubkey
|
|
in inventory — likely a removed/revoked host whose secrets weren't
|
|
fully cleaned up (or a stale rule).
|
|
2. An enrolled host (non-empty age_pubkey) missing from the
|
|
secrets/hello.yaml rule — every enrolled client should be able to
|
|
decrypt the bootstrap canary; absence usually means enrollment
|
|
stalled partway.
|
|
"""
|
|
inv = inv or _load_inventory()
|
|
hosts = inv.get("hosts", {})
|
|
known_pubkeys = {e["age_pubkey"]: name for name, e in hosts.items()
|
|
if e.get("age_pubkey")}
|
|
rules = _sops_rules()
|
|
findings = []
|
|
|
|
all_recipients = {pk for r in rules for pk in r["recipients"]}
|
|
for pk in all_recipients:
|
|
if pk not in known_pubkeys:
|
|
findings.append({
|
|
"kind": "sops-orphan-recipient", "severity": "warning",
|
|
"entity": "repo:Homelab-Docs",
|
|
"evidence": f"age recipient {pk[:20]}... appears in .sops.yaml but "
|
|
f"matches no host's age_pubkey in inventory.yaml",
|
|
"likely_cause": "host removed without full secret revocation, or a stale rule",
|
|
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
|
|
"verification": "grep <pubkey> .sops.yaml; check inventory.yaml + archaeology",
|
|
})
|
|
|
|
hello_rule = next((r for r in rules if "hello" in r["path_regex"]), None)
|
|
if hello_rule:
|
|
for pubkey, name in known_pubkeys.items():
|
|
if pubkey not in hello_rule["recipients"]:
|
|
findings.append({
|
|
"kind": "sops-missing-recipient", "severity": "warning",
|
|
"entity": f"host:{name}",
|
|
"evidence": f"{name}'s age_pubkey is not a recipient of secrets/hello.yaml",
|
|
"likely_cause": "client-add --finalize-pubkey ran incompletely",
|
|
"recommended_action": {"runbook": "client-enrollment", "risk": "config_mutation"},
|
|
"verification": f"homelab client add {name} --finalize-pubkey <key>",
|
|
})
|
|
return findings
|
|
|
|
|
|
def check_lifecycle_consistency(inv: dict | None = None) -> list[dict]:
|
|
"""state: vs reality, using the ontology graph (oikos/relations.py) —
|
|
no SSH needed, this only reasons over inventory.yaml itself."""
|
|
inv = inv or _load_inventory()
|
|
hosts = inv.get("hosts", {})
|
|
archaeology = inv.get("archaeology", {})
|
|
findings = []
|
|
|
|
for name, e in hosts.items():
|
|
state = e.get("state", "active")
|
|
if state == "deprecated":
|
|
rel = oikos_relations.relations(f"host:{name}", inv)
|
|
if rel["affected_by"]:
|
|
findings.append({
|
|
"kind": "lifecycle-inconsistent", "severity": "warning",
|
|
"entity": f"host:{name}",
|
|
"evidence": f"deprecated but still has inbound edges: {', '.join(rel['affected_by'])}",
|
|
"likely_cause": "deprecation started before dependents were migrated off",
|
|
"recommended_action": {"runbook": "lifecycle-deprecate-node", "risk": "config_mutation"},
|
|
"verification": f"homelab node {name} relations",
|
|
})
|
|
elif state == "destroyed":
|
|
findings.append({
|
|
"kind": "lifecycle-inconsistent", "severity": "critical",
|
|
"entity": f"host:{name}",
|
|
"evidence": "state is 'destroyed' but the host still has a live hosts.<name>: entry "
|
|
"(destroyed nodes belong in the archaeology: section, not hosts:)",
|
|
"likely_cause": "lifecycle-destroy-node runbook step 6 (move to archaeology) was skipped",
|
|
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
|
|
"verification": "grep -A3 '^ " + name + ":' inventory.yaml",
|
|
})
|
|
|
|
# A pve_id collision between an active host and an archaeology entry on
|
|
# the same Proxmox node usually means the ID was reused without the old
|
|
# entry's data being fully accounted for.
|
|
active_by_id = {(e.get("host"), e.get("pve_id")): name
|
|
for name, e in hosts.items() if e.get("pve_id")}
|
|
for aname, ae in archaeology.items():
|
|
key = (None, ae.get("pve_id")) # archaeology doesn't record which node
|
|
for (host, pve_id), name in active_by_id.items():
|
|
if pve_id == ae.get("pve_id") and name != aname:
|
|
findings.append({
|
|
"kind": "lifecycle-pve-id-reuse", "severity": "info",
|
|
"entity": f"host:{name}",
|
|
"evidence": f"pve_id {pve_id} is both active ({name}) and in archaeology ({aname}, "
|
|
f"destroyed {ae.get('destroyed', '?')})",
|
|
"likely_cause": "normal ID reuse after destroy — informational only",
|
|
"recommended_action": None,
|
|
"verification": None,
|
|
})
|
|
return findings
|
|
|
|
|
|
def _ssh_hubris(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
"""Best-effort ssh to hubris. Returns None (not a CompletedProcess with
|
|
nonzero rc) if ssh itself can't run/connect at all, so callers can
|
|
distinguish "unreachable" from "reachable but the command failed."""
|
|
try:
|
|
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
|
"root@192.168.8.77", " ".join(cmd)],
|
|
capture_output=True, text=True, timeout=15)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return None
|
|
|
|
|
|
def check_pct_list(inv: dict | None = None) -> list[dict]:
|
|
"""Inventory LXCs/VMs on hubris vs live `pct list` + `qm list`."""
|
|
inv = inv or _load_inventory()
|
|
hosts = inv.get("hosts", {})
|
|
proc = _ssh_hubris(["pct", "list"])
|
|
if proc is None or proc.returncode != 0:
|
|
return [{
|
|
"kind": "probe-unreachable", "severity": "info", "entity": "host:hubris",
|
|
"evidence": "could not reach hubris to run `pct list`",
|
|
"likely_cause": "no network path from this client to hubris, or ssh key not authorized",
|
|
"recommended_action": None, "verification": "homelab ssh hubris -- pct list",
|
|
}]
|
|
live_ids = set()
|
|
for line in proc.stdout.splitlines()[1:]:
|
|
parts = line.split()
|
|
if parts and parts[0].isdigit():
|
|
live_ids.add(int(parts[0]))
|
|
|
|
inventory_ids = {e["pve_id"] for name, e in hosts.items()
|
|
if e.get("kind") == "lxc" and e.get("host") == "hubris" and e.get("pve_id")}
|
|
|
|
findings = []
|
|
for missing in sorted(inventory_ids - live_ids):
|
|
name = next(n for n, e in hosts.items() if e.get("pve_id") == missing)
|
|
findings.append({
|
|
"kind": "inventory-vs-live", "severity": "critical", "entity": f"host:{name}",
|
|
"evidence": f"pve_id {missing} ({name}) is in inventory.yaml but absent from "
|
|
f"`pct list` on hubris",
|
|
"likely_cause": "destroyed outside the lifecycle-destroy-node runbook, or migrated "
|
|
"without updating inventory",
|
|
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
|
|
"verification": "homelab ssh hubris -- pct list",
|
|
})
|
|
for extra in sorted(live_ids - inventory_ids):
|
|
findings.append({
|
|
"kind": "inventory-vs-live", "severity": "warning", "entity": "host:hubris",
|
|
"evidence": f"pve_id {extra} exists on hubris (`pct list`) but has no inventory.yaml entry",
|
|
"likely_cause": "created outside the provision-node runbook",
|
|
"recommended_action": {"runbook": "lifecycle-provision-node", "risk": "config_mutation"},
|
|
"verification": "homelab ssh hubris -- pct config " + str(extra),
|
|
})
|
|
return findings
|
|
|
|
|
|
def check_caddy_backends(inv: dict | None = None) -> list[dict]:
|
|
"""Caddy's /etc/caddy (a git checkout of dtoro/caddy-conf, per
|
|
containers/121-caddy.md) vs inventory service backend IPs. Best-effort
|
|
grep for reverse_proxy targets; skips services whose Caddyfile snippet
|
|
doesn't use a bare IP (e.g. references a Caddy snippet/import)."""
|
|
inv = inv or _load_inventory()
|
|
hosts = inv.get("hosts", {})
|
|
services = inv.get("services", {})
|
|
caddy_ip = hosts.get("caddy", {}).get("lan_ip")
|
|
if not caddy_ip:
|
|
return []
|
|
try:
|
|
proc = subprocess.run(
|
|
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", f"root@{caddy_ip}",
|
|
"grep -rhoE 'reverse_proxy[^{]*' /etc/caddy/ 2>/dev/null"],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
proc = None
|
|
if proc is None or proc.returncode != 0:
|
|
return [{
|
|
"kind": "probe-unreachable", "severity": "info", "entity": "host:caddy",
|
|
"evidence": "could not reach caddy to inspect /etc/caddy",
|
|
"likely_cause": "no network path from this client to caddy, or ssh key not authorized",
|
|
"recommended_action": None, "verification": "homelab ssh caddy -- grep -r reverse_proxy /etc/caddy",
|
|
}]
|
|
|
|
live_ips = set(re.findall(r"\d+\.\d+\.\d+\.\d+", proc.stdout))
|
|
findings = []
|
|
for svc, e in services.items():
|
|
if not isinstance(e, dict):
|
|
continue
|
|
backend = e.get("backend")
|
|
if not backend or not (e.get("url") or e.get("public_host")):
|
|
continue
|
|
backend_ip = hosts.get(backend, {}).get("lan_ip")
|
|
if backend_ip and live_ips and backend_ip not in live_ips:
|
|
findings.append({
|
|
"kind": "caddy-backend-mismatch", "severity": "warning", "entity": f"service:{svc}",
|
|
"evidence": f"inventory backend IP {backend_ip} ({backend}) not found in any "
|
|
f"reverse_proxy directive on caddy",
|
|
"likely_cause": "backend migrated (lan_ip changed) without updating the Caddyfile, "
|
|
"or the service uses a snippet/import this grep can't resolve",
|
|
"recommended_action": {"runbook": "config-change-deploy", "risk": "config_mutation"},
|
|
"verification": f"homelab service {svc} health",
|
|
})
|
|
return findings
|
|
|
|
|
|
DETECTORS = [check_sops_recipients, check_lifecycle_consistency,
|
|
check_pct_list, check_caddy_backends]
|
|
|
|
|
|
def run_all(inv: dict | None = None, *, raise_signals: bool = False) -> list[dict]:
|
|
inv = inv or _load_inventory()
|
|
findings: list[dict] = []
|
|
for detector in DETECTORS:
|
|
findings.extend(detector(inv))
|
|
if raise_signals:
|
|
for f in findings:
|
|
if oikos_signal.open_signal_for(f["entity"], f["kind"]) is not None:
|
|
continue # already open, don't duplicate
|
|
oikos_signal.raise_signal(
|
|
f["kind"], f["severity"], f["entity"], f["evidence"],
|
|
likely_cause=f.get("likely_cause"),
|
|
recommended_action=f.get("recommended_action"),
|
|
verification=f.get("verification"),
|
|
)
|
|
return findings
|
|
|
|
|
|
def main() -> int:
|
|
import argparse
|
|
import json
|
|
p = argparse.ArgumentParser(description="oikos drift detectors")
|
|
p.add_argument("--raise-signals", action="store_true",
|
|
help="raise a Signal for each new finding (default: print only)")
|
|
args = p.parse_args()
|
|
findings = run_all(raise_signals=args.raise_signals)
|
|
for f in findings:
|
|
print(json.dumps(f))
|
|
print(f"\n{len(findings)} finding(s)", file=sys.stderr)
|
|
return 1 if any(f["severity"] == "critical" for f in findings) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|