Add home-security-agent with PostgreSQL persistence for dashboard.
The autonomous agent writes all observations to agent.* tables consumed by Homelab Command on port 8765. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""PostgreSQL — alle observaties voor dashboard :8765."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
from agent.brain import AgentDecision
|
||||
from agent.observer import ObservationReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pg_enabled() -> bool:
|
||||
return os.getenv("PG_DISABLED", "").lower() not in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _connect():
|
||||
url = os.getenv("DATABASE_URL", "").strip()
|
||||
if url:
|
||||
return psycopg2.connect(url)
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("PG_HOST", "192.168.1.211"),
|
||||
port=int(os.getenv("PG_PORT", "5433")),
|
||||
user=os.getenv("PG_USER", "mo"),
|
||||
password=os.getenv("PG_PASSWORD", ""),
|
||||
dbname=os.getenv("PG_DATABASE", "homelab"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_ts(ts: str) -> datetime:
|
||||
if ts.endswith("Z"):
|
||||
ts = ts[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(ts)
|
||||
|
||||
|
||||
def persist_observation(report: ObservationReport, decision: AgentDecision) -> Optional[int]:
|
||||
"""Schrijf run + findings naar agent.*; retourneert run_id."""
|
||||
if not _pg_enabled():
|
||||
return None
|
||||
summary = report.to_dict().get("summary") or {}
|
||||
decision_json = {
|
||||
"alert": decision.alert,
|
||||
"severity": decision.severity,
|
||||
"title": decision.title,
|
||||
"body": decision.body,
|
||||
"fingerprint": decision.fingerprint,
|
||||
"actions": decision.actions,
|
||||
}
|
||||
try:
|
||||
conn = _connect()
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET search_path TO agent, public;")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO observation_runs
|
||||
(observed_at, total_checks, failed_checks, summary, decision)
|
||||
VALUES (%s, %s, %s, %s::jsonb, %s::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
_parse_ts(report.timestamp),
|
||||
int(summary.get("total", len(report.findings))),
|
||||
int(summary.get("failed", sum(1 for f in report.findings if not f.ok))),
|
||||
json.dumps(report.to_dict(), default=str),
|
||||
json.dumps(decision_json, default=str),
|
||||
),
|
||||
)
|
||||
run_id = cur.fetchone()[0]
|
||||
rows = [
|
||||
(
|
||||
run_id,
|
||||
f.kind,
|
||||
f.name,
|
||||
f.ok,
|
||||
f.detail,
|
||||
json.dumps(f.meta or {}, default=str),
|
||||
)
|
||||
for f in report.findings
|
||||
]
|
||||
if rows:
|
||||
psycopg2.extras.execute_batch(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO findings (run_id, kind, name, ok, detail, meta)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("PostgreSQL run %s: %d findings", run_id, len(rows))
|
||||
return int(run_id)
|
||||
except Exception as e:
|
||||
logger.warning("PostgreSQL observatie mislukt: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def persist_incident(
|
||||
run_id: Optional[int],
|
||||
fingerprint: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
body: str,
|
||||
notified: bool,
|
||||
) -> None:
|
||||
if not _pg_enabled():
|
||||
return
|
||||
try:
|
||||
conn = _connect()
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET search_path TO agent, public;")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO incidents (run_id, fingerprint, severity, title, body, notified)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(run_id, fingerprint, severity, title, body, notified),
|
||||
)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning("PostgreSQL incident mislukt: %s", e)
|
||||
|
||||
|
||||
def was_notified_recently_pg(fingerprint: str, minutes: int) -> bool:
|
||||
if not _pg_enabled():
|
||||
return False
|
||||
try:
|
||||
conn = _connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET search_path TO agent, public;")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM incidents
|
||||
WHERE fingerprint = %s AND notified = true
|
||||
AND created_at > NOW() - (%s || ' minutes')::interval
|
||||
LIMIT 1
|
||||
""",
|
||||
(fingerprint, str(int(minutes))),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
except Exception:
|
||||
return False
|
||||
Reference in New Issue
Block a user