Files
ntfy-bridge/config.v
T
bruno 4e1d7cd94a 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.
2026-08-03 12:12:12 -04:00

153 lines
4.2 KiB
V

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
}