feat: phase 3 + phase 4 complete — v0.4.0
CI / test (push) Failing after 24s

Phase 3:
- Config reload via SIGHUP (handle_signals loop: .hup reloads config, .int/.term shutdown)
- Hot reload: cfg, webhook_routes, ntfy client, dedup updated without restart

Phase 4:
- Silence rules: POST /api/silence?duration=30m, GET /api/silence, DELETE /api/silence
- Grouping: buffer 10s, events grouped with (×N in 10s) suffix
- CI/CD: .gitea/workflows/ci.yml (build + test on push/PR)

Docs:
- ROADMAP.md: phases 3 & 4 marked DONE, version table updated
- ARCHITECTURE.md: synced with actual code

Version: 0.1.0 → 0.4.0 (main.v, v.mod)
This commit is contained in:
2026-08-03 14:35:59 -04:00
parent 4e1d7cd94a
commit 72b797704f
6 changed files with 503 additions and 200 deletions
+135 -106
View File
@@ -2,14 +2,15 @@
## Philosophie
ntfy-bridge est un **daemon HTTP léger** écrit en V (~500 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 (~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
- **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 = implémenter un module dans `sources/`
- **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
@@ -18,9 +19,9 @@ ntfy-bridge est un **daemon HTTP léger** écrit en V (~500 lignes) qui compile
│ ntfy-bridge │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron │ │
│ │ Server │ │ Watcher │ │ Poller │ │ Receiver │ │
│ │ :9090 │ │ goroutine│ │ goroutine│ │ /webhooks │ │
│ │ HTTP │ │ Docker │ │ HTTP │ │ Cron/ │ │
│ │ Server │ │ Watcher │ │ Poller │ │ Generic │ │
│ │ :9090 │ │ goroutine│ │ goroutine│ │ Receiver │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ └──────────────┼──────────────┼──────────────┘ │
@@ -34,49 +35,53 @@ ntfy-bridge est un **daemon HTTP léger** écrit en V (~500 lignes) qui compile
│ └────────────┬─────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Ntfy Publisher │ │
│ │ Ntfy Publisher (retry) │ │
│ │ POST /<topic> │ │
│ │ 3 tentatives, backoff │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Flux de données
### 1. Webhook entrant (Gitea, Uptime Kuma, generic)
### 1. Webhook entrant (Gitea, Uptime Kuma, Cron, Generic)
```
POST /webhooks/gitea-flowdeck
│
▼
1. Verify signature (optionnel)
2. Parse JSON → Event struct
3. Route vers le transformer approprié
4. Appliquer priority_map
5. Appliquer template de message
6. POST vers Ntfy
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 DockerWatcher
goroutine docker_watch_loop (via start_background_tasks)
│
▼
1. Connexion au socket Docker (/var/run/docker.sock)
2. Stream events via GET /events?filters=...
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
4. Extraire container_name, image, exit_code via transform_docker_event
5. Appliquer template
6. POST vers Ntfy
6. Pipeline publish → Ntfy
```
### 3. HTTP Poller
```
goroutine HttpPoller (toutes les N secondes)
goroutine http_poll_loop (via start_background_tasks, toutes les N secondes)
│
▼
1. GET <url> avec timeout configuré
2. Si status ≠ expected → Event(priority=5)
3. Si OK et précédemment DOWN → Event(priority=1, tags="white_check_mark")
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
@@ -85,69 +90,74 @@ POST /webhooks/cron-disk
│
▼
1. Body = texte brut du script cron
2. Pass-through vers Ntfy
2. Pass-through vers Ntfy (transform_cron → Event)
3. Le script cron contrôle son propre format
```
## Modèle de données
```v
struct Config { // dans config.v
server ServerConfig
defaults DefaultConfig
sources SourcesConfig
}
// config.v
struct ServerConfig {
url string // URL du serveur Ntfy
auth_token string // Token d'auth (optionnel)
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 DefaultConfig {
priority int // 1-5
tags []string // ["loudspeaker"]
struct DedupConfig {
enabled bool
ttl_seconds int // TTL du cache de déduplication
rate_limit RateLimitConfig // max_per_minute, max_per_source
}
struct Source { // types spécifiques dans sources/event.v
name string
webhook_path string
topic string
template string // Go-like template
priority_map map[string]int
tags []string
}
struct Event { // dans sources/event.v
source string // "gitea", "docker", "uptime_kuma", "http_poll", "cron"
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)
// 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 implémente l'interface :
Chaque source a une **free function** dans `sources/` :
```v
interface Transformer {
transform(raw string, source &Source) ?Event
}
// 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 |
|---|---|---|
| `gitea_transformer` | JSON webhook Gitea | `🔨 [FlowDeck] bruno pushed to main: "Fix bug" (a3f2c1d)` |
| `uptime_kuma_transformer` | JSON webhook Kuma | `🚨 Gitea is DOWN — 503 — since 14:32` |
| `docker_transformer` | Docker event JSON | `🐳 flowdeck exited OOMKilled on docker-prod-1` |
| `http_poll_transformer` | HTTP response | `❌ obsigate.dracodev.net/health → timeout 30s` |
| `generic_transformer` | Raw body | Pass-through, pas de transformation |
| `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
@@ -157,64 +167,80 @@ interface Transformer {
| 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 (si configuré) |
| 1 (min) | Debug, heartbeat | Health check OK |
## Déduplication et rate limiting
- **Déduplication** : même source + même type d'événement + même cible → cooldown 5 minutes
- **Rate limiting** : max 10 notifs/minute/topic (configurable)
- **Uptime Kuma spécifique** : les "down" répétés dans un intervalle de 2 min sont dédupliqués
- **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
├── ARCHITECTURE.md # Ce document
├── ROADMAP.md # Phases de développement
├── CONTRIBUTING.md # Guide de contribution
├── 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
├── config.v # Chargement + validation YAML
├── server.v # HTTP server + routing webhooks
├── ntfy.v # Client HTTP Ntfy (POST)
├── dedup.v # Déduplication + rate limiting
├── template.v # Mini moteur de template
├── log.v # Logging structuré
├── docker_watcher.v # Watcher socket Docker (Unix)
├── 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
│ ├── gitea.v # Transformer Gitea
│ ├── uptime_kuma.v # Transformer Uptime Kuma
│ ├── 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
│ ├── generic.v # Webhook passe-partout
│ └── cron.v # Webhook cron
├── *_test.v # Tests unitaires
│ ├── 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
└── v.mod # Dépendances V (aucune dépendance externe)
```
## Dépendances V (v.mod)
## Dépendances
```v
// v.mod
Module {
name: 'ntfy-bridge'
description: 'Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy'
version: '0.1.0'
deps: [
'ui-driver.cua'
]
dependencies: [] // zéro dépendance externe
}
```
Dépendances externes minimales :
- `vsl` — parsing YAML (sinon implémenter un parseur minimal)
- `x.net` — client HTTP (ou lib standard V `net`)
- `json` — stdlib V pour le parsing JSON
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`)
En réalité, V a tout dans sa stdlib : `net.http`, `json`, `time`, `os`, `flag`. Le projet vise **zéro dépendance externe**.
## 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
@@ -226,7 +252,7 @@ Description=ntfy-bridge notification hub
After=network.target
[Service]
ExecStart=/usr/local/bin/ntfy-bridge --config /etc/ntfy-bridge.yaml
ExecStart=/usr/local/bin/ntfy-bridge --quiet --config /etc/ntfy-bridge.yaml
Restart=always
RestartSec=10
@@ -238,29 +264,32 @@ WantedBy=multi-user.target
```dockerfile
FROM alpine:latest
COPY ntfy-bridge /usr/local/bin/
COPY ntfy-bridge.yaml /etc/
COPY ntfy-bridge.yaml dashboard.html /etc/ntfy-bridge/
WORKDIR /etc/ntfy-bridge
EXPOSE 9090
CMD ["/usr/local/bin/ntfy-bridge", "--config", "/etc/ntfy-bridge.yaml"]
CMD ["/usr/local/bin/ntfy-bridge", "--quiet", "--config", "/etc/ntfy-bridge/ntfy-bridge.yaml"]
```
### Option C : Compilation croisée
```bash
v -os windows . -o ntfy-bridge.exe # Pour Windows
v -os linux . -o ntfy-bridge # Pour Linux
v -os macos . -o ntfy-bridge-mac # Pour macOS
v -prod -os windows . -o ntfy-bridge.exe
v -prod -os linux . -o ntfy-bridge
```
## Sécurité
- **Signature webhooks** : vérification HMAC-SHA256 optionnelle pour Gitea
- **Auth token** : token optionnel pour le serveur Ntfy (si auth activée)
- **Pas d'exposition externe** : le serveur HTTP écoute sur localhost par défaut (:9090)
- **Rate limiting interne** : max 10 notifs/minute/source pour éviter le spam
- **Input validation** : tout JSON entrant est validé avant processing
- **No secrets in config** : le token Ntfy peut être passé via variable d'environnement `NTFY_TOKEN`
- **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, latence Ntfy
- **Logging** : stdout en JSON structuré (timestamp, level, source, event, error)
- **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)