From 0e73f14b6e63c44aa2b572164e17ab42a24819fc Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 4 Aug 2026 22:14:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v0.6.0=20=E2=80=94=20ACLs=20multi-utili?= =?UTF-8?q?sateurs=20+=20Plugin=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- README.md | 166 +++++---- acl_test.v | 105 ++++++ config.v | 1 + docker_watcher.v | 1 - docs/ARCHITECTURE.md | 630 ++++++++++++++++++--------------- docs/ROADMAP.md | 346 +++++++++--------- main.v | 6 +- ntfy-bridge.example.yaml | 320 +++++++++-------- scripts/example-plugin-disk.sh | 27 ++ server.v | 200 +++++++++++ sources/event.v | 21 ++ sources/plugin.v | 86 +++++ webhook.v | 6 + 13 files changed, 1229 insertions(+), 686 deletions(-) create mode 100644 acl_test.v create mode 100644 scripts/example-plugin-disk.sh create mode 100644 sources/plugin.v diff --git a/README.md b/README.md index 1c942be..34ac435 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,90 @@ -# 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. - -``` -┌─────────────────┐ -│ Gitea webhook │──┐ -├─────────────────┤ │ -│ Uptime Kuma │──┤ -├─────────────────┤ │ ┌──────────────┐ ┌───────────┐ ┌──────────────┐ -│ Docker events │──┤────→│ ntfy-bridge │─────→│ Ntfy Srv │─────→│ Ton phone │ -├─────────────────┤ │ └──────────────┘ └───────────┘ └──────────────┘ -│ HTTP health │──┤ -├─────────────────┤ │ -│ Scripts cron │──┘ -└─────────────────┘ -``` - -## 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. - -| Sans ntfy-bridge | Avec ntfy-bridge | -|---|---| -| 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 | -| Container qui crashe → logs Docker | "🐳 flowdeck exited OOMKilled on docker-prod-1" | -| Script cron → email perdu | Résumé quotidien disque à 9h dans ta poche | - -## Quick Start - -```bash -# Installer V (si pas déjà fait) -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 - -# Compiler -git clone https://git.dracodev.net/bruno/ntfy-bridge.git -cd ntfy-bridge -v . - -# Configurer -cp ntfy-bridge.example.yaml ntfy-bridge.yaml -vim ntfy-bridge.yaml - -# Lancer -./ntfy-bridge --config ntfy-bridge.yaml -``` - -## Sources supportées - -| Source | Type | Description | -|--------|------|-------------| -| **Gitea** | Webhook | Push, PR, issues, releases → notifs formatées | -| **Uptime Kuma** | Webhook | Statut up/down avec priorité critique | -| **Docker** | Socket | Container start/die/oom/health_status | -| **HTTP Poll** | Polling | Health checks périodiques | -| **Cron** | Webhook | Reçoit des notifs depuis des scripts shell | -| **Generic** | Webhook | Endpoint passe-partout pour tout script custom | - -## Exemple de config minimale - -```yaml -server: - url: https://ntfy.dracodev.net - listen: ":9090" - -sources: - gitea: - - webhook_path: /webhooks/gitea - topic: dev-notifs -``` - -## Licence - -MIT +# 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. + +``` +┌─────────────────┐ +│ Gitea webhook │──┐ +├─────────────────┤ │ +│ Uptime Kuma │──┤ +├─────────────────┤ │ ┌──────────────┐ ┌───────────┐ ┌──────────────┐ +│ Docker events │──┤────→│ ntfy-bridge │─────→│ Ntfy Srv │─────→│ Ton phone │ +├─────────────────┤ │ └──────────────┘ └───────────┘ └──────────────┘ +│ HTTP health │──┤ +├─────────────────┤ │ +│ Scripts cron │──┘ +└─────────────────┘ +``` + +## 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. + +| Sans ntfy-bridge | Avec ntfy-bridge | +|---|---| +| 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 | +| Container qui crashe → logs Docker | "🐳 flowdeck exited OOMKilled on docker-prod-1" | +| Script cron → email perdu | Résumé quotidien disque à 9h dans ta poche | + +## Quick Start + +```bash +# Installer V (si pas déjà fait) +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 + +# Compiler +git clone https://git.dracodev.net/bruno/ntfy-bridge.git +cd ntfy-bridge +v . + +# Configurer +cp ntfy-bridge.example.yaml ntfy-bridge.yaml +vim ntfy-bridge.yaml + +# Lancer +./ntfy-bridge --config ntfy-bridge.yaml +``` + +## Sources supportées + +| Source | Type | Description | +|--------|------|-------------| +| **Gitea** | Webhook | Push, PR, issues, releases → notifs formatées | +| **Uptime Kuma** | Webhook | Statut up/down avec priorité critique | +| **Docker** | Socket | Container start/die/oom/health_status | +| **HTTP Poll** | Polling | Health checks périodiques | +| **Cron** | Webhook | Reçoit des notifs depuis des scripts shell | +| **Generic** | Webhook | Endpoint passe-partout pour tout script custom | +| **Plugin** | Exec | Exécutable externe appelé périodiquement (bash, python, …) | + +## Features + +- **ACLs** — Restriction par IP (CIDR) ou Bearer token sur chaque webhook +- **Plugins** — Scripts externes exécutés toutes les 60s, contrat JSON stdout +- **Dashboard** — Interface web avec stats, statuts, historique temps réel +- **Filtres** — Règles conditionnelles drop/set_priority/add_tag par source +- **Actions Ntfy** — Boutons click, vues, broadcast intégrés +- **Prometheus** — Endpoint `/metrics` pour Grafana +- **Outgoing webhooks** — Forward vers Slack, Discord, JSON +- **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 + +## Exemple de config minimale + +```yaml +server: + url: https://ntfy.dracodev.net + listen: ":9090" + +sources: + gitea: + - webhook_path: /webhooks/gitea + topic: dev-notifs +``` + +## Licence + +MIT diff --git a/acl_test.v b/acl_test.v new file mode 100644 index 0000000..7b89d00 --- /dev/null +++ b/acl_test.v @@ -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{} + } +} diff --git a/config.v b/config.v index 2df227d..12762fc 100644 --- a/config.v +++ b/config.v @@ -66,6 +66,7 @@ pub mut: http_poll []sources.HttpPollSource @[json: 'http_poll'] cron []sources.CronSource @[json: 'cron'] generic []sources.GenericSource @[json: 'generic'] + plugin []sources.PluginSource @[json: 'plugin'] } pub struct Config { diff --git a/docker_watcher.v b/docker_watcher.v index a7ec5e2..83bebdd 100644 --- a/docker_watcher.v +++ b/docker_watcher.v @@ -5,7 +5,6 @@ $if linux || macos { import net.unix import os } -import sources fn (mut app App) docker_watch_loop() { $if linux || macos { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 39b8a0a..811fb17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,295 +1,335 @@ -# ntfy-bridge — Architecture - -## 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. - -**Principes :** -- **Un binaire, zéro runtime** — pas de Node, Python, JVM ou conteneur obligatoire -- **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 -- **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 -- **Zéro dépendance externe** — 100% stdlib V (`net.http`, `json`, `yaml`, `time`, `os`, `flag`) - -## Architecture globale - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ntfy-bridge │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ -│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron/ │ │ -│ │ Server │ │ Watcher │ │ Poller │ │ Generic │ │ -│ │ :9090 │ │ goroutine│ │ goroutine│ │ Receiver │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ -│ │ │ │ │ │ -│ └──────────────┼──────────────┼──────────────┘ │ -│ ▼ ▼ │ -│ ┌──────────────────────────┐ │ -│ │ Event Pipeline │ │ -│ │ ┌────────┐ ┌────────┐ │ │ -│ │ │Parse & │ │Dedup & │ │ │ -│ │ │Format │──│RateLim │ │ │ -│ │ └────────┘ └────────┘ │ │ -│ └────────────┬─────────────┘ │ -│ ▼ │ -│ ┌──────────────────────────┐ │ -│ │ Ntfy Publisher (retry) │ │ -│ │ POST / │ │ -│ │ 3 tentatives, backoff │ │ -│ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Flux de données - -### 1. Webhook entrant (Gitea, Uptime Kuma, Cron, Generic) -``` -POST /webhooks/gitea-flowdeck - │ - ▼ -1. O(1) lookup dans webhook_routes (map[string]WebhookRoute) -2. Verify HMAC-SHA256 signature (si configuré, global) -3. Dispatcher vers le transformer approprié (sources/transform_*.v) -4. Parse JSON → Event struct -5. Appliquer priority_map -6. Appliquer template de message (via render_source_template) -7. Pipeline publish: apply_defaults → dedup.allow → ntfy.publish -8. POST vers Ntfy avec retry (3 tentatives, backoff 1s/2s/4s) -``` - -### 2. Docker watcher (socket) -``` -goroutine docker_watch_loop (via start_background_tasks) - │ - ▼ -1. Connexion au socket Docker Unix (/var/run/docker.sock) -2. Stream events via GET /events?filters={"die":true,"oom":true,...} -3. Filtrer par événements configurés (die, oom, health_status) -4. Extraire container_name, image, exit_code via transform_docker_event -5. Appliquer template -6. Pipeline publish → Ntfy -``` - -### 3. HTTP Poller -``` -goroutine http_poll_loop (via start_background_tasks, toutes les N secondes) - │ - ▼ -1. GET avec timeout configuré -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"]) -4. Sinon → rien (pas de notification si tout va bien) -5. State machine: poll_state[url] = is_up → notification uniquement au changement -``` - -### 4. Cron receiver -``` -POST /webhooks/cron-disk - │ - ▼ -1. Body = texte brut du script cron -2. Pass-through vers Ntfy (transform_cron → Event) -3. Le script cron contrôle son propre format -``` - -## Modèle de données - -```v -// config.v -struct ServerConfig { - url string // URL du serveur Ntfy - auth_token string // Token d'auth (optionnel, override via NTFY_TOKEN env) - listen string // Adresse d'écoute HTTP (:9090) - hmac_secret string // Secret partagé HMAC-SHA256 (override via NTFY_HMAC_SECRET env) -} - -struct DedupConfig { - enabled bool - ttl_seconds int // TTL du cache de déduplication - rate_limit RateLimitConfig // max_per_minute, max_per_source -} - -// sources/event.v -struct Event { - source string // "gitea", "docker", "uptime_kuma", "http_poll", "cron", "generic" - name string // nom de la source configurée - topic string // topic Ntfy cible - priority int // 1-5 - tags []string - message string // message formaté final - raw string // payload brut (pour debug) -} -``` - -Les types de sources (GiteaSource, UptimeKumaSource, DockerSource, HttpPollSource/HttpCheck, CronSource, GenericSource) sont définis dans `sources/event.v`. - -## Pipeline de transformation - -Chaque source a une **free function** dans `sources/` : - -```v -// Exemple : sources/gitea.v -pub fn transform_gitea(raw string, source GiteaSource) ?Event { ... } -``` - -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 -2. Un type source dans `sources/event.v` -3. Une entrée dans `SourcesConfig` (dans `config.v`) -4. Une branche dans `build_webhook_routes` et `handle_webhook` - -### Transformers inclus - -| Transformer | Entrée | Sortie | -|---|---|---| -| `transform_gitea` | JSON webhook Gitea | `🔨 [FlowDeck] bruno pushed: "Fix bug" (a3f2c1d)` | -| `transform_uptime_kuma` | JSON webhook Kuma | `🚨 Gitea is DOWN — 503 — since 14:32` | -| `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 | - -### Template engine - -`render_source_template(template, vars)` dans `sources/event.v` — remplacement simple `{variable}` → valeur. Supporté par Gitea, Uptime Kuma, Docker, HTTP poll. - -``` -template: "🐳 {container_name} → {status}\nImage: {image}\nHost: {host}" -``` - -### Priorités Ntfy - -| Niveau | Usage | Exemple | -|---|---|---| -| 5 (urgent) | Service critique down, container OOM | Uptime Kuma DOWN | -| 4 (high) | PR ouverte, container crash | Gitea pull_request, Docker die | -| 3 (default) | Push, issue, activité normale | Gitea push | -| 2 (low) | Info, succès | Service back UP | -| 1 (min) | Debug, heartbeat | Health check OK | - -## Déduplication et rate limiting - -- **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é. -- **Rate limiting par topic** : `max_per_minute` notifications max par topic (défaut: 10). Fenêtre glissante de 60s. -- **Rate limiting par source** : `max_per_source` notifications max par source configurée (défaut: 30). Fenêtre glissante de 60s. -- **Nettoyage automatique** : les entrées expirées sont nettoyées à chaque appel `allow()`. - -## Structure du projet - -``` -ntfy-bridge/ -├── README.md # Vue d'ensemble, quickstart -├── docs/ -│ ├── ARCHITECTURE.md # Ce document -│ ├── ROADMAP.md # Phases de développement -│ ├── CONTRIBUTING.md # Guide de contribution -│ └── WEBHOOK_GITEA.md # Guide config webhook Gitea -├── ntfy-bridge.example.yaml # Exemple de configuration complet -├── main.v # Entrypoint, parsing CLI, header ASCII -├── config.v # Chargement + validation YAML, env overrides -├── server.v # HTTP server, routing webhooks O(1), dashboard, API -├── ntfy.v # Client HTTP Ntfy (POST + retry backoff) -├── 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) -├── docker_watcher.v # Watcher socket Docker (Unix seulement) -├── dashboard.html # Interface web : stats, status, historique -├── sources/ # Types Event + transformers -│ ├── event.v # Types Event, Source, render_source_template -│ ├── gitea.v # Transformer Gitea (push, PR, issue, release) -│ ├── uptime_kuma.v # Transformer Uptime Kuma (state_map) -│ ├── docker.v # Transformer Docker events -│ ├── http_poll.v # Transformer HTTP poll (up/down) -│ ├── cron.v # Transformer Cron (pass-through) -│ └── generic.v # Transformer Generic (pass-through) -├── *_test.v # Tests unitaires (config, gitea, uptime_kuma, dedup) -├── scripts/ -│ └── example-cron-disk.sh # Exemple script cron → Ntfy -└── v.mod # Dépendances V (aucune dépendance externe) -``` - -## Dépendances - -```v -// v.mod -Module { - name: 'ntfy-bridge' - version: '0.1.0' - dependencies: [] // zéro dépendance externe -} -``` - -Le projet utilise **uniquement la stdlib V** : -- `net.http` — serveur HTTP + client Ntfy -- `x.json2` — parsing JSON -- `yaml` — parsing YAML -- `crypto.hmac`, `crypto.sha256`, `encoding.hex` — validation HMAC -- `time`, `os`, `flag` — utilitaires -- `net.unix` — socket Docker (Linux/macOS seulement, via `$if`) - -## Endpoints HTTP - -| Méthode | Path | Description | -|---------|------|-------------| -| GET | `/` ou `/dashboard` | Dashboard HTML | -| GET | `/health` | Health check → `{"status":"ok","uptime_seconds":N,"total_notifications":N}` | -| GET | `/stats` | Stats par source → `{"total":N,"errors":N,"by_source":{...}}` | -| GET | `/api/config` | Config résumée (safe, sans secrets) | -| GET | `/api/status` | État des health checks HTTP poll | -| GET | `/api/history` | 100 derniers événements | -| POST | `/webhooks/*` | Webhooks (Gitea, Uptime Kuma, Cron, Generic) | - -## Déploiement - -### Option A : Binaire nu + systemd -```ini -# /etc/systemd/system/ntfy-bridge.service -[Unit] -Description=ntfy-bridge notification hub -After=network.target - -[Service] -ExecStart=/usr/local/bin/ntfy-bridge --quiet --config /etc/ntfy-bridge.yaml -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é - -- **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) +# ntfy-bridge — Architecture + +## Philosophie + +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 :** +- **Un binaire, zéro runtime** — pas de Node, Python, JVM ou conteneur obligatoire +- **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 +- **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 +- **Zéro dépendance externe** — 100% stdlib V (`net.http`, `json`, `yaml`, `time`, `os`, `flag`) + +## Architecture globale + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ntfy-bridge │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ │ +│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron/ │ │ Plugin │ │ +│ │ Server │ │ Watcher │ │ Poller │ │ Generic │ │ Runner │ │ +│ │ :9090 │ │ goroutine│ │ goroutine│ │ Receiver │ │ goroutine│ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ └────┬─────┘ │ +│ │ │ │ │ │ │ +│ └──────────────┼──────────────┼──────────────┼──────────────┘ │ +│ ▼ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Event Pipeline │ │ +│ │ ┌────────┐ ┌────────┐ │ │ +│ │ │Parse & │ │Dedup & │ │ │ +│ │ │Format │──│RateLim │ │ │ +│ │ └────────┘ └────────┘ │ │ +│ └────────────┬─────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Ntfy Publisher (retry) │ │ +│ │ POST / │ │ +│ │ 3 tentatives, backoff │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Flux de données + +### 1. Webhook entrant (Gitea, Uptime Kuma, Cron, Generic) +``` +POST /webhooks/gitea-flowdeck + │ + ▼ +1. O(1) lookup dans webhook_routes (map[string]WebhookRoute) +2. Verify HMAC-SHA256 signature (si configuré, global) +3. Dispatcher vers le transformer approprié (sources/transform_*.v) +4. Parse JSON → Event struct +5. Appliquer priority_map +6. Appliquer template de message (via render_source_template) +7. Pipeline publish: apply_defaults → dedup.allow → ntfy.publish +8. POST vers Ntfy avec retry (3 tentatives, backoff 1s/2s/4s) +``` + +### 2. Docker watcher (socket) +``` +goroutine docker_watch_loop (via start_background_tasks) + │ + ▼ +1. Connexion au socket Docker Unix (/var/run/docker.sock) +2. Stream events via GET /events?filters={"die":true,"oom":true,...} +3. Filtrer par événements configurés (die, oom, health_status) +4. Extraire container_name, image, exit_code via transform_docker_event +5. Appliquer template +6. Pipeline publish → Ntfy +``` + +### 3. HTTP Poller +``` +goroutine http_poll_loop (via start_background_tasks, toutes les N secondes) + │ + ▼ +1. GET avec timeout configuré +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"]) +4. Sinon → rien (pas de notification si tout va bien) +5. State machine: poll_state[url] = is_up → notification uniquement au changement +``` + +### 4. Cron receiver +``` +POST /webhooks/cron-disk + │ + ▼ +1. Body = texte brut du script cron +2. Pass-through vers Ntfy (transform_cron → Event) +3. Le script cron contrôle son propre format +``` + +### 5. Plugin runner (v0.6) +``` +goroutine plugin_loop (via start_background_tasks, toutes les 60s) + │ + ▼ +1. Exécute le binaire configuré (os.execute) +2. Lit stdout → parse JSON → Event struct +3. Exit 0 = publier, exit ≠ 0 = skip (pas de notif) +4. Supporte priority, tags, message, click_url, actions +5. Pipeline publish → Ntfy +``` + +### 6. ACL pipeline (v0.6) +``` +POST /webhooks/* + │ + ▼ +1. Extraire IP client (X-Forwarded-For > X-Real-IP) +2. Extraire Bearer token (Authorization header) +3. Si ACL configurée sur le webhook → valider IP et/ou token +4. Si échec → 403 Forbidden +5. Sinon → continuer le pipeline normal +``` + +## Modèle de données + +```v +// config.v +struct ServerConfig { + url string // URL du serveur Ntfy + auth_token string // Token d'auth (optionnel, override via NTFY_TOKEN env) + listen string // Adresse d'écoute HTTP (:9090) + hmac_secret string // Secret partagé HMAC-SHA256 (override via NTFY_HMAC_SECRET env) +} + +struct DedupConfig { + enabled bool + ttl_seconds int // TTL du cache de déduplication + rate_limit RateLimitConfig // max_per_minute, max_per_source +} + +// sources/event.v +struct Event { + source string // "gitea", "docker", "uptime_kuma", "http_poll", "cron", "generic", "plugin" + name string // nom de la source configurée + topic string // topic Ntfy cible + priority int // 1-5 + tags []string + message string // message formaté final + raw string // payload brut (pour debug) + click_url string // URL ouverte au clic sur la notif (v0.5) + actions []Action // boutons d'action Ntfy (v0.5) +} +``` + +Les types de sources (GiteaSource, UptimeKumaSource, DockerSource, HttpPollSource/HttpCheck, CronSource, GenericSource, PluginSource) et AclConfig sont définis dans `sources/event.v`. + +## Pipeline de transformation + +Chaque source a une **free function** dans `sources/` : + +```v +// Exemple : sources/gitea.v +pub fn transform_gitea(raw string, source GiteaSource) ?Event { ... } +``` + +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 +2. Un type source dans `sources/event.v` +3. Une entrée dans `SourcesConfig` (dans `config.v`) +4. Une branche dans `build_webhook_routes` et `handle_webhook` + +### Transformers inclus + +| Transformer | Entrée | Sortie | +|---|---|---| +| `transform_gitea` | JSON webhook Gitea | `🔨 [FlowDeck] bruno pushed: "Fix bug" (a3f2c1d)` | +| `transform_uptime_kuma` | JSON webhook Kuma | `🚨 Gitea is DOWN — 503 — since 14:32` | +| `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 | +| `transform_plugin` | stdout d'un exécutable externe | JSON → Event (exit 0 = publish, ≠ 0 = skip) | + +### Template engine + +`render_source_template(template, vars)` dans `sources/event.v` — remplacement simple `{variable}` → valeur. Supporté par Gitea, Uptime Kuma, Docker, HTTP poll. + +``` +template: "🐳 {container_name} → {status}\nImage: {image}\nHost: {host}" +``` + +### Priorités Ntfy + +| Niveau | Usage | Exemple | +|---|---|---| +| 5 (urgent) | Service critique down, container OOM | Uptime Kuma DOWN | +| 4 (high) | PR ouverte, container crash | Gitea pull_request, Docker die | +| 3 (default) | Push, issue, activité normale | Gitea push | +| 2 (low) | Info, succès | Service back UP | +| 1 (min) | Debug, heartbeat | Health check OK | + +## Déduplication et rate limiting + +- **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é. +- **Rate limiting par topic** : `max_per_minute` notifications max par topic (défaut: 10). Fenêtre glissante de 60s. +- **Rate limiting par source** : `max_per_source` notifications max par source configurée (défaut: 30). Fenêtre glissante de 60s. +- **Nettoyage automatique** : les entrées expirées sont nettoyées à chaque appel `allow()`. + +## Structure du projet + +``` +ntfy-bridge/ +├── README.md # Vue d'ensemble, quickstart +├── docs/ +│ ├── ARCHITECTURE.md # Ce document +│ ├── ROADMAP.md # Phases de développement +│ ├── CONTRIBUTING.md # Guide de contribution +│ └── WEBHOOK_GITEA.md # Guide config webhook Gitea +├── ntfy-bridge.example.yaml # Exemple de configuration complet +├── main.v # Entrypoint, parsing CLI, header ASCII +├── config.v # Chargement + validation YAML, env overrides +├── server.v # HTTP server, routing webhooks O(1), dashboard, API +├── ntfy.v # Client HTTP Ntfy (POST + retry backoff) +├── 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) +├── docker_watcher.v # Watcher socket Docker (Unix seulement) +├── filter_outgoing.v # Filtres avancés + outgoing webhooks + state persistence +├── metrics.v # Endpoint Prometheus /metrics +├── service.v # Installation/désinstallation de service (systemd, openrc, nssm) +├── docker_install.v # Génération de stack docker-compose +├── dashboard.html # Interface web : stats, status, historique +├── sources/ # Types Event + transformers +│ ├── event.v # Types Event, Source, render_source_template +│ ├── gitea.v # Transformer Gitea (push, PR, issue, release) +│ ├── uptime_kuma.v # Transformer Uptime Kuma (state_map) +│ ├── docker.v # Transformer Docker events +│ ├── http_poll.v # Transformer HTTP poll (up/down) +│ ├── cron.v # Transformer Cron (pass-through) +│ ├── generic.v # Transformer Generic (pass-through) +│ └── plugin.v # Transformer Plugin (exec stdout JSON) +├── *_test.v # Tests unitaires (config, gitea, uptime_kuma, dedup, filter_outgoing, metrics, acl) +├── scripts/ +│ ├── example-cron-disk.sh # Exemple script cron → Ntfy +│ ├── example-plugin-disk.sh # Exemple plugin disk check +│ ├── install.sh # Script d'installation Linux one-liner +│ └── install.ps1 # Script d'installation Windows PowerShell +└── v.mod # Dépendances V (aucune dépendance externe) +``` + +## Dépendances + +```v +// v.mod +Module { + name: 'ntfy-bridge' + version: '0.1.0' + dependencies: [] // zéro dépendance externe +} +``` + +Le projet utilise **uniquement la stdlib V** : +- `net.http` — serveur HTTP + client Ntfy +- `x.json2` — parsing JSON +- `yaml` — parsing YAML +- `crypto.hmac`, `crypto.sha256`, `encoding.hex` — validation HMAC +- `time`, `os`, `flag` — utilitaires +- `net.unix` — socket Docker (Linux/macOS seulement, via `$if`) + +## Endpoints HTTP + +| Méthode | Path | Description | +|---------|------|-------------| +| GET | `/` ou `/dashboard` | Dashboard HTML | +| GET | `/health` | Health check → `{"status":"ok","uptime_seconds":N,"total_notifications":N}` | +| GET | `/stats` | Stats par source → `{"total":N,"errors":N,"by_source":{...}}` | +| GET | `/api/config` | Config résumée (safe, sans secrets) | +| GET | `/api/status` | État des health checks HTTP poll | +| GET | `/api/history` | 100 derniers événements | +| GET | `/api/silence` | État du silence (actif/restant) | +| POST | `/api/silence?duration=30m` | Activer le silence | +| DELETE | `/api/silence` | Désactiver le silence | +| GET | `/metrics` | Métriques Prometheus / OpenMetrics | +| POST | `/webhooks/*` | Webhooks (Gitea, Uptime Kuma, Cron, Generic) | + +## Déploiement + +### Option A : Binaire nu + systemd +```ini +# /etc/systemd/system/ntfy-bridge.service +[Unit] +Description=ntfy-bridge notification hub +After=network.target + +[Service] +ExecStart=/usr/local/bin/ntfy-bridge --quiet --config /etc/ntfy-bridge.yaml +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) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 841f6e0..f546b10 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,169 +1,177 @@ -# ntfy-bridge — Roadmap - -## Phase 1 : MVP (core engine) — v0.1.0 ✓ DONE - -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] `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] `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] `sources/generic.v` — Webhook passe-partout (POST raw → Ntfy) -- [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 - -**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 - -- [x] `sources/gitea.v` — Transformer Gitea (push, PR, issues, releases) - - Parse le JSON webhook Gitea - - Template configurable avec variables `{repo}`, `{user}`, `{action}`, `{title}`, `{sha}` - - Priority map par type d'événement -- [x] `sources/uptime_kuma.v` — Transformer Uptime Kuma - - Parse le JSON webhook Kuma (heartbeat) - - `state_map` pour configurer priority/tags par état (up/down) - - Template configurable avec variables `{monitor}`, `{status}`, `{msg}`, `{ping}`, `{time}` -- [x] `sources/docker.v` — Watcher socket Docker - - Connexion au socket Docker Unix (`docker_watcher.v`) - - Filtrage par événements (die, health_status, oom) - - Template de message configurable - - Multi-hôtes (plusieurs sockets dans la config) -- [x] `sources/http_poll.v` — Poller HTTP - - Goroutine de polling périodique (intervalle configurable) - - Vérification status code + timeout - - State machine up/down avec notification uniquement au changement - - Template configurable avec variables `{url}`, `{error}`, `{status}` -- [x] `dedup.v` — Déduplication + rate limiting - - Cache avec TTL (5 min par défaut) basé sur hash du message - - Clé de déduplication : source + type + topic + hash(message) - - Rate limiting par topic et par source - -**Bonus (non planifié) :** -- [x] `sources/cron.v` — Webhook pour scripts cron (pass-through) -- [x] `webhook.v` — Route dispatch O(1) via `map[string]WebhookRoute` - -**Sortie** : Toutes les sources majeures sont intégrées et fonctionnelles. ✅ - -## Phase 3 : Robustesse — v0.3.0 ✅ DONE - -- [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] 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] Health endpoint — `GET /health` → 200 + uptime + stats basiques -- [x] Stats endpoint — `GET /stats` → compteurs par source -- [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` - -**Sortie** : Le daemon est prêt pour la production homelab. ✅ - -## Phase 4 : Qualité de vie — v0.4.0 ✅ DONE - -- [x] Template engine — Support des variables `{key}` dans les messages (toutes les sources) - ``` - "🐳 {container_name} → {status} (exit: {exit_code}) on {host}" - ``` -- [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] Webhook secret validation — HMAC global (tous les webhooks, pas seulement Gitea) -- [x] CI/CD via Gitea Actions — `.gitea/workflows/ci.yml` - -**Sortie** : Expérience utilisateur complète. ✅ - -## 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. - -- [x] `service.v` — Module d'installation de service unifié - - Commande `--install-service` : détecte l'OS et installe le service - - Commande `--uninstall-service` : désinstalle le service - - Commande `--service-status` : vérifie si le service est installé + running -- [x] Windows Service — Intégration native Windows - - Installation via `sc.exe` (Service Control Manager) - - Support des événements start/stop/query - - Options de recovery configurées (restart auto) -- [x] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service` - - Création + activation automatique du unit file - - `--quiet` par défaut (logging JSON → journald) - - Restart=always, démarrage après network.target -- [x] Linux Alpine (openrc) — Script init.d - - `/etc/init.d/ntfy-bridge` généré automatiquement - - Commandes start/stop/restart/status - - Ajout au runlevel default -- [x] Raspberry Pi (Debian aarch64) — Support confirmé - - Cross-compilation `v -os linux -arch arm64` - - Même unit systemd que Debian x86_64 - - Testé sur Raspberry Pi OS (arm64) -- [x] Docker image multi-arch — `Dockerfile` + `docker-compose.yml` - - Build multi-stage (V from source → minimal Alpine runtime) - - docker-compose.yml prêt à l'emploi avec healthcheck -- [x] Scripts d'installation one-liner - - `scripts/install.sh` : détecte l'OS/arch, build V, installe le service - - `scripts/install.ps1` : équivalent Windows PowerShell - - `curl -fsSL https://.../install.sh | bash` - -**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) - -- [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints -- [x] Filtres avancés — Expressions conditionnelles par source - ```yaml - filters: - rules: - - match_source: "docker" - match_name: "test-*" - action: drop - ``` -- [x] Notifications structurées — Support des actions Ntfy (boutons click) - - Champ `click_url` pour ouvrir une URL au clic - - Champ `actions` pour boutons d'action (view, broadcast, http) -- [x] Intégration Prometheus — Métriques exposées au format OpenMetrics - - Endpoint `GET /metrics` : gauges (uptime, poll_state, silence) + counters (notifications, errors, http_polls) - - Format compatible Prometheus/Grafana -- [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON -- [ ] Support multi-utilisateurs — ACLs par webhook_path -- [x] Fichier d'état — Persistance de l'état up/down des health checks -- [ ] Plugin system — Sources customisables via dll/.so - - url: "https://hooks.slack.com/..." - format: slack - - url: "https://discord.com/api/webhooks/..." - format: discord - ``` -- [ ] Support multi-utilisateurs — ACLs par webhook_path -- [x] Fichier d'état — Persistance de l'état up/down des health checks - ```yaml - state: - file: "/var/lib/ntfy-bridge/state.json" - ``` - - Sauvegarde `poll_state` (up/down) + `silence_until` - - Restauré au démarrage, persisté à chaque changement -- [ ] Plugin system — Sources customisables via dll/.so - -## Fonctionnalités non planifiées mais implémentées - -Ces features ont été ajoutées en cours de route : - -| Feature | Fichier | Description | -|---------|---------|-------------| -| 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 | -| 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 | -| 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 | -| Flag `-V` (version) | `main.v` | Version courte en plus de `--version` | -| API endpoints | `server.v` | `/api/config`, `/api/status`, `/api/history` pour le dashboard | - -## Suivi des versions - -| 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+ | 🚧 6/8 | Dashboard, filtres, actions Ntfy, Prometheus, outgoing webhooks, state file — manque multi-user, plugins | +# ntfy-bridge — Roadmap + +## Phase 1 : MVP (core engine) — v0.1.0 ✓ DONE + +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] `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] `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] `sources/generic.v` — Webhook passe-partout (POST raw → Ntfy) +- [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 + +**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 + +- [x] `sources/gitea.v` — Transformer Gitea (push, PR, issues, releases) + - Parse le JSON webhook Gitea + - Template configurable avec variables `{repo}`, `{user}`, `{action}`, `{title}`, `{sha}` + - Priority map par type d'événement +- [x] `sources/uptime_kuma.v` — Transformer Uptime Kuma + - Parse le JSON webhook Kuma (heartbeat) + - `state_map` pour configurer priority/tags par état (up/down) + - Template configurable avec variables `{monitor}`, `{status}`, `{msg}`, `{ping}`, `{time}` +- [x] `sources/docker.v` — Watcher socket Docker + - Connexion au socket Docker Unix (`docker_watcher.v`) + - Filtrage par événements (die, health_status, oom) + - Template de message configurable + - Multi-hôtes (plusieurs sockets dans la config) +- [x] `sources/http_poll.v` — Poller HTTP + - Goroutine de polling périodique (intervalle configurable) + - Vérification status code + timeout + - State machine up/down avec notification uniquement au changement + - Template configurable avec variables `{url}`, `{error}`, `{status}` +- [x] `dedup.v` — Déduplication + rate limiting + - Cache avec TTL (5 min par défaut) basé sur hash du message + - Clé de déduplication : source + type + topic + hash(message) + - Rate limiting par topic et par source + +**Bonus (non planifié) :** +- [x] `sources/cron.v` — Webhook pour scripts cron (pass-through) +- [x] `webhook.v` — Route dispatch O(1) via `map[string]WebhookRoute` + +**Sortie** : Toutes les sources majeures sont intégrées et fonctionnelles. ✅ + +## Phase 3 : Robustesse — v0.3.0 ✅ DONE + +- [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] 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] Health endpoint — `GET /health` → 200 + uptime + stats basiques +- [x] Stats endpoint — `GET /stats` → compteurs par source +- [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` + +**Sortie** : Le daemon est prêt pour la production homelab. ✅ + +## Phase 4 : Qualité de vie — v0.4.0 ✅ DONE + +- [x] Template engine — Support des variables `{key}` dans les messages (toutes les sources) + ``` + "🐳 {container_name} → {status} (exit: {exit_code}) on {host}" + ``` +- [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] Webhook secret validation — HMAC global (tous les webhooks, pas seulement Gitea) +- [x] CI/CD via Gitea Actions — `.gitea/workflows/ci.yml` + +**Sortie** : Expérience utilisateur complète. ✅ + +## 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. + +- [x] `service.v` — Module d'installation de service unifié + - Commande `--install-service` : détecte l'OS et installe le service + - Commande `--uninstall-service` : désinstalle le service + - Commande `--service-status` : vérifie si le service est installé + running +- [x] Windows Service — Intégration native Windows + - Installation via `sc.exe` (Service Control Manager) + - Support des événements start/stop/query + - Options de recovery configurées (restart auto) +- [x] Linux Debian/Ubuntu (systemd) — `ntfy-bridge.service` + - Création + activation automatique du unit file + - `--quiet` par défaut (logging JSON → journald) + - Restart=always, démarrage après network.target +- [x] Linux Alpine (openrc) — Script init.d + - `/etc/init.d/ntfy-bridge` généré automatiquement + - Commandes start/stop/restart/status + - Ajout au runlevel default +- [x] Raspberry Pi (Debian aarch64) — Support confirmé + - Cross-compilation `v -os linux -arch arm64` + - Même unit systemd que Debian x86_64 + - Testé sur Raspberry Pi OS (arm64) +- [x] Docker image multi-arch — `Dockerfile` + `docker-compose.yml` + - Build multi-stage (V from source → minimal Alpine runtime) + - docker-compose.yml prêt à l'emploi avec healthcheck +- [x] Scripts d'installation one-liner + - `scripts/install.sh` : détecte l'OS/arch, build V, installe le service + - `scripts/install.ps1` : équivalent Windows PowerShell + - `curl -fsSL https://.../install.sh | bash` + +**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+ (8/8) ✅ DONE + +- [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints +- [x] Filtres avancés — Expressions conditionnelles par source + ```yaml + filters: + rules: + - match_source: "docker" + match_name: "test-*" + action: drop + ``` +- [x] Notifications structurées — Support des actions Ntfy (boutons click) + - Champ `click_url` pour ouvrir une URL au clic + - Champ `actions` pour boutons d'action (view, broadcast, http) +- [x] Intégration Prometheus — Métriques exposées au format OpenMetrics + - Endpoint `GET /metrics` : gauges (uptime, poll_state, silence) + counters (notifications, errors, http_polls) + - Format compatible Prometheus/Grafana +- [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON +- [x] Support multi-utilisateurs — ACLs par webhook_path + ```yaml + acl: + allowed_ips: ["192.168.30.5", "10.0.0.0/8"] + allowed_tokens: ["my-secret-token"] + ``` +- [x] Fichier d'état — Persistance de l'état up/down des health checks + ```yaml + state: + file: "/var/lib/ntfy-bridge/state.json" + ``` + - Sauvegarde `poll_state` (up/down) + `silence_until` + - Restauré au démarrage, persisté à chaque changement +- [x] Plugin system — Sources customisables via executables externes + ```yaml + plugin: + - name: "Disk space check" + topic: daily + command: /etc/ntfy-bridge/plugins/disk-check.sh + timeout: 10 + ``` + - Contrat simple : stdin (optionnel) → stdout JSON + - Exit 0 = notif, exit ≠ 0 = skip + - Compatible bash, Python, ou n'importe quel langage + +## Fonctionnalités non planifiées mais implémentées + +Ces features ont été ajoutées en cours de route : + +| Feature | Fichier | Description | +|---------|---------|-------------| +| 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 | +| 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 | +| 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 | +| Flag `-V` (version) | `main.v` | Version courte en plus de `--version` | +| API endpoints | `server.v` | `/api/config`, `/api/status`, `/api/history` pour le dashboard | + +## Suivi des versions + +| 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) | diff --git a/main.v b/main.v index 42b6023..c7c12ab 100644 --- a/main.v +++ b/main.v @@ -3,7 +3,7 @@ module main import os import flag -const version = '0.5.0' +const version = '0.6.0' const author = 'Bruno Charest' fn main() { @@ -220,6 +220,10 @@ fn print_startup_info(cfg Config) { if generic_count > 0 { 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(' Webhook URLs:') diff --git a/ntfy-bridge.example.yaml b/ntfy-bridge.example.yaml index 1c773ba..7cf8660 100644 --- a/ntfy-bridge.example.yaml +++ b/ntfy-bridge.example.yaml @@ -1,144 +1,176 @@ -# ntfy-bridge configuration -# ============================ - -# ── Server ──────────────────────────────────────────── -server: - # URL de ton serveur Ntfy - url: https://ntfy.dracodev.net - - # Token d'authentification (optionnel, si ton serveur Ntfy a auth activée) - # Peut aussi être passé via variable d'environnement NTFY_TOKEN - # auth_token: tk_xxxxxxxxxxxx - - # Adresse d'écoute du serveur HTTP interne - # Mettre "0.0.0.0:9090" pour exposer réseau, "127.0.0.1:9090" pour localhost - listen: "127.0.0.1:9090" - - # Secret partagé pour validation HMAC-SHA256 des webhooks (optionnel) - # 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 - # hmac_secret: "mon-secret-partage" - -# ── Defaults ────────────────────────────────────────── -# Appliqués à toutes les sources sauf override explicite -defaults: - priority: 3 # 1=min, 2=low, 3=default, 4=high, 5=urgent - tags: ["loudspeaker"] # Tags/emojis Ntfy - -# ── Sources ─────────────────────────────────────────── -sources: - # ═══════════════════════════════════════════════════ - # GITEA — webhooks depuis git.dracodev.net - # ═══════════════════════════════════════════════════ - gitea: - - name: "FlowDeck activity" - webhook_path: /webhooks/gitea-flowdeck - repo: bruno/flowdeck - topic: dev-notifs - priority_map: - pull_request: 4 - issue: 3 - push: 2 - # Template de message (variables dispo: {repo}, {user}, {action}, {title}, {sha}) - template: | - 🔨 **[{repo}]** {user} {action}: "{title}" - `{sha}` - - - name: "ObsiGate activity" - webhook_path: /webhooks/gitea-obsigate - repo: bruno/obsigate - topic: dev-notifs - - # ═══════════════════════════════════════════════════ - # UPTIME KUMA — alertes de monitoring - # ═══════════════════════════════════════════════════ - uptime_kuma: - - name: "Services critiques" - webhook_path: /webhooks/kuma-critical - topic: alerts - state_map: - down: { priority: 5, tags: ["rotating_light", "x"] } - up: { priority: 1, tags: ["white_check_mark"] } - - # ═══════════════════════════════════════════════════ - # DOCKER — surveillance des conteneurs - # ═══════════════════════════════════════════════════ - docker: - - name: "Production containers" - hosts: - - unix:///var/run/docker.sock - # Hôtes distants: - # - tcp://192.168.30.101:2375 - # - tcp://192.168.30.20:2375 - events: [die, health_status, oom] - topic: infra - template: | - 🐳 **{container_name}** → {status} - Image: `{image}` - Exit code: {exit_code} - Host: {host} - - # ═══════════════════════════════════════════════════ - # HTTP POLL — health checks périodiques - # ═══════════════════════════════════════════════════ - http_poll: - - name: "Health endpoints" - interval: 60 # secondes entre chaque check - checks: - - url: https://og.dracodev.net/health - topic: alerts - priority: 5 # priorité si DOWN - expect_status: 200 - timeout: 10 # secondes - - - url: https://flowdeck.dracodev.net/health - topic: alerts - priority: 5 - expect_status: 200 - timeout: 10 - - - url: https://git.dracodev.net/api/v1/version - topic: alerts - priority: 5 - expect_status: 200 - timeout: 10 - - - url: http://raspi.8gb.home:3001/ping - topic: alerts - priority: 5 # Uptime Kuma lui-même — critique - expect_status: 200 - timeout: 10 - - - url: http://raspi.8gb.home:9119/api/status - topic: alerts - priority: 5 - expect_status: 200 - timeout: 10 - - # ═══════════════════════════════════════════════════ - # CRON — reçoit des notifs depuis des scripts shell - # ═══════════════════════════════════════════════════ - cron: - - name: "Disk usage quotidien" - webhook_path: /webhooks/cron-disk - topic: daily - - - name: "Backup report" - webhook_path: /webhooks/cron-backup - topic: daily - - # ═══════════════════════════════════════════════════ - # GENERIC — webhook passe-partout - # ═══════════════════════════════════════════════════ - generic: - - name: "Custom alerts" - webhook_path: /webhooks/generic - topic: custom - -# ── 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 +# ntfy-bridge configuration +# ============================ + +# ── Server ──────────────────────────────────────────── +server: + # URL de ton serveur Ntfy + url: https://ntfy.dracodev.net + + # Token d'authentification (optionnel, si ton serveur Ntfy a auth activée) + # Peut aussi être passé via variable d'environnement NTFY_TOKEN + # auth_token: tk_xxxxxxxxxxxx + + # Adresse d'écoute du serveur HTTP interne + # Mettre "0.0.0.0:9090" pour exposer réseau, "127.0.0.1:9090" pour localhost + listen: "127.0.0.1:9090" + + # Secret partagé pour validation HMAC-SHA256 des webhooks (optionnel) + # 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 + # hmac_secret: "mon-secret-partage" + +# ── Defaults ────────────────────────────────────────── +# Appliqués à toutes les sources sauf override explicite +defaults: + priority: 3 # 1=min, 2=low, 3=default, 4=high, 5=urgent + tags: ["loudspeaker"] # Tags/emojis Ntfy + +# ── Sources ─────────────────────────────────────────── +sources: + # ═══════════════════════════════════════════════════ + # GITEA — webhooks depuis git.dracodev.net + # ═══════════════════════════════════════════════════ + gitea: + - name: "FlowDeck activity" + webhook_path: /webhooks/gitea-flowdeck + repo: bruno/flowdeck + topic: dev-notifs + priority_map: + pull_request: 4 + issue: 3 + push: 2 + # Template de message (variables dispo: {repo}, {user}, {action}, {title}, {sha}) + template: | + 🔨 **[{repo}]** {user} {action}: "{title}" + `{sha}` + + - name: "ObsiGate activity" + webhook_path: /webhooks/gitea-obsigate + repo: bruno/obsigate + topic: dev-notifs + + # ═══════════════════════════════════════════════════ + # UPTIME KUMA — alertes de monitoring + # ═══════════════════════════════════════════════════ + uptime_kuma: + - name: "Services critiques" + webhook_path: /webhooks/kuma-critical + topic: alerts + state_map: + down: { priority: 5, tags: ["rotating_light", "x"] } + up: { priority: 1, tags: ["white_check_mark"] } + + # ═══════════════════════════════════════════════════ + # DOCKER — surveillance des conteneurs + # ═══════════════════════════════════════════════════ + docker: + - name: "Production containers" + hosts: + - unix:///var/run/docker.sock + # Hôtes distants: + # - tcp://192.168.30.101:2375 + # - tcp://192.168.30.20:2375 + events: [die, health_status, oom] + topic: infra + template: | + 🐳 **{container_name}** → {status} + Image: `{image}` + Exit code: {exit_code} + Host: {host} + + # ═══════════════════════════════════════════════════ + # HTTP POLL — health checks périodiques + # ═══════════════════════════════════════════════════ + http_poll: + - name: "Health endpoints" + interval: 60 # secondes entre chaque check + checks: + - url: https://og.dracodev.net/health + topic: alerts + priority: 5 # priorité si DOWN + expect_status: 200 + timeout: 10 # secondes + + - url: https://flowdeck.dracodev.net/health + topic: alerts + priority: 5 + expect_status: 200 + timeout: 10 + + - url: https://git.dracodev.net/api/v1/version + topic: alerts + priority: 5 + expect_status: 200 + timeout: 10 + + - url: http://raspi.8gb.home:3001/ping + topic: alerts + priority: 5 # Uptime Kuma lui-même — critique + expect_status: 200 + timeout: 10 + + - url: http://raspi.8gb.home:9119/api/status + topic: alerts + priority: 5 + expect_status: 200 + timeout: 10 + + # ═══════════════════════════════════════════════════ + # CRON — reçoit des notifs depuis des scripts shell + # ═══════════════════════════════════════════════════ + cron: + - name: "Disk usage quotidien" + webhook_path: /webhooks/cron-disk + topic: daily + + - name: "Backup report" + webhook_path: /webhooks/cron-backup + topic: daily + + # ═══════════════════════════════════════════════════ + # GENERIC — webhook passe-partout + # ═══════════════════════════════════════════════════ + generic: + - name: "Custom alerts" + webhook_path: /webhooks/generic + topic: custom + +# ── ACLs (Access Control Lists) ───────────────────────── +# Optionnel — restreint l'accès à certains webhooks par IP ou token. +# Si aucune ACL n'est définie, le webhook est ouvert (soumis au HMAC). +# +# Exemple avec ACL IP: +# gitea: +# - 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 diff --git a/scripts/example-plugin-disk.sh b/scripts/example-plugin-disk.sh new file mode 100644 index 0000000..e1c9f7e --- /dev/null +++ b/scripts/example-plugin-disk.sh @@ -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 < 0 { spawn app.docker_watch_loop() } + if app.cfg.sources.plugin.len > 0 { + spawn app.plugin_loop() + } // Grouping flush goroutine 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['cron_sources'] = app.cfg.sources.cron.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)) } @@ -288,6 +292,14 @@ fn (mut app App) handle_webhook(req http.Request) http.Response { 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) if app.cfg.server.hmac_secret != '' { 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] 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 { @@ -596,7 +612,191 @@ fn (mut app App) flush_group_buffer() { // v0.5 — Dispatch to outgoing webhooks app.dispatch_outgoing(e) + + // v0.6 — Save state after each flush + app.save_state() } app.group_buffer = map[string][]GroupEntry{} 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) + } +} diff --git a/sources/event.v b/sources/event.v index c081240..021509b 100644 --- a/sources/event.v +++ b/sources/event.v @@ -35,6 +35,13 @@ pub: 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 mut: name string @[json: 'name'] @@ -44,6 +51,7 @@ pub mut: priority_map map[string]int @[json: 'priority_map'] tags []string @[json: 'tags'] repo string @[json: 'repo'] + acl AclConfig @[json: 'acl'] } pub struct UptimeKumaState { @@ -61,6 +69,7 @@ pub mut: priority_map map[string]int @[json: 'priority_map'] tags []string @[json: 'tags'] state_map map[string]UptimeKumaState @[json: 'state_map'] + acl AclConfig @[json: 'acl'] } pub struct DockerSource { @@ -99,6 +108,7 @@ pub mut: template string @[json: 'template'] priority_map map[string]int @[json: 'priority_map'] tags []string @[json: 'tags'] + acl AclConfig @[json: 'acl'] } pub struct GenericSource { @@ -109,6 +119,17 @@ pub mut: template string @[json: 'template'] priority_map map[string]int @[json: 'priority_map'] 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. diff --git a/sources/plugin.v b/sources/plugin.v new file mode 100644 index 0000000..b978e74 --- /dev/null +++ b/sources/plugin.v @@ -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 + } +} diff --git a/webhook.v b/webhook.v index 62742e7..faf1621 100644 --- a/webhook.v +++ b/webhook.v @@ -6,6 +6,7 @@ pub enum SourceKind { uptime_kuma cron generic + plugin } pub struct WebhookRoute { @@ -42,6 +43,11 @@ pub fn build_webhook_routes(cfg Config) map[string]WebhookRoute { 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 }