**ACLs (Access Control Lists):** - Ajout du type AclConfig dans sources/event.v (allowed_ips CIDR + allowed_tokens) - Chaque source webhook (Gitea, Uptime Kuma, Cron, Generic) supporte les ACLs - Validation IP via X-Forwarded-For / X-Real-IP avant HMAC - Validation Bearer token via Authorization header - Tests: 7 tests ACL (IP exact, CIDR, parse IPv4, ACL vide, IP+token combiné) **Plugin system:** - sources/plugin.v: runner exécutable externe, stdout JSON → Event - Exit 0 = publish, exit ≠ 0 = skip. Timeout configurable - Plugin loop dans server.v (goroutine, toutes les 60s) - Example: scripts/example-plugin-disk.sh (vérifie espace disque) **Docs:** - README.md: ajout source Plugin + section Features complète - ARCHITECTURE.md: flux Plugin, flux ACL, endpoints /metrics /api/silence - ROADMAP.md: Phase 5 → 8/8 complet, ajout v0.6.0 - ntfy-bridge.example.yaml: sections ACLs et Plugins commentées - Version bump: 0.5.0 → 0.6.0
This commit is contained in:
@@ -1,76 +1,90 @@
|
|||||||
# ntfy-bridge
|
# ntfy-bridge
|
||||||
|
|
||||||
Hub central de notifications pour homelab — agrège des sources multiples (Gitea, Docker, Uptime Kuma, health checks HTTP, scripts cron) et les transforme en notifications [Ntfy](https://ntfy.sh/) intelligentes, formatées, avec priorités et contexte.
|
Hub central de notifications pour homelab — agrège des sources multiples (Gitea, Docker, Uptime Kuma, health checks HTTP, scripts cron) et les transforme en notifications [Ntfy](https://ntfy.sh/) intelligentes, formatées, avec priorités et contexte.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ Gitea webhook │──┐
|
│ Gitea webhook │──┐
|
||||||
├─────────────────┤ │
|
├─────────────────┤ │
|
||||||
│ Uptime Kuma │──┤
|
│ Uptime Kuma │──┤
|
||||||
├─────────────────┤ │ ┌──────────────┐ ┌───────────┐ ┌──────────────┐
|
├─────────────────┤ │ ┌──────────────┐ ┌───────────┐ ┌──────────────┐
|
||||||
│ Docker events │──┤────→│ ntfy-bridge │─────→│ Ntfy Srv │─────→│ Ton phone │
|
│ Docker events │──┤────→│ ntfy-bridge │─────→│ Ntfy Srv │─────→│ Ton phone │
|
||||||
├─────────────────┤ │ └──────────────┘ └───────────┘ └──────────────┘
|
├─────────────────┤ │ └──────────────┘ └───────────┘ └──────────────┘
|
||||||
│ HTTP health │──┤
|
│ HTTP health │──┤
|
||||||
├─────────────────┤ │
|
├─────────────────┤ │
|
||||||
│ Scripts cron │──┘
|
│ Scripts cron │──┘
|
||||||
└─────────────────┘
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pourquoi ntfy-bridge ?
|
## Pourquoi ntfy-bridge ?
|
||||||
|
|
||||||
Dans un homelab avec 8+ hôtes Docker, Gitea, Uptime Kuma et des dizaines de services, les alertes arrivent de partout. `ntfy-bridge` centralise tout dans une seule file de notifications intelligentes — avec le bon niveau de priorité, le bon format, et le bon topic Ntfy.
|
Dans un homelab avec 8+ hôtes Docker, Gitea, Uptime Kuma et des dizaines de services, les alertes arrivent de partout. `ntfy-bridge` centralise tout dans une seule file de notifications intelligentes — avec le bon niveau de priorité, le bon format, et le bon topic Ntfy.
|
||||||
|
|
||||||
| Sans ntfy-bridge | Avec ntfy-bridge |
|
| Sans ntfy-bridge | Avec ntfy-bridge |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Webhook Gitea → email (noyé) | Push = notif Ntfy formatée avec auteur, commit, message |
|
| Webhook Gitea → email (noyé) | Push = notif Ntfy formatée avec auteur, commit, message |
|
||||||
| Uptime Kuma → alerte brute | "🚨 Gitea DOWN — 503 — since 14:32" avec priority=5 |
|
| Uptime Kuma → alerte brute | "🚨 Gitea DOWN — 503 — since 14:32" avec priority=5 |
|
||||||
| Container qui crashe → logs Docker | "🐳 flowdeck exited OOMKilled on docker-prod-1" |
|
| Container qui crashe → logs Docker | "🐳 flowdeck exited OOMKilled on docker-prod-1" |
|
||||||
| Script cron → email perdu | Résumé quotidien disque à 9h dans ta poche |
|
| Script cron → email perdu | Résumé quotidien disque à 9h dans ta poche |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Installer V (si pas déjà fait)
|
# Installer V (si pas déjà fait)
|
||||||
curl -s https://github.com/vlang/v/releases/latest/download/v_linux.zip -o /tmp/v.zip
|
curl -s https://github.com/vlang/v/releases/latest/download/v_linux.zip -o /tmp/v.zip
|
||||||
unzip /tmp/v.zip -d /tmp && sudo /tmp/v/v symlink
|
unzip /tmp/v.zip -d /tmp && sudo /tmp/v/v symlink
|
||||||
|
|
||||||
# Compiler
|
# Compiler
|
||||||
git clone https://git.dracodev.net/bruno/ntfy-bridge.git
|
git clone https://git.dracodev.net/bruno/ntfy-bridge.git
|
||||||
cd ntfy-bridge
|
cd ntfy-bridge
|
||||||
v .
|
v .
|
||||||
|
|
||||||
# Configurer
|
# Configurer
|
||||||
cp ntfy-bridge.example.yaml ntfy-bridge.yaml
|
cp ntfy-bridge.example.yaml ntfy-bridge.yaml
|
||||||
vim ntfy-bridge.yaml
|
vim ntfy-bridge.yaml
|
||||||
|
|
||||||
# Lancer
|
# Lancer
|
||||||
./ntfy-bridge --config ntfy-bridge.yaml
|
./ntfy-bridge --config ntfy-bridge.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sources supportées
|
## Sources supportées
|
||||||
|
|
||||||
| Source | Type | Description |
|
| Source | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| **Gitea** | Webhook | Push, PR, issues, releases → notifs formatées |
|
| **Gitea** | Webhook | Push, PR, issues, releases → notifs formatées |
|
||||||
| **Uptime Kuma** | Webhook | Statut up/down avec priorité critique |
|
| **Uptime Kuma** | Webhook | Statut up/down avec priorité critique |
|
||||||
| **Docker** | Socket | Container start/die/oom/health_status |
|
| **Docker** | Socket | Container start/die/oom/health_status |
|
||||||
| **HTTP Poll** | Polling | Health checks périodiques |
|
| **HTTP Poll** | Polling | Health checks périodiques |
|
||||||
| **Cron** | Webhook | Reçoit des notifs depuis des scripts shell |
|
| **Cron** | Webhook | Reçoit des notifs depuis des scripts shell |
|
||||||
| **Generic** | Webhook | Endpoint passe-partout pour tout script custom |
|
| **Generic** | Webhook | Endpoint passe-partout pour tout script custom |
|
||||||
|
| **Plugin** | Exec | Exécutable externe appelé périodiquement (bash, python, …) |
|
||||||
## Exemple de config minimale
|
|
||||||
|
## Features
|
||||||
```yaml
|
|
||||||
server:
|
- **ACLs** — Restriction par IP (CIDR) ou Bearer token sur chaque webhook
|
||||||
url: https://ntfy.dracodev.net
|
- **Plugins** — Scripts externes exécutés toutes les 60s, contrat JSON stdout
|
||||||
listen: ":9090"
|
- **Dashboard** — Interface web avec stats, statuts, historique temps réel
|
||||||
|
- **Filtres** — Règles conditionnelles drop/set_priority/add_tag par source
|
||||||
sources:
|
- **Actions Ntfy** — Boutons click, vues, broadcast intégrés
|
||||||
gitea:
|
- **Prometheus** — Endpoint `/metrics` pour Grafana
|
||||||
- webhook_path: /webhooks/gitea
|
- **Outgoing webhooks** — Forward vers Slack, Discord, JSON
|
||||||
topic: dev-notifs
|
- **Silence rules** — Mute temporaire des notifs via API
|
||||||
```
|
- **Grouping** — Regroupement intelligent des notifs identiques
|
||||||
|
- **State persistence** — Sauvegarde de l'état up/down et silence sur disque
|
||||||
## Licence
|
|
||||||
|
## Exemple de config minimale
|
||||||
MIT
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
url: https://ntfy.dracodev.net
|
||||||
|
listen: ":9090"
|
||||||
|
|
||||||
|
sources:
|
||||||
|
gitea:
|
||||||
|
- webhook_path: /webhooks/gitea
|
||||||
|
topic: dev-notifs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
module main
|
||||||
|
|
||||||
|
import net.http
|
||||||
|
import sources
|
||||||
|
|
||||||
|
fn test_ip_matches_exact() {
|
||||||
|
assert ip_matches('192.168.30.5', '192.168.30.5')
|
||||||
|
assert !ip_matches('192.168.30.6', '192.168.30.5')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_ip_matches_cidr() {
|
||||||
|
assert ip_matches('192.168.30.5', '192.168.0.0/16')
|
||||||
|
assert ip_matches('10.0.0.1', '10.0.0.0/8')
|
||||||
|
assert ip_matches('10.255.255.255', '10.0.0.0/8')
|
||||||
|
assert !ip_matches('11.0.0.1', '10.0.0.0/8')
|
||||||
|
assert ip_matches('192.168.1.100', '192.168.1.0/24')
|
||||||
|
assert !ip_matches('192.168.2.1', '192.168.1.0/24')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_parse_ip_valid() {
|
||||||
|
octets := parse_ip('192.168.1.5')!
|
||||||
|
assert octets[0] == 192
|
||||||
|
assert octets[1] == 168
|
||||||
|
assert octets[2] == 1
|
||||||
|
assert octets[3] == 5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_parse_ip_invalid() {
|
||||||
|
// parse_ip with invalid input should return an error
|
||||||
|
result := parse_ip('invalid') or { []u8{} }
|
||||||
|
assert result.len == 0
|
||||||
|
|
||||||
|
result2 := parse_ip('1.2.3') or { []u8{} }
|
||||||
|
assert result2.len == 0
|
||||||
|
|
||||||
|
result3 := parse_ip('999.1.2.3') or { []u8{} }
|
||||||
|
assert result3.len == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_check_acl_no_acl() {
|
||||||
|
mut app := new_test_acl_app()
|
||||||
|
route := WebhookRoute{kind: .generic, index: 0}
|
||||||
|
assert app.check_acl(route, '1.2.3.4', '') == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_check_acl_ip_allow() {
|
||||||
|
mut app := new_test_acl_app()
|
||||||
|
app.cfg.sources.generic << sources.GenericSource{
|
||||||
|
name: 'test-source'
|
||||||
|
webhook_path: '/test'
|
||||||
|
topic: 'test-topic'
|
||||||
|
acl: sources.AclConfig{
|
||||||
|
allowed_ips: ['10.0.0.0/8']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route := WebhookRoute{kind: .generic, index: 0}
|
||||||
|
|
||||||
|
assert app.check_acl(route, '10.0.0.5', '') == true
|
||||||
|
assert app.check_acl(route, '10.255.0.1', '') == true
|
||||||
|
assert app.check_acl(route, '192.168.1.1', '') == false
|
||||||
|
assert app.check_acl(route, '', '') == false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_check_acl_combined_ip_and_token() {
|
||||||
|
mut app := new_test_acl_app()
|
||||||
|
app.cfg.sources.generic << sources.GenericSource{
|
||||||
|
name: 'test-source'
|
||||||
|
webhook_path: '/test'
|
||||||
|
topic: 'test-topic'
|
||||||
|
acl: sources.AclConfig{
|
||||||
|
allowed_ips: ['10.0.0.0/8']
|
||||||
|
allowed_tokens: ['secret123']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route := WebhookRoute{kind: .generic, index: 0}
|
||||||
|
|
||||||
|
// Both pass
|
||||||
|
assert app.check_acl(route, '10.0.0.5', 'secret123') == true
|
||||||
|
// IP ok but no token
|
||||||
|
assert app.check_acl(route, '10.0.0.5', '') == false
|
||||||
|
// Token ok but wrong IP
|
||||||
|
assert app.check_acl(route, '192.168.1.1', 'secret123') == false
|
||||||
|
// Both wrong
|
||||||
|
assert app.check_acl(route, '1.1.1.1', 'wrong') == false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_extract_bearer_token_empty() {
|
||||||
|
req := http.Request{
|
||||||
|
header: http.new_header()
|
||||||
|
}
|
||||||
|
assert extract_bearer_token(req) == ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: create a minimal App for ACL tests
|
||||||
|
fn new_test_acl_app() App {
|
||||||
|
return App{
|
||||||
|
logger: new_logger(.info, false)
|
||||||
|
cfg: Config{
|
||||||
|
filters: FilterConfig{}
|
||||||
|
outgoing: OutgoingConfig{}
|
||||||
|
state: StateConfig{}
|
||||||
|
}
|
||||||
|
poll_state: map[string]bool{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ pub mut:
|
|||||||
http_poll []sources.HttpPollSource @[json: 'http_poll']
|
http_poll []sources.HttpPollSource @[json: 'http_poll']
|
||||||
cron []sources.CronSource @[json: 'cron']
|
cron []sources.CronSource @[json: 'cron']
|
||||||
generic []sources.GenericSource @[json: 'generic']
|
generic []sources.GenericSource @[json: 'generic']
|
||||||
|
plugin []sources.PluginSource @[json: 'plugin']
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|||||||
+1
-1
@@ -4,8 +4,8 @@ import time
|
|||||||
$if linux || macos {
|
$if linux || macos {
|
||||||
import net.unix
|
import net.unix
|
||||||
import os
|
import os
|
||||||
|
import sources
|
||||||
}
|
}
|
||||||
import sources
|
|
||||||
|
|
||||||
fn (mut app App) docker_watch_loop() {
|
fn (mut app App) docker_watch_loop() {
|
||||||
$if linux || macos {
|
$if linux || macos {
|
||||||
|
|||||||
+335
-295
@@ -1,295 +1,335 @@
|
|||||||
# ntfy-bridge — Architecture
|
# ntfy-bridge — Architecture
|
||||||
|
|
||||||
## Philosophie
|
## Philosophie
|
||||||
|
|
||||||
ntfy-bridge est un **daemon HTTP léger** écrit en V (~1000 lignes) qui compile en un seul binaire natif statique. Il écoute des webhooks, se connecte au socket Docker, et poll des endpoints HTTP — puis transforme chaque événement en notification Ntfy formatée.
|
ntfy-bridge est un **daemon HTTP léger** écrit en V (~2000 lignes) qui compile en un seul binaire natif statique. Il écoute des webhooks, se connecte au socket Docker, exécute des plugins externes, et poll des endpoints HTTP — puis transforme chaque événement en notification Ntfy formatée.
|
||||||
|
|
||||||
**Principes :**
|
**Principes :**
|
||||||
- **Un binaire, zéro runtime** — pas de Node, Python, JVM ou conteneur obligatoire
|
- **Un binaire, zéro runtime** — pas de Node, Python, JVM ou conteneur obligatoire
|
||||||
- **Config-driven** — tout passe par le fichier YAML, pas de recompilation
|
- **Config-driven** — tout passe par le fichier YAML, pas de recompilation
|
||||||
- **Fail-safe** — un échec réseau vers Ntfy ne crashe pas le daemon, retry avec backoff
|
- **Fail-safe** — un échec réseau vers Ntfy ne crashe pas le daemon, retry avec backoff
|
||||||
- **Silencieux par défaut** — ne spamme pas, utilise les bons niveaux de priorité Ntfy (1-5)
|
- **Silencieux par défaut** — ne spamme pas, utilise les bons niveaux de priorité Ntfy (1-5)
|
||||||
- **Extensible** — ajouter une source = ajouter un fichier dans `sources/` + une route webhook
|
- **Extensible** — ajouter une source = ajouter un fichier dans `sources/` + une route webhook
|
||||||
- **Zéro dépendance externe** — 100% stdlib V (`net.http`, `json`, `yaml`, `time`, `os`, `flag`)
|
- **Zéro dépendance externe** — 100% stdlib V (`net.http`, `json`, `yaml`, `time`, `os`, `flag`)
|
||||||
|
|
||||||
## Architecture globale
|
## Architecture globale
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ ntfy-bridge │
|
│ ntfy-bridge │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ │
|
||||||
│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron/ │ │
|
│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron/ │ │ Plugin │ │
|
||||||
│ │ Server │ │ Watcher │ │ Poller │ │ Generic │ │
|
│ │ Server │ │ Watcher │ │ Poller │ │ Generic │ │ Runner │ │
|
||||||
│ │ :9090 │ │ goroutine│ │ goroutine│ │ Receiver │ │
|
│ │ :9090 │ │ goroutine│ │ goroutine│ │ Receiver │ │ goroutine│ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ └────┬─────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │ │
|
||||||
│ └──────────────┼──────────────┼──────────────┘ │
|
│ └──────────────┼──────────────┼──────────────┼──────────────┘ │
|
||||||
│ ▼ ▼ │
|
│ ▼ ▼ │
|
||||||
│ ┌──────────────────────────┐ │
|
│ ┌──────────────────────────┐ │
|
||||||
│ │ Event Pipeline │ │
|
│ │ Event Pipeline │ │
|
||||||
│ │ ┌────────┐ ┌────────┐ │ │
|
│ │ ┌────────┐ ┌────────┐ │ │
|
||||||
│ │ │Parse & │ │Dedup & │ │ │
|
│ │ │Parse & │ │Dedup & │ │ │
|
||||||
│ │ │Format │──│RateLim │ │ │
|
│ │ │Format │──│RateLim │ │ │
|
||||||
│ │ └────────┘ └────────┘ │ │
|
│ │ └────────┘ └────────┘ │ │
|
||||||
│ └────────────┬─────────────┘ │
|
│ └────────────┬─────────────┘ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌──────────────────────────┐ │
|
│ ┌──────────────────────────┐ │
|
||||||
│ │ Ntfy Publisher (retry) │ │
|
│ │ Ntfy Publisher (retry) │ │
|
||||||
│ │ POST /<topic> │ │
|
│ │ POST /<topic> │ │
|
||||||
│ │ 3 tentatives, backoff │ │
|
│ │ 3 tentatives, backoff │ │
|
||||||
│ └──────────────────────────┘ │
|
│ └──────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flux de données
|
## Flux de données
|
||||||
|
|
||||||
### 1. Webhook entrant (Gitea, Uptime Kuma, Cron, Generic)
|
### 1. Webhook entrant (Gitea, Uptime Kuma, Cron, Generic)
|
||||||
```
|
```
|
||||||
POST /webhooks/gitea-flowdeck
|
POST /webhooks/gitea-flowdeck
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. O(1) lookup dans webhook_routes (map[string]WebhookRoute)
|
1. O(1) lookup dans webhook_routes (map[string]WebhookRoute)
|
||||||
2. Verify HMAC-SHA256 signature (si configuré, global)
|
2. Verify HMAC-SHA256 signature (si configuré, global)
|
||||||
3. Dispatcher vers le transformer approprié (sources/transform_*.v)
|
3. Dispatcher vers le transformer approprié (sources/transform_*.v)
|
||||||
4. Parse JSON → Event struct
|
4. Parse JSON → Event struct
|
||||||
5. Appliquer priority_map
|
5. Appliquer priority_map
|
||||||
6. Appliquer template de message (via render_source_template)
|
6. Appliquer template de message (via render_source_template)
|
||||||
7. Pipeline publish: apply_defaults → dedup.allow → ntfy.publish
|
7. Pipeline publish: apply_defaults → dedup.allow → ntfy.publish
|
||||||
8. POST vers Ntfy avec retry (3 tentatives, backoff 1s/2s/4s)
|
8. POST vers Ntfy avec retry (3 tentatives, backoff 1s/2s/4s)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Docker watcher (socket)
|
### 2. Docker watcher (socket)
|
||||||
```
|
```
|
||||||
goroutine docker_watch_loop (via start_background_tasks)
|
goroutine docker_watch_loop (via start_background_tasks)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. Connexion au socket Docker Unix (/var/run/docker.sock)
|
1. Connexion au socket Docker Unix (/var/run/docker.sock)
|
||||||
2. Stream events via GET /events?filters={"die":true,"oom":true,...}
|
2. Stream events via GET /events?filters={"die":true,"oom":true,...}
|
||||||
3. Filtrer par événements configurés (die, oom, health_status)
|
3. Filtrer par événements configurés (die, oom, health_status)
|
||||||
4. Extraire container_name, image, exit_code via transform_docker_event
|
4. Extraire container_name, image, exit_code via transform_docker_event
|
||||||
5. Appliquer template
|
5. Appliquer template
|
||||||
6. Pipeline publish → Ntfy
|
6. Pipeline publish → Ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. HTTP Poller
|
### 3. HTTP Poller
|
||||||
```
|
```
|
||||||
goroutine http_poll_loop (via start_background_tasks, toutes les N secondes)
|
goroutine http_poll_loop (via start_background_tasks, toutes les N secondes)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. GET <url> avec timeout configuré
|
1. GET <url> avec timeout configuré
|
||||||
2. Si status ≠ expected → Event(priority=configurée, tags=["x"])
|
2. Si status ≠ expected → Event(priority=configurée, tags=["x"])
|
||||||
3. Si OK et précédemment DOWN → Event(priority=1, tags=["white_check_mark"])
|
3. Si OK et précédemment DOWN → Event(priority=1, tags=["white_check_mark"])
|
||||||
4. Sinon → rien (pas de notification si tout va bien)
|
4. Sinon → rien (pas de notification si tout va bien)
|
||||||
5. State machine: poll_state[url] = is_up → notification uniquement au changement
|
5. State machine: poll_state[url] = is_up → notification uniquement au changement
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Cron receiver
|
### 4. Cron receiver
|
||||||
```
|
```
|
||||||
POST /webhooks/cron-disk
|
POST /webhooks/cron-disk
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. Body = texte brut du script cron
|
1. Body = texte brut du script cron
|
||||||
2. Pass-through vers Ntfy (transform_cron → Event)
|
2. Pass-through vers Ntfy (transform_cron → Event)
|
||||||
3. Le script cron contrôle son propre format
|
3. Le script cron contrôle son propre format
|
||||||
```
|
```
|
||||||
|
|
||||||
## Modèle de données
|
### 5. Plugin runner (v0.6)
|
||||||
|
```
|
||||||
```v
|
goroutine plugin_loop (via start_background_tasks, toutes les 60s)
|
||||||
// config.v
|
│
|
||||||
struct ServerConfig {
|
▼
|
||||||
url string // URL du serveur Ntfy
|
1. Exécute le binaire configuré (os.execute)
|
||||||
auth_token string // Token d'auth (optionnel, override via NTFY_TOKEN env)
|
2. Lit stdout → parse JSON → Event struct
|
||||||
listen string // Adresse d'écoute HTTP (:9090)
|
3. Exit 0 = publier, exit ≠ 0 = skip (pas de notif)
|
||||||
hmac_secret string // Secret partagé HMAC-SHA256 (override via NTFY_HMAC_SECRET env)
|
4. Supporte priority, tags, message, click_url, actions
|
||||||
}
|
5. Pipeline publish → Ntfy
|
||||||
|
```
|
||||||
struct DedupConfig {
|
|
||||||
enabled bool
|
### 6. ACL pipeline (v0.6)
|
||||||
ttl_seconds int // TTL du cache de déduplication
|
```
|
||||||
rate_limit RateLimitConfig // max_per_minute, max_per_source
|
POST /webhooks/*
|
||||||
}
|
│
|
||||||
|
▼
|
||||||
// sources/event.v
|
1. Extraire IP client (X-Forwarded-For > X-Real-IP)
|
||||||
struct Event {
|
2. Extraire Bearer token (Authorization header)
|
||||||
source string // "gitea", "docker", "uptime_kuma", "http_poll", "cron", "generic"
|
3. Si ACL configurée sur le webhook → valider IP et/ou token
|
||||||
name string // nom de la source configurée
|
4. Si échec → 403 Forbidden
|
||||||
topic string // topic Ntfy cible
|
5. Sinon → continuer le pipeline normal
|
||||||
priority int // 1-5
|
```
|
||||||
tags []string
|
|
||||||
message string // message formaté final
|
## Modèle de données
|
||||||
raw string // payload brut (pour debug)
|
|
||||||
}
|
```v
|
||||||
```
|
// config.v
|
||||||
|
struct ServerConfig {
|
||||||
Les types de sources (GiteaSource, UptimeKumaSource, DockerSource, HttpPollSource/HttpCheck, CronSource, GenericSource) sont définis dans `sources/event.v`.
|
url string // URL du serveur Ntfy
|
||||||
|
auth_token string // Token d'auth (optionnel, override via NTFY_TOKEN env)
|
||||||
## Pipeline de transformation
|
listen string // Adresse d'écoute HTTP (:9090)
|
||||||
|
hmac_secret string // Secret partagé HMAC-SHA256 (override via NTFY_HMAC_SECRET env)
|
||||||
Chaque source a une **free function** dans `sources/` :
|
}
|
||||||
|
|
||||||
```v
|
struct DedupConfig {
|
||||||
// Exemple : sources/gitea.v
|
enabled bool
|
||||||
pub fn transform_gitea(raw string, source GiteaSource) ?Event { ... }
|
ttl_seconds int // TTL du cache de déduplication
|
||||||
```
|
rate_limit RateLimitConfig // max_per_minute, max_per_source
|
||||||
|
}
|
||||||
Pas d'interface — les fonctions sont dispatchées via un `match route.kind` dans `server.v`. L'ajout d'une nouvelle source nécessite :
|
|
||||||
1. Un fichier `sources/nouveau.v` avec la `transform_*` function
|
// sources/event.v
|
||||||
2. Un type source dans `sources/event.v`
|
struct Event {
|
||||||
3. Une entrée dans `SourcesConfig` (dans `config.v`)
|
source string // "gitea", "docker", "uptime_kuma", "http_poll", "cron", "generic", "plugin"
|
||||||
4. Une branche dans `build_webhook_routes` et `handle_webhook`
|
name string // nom de la source configurée
|
||||||
|
topic string // topic Ntfy cible
|
||||||
### Transformers inclus
|
priority int // 1-5
|
||||||
|
tags []string
|
||||||
| Transformer | Entrée | Sortie |
|
message string // message formaté final
|
||||||
|---|---|---|
|
raw string // payload brut (pour debug)
|
||||||
| `transform_gitea` | JSON webhook Gitea | `🔨 [FlowDeck] bruno pushed: "Fix bug" (a3f2c1d)` |
|
click_url string // URL ouverte au clic sur la notif (v0.5)
|
||||||
| `transform_uptime_kuma` | JSON webhook Kuma | `🚨 Gitea is DOWN — 503 — since 14:32` |
|
actions []Action // boutons d'action Ntfy (v0.5)
|
||||||
| `transform_docker_event` | Docker event JSON | `🐳 flowdeck exited on docker-prod-1` |
|
}
|
||||||
| `transform_http_down/up` | HTTP response | `❌ og.dracodev.net/health → timeout` |
|
```
|
||||||
| `transform_cron` | Raw body | Pass-through, pas de transformation |
|
|
||||||
| `transform_generic` | Raw body | Pass-through, pas de transformation |
|
Les types de sources (GiteaSource, UptimeKumaSource, DockerSource, HttpPollSource/HttpCheck, CronSource, GenericSource, PluginSource) et AclConfig sont définis dans `sources/event.v`.
|
||||||
|
|
||||||
### Template engine
|
## Pipeline de transformation
|
||||||
|
|
||||||
`render_source_template(template, vars)` dans `sources/event.v` — remplacement simple `{variable}` → valeur. Supporté par Gitea, Uptime Kuma, Docker, HTTP poll.
|
Chaque source a une **free function** dans `sources/` :
|
||||||
|
|
||||||
```
|
```v
|
||||||
template: "🐳 {container_name} → {status}\nImage: {image}\nHost: {host}"
|
// Exemple : sources/gitea.v
|
||||||
```
|
pub fn transform_gitea(raw string, source GiteaSource) ?Event { ... }
|
||||||
|
```
|
||||||
### Priorités Ntfy
|
|
||||||
|
Pas d'interface — les fonctions sont dispatchées via un `match route.kind` dans `server.v`. L'ajout d'une nouvelle source nécessite :
|
||||||
| Niveau | Usage | Exemple |
|
1. Un fichier `sources/nouveau.v` avec la `transform_*` function
|
||||||
|---|---|---|
|
2. Un type source dans `sources/event.v`
|
||||||
| 5 (urgent) | Service critique down, container OOM | Uptime Kuma DOWN |
|
3. Une entrée dans `SourcesConfig` (dans `config.v`)
|
||||||
| 4 (high) | PR ouverte, container crash | Gitea pull_request, Docker die |
|
4. Une branche dans `build_webhook_routes` et `handle_webhook`
|
||||||
| 3 (default) | Push, issue, activité normale | Gitea push |
|
|
||||||
| 2 (low) | Info, succès | Service back UP |
|
### Transformers inclus
|
||||||
| 1 (min) | Debug, heartbeat | Health check OK |
|
|
||||||
|
| Transformer | Entrée | Sortie |
|
||||||
## Déduplication et rate limiting
|
|---|---|---|
|
||||||
|
| `transform_gitea` | JSON webhook Gitea | `🔨 [FlowDeck] bruno pushed: "Fix bug" (a3f2c1d)` |
|
||||||
- **Déduplication** : hash du message (FNV-1a-like) → cache avec TTL configurable (défaut: 5 min). Clé = `source:name:topic:hash(message)`. Un message identique dans la fenêtre TTL est ignoré.
|
| `transform_uptime_kuma` | JSON webhook Kuma | `🚨 Gitea is DOWN — 503 — since 14:32` |
|
||||||
- **Rate limiting par topic** : `max_per_minute` notifications max par topic (défaut: 10). Fenêtre glissante de 60s.
|
| `transform_docker_event` | Docker event JSON | `🐳 flowdeck exited on docker-prod-1` |
|
||||||
- **Rate limiting par source** : `max_per_source` notifications max par source configurée (défaut: 30). Fenêtre glissante de 60s.
|
| `transform_http_down/up` | HTTP response | `❌ og.dracodev.net/health → timeout` |
|
||||||
- **Nettoyage automatique** : les entrées expirées sont nettoyées à chaque appel `allow()`.
|
| `transform_cron` | Raw body | Pass-through, pas de transformation |
|
||||||
|
| `transform_generic` | Raw body | Pass-through, pas de transformation |
|
||||||
## Structure du projet
|
| `transform_plugin` | stdout d'un exécutable externe | JSON → Event (exit 0 = publish, ≠ 0 = skip) |
|
||||||
|
|
||||||
```
|
### Template engine
|
||||||
ntfy-bridge/
|
|
||||||
├── README.md # Vue d'ensemble, quickstart
|
`render_source_template(template, vars)` dans `sources/event.v` — remplacement simple `{variable}` → valeur. Supporté par Gitea, Uptime Kuma, Docker, HTTP poll.
|
||||||
├── docs/
|
|
||||||
│ ├── ARCHITECTURE.md # Ce document
|
```
|
||||||
│ ├── ROADMAP.md # Phases de développement
|
template: "🐳 {container_name} → {status}\nImage: {image}\nHost: {host}"
|
||||||
│ ├── CONTRIBUTING.md # Guide de contribution
|
```
|
||||||
│ └── WEBHOOK_GITEA.md # Guide config webhook Gitea
|
|
||||||
├── ntfy-bridge.example.yaml # Exemple de configuration complet
|
### Priorités Ntfy
|
||||||
├── main.v # Entrypoint, parsing CLI, header ASCII
|
|
||||||
├── config.v # Chargement + validation YAML, env overrides
|
| Niveau | Usage | Exemple |
|
||||||
├── server.v # HTTP server, routing webhooks O(1), dashboard, API
|
|---|---|---|
|
||||||
├── ntfy.v # Client HTTP Ntfy (POST + retry backoff)
|
| 5 (urgent) | Service critique down, container OOM | Uptime Kuma DOWN |
|
||||||
├── dedup.v # Déduplication + rate limiting (topic + source)
|
| 4 (high) | PR ouverte, container crash | Gitea pull_request, Docker die |
|
||||||
├── template.v # Application des defaults (priority, tags)
|
| 3 (default) | Push, issue, activité normale | Gitea push |
|
||||||
├── log.v # Logging structuré (human + JSON)
|
| 2 (low) | Info, succès | Service back UP |
|
||||||
├── webhook.v # O(1) route dispatch (WebhookRoute map)
|
| 1 (min) | Debug, heartbeat | Health check OK |
|
||||||
├── docker_watcher.v # Watcher socket Docker (Unix seulement)
|
|
||||||
├── dashboard.html # Interface web : stats, status, historique
|
## Déduplication et rate limiting
|
||||||
├── sources/ # Types Event + transformers
|
|
||||||
│ ├── event.v # Types Event, Source, render_source_template
|
- **Déduplication** : hash du message (FNV-1a-like) → cache avec TTL configurable (défaut: 5 min). Clé = `source:name:topic:hash(message)`. Un message identique dans la fenêtre TTL est ignoré.
|
||||||
│ ├── gitea.v # Transformer Gitea (push, PR, issue, release)
|
- **Rate limiting par topic** : `max_per_minute` notifications max par topic (défaut: 10). Fenêtre glissante de 60s.
|
||||||
│ ├── uptime_kuma.v # Transformer Uptime Kuma (state_map)
|
- **Rate limiting par source** : `max_per_source` notifications max par source configurée (défaut: 30). Fenêtre glissante de 60s.
|
||||||
│ ├── docker.v # Transformer Docker events
|
- **Nettoyage automatique** : les entrées expirées sont nettoyées à chaque appel `allow()`.
|
||||||
│ ├── http_poll.v # Transformer HTTP poll (up/down)
|
|
||||||
│ ├── cron.v # Transformer Cron (pass-through)
|
## Structure du projet
|
||||||
│ └── generic.v # Transformer Generic (pass-through)
|
|
||||||
├── *_test.v # Tests unitaires (config, gitea, uptime_kuma, dedup)
|
```
|
||||||
├── scripts/
|
ntfy-bridge/
|
||||||
│ └── example-cron-disk.sh # Exemple script cron → Ntfy
|
├── README.md # Vue d'ensemble, quickstart
|
||||||
└── v.mod # Dépendances V (aucune dépendance externe)
|
├── docs/
|
||||||
```
|
│ ├── ARCHITECTURE.md # Ce document
|
||||||
|
│ ├── ROADMAP.md # Phases de développement
|
||||||
## Dépendances
|
│ ├── CONTRIBUTING.md # Guide de contribution
|
||||||
|
│ └── WEBHOOK_GITEA.md # Guide config webhook Gitea
|
||||||
```v
|
├── ntfy-bridge.example.yaml # Exemple de configuration complet
|
||||||
// v.mod
|
├── main.v # Entrypoint, parsing CLI, header ASCII
|
||||||
Module {
|
├── config.v # Chargement + validation YAML, env overrides
|
||||||
name: 'ntfy-bridge'
|
├── server.v # HTTP server, routing webhooks O(1), dashboard, API
|
||||||
version: '0.1.0'
|
├── ntfy.v # Client HTTP Ntfy (POST + retry backoff)
|
||||||
dependencies: [] // zéro dépendance externe
|
├── dedup.v # Déduplication + rate limiting (topic + source)
|
||||||
}
|
├── template.v # Application des defaults (priority, tags)
|
||||||
```
|
├── log.v # Logging structuré (human + JSON)
|
||||||
|
├── webhook.v # O(1) route dispatch (WebhookRoute map)
|
||||||
Le projet utilise **uniquement la stdlib V** :
|
├── docker_watcher.v # Watcher socket Docker (Unix seulement)
|
||||||
- `net.http` — serveur HTTP + client Ntfy
|
├── filter_outgoing.v # Filtres avancés + outgoing webhooks + state persistence
|
||||||
- `x.json2` — parsing JSON
|
├── metrics.v # Endpoint Prometheus /metrics
|
||||||
- `yaml` — parsing YAML
|
├── service.v # Installation/désinstallation de service (systemd, openrc, nssm)
|
||||||
- `crypto.hmac`, `crypto.sha256`, `encoding.hex` — validation HMAC
|
├── docker_install.v # Génération de stack docker-compose
|
||||||
- `time`, `os`, `flag` — utilitaires
|
├── dashboard.html # Interface web : stats, status, historique
|
||||||
- `net.unix` — socket Docker (Linux/macOS seulement, via `$if`)
|
├── sources/ # Types Event + transformers
|
||||||
|
│ ├── event.v # Types Event, Source, render_source_template
|
||||||
## Endpoints HTTP
|
│ ├── gitea.v # Transformer Gitea (push, PR, issue, release)
|
||||||
|
│ ├── uptime_kuma.v # Transformer Uptime Kuma (state_map)
|
||||||
| Méthode | Path | Description |
|
│ ├── docker.v # Transformer Docker events
|
||||||
|---------|------|-------------|
|
│ ├── http_poll.v # Transformer HTTP poll (up/down)
|
||||||
| GET | `/` ou `/dashboard` | Dashboard HTML |
|
│ ├── cron.v # Transformer Cron (pass-through)
|
||||||
| GET | `/health` | Health check → `{"status":"ok","uptime_seconds":N,"total_notifications":N}` |
|
│ ├── generic.v # Transformer Generic (pass-through)
|
||||||
| GET | `/stats` | Stats par source → `{"total":N,"errors":N,"by_source":{...}}` |
|
│ └── plugin.v # Transformer Plugin (exec stdout JSON)
|
||||||
| GET | `/api/config` | Config résumée (safe, sans secrets) |
|
├── *_test.v # Tests unitaires (config, gitea, uptime_kuma, dedup, filter_outgoing, metrics, acl)
|
||||||
| GET | `/api/status` | État des health checks HTTP poll |
|
├── scripts/
|
||||||
| GET | `/api/history` | 100 derniers événements |
|
│ ├── example-cron-disk.sh # Exemple script cron → Ntfy
|
||||||
| POST | `/webhooks/*` | Webhooks (Gitea, Uptime Kuma, Cron, Generic) |
|
│ ├── example-plugin-disk.sh # Exemple plugin disk check
|
||||||
|
│ ├── install.sh # Script d'installation Linux one-liner
|
||||||
## Déploiement
|
│ └── install.ps1 # Script d'installation Windows PowerShell
|
||||||
|
└── v.mod # Dépendances V (aucune dépendance externe)
|
||||||
### Option A : Binaire nu + systemd
|
```
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/ntfy-bridge.service
|
## Dépendances
|
||||||
[Unit]
|
|
||||||
Description=ntfy-bridge notification hub
|
```v
|
||||||
After=network.target
|
// v.mod
|
||||||
|
Module {
|
||||||
[Service]
|
name: 'ntfy-bridge'
|
||||||
ExecStart=/usr/local/bin/ntfy-bridge --quiet --config /etc/ntfy-bridge.yaml
|
version: '0.1.0'
|
||||||
Restart=always
|
dependencies: [] // zéro dépendance externe
|
||||||
RestartSec=10
|
}
|
||||||
|
```
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
Le projet utilise **uniquement la stdlib V** :
|
||||||
```
|
- `net.http` — serveur HTTP + client Ntfy
|
||||||
|
- `x.json2` — parsing JSON
|
||||||
### Option B : Conteneur Docker
|
- `yaml` — parsing YAML
|
||||||
```dockerfile
|
- `crypto.hmac`, `crypto.sha256`, `encoding.hex` — validation HMAC
|
||||||
FROM alpine:latest
|
- `time`, `os`, `flag` — utilitaires
|
||||||
COPY ntfy-bridge /usr/local/bin/
|
- `net.unix` — socket Docker (Linux/macOS seulement, via `$if`)
|
||||||
COPY ntfy-bridge.yaml dashboard.html /etc/ntfy-bridge/
|
|
||||||
WORKDIR /etc/ntfy-bridge
|
## Endpoints HTTP
|
||||||
EXPOSE 9090
|
|
||||||
CMD ["/usr/local/bin/ntfy-bridge", "--quiet", "--config", "/etc/ntfy-bridge/ntfy-bridge.yaml"]
|
| Méthode | Path | Description |
|
||||||
```
|
|---------|------|-------------|
|
||||||
|
| GET | `/` ou `/dashboard` | Dashboard HTML |
|
||||||
### Option C : Compilation croisée
|
| GET | `/health` | Health check → `{"status":"ok","uptime_seconds":N,"total_notifications":N}` |
|
||||||
```bash
|
| GET | `/stats` | Stats par source → `{"total":N,"errors":N,"by_source":{...}}` |
|
||||||
v -prod -os windows . -o ntfy-bridge.exe
|
| GET | `/api/config` | Config résumée (safe, sans secrets) |
|
||||||
v -prod -os linux . -o ntfy-bridge
|
| GET | `/api/status` | État des health checks HTTP poll |
|
||||||
```
|
| GET | `/api/history` | 100 derniers événements |
|
||||||
|
| GET | `/api/silence` | État du silence (actif/restant) |
|
||||||
## Sécurité
|
| POST | `/api/silence?duration=30m` | Activer le silence |
|
||||||
|
| DELETE | `/api/silence` | Désactiver le silence |
|
||||||
- **Signature webhooks** : vérification HMAC-SHA256 pour **tous** les webhooks (header `X-Hub-Signature-256: sha256=...`). Activé dès que `server.hmac_secret` est défini.
|
| GET | `/metrics` | Métriques Prometheus / OpenMetrics |
|
||||||
- **Auth token** : token Bearer optionnel pour le serveur Ntfy (si auth activée sur le serveur Ntfy)
|
| POST | `/webhooks/*` | Webhooks (Gitea, Uptime Kuma, Cron, Generic) |
|
||||||
- **Pas d'exposition externe** : le serveur HTTP écoute sur localhost par défaut (`127.0.0.1:9090`)
|
|
||||||
- **Rate limiting interne** : par topic ET par source (configurable dans `dedup.rate_limit`)
|
## Déploiement
|
||||||
- **Input validation** : tout JSON entrant est validé avant processing, chemins webhook dupliqués détectés au démarrage
|
|
||||||
- **No secrets in config** : `NTFY_TOKEN` et `NTFY_HMAC_SECRET` peuvent être passés via variables d'environnement
|
### Option A : Binaire nu + systemd
|
||||||
|
```ini
|
||||||
## Métriques et observabilité
|
# /etc/systemd/system/ntfy-bridge.service
|
||||||
|
[Unit]
|
||||||
- **Health endpoint** : `GET /health` → 200 OK + uptime + compteur de notifs
|
Description=ntfy-bridge notification hub
|
||||||
- **Stats endpoint** : `GET /stats` → compteurs par source + erreurs
|
After=network.target
|
||||||
- **API status** : `GET /api/status` → état des health checks HTTP poll
|
|
||||||
- **API history** : `GET /api/history` → 100 derniers événements avec timestamps
|
[Service]
|
||||||
- **Dashboard** : interface HTML avec stats temps réel, statut des services, historique récent
|
ExecStart=/usr/local/bin/ntfy-bridge --quiet --config /etc/ntfy-bridge.yaml
|
||||||
- **Logging** : stdout en mode human-readable (console) ou JSON structuré (`--quiet`, pour systemd)
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B : Conteneur Docker
|
||||||
|
```dockerfile
|
||||||
|
FROM alpine:latest
|
||||||
|
COPY ntfy-bridge /usr/local/bin/
|
||||||
|
COPY ntfy-bridge.yaml dashboard.html /etc/ntfy-bridge/
|
||||||
|
WORKDIR /etc/ntfy-bridge
|
||||||
|
EXPOSE 9090
|
||||||
|
CMD ["/usr/local/bin/ntfy-bridge", "--quiet", "--config", "/etc/ntfy-bridge/ntfy-bridge.yaml"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option C : Compilation croisée
|
||||||
|
```bash
|
||||||
|
v -prod -os windows . -o ntfy-bridge.exe
|
||||||
|
v -prod -os linux . -o ntfy-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **ACLs par webhook** (v0.6) : restriction par IP (CIDR ou exacte) et/ou Bearer token. Validé avant HMAC.
|
||||||
|
- **Signature webhooks** : vérification HMAC-SHA256 pour **tous** les webhooks (header `X-Hub-Signature-256: sha256=...`). Activé dès que `server.hmac_secret` est défini.
|
||||||
|
- **Auth token** : token Bearer optionnel pour le serveur Ntfy (si auth activée sur le serveur Ntfy)
|
||||||
|
- **Pas d'exposition externe** : le serveur HTTP écoute sur localhost par défaut (`127.0.0.1:9090`)
|
||||||
|
- **Rate limiting interne** : par topic ET par source (configurable dans `dedup.rate_limit`)
|
||||||
|
- **Input validation** : tout JSON entrant est validé avant processing, chemins webhook dupliqués détectés au démarrage
|
||||||
|
- **No secrets in config** : `NTFY_TOKEN` et `NTFY_HMAC_SECRET` peuvent être passés via variables d'environnement
|
||||||
|
|
||||||
|
## Métriques et observabilité
|
||||||
|
|
||||||
|
- **Health endpoint** : `GET /health` → 200 OK + uptime + compteur de notifs
|
||||||
|
- **Stats endpoint** : `GET /stats` → compteurs par source + erreurs
|
||||||
|
- **API status** : `GET /api/status` → état des health checks HTTP poll
|
||||||
|
- **API history** : `GET /api/history` → 100 derniers événements avec timestamps
|
||||||
|
- **Dashboard** : interface HTML avec stats temps réel, statut des services, historique récent
|
||||||
|
- **Logging** : stdout en mode human-readable (console) ou JSON structuré (`--quiet`, pour systemd)
|
||||||
|
|||||||
+177
-169
@@ -1,169 +1,177 @@
|
|||||||
# ntfy-bridge — Roadmap
|
# ntfy-bridge — Roadmap
|
||||||
|
|
||||||
## Phase 1 : MVP (core engine) — v0.1.0 ✓ DONE
|
## Phase 1 : MVP (core engine) — v0.1.0 ✓ DONE
|
||||||
|
|
||||||
Objectif : daemon qui compile, lit une config, écoute des webhooks et envoie vers Ntfy.
|
Objectif : daemon qui compile, lit une config, écoute des webhooks et envoie vers Ntfy.
|
||||||
|
|
||||||
- [x] `main.v` — CLI args (`--config`, `--version`, `--validate`, `--quiet`, `--help`)
|
- [x] `main.v` — CLI args (`--config`, `--version`, `--validate`, `--quiet`, `--help`)
|
||||||
- [x] `config.v` — Chargement + validation du YAML (env overrides `NTFY_TOKEN`, `NTFY_HMAC_SECRET`, détection de chemins dupliqués)
|
- [x] `config.v` — Chargement + validation du YAML (env overrides `NTFY_TOKEN`, `NTFY_HMAC_SECRET`, détection de chemins dupliqués)
|
||||||
- [x] `server.v` — HTTP server sur `:9090` avec routing O(1), dashboard, health/stats endpoints
|
- [x] `server.v` — HTTP server sur `:9090` avec routing O(1), dashboard, health/stats endpoints
|
||||||
- [x] `event.v` — Types de données : `Event`, `Config`, `Source` + template engine `render_source_template`
|
- [x] `event.v` — Types de données : `Event`, `Config`, `Source` + template engine `render_source_template`
|
||||||
- [x] `ntfy.v` — Client HTTP POST vers Ntfy (topic, message, priority, tags) + retry avec backoff exponentiel (3 tentatives)
|
- [x] `ntfy.v` — Client HTTP POST vers Ntfy (topic, message, priority, tags) + retry avec backoff exponentiel (3 tentatives)
|
||||||
- [x] `sources/generic.v` — Webhook passe-partout (POST raw → Ntfy)
|
- [x] `sources/generic.v` — Webhook passe-partout (POST raw → Ntfy)
|
||||||
- [x] `log.v` — Logging structuré (timestamp, level, message) — dual mode: human-readable + JSON
|
- [x] `log.v` — Logging structuré (timestamp, level, message) — dual mode: human-readable + JSON
|
||||||
- [x] `ntfy-bridge.example.yaml` — Config d'exemple complète et commentée
|
- [x] `ntfy-bridge.example.yaml` — Config d'exemple complète et commentée
|
||||||
|
|
||||||
**Sortie** : `ntfy-bridge` compile, accepte un webhook générique et l'envoie vers Ntfy. ✅
|
**Sortie** : `ntfy-bridge` compile, accepte un webhook générique et l'envoie vers Ntfy. ✅
|
||||||
|
|
||||||
## Phase 2 : Sources spécialisées — v0.2.0 ✓ DONE
|
## Phase 2 : Sources spécialisées — v0.2.0 ✓ DONE
|
||||||
|
|
||||||
- [x] `sources/gitea.v` — Transformer Gitea (push, PR, issues, releases)
|
- [x] `sources/gitea.v` — Transformer Gitea (push, PR, issues, releases)
|
||||||
- Parse le JSON webhook Gitea
|
- Parse le JSON webhook Gitea
|
||||||
- Template configurable avec variables `{repo}`, `{user}`, `{action}`, `{title}`, `{sha}`
|
- Template configurable avec variables `{repo}`, `{user}`, `{action}`, `{title}`, `{sha}`
|
||||||
- Priority map par type d'événement
|
- Priority map par type d'événement
|
||||||
- [x] `sources/uptime_kuma.v` — Transformer Uptime Kuma
|
- [x] `sources/uptime_kuma.v` — Transformer Uptime Kuma
|
||||||
- Parse le JSON webhook Kuma (heartbeat)
|
- Parse le JSON webhook Kuma (heartbeat)
|
||||||
- `state_map` pour configurer priority/tags par état (up/down)
|
- `state_map` pour configurer priority/tags par état (up/down)
|
||||||
- Template configurable avec variables `{monitor}`, `{status}`, `{msg}`, `{ping}`, `{time}`
|
- Template configurable avec variables `{monitor}`, `{status}`, `{msg}`, `{ping}`, `{time}`
|
||||||
- [x] `sources/docker.v` — Watcher socket Docker
|
- [x] `sources/docker.v` — Watcher socket Docker
|
||||||
- Connexion au socket Docker Unix (`docker_watcher.v`)
|
- Connexion au socket Docker Unix (`docker_watcher.v`)
|
||||||
- Filtrage par événements (die, health_status, oom)
|
- Filtrage par événements (die, health_status, oom)
|
||||||
- Template de message configurable
|
- Template de message configurable
|
||||||
- Multi-hôtes (plusieurs sockets dans la config)
|
- Multi-hôtes (plusieurs sockets dans la config)
|
||||||
- [x] `sources/http_poll.v` — Poller HTTP
|
- [x] `sources/http_poll.v` — Poller HTTP
|
||||||
- Goroutine de polling périodique (intervalle configurable)
|
- Goroutine de polling périodique (intervalle configurable)
|
||||||
- Vérification status code + timeout
|
- Vérification status code + timeout
|
||||||
- State machine up/down avec notification uniquement au changement
|
- State machine up/down avec notification uniquement au changement
|
||||||
- Template configurable avec variables `{url}`, `{error}`, `{status}`
|
- Template configurable avec variables `{url}`, `{error}`, `{status}`
|
||||||
- [x] `dedup.v` — Déduplication + rate limiting
|
- [x] `dedup.v` — Déduplication + rate limiting
|
||||||
- Cache avec TTL (5 min par défaut) basé sur hash du message
|
- Cache avec TTL (5 min par défaut) basé sur hash du message
|
||||||
- Clé de déduplication : source + type + topic + hash(message)
|
- Clé de déduplication : source + type + topic + hash(message)
|
||||||
- Rate limiting par topic et par source
|
- Rate limiting par topic et par source
|
||||||
|
|
||||||
**Bonus (non planifié) :**
|
**Bonus (non planifié) :**
|
||||||
- [x] `sources/cron.v` — Webhook pour scripts cron (pass-through)
|
- [x] `sources/cron.v` — Webhook pour scripts cron (pass-through)
|
||||||
- [x] `webhook.v` — Route dispatch O(1) via `map[string]WebhookRoute`
|
- [x] `webhook.v` — Route dispatch O(1) via `map[string]WebhookRoute`
|
||||||
|
|
||||||
**Sortie** : Toutes les sources majeures sont intégrées et fonctionnelles. ✅
|
**Sortie** : Toutes les sources majeures sont intégrées et fonctionnelles. ✅
|
||||||
|
|
||||||
## Phase 3 : Robustesse — v0.3.0 ✅ DONE
|
## Phase 3 : Robustesse — v0.3.0 ✅ DONE
|
||||||
|
|
||||||
- [x] ~~`dedup.v` amélioré~~ — Rate limiting déjà intégré dans la phase 2
|
- [x] ~~`dedup.v` amélioré~~ — Rate limiting déjà intégré dans la phase 2
|
||||||
- [x] Signature webhooks — Vérification HMAC-SHA256 (appliquée à **tous** les webhooks, pas seulement Gitea)
|
- [x] Signature webhooks — Vérification HMAC-SHA256 (appliquée à **tous** les webhooks, pas seulement Gitea)
|
||||||
- [x] Retry logic — Retry avec backoff exponentiel vers Ntfy (3 tentatives, 1s/2s/4s)
|
- [x] Retry logic — Retry avec backoff exponentiel vers Ntfy (3 tentatives, 1s/2s/4s)
|
||||||
- [x] Graceful shutdown — SIGTERM/SIGINT → drain des événements en cours (Linux/macOS)
|
- [x] Graceful shutdown — SIGTERM/SIGINT → drain des événements en cours (Linux/macOS)
|
||||||
- [x] Health endpoint — `GET /health` → 200 + uptime + stats basiques
|
- [x] Health endpoint — `GET /health` → 200 + uptime + stats basiques
|
||||||
- [x] Stats endpoint — `GET /stats` → compteurs par source
|
- [x] Stats endpoint — `GET /stats` → compteurs par source
|
||||||
- [x] Config reload — `SIGHUP` → reload config sans redémarrage
|
- [x] Config reload — `SIGHUP` → reload config sans redémarrage
|
||||||
- [x] Tests unitaires — `config_test.v`, `gitea_test.v`, `uptime_kuma_test.v`, `dedup_test.v`
|
- [x] Tests unitaires — `config_test.v`, `gitea_test.v`, `uptime_kuma_test.v`, `dedup_test.v`
|
||||||
|
|
||||||
**Sortie** : Le daemon est prêt pour la production homelab. ✅
|
**Sortie** : Le daemon est prêt pour la production homelab. ✅
|
||||||
|
|
||||||
## Phase 4 : Qualité de vie — v0.4.0 ✅ DONE
|
## Phase 4 : Qualité de vie — v0.4.0 ✅ DONE
|
||||||
|
|
||||||
- [x] Template engine — Support des variables `{key}` dans les messages (toutes les sources)
|
- [x] Template engine — Support des variables `{key}` dans les messages (toutes les sources)
|
||||||
```
|
```
|
||||||
"🐳 {container_name} → {status} (exit: {exit_code}) on {host}"
|
"🐳 {container_name} → {status} (exit: {exit_code}) on {host}"
|
||||||
```
|
```
|
||||||
- [x] Silence rules — `POST /api/silence?duration=30m` + `GET /api/silence` + `DELETE /api/silence`
|
- [x] Silence rules — `POST /api/silence?duration=30m` + `GET /api/silence` + `DELETE /api/silence`
|
||||||
- [x] Grouping — Regrouper N notifications similaires (buffer 10s, flush avec ×N)
|
- [x] Grouping — Regrouper N notifications similaires (buffer 10s, flush avec ×N)
|
||||||
- [x] Webhook secret validation — HMAC global (tous les webhooks, pas seulement Gitea)
|
- [x] Webhook secret validation — HMAC global (tous les webhooks, pas seulement Gitea)
|
||||||
- [x] CI/CD via Gitea Actions — `.gitea/workflows/ci.yml`
|
- [x] CI/CD via Gitea Actions — `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
**Sortie** : Expérience utilisateur complète. ✅
|
**Sortie** : Expérience utilisateur complète. ✅
|
||||||
|
|
||||||
## Phase 4.5 : Déploiement multi-plateforme — v0.4.5 ✅ DONE
|
## Phase 4.5 : Déploiement multi-plateforme — v0.4.5 ✅ DONE
|
||||||
|
|
||||||
Objectif : un seul binaire, une seule commande pour installer/désinstaller le service sur tous les OS supportés.
|
Objectif : un seul binaire, une seule commande pour installer/désinstaller le service sur tous les OS supportés.
|
||||||
|
|
||||||
- [x] `service.v` — Module d'installation de service unifié
|
- [x] `service.v` — Module d'installation de service unifié
|
||||||
- Commande `--install-service` : détecte l'OS et installe le service
|
- Commande `--install-service` : détecte l'OS et installe le service
|
||||||
- Commande `--uninstall-service` : désinstalle le service
|
- Commande `--uninstall-service` : désinstalle le service
|
||||||
- Commande `--service-status` : vérifie si le service est installé + running
|
- Commande `--service-status` : vérifie si le service est installé + running
|
||||||
- [x] Windows Service — Intégration native Windows
|
- [x] Windows Service — Intégration native Windows
|
||||||
- Installation via `sc.exe` (Service Control Manager)
|
- Installation via `sc.exe` (Service Control Manager)
|
||||||
- Support des événements start/stop/query
|
- Support des événements start/stop/query
|
||||||
- Options de recovery configurées (restart auto)
|
- Options de recovery configurées (restart auto)
|
||||||
- [x] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service`
|
- [x] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service`
|
||||||
- Création + activation automatique du unit file
|
- Création + activation automatique du unit file
|
||||||
- `--quiet` par défaut (logging JSON → journald)
|
- `--quiet` par défaut (logging JSON → journald)
|
||||||
- Restart=always, démarrage après network.target
|
- Restart=always, démarrage après network.target
|
||||||
- [x] Linux Alpine (openrc) — Script init.d
|
- [x] Linux Alpine (openrc) — Script init.d
|
||||||
- `/etc/init.d/ntfy-bridge` généré automatiquement
|
- `/etc/init.d/ntfy-bridge` généré automatiquement
|
||||||
- Commandes start/stop/restart/status
|
- Commandes start/stop/restart/status
|
||||||
- Ajout au runlevel default
|
- Ajout au runlevel default
|
||||||
- [x] Raspberry Pi (Debian aarch64) — Support confirmé
|
- [x] Raspberry Pi (Debian aarch64) — Support confirmé
|
||||||
- Cross-compilation `v -os linux -arch arm64`
|
- Cross-compilation `v -os linux -arch arm64`
|
||||||
- Même unit systemd que Debian x86_64
|
- Même unit systemd que Debian x86_64
|
||||||
- Testé sur Raspberry Pi OS (arm64)
|
- Testé sur Raspberry Pi OS (arm64)
|
||||||
- [x] Docker image multi-arch — `Dockerfile` + `docker-compose.yml`
|
- [x] Docker image multi-arch — `Dockerfile` + `docker-compose.yml`
|
||||||
- Build multi-stage (V from source → minimal Alpine runtime)
|
- Build multi-stage (V from source → minimal Alpine runtime)
|
||||||
- docker-compose.yml prêt à l'emploi avec healthcheck
|
- docker-compose.yml prêt à l'emploi avec healthcheck
|
||||||
- [x] Scripts d'installation one-liner
|
- [x] Scripts d'installation one-liner
|
||||||
- `scripts/install.sh` : détecte l'OS/arch, build V, installe le service
|
- `scripts/install.sh` : détecte l'OS/arch, build V, installe le service
|
||||||
- `scripts/install.ps1` : équivalent Windows PowerShell
|
- `scripts/install.ps1` : équivalent Windows PowerShell
|
||||||
- `curl -fsSL https://.../install.sh | bash`
|
- `curl -fsSL https://.../install.sh | bash`
|
||||||
|
|
||||||
**Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). ✅
|
**Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). ✅
|
||||||
|
|
||||||
## Phase 5 : Futures idées — v0.5.0+ (6/8)
|
## Phase 5 : Futures idées — v0.5.0+ (8/8) ✅ DONE
|
||||||
|
|
||||||
- [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints
|
- [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints
|
||||||
- [x] Filtres avancés — Expressions conditionnelles par source
|
- [x] Filtres avancés — Expressions conditionnelles par source
|
||||||
```yaml
|
```yaml
|
||||||
filters:
|
filters:
|
||||||
rules:
|
rules:
|
||||||
- match_source: "docker"
|
- match_source: "docker"
|
||||||
match_name: "test-*"
|
match_name: "test-*"
|
||||||
action: drop
|
action: drop
|
||||||
```
|
```
|
||||||
- [x] Notifications structurées — Support des actions Ntfy (boutons click)
|
- [x] Notifications structurées — Support des actions Ntfy (boutons click)
|
||||||
- Champ `click_url` pour ouvrir une URL au clic
|
- Champ `click_url` pour ouvrir une URL au clic
|
||||||
- Champ `actions` pour boutons d'action (view, broadcast, http)
|
- Champ `actions` pour boutons d'action (view, broadcast, http)
|
||||||
- [x] Intégration Prometheus — Métriques exposées au format OpenMetrics
|
- [x] Intégration Prometheus — Métriques exposées au format OpenMetrics
|
||||||
- Endpoint `GET /metrics` : gauges (uptime, poll_state, silence) + counters (notifications, errors, http_polls)
|
- Endpoint `GET /metrics` : gauges (uptime, poll_state, silence) + counters (notifications, errors, http_polls)
|
||||||
- Format compatible Prometheus/Grafana
|
- Format compatible Prometheus/Grafana
|
||||||
- [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON
|
- [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON
|
||||||
- [ ] Support multi-utilisateurs — ACLs par webhook_path
|
- [x] Support multi-utilisateurs — ACLs par webhook_path
|
||||||
- [x] Fichier d'état — Persistance de l'état up/down des health checks
|
```yaml
|
||||||
- [ ] Plugin system — Sources customisables via dll/.so
|
acl:
|
||||||
- url: "https://hooks.slack.com/..."
|
allowed_ips: ["192.168.30.5", "10.0.0.0/8"]
|
||||||
format: slack
|
allowed_tokens: ["my-secret-token"]
|
||||||
- url: "https://discord.com/api/webhooks/..."
|
```
|
||||||
format: discord
|
- [x] Fichier d'état — Persistance de l'état up/down des health checks
|
||||||
```
|
```yaml
|
||||||
- [ ] Support multi-utilisateurs — ACLs par webhook_path
|
state:
|
||||||
- [x] Fichier d'état — Persistance de l'état up/down des health checks
|
file: "/var/lib/ntfy-bridge/state.json"
|
||||||
```yaml
|
```
|
||||||
state:
|
- Sauvegarde `poll_state` (up/down) + `silence_until`
|
||||||
file: "/var/lib/ntfy-bridge/state.json"
|
- Restauré au démarrage, persisté à chaque changement
|
||||||
```
|
- [x] Plugin system — Sources customisables via executables externes
|
||||||
- Sauvegarde `poll_state` (up/down) + `silence_until`
|
```yaml
|
||||||
- Restauré au démarrage, persisté à chaque changement
|
plugin:
|
||||||
- [ ] Plugin system — Sources customisables via dll/.so
|
- name: "Disk space check"
|
||||||
|
topic: daily
|
||||||
## Fonctionnalités non planifiées mais implémentées
|
command: /etc/ntfy-bridge/plugins/disk-check.sh
|
||||||
|
timeout: 10
|
||||||
Ces features ont été ajoutées en cours de route :
|
```
|
||||||
|
- Contrat simple : stdin (optionnel) → stdout JSON
|
||||||
| Feature | Fichier | Description |
|
- Exit 0 = notif, exit ≠ 0 = skip
|
||||||
|---------|---------|-------------|
|
- Compatible bash, Python, ou n'importe quel langage
|
||||||
| Source Cron | `sources/cron.v` | Webhook pass-through pour scripts cron (curl → Ntfy) |
|
|
||||||
| Dashboard web | `dashboard.html` | Interface avec stats, statut des health checks, historique récent |
|
## Fonctionnalités non planifiées mais implémentées
|
||||||
| Logging JSON | `log.v` | Mode JSON structuré activé avec `--quiet` (pour systemd) |
|
|
||||||
| Env overrides | `config.v` | `NTFY_TOKEN` et `NTFY_HMAC_SECRET` depuis l'environnement |
|
Ces features ont été ajoutées en cours de route :
|
||||||
| Validation dupplicates | `config.v` | Détection de chemins webhook dupliqués dans la config |
|
|
||||||
| Flag `--quiet` | `main.v` | Supprime la bannière ASCII au démarrage |
|
| Feature | Fichier | Description |
|
||||||
| Flag `-V` (version) | `main.v` | Version courte en plus de `--version` |
|
|---------|---------|-------------|
|
||||||
| API endpoints | `server.v` | `/api/config`, `/api/status`, `/api/history` pour le dashboard |
|
| Source Cron | `sources/cron.v` | Webhook pass-through pour scripts cron (curl → Ntfy) |
|
||||||
|
| Dashboard web | `dashboard.html` | Interface avec stats, statut des health checks, historique récent |
|
||||||
## Suivi des versions
|
| Logging JSON | `log.v` | Mode JSON structuré activé avec `--quiet` (pour systemd) |
|
||||||
|
| Env overrides | `config.v` | `NTFY_TOKEN` et `NTFY_HMAC_SECRET` depuis l'environnement |
|
||||||
| Version | Statut | Contenu |
|
| Validation dupplicates | `config.v` | Détection de chemins webhook dupliqués dans la config |
|
||||||
|---------|--------|---------|
|
| Flag `--quiet` | `main.v` | Supprime la bannière ASCII au démarrage |
|
||||||
| v0.1.0 | ✅ Terminé | Moteur core + webhook générique + logging |
|
| Flag `-V` (version) | `main.v` | Version courte en plus de `--version` |
|
||||||
| v0.2.0 | ✅ Terminé | Sources Gitea, Uptime Kuma, Docker, HTTP poll, Cron, déduplication |
|
| API endpoints | `server.v` | `/api/config`, `/api/status`, `/api/history` pour le dashboard |
|
||||||
| v0.3.0 | ✅ Terminé | Robustesse, HMAC, retry, graceful shutdown, SIGHUP reload, tests |
|
|
||||||
| v0.4.0 | ✅ Terminé | Templates, silence, grouping, HMAC global, CI/CD |
|
## Suivi des versions
|
||||||
| v0.4.5 | ✅ Terminé | Déploiement multi-plateforme : --install-service, Windows, systemd, openrc, Docker, install scripts |
|
|
||||||
| v0.5.0+ | 🚧 6/8 | Dashboard, filtres, actions Ntfy, Prometheus, outgoing webhooks, state file — manque multi-user, plugins |
|
| Version | Statut | Contenu |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| v0.1.0 | ✅ Terminé | Moteur core + webhook générique + logging |
|
||||||
|
| v0.2.0 | ✅ Terminé | Sources Gitea, Uptime Kuma, Docker, HTTP poll, Cron, déduplication |
|
||||||
|
| v0.3.0 | ✅ Terminé | Robustesse, HMAC, retry, graceful shutdown, SIGHUP reload, tests |
|
||||||
|
| v0.4.0 | ✅ Terminé | Templates, silence, grouping, HMAC global, CI/CD |
|
||||||
|
| v0.4.5 | ✅ Terminé | Déploiement multi-plateforme : --install-service, Windows, systemd, openrc, Docker, install scripts |
|
||||||
|
| v0.5.0 | ✅ Terminé | Dashboard, filtres, actions Ntfy, Prometheus, outgoing webhooks, state file |
|
||||||
|
| v0.6.0 | ✅ Terminé | ACLs multi-utilisateurs, plugin system (scripts externes) |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module main
|
|||||||
import os
|
import os
|
||||||
import flag
|
import flag
|
||||||
|
|
||||||
const version = '0.5.0'
|
const version = '0.6.0'
|
||||||
const author = 'Bruno Charest'
|
const author = 'Bruno Charest'
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -220,6 +220,10 @@ fn print_startup_info(cfg Config) {
|
|||||||
if generic_count > 0 {
|
if generic_count > 0 {
|
||||||
println(' Generic: ${generic_count} webhook${if generic_count > 1 { 's' } else { '' }}')
|
println(' Generic: ${generic_count} webhook${if generic_count > 1 { 's' } else { '' }}')
|
||||||
}
|
}
|
||||||
|
plugin_count := cfg.sources.plugin.len
|
||||||
|
if plugin_count > 0 {
|
||||||
|
println(' Plugin: ${plugin_count} script${if plugin_count > 1 { 's' } else { '' }}')
|
||||||
|
}
|
||||||
|
|
||||||
println('')
|
println('')
|
||||||
println(' Webhook URLs:')
|
println(' Webhook URLs:')
|
||||||
|
|||||||
+176
-144
@@ -1,144 +1,176 @@
|
|||||||
# ntfy-bridge configuration
|
# ntfy-bridge configuration
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
# ── Server ────────────────────────────────────────────
|
# ── Server ────────────────────────────────────────────
|
||||||
server:
|
server:
|
||||||
# URL de ton serveur Ntfy
|
# URL de ton serveur Ntfy
|
||||||
url: https://ntfy.dracodev.net
|
url: https://ntfy.dracodev.net
|
||||||
|
|
||||||
# Token d'authentification (optionnel, si ton serveur Ntfy a auth activée)
|
# Token d'authentification (optionnel, si ton serveur Ntfy a auth activée)
|
||||||
# Peut aussi être passé via variable d'environnement NTFY_TOKEN
|
# Peut aussi être passé via variable d'environnement NTFY_TOKEN
|
||||||
# auth_token: tk_xxxxxxxxxxxx
|
# auth_token: tk_xxxxxxxxxxxx
|
||||||
|
|
||||||
# Adresse d'écoute du serveur HTTP interne
|
# Adresse d'écoute du serveur HTTP interne
|
||||||
# Mettre "0.0.0.0:9090" pour exposer réseau, "127.0.0.1:9090" pour localhost
|
# Mettre "0.0.0.0:9090" pour exposer réseau, "127.0.0.1:9090" pour localhost
|
||||||
listen: "127.0.0.1:9090"
|
listen: "127.0.0.1:9090"
|
||||||
|
|
||||||
# Secret partagé pour validation HMAC-SHA256 des webhooks (optionnel)
|
# Secret partagé pour validation HMAC-SHA256 des webhooks (optionnel)
|
||||||
# Si défini, tous les webhooks doivent inclure le header X-Hub-Signature-256
|
# Si défini, tous les webhooks doivent inclure le header X-Hub-Signature-256
|
||||||
# Peut aussi être passé via variable d'environnement NTFY_HMAC_SECRET
|
# Peut aussi être passé via variable d'environnement NTFY_HMAC_SECRET
|
||||||
# hmac_secret: "mon-secret-partage"
|
# hmac_secret: "mon-secret-partage"
|
||||||
|
|
||||||
# ── Defaults ──────────────────────────────────────────
|
# ── Defaults ──────────────────────────────────────────
|
||||||
# Appliqués à toutes les sources sauf override explicite
|
# Appliqués à toutes les sources sauf override explicite
|
||||||
defaults:
|
defaults:
|
||||||
priority: 3 # 1=min, 2=low, 3=default, 4=high, 5=urgent
|
priority: 3 # 1=min, 2=low, 3=default, 4=high, 5=urgent
|
||||||
tags: ["loudspeaker"] # Tags/emojis Ntfy
|
tags: ["loudspeaker"] # Tags/emojis Ntfy
|
||||||
|
|
||||||
# ── Sources ───────────────────────────────────────────
|
# ── Sources ───────────────────────────────────────────
|
||||||
sources:
|
sources:
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# GITEA — webhooks depuis git.dracodev.net
|
# GITEA — webhooks depuis git.dracodev.net
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
gitea:
|
gitea:
|
||||||
- name: "FlowDeck activity"
|
- name: "FlowDeck activity"
|
||||||
webhook_path: /webhooks/gitea-flowdeck
|
webhook_path: /webhooks/gitea-flowdeck
|
||||||
repo: bruno/flowdeck
|
repo: bruno/flowdeck
|
||||||
topic: dev-notifs
|
topic: dev-notifs
|
||||||
priority_map:
|
priority_map:
|
||||||
pull_request: 4
|
pull_request: 4
|
||||||
issue: 3
|
issue: 3
|
||||||
push: 2
|
push: 2
|
||||||
# Template de message (variables dispo: {repo}, {user}, {action}, {title}, {sha})
|
# Template de message (variables dispo: {repo}, {user}, {action}, {title}, {sha})
|
||||||
template: |
|
template: |
|
||||||
🔨 **[{repo}]** {user} {action}: "{title}"
|
🔨 **[{repo}]** {user} {action}: "{title}"
|
||||||
`{sha}`
|
`{sha}`
|
||||||
|
|
||||||
- name: "ObsiGate activity"
|
- name: "ObsiGate activity"
|
||||||
webhook_path: /webhooks/gitea-obsigate
|
webhook_path: /webhooks/gitea-obsigate
|
||||||
repo: bruno/obsigate
|
repo: bruno/obsigate
|
||||||
topic: dev-notifs
|
topic: dev-notifs
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# UPTIME KUMA — alertes de monitoring
|
# UPTIME KUMA — alertes de monitoring
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
uptime_kuma:
|
uptime_kuma:
|
||||||
- name: "Services critiques"
|
- name: "Services critiques"
|
||||||
webhook_path: /webhooks/kuma-critical
|
webhook_path: /webhooks/kuma-critical
|
||||||
topic: alerts
|
topic: alerts
|
||||||
state_map:
|
state_map:
|
||||||
down: { priority: 5, tags: ["rotating_light", "x"] }
|
down: { priority: 5, tags: ["rotating_light", "x"] }
|
||||||
up: { priority: 1, tags: ["white_check_mark"] }
|
up: { priority: 1, tags: ["white_check_mark"] }
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# DOCKER — surveillance des conteneurs
|
# DOCKER — surveillance des conteneurs
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
docker:
|
docker:
|
||||||
- name: "Production containers"
|
- name: "Production containers"
|
||||||
hosts:
|
hosts:
|
||||||
- unix:///var/run/docker.sock
|
- unix:///var/run/docker.sock
|
||||||
# Hôtes distants:
|
# Hôtes distants:
|
||||||
# - tcp://192.168.30.101:2375
|
# - tcp://192.168.30.101:2375
|
||||||
# - tcp://192.168.30.20:2375
|
# - tcp://192.168.30.20:2375
|
||||||
events: [die, health_status, oom]
|
events: [die, health_status, oom]
|
||||||
topic: infra
|
topic: infra
|
||||||
template: |
|
template: |
|
||||||
🐳 **{container_name}** → {status}
|
🐳 **{container_name}** → {status}
|
||||||
Image: `{image}`
|
Image: `{image}`
|
||||||
Exit code: {exit_code}
|
Exit code: {exit_code}
|
||||||
Host: {host}
|
Host: {host}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# HTTP POLL — health checks périodiques
|
# HTTP POLL — health checks périodiques
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
http_poll:
|
http_poll:
|
||||||
- name: "Health endpoints"
|
- name: "Health endpoints"
|
||||||
interval: 60 # secondes entre chaque check
|
interval: 60 # secondes entre chaque check
|
||||||
checks:
|
checks:
|
||||||
- url: https://og.dracodev.net/health
|
- url: https://og.dracodev.net/health
|
||||||
topic: alerts
|
topic: alerts
|
||||||
priority: 5 # priorité si DOWN
|
priority: 5 # priorité si DOWN
|
||||||
expect_status: 200
|
expect_status: 200
|
||||||
timeout: 10 # secondes
|
timeout: 10 # secondes
|
||||||
|
|
||||||
- url: https://flowdeck.dracodev.net/health
|
- url: https://flowdeck.dracodev.net/health
|
||||||
topic: alerts
|
topic: alerts
|
||||||
priority: 5
|
priority: 5
|
||||||
expect_status: 200
|
expect_status: 200
|
||||||
timeout: 10
|
timeout: 10
|
||||||
|
|
||||||
- url: https://git.dracodev.net/api/v1/version
|
- url: https://git.dracodev.net/api/v1/version
|
||||||
topic: alerts
|
topic: alerts
|
||||||
priority: 5
|
priority: 5
|
||||||
expect_status: 200
|
expect_status: 200
|
||||||
timeout: 10
|
timeout: 10
|
||||||
|
|
||||||
- url: http://raspi.8gb.home:3001/ping
|
- url: http://raspi.8gb.home:3001/ping
|
||||||
topic: alerts
|
topic: alerts
|
||||||
priority: 5 # Uptime Kuma lui-même — critique
|
priority: 5 # Uptime Kuma lui-même — critique
|
||||||
expect_status: 200
|
expect_status: 200
|
||||||
timeout: 10
|
timeout: 10
|
||||||
|
|
||||||
- url: http://raspi.8gb.home:9119/api/status
|
- url: http://raspi.8gb.home:9119/api/status
|
||||||
topic: alerts
|
topic: alerts
|
||||||
priority: 5
|
priority: 5
|
||||||
expect_status: 200
|
expect_status: 200
|
||||||
timeout: 10
|
timeout: 10
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# CRON — reçoit des notifs depuis des scripts shell
|
# CRON — reçoit des notifs depuis des scripts shell
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
cron:
|
cron:
|
||||||
- name: "Disk usage quotidien"
|
- name: "Disk usage quotidien"
|
||||||
webhook_path: /webhooks/cron-disk
|
webhook_path: /webhooks/cron-disk
|
||||||
topic: daily
|
topic: daily
|
||||||
|
|
||||||
- name: "Backup report"
|
- name: "Backup report"
|
||||||
webhook_path: /webhooks/cron-backup
|
webhook_path: /webhooks/cron-backup
|
||||||
topic: daily
|
topic: daily
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# GENERIC — webhook passe-partout
|
# GENERIC — webhook passe-partout
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
generic:
|
generic:
|
||||||
- name: "Custom alerts"
|
- name: "Custom alerts"
|
||||||
webhook_path: /webhooks/generic
|
webhook_path: /webhooks/generic
|
||||||
topic: custom
|
topic: custom
|
||||||
|
|
||||||
# ── Déduplication ──────────────────────────────────────
|
# ── ACLs (Access Control Lists) ─────────────────────────
|
||||||
dedup:
|
# Optionnel — restreint l'accès à certains webhooks par IP ou token.
|
||||||
enabled: true
|
# Si aucune ACL n'est définie, le webhook est ouvert (soumis au HMAC).
|
||||||
ttl_seconds: 300 # 5 minutes — durée du cache de déduplication
|
#
|
||||||
rate_limit:
|
# Exemple avec ACL IP:
|
||||||
max_per_minute: 10 # max notifications/minute par topic
|
# gitea:
|
||||||
max_per_source: 30 # max notifications/minute toute source confondue
|
# - name: "FlowDeck"
|
||||||
|
# webhook_path: /webhooks/gitea-flowdeck
|
||||||
|
# topic: dev-notifs
|
||||||
|
# acl:
|
||||||
|
# allowed_ips: ["192.168.30.5", "10.0.0.0/8"]
|
||||||
|
#
|
||||||
|
# Exemple avec ACL token:
|
||||||
|
# generic:
|
||||||
|
# - name: "Secure alerts"
|
||||||
|
# webhook_path: /webhooks/secure
|
||||||
|
# topic: secure
|
||||||
|
# acl:
|
||||||
|
# allowed_tokens: ["my-secret-webhook-token"]
|
||||||
|
# → Le client doit envoyer: Authorization: Bearer my-secret-webhook-token
|
||||||
|
|
||||||
|
# ── Plugins — scripts externes exécutés périodiquement ──
|
||||||
|
# Chaque plugin est un exécutable appelé toutes les 60s.
|
||||||
|
# Il lit (optionnel) sur stdin et doit écrire un objet JSON Event sur stdout.
|
||||||
|
# Exit 0 = envoyer la notif, exit ≠ 0 = ignorer.
|
||||||
|
#
|
||||||
|
# plugin:
|
||||||
|
# - name: "Disk space check"
|
||||||
|
# topic: daily
|
||||||
|
# command: /etc/ntfy-bridge/plugins/disk-check.sh
|
||||||
|
# timeout: 10
|
||||||
|
|
||||||
|
# ── Déduplication ──────────────────────────────────────
|
||||||
|
dedup:
|
||||||
|
enabled: true
|
||||||
|
ttl_seconds: 300 # 5 minutes — durée du cache de déduplication
|
||||||
|
rate_limit:
|
||||||
|
max_per_minute: 10 # max notifications/minute par topic
|
||||||
|
max_per_source: 30 # max notifications/minute toute source confondue
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Example ntfy-bridge plugin — disk space checker
|
||||||
|
#
|
||||||
|
# A plugin receives nothing on stdin (when polled) and outputs a JSON
|
||||||
|
# Event object on stdout. Exit 0 = send, exit 1 = skip/drop.
|
||||||
|
#
|
||||||
|
# Fields: source, name, topic, priority (1-5), message, tags[], click_url, actions[]
|
||||||
|
|
||||||
|
THRESHOLD=90
|
||||||
|
USAGE=$(df -h / | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||||
|
|
||||||
|
if [ "$USAGE" -lt "$THRESHOLD" ]; then
|
||||||
|
# No alert needed — exit non-zero to skip
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output a JSON Event to stdout
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"source": "plugin",
|
||||||
|
"name": "disk-check",
|
||||||
|
"topic": "daily",
|
||||||
|
"priority": 4,
|
||||||
|
"message": "💾 **Disk usage: ${USAGE}%**\nThreshold: ${THRESHOLD}%\nHost: $(hostname)",
|
||||||
|
"tags": ["warning"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
@@ -161,6 +161,9 @@ fn (mut app App) start_background_tasks() {
|
|||||||
if app.cfg.sources.docker.len > 0 {
|
if app.cfg.sources.docker.len > 0 {
|
||||||
spawn app.docker_watch_loop()
|
spawn app.docker_watch_loop()
|
||||||
}
|
}
|
||||||
|
if app.cfg.sources.plugin.len > 0 {
|
||||||
|
spawn app.plugin_loop()
|
||||||
|
}
|
||||||
// Grouping flush goroutine
|
// Grouping flush goroutine
|
||||||
spawn app.group_flush_loop()
|
spawn app.group_flush_loop()
|
||||||
}
|
}
|
||||||
@@ -245,6 +248,7 @@ fn (mut app App) config_api_response() http.Response {
|
|||||||
safe['http_poll_sources'] = app.cfg.sources.http_poll.len.str()
|
safe['http_poll_sources'] = app.cfg.sources.http_poll.len.str()
|
||||||
safe['cron_sources'] = app.cfg.sources.cron.len.str()
|
safe['cron_sources'] = app.cfg.sources.cron.len.str()
|
||||||
safe['generic_sources'] = app.cfg.sources.generic.len.str()
|
safe['generic_sources'] = app.cfg.sources.generic.len.str()
|
||||||
|
safe['plugin_sources'] = app.cfg.sources.plugin.len.str()
|
||||||
|
|
||||||
return new_json_response(.ok, json.encode(safe))
|
return new_json_response(.ok, json.encode(safe))
|
||||||
}
|
}
|
||||||
@@ -288,6 +292,14 @@ fn (mut app App) handle_webhook(req http.Request) http.Response {
|
|||||||
return new_json_response(.not_found, '{"error":"unknown webhook"}')
|
return new_json_response(.not_found, '{"error":"unknown webhook"}')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACL check — validate client IP/token before HMAC
|
||||||
|
client_ip := extract_client_ip(req)
|
||||||
|
client_token := extract_bearer_token(req)
|
||||||
|
if !app.check_acl(route, client_ip, client_token) {
|
||||||
|
app.logger.warn('server', 'webhook', 'ACL denied for ${path} from ${client_ip}')
|
||||||
|
return new_json_response(.forbidden, '{"error":"access denied"}')
|
||||||
|
}
|
||||||
|
|
||||||
// HMAC validation (if secret is configured)
|
// HMAC validation (if secret is configured)
|
||||||
if app.cfg.server.hmac_secret != '' {
|
if app.cfg.server.hmac_secret != '' {
|
||||||
sig_header := req.header.get_custom('X-Hub-Signature-256') or { '' }
|
sig_header := req.header.get_custom('X-Hub-Signature-256') or { '' }
|
||||||
@@ -315,6 +327,10 @@ fn (mut app App) handle_webhook(req http.Request) http.Response {
|
|||||||
src := app.cfg.sources.generic[route.index]
|
src := app.cfg.sources.generic[route.index]
|
||||||
event_opt = sources.transform_generic(req.data, src)
|
event_opt = sources.transform_generic(req.data, src)
|
||||||
}
|
}
|
||||||
|
.plugin {
|
||||||
|
// Plugin sources are background tasks, not webhook-triggered
|
||||||
|
return new_json_response(.bad_request, '{"error":"plugin sources are not webhook-triggered"}')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event := event_opt or {
|
event := event_opt or {
|
||||||
@@ -596,7 +612,191 @@ fn (mut app App) flush_group_buffer() {
|
|||||||
|
|
||||||
// v0.5 — Dispatch to outgoing webhooks
|
// v0.5 — Dispatch to outgoing webhooks
|
||||||
app.dispatch_outgoing(e)
|
app.dispatch_outgoing(e)
|
||||||
|
|
||||||
|
// v0.6 — Save state after each flush
|
||||||
|
app.save_state()
|
||||||
}
|
}
|
||||||
app.group_buffer = map[string][]GroupEntry{}
|
app.group_buffer = map[string][]GroupEntry{}
|
||||||
app.group_last_flush = time.now()
|
app.group_last_flush = time.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.6 — ACL (Access Control Lists) ───────────────────────────────────
|
||||||
|
|
||||||
|
// check_acl validates the client IP and/or token against the webhook route's ACL config.
|
||||||
|
// Returns true if access is allowed (no ACL = open access).
|
||||||
|
fn (mut app App) check_acl(route WebhookRoute, client_ip string, client_token string) bool {
|
||||||
|
acl := app.get_acl_for_route(route)
|
||||||
|
if acl.allowed_ips.len == 0 && acl.allowed_tokens.len == 0 {
|
||||||
|
return true // no ACL configured → open access
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP first
|
||||||
|
if acl.allowed_ips.len > 0 {
|
||||||
|
if client_ip == '' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mut ip_ok := false
|
||||||
|
for allowed in acl.allowed_ips {
|
||||||
|
if ip_matches(client_ip, allowed) {
|
||||||
|
ip_ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ip_ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token
|
||||||
|
if acl.allowed_tokens.len > 0 {
|
||||||
|
if client_token == '' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mut token_ok := false
|
||||||
|
for allowed in acl.allowed_tokens {
|
||||||
|
if client_token == allowed {
|
||||||
|
token_ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !token_ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_acl_for_route returns the ACL config for a given webhook route.
|
||||||
|
// Returns an empty ACL (open access) if the index is out of range.
|
||||||
|
fn (mut app App) get_acl_for_route(route WebhookRoute) sources.AclConfig {
|
||||||
|
match route.kind {
|
||||||
|
.gitea {
|
||||||
|
if route.index >= app.cfg.sources.gitea.len {
|
||||||
|
return sources.AclConfig{}
|
||||||
|
}
|
||||||
|
src := app.cfg.sources.gitea[route.index]
|
||||||
|
return src.acl
|
||||||
|
}
|
||||||
|
.uptime_kuma {
|
||||||
|
if route.index >= app.cfg.sources.uptime_kuma.len {
|
||||||
|
return sources.AclConfig{}
|
||||||
|
}
|
||||||
|
src := app.cfg.sources.uptime_kuma[route.index]
|
||||||
|
return src.acl
|
||||||
|
}
|
||||||
|
.cron {
|
||||||
|
if route.index >= app.cfg.sources.cron.len {
|
||||||
|
return sources.AclConfig{}
|
||||||
|
}
|
||||||
|
src := app.cfg.sources.cron[route.index]
|
||||||
|
return src.acl
|
||||||
|
}
|
||||||
|
.generic {
|
||||||
|
if route.index >= app.cfg.sources.generic.len {
|
||||||
|
return sources.AclConfig{}
|
||||||
|
}
|
||||||
|
src := app.cfg.sources.generic[route.index]
|
||||||
|
return src.acl
|
||||||
|
}
|
||||||
|
.plugin {
|
||||||
|
if route.index >= app.cfg.sources.plugin.len {
|
||||||
|
return sources.AclConfig{}
|
||||||
|
}
|
||||||
|
src := app.cfg.sources.plugin[route.index]
|
||||||
|
return src.acl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract_client_ip extracts the real client IP from request headers or remote address.
|
||||||
|
fn extract_client_ip(req http.Request) string {
|
||||||
|
// Check common proxy/forwarded headers first
|
||||||
|
forwarded_val := req.header.get_custom('X-Forwarded-For') or { '' }
|
||||||
|
if forwarded_val != '' {
|
||||||
|
return forwarded_val.split(',')[0].trim_space()
|
||||||
|
}
|
||||||
|
real_ip := req.header.get_custom('X-Real-IP') or { '' }
|
||||||
|
if real_ip != '' {
|
||||||
|
return real_ip.trim_space()
|
||||||
|
}
|
||||||
|
// Fall back to remote address from the connection
|
||||||
|
// The http.Request doesn't expose the remote addr directly, so we check the header
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract_bearer_token extracts a Bearer token from the Authorization header.
|
||||||
|
fn extract_bearer_token(req http.Request) string {
|
||||||
|
auth := req.header.get(.authorization) or { return '' }
|
||||||
|
if auth.starts_with('Bearer ') {
|
||||||
|
return auth[7..].trim_space()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip_matches checks if an IP address matches a pattern (CIDR or exact IP).
|
||||||
|
// Supports full CIDR notation (e.g. "192.168.0.0/16") and exact IP matching.
|
||||||
|
fn ip_matches(ip string, pattern string) bool {
|
||||||
|
if ip == pattern {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !pattern.contains('/') {
|
||||||
|
return ip == pattern
|
||||||
|
}
|
||||||
|
// CIDR matching
|
||||||
|
parts := pattern.split('/')
|
||||||
|
if parts.len != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cidr_ip := parts[0]
|
||||||
|
cidr_bits := strconv.atoi(parts[1]) or { return false }
|
||||||
|
|
||||||
|
ip_parts := parse_ip(ip) or { return false }
|
||||||
|
cidr_parts := parse_ip(cidr_ip) or { return false }
|
||||||
|
|
||||||
|
// Convert to 32-bit integer for IPv4
|
||||||
|
ip_int := u32(ip_parts[0]) << 24 | u32(ip_parts[1]) << 16 | u32(ip_parts[2]) << 8 | u32(ip_parts[3])
|
||||||
|
cidr_int := u32(cidr_parts[0]) << 24 | u32(cidr_parts[1]) << 16 | u32(cidr_parts[2]) << 8 | u32(cidr_parts[3])
|
||||||
|
|
||||||
|
mask := ~u32(0) << (32 - u32(cidr_bits))
|
||||||
|
return (ip_int & mask) == (cidr_int & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_ip parses an IPv4 dotted-quad string into 4 octets.
|
||||||
|
fn parse_ip(ip string) ![]u8 {
|
||||||
|
parts := ip.split('.')
|
||||||
|
if parts.len != 4 {
|
||||||
|
return error('invalid IP')
|
||||||
|
}
|
||||||
|
mut octets := []u8{}
|
||||||
|
for part in parts {
|
||||||
|
val := strconv.atoi(part) or { return error('invalid octet: ${part}') }
|
||||||
|
if val < 0 || val > 255 {
|
||||||
|
return error('octet out of range: ${val}')
|
||||||
|
}
|
||||||
|
octets << u8(val)
|
||||||
|
}
|
||||||
|
return octets
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── v0.6 — Plugin System ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// plugin_loop runs each configured plugin on a timer (every 60s by default).
|
||||||
|
fn (mut app App) plugin_loop() {
|
||||||
|
for {
|
||||||
|
if app.shutdown_flag {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for i, src in app.cfg.sources.plugin {
|
||||||
|
event := sources.transform_plugin('', src) or {
|
||||||
|
app.logger.debug('server', 'plugin', '${src.name}: no event (exit non-zero or error)')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
app.logger.info('server', 'plugin', '${src.name}: received event → ${src.topic}')
|
||||||
|
app.publish(event)
|
||||||
|
// Prevent unused variable warning for `i`
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
time.sleep(60 * time.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ pub:
|
|||||||
clear bool // whether to dismiss notification after action
|
clear bool // whether to dismiss notification after action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.6 — ACL per webhook path
|
||||||
|
pub struct AclConfig {
|
||||||
|
pub mut:
|
||||||
|
allowed_ips []string @[json: 'allowed_ips'] // CIDR ranges or single IPs
|
||||||
|
allowed_tokens []string @[json: 'allowed_tokens'] // bearer tokens
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GiteaSource {
|
pub struct GiteaSource {
|
||||||
pub mut:
|
pub mut:
|
||||||
name string @[json: 'name']
|
name string @[json: 'name']
|
||||||
@@ -44,6 +51,7 @@ pub mut:
|
|||||||
priority_map map[string]int @[json: 'priority_map']
|
priority_map map[string]int @[json: 'priority_map']
|
||||||
tags []string @[json: 'tags']
|
tags []string @[json: 'tags']
|
||||||
repo string @[json: 'repo']
|
repo string @[json: 'repo']
|
||||||
|
acl AclConfig @[json: 'acl']
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UptimeKumaState {
|
pub struct UptimeKumaState {
|
||||||
@@ -61,6 +69,7 @@ pub mut:
|
|||||||
priority_map map[string]int @[json: 'priority_map']
|
priority_map map[string]int @[json: 'priority_map']
|
||||||
tags []string @[json: 'tags']
|
tags []string @[json: 'tags']
|
||||||
state_map map[string]UptimeKumaState @[json: 'state_map']
|
state_map map[string]UptimeKumaState @[json: 'state_map']
|
||||||
|
acl AclConfig @[json: 'acl']
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DockerSource {
|
pub struct DockerSource {
|
||||||
@@ -99,6 +108,7 @@ pub mut:
|
|||||||
template string @[json: 'template']
|
template string @[json: 'template']
|
||||||
priority_map map[string]int @[json: 'priority_map']
|
priority_map map[string]int @[json: 'priority_map']
|
||||||
tags []string @[json: 'tags']
|
tags []string @[json: 'tags']
|
||||||
|
acl AclConfig @[json: 'acl']
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GenericSource {
|
pub struct GenericSource {
|
||||||
@@ -109,6 +119,17 @@ pub mut:
|
|||||||
template string @[json: 'template']
|
template string @[json: 'template']
|
||||||
priority_map map[string]int @[json: 'priority_map']
|
priority_map map[string]int @[json: 'priority_map']
|
||||||
tags []string @[json: 'tags']
|
tags []string @[json: 'tags']
|
||||||
|
acl AclConfig @[json: 'acl']
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.6 — Plugin source: external executable that receives JSON on stdin, outputs JSON on stdout
|
||||||
|
pub struct PluginSource {
|
||||||
|
pub mut:
|
||||||
|
name string @[json: 'name']
|
||||||
|
topic string @[json: 'topic']
|
||||||
|
command string @[json: 'command'] // path to executable
|
||||||
|
timeout int @[json: 'timeout'] // seconds (default: 10)
|
||||||
|
acl AclConfig @[json: 'acl']
|
||||||
}
|
}
|
||||||
|
|
||||||
// render_source_template applies variable substitution to a template string.
|
// render_source_template applies variable substitution to a template string.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
module sources
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import x.json2 as json
|
||||||
|
|
||||||
|
// transform_plugin runs an external executable, passes the raw event on stdin,
|
||||||
|
// and expects a JSON Event on stdout. Exit code 0 = success, non-zero = drop.
|
||||||
|
// timeout defaults to 10s if not set.
|
||||||
|
pub fn transform_plugin(raw string, source PluginSource) ?Event {
|
||||||
|
timeout := if source.timeout > 0 { source.timeout } else { 10 }
|
||||||
|
|
||||||
|
// Write raw input to a temp file for the plugin to read (optional)
|
||||||
|
// We use os.execute with timeout via a wrapper
|
||||||
|
start := time.now()
|
||||||
|
|
||||||
|
// Run the plugin command. The plugin receives nothing on stdin;
|
||||||
|
// it's a polling model — the plugin is called periodically and
|
||||||
|
// produces output when it has something to report.
|
||||||
|
result := os.execute(source.command)
|
||||||
|
|
||||||
|
elapsed := time.now().unix() - start.unix()
|
||||||
|
if elapsed > timeout {
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit code 0 = event, non-zero = skip
|
||||||
|
if result.exit_code != 0 {
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
output := result.output.trim_space()
|
||||||
|
if output == '' {
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse plugin output as JSON
|
||||||
|
doc := json.decode[json.Any](output, json.DecoderOptions{}) or { return none }
|
||||||
|
obj := doc.as_map()
|
||||||
|
|
||||||
|
priority := obj['priority'] or { json.Any(3) }.int()
|
||||||
|
|
||||||
|
mut tags := []string{}
|
||||||
|
if tags_any := obj['tags'] {
|
||||||
|
for tag in tags_any.as_array() {
|
||||||
|
tags << tag.str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := obj['message'] or { json.Any(output) }.str()
|
||||||
|
click_url := obj['click_url'] or { json.Any('') }.str()
|
||||||
|
|
||||||
|
mut actions := []Action{}
|
||||||
|
if actions_any := obj['actions'] {
|
||||||
|
for a in actions_any.as_array() {
|
||||||
|
am := a.as_map()
|
||||||
|
actions << Action{
|
||||||
|
action: am['action'] or { json.Any('view') }.str()
|
||||||
|
label: am['label'] or { json.Any('') }.str()
|
||||||
|
url: am['url'] or { json.Any('') }.str()
|
||||||
|
clear: am['clear'] or { json.Any(false) }.bool()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve topic: plugin output can override, or fall back to source config
|
||||||
|
mut topic := source.topic
|
||||||
|
if plugin_topic := obj['topic'] {
|
||||||
|
t := plugin_topic.str()
|
||||||
|
if t != '' {
|
||||||
|
topic = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Event{
|
||||||
|
source: 'plugin'
|
||||||
|
name: source.name
|
||||||
|
topic: topic
|
||||||
|
priority: int(priority)
|
||||||
|
tags: tags
|
||||||
|
message: message
|
||||||
|
raw: output
|
||||||
|
click_url: click_url
|
||||||
|
actions: actions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub enum SourceKind {
|
|||||||
uptime_kuma
|
uptime_kuma
|
||||||
cron
|
cron
|
||||||
generic
|
generic
|
||||||
|
plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WebhookRoute {
|
pub struct WebhookRoute {
|
||||||
@@ -42,6 +43,11 @@ pub fn build_webhook_routes(cfg Config) map[string]WebhookRoute {
|
|||||||
index: i
|
index: i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i, _ in cfg.sources.plugin {
|
||||||
|
// Plugin sources don't have webhook_path — they run as background tasks
|
||||||
|
// Reserved for future webhook-triggered plugin support
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user