Add config validation and dashboard

Add YAML config loading with validation, env overrides for secrets, and
a web dashboard exposing health, stats, and recent events.
This commit is contained in:
2026-08-03 12:12:12 -04:00
parent 3f48912ad7
commit 4e1d7cd94a
29 changed files with 2344 additions and 55 deletions
+3
View File
@@ -23,6 +23,9 @@ ntfy-bridge.yaml
*.swp
*.swo
# Windows build artifacts
*.def
# OS
.DS_Store
Thumbs.db
+152
View File
@@ -0,0 +1,152 @@
module main
import os
import yaml
import sources
pub struct ServerConfig {
pub mut:
url string @[json: 'url']
auth_token string @[json: 'auth_token']
listen string @[json: 'listen']
hmac_secret string @[json: 'hmac_secret'] // shared secret for webhook HMAC-SHA256 validation
}
pub struct DefaultConfig {
pub mut:
priority int @[json: 'priority']
tags []string @[json: 'tags']
}
pub struct RateLimitConfig {
pub mut:
max_per_minute int @[json: 'max_per_minute']
max_per_source int @[json: 'max_per_source']
}
pub struct DedupConfig {
pub mut:
enabled bool @[json: 'enabled']
ttl_seconds int @[json: 'ttl_seconds']
rate_limit RateLimitConfig @[json: 'rate_limit']
}
pub struct SourcesConfig {
pub mut:
gitea []sources.GiteaSource @[json: 'gitea']
uptime_kuma []sources.UptimeKumaSource @[json: 'uptime_kuma']
docker []sources.DockerSource @[json: 'docker']
http_poll []sources.HttpPollSource @[json: 'http_poll']
cron []sources.CronSource @[json: 'cron']
generic []sources.GenericSource @[json: 'generic']
}
pub struct Config {
pub mut:
server ServerConfig @[json: 'server']
defaults DefaultConfig @[json: 'defaults']
sources SourcesConfig @[json: 'sources']
dedup DedupConfig @[json: 'dedup']
}
pub fn load_config(path string) !Config {
if !os.exists(path) {
return error('config file not found: ${path}')
}
mut cfg := yaml.decode_file[Config](path)!
// Env override for auth token
env_token := os.getenv('NTFY_TOKEN')
if env_token != '' {
cfg.server.auth_token = env_token
}
// Env override for HMAC secret
env_hmac := os.getenv('NTFY_HMAC_SECRET')
if env_hmac != '' {
cfg.server.hmac_secret = env_hmac
}
validate_config(cfg)!
return cfg
}
pub fn validate_config(cfg Config) ! {
if cfg.server.url == '' {
return error('server.url is required')
}
if cfg.server.listen == '' {
return error('server.listen is required')
}
if cfg.defaults.priority < 1 || cfg.defaults.priority > 5 {
return error('defaults.priority must be between 1 and 5')
}
// Collect all webhook paths and detect duplicates
mut seen_paths := map[string]string{} // path -> source description
for src in cfg.sources.gitea {
if src.webhook_path == '' {
return error('gitea source "${src.name}" missing webhook_path')
}
if src.topic == '' {
return error('gitea source "${src.name}" missing topic')
}
check_duplicate_path(mut seen_paths, src.webhook_path, 'gitea:${src.name}')!
}
for src in cfg.sources.uptime_kuma {
if src.webhook_path == '' {
return error('uptime_kuma source "${src.name}" missing webhook_path')
}
if src.topic == '' {
return error('uptime_kuma source "${src.name}" missing topic')
}
check_duplicate_path(mut seen_paths, src.webhook_path, 'uptime_kuma:${src.name}')!
}
for src in cfg.sources.docker {
if src.topic == '' {
return error('docker source "${src.name}" missing topic')
}
if src.hosts.len == 0 {
return error('docker source "${src.name}" requires at least one host')
}
}
for src in cfg.sources.http_poll {
if src.interval <= 0 {
return error('http_poll source "${src.name}" interval must be > 0')
}
for chk in src.checks {
if chk.url == '' {
return error('http_poll check missing url')
}
if chk.topic == '' {
return error('http_poll check for "${chk.url}" missing topic')
}
}
}
for src in cfg.sources.cron {
if src.webhook_path == '' {
return error('cron source "${src.name}" missing webhook_path')
}
if src.topic == '' {
return error('cron source "${src.name}" missing topic')
}
check_duplicate_path(mut seen_paths, src.webhook_path, 'cron:${src.name}')!
}
for src in cfg.sources.generic {
if src.webhook_path == '' {
return error('generic source "${src.name}" missing webhook_path')
}
if src.topic == '' {
return error('generic source "${src.name}" missing topic')
}
check_duplicate_path(mut seen_paths, src.webhook_path, 'generic:${src.name}')!
}
}
fn check_duplicate_path(mut seen map[string]string, path string, source_name string) ! {
if existing := seen[path] {
return error('duplicate webhook path "${path}" used by ${existing} and ${source_name}')
}
seen[path] = source_name
}
+63
View File
@@ -0,0 +1,63 @@
module main
import sources
fn test_validate_minimal_config() {
cfg := Config{
server: ServerConfig{
url: 'https://ntfy.example.com'
listen: ':9090'
}
defaults: DefaultConfig{
priority: 3
tags: ['loudspeaker']
}
sources: SourcesConfig{
generic: [
sources.GenericSource{
name: 'test'
webhook_path: '/webhooks/test'
topic: 'test'
},
]
}
dedup: DedupConfig{
enabled: true
ttl_seconds: 300
rate_limit: RateLimitConfig{
max_per_minute: 10
max_per_source: 30
}
}
}
validate_config(cfg) or { panic(err) }
}
fn test_validate_missing_url() {
cfg := Config{
server: ServerConfig{
listen: ':9090'
}
defaults: DefaultConfig{
priority: 3
}
}
if _ := validate_config(cfg) {
panic('expected validation to fail')
}
}
fn test_validate_invalid_priority() {
cfg := Config{
server: ServerConfig{
url: 'https://ntfy.example.com'
listen: ':9090'
}
defaults: DefaultConfig{
priority: 10
}
}
if _ := validate_config(cfg) {
panic('expected validation to fail')
}
}
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ntfy-bridge Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
header{background:#1e293b;border-bottom:1px solid #334155;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:1.25rem;font-weight:600;color:#f1f5f9}
header .badge{background:#16a34a;color:#fff;padding:4px 12px;border-radius:12px;font-size:.75rem;font-weight:600}
.container{max-width:1200px;margin:0 auto;padding:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px;margin-bottom:24px}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:20px}
.card h2{font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.card h2 .icon{font-size:1.1rem}
.stat-value{font-size:2rem;font-weight:700;color:#f1f5f9;line-height:1.2}
.stat-label{font-size:.75rem;color:#64748b;margin-top:4px}
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px}
.status-up{background:#16a34a}
.status-down{background:#dc2626}
.status-item{display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #334155;font-size:.875rem}
.status-item:last-child{border-bottom:none}
.config-row{display:flex;justify-content:space-between;padding:6px 0;font-size:.8125rem;border-bottom:1px solid #1e293b}
.config-row:last-child{border-bottom:none}
.config-key{color:#94a3b8}
.config-val{color:#e2e8f0;font-weight:500}
.history-item{padding:10px 0;border-bottom:1px solid #334155;font-size:.8125rem}
.history-item:last-child{border-bottom:none}
.history-meta{display:flex;gap:12px;color:#64748b;margin-bottom:4px;flex-wrap:wrap}
.history-msg{color:#e2e8f0;word-break:break-all}
.priority-tag{display:inline-block;padding:1px 6px;border-radius:4px;font-size:.6875rem;font-weight:700}
.p5{background:#7f1d1d;color:#fca5a5}
.p4{background:#78350f;color:#fcd34d}
.p3{background:#1e3a5f;color:#93c5fd}
.p2{background:#1e293b;color:#94a3b8}
.p1{background:#0f172a;color:#64748b}
.no-data{color:#64748b;font-style:italic;text-align:center;padding:24px 0}
.tabs{display:flex;gap:4px;margin-bottom:16px;background:#0f172a;border-radius:6px;padding:4px}
.tab{padding:6px 16px;border-radius:4px;font-size:.8125rem;cursor:pointer;color:#94a3b8;border:none;background:none}
.tab.active{background:#334155;color:#f1f5f9}
.tab-content{display:none}
.tab-content.active{display:block}
footer{text-align:center;padding:24px;color:#475569;font-size:.75rem}
</style>
</head>
<body>
<header>
<h1>🔔 ntfy-bridge</h1>
<span class="badge" id="uptime-badge">LIVE</span>
</header>
<div class="container">
<div class="grid">
<div class="card">
<h2><span class="icon">📊</span> Notifications</h2>
<div class="stat-grid">
<div><div class="stat-value" id="total-notif">—</div><div class="stat-label">Total</div></div>
<div><div class="stat-value" id="total-errors">—</div><div class="stat-label">Errors</div></div>
</div>
</div>
<div class="card">
<h2><span class="icon">⏱️</span> Uptime</h2>
<div class="stat-value" id="uptime">—</div>
<div class="stat-label" id="uptime-human">Server running</div>
</div>
<div class="card">
<h2><span class="icon">🔗</span> Ntfy Server</h2>
<div class="stat-value" style="font-size:1rem" id="ntfy-url">—</div>
<div class="stat-label">Target</div>
</div>
</div>
<div class="grid">
<div class="card" style="grid-column:span 2">
<h2><span class="icon">📋</span> Details</h2>
<div class="tabs">
<button class="tab active" onclick="switchTab('status')">Service Status</button>
<button class="tab" onclick="switchTab('config')">Configuration</button>
<button class="tab" onclick="switchTab('history')">Recent History</button>
</div>
<div class="tab-content active" id="tab-status">
<div id="status-list" class="no-data">Loading...</div>
</div>
<div class="tab-content" id="tab-config">
<div id="config-list" class="no-data">Loading...</div>
</div>
<div class="tab-content" id="tab-history">
<div id="history-list" class="no-data">Loading...</div>
</div>
</div>
</div>
</div>
<footer>ntfy-bridge v0.1.0 · Homelab Notification Hub</footer>
<script>
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
var sel = '.tab[onclick*="switchTab(\'' + name + '\')"]';
document.querySelector(sel).classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
async function fetchJSON(url) {
var r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return r.json();
}
function fmtUptime(s) {
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h > 24) { var d = Math.floor(h / 24); return d + 'd ' + (h % 24) + 'h'; }
return h + 'h ' + m + 'm';
}
async function load() {
try {
var health = await fetchJSON('/health');
document.getElementById('total-notif').textContent = health.total_notifications;
document.getElementById('uptime').textContent = fmtUptime(health.uptime_seconds);
document.getElementById('uptime-human').textContent = 'since ' + new Date(Date.now() - health.uptime_seconds * 1000).toLocaleString();
var stats = await fetchJSON('/stats');
document.getElementById('total-errors').textContent = stats.errors;
var config = await fetchJSON('/api/config');
document.getElementById('ntfy-url').textContent = config.server_url;
var statusData = await fetchJSON('/api/status');
var sl = document.getElementById('status-list');
if (!statusData || statusData.length === 0) {
sl.innerHTML = '<div class=no-data>No HTTP poll checks configured</div>';
} else {
sl.innerHTML = statusData.map(function(s) {
var cls = s.status === 'up' ? 'status-up' : 'status-down';
return '<div class=status-item><span class="status-dot ' + cls + '"></span>' + s.name + ' &mdash; <strong>' + s.status.toUpperCase() + '</strong></div>';
}).join('');
}
var cl = document.getElementById('config-list');
cl.innerHTML = Object.entries(config).map(function(e) {
var k = e[0], v = e[1];
return '<div class=config-row><span class=config-key>' + k.replace(/_/g, ' ') + '</span><span class=config-val>' + v + '</span></div>';
}).join('');
var history = await fetchJSON('/api/history');
var hl = document.getElementById('history-list');
if (!history || history.length === 0) {
hl.innerHTML = '<div class=no-data>No events yet</div>';
} else {
hl.innerHTML = history.slice().reverse().map(function(e) {
var pcls = 'p' + e.priority;
var ts = new Date(e.timestamp).toLocaleTimeString();
var safeMsg = e.message.replace(/</g, '&lt;').replace(/>/g, '&gt;').substring(0, 150);
return '<div class=history-item><div class=history-meta><span>' + ts + '</span><span>' + e.source + ':' + e.name + '</span><span class="priority-tag ' + pcls + '">P' + e.priority + '</span><span> &rarr; ' + e.topic + '</span></div><div class=history-msg>' + safeMsg + '</div></div>';
}).join('');
}
} catch (e) {
console.error(e);
}
}
load();
setInterval(load, 15000);
</script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
module main
import time
import sources
pub struct DedupEntry {
pub mut:
last_seen time.Time
count int
}
pub struct Dedup {
pub mut:
enabled bool
ttl_seconds int
max_per_minute int
max_per_source int
cache map[string]DedupEntry
topic_history map[string][]time.Time
source_history map[string][]time.Time
}
pub fn new_dedup(cfg DedupConfig) Dedup {
return Dedup{
enabled: cfg.enabled
ttl_seconds: cfg.ttl_seconds
max_per_minute: cfg.rate_limit.max_per_minute
max_per_source: cfg.rate_limit.max_per_source
cache: map[string]DedupEntry{}
topic_history: map[string][]time.Time{}
source_history: map[string][]time.Time{}
}
}
fn simple_hash(s string) u64 {
mut h := u64(14695981039346656037)
for c in s.bytes() {
h ^= u64(c)
h *= u64(1099511628211)
}
return h
}
fn dedup_key(event sources.Event) string {
h := simple_hash(event.message)
return '${event.source}:${event.name}:${event.topic}:${h}'
}
fn (mut d Dedup) cleanup(now time.Time) {
now_unix := now.unix()
for key, entry in d.cache {
if now_unix - entry.last_seen.unix() > d.ttl_seconds {
d.cache.delete(key)
}
}
for topic, times in d.topic_history {
mut kept := []time.Time{}
for t in times {
if now_unix - t.unix() <= 60 {
kept << t
}
}
if kept.len == 0 {
d.topic_history.delete(topic)
} else {
d.topic_history[topic] = kept
}
}
for source, times in d.source_history {
mut kept := []time.Time{}
for t in times {
if now_unix - t.unix() <= 60 {
kept << t
}
}
if kept.len == 0 {
d.source_history.delete(source)
} else {
d.source_history[source] = kept
}
}
}
pub fn (mut d Dedup) allow(event sources.Event) bool {
if !d.enabled {
return true
}
now := time.now()
d.cleanup(now)
// Rate limit per topic
topic_times := d.topic_history[event.topic] or { []time.Time{} }
if topic_times.len >= d.max_per_minute && d.max_per_minute > 0 {
return false
}
// Rate limit per source
source_key := '${event.source}:${event.name}'
source_times := d.source_history[source_key] or { []time.Time{} }
if source_times.len >= d.max_per_source && d.max_per_source > 0 {
return false
}
// Dedup check
key := dedup_key(event)
entry := d.cache[key] or { DedupEntry{} }
if entry.count > 0 {
d.cache[key] = DedupEntry{
last_seen: now
count: entry.count + 1
}
return false
}
d.cache[key] = DedupEntry{
last_seen: now
count: 1
}
mut new_topic_times := topic_times.clone()
new_topic_times << now
d.topic_history[event.topic] = new_topic_times
mut new_source_times := source_times.clone()
new_source_times << now
d.source_history[source_key] = new_source_times
return true
}
+59
View File
@@ -0,0 +1,59 @@
module main
import sources
fn test_dedup_allows_first_event() {
mut d := new_dedup(DedupConfig{
enabled: true
ttl_seconds: 300
rate_limit: RateLimitConfig{
max_per_minute: 10
max_per_source: 30
}
})
event := sources.Event{
source: 'generic'
name: 'test'
topic: 'test'
message: 'hello'
}
assert d.allow(event) == true
}
fn test_dedup_blocks_duplicate() {
mut d := new_dedup(DedupConfig{
enabled: true
ttl_seconds: 300
rate_limit: RateLimitConfig{
max_per_minute: 10
max_per_source: 30
}
})
event := sources.Event{
source: 'generic'
name: 'test'
topic: 'test'
message: 'hello'
}
assert d.allow(event) == true
assert d.allow(event) == false
}
fn test_dedup_disabled() {
mut d := new_dedup(DedupConfig{
enabled: false
ttl_seconds: 300
rate_limit: RateLimitConfig{
max_per_minute: 10
max_per_source: 30
}
})
event := sources.Event{
source: 'generic'
name: 'test'
topic: 'test'
message: 'hello'
}
assert d.allow(event) == true
assert d.allow(event) == true
}
+75
View File
@@ -0,0 +1,75 @@
module main
import time
$if linux || macos {
import net.unix
import os
}
import sources as _
fn (mut app App) docker_watch_loop() {
$if linux || macos {
for {
for src in app.cfg.sources.docker {
for host in src.hosts {
app.watch_docker_host(src, host)
}
}
time.sleep(10 * time.second)
}
} $else {
app.logger.warn('docker', 'watch',
'Docker socket watching is only supported on Linux/macOS')
for {
time.sleep(60 * time.second)
}
}
}
$if linux || macos {
fn (mut app App) watch_docker_host(src _.DockerSource, host string) {
socket_path := host.replace('unix://', '')
if !os.exists(socket_path) {
app.logger.warn('docker', 'watch', 'socket not found: ${socket_path}')
return
}
mut conn := unix.connect_stream(socket_path) or {
app.logger.warn('docker', 'watch', 'failed to connect to ${socket_path}: ${err}')
return
}
defer {
conn.close() or {}
}
filters := build_docker_filters(src.events)
request := 'GET /events?filters=${filters} HTTP/1.1\r\nHost: localhost\r\n\r\n'
conn.write_string(request) or {
app.logger.warn('docker', 'watch', 'write error: ${err}')
return
}
mut buf := []u8{len: 4096}
for {
read := conn.read(mut buf) or { break }
if read <= 0 {
break
}
lines := buf[..read].bytestr().split_into_lines()
for line in lines {
if line.starts_with('{') {
event := sources.transform_docker_event(line, src, host) or { continue }
app.publish(event)
}
}
}
}
fn build_docker_filters(events []string) string {
mut parts := []string{}
for event in events {
parts << '"${event}":true'
}
return '{' + parts.join(',') + '}'
}
}
+20 -23
View File
@@ -92,7 +92,7 @@ POST /webhooks/cron-disk
## Modèle de données
```v
struct Config {
struct Config { // dans config.v
server ServerConfig
defaults DefaultConfig
sources SourcesConfig
@@ -109,7 +109,7 @@ struct DefaultConfig {
tags []string // ["loudspeaker"]
}
struct Source {
struct Source { // types spécifiques dans sources/event.v
name string
webhook_path string
topic string
@@ -118,7 +118,7 @@ struct Source {
tags []string
}
struct Event {
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
@@ -174,26 +174,23 @@ ntfy-bridge/
├── ROADMAP.md # Phases de développement
├── CONTRIBUTING.md # Guide de contribution
├── ntfy-bridge.example.yaml # Exemple de configuration complet
├── src/
│ ├── main.v # Entrypoint, parsing CLI
│ ├── config.v # Chargement + validation YAML
│ ├── server.v # HTTP server + routing webhooks
│ ├── event.v # Types Event, pipeline
│ ├── ntfy.v # Client HTTP Ntfy (POST)
│ ├── dedup.v # Déduplication + rate limiting
│ ├── template.v # Mini moteur de template
│ ├── sources/
│ │ ├── gitea.v # Transformer Gitea
│ │ ├── uptime_kuma.v # Transformer Uptime Kuma
│ │ ├── docker.v # Watcher Docker socket
│ │ ├── http_poll.v # Poller HTTP périodique
│ │ └── generic.v # Webhook passe-partout
│ └── log.v # Logging structuré
├── tests/
│ ├── config_test.v # Tests validation config
│ ├── gitea_test.v # Tests transformer Gitea
│ ├── uptime_kuma_test.v # Tests transformer Kuma
│ └── dedup_test.v # Tests déduplication
├── 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)
├── sources/ # Types Event + transformers
│ ├── event.v # Types Event, Source
│ ├── gitea.v # Transformer Gitea
│ ├── uptime_kuma.v # Transformer Uptime Kuma
│ ├── docker.v # Transformer Docker events
│ ├── http_poll.v # Transformer HTTP poll
│ ├── generic.v # Webhook passe-partout
│ └── cron.v # Webhook cron
├── *_test.v # Tests unitaires
├── scripts/
│ └── example-cron-disk.sh # Exemple script cron → Ntfy
└── v.mod # Dépendances V
+25 -21
View File
@@ -30,30 +30,34 @@ v -prod .
## Structure du code
```
src/
├── main.v # Entrypoint, flag parsing, orchestration
├── config.v # Config loading + validation
├── server.v # HTTP server, webhook routing
├── event.v # Event types, pipeline
├── ntfy.v # Ntfy HTTP client
├── dedup.v # Deduplication + rate limiting
├── template.v # Mini template engine
├── log.v # Structured logging
└── sources/ # Source-specific transformers
├── gitea.v
├── uptime_kuma.v
├── docker.v
├── http_poll.v
└── generic.v
.
├── main.v # Entrypoint, flag parsing, orchestration
├── config.v # Config loading + validation
├── server.v # HTTP server, webhook routing
├── ntfy.v # Ntfy HTTP client
├── dedup.v # Deduplication + rate limiting
├── template.v # Mini template engine
├── log.v # Structured logging
├── docker_watcher.v # Docker socket watcher (Unix)
├── sources/ # Event types + source-specific transformers
│ ├── event.v
│ ├── gitea.v
│ ├── uptime_kuma.v
│ ├── docker.v
│ ├── http_poll.v
│ ├── generic.v
│ └── cron.v
└── *_test.v # Unit tests
```
## Ajouter une nouvelle source
1. Créer `src/sources/ma_source.v`
2. Implémenter la fonction `transform(raw string, source &Source) ?Event`
3. Déclarer le type dans `sources` du `config.v`
4. Router l'endpoint dans `server.v`
5. Ajouter un test dans `tests/ma_source_test.v`
1. Créer `sources/ma_source.v`
2. Implémenter la fonction `transform(raw string, source MaSource) ?Event`
3. Déclarer le type source dans `sources/event.v`
4. Référencer le type dans `config.v` (`SourcesConfig`)
5. Router l'endpoint dans `server.v`
6. Ajouter un test dans `ma_source_test.v`
## Conventions de code
@@ -72,7 +76,7 @@ src/
v test .
# Test spécifique
v test tests/gitea_test.v
v test gitea_test.v
# Avec verbose
v test . -stats
View File
+255
View File
@@ -0,0 +1,255 @@
# Configurer les webhooks Gitea avec ntfy-bridge
Guide tape par tape pour connecter Gitea ntfy-bridge et recevoir des notifications Ntfy pour chaque push, PR, issue ou release.
---
## Prrequis
- **ntfy-bridge** install et configur (voir [README.md](../README.md))
- Un serveur **Ntfy** accessible (ex: `https://ntfy.dracodev.net`)
- Un compte **Gitea** avec accs admin au repo (ex: `https://git.dracodev.net`)
---
## Architecture
```
Gitea (git.dracodev.net) ntfy-bridge (ton serveur) Ntfy (ntfy.dracodev.net)
┌─────────────────────┐ ┌────────────────────────┐ ┌──────────────────────┐
│ bruno/flowdeck │ │ coute sur :9090 │ │ Topic: dev-notifs │
│ │ POST │ │ POST │ │
│ vnement (push) │──────────→│ /webhooks/gitea-... │───────→│ Ton tlphone │
│ JSON + HMAC sig │ │ │ │ │
│ │ │ Parse, format, forward │ │ │
└─────────────────────┘ └────────────────────────┘ └──────────────────────┘
```
**Le principe** : Gitea envoie un POST HTTP contenant un JSON avec tous les dtails de l'vnement. ntfy-bridge l'coute, le parse, le formate, et le forward vers Ntfy.
---
## tape 1 — Configurer ntfy-bridge
### 1a. Modifier `ntfy-bridge.yaml`
```yaml
server:
url: https://ntfy.dracodev.net # Ton serveur Ntfy
listen: "0.0.0.0:9090" # couter sur le rseau (pas juste localhost)
hmac_secret: "un-secret-que-tu-choisis" # Secret partag avec Gitea
sources:
gitea:
- name: "FlowDeck activity" # Nom affich dans les logs/dashboard
webhook_path: /webhooks/gitea-flowdeck # Chemin d'coute
repo: bruno/flowdeck # Repo concern
topic: dev-notifs # Topic Ntfy cible
priority_map: # Priorits par type d'vent
pull_request: 4 # 4 = high
issue: 3 # 3 = default
push: 2 # 2 = low
template: | # Format du message (optionnel)
{user} {action} sur **[{repo}]**: "{title}"
```
**Points cls** :
- `listen: "0.0.0.0:9090"` — indispensable si Gitea est sur un autre serveur. `127.0.0.1` = localhost seulement.
- `hmac_secret` — le mme secret que tu mettras dans Gitea. Optionnel mais recommand.
- `webhook_path` — le chemin dans l'URL. Doit tre unique par source.
- `template` — si absent, un format par dfaut est gnr automatiquement avec emoji.
### 1b. Lancer ntfy-bridge
```bash
cd /c/dev/git/V/ntfy-bridge
./ntfy-bridge.exe --config ntfy-bridge.yaml
```
Sortie attendue :
```
──────────────────────────────────────────────────────────────────────
ntfy-bridge v0.1.0 — Homelab Notification Hub
──────────────────────────────────────────────────────────────────────
Ntfy server: https://ntfy.dracodev.net
Listening on: http://0.0.0.0:9090
Dashboard: http://0.0.0.0:9090/
Sources loaded:
Gitea: 2 webhooks
Uptime Kuma: 1 webhooks
Docker: 1 watchers
HTTP Poll: 1 pollers (5 checks)
Cron: 2 webhooks
Generic: 1 webhooks
──────────────────────────────────────────────────────────────────────
```
### 1c. Vrifier que a marche
```bash
# Test health endpoint
curl http://localhost:9090/health
# → {"status":"ok","uptime_seconds":12,"total_notifications":0}
# Test dashboard
curl http://localhost:9090/
# → HTML du dashboard
```
---
## tape 2 — Configurer Gitea
### 2a. Aller dans les rglages du repo
1. Ouvre `https://git.dracodev.net/bruno/flowdeck`
2. Clic sur l'engrenage **Settings** (haut droite)
3. Menu gauche : **Webhooks**
4. Bouton **Add Webhook** → choisir **Gitea**
### 2b. Remplir le formulaire
| Champ | Valeur | Notes |
|---|---|---|
| **Target URL** | `http://192.168.30.X:9090/webhooks/gitea-flowdeck` | Remplacer X par l'IP relle du serveur |
| **HTTP Method** | POST | |
| **POST Content Type** | application/json | |
| **Secret** | `un-secret-que-tu-choisis` | Identique `hmac_secret` dans le YAML |
| **Trigger On** | Slectionner les vnements souhauts | Push, Pull Request, Issue, Release... |
**Trouver la bonne IP** : sur le serveur o tourne ntfy-bridge :
```bash
# Linux
ip addr show | grep "inet " | grep -v 127.0.0.1
# Windows
ipconfig | findstr "IPv4"
```
### 2c. Cliquer "Add Webhook"
Gitea envoie immdiatement un **test ping** pour vrifier la connectivit.
Rsultat russi :
```
✓ Delivery successful (HTTP 200)
```
En cas d'chec :
| Erreur | Cause probable | Solution |
|---|---|---|
| Connection refused | ntfy-bridge n'est pas lanc | Lancer `./ntfy-bridge.exe` |
| Connection refused | Firewall bloque le port 9090 | Ouvrir le port dans le firewall |
| HTTP 404 | Mauvais `webhook_path` | Vrifier que `/webhooks/gitea-flowdeck` est exact |
| HTTP 401 | Mauvais `hmac_secret` | Vrifier que le secret est identique des deux cts |
| Timeout | Mauvaise IP ou rseau inaccessible | Vrifier la connectivit rseau |
---
## tape 3 — Tester avec un vrai vnement
### Option A : Test simul (curl)
Depuis n'importe quel poste :
```bash
curl -X POST http://192.168.30.X:9090/webhooks/gitea-flowdeck \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=..." \
-d '{
"commits": [{"id": "abc1234567890abcdef1234567890abcdef1234", "message": "test notification ntfy-bridge"}],
"pusher": {"username": "bruno"},
"repository": {"full_name": "bruno/flowdeck"}
}'
```
> Note : si `hmac_secret` est configur, gnre la signature correcte ou dsactive-le temporairement pour le test.
### Option B : Test rel
Fais un push, cre une PR, ou ouvre une issue dans le repo Gitea. Tu devrais recevoir une notification Ntfy dans les secondes qui suivent.
Exemple de notification reue :
```
bruno pushed sur [bruno/flowdeck]: "fix: corrige le bug du dashboard"
```
Ou avec le template par dfaut :
```
[bruno/flowdeck] bruno pushed: "fix: corrige le bug du dashboard" (abc1234)
```
---
## Ajouter d'autres repos
Rpte les mmes tapes pour chaque repo :
### Dans `ntfy-bridge.yaml`
```yaml
sources:
gitea:
- name: "FlowDeck activity"
webhook_path: /webhooks/gitea-flowdeck
repo: bruno/flowdeck
topic: dev-notifs
- name: "ObsiGate activity"
webhook_path: /webhooks/gitea-obsigate
repo: bruno/obsigate
topic: dev-notifs
- name: "Imago activity"
webhook_path: /webhooks/gitea-imago
repo: bruno/imago
topic: dev-notifs
```
### Dans Gitea
Pour chaque repo : Settings → Webhooks → Add Webhook avec l'URL correspondante.
```text
http://192.168.30.X:9090/webhooks/gitea-flowdeck → repo bruno/flowdeck
http://192.168.30.X:9090/webhooks/gitea-obsigate → repo bruno/obsigate
http://192.168.30.X:9090/webhooks/gitea-imago → repo bruno/imago
```
---
## Dpannage rapide
```bash
# Vrifier que ntfy-bridge tourne
curl http://localhost:9090/health
# Voir les stats
curl http://localhost:9090/stats
# Voir la config (sans les secrets)
curl http://localhost:9090/api/config
# Voir l'historique des derniers vnements
curl http://localhost:9090/api/history
# Voir le dashboard web
# Ouvre http://localhost:9090/ dans ton navigateur
```
---
## Rfrences
- [README.md](../README.md) — Vue d'ensemble du projet
- [ARCHITECTURE.md](../ARCHITECTURE.md) — Architecture dtaille
- [ntfy-bridge.example.yaml](../ntfy-bridge.example.yaml) — Exemple de config complet
- [Documentation webhooks Gitea](https://docs.gitea.com/usage/webhooks)
- [Documentation Ntfy](https://docs.ntfy.sh/)
+37
View File
@@ -0,0 +1,37 @@
module main
import sources
fn test_transform_gitea_push() {
raw := '{"repository":{"full_name":"bruno/flowdeck"},"pusher":{"username":"bruno"},"commits":[{"message":"Fix bug","id":"a3f2c1d1234567890"}]}'
src := sources.GiteaSource{
name: 'FlowDeck'
webhook_path: '/webhooks/gitea-flowdeck'
topic: 'dev-notifs'
priority_map: {
'push': 2
}
}
event := sources.transform_gitea(raw, src) or { panic(err) }
assert event.source == 'gitea'
assert event.topic == 'dev-notifs'
assert event.priority == 2
assert event.message.contains('bruno/flowdeck')
assert event.message.contains('bruno')
assert event.message.contains('Fix bug')
}
fn test_transform_gitea_pull_request() {
raw := '{"repository":{"full_name":"bruno/flowdeck"},"action":"opened","pull_request":{"title":"New feature","merge_commit_sha":"abc123"}}'
src := sources.GiteaSource{
name: 'FlowDeck'
webhook_path: '/webhooks/gitea-flowdeck'
topic: 'dev-notifs'
priority_map: {
'pull_request': 4
}
}
event := sources.transform_gitea(raw, src) or { panic(err) }
assert event.priority == 4
assert event.message.contains('New feature')
}
+119
View File
@@ -0,0 +1,119 @@
module main
import time
pub enum LogLevel {
debug
info
warn
error
}
pub struct Logger {
level LogLevel
json_mode bool
}
pub fn new_logger(level LogLevel, json_mode bool) Logger {
return Logger{
level: level
json_mode: json_mode
}
}
fn level_string(level LogLevel) string {
return match level {
.debug { 'DEBUG' }
.info { 'INFO' }
.warn { 'WARN' }
.error { 'ERROR' }
}
}
fn level_icon(level LogLevel) string {
return match level {
.debug { '🔍' }
.info { 'ℹ️ ' }
.warn { '⚠️ ' }
.error { '❌' }
}
}
fn (l Logger) should_log(level LogLevel) bool {
return int(level) >= int(l.level)
}
pub fn (l Logger) log(level LogLevel, source string, event string, message string) {
if !l.should_log(level) {
return
}
if l.json_mode {
l.log_json(level, source, event, message)
} else {
l.log_human(level, source, event, message)
}
}
fn (l Logger) log_json(level LogLevel, source string, event string, message string) {
timestamp := time.now().format_rfc3339()
level_str := level_string(level)
// Manual JSON construction — simple enough to avoid importing json for each log line
// Escape backslashes and quotes in message
escaped := escape_json(message)
output := '{"timestamp":"${timestamp}","level":"${level_str}","source":"${source}","event":"${event}","message":"${escaped}"}'
if level == .error {
eprintln(output)
} else {
println(output)
}
}
fn escape_json(s string) string {
mut result := s.clone()
result = result.replace('\\', '\\\\')
result = result.replace('"', '\\"')
result = result.replace('\n', '\\n')
result = result.replace('\r', '\\r')
result = result.replace('\t', '\\t')
return result
}
fn (l Logger) log_human(level LogLevel, source string, _event string, message string) {
icon := level_icon(level)
ts := time.now()
ts_str := format_time_compact(ts)
output := '${ts_str} ${icon} [${source}] ${message}'
if level == .error {
eprintln(output)
} else if level == .warn {
eprintln(output)
} else {
println(output)
}
}
// format_time_compact returns "HH:MM:SS" (compact time for console output)
fn format_time_compact(t time.Time) string {
h := int(t.hour)
m := int(t.minute)
s := int(t.second)
return '${h:02d}:${m:02d}:${s:02d}'
}
pub fn (l Logger) debug(source string, event string, message string) {
l.log(.debug, source, event, message)
}
pub fn (l Logger) info(source string, event string, message string) {
l.log(.info, source, event, message)
}
pub fn (l Logger) warn(source string, event string, message string) {
l.log(.warn, source, event, message)
}
pub fn (l Logger) error(source string, event string, message string) {
l.log(.error, source, event, message)
}
+202
View File
@@ -0,0 +1,202 @@
module main
import os
import flag
const version = '0.1.0'
const author = 'Bruno Charest'
fn main() {
mut fp := flag.new_flag_parser(os.args)
fp.application('ntfy-bridge')
fp.version(version)
fp.description('Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy')
fp.skip_executable()
config_path := fp.string('config', `c`, 'ntfy-bridge.yaml', 'Path to config file (YAML)')
validate_only := fp.bool('validate', `v`, false, 'Validate config and exit')
quiet := fp.bool('quiet', `q`, false, 'Quiet mode — suppress startup banner (for scripts/systemd)')
version_only := fp.bool('version', `V`, false, 'Print version and exit')
// Show help if no args or --help/-h requested
if os.args.len <= 1 || '--help' in os.args || '-h' in os.args {
print_help()
return
}
fp.finalize() or {
eprintln(err)
exit(1)
return
}
if version_only {
println('ntfy-bridge ${version}')
return
}
cfg := load_config(config_path) or {
eprintln('Failed to load config: ${err}')
exit(1)
return
}
if validate_only {
println('Config is valid')
return
}
if !quiet {
print_header()
println('')
print_startup_info(cfg)
}
mut app := new_app(cfg, quiet)
app.run() or {
eprintln('Server error: ${err}')
exit(1)
}
}
// ── ASCII Art Header ─────────────────────────────────────────────────────
const box_w = 60 // inner width between ║ borders
fn build_header() string {
// Tagline — centered within the box
tag_text := 'Homelab Notification Hub · v${version}'
tag_left := (box_w - tag_text.len) / 2
tag_right := box_w - tag_left - tag_text.len
tag_line := ' '.repeat(tag_left) + tag_text + ' '.repeat(tag_right)
// Version line — left-aligned with 5-space indent
vinfo := ' Version ${version} │ ${author}'
vinfo_pad := box_w - vinfo.len
return '╔══════════════════════════════════════════════════════════════╗\n' +
'║ __ ____ __ _ __ ║\n' +
'║ ____ / /_/ __/_ __ / /_ _____(_)___/ /___ ____ ║\n' +
'║ / __ \\/ __/ /_/ / / /_____/ __ \\/ ___/ / __ / __ `/ _ \\ ║\n' +
'║ / / / / /_/ __/ /_/ /_____/ /_/ / / / / /_/ / /_/ / __/ ║\n' +
'║ /_/ /_/\\__/_/ \\__, / /_.___/_/ /_/\\__,_/\\__, /\\___/ ║\n' +
'║ /____/ /____/ ║\n' +
'║' + tag_line + '║\n' +
'╠══════════════════════════════════════════════════════════════╣\n' +
'║' + vinfo + ' '.repeat(vinfo_pad) + '║\n' +
'╚══════════════════════════════════════════════════════════════╝'
}
fn print_header() {
println(build_header())
}
fn print_help() {
println('')
println(build_header())
println('')
println(' A simple daemon to aggregate notifications from multiple sources')
println(' (Gitea, Uptime Kuma, Docker, HTTP health checks, Cron scripts)')
println(' and forward them as formatted Ntfy push notifications.')
println('')
println('Usage:')
println(' ntfy-bridge [flags]')
println(' ntfy-bridge --config ntfy-bridge.yaml')
println('')
println('Flags:')
println(' -c, --config <path> Path to config file (default: ntfy-bridge.yaml)')
println(' -v, --validate Validate config and exit')
println(' -q, --quiet Quiet mode — suppress startup banner')
println(' -V, --version Print version and exit')
println(' -h, --help Show this help message')
println('')
println('Config:')
println(' Copy ntfy-bridge.example.yaml → ntfy-bridge.yaml and edit it.')
println(' Use NTFY_TOKEN and NTFY_HMAC_SECRET env vars for secrets.')
println('')
println('Docs: https://git.dracodev.net/bruno/ntfy-bridge')
println(line_double_r)
println(' Version: ${version} | Author: ${author}')
println(line_double_r)
println('')
}
fn print_startup_info(cfg Config) {
println(line_double_r)
println(' Ntfy server: ${cfg.server.url}')
println(' Listening on: http://${cfg.server.listen}')
println(' Dashboard: http://${normalize_listen(cfg.server.listen)}/')
println(' Health check: http://${normalize_listen(cfg.server.listen)}/health')
println('')
println(' Sources configured:')
gitea_count := cfg.sources.gitea.len
if gitea_count > 0 {
println(' Gitea: ${gitea_count} webhook${if gitea_count > 1 { 's' } else { '' }}')
}
uk_count := cfg.sources.uptime_kuma.len
if uk_count > 0 {
println(' Uptime Kuma: ${uk_count} webhook${if uk_count > 1 { 's' } else { '' }}')
}
docker_count := cfg.sources.docker.len
if docker_count > 0 {
println(' Docker: ${docker_count} watcher${if docker_count > 1 { 's' } else { '' }}')
}
poll_count := cfg.sources.http_poll.len
if poll_count > 0 {
mut total_checks := 0
for p in cfg.sources.http_poll {
total_checks += p.checks.len
}
println(' HTTP Poll: ${poll_count} poller${if poll_count > 1 { 's' } else { '' }} (${total_checks} check${if total_checks > 1 { 's' } else { '' }})')
}
cron_count := cfg.sources.cron.len
if cron_count > 0 {
println(' Cron: ${cron_count} webhook${if cron_count > 1 { 's' } else { '' }}')
}
generic_count := cfg.sources.generic.len
if generic_count > 0 {
println(' Generic: ${generic_count} webhook${if generic_count > 1 { 's' } else { '' }}')
}
println('')
println(' Webhook URLs:')
for src in cfg.sources.gitea {
println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Gitea: ${src.name})')
}
for src in cfg.sources.uptime_kuma {
println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Uptime Kuma: ${src.name})')
}
for src in cfg.sources.cron {
println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Cron: ${src.name})')
}
for src in cfg.sources.generic {
println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Generic: ${src.name})')
}
println('')
if cfg.dedup.enabled {
println(' Dedup: enabled (TTL: ${cfg.dedup.ttl_seconds}s, rate: ${cfg.dedup.rate_limit.max_per_minute}/min)')
} else {
println(' Dedup: disabled')
}
println(line_double_r)
println('')
}
// normalize_listen converts "0.0.0.0:9090" to "localhost:9090" for display URLs,
// and leaves specific IPs intact (e.g. "192.168.30.101:9090" stays as-is).
fn normalize_listen(listen string) string {
if listen.starts_with('0.0.0.0') {
return 'localhost' + listen[7..]
}
if listen.starts_with('127.0.0.1') {
return listen // already localhost
}
return listen
}
// ── Display Constants ─────────────────────────────────────────────────────
const line_double_r = '══════════════════════════════════════════════════════════════'
+27 -11
View File
@@ -14,15 +14,19 @@ server:
# 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
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
# ═══════════════════════════════════════════════════
@@ -54,7 +58,7 @@ sources:
topic: alerts
state_map:
down: { priority: 5, tags: ["rotating_light", "x"] }
up: { priority: 1, tags: ["white_check_mark"] }
up: { priority: 1, tags: ["white_check_mark"] }
# ═══════════════════════════════════════════════════
# DOCKER — surveillance des conteneurs
@@ -79,13 +83,13 @@ sources:
# ═══════════════════════════════════════════════════
http_poll:
- name: "Health endpoints"
interval: 60 # secondes entre chaque check
interval: 60 # secondes entre chaque check
checks:
- url: https://obsigate.dracodev.net/health
- url: https://og.dracodev.net/health
topic: alerts
priority: 5 # priorité si DOWN
priority: 5 # priorité si DOWN
expect_status: 200
timeout: 10 # secondes
timeout: 10 # secondes
- url: https://flowdeck.dracodev.net/health
topic: alerts
@@ -99,6 +103,18 @@ sources:
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
# ═══════════════════════════════════════════════════
@@ -122,7 +138,7 @@ sources:
# ── Déduplication ──────────────────────────────────────
dedup:
enabled: true
ttl_seconds: 300 # 5 minutes — durée du cache de déduplication
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
max_per_minute: 10 # max notifications/minute par topic
max_per_source: 30 # max notifications/minute toute source confondue
+76
View File
@@ -0,0 +1,76 @@
module main
import net.http
import time
import sources
pub struct NtfyClient {
pub mut:
base_url string
auth_token string
logger Logger
}
pub fn new_ntfy_client(cfg ServerConfig, logger Logger) NtfyClient {
return NtfyClient{
base_url: cfg.url.trim_right('/')
auth_token: cfg.auth_token
logger: logger
}
}
fn join_tags(tags []string) string {
return tags.join(',')
}
// publish sends an event to the Ntfy server with exponential backoff retry (3 attempts).
pub fn (c NtfyClient) publish(event sources.Event) ! {
url := '${c.base_url}/${event.topic}'
mut header := http.Header{}
header.add_custom('Content-Type', 'text/plain')!
header.add_custom('X-Priority', event.priority.str())!
if event.tags.len > 0 {
header.add_custom('X-Tags', join_tags(event.tags))!
}
if c.auth_token != '' {
header.add(.authorization, 'Bearer ${c.auth_token}')
}
c.logger.debug('ntfy', 'publish',
'POST ${url} priority=${event.priority} tags=${join_tags(event.tags)}')
// Retry with exponential backoff: 3 attempts, 1s / 2s / 4s delays
mut last_err := error('unknown')
for attempt := 1; attempt <= 3; attempt++ {
resp := http.fetch(http.FetchConfig{
url: url
method: .post
header: header
data: event.message
}) or {
last_err = err
if attempt < 3 {
delay := time.second * (1 << (attempt - 1))
c.logger.warn('ntfy', 'publish',
'attempt ${attempt}/3 failed: ${err}, retrying in ${delay}')
time.sleep(delay)
}
continue
}
if resp.status_code >= 200 && resp.status_code < 300 {
c.logger.debug('ntfy', 'publish', 'success ${resp.status_code}')
return
}
last_err = error('ntfy returned ${resp.status_code}: ${resp.body}')
if attempt < 3 {
delay := time.second * (1 << (attempt - 1))
c.logger.warn('ntfy', 'publish',
'attempt ${attempt}/3 failed: ${last_err}, retrying in ${delay}')
time.sleep(delay)
}
}
return last_err
}
+390
View File
@@ -0,0 +1,390 @@
module main
import net.http
import time
import json
import crypto.hmac
import crypto.sha256
import encoding.hex
import sources
import os
pub struct Stats {
pub mut:
total_notifications int
errors int
by_source map[string]int
}
pub struct App {
pub mut:
cfg Config
logger Logger
ntfy NtfyClient
dedup Dedup
stats Stats
start_time time.Time
poll_state map[string]bool // url -> is_down
webhook_routes map[string]WebhookRoute
recent_events []RecentEvent
shutdown_flag bool
dashboard_html string // cached dashboard HTML
}
pub struct RecentEvent {
timestamp time.Time
source string
name string
topic string
priority int
message string
success bool
}
const recent_events_max = 100
pub fn new_app(cfg Config, quiet bool) App {
// json_mode = quiet — when running as a systemd service (quiet mode),
// use JSON structured logging; otherwise use human-readable console output
logger := new_logger(.info, quiet)
dashboard := load_dashboard_html(logger)
return App{
cfg: cfg
logger: logger
ntfy: new_ntfy_client(cfg.server, logger)
dedup: new_dedup(cfg.dedup)
stats: Stats{
by_source: map[string]int{}
}
start_time: time.now()
poll_state: map[string]bool{}
webhook_routes: build_webhook_routes(cfg)
recent_events: []RecentEvent{}
shutdown_flag: false
dashboard_html: dashboard
}
}
fn load_dashboard_html(logger Logger) string {
// Try dashboard.html from current working directory
if os.exists('dashboard.html') {
content := os.read_file('dashboard.html') or {
logger.warn('server', 'dashboard', 'failed to read dashboard.html, using fallback')
return dashboard_fallback_html()
}
logger.info('server', 'dashboard', 'loaded dashboard.html (${content.len} bytes)')
return content
}
logger.warn('server', 'dashboard', 'dashboard.html not found, using fallback')
return dashboard_fallback_html()
}
fn dashboard_fallback_html() string {
return '<!DOCTYPE html><html><head><title>ntfy-bridge</title><style>body{background:#0f172a;color:#e2e8f0;font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.msg{text-align:center}.msg h1{font-size:1.5rem}.msg p{color:#94a3b8;margin-top:8px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:.875rem}</style></head><body><div class=msg><h1>🔔 ntfy-bridge</h1><p>Dashboard file not found.</p><p>Place <code>dashboard.html</code> in the working directory.</p></div></body></html>'
}
pub fn (mut app App) run() ! {
app.start_background_tasks()
app.logger.info('server', 'start', 'listening on ${app.cfg.server.listen}')
// Graceful shutdown via signal handling on Unix
$if linux || macos {
spawn app.handle_signals()
}
mut s := http.Server{
addr: app.cfg.server.listen
handler: app
}
s.listen_and_serve()
}
$if linux || macos {
fn (mut app App) handle_signals() {
sigch := os.new_signal_channel(.int, .term)
_ := <-sigch
app.logger.info('server', 'shutdown', 'received signal, shutting down...')
app.shutdown_flag = true
time.sleep(500 * time.millisecond)
exit(0)
}
}
fn (mut app App) start_background_tasks() {
if app.cfg.sources.http_poll.len > 0 {
spawn app.http_poll_loop()
}
if app.cfg.sources.docker.len > 0 {
spawn app.docker_watch_loop()
}
}
// ---- HTTP Handler ----
pub fn (mut app App) handle(req http.Request) http.Response {
match req.method {
.get {
if req.url == '/' || req.url == '/dashboard' {
return app.dashboard_response()
}
if req.url == '/health' {
return app.health_response()
}
if req.url == '/stats' {
return app.stats_response()
}
if req.url == '/api/config' {
return app.config_api_response()
}
if req.url == '/api/status' {
return app.status_api_response()
}
if req.url == '/api/history' {
return app.history_api_response()
}
}
.post {
return app.handle_webhook(req)
}
else {}
}
return new_json_response(.not_found, '{"error":"not found"}')
}
// ---- Health & Stats ----
fn (mut app App) health_response() http.Response {
uptime := time.now().unix() - app.start_time.unix()
body := '{"status":"ok","uptime_seconds":${uptime},"total_notifications":${app.stats.total_notifications}}'
return new_json_response(.ok, body)
}
fn (mut app App) stats_response() http.Response {
mut parts := []string{}
for source, count in app.stats.by_source {
parts << '"${source}":${count}'
}
by_source := '{' + parts.join(',') + '}'
body := '{"total":${app.stats.total_notifications},"errors":${app.stats.errors},"by_source":${by_source}}'
return new_json_response(.ok, body)
}
// ---- API endpoints for dashboard ----
fn (mut app App) config_api_response() http.Response {
mut safe := map[string]string{}
safe['server_url'] = app.cfg.server.url
safe['listen'] = app.cfg.server.listen
safe['dedup_enabled'] = app.cfg.dedup.enabled.str()
safe['dedup_ttl'] = app.cfg.dedup.ttl_seconds.str()
safe['default_priority'] = app.cfg.defaults.priority.str()
safe['gitea_sources'] = app.cfg.sources.gitea.len.str()
safe['uptime_kuma_sources'] = app.cfg.sources.uptime_kuma.len.str()
safe['docker_sources'] = app.cfg.sources.docker.len.str()
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()
return new_json_response(.ok, json.encode(safe))
}
fn (mut app App) status_api_response() http.Response {
mut items := []map[string]string{}
for src in app.cfg.sources.http_poll {
for check in src.checks {
is_down := app.poll_state[check.url] or { false }
status := if is_down { 'up' } else { 'down' }
items << {
'name': check.url
'status': status
}
}
}
return new_json_response(.ok, json.encode(items))
}
fn (mut app App) history_api_response() http.Response {
mut entries := []map[string]string{}
for ev in app.recent_events {
entries << {
'timestamp': ev.timestamp.format_rfc3339()
'source': ev.source
'name': ev.name
'topic': ev.topic
'priority': ev.priority.str()
'message': ev.message
'success': ev.success.str()
}
}
return new_json_response(.ok, json.encode(entries))
}
// ---- Webhook handling (O(1) dispatch) ----
fn (mut app App) handle_webhook(req http.Request) http.Response {
path := req.url
route := app.webhook_routes[path] or {
return new_json_response(.not_found, '{"error":"unknown webhook"}')
}
// HMAC validation (if secret is configured)
if app.cfg.server.hmac_secret != '' {
sig_header := req.header.get_custom('X-Hub-Signature-256') or { '' }
if sig_header == '' || !app.verify_hmac(req.data, sig_header) {
app.logger.warn('server', 'webhook', 'HMAC validation failed for ${path}')
return new_json_response(.unauthorized, '{"error":"invalid signature"}')
}
}
mut event_opt := ?sources.Event(none)
match route.kind {
.gitea {
src := app.cfg.sources.gitea[route.index]
event_opt = sources.transform_gitea(req.data, src)
}
.uptime_kuma {
src := app.cfg.sources.uptime_kuma[route.index]
event_opt = sources.transform_uptime_kuma(req.data, src)
}
.cron {
src := app.cfg.sources.cron[route.index]
event_opt = sources.transform_cron(req.data, src)
}
.generic {
src := app.cfg.sources.generic[route.index]
event_opt = sources.transform_generic(req.data, src)
}
}
event := event_opt or {
app.logger.warn('server', 'webhook', 'failed to transform: ${path}')
return new_json_response(.bad_request, '{"error":"bad request"}')
}
app.publish(event)
return new_json_response(.ok, '{"ok":true}')
}
fn (mut app App) verify_hmac(payload string, sig_header string) bool {
if !sig_header.starts_with('sha256=') {
return false
}
expected_hex := sig_header[7..] // strip "sha256=" prefix
key := app.cfg.server.hmac_secret.bytes()
data := payload.bytes()
computed := hmac.new(key, data, sha256.sum, sha256.block_size)
computed_hex := hex.encode(computed)
return computed_hex == expected_hex
}
// ---- Publish pipeline ----
pub fn (mut app App) publish(event sources.Event) {
mut e := apply_defaults(event, app.cfg.defaults)
if !app.dedup.allow(e) {
app.logger.debug('server', 'dedup', 'dropped event ${e.source}:${e.name}')
return
}
now := time.now()
app.ntfy.publish(e) or {
app.logger.error('server', 'publish', 'failed to publish: ${err}')
app.stats.errors++
app.add_recent_event(now, e, false)
return
}
app.stats.total_notifications++
mut count := app.stats.by_source[e.source] or { 0 }
count++
app.stats.by_source[e.source] = count
app.logger.info('server', 'publish', '${e.source}:${e.name} → ${e.topic}')
app.add_recent_event(now, e, true)
}
fn (mut app App) add_recent_event(ts time.Time, event sources.Event, success bool) {
app.recent_events << RecentEvent{
timestamp: ts
source: event.source
name: event.name
topic: event.topic
priority: event.priority
message: event.message
success: success
}
if app.recent_events.len > recent_events_max {
app.recent_events = app.recent_events[app.recent_events.len - recent_events_max..]
}
}
// ---- HTTP Poll Loop ----
fn (mut app App) http_poll_loop() {
for {
if app.shutdown_flag {
break
}
for src in app.cfg.sources.http_poll {
for check in src.checks {
app.run_http_check(check)
}
}
mut min_interval := 60
for src in app.cfg.sources.http_poll {
if src.interval > 0 && src.interval < min_interval {
min_interval = src.interval
}
}
time.sleep(min_interval * time.second)
}
}
fn (mut app App) run_http_check(check sources.HttpCheck) {
resp := http.fetch(http.FetchConfig{
url: check.url
method: .get
read_timeout: check.timeout * time.second
}) or {
app.handle_http_result(check, false, err.msg())
return
}
is_up := resp.status_code == check.expect_status
app.handle_http_result(check, is_up, 'status ${resp.status_code}')
}
fn (mut app App) handle_http_result(check sources.HttpCheck, is_up bool, detail string) {
was_up := app.poll_state[check.url] or { true }
if is_up && !was_up {
app.publish(sources.transform_http_up(check))
app.poll_state[check.url] = true
} else if !is_up && was_up {
app.publish(sources.transform_http_down(check, detail))
app.poll_state[check.url] = false
}
}
// ---- Helpers ----
fn new_json_response(status http.Status, body string) http.Response {
mut header := http.Header{}
header.add_custom('Content-Type', 'application/json') or { panic(err) }
return http.new_response(http.ResponseConfig{
status: status
body: body
header: header
})
}
fn (mut app App) dashboard_response() http.Response {
mut header := http.Header{}
header.add_custom('Content-Type', 'text/html; charset=utf-8') or { panic(err) }
return http.new_response(http.ResponseConfig{
status: .ok
body: app.dashboard_html
header: header
})
}
+13
View File
@@ -0,0 +1,13 @@
module sources
pub fn transform_cron(raw string, source CronSource) ?Event {
return Event{
source: 'cron'
name: source.name
topic: source.topic
priority: source.priority_map['default'] or { 0 }
tags: source.tags.clone()
message: raw
raw: raw
}
}
+68
View File
@@ -0,0 +1,68 @@
module sources
import x.json2 as json
pub fn transform_docker_event(raw string, source DockerSource, host string) ?Event {
doc := json.decode[json.Any](raw, json.DecoderOptions{}) or { return none }
obj := doc.as_map()
event_type := get_string(obj, 'status')
actor := obj['Actor'] or { json.Any(map[string]json.Any{}) }.as_map()
attrs := actor['Attributes'] or { json.Any(map[string]json.Any{}) }.as_map()
container_name := get_string(attrs, 'name')
image := get_string(attrs, 'image')
exit_code := get_string(attrs, 'exitCode')
priority := source.priority_map[event_type] or { 0 }
mut tags := source.tags.clone()
if event_type == 'oom' && 'skull' !in tags {
tags << 'skull'
}
if source.template != '' {
vars := {
'container_name': container_name
'status': event_type
'image': image
'exit_code': exit_code
'host': host
}
return Event{
source: 'docker'
name: source.name
topic: source.topic
priority: priority
tags: tags
message: render_source_template(source.template, vars)
raw: raw
}
}
emoji := match event_type {
'die' { '💀' }
'oom' { '☠️' }
'health_status' { '💓' }
else { '🐳' }
}
mut message := '${emoji} ${container_name} → ${event_type}'
if image != '' {
message += '\nImage: ${image}'
}
if exit_code != '' {
message += '\nExit code: ${exit_code}'
}
message += '\nHost: ${host}'
return Event{
source: 'docker'
name: source.name
topic: source.topic
priority: priority
tags: tags
message: message
raw: raw
}
}
+111
View File
@@ -0,0 +1,111 @@
module sources
pub struct Event {
pub mut:
source string
name string
topic string
priority int
tags []string
message string
raw string
}
pub fn (e Event) clone() Event {
return Event{
source: e.source
name: e.name
topic: e.topic
priority: e.priority
tags: e.tags.clone()
message: e.message
raw: e.raw
}
}
pub struct GiteaSource {
pub mut:
name string @[json: 'name']
webhook_path string @[json: 'webhook_path']
topic string @[json: 'topic']
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
repo string @[json: 'repo']
}
pub struct UptimeKumaState {
pub mut:
priority int @[json: 'priority']
tags []string @[json: 'tags']
}
pub struct UptimeKumaSource {
pub mut:
name string @[json: 'name']
webhook_path string @[json: 'webhook_path']
topic string @[json: 'topic']
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
state_map map[string]UptimeKumaState @[json: 'state_map']
}
pub struct DockerSource {
pub mut:
name string @[json: 'name']
topic string @[json: 'topic']
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
hosts []string @[json: 'hosts']
events []string @[json: 'events']
}
pub struct HttpCheck {
pub mut:
url string @[json: 'url']
topic string @[json: 'topic']
priority int @[json: 'priority']
expect_status int @[json: 'expect_status']
timeout int @[json: 'timeout']
template string @[json: 'template']
}
pub struct HttpPollSource {
pub mut:
name string @[json: 'name']
interval int @[json: 'interval']
checks []HttpCheck @[json: 'checks']
}
pub struct CronSource {
pub mut:
name string @[json: 'name']
webhook_path string @[json: 'webhook_path']
topic string @[json: 'topic']
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
}
pub struct GenericSource {
pub mut:
name string @[json: 'name']
webhook_path string @[json: 'webhook_path']
topic string @[json: 'topic']
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
}
// render_source_template applies variable substitution to a template string.
// Placeholders are {varname}. Shared by all source transformers in this module.
pub fn render_source_template(template string, vars map[string]string) string {
mut result := template.clone()
for key, value in vars {
placeholder := '{${key}}'
result = result.replace(placeholder, value)
}
return result
}
+13
View File
@@ -0,0 +1,13 @@
module sources
pub fn transform_generic(raw string, source GenericSource) ?Event {
return Event{
source: 'generic'
name: source.name
topic: source.topic
priority: source.priority_map['default'] or { 0 }
tags: source.tags.clone()
message: raw
raw: raw
}
}
+114
View File
@@ -0,0 +1,114 @@
module sources
import x.json2 as json
pub fn transform_gitea(raw string, source GiteaSource) ?Event {
doc := json.decode[json.Any](raw, json.DecoderOptions{}) or { return none }
obj := doc.as_map()
event_type := detect_gitea_event(obj)
priority := source.priority_map[event_type] or { 0 }
repo := get_string(obj, 'repository.full_name')
mut user := get_string(obj, 'pusher.username')
action := get_string(obj, 'action')
mut title := get_string(obj, 'commits.0.message')
mut sha := get_string(obj, 'commits.0.id')
if event_type == 'pull_request' {
pr := obj['pull_request'] or { json.Any(map[string]json.Any{}) }.as_map()
title = get_string(pr, 'title')
sha = get_string(pr, 'merge_commit_sha')
if user == '' {
user = get_string(pr, 'user.login')
}
} else if event_type == 'issue' {
issue := obj['issue'] or { json.Any(map[string]json.Any{}) }.as_map()
title = get_string(issue, 'title')
sha = ''
if user == '' {
user = get_string(issue, 'user.login')
}
}
template := source.template
mut message := ''
if template != '' {
vars := {
'repo': repo
'user': user
'action': action
'title': title
'sha': sha
}
message = render_source_template(template, vars)
} else {
mut parts := []string{}
if repo != '' {
parts << '[${repo}]'
}
if user != '' {
parts << '${user}'
}
if action != '' {
parts << '${action}:'
}
if title != '' {
parts << '"${title}"'
}
if sha != '' {
short := if sha.len >= 7 { sha[..7] } else { sha }
parts << '(${short})'
}
emoji := match event_type {
'pull_request' { '🔀' }
'issue' { '📝' }
'release' { '🚀' }
else { '🔨' }
}
message = '${emoji} ' + parts.join(' ')
}
return Event{
source: 'gitea'
name: source.name
topic: source.topic
priority: priority
tags: source.tags.clone()
message: message
raw: raw
}
}
fn detect_gitea_event(obj map[string]json.Any) string {
if 'commits' in obj {
return 'push'
}
if 'pull_request' in obj {
return 'pull_request'
}
if 'issue' in obj {
return 'issue'
}
if 'release' in obj {
return 'release'
}
return 'unknown'
}
fn get_string(obj map[string]json.Any, path string) string {
parts := path.split('.')
mut current := obj.clone()
for i, part in parts {
if i == parts.len - 1 {
val := current[part] or { return '' }
return val.str()
}
next := current[part] or { return '' }
current = next.as_map().clone()
}
return ''
}
+37
View File
@@ -0,0 +1,37 @@
module sources
pub fn transform_http_down(check HttpCheck, err string) Event {
mut message := ''
if check.template != '' {
vars := {
'url': check.url
'error': err
'status': ''
}
message = render_source_template(check.template, vars)
} else {
message = '❌ ${check.url} → ${err}'
}
return Event{
source: 'http_poll'
name: check.url
topic: check.topic
priority: check.priority
tags: ['x']
message: message
raw: err
}
}
pub fn transform_http_up(check HttpCheck) Event {
message := '✅ ${check.url} is UP'
return Event{
source: 'http_poll'
name: check.url
topic: check.topic
priority: 1
tags: ['white_check_mark']
message: message
raw: ''
}
}
+53
View File
@@ -0,0 +1,53 @@
module sources
import x.json2 as json
pub fn transform_uptime_kuma(raw string, source UptimeKumaSource) ?Event {
doc := json.decode[json.Any](raw, json.DecoderOptions{}) or { return none }
obj := doc.as_map()
monitor := get_string(obj, 'monitor.name')
status := get_string(obj, 'heartbeat.status')
msg := get_string(obj, 'heartbeat.msg')
ping := get_string(obj, 'heartbeat.ping')
time_str := get_string(obj, 'heartbeat.time')
state_cfg := source.state_map[status] or { UptimeKumaState{} }
priority := state_cfg.priority
mut tags := state_cfg.tags.clone()
if source.tags.len > 0 && tags.len == 0 {
tags << source.tags
}
emoji := if status == 'up' { '✅' } else { '🚨' }
mut message := ''
if source.template != '' {
vars := {
'monitor': monitor
'status': status
'msg': msg
'ping': ping
'time': time_str
}
message = render_source_template(source.template, vars)
} else {
message = '${emoji} ${monitor} is ${status.to_upper()}'
if msg != '' {
message += ' — ${msg}'
}
if time_str != '' {
message += ' — ${time_str}'
}
}
return Event{
source: 'uptime_kuma'
name: source.name
topic: source.topic
priority: priority
tags: tags
message: message
raw: raw
}
}
+15
View File
@@ -0,0 +1,15 @@
module main
import sources
// apply_defaults fills in default priority and tags when the event doesn't set them.
pub fn apply_defaults(event sources.Event, defaults DefaultConfig) sources.Event {
mut e := event.clone()
if e.priority == 0 {
e.priority = defaults.priority
}
if e.tags.len == 0 {
e.tags = defaults.tags.clone()
}
return e
}
+20
View File
@@ -0,0 +1,20 @@
server:
url: https://ntfy.example.com
listen: ":9090"
defaults:
priority: 3
sources:
gitea:
- name: "test1"
webhook_path: /webhooks/same
topic: test
generic:
- name: "test2"
webhook_path: /webhooks/same
topic: test
dedup:
enabled: false
ttl_seconds: 300
rate_limit:
max_per_minute: 10
max_per_source: 30
+41
View File
@@ -0,0 +1,41 @@
module main
import sources
fn test_transform_uptime_kuma_down() {
raw := '{"monitor":{"name":"Gitea"},"heartbeat":{"status":"down","msg":"503","time":"14:32"}}'
src := sources.UptimeKumaSource{
name: 'Critical'
webhook_path: '/webhooks/kuma'
topic: 'alerts'
state_map: {
'down': sources.UptimeKumaState{
priority: 5
tags: ['rotating_light']
}
}
}
event := sources.transform_uptime_kuma(raw, src) or { panic(err) }
assert event.source == 'uptime_kuma'
assert event.priority == 5
assert event.message.contains('Gitea')
assert event.message.contains('DOWN')
}
fn test_transform_uptime_kuma_up() {
raw := '{"monitor":{"name":"Gitea"},"heartbeat":{"status":"up"}}'
src := sources.UptimeKumaSource{
name: 'Critical'
webhook_path: '/webhooks/kuma'
topic: 'alerts'
state_map: {
'up': sources.UptimeKumaState{
priority: 1
tags: ['white_check_mark']
}
}
}
event := sources.transform_uptime_kuma(raw, src) or { panic(err) }
assert event.priority == 1
assert event.message.contains('UP')
}
+8
View File
@@ -0,0 +1,8 @@
Module {
name: 'ntfy-bridge'
description: 'Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy'
version: '0.1.0'
license: 'MIT'
dependencies: []
subdirs: ['sources']
}
+47
View File
@@ -0,0 +1,47 @@
module main
// WebhookRoute maps a webhook path to its source type and index for O(1) dispatch.
pub enum SourceKind {
gitea
uptime_kuma
cron
generic
}
pub struct WebhookRoute {
kind SourceKind
index int // index in the config's source array
}
// build_webhook_routes constructs a map of webhook_path → WebhookRoute
// for O(1) dispatch instead of scanning all sources on every request.
pub fn build_webhook_routes(cfg Config) map[string]WebhookRoute {
mut routes := map[string]WebhookRoute{}
for i, src in cfg.sources.gitea {
routes[src.webhook_path] = WebhookRoute{
kind: .gitea
index: i
}
}
for i, src in cfg.sources.uptime_kuma {
routes[src.webhook_path] = WebhookRoute{
kind: .uptime_kuma
index: i
}
}
for i, src in cfg.sources.cron {
routes[src.webhook_path] = WebhookRoute{
kind: .cron
index: i
}
}
for i, src in cfg.sources.generic {
routes[src.webhook_path] = WebhookRoute{
kind: .generic
index: i
}
}
return routes
}