Files
ntfy-bridge/main.v
T
bruno 1a4808f5f7
CI / test (push) Has been cancelled
feat: v0.6.0 — ACLs multi-utilisateurs + Plugin system
**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
2026-08-04 22:25:48 -04:00

269 lines
9.3 KiB
V

module main
import os
import flag
const version = '0.6.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')
install_svc := fp.bool('install-service', 0, false, 'Install as a system service (auto-detects OS)')
uninstall_svc := fp.bool('uninstall-service', 0, false, 'Uninstall the system service')
svc_status := fp.bool('service-status', 0, false, 'Check if the service is installed and running')
install_docker := fp.string('install-docker', 0, '', 'Generate docker-compose stack in <dir>')
install_docker_prom := fp.string('install-docker-prometheus', 0, '', 'Generate docker-compose + Prometheus stack in <dir>')
// 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
}
if install_docker != '' {
install_docker_stack(install_docker, false) or {
eprintln('Error: ${err}')
exit(1)
return
}
return
}
if install_docker_prom != '' {
install_docker_stack(install_docker_prom, true) or {
eprintln('Error: ${err}')
exit(1)
return
}
return
}
if install_svc {
service_install(config_path, quiet) or {
eprintln('Error: ${err}')
exit(1)
return
}
println('')
println('Service installed. Edit ${config_path} to configure sources,')
println('then restart the service to apply changes.')
return
}
if uninstall_svc {
service_uninstall() or {
eprintln('Error: ${err}')
exit(1)
return
}
return
}
if svc_status {
service_status() or {
eprintln('Error: ${err}')
exit(1)
return
}
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, config_path, 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('Service management:')
println(' --install-service Install as a system service (auto-detects OS)')
println(' --uninstall-service Uninstall the system service')
println(' --service-status Check if the service is installed and running')
println('')
println('Docker deployment:')
println(' --install-docker <dir> Generate docker-compose stack in <dir>')
println(' --install-docker-prometheus <dir> Generate docker-compose + Prometheus stack')
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 { '' }}')
}
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:')
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 = '══════════════════════════════════════════════════════════════'