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