From 02b1d155d46861570c17f7ce519988da12e28985 Mon Sep 17 00:00:00 2001 From: mo Date: Sun, 17 May 2026 21:57:16 +0200 Subject: [PATCH] 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 --- INVENTORY.md | 1 + apps/home-security-agent/.env | 14 + apps/home-security-agent/.env.example | 14 + apps/home-security-agent/Dockerfile | 14 + apps/home-security-agent/README.md | 78 ++++++ apps/home-security-agent/agent/__init__.py | 1 + apps/home-security-agent/agent/brain.py | 243 ++++++++++++++++++ apps/home-security-agent/agent/main.py | 91 +++++++ apps/home-security-agent/agent/observer.py | 159 ++++++++++++ apps/home-security-agent/agent/pg_store.py | 152 +++++++++++ apps/home-security-agent/agent/state.py | 75 ++++++ .../agent/telegram_notify.py | 42 +++ apps/home-security-agent/config/policies.yaml | 20 ++ apps/home-security-agent/config/targets.yaml | 44 ++++ apps/home-security-agent/docker-compose.yml | 20 ++ .../004_home_agent_observations.sql | 51 ++++ apps/home-security-agent/requirements.txt | 5 + 17 files changed, 1024 insertions(+) create mode 100644 apps/home-security-agent/.env create mode 100644 apps/home-security-agent/.env.example create mode 100644 apps/home-security-agent/Dockerfile create mode 100644 apps/home-security-agent/README.md create mode 100644 apps/home-security-agent/agent/__init__.py create mode 100644 apps/home-security-agent/agent/brain.py create mode 100644 apps/home-security-agent/agent/main.py create mode 100644 apps/home-security-agent/agent/observer.py create mode 100644 apps/home-security-agent/agent/pg_store.py create mode 100644 apps/home-security-agent/agent/state.py create mode 100644 apps/home-security-agent/agent/telegram_notify.py create mode 100644 apps/home-security-agent/config/policies.yaml create mode 100644 apps/home-security-agent/config/targets.yaml create mode 100644 apps/home-security-agent/docker-compose.yml create mode 100644 apps/home-security-agent/migrations/004_home_agent_observations.sql create mode 100644 apps/home-security-agent/requirements.txt diff --git a/INVENTORY.md b/INVENTORY.md index d25bee4..a0c8b7d 100644 --- a/INVENTORY.md +++ b/INVENTORY.md @@ -20,6 +20,7 @@ Private repo. Laatst bijgewerkt vanaf NAS `192.168.1.211`. | Prometheus | [apps/monitoring](apps/monitoring/) | :9090 | running | | Grafana | [apps/monitoring](apps/monitoring/) | :3002 | running | | Homelab Command | [homelab-command repo](http://192.168.1.211:3000/mo/homelab-command) | :8765 | running | +| Security Agent | [apps/home-security-agent](apps/home-security-agent/) | Docker (NAS) | agentic · Telegram | | NATS + mesh | [apps/monitoring](apps/monitoring/) | :4222 | running | ## Synology NAS — Docker (gestopt / image aanwezig) diff --git a/apps/home-security-agent/.env b/apps/home-security-agent/.env new file mode 100644 index 0000000..614bf68 --- /dev/null +++ b/apps/home-security-agent/.env @@ -0,0 +1,14 @@ +# Telegram (verplicht voor meldingen) +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# PostgreSQL — observaties voor dashboard http://192.168.1.211:8765 +PG_HOST=192.168.1.211 +PG_PORT=5433 +PG_USER=mo +PG_PASSWORD=WaQTUw2t +PG_DATABASE=homelab + +# Optioneel: LLM-agent (zonder key = regel-gebaseerde modus) +OPENAI_API_KEY= +AGENT_MODEL=gpt-4o-mini diff --git a/apps/home-security-agent/.env.example b/apps/home-security-agent/.env.example new file mode 100644 index 0000000..407f939 --- /dev/null +++ b/apps/home-security-agent/.env.example @@ -0,0 +1,14 @@ +# Telegram (verplicht voor meldingen) +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# PostgreSQL — observaties voor dashboard http://192.168.1.211:8765 +PG_HOST=192.168.1.211 +PG_PORT=5433 +PG_USER=mo +PG_PASSWORD= +PG_DATABASE=homelab + +# Optioneel: LLM-agent (zonder key = regel-gebaseerde modus) +OPENAI_API_KEY= +AGENT_MODEL=gpt-4o-mini diff --git a/apps/home-security-agent/Dockerfile b/apps/home-security-agent/Dockerfile new file mode 100644 index 0000000..ed6ee06 --- /dev/null +++ b/apps/home-security-agent/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends iputils-ping docker.io \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent ./agent +COPY config ./config + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "-m", "agent.main"] diff --git a/apps/home-security-agent/README.md b/apps/home-security-agent/README.md new file mode 100644 index 0000000..1997aeb --- /dev/null +++ b/apps/home-security-agent/README.md @@ -0,0 +1,78 @@ +# EL-KADI Home Security Agent + +**Autonome** security agent voor thuis — zonder Wazuh, Uptime Kuma, n8n of Security Mesh. + +De agent: +1. **Observeert** zelf (HTTP/TCP, Docker, Proxmox, LAN-gateway) +2. **Redeneert** (OpenAI met tools, of regels zonder API-key) +3. **Onthoudt** incidenten (SQLite, dedupe) +4. **Meld** via **Telegram** + +## Starten + +```bash +cd /volume1/docker/homelab-configs/apps/home-security-agent +cp .env.example .env +# Vul TELEGRAM_BOT_TOKEN en TELEGRAM_CHAT_ID in +# Optioneel: OPENAI_API_KEY voor agentische modus + +docker-compose up -d --build +``` + +Eén run testen: + +```bash +docker-compose run --rm security-agent python -m agent.main once +``` + +## Configuratie + +| Bestand | Doel | +|---------|------| +| `config/targets.yaml` | Wat gemonitord wordt | +| `config/policies.yaml` | Interval, quiet hours, severity | +| `.env` | Telegram + OpenAI | + +## Agentische modus (LLM) + +Met `OPENAI_API_KEY` krijgt het model tools (`probe_tcp`, `probe_http`, `probe_proxmox`) en mag zelf verifiëren voordat het alert=true zet. + +Zonder key: **regel-engine** (down services → Telegram). + +## Uitbreiden + +Voeg in `targets.yaml` services toe. Voor diepere agent-gedrag later: + +- SSH-log tail (auth failures) +- Proxmox API (VM status) als aparte tool +- LAN device discovery + `known_hosts` whitelist +- Lokale Ollama (`AGENT_MODEL` + OpenAI-compatible URL) + +## Dashboard + +Alle observaties gaan naar **PostgreSQL** (`agent.observation_runs`, `agent.findings`, `agent.incidents`). + +Bekijk ze in **Homelab Command**: http://192.168.1.211:8765/dashboard#security (tab Security → Home Security Agent). + +Eénmalig schema: + +```bash +docker exec -i postgres-homelab psql -U mo -d homelab < migrations/004_home_agent_observations.sql +``` + +Of vanuit homelab-command: `scripts/apply_mesh_migrations.sh` (past alle `migrations/*.sql` toe). + +## Architectuur + +``` +┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ +│ Observer │────▶│ Brain │────▶│ PostgreSQL │────▶│ Dashboard│ +│ (eigen │ │ LLM + tools │ │ agent.* │ │ :8765 │ +│ probes) │ │ of regels │ └────────────┘ └──────────┘ +└─────────────┘ └──────┬───────┘ │ + │ │ + ┌──────▼───────┐ ┌──────▼───────┐ + │ SQLite state │ │ Telegram │ + │ dedupe │ │ meldingen │ + └──────────────┘ └──────────────┘ +``` diff --git a/apps/home-security-agent/agent/__init__.py b/apps/home-security-agent/agent/__init__.py new file mode 100644 index 0000000..5070f68 --- /dev/null +++ b/apps/home-security-agent/agent/__init__.py @@ -0,0 +1 @@ +# EL-KADI Home Security Agent diff --git a/apps/home-security-agent/agent/brain.py b/apps/home-security-agent/agent/brain.py new file mode 100644 index 0000000..c02bb20 --- /dev/null +++ b/apps/home-security-agent/agent/brain.py @@ -0,0 +1,243 @@ +"""Agentisch brein — LLM met tools, of regel-fallback zonder API.""" +import json +import os +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional + +from agent.observer import ( + ObservationReport, + load_yaml, + probe_http, + probe_proxmox, + probe_tcp, +) +from agent.state import recent_incidents + + +@dataclass +class AgentDecision: + alert: bool + severity: str + title: str + body: str + fingerprint: str + actions: List[str] + + +TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "probe_tcp", + "description": "Test of een TCP-poort open is op een host", + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string"}, + "port": {"type": "integer"}, + }, + "required": ["host", "port"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "probe_http", + "description": "HTTP GET check op een URL", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string"}, + "insecure_tls": {"type": "boolean"}, + }, + "required": ["url"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "probe_proxmox", + "description": "Check Proxmox web UI bereikbaarheid", + "parameters": { + "type": "object", + "properties": {"host": {"type": "string"}}, + "required": ["host"], + }, + }, + }, +] + + +def _run_tool(name: str, args: dict) -> str: + if name == "probe_tcp": + ok, detail = probe_tcp(args["host"], int(args["port"])) + return json.dumps({"ok": ok, "detail": detail}) + if name == "probe_http": + ok, detail = probe_http(args["url"], insecure=bool(args.get("insecure_tls"))) + return json.dumps({"ok": ok, "detail": detail}) + if name == "probe_proxmox": + ok, detail = probe_proxmox(args["host"]) + return json.dumps({"ok": ok, "detail": detail}) + return json.dumps({"error": "unknown tool"}) + + +def _rule_based_decide(report: ObservationReport, policies: dict) -> AgentDecision: + rules = policies.get("rules") or {} + failed = [f for f in report.findings if not f.ok] + if not failed: + return AgentDecision( + alert=False, + severity="info", + title="Alles OK", + body=f"{len(report.findings)} checks geslaagd.", + fingerprint="all_ok", + actions=[], + ) + + worst = "low" + lines = [] + for f in failed: + lines.append(f"• {f.kind}/{f.name}: {f.detail}") + if f.kind == "proxmox" and not f.ok: + worst = rules.get("proxmox_unreachable", "critical") + elif f.kind == "nas" and not f.ok: + worst = max_sev(worst, rules.get("nas_unreachable", "critical")) + elif f.kind == "service" and not f.ok: + worst = max_sev(worst, rules.get("any_service_down", "high")) + elif f.kind == "docker" and not f.ok: + worst = max_sev(worst, "high") + + fp = "fail:" + "|".join(sorted(f"{f.kind}:{f.name}" for f in failed))[:200] + return AgentDecision( + alert=True, + severity=worst, + title=f"{len(failed)} probleem(en) gedetecteerd", + body="\n".join(lines), + fingerprint=fp, + actions=["Herhaal check over 5 min", "Controleer host handmatig"], + ) + + +def max_sev(a: str, b: str) -> str: + order = ["info", "low", "medium", "high", "critical"] + return a if order.index(a) >= order.index(b) else b + + +def _in_quiet_hours(policies: dict) -> bool: + qh = policies.get("quiet_hours") or {} + if not qh: + return False + try: + from zoneinfo import ZoneInfo + + tz = ZoneInfo(qh.get("timezone", "UTC")) + except Exception: + tz = None + now = datetime.now(tz) if tz else datetime.now() + start = qh.get("start", "23:00") + end = qh.get("end", "07:00") + h, m = map(int, now.strftime("%H %M").split()) + cur = h * 60 + m + sh, sm = map(int, start.split(":")) + eh, em = map(int, end.split(":")) + s, e = sh * 60 + sm, eh * 60 + em + if s <= e: + return s <= cur < e + return cur >= s or cur < e + return False + + +def decide(report: ObservationReport) -> AgentDecision: + policies = load_yaml("policies.yaml") + history = recent_incidents(8) + + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + model = os.environ.get("AGENT_MODEL", "gpt-4o-mini") + + if not api_key: + return _rule_based_decide(report, policies) + + try: + from openai import OpenAI + + client = OpenAI(api_key=api_key) + system = """Je bent de autonome security agent voor het EL-KADI homelab thuis. +Je krijgt ruwe observaties (eigen probes, geen Wazuh/Uptime Kuma). +Beslis of de gebruiker een Telegram-melding moet krijgen. +Wees conservatief: alleen alert bij echt problemen of verdachte wijzigingen. +Antwoord uiteindelijk ALTIJD met JSON: +{"alert":bool,"severity":"info|low|medium|high|critical","title":"...","body":"...","fingerprint":"korte_sleutel","actions":["..."]} +Je mag tools aanroepen om te verifiëren voordat je alert=true zet.""" + + user_msg = json.dumps( + { + "observations": report.to_dict(), + "recent_incidents": history, + "policies": { + "quiet_hours": policies.get("quiet_hours"), + "dedupe_minutes": policies.get("dedupe_minutes"), + }, + }, + indent=2, + default=str, + ) + + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_msg}, + ] + + for _ in range(5): + resp = client.chat.completions.create( + model=model, + messages=messages, + tools=TOOLS_SCHEMA, + tool_choice="auto", + ) + msg = resp.choices[0].message + if msg.tool_calls: + messages.append(msg) + for tc in msg.tool_calls: + result = _run_tool(tc.function.name, json.loads(tc.function.arguments)) + messages.append( + { + "role": "tool", + "tool_call_id": tc.id, + "content": result, + } + ) + continue + text = (msg.content or "").strip() + if "```" in text: + text = text.split("```")[1].replace("json", "").strip() + data = json.loads(text) + return AgentDecision( + alert=bool(data.get("alert")), + severity=str(data.get("severity", "medium")), + title=str(data.get("title", "Security")), + body=str(data.get("body", "")), + fingerprint=str(data.get("fingerprint", "llm")), + actions=list(data.get("actions") or []), + ) + except Exception as e: + dec = _rule_based_decide(report, policies) + dec.body += f"\n\n(LLM fallback: {e})" + return dec + + return _rule_based_decide(report, policies) + + +def should_notify(decision: AgentDecision, policies: dict) -> bool: + if not decision.alert: + return False + allowed = policies.get("severity_telegram") or ["critical", "high"] + if decision.severity not in allowed: + return False + if _in_quiet_hours(policies): + return decision.severity == (policies.get("quiet_hours") or {}).get( + "allow_severity", "critical" + ) + return True diff --git a/apps/home-security-agent/agent/main.py b/apps/home-security-agent/agent/main.py new file mode 100644 index 0000000..ec91cd4 --- /dev/null +++ b/apps/home-security-agent/agent/main.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""EL-KADI Home Security Agent — autonome loop.""" +import os +import sys +import time +from pathlib import Path + +# Package root on path +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from dotenv import load_dotenv + +load_dotenv(ROOT / ".env") + +from agent.brain import decide, should_notify +from agent.observer import load_yaml, run_observation +from agent.pg_store import ( + persist_incident as pg_record_incident, + persist_observation, + was_notified_recently_pg, +) +from agent.state import log_run, record_incident, was_notified_recently +from agent.telegram_notify import format_alert, send_message + + +def _record_incident( + run_id, + fingerprint: str, + severity: str, + title: str, + body: str, + notified: bool, +) -> None: + record_incident(fingerprint, severity, title, body, notified) + pg_record_incident(run_id, fingerprint, severity, title, body, notified) + + +def run_once() -> int: + policies = load_yaml("policies.yaml") + report = run_observation() + decision = decide(report) + log_run(decision.title, report.to_dict()) + run_id = persist_observation(report, decision) + + print(f"[{report.timestamp}] {decision.severity}: {decision.title} (alert={decision.alert})") + if run_id: + print(f" → PostgreSQL run #{run_id}") + + if not should_notify(decision, policies): + if decision.alert: + _record_incident(run_id, decision.fingerprint, decision.severity, decision.title, decision.body, False) + return 0 + + dedupe = int(policies.get("dedupe_minutes", 30)) + if was_notified_recently(decision.fingerprint, dedupe) or was_notified_recently_pg( + decision.fingerprint, dedupe + ): + print(f" dedupe skip ({decision.fingerprint})") + return 0 + + text = format_alert(decision.severity, decision.title, decision.body, decision.actions) + if send_message(text): + _record_incident(run_id, decision.fingerprint, decision.severity, decision.title, decision.body, True) + print(" → Telegram verzonden") + return 0 + + print(" → Telegram mislukt (check TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID)") + _record_incident(run_id, decision.fingerprint, decision.severity, decision.title, decision.body, False) + return 1 + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "once": + sys.exit(run_once()) + + policies = load_yaml("policies.yaml") + interval = int(policies.get("interval_seconds", 300)) + print(f"EL-KADI Security Agent — loop elke {interval}s (Ctrl+C stop)") + while True: + try: + run_once() + except KeyboardInterrupt: + raise + except Exception as e: + print(f"run error: {e}") + time.sleep(interval) + + +if __name__ == "__main__": + main() diff --git a/apps/home-security-agent/agent/observer.py b/apps/home-security-agent/agent/observer.py new file mode 100644 index 0000000..ec9518c --- /dev/null +++ b/apps/home-security-agent/agent/observer.py @@ -0,0 +1,159 @@ +"""Directe observatie — geen Wazuh/Uptime Kuma/n8n.""" +import socket +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +import httpx +import yaml + +CONFIG_DIR = __import__("pathlib").Path(__file__).resolve().parent.parent / "config" + + +@dataclass +class Finding: + kind: str + name: str + ok: bool + detail: str + meta: dict = field(default_factory=dict) + + +@dataclass +class ObservationReport: + timestamp: str + findings: list[Finding] + + def to_dict(self) -> dict: + return { + "timestamp": self.timestamp, + "findings": [ + {"kind": f.kind, "name": f.name, "ok": f.ok, "detail": f.detail, "meta": f.meta} + for f in self.findings + ], + "summary": { + "total": len(self.findings), + "failed": sum(1 for f in self.findings if not f.ok), + }, + } + + +def load_yaml(name: str) -> dict: + with open(CONFIG_DIR / name, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def probe_tcp(host: str, port: int, timeout: float = 4) -> tuple[bool, str]: + try: + with socket.create_connection((host, port), timeout=timeout): + return True, "open" + except Exception as e: + return False, str(e) + + +def probe_http(url: str, insecure: bool = False, timeout: float = 8) -> tuple[bool, str]: + try: + r = httpx.get(url, timeout=timeout, verify=not insecure, follow_redirects=True) + if r.status_code < 500: + return True, f"HTTP {r.status_code}" + return False, f"HTTP {r.status_code}" + except Exception as e: + return False, str(e) + + +def probe_proxmox(host: str, port: int = 8006) -> tuple[bool, str]: + ok, detail = probe_tcp(host, port) + if not ok: + return False, detail + try: + r = httpx.get(f"https://{host}:{port}/", timeout=6, verify=False) + return r.status_code in (200, 301, 302, 401, 403), f"HTTPS {r.status_code}" + except Exception as e: + return False, str(e) + + +def docker_container_states() -> list[Finding]: + """Leest lokale Docker socket (NAS).""" + findings = [] + try: + out = subprocess.run( + ["docker", "ps", "-a", "--format", "{{.Names}}|{{.Status}}"], + capture_output=True, + text=True, + timeout=30, + ) + if out.returncode != 0: + return [Finding("docker", "docker", False, out.stderr.strip() or "docker ps failed")] + for line in out.stdout.strip().splitlines(): + if not line or "|" not in line: + continue + name, status = line.split("|", 1) + up = status.lower().startswith("up") + findings.append( + Finding( + "docker", + name, + up, + status, + {"exited": "exited" in status.lower()}, + ) + ) + except FileNotFoundError: + findings.append(Finding("docker", "docker", False, "docker CLI niet beschikbaar")) + return findings + + +def lan_ping_watch(subnet: str, sample_hosts: list[str] | None = None) -> list[Finding]: + """Lightweight: ping gateway + optioneel lijst — geen mass scan standaard.""" + findings = [] + gateway = ".".join(subnet.split(".")[:3]) + ".1" + ok, detail = _ping_once(gateway) + findings.append(Finding("lan", "gateway", ok, detail, {"ip": gateway})) + return findings + + +def _ping_once(ip: str) -> tuple[bool, str]: + try: + out = subprocess.run( + ["ping", "-c", "1", "-W", "2", ip], + capture_output=True, + text=True, + timeout=5, + ) + return out.returncode == 0, "reachable" if out.returncode == 0 else "no reply" + except Exception as e: + return False, str(e) + + +def run_observation() -> ObservationReport: + targets = load_yaml("targets.yaml") + findings: list[Finding] = [] + + nas = targets.get("nas") or {} + host = nas.get("host", "192.168.1.211") + for chk in nas.get("checks") or []: + name = chk.get("name", "check") + if chk.get("type") == "tcp": + ok, detail = probe_tcp(host, int(chk["port"])) + elif chk.get("type") == "http": + ok, detail = probe_http(chk["url"]) + else: + ok, detail = False, "unknown check type" + findings.append(Finding("nas", name, ok, detail)) + + for px in targets.get("proxmox_hosts") or []: + ok, detail = probe_proxmox(px["host"], int(px.get("port", 8006))) + findings.append(Finding("proxmox", px.get("name", px["host"]), ok, detail)) + + for svc in targets.get("services") or []: + ok, detail = probe_http(svc["url"], insecure=bool(svc.get("insecure_tls"))) + findings.append(Finding("service", svc.get("name", svc["url"]), ok, detail)) + + findings.extend(docker_container_states()) + + lan = targets.get("lan_watch") or {} + if lan.get("enabled"): + findings.extend(lan_ping_watch(lan.get("subnet", "192.168.1.0/24"))) + + return ObservationReport(timestamp=datetime.utcnow().isoformat() + "Z", findings=findings) diff --git a/apps/home-security-agent/agent/pg_store.py b/apps/home-security-agent/agent/pg_store.py new file mode 100644 index 0000000..09fe3e4 --- /dev/null +++ b/apps/home-security-agent/agent/pg_store.py @@ -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 diff --git a/apps/home-security-agent/agent/state.py b/apps/home-security-agent/agent/state.py new file mode 100644 index 0000000..cda7da2 --- /dev/null +++ b/apps/home-security-agent/agent/state.py @@ -0,0 +1,75 @@ +"""SQLite — geheugen van de agent (dedupe, incidenten).""" +import json +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "agent.db" + + +def connect(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL, + severity TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + notified INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_fp ON incidents(fingerprint); + CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + summary TEXT, + raw_observations TEXT + ); + """ + ) + return conn + + +def was_notified_recently(fingerprint: str, minutes: int) -> bool: + since = (datetime.utcnow() - timedelta(minutes=minutes)).isoformat() + conn = connect() + row = conn.execute( + "SELECT 1 FROM incidents WHERE fingerprint=? AND notified=1 AND created_at>?", + (fingerprint, since), + ).fetchone() + conn.close() + return row is not None + + +def record_incident(fingerprint: str, severity: str, title: str, body: str, notified: bool): + conn = connect() + conn.execute( + "INSERT INTO incidents (fingerprint, severity, title, body, created_at, notified) VALUES (?,?,?,?,?,?)", + (fingerprint, severity, title, body, datetime.utcnow().isoformat(), 1 if notified else 0), + ) + conn.commit() + conn.close() + + +def recent_incidents(limit: int = 10) -> list[dict]: + conn = connect() + rows = conn.execute( + "SELECT severity, title, body, created_at FROM incidents ORDER BY id DESC LIMIT ?", + (limit,), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def log_run(summary: str, observations: dict): + conn = connect() + conn.execute( + "INSERT INTO runs (started_at, summary, raw_observations) VALUES (?,?,?)", + (datetime.utcnow().isoformat(), summary, json.dumps(observations, default=str)), + ) + conn.commit() + conn.close() diff --git a/apps/home-security-agent/agent/telegram_notify.py b/apps/home-security-agent/agent/telegram_notify.py new file mode 100644 index 0000000..b4ad3ac --- /dev/null +++ b/apps/home-security-agent/agent/telegram_notify.py @@ -0,0 +1,42 @@ +"""Telegram — enige externe meldkanaal.""" +import os +import httpx + + +def send_message(text: str, parse_mode: str = "HTML") -> bool: + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + chat_id = os.environ.get("TELEGRAM_CHAT_ID", "").strip() + if not token or not chat_id: + return False + url = f"https://api.telegram.org/bot{token}/sendMessage" + # Telegram max ~4096 + if len(text) > 4000: + text = text[:3990] + "…" + r = httpx.post( + url, + data={"chat_id": chat_id, "text": text, "parse_mode": parse_mode}, + timeout=30, + ) + return r.status_code == 200 + + +def format_alert(severity: str, title: str, body: str, actions=None) -> str: + icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪"}.get( + severity, "⚪" + ) + lines = [ + f"{icon} EL-KADI SECURITY · {severity.upper()}", + f"{_esc(title)}", + "", + _esc(body), + ] + if actions: + lines.append("") + lines.append("Agent acties:") + for a in actions: + lines.append(f"• {_esc(a)}") + return "\n".join(lines) + + +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") diff --git a/apps/home-security-agent/config/policies.yaml b/apps/home-security-agent/config/policies.yaml new file mode 100644 index 0000000..48241d2 --- /dev/null +++ b/apps/home-security-agent/config/policies.yaml @@ -0,0 +1,20 @@ +# Agent-gedrag +interval_seconds: 300 +quiet_hours: + start: "23:00" + end: "07:00" + timezone: Europe/Brussels + allow_severity: critical + +dedupe_minutes: 30 + +severity_telegram: + - critical + - high + +# Zonder LLM: regels +rules: + any_service_down: high + proxmox_unreachable: critical + nas_unreachable: critical + unknown_lan_device: medium diff --git a/apps/home-security-agent/config/targets.yaml b/apps/home-security-agent/config/targets.yaml new file mode 100644 index 0000000..830f322 --- /dev/null +++ b/apps/home-security-agent/config/targets.yaml @@ -0,0 +1,44 @@ +# Doelen die de agent zelf monitort (geen Wazuh/Uptime Kuma/n8n) +nas: + host: 192.168.1.211 + checks: + - name: NAS SSH + type: tcp + port: 22 + - name: Gitea + type: http + url: http://192.168.1.211:3000 + - name: AdGuard + type: http + url: http://192.168.1.211:3001 + +proxmox_hosts: + - name: pve + host: 192.168.1.216 + port: 8006 + tls: true + - name: dell-proxmox + host: 192.168.1.56 + port: 8006 + tls: true + +services: + - name: Homepage + url: http://192.168.1.192:3000 + - name: Home Assistant + url: http://192.168.1.235:8123 + - name: UniFi + url: https://192.168.1.24 + insecure_tls: true + - name: Frigate + url: https://192.168.1.185:30058 + insecure_tls: true + - name: Homelab Command + url: http://192.168.1.211:8765 + +# Optioneel: bekende apparaten op LAN (ARP/ping — geen externe SIEM) +lan_watch: + enabled: true + subnet: 192.168.1.0/24 + # Bekende MACs → negeer of label (vul aan na eerste scan) + known_hosts: [] diff --git a/apps/home-security-agent/docker-compose.yml b/apps/home-security-agent/docker-compose.yml new file mode 100644 index 0000000..b1d4e29 --- /dev/null +++ b/apps/home-security-agent/docker-compose.yml @@ -0,0 +1,20 @@ +# Autonome home security agent — geen Wazuh/Uptime Kuma/n8n +services: + security-agent: + build: . + container_name: el-kadi-security-agent + restart: unless-stopped + network_mode: host + env_file: + - .env + environment: + PG_HOST: ${PG_HOST:-192.168.1.211} + PG_PORT: ${PG_PORT:-5433} + PG_USER: ${PG_USER:-mo} + PG_PASSWORD: ${PG_PASSWORD:-} + PG_DATABASE: ${PG_DATABASE:-homelab} + volumes: + - ./config:/app/config:ro + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + working_dir: /app diff --git a/apps/home-security-agent/migrations/004_home_agent_observations.sql b/apps/home-security-agent/migrations/004_home_agent_observations.sql new file mode 100644 index 0000000..0973e2c --- /dev/null +++ b/apps/home-security-agent/migrations/004_home_agent_observations.sql @@ -0,0 +1,51 @@ +-- Home Security Agent — observaties voor dashboard :8765 (schema agent.*) + +CREATE SCHEMA IF NOT EXISTS agent; + +CREATE TABLE IF NOT EXISTS agent.observation_runs ( + id bigserial PRIMARY KEY, + observed_at timestamptz NOT NULL, + total_checks integer NOT NULL DEFAULT 0, + failed_checks integer NOT NULL DEFAULT 0, + summary jsonb NOT NULL DEFAULT '{}', + decision jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_agent_runs_observed + ON agent.observation_runs (observed_at DESC); + +CREATE TABLE IF NOT EXISTS agent.findings ( + id bigserial PRIMARY KEY, + run_id bigint NOT NULL REFERENCES agent.observation_runs(id) ON DELETE CASCADE, + kind text NOT NULL, + name text NOT NULL, + ok boolean NOT NULL, + detail text, + meta jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_agent_findings_run ON agent.findings (run_id); +CREATE INDEX IF NOT EXISTS ix_agent_findings_failed ON agent.findings (run_id, ok) WHERE NOT ok; + +CREATE TABLE IF NOT EXISTS agent.incidents ( + id bigserial PRIMARY KEY, + run_id bigint REFERENCES agent.observation_runs(id) ON DELETE SET NULL, + fingerprint text NOT NULL, + severity text NOT NULL, + title text NOT NULL, + body text, + notified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_agent_incidents_created + ON agent.incidents (created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_agent_incidents_fp + ON agent.incidents (fingerprint, created_at DESC); + +GRANT USAGE ON SCHEMA agent TO PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA agent TO PUBLIC; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA agent TO PUBLIC; diff --git a/apps/home-security-agent/requirements.txt b/apps/home-security-agent/requirements.txt new file mode 100644 index 0000000..50e13a1 --- /dev/null +++ b/apps/home-security-agent/requirements.txt @@ -0,0 +1,5 @@ +httpx==0.28.1 +pyyaml==6.0.2 +openai==1.58.1 +python-dotenv==1.0.1 +psycopg2-binary==2.9.10