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