Compare commits
5 Commits
43c4ed7a6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9e08cdc0 | |||
| 3a77680477 | |||
| 1010a4b1ac | |||
| 0d6ee22247 | |||
| 02b1d155d4 |
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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/` | :49153–49155 | 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/) | :49153–49155 | 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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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).
|
||||||
|
|||||||
Reference in New Issue
Block a user