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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user