Compare commits

...

5 Commits

Author SHA1 Message Date
mo ef9e08cdc0 Sync Prometheus config with working VM102 and NAS scrape targets.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 23:28:40 +02:00
mo 3a77680477 Expand ARCHITECTURE.md with Proxmox, NAS, and LAN system diagrams.
Document pve (.216) and dell (.56) VMs/LXCs, other LAN services,
management flows, and update HOMELAB_IPS reference table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 23:19:53 +02:00
mo 1010a4b1ac Fix rollback doc link in ARCHITECTURE.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 23:16:00 +02:00
mo 0d6ee22247 Document VM 102 security stack and update IPs to 192.168.1.105.
Add ARCHITECTURE.md and HOMELAB_IPS.md, refresh inventory and app configs
for Postgres, Neo4j, Homelab Command, pgAdmin, Homarr, and Homepage links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 23:15:42 +02:00
mo 02b1d155d4 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>
2026-05-17 21:57:16 +02:00
31 changed files with 1546 additions and 48 deletions
+3
View File
@@ -1,7 +1,10 @@
# Private homelab — echte credentials (repo is privé op Gitea) # Private homelab — echte credentials (repo is privé op Gitea)
NAS_IP=192.168.1.211 NAS_IP=192.168.1.211
# Proxmox VM 102 — Postgres, Neo4j, Homelab Command, syslog, NATS, security agent
VM102_IP=192.168.1.105
POSTGRES_USER=mo POSTGRES_USER=mo
PG_HOST=192.168.1.105
POSTGRES_PASSWORD=WaQTUw2t POSTGRES_PASSWORD=WaQTUw2t
POSTGRES_DB=homelab POSTGRES_DB=homelab
PG_HOST_PORT=5433 PG_HOST_PORT=5433
+309
View File
@@ -0,0 +1,309 @@
# Homelab architectuur
Volledig overzicht van hosts, Proxmox, NAS Docker en de security stack op VM 102.
Korte IP-lijst: [HOMELAB_IPS.md](HOMELAB_IPS.md) · Inventaris: [INVENTORY.md](INVENTORY.md) · Proxmox detail: [apps/proxmox/lxc-inventory.md](apps/proxmox/lxc-inventory.md).
---
## 1. LAN-overzicht
```mermaid
flowchart TB
subgraph core [Kern infrastructuur]
NAS["Synology NAS\n192.168.1.211"]
PVE["Proxmox pve\n192.168.1.216 :8006"]
DELL["Proxmox dell-proxmox\n192.168.1.56 :8006"]
end
subgraph vm102 [VM 102 Postgress]
VM102["192.168.1.105\nSecurity stack"]
end
subgraph vm105 [VM 105 docker]
VM227["192.168.1.227\noffice_desk_agent"]
end
subgraph network [Netwerk en DNS]
UDM["UniFi UDM\n192.168.1.24"]
AdGuard["AdGuard NAS\n:3001 / :53"]
end
subgraph smarthome [Smart home]
HA["Home Assistant\n192.168.1.235 :8123"]
end
subgraph storage [Storage / media elders]
TN["TrueNAS\n192.168.1.185"]
end
Internet((Internet)) --> UDM
UDM --> AdGuard
AdGuard --> NAS
AdGuard --> PVE
AdGuard --> DELL
AdGuard --> VM102
AdGuard --> HA
User["Browser / Git"] --> NAS
User --> VM102
User --> PVE
User --> DELL
PVE --> VM102
DELL --> VM102
DELL --> VM227
NAS -->|"Gitea configs"| PVE
NAS -->|"Gitea configs"| DELL
```
---
## 2. Proxmox — twee clusters
Configs in repo: `apps/proxmox/hosts/pve/` en `apps/proxmox/hosts/dell-proxmox/`.
Pull live LXC-configs: `python3 scripts/pull-lxc-from-proxmox.py` (vanaf NAS).
```mermaid
flowchart TB
subgraph pve216 [pve — 192.168.1.216]
direction TB
PVE_API["Web UI :8006"]
subgraph pve_lxc [LXC running]
L104[vaultwarden .5]
L105[linkwarden .142]
L107[pve-scripts .23]
L117[Proxy .165]
L118[paymenter .45]
L119[nodecast .99]
L120[homepage .192]
L121[nginxproxymanager]
L100[autocaliweb]
L102[clawbot]
end
subgraph pve_vm [QEMU]
Q101[W11 — stopped]
Q111[Syno-latest — stopped]
end
end
subgraph dell56 [dell-proxmox — 192.168.1.56]
direction TB
DELL_API["Web UI :8006"]
subgraph dell_qemu [QEMU running]
Q102["102 Postgress\n→ .105 security"]
Q104[kassa-dev]
Q105["105 docker\n→ .227 office agent"]
Q114[DeepseekTUI]
end
subgraph dell_lxc [LXC running]
D107[Virtualmin 192.168.5.24]
D109[nginxproxymanager .173]
D111[pegaprox .249]
end
subgraph dell_stopped [QEMU stopped]
Q101s[opnsense]
Q103[Synology]
end
end
NAS["NAS .211\nbeheer / Gitea"] --> PVE_API
NAS --> DELL_API
```
### Proxmox — tabel (belangrijkste systemen)
| Host | IP | VMID | Naam | Type | IP app | Rol |
|------|-----|------|------|------|--------|-----|
| **dell** | .56 | 102 | Postgress | QEMU | **.105** | Postgres, Neo4j, Homelab Command, syslog, NATS, agent |
| **dell** | .56 | 105 | docker | QEMU | **.227** | Office desk agent :8000 |
| **dell** | .56 | 104 | kassa-dev | QEMU | — | Kassa dev |
| **dell** | .56 | 114 | DeepseekTUI | QEMU | — | Deepseek TUI |
| **dell** | .56 | 107 | Virtualmin | LXC | 192.168.5.24 | Web hosting |
| **dell** | .56 | 109 | nginxproxymanager | LXC | .173 | Reverse proxy |
| **dell** | .56 | 111 | pegaprox | LXC | .249 | Proxy |
| **pve** | .216 | 120 | homepage | LXC | .192 | Homepage dashboard :3000 |
| **pve** | .216 | 104 | vaultwarden | LXC | .5 | Wachtwoorden |
| **pve** | .216 | 105 | linkwarden | LXC | .142 | Bookmarks |
| **pve** | .216 | 119 | nodecast-tv | LXC | .107 | Media |
| **pve** | .216 | 117 | Proxy | LXC | .165 | Proxy |
| **pve** | .216 | 118 | paymenter | LXC | .45 | Billing |
| **pve** | .216 | 121 | nginxproxymanager | LXC | — | NPM |
> Veel LXCs staan **stopped** (immich, n8n, tunarr, …) — zie [lxc-inventory.md](apps/proxmox/lxc-inventory.md).
---
## 3. Synology NAS — Docker
```mermaid
flowchart LR
subgraph nas211 [NAS 192.168.1.211]
direction TB
subgraph infra [Infra en Git]
Gitea[Gitea :3000\nSSH :2222]
Portainer[Portainer :9000]
DuckDNS[DuckDNS]
end
subgraph data [Data en DNS]
PgAdmin[pgAdmin :5434]
PGBak[(Postgres backup :5433)]
AdGuard[AdGuard :3001]
end
subgraph monitor [Monitoring]
Prom[Prometheus :9090]
Graf[Grafana :3002]
PGexp[postgres-exporter :9187]
end
subgraph apps [Apps]
Homarr[Homarr :4755]
Remote[Remotely :8080]
Excal[Excalidraw :3765]
end
end
PgAdmin -->|SQL| VM102PG[(Postgres VM102 :5433)]
Graf --> VM102PG
PGexp --> VM102PG
Prom --> PGexp
Prom --> Neo4jVM[Neo4j .105 :2004]
```
| Service | Poort | Verbonden met |
|---------|-------|----------------|
| Gitea | 3000 | Config-repo's (`homelab-configs`, `homelab-command`) |
| pgAdmin | 5434 | Postgres **productie** op .105:5433 |
| AdGuard | 3001, 53 | LAN DNS-filter |
| Prometheus + Grafana | 9090, 3002 | Scrape VM102 + NAS |
| Postgres (backup) | 5433 | Oude kopie; rollback |
| Homarr / Homepage links | 4755 | Wijzen naar .105 voor security |
---
## 4. Security stack (productie VM 102)
```mermaid
flowchart LR
subgraph sources [Ingest bronnen]
UniFi[UniFi .24]
SyslogDev[Switches / APs / routers]
Zeek[Zeek / Suricata]
end
subgraph vm105 [192.168.1.105 — VM 102]
UI[homelab-command :8765]
SyslogUDP[Syslog UDP :5514]
PG[(postgres-homelab :5433)]
Neo[(Neo4j :49153\nBrowser :49154)]
NATS[NATS :4222]
Mesh[mesh-normalizer]
Agent[el-kadi-security-agent]
end
SyslogDev -->|UDP| SyslogUDP
UniFi -->|API| UI
Zeek -->|NATS| NATS
NATS --> Mesh
SyslogUDP --> PG
UI --> PG
UI --> Neo
Mesh --> PG
Agent --> PG
UI -->|DNS stats| AdGuardNAS[AdGuard .211]
```
| Datastroom | Protocol | Doel |
|------------|----------|------|
| Syslog | UDP 5514 → .105 | `mesh.syslog_entries` |
| UniFi poll | HTTPS .24 | `mesh.unifi_polls` |
| Mesh events | NATS 4222 | `mesh.network_flows` |
| Agent | loop 300s | `agent.*` |
| Dashboard | HTTP 8765 | UI + API |
---
## 5. Overige LAN-systemen
Deze draaien **niet** op NAS of VM 102, maar staan in Homarr/Homepage en worden door de security agent gemonitord waar nodig.
```mermaid
flowchart TB
subgraph lan_other [Andere vaste systemen]
HA["Home Assistant .235"]
TN["TrueNAS .185\nFrigate :30058"]
UDM["UniFi .24"]
NC["Nextcloud cloud.el-kadi.nl"]
MO150["Diverse apps .150\nPortainer, DSM, …"]
MO117["Change detection .117"]
MO203["Minarca .203"]
Wazuh["Wazuh .73"]
end
AgentVM[security-agent .105] -.->|HTTP checks| HA
AgentVM -.-> UDM
UI105[homelab-command .105] -.->|Proxmox API| PVE216[.216]
UI105 -.-> DELL56[.56]
```
| IP | Systeem | Opmerking |
|----|---------|-----------|
| 192.168.1.235 | Home Assistant | Smart home |
| 192.168.1.185 | TrueNAS / Frigate | NVR / camera AI |
| 192.168.1.24 | UniFi | Gateway + controller |
| 192.168.1.150 | mo-nas / apps | Meerdere kleine services |
| 192.168.1.192 | Homepage LXC | Op pve CT 120 |
| 192.168.1.173 | NPM | dell LXC 109 |
| 192.168.1.107 | nodecast | pve LXC 119 |
| 192.168.5.24 | Virtualmin | dell LXC (ander subnet) |
---
## 6. Beheer- en config-flow
```mermaid
sequenceDiagram
participant Dev as Ontwikkelaar
participant Gitea as Gitea NAS :3000
participant NAS as NAS Docker
participant VM as VM102 .105
participant PVE as Proxmox .56/.216
Dev->>Gitea: push homelab-configs
Dev->>VM: ssh mo@.105 deploy homelab-command
Dev->>PVE: Web UI / API beheer VMs
NAS->>VM: postgres-exporter scrape
NAS->>Gitea: clone configs voor restore
VM->>PVE: Proxmox API in dashboard
```
| Actie | Waar |
|-------|------|
| Git configs | Gitea op NAS |
| Security productie | VM 102 (.105) |
| Proxmox beheer | .216 (pve) en .56 (dell) |
| DNS | AdGuard op NAS |
| DB GUI | pgAdmin NAS → Postgres .105 |
---
## 7. Snelle URL-lijst
| Wat | URL |
|-----|-----|
| **Security dashboard** | http://192.168.1.105:8765/dashboard |
| **Neo4j Browser** | http://192.168.1.105:49154 |
| **Proxmox pve** | https://192.168.1.216:8006 |
| **Proxmox dell** | https://192.168.1.56:8006 |
| **NAS DSM / apps** | http://192.168.1.211:5000 |
| **Gitea** | http://192.168.1.211:3000 |
| **pgAdmin** | http://192.168.1.211:5434 |
| **Grafana** | http://192.168.1.211:3002 |
| **AdGuard** | http://192.168.1.211:3001 |
| **Portainer NAS** | http://192.168.1.211:9000 |
| **Homarr** | http://192.168.1.211:4755 |
| **Home Assistant** | http://192.168.1.235:8123 |
| **UniFi** | https://192.168.1.24 |
| **Office agent** | http://192.168.1.227:8000 |
---
## 8. Rollback Postgres
Zie repo `homelab-command``docs/POSTGRES_ROLLBACK.md`: `PG_HOST` terug naar `.211` en NAS-container `postgres-homelab` herstarten.
+65
View File
@@ -0,0 +1,65 @@
# Homelab IP-adressen (referentie)
## Kernhosts
| IP | Host | Rol |
|----|------|-----|
| **192.168.1.211** | Synology NAS | Gitea, AdGuard, Portainer, Grafana, Prometheus, pgAdmin, Homarr, Postgres backup |
| **192.168.1.105** | Proxmox VM 102 `Postgress` | **Productie security:** Postgres, Neo4j, Dashboard, syslog, NATS, agent |
| **192.168.1.227** | Proxmox VM 105 `docker` | Office desk agent :8000 |
| **192.168.1.216** | Proxmox **pve** | Hypervisor API :8006, veel LXC (vaultwarden, homepage, …) |
| **192.168.1.56** | Proxmox **dell-proxmox** | Hypervisor API :8006, VM 102/105, NPM, Virtualmin |
## Netwerk en smart home
| IP | Systeem |
|----|---------|
| 192.168.1.24 | UniFi controller / gateway |
| 192.168.1.235 | Home Assistant :8123 |
## Proxmox LXC (selectie, running)
| IP | Hostnaam | Proxmox |
|----|----------|---------|
| 192.168.1.192 | homepage | pve CT 120 |
| 192.168.1.173 | nginxproxymanager | dell CT 109 |
| 192.168.1.249 | pegaprox | dell CT 111 |
| 192.168.5.24 | Virtualmin | dell CT 107 |
| 192.168.1.142 | linkwarden | pve CT 105 |
| 192.168.1.107 | nodecast-tv | pve CT 119 |
Volledige LXC-tabel: [apps/proxmox/lxc-inventory.md](apps/proxmox/lxc-inventory.md).
## Overige LAN (Homepage / monitoring)
| IP | Systeem |
|----|---------|
| 192.168.1.185 | TrueNAS / Frigate |
| 192.168.1.150 | mo-nas, diverse apps |
| 192.168.1.117 | Change detection |
| 192.168.1.230 | Proxmox (extra node in Homepage) |
## Env-variabelen (`homelab-configs/.env.example`)
```env
NAS_IP=192.168.1.211
VM102_IP=192.168.1.105
PG_HOST=192.168.1.105
PROXMOX_HOST_PVE=192.168.1.216
PROXMOX_HOST_DELL=192.168.1.56
```
## Productie-URLs
| Service | URL |
|---------|-----|
| Security dashboard | http://192.168.1.105:8765/dashboard |
| Neo4j | http://192.168.1.105:49154 |
| Proxmox pve | https://192.168.1.216:8006 |
| Proxmox dell | https://192.168.1.56:8006 |
| Gitea | http://192.168.1.211:3000 |
| pgAdmin | http://192.168.1.211:5434 |
## Syslog
Remote syslog → **192.168.1.105:5514**
+18 -6
View File
@@ -1,17 +1,29 @@
# Homelab inventaris — alles thuis # Homelab inventaris — alles thuis
Private repo. Laatst bijgewerkt vanaf NAS `192.168.1.211`. Private repo. Laatst bijgewerkt: security stack op VM 102 (`192.168.1.105`), overige apps op NAS (`192.168.1.211`).
## Proxmox VM 102 Postgress — `192.168.1.105` (productie security)
| App | Map / pad op VM | IP:poort | Status |
|-----|-----------------|----------|--------|
| PostgreSQL | `~/homelab-postgres/` | :5433 | running |
| Neo4j | `~/neo4j/` | :4915349155 | running |
| Homelab Command | `~/homelab-command/` | :8765 | running |
| Syslog UDP | homelab-command | :5514 | → `.105` |
| NATS + mesh-normalizer | `~/homelab-command/` | :4222 | running |
| Security Agent | `~/home-security-agent/` | host | running |
**Dashboard:** http://192.168.1.105:8765/dashboard · **Neo4j UI:** http://192.168.1.105:49154
## Synology NAS — Docker (actief) ## Synology NAS — Docker (actief)
| App | Map | IP:poort | Status | | App | Map | IP:poort | Status |
|-----|-----|----------|--------| |-----|-----|----------|--------|
| PostgreSQL | [apps/postgres](apps/postgres/) | :5433 | running | | PostgreSQL (backup) | [apps/postgres](apps/postgres/) | 192.168.1.211:5433 | running · fallback |
| pgAdmin | [apps/pgadmin](apps/pgadmin/) | :5434 | running | | pgAdmin | [apps/pgadmin](apps/pgadmin/) | :5434 | running → DB op `.105` |
| Gitea | [apps/gitea](apps/gitea/) | :3000 | running | | Gitea | [apps/gitea](apps/gitea/) | :3000 | running |
| AdGuard Home | [apps/adguard](apps/adguard/) | :53, :3001 | running | | AdGuard Home | [apps/adguard](apps/adguard/) | :53, :3001 | running |
| DuckDNS | [apps/duckdns](apps/duckdns/) | — | running | | DuckDNS | [apps/duckdns](apps/duckdns/) | — | running |
| Neo4j | [apps/neo4j](apps/neo4j/) | :4915349155 | running |
| Homarr | [apps/homarr](apps/homarr/) | :4755 | running | | Homarr | [apps/homarr](apps/homarr/) | :4755 | running |
| Homepage | [apps/homepage](apps/homepage/) | http://192.168.1.192:3000 (pve CT 120) | running | | Homepage | [apps/homepage](apps/homepage/) | http://192.168.1.192:3000 (pve CT 120) | running |
| Portainer | [apps/portainer](apps/portainer/) | :9000 | running | | Portainer | [apps/portainer](apps/portainer/) | :9000 | running |
@@ -19,8 +31,6 @@ Private repo. Laatst bijgewerkt vanaf NAS `192.168.1.211`.
| Excalidraw | [apps/excalidraw](apps/excalidraw/) | :3765 | running | | Excalidraw | [apps/excalidraw](apps/excalidraw/) | :3765 | running |
| Prometheus | [apps/monitoring](apps/monitoring/) | :9090 | running | | Prometheus | [apps/monitoring](apps/monitoring/) | :9090 | running |
| Grafana | [apps/monitoring](apps/monitoring/) | :3002 | running | | Grafana | [apps/monitoring](apps/monitoring/) | :3002 | running |
| Homelab Command | [homelab-command repo](http://192.168.1.211:3000/mo/homelab-command) | :8765 | running |
| NATS + mesh | [apps/monitoring](apps/monitoring/) | :4222 | running |
## Synology NAS — Docker (gestopt / image aanwezig) ## Synology NAS — Docker (gestopt / image aanwezig)
@@ -81,6 +91,8 @@ python3 scripts/pull-lxc-from-proxmox.py # op NAS, via Proxmox SSH
| IP | Rol | | IP | Rol |
|----|-----| |----|-----|
| 192.168.1.211 | Synology NAS | | 192.168.1.211 | Synology NAS |
| 192.168.1.105 | Proxmox VM 102 Postgress (Postgres, Neo4j, Homelab Command) |
| 192.168.1.227 | Proxmox VM 105 docker (office agent) |
| 192.168.1.216 | Proxmox pve | | 192.168.1.216 | Proxmox pve |
| 192.168.1.56 | Proxmox dell | | 192.168.1.56 | Proxmox dell |
| 192.168.1.24 | UniFi controller | | 192.168.1.24 | UniFi controller |
+4 -1
View File
@@ -1,6 +1,9 @@
# Homelab Infrastructure Configuration # Homelab Infrastructure Configuration
Private Gitea-repo met **alle configs per applicatie** voor Synology NAS (`192.168.1.211`) en Proxmox hosts. Private Gitea-repo met **alle configs per applicatie** voor Synology NAS (`192.168.1.211`) en Proxmox VM 102 (`192.168.1.105`).
- **Architectuur-diagrammen:** [ARCHITECTURE.md](ARCHITECTURE.md) (Proxmox, NAS, security stack, LAN)
- **IP-lijst:** [HOMELAB_IPS.md](HOMELAB_IPS.md)
**Snel herstellen:** [RESTORE.md](RESTORE.md) **Snel herstellen:** [RESTORE.md](RESTORE.md)
**Volledige inventaris:** [INVENTORY.md](INVENTORY.md) **Volledige inventaris:** [INVENTORY.md](INVENTORY.md)
+6 -6
View File
@@ -4007,7 +4007,7 @@
{ {
"id": "1446d0cd-5449-4e41-b68b-15b4052f6325", "id": "1446d0cd-5449-4e41-b68b-15b4052f6325",
"name": "Neo4j Browser", "name": "Neo4j Browser",
"url": "http://192.168.1.211:49154", "url": "http://192.168.1.105:49154",
"appearance": { "appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/neo4j.png", "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/neo4j.png",
"appNameStatus": "normal", "appNameStatus": "normal",
@@ -4028,7 +4028,7 @@
}, },
"behaviour": { "behaviour": {
"isOpeningNewTab": true, "isOpeningNewTab": true,
"externalUrl": "http://192.168.1.211:49154" "externalUrl": "http://192.168.1.105:49154"
}, },
"area": { "area": {
"type": "category", "type": "category",
@@ -4066,7 +4066,7 @@
{ {
"id": "039d3bf6-bf8a-4944-a8b1-7cc886daebe7", "id": "039d3bf6-bf8a-4944-a8b1-7cc886daebe7",
"name": "HA Voice Ctrl", "name": "HA Voice Ctrl",
"url": "http://192.168.1.211:8765", "url": "http://192.168.1.105:8765",
"appearance": { "appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/home-assistant.png", "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/home-assistant.png",
"appNameStatus": "normal", "appNameStatus": "normal",
@@ -4087,7 +4087,7 @@
}, },
"behaviour": { "behaviour": {
"isOpeningNewTab": true, "isOpeningNewTab": true,
"externalUrl": "http://192.168.1.211:8765" "externalUrl": "http://192.168.1.105:8765"
}, },
"area": { "area": {
"type": "category", "type": "category",
@@ -5426,7 +5426,7 @@
{ {
"id": "9be593d8-a4b4-460a-8998-6cafefb4271e", "id": "9be593d8-a4b4-460a-8998-6cafefb4271e",
"name": "Home Control", "name": "Home Control",
"url": "http://192.168.1.211:8765/dashboard#live", "url": "http://192.168.1.105:8765/dashboard#live",
"appearance": { "appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/crafty-controller.svg", "iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/crafty-controller.svg",
"appNameStatus": "normal", "appNameStatus": "normal",
@@ -5447,7 +5447,7 @@
}, },
"behaviour": { "behaviour": {
"isOpeningNewTab": true, "isOpeningNewTab": true,
"externalUrl": "http://192.168.1.211:8765/dashboard#live" "externalUrl": "http://192.168.1.105:8765/dashboard#live"
}, },
"area": { "area": {
"type": "category", "type": "category",
+14
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Telegram (verplicht voor meldingen)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# PostgreSQL — observaties voor dashboard http://192.168.1.105:8765
PG_HOST=192.168.1.105
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
+14
View File
@@ -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"]
+79
View File
@@ -0,0 +1,79 @@
# 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.105: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
# Postgres draait op VM 102: ssh mo@192.168.1.105 → docker exec postgres-homelab psql ...
```
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 │
└──────────────┘ └──────────────┘
```
@@ -0,0 +1 @@
# EL-KADI Home Security Agent
+243
View File
@@ -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
+91
View File
@@ -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()
+159
View File
@@ -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)
+152
View File
@@ -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.105"),
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
+75
View File
@@ -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()
@@ -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} <b>EL-KADI SECURITY</b> · {severity.upper()}",
f"<b>{_esc(title)}</b>",
"",
_esc(body),
]
if actions:
lines.append("")
lines.append("<b>Agent acties:</b>")
for a in actions:
lines.append(f"{_esc(a)}")
return "\n".join(lines)
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
@@ -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
@@ -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.105: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: []
@@ -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.105}
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
@@ -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;
@@ -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
+6 -6
View File
@@ -207,9 +207,9 @@
- HA Voice Ctrl: - HA Voice Ctrl:
icon: home-assistant.png icon: home-assistant.png
href: http://192.168.1.211:8765 href: http://192.168.1.105:8765
description: HA Voice Ctrl description: HA Voice Ctrl
siteMonitor: http://192.168.1.211:8765 siteMonitor: http://192.168.1.105:8765
statusStyle: dot statusStyle: dot
- Productivity: - Productivity:
@@ -302,9 +302,9 @@
- Neo4j Browser: - Neo4j Browser:
icon: neo4j.png icon: neo4j.png
href: http://192.168.1.211:49154 href: http://192.168.1.105:49154
description: Neo4j Browser description: Neo4j Browser
siteMonitor: http://192.168.1.211:49154 siteMonitor: http://192.168.1.105:49154
statusStyle: dot statusStyle: dot
- OnlyOffice: - OnlyOffice:
@@ -478,9 +478,9 @@
- Home Control: - Home Control:
icon: mdi-server-network-#14b8a6 icon: mdi-server-network-#14b8a6
href: http://192.168.1.211:8765/dashboard#live href: http://192.168.1.105:8765/dashboard#live
description: Home Control description: Home Control
siteMonitor: http://192.168.1.211:8765 siteMonitor: http://192.168.1.105:8765
statusStyle: dot statusStyle: dot
- Web Design: - Web Design:
+1 -1
View File
@@ -14,7 +14,7 @@ services:
POSTGRES_DATABASE: ${JOPLIN_DB:-joplin} POSTGRES_DATABASE: ${JOPLIN_DB:-joplin}
POSTGRES_USER: ${POSTGRES_USER:-mo} POSTGRES_USER: ${POSTGRES_USER:-mo}
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_HOST: postgres-homelab POSTGRES_HOST: ${POSTGRES_HOST:-192.168.1.105}
depends_on: depends_on:
- joplin-db - joplin-db
+1 -1
View File
@@ -41,7 +41,7 @@ services:
ports: ports:
- "${POSTGRES_EXPORTER_PORT:-9187}:9187" - "${POSTGRES_EXPORTER_PORT:-9187}:9187"
environment: environment:
DATA_SOURCE_NAME: "postgresql://${PG_USER:-mo}:${PG_PASSWORD}@postgres-homelab:5432/${PG_DATABASE:-homelab}?sslmode=disable" DATA_SOURCE_NAME: "postgresql://${PG_USER:-mo}:${PG_PASSWORD}@${PG_HOST:-192.168.1.105}:${PG_PORT:-5433}/${PG_DATABASE:-homelab}?sslmode=disable"
networks: networks:
- homelab-monitor - homelab-monitor
@@ -0,0 +1,70 @@
# Monitoring stack — Prometheus + postgres-exporter + Grafana
# Start: cd monitoring && docker compose up -d --build
# UI: Grafana http://192.168.1.211:3002 · Prometheus http://192.168.1.211:9090
services:
prometheus:
image: prom/prometheus:v2.53.2
container_name: prometheus-homelab
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/targets:/etc/prometheus/targets:ro
- prometheus-homelab-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.enable-lifecycle
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- homelab-monitor
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: postgres-exporter
restart: unless-stopped
ports:
- "9187:9187"
environment:
DATA_SOURCE_NAME: "postgresql://mo:${PG_PASSWORD:-WaQTUw2t}@192.168.1.105:5433/homelab?sslmode=disable"
networks:
- homelab-monitor
grafana:
build:
context: ..
dockerfile: Dockerfile.grafana
image: grafana-homelab:latest
container_name: grafana-homelab
restart: unless-stopped
ports:
- "3002:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-WaQTUw2t}
GF_USERS_DEFAULT_THEME: dark
GF_SERVER_ROOT_URL: http://192.168.1.211:3002
PG_USER: mo
PG_DATABASE: homelab
HOMELAB_PG_PASSWORD: ${PG_PASSWORD:-WaQTUw2t}
volumes:
- grafana-homelab-data:/var/lib/grafana
- ../grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ../grafana/dashboards/homelab:/var/lib/grafana/dashboards/homelab:ro
- ../grafana/dashboards/imported:/var/lib/grafana/dashboards/imported:ro
depends_on:
- prometheus
networks:
- homelab-monitor
volumes:
prometheus-homelab-data:
grafana-homelab-data:
networks:
homelab-monitor:
name: homelab-monitor
driver: bridge
+16 -22
View File
@@ -1,4 +1,4 @@
# Prometheus — scrape targets op Docker bridge (naast postgres-homelab, neo4j, …). # Prometheus — homelab metrics (NAS stack scrapet LAN + bridge targets).
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
@@ -10,35 +10,29 @@ scrape_configs:
- job_name: postgres-exporter - job_name: postgres-exporter
static_configs: static_configs:
- targets: ["postgres-exporter-homelab:9187"] - targets: ["postgres-exporter:9187"]
labels: labels:
instance: postgres-homelab instance: postgres-vm102
# Neo4j 4.4+ enterprise metrics.prometheus.enabled → endpoint op poort 2004 # Neo4j Community 2026 heeft geen Prometheus :2004 — gebruik Neo4j dashboard via Postgres/Grafana SQL.
- job_name: neo4j # Enterprise: zet server.metrics.prometheus.enabled=true en scrape :2004.
- job_name: node-exporter
scrape_interval: 30s scrape_interval: 30s
metrics_path: /metrics
static_configs: static_configs:
- targets: ["neo4j:2004"] - targets: ["192.168.1.105:9100"]
labels: labels:
instance: neo4j instance: vm102-postgress
role: security
- targets: ["192.168.1.211:9100"]
labels:
instance: synology-nas
role: nas
# Proxmox VE — prometheus-pve-exporter; vul monitoring/prometheus/targets/extra.yml # Proxmox: vul targets in prometheus/targets/extra.yml (prometheus-pve-exporter :9221)
- job_name: proxmox-pve - job_name: proxmox
scrape_interval: 30s scrape_interval: 30s
file_sd_configs: file_sd_configs:
- files: - files:
- /etc/prometheus/targets/extra.yml - /etc/prometheus/targets/extra.yml
refresh_interval: 1m refresh_interval: 1m
# Synology / SNMP: zet targets in monitoring/prometheus/targets/snmp.yml en uncomment hieronder.
# - job_name: snmp
# scrape_interval: 60s
# metrics_path: /snmp
# params:
# module: [synology]
# static_configs:
# - targets:
# - 192.168.1.211
# labels:
# job: snmp-nas
+2 -1
View File
@@ -2,7 +2,8 @@
| | | | | |
|---|---| |---|---|
| **Productie** | **192.168.1.105** — Bolt :49153, Browser :49154 (`~/neo4j/` op VM 102) |
| **NAS compose** | Oude map; stack verplaatst naar VM 102 |
| **Poort** | 49153 | | **Poort** | 49153 |
| **Start** | `docker compose up -d` |
Zie [apps/README.md](../README.md) en [RESTORE.md](../../RESTORE.md). Zie [apps/README.md](../README.md) en [RESTORE.md](../../RESTORE.md).
+1
View File
@@ -22,6 +22,7 @@ services:
# Masquerade root URL voor nginx reverse proxy # Masquerade root URL voor nginx reverse proxy
- PGADMIN_CONFIG_SERVER_MODE=True - PGADMIN_CONFIG_SERVER_MODE=True
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False - PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False
- PGADMIN_SERVER_JSON_FILE=/pgadmin4/servers.json
volumes: volumes:
- pgadmin-data:/var/lib/pgadmin # persistentie: server lijst, instellingen - pgadmin-data:/var/lib/pgadmin # persistentie: server lijst, instellingen
+18 -3
View File
@@ -1,8 +1,23 @@
{ {
"Servers": { "Servers": {
"1": { "1": {
"Name": "Homelab PostgreSQL", "Name": "Homelab PostgreSQL (VM102)",
"Group": "Servers", "Group": "Homelab",
"Host": "192.168.1.105",
"Port": 5433,
"MaintenanceDB": "homelab",
"Username": "mo",
"Password": "WaQTUw2t",
"SSLMode": "prefer",
"PassFile": "",
"SSLCert": "",
"SSLKey": "",
"SSLRootCert": "",
"Comment": "Proxmox VM 102 Postgress — productie homelab DB"
},
"2": {
"Name": "Homelab PostgreSQL NAS backup",
"Group": "Homelab",
"Host": "192.168.1.211", "Host": "192.168.1.211",
"Port": 5433, "Port": 5433,
"MaintenanceDB": "homelab", "MaintenanceDB": "homelab",
@@ -13,7 +28,7 @@
"SSLCert": "", "SSLCert": "",
"SSLKey": "", "SSLKey": "",
"SSLRootCert": "", "SSLRootCert": "",
"Comment": "Synology NAS — Homelab dashboard database" "Comment": "Oude NAS-kopie — rollback / vergelijken"
} }
} }
} }
+2 -1
View File
@@ -2,7 +2,8 @@
| | | | | |
|---|---| |---|---|
| **Productie** | **192.168.1.105:5433** (VM 102, `~/homelab-postgres/`) |
| **NAS backup** | 192.168.1.211:5433 (`docker compose up -d` in deze map) |
| **Poort** | 5433 | | **Poort** | 5433 |
| **Start** | `docker compose up -d` |
Zie [apps/README.md](../README.md) en [RESTORE.md](../../RESTORE.md). Zie [apps/README.md](../README.md) en [RESTORE.md](../../RESTORE.md).