feat: v0.6.0 — ACLs multi-utilisateurs + Plugin system
CI / test (push) Has been cancelled

**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
This commit is contained in:
2026-08-04 22:25:48 -04:00
parent 151dd4a1a8
commit 1a4808f5f7
13 changed files with 1230 additions and 686 deletions
+200
View File
@@ -161,6 +161,9 @@ fn (mut app App) start_background_tasks() {
if app.cfg.sources.docker.len > 0 {
spawn app.docker_watch_loop()
}
if app.cfg.sources.plugin.len > 0 {
spawn app.plugin_loop()
}
// Grouping flush goroutine
spawn app.group_flush_loop()
}
@@ -245,6 +248,7 @@ fn (mut app App) config_api_response() http.Response {
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()
safe['plugin_sources'] = app.cfg.sources.plugin.len.str()
return new_json_response(.ok, json.encode(safe))
}
@@ -288,6 +292,14 @@ fn (mut app App) handle_webhook(req http.Request) http.Response {
return new_json_response(.not_found, '{"error":"unknown webhook"}')
}
// ACL check — validate client IP/token before HMAC
client_ip := extract_client_ip(req)
client_token := extract_bearer_token(req)
if !app.check_acl(route, client_ip, client_token) {
app.logger.warn('server', 'webhook', 'ACL denied for ${path} from ${client_ip}')
return new_json_response(.forbidden, '{"error":"access denied"}')
}
// HMAC validation (if secret is configured)
if app.cfg.server.hmac_secret != '' {
sig_header := req.header.get_custom('X-Hub-Signature-256') or { '' }
@@ -315,6 +327,10 @@ fn (mut app App) handle_webhook(req http.Request) http.Response {
src := app.cfg.sources.generic[route.index]
event_opt = sources.transform_generic(req.data, src)
}
.plugin {
// Plugin sources are background tasks, not webhook-triggered
return new_json_response(.bad_request, '{"error":"plugin sources are not webhook-triggered"}')
}
}
event := event_opt or {
@@ -596,7 +612,191 @@ fn (mut app App) flush_group_buffer() {
// v0.5 — Dispatch to outgoing webhooks
app.dispatch_outgoing(e)
// v0.6 — Save state after each flush
app.save_state()
}
app.group_buffer = map[string][]GroupEntry{}
app.group_last_flush = time.now()
}
// ── v0.6 — ACL (Access Control Lists) ───────────────────────────────────
// check_acl validates the client IP and/or token against the webhook route's ACL config.
// Returns true if access is allowed (no ACL = open access).
fn (mut app App) check_acl(route WebhookRoute, client_ip string, client_token string) bool {
acl := app.get_acl_for_route(route)
if acl.allowed_ips.len == 0 && acl.allowed_tokens.len == 0 {
return true // no ACL configured → open access
}
// Check IP first
if acl.allowed_ips.len > 0 {
if client_ip == '' {
return false
}
mut ip_ok := false
for allowed in acl.allowed_ips {
if ip_matches(client_ip, allowed) {
ip_ok = true
break
}
}
if !ip_ok {
return false
}
}
// Check token
if acl.allowed_tokens.len > 0 {
if client_token == '' {
return false
}
mut token_ok := false
for allowed in acl.allowed_tokens {
if client_token == allowed {
token_ok = true
break
}
}
if !token_ok {
return false
}
}
return true
}
// get_acl_for_route returns the ACL config for a given webhook route.
// Returns an empty ACL (open access) if the index is out of range.
fn (mut app App) get_acl_for_route(route WebhookRoute) sources.AclConfig {
match route.kind {
.gitea {
if route.index >= app.cfg.sources.gitea.len {
return sources.AclConfig{}
}
src := app.cfg.sources.gitea[route.index]
return src.acl
}
.uptime_kuma {
if route.index >= app.cfg.sources.uptime_kuma.len {
return sources.AclConfig{}
}
src := app.cfg.sources.uptime_kuma[route.index]
return src.acl
}
.cron {
if route.index >= app.cfg.sources.cron.len {
return sources.AclConfig{}
}
src := app.cfg.sources.cron[route.index]
return src.acl
}
.generic {
if route.index >= app.cfg.sources.generic.len {
return sources.AclConfig{}
}
src := app.cfg.sources.generic[route.index]
return src.acl
}
.plugin {
if route.index >= app.cfg.sources.plugin.len {
return sources.AclConfig{}
}
src := app.cfg.sources.plugin[route.index]
return src.acl
}
}
}
// extract_client_ip extracts the real client IP from request headers or remote address.
fn extract_client_ip(req http.Request) string {
// Check common proxy/forwarded headers first
forwarded_val := req.header.get_custom('X-Forwarded-For') or { '' }
if forwarded_val != '' {
return forwarded_val.split(',')[0].trim_space()
}
real_ip := req.header.get_custom('X-Real-IP') or { '' }
if real_ip != '' {
return real_ip.trim_space()
}
// Fall back to remote address from the connection
// The http.Request doesn't expose the remote addr directly, so we check the header
return ''
}
// extract_bearer_token extracts a Bearer token from the Authorization header.
fn extract_bearer_token(req http.Request) string {
auth := req.header.get(.authorization) or { return '' }
if auth.starts_with('Bearer ') {
return auth[7..].trim_space()
}
return ''
}
// ip_matches checks if an IP address matches a pattern (CIDR or exact IP).
// Supports full CIDR notation (e.g. "192.168.0.0/16") and exact IP matching.
fn ip_matches(ip string, pattern string) bool {
if ip == pattern {
return true
}
if !pattern.contains('/') {
return ip == pattern
}
// CIDR matching
parts := pattern.split('/')
if parts.len != 2 {
return false
}
cidr_ip := parts[0]
cidr_bits := strconv.atoi(parts[1]) or { return false }
ip_parts := parse_ip(ip) or { return false }
cidr_parts := parse_ip(cidr_ip) or { return false }
// Convert to 32-bit integer for IPv4
ip_int := u32(ip_parts[0]) << 24 | u32(ip_parts[1]) << 16 | u32(ip_parts[2]) << 8 | u32(ip_parts[3])
cidr_int := u32(cidr_parts[0]) << 24 | u32(cidr_parts[1]) << 16 | u32(cidr_parts[2]) << 8 | u32(cidr_parts[3])
mask := ~u32(0) << (32 - u32(cidr_bits))
return (ip_int & mask) == (cidr_int & mask)
}
// parse_ip parses an IPv4 dotted-quad string into 4 octets.
fn parse_ip(ip string) ![]u8 {
parts := ip.split('.')
if parts.len != 4 {
return error('invalid IP')
}
mut octets := []u8{}
for part in parts {
val := strconv.atoi(part) or { return error('invalid octet: ${part}') }
if val < 0 || val > 255 {
return error('octet out of range: ${val}')
}
octets << u8(val)
}
return octets
}
// ── v0.6 — Plugin System ────────────────────────────────────────────────
// plugin_loop runs each configured plugin on a timer (every 60s by default).
fn (mut app App) plugin_loop() {
for {
if app.shutdown_flag {
break
}
for i, src in app.cfg.sources.plugin {
event := sources.transform_plugin('', src) or {
app.logger.debug('server', 'plugin', '${src.name}: no event (exit non-zero or error)')
continue
}
app.logger.info('server', 'plugin', '${src.name}: received event → ${src.topic}')
app.publish(event)
// Prevent unused variable warning for `i`
_ = i
}
time.sleep(60 * time.second)
}
}