feat: v0.6.0 — ACLs multi-utilisateurs + Plugin system
CI / test (push) Failing after 1m18s

**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:14:37 -04:00
parent 151dd4a1a8
commit 0e73f14b6e
13 changed files with 1229 additions and 686 deletions
+21
View File
@@ -35,6 +35,13 @@ pub:
clear bool // whether to dismiss notification after action
}
// v0.6 — ACL per webhook path
pub struct AclConfig {
pub mut:
allowed_ips []string @[json: 'allowed_ips'] // CIDR ranges or single IPs
allowed_tokens []string @[json: 'allowed_tokens'] // bearer tokens
}
pub struct GiteaSource {
pub mut:
name string @[json: 'name']
@@ -44,6 +51,7 @@ pub mut:
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
repo string @[json: 'repo']
acl AclConfig @[json: 'acl']
}
pub struct UptimeKumaState {
@@ -61,6 +69,7 @@ pub mut:
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
state_map map[string]UptimeKumaState @[json: 'state_map']
acl AclConfig @[json: 'acl']
}
pub struct DockerSource {
@@ -99,6 +108,7 @@ pub mut:
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
acl AclConfig @[json: 'acl']
}
pub struct GenericSource {
@@ -109,6 +119,17 @@ pub mut:
template string @[json: 'template']
priority_map map[string]int @[json: 'priority_map']
tags []string @[json: 'tags']
acl AclConfig @[json: 'acl']
}
// v0.6 — Plugin source: external executable that receives JSON on stdin, outputs JSON on stdout
pub struct PluginSource {
pub mut:
name string @[json: 'name']
topic string @[json: 'topic']
command string @[json: 'command'] // path to executable
timeout int @[json: 'timeout'] // seconds (default: 10)
acl AclConfig @[json: 'acl']
}
// render_source_template applies variable substitution to a template string.
+86
View File
@@ -0,0 +1,86 @@
module sources
import os
import time
import x.json2 as json
// transform_plugin runs an external executable, passes the raw event on stdin,
// and expects a JSON Event on stdout. Exit code 0 = success, non-zero = drop.
// timeout defaults to 10s if not set.
pub fn transform_plugin(raw string, source PluginSource) ?Event {
timeout := if source.timeout > 0 { source.timeout } else { 10 }
// Write raw input to a temp file for the plugin to read (optional)
// We use os.execute with timeout via a wrapper
start := time.now()
// Run the plugin command. The plugin receives nothing on stdin;
// it's a polling model — the plugin is called periodically and
// produces output when it has something to report.
result := os.execute(source.command)
elapsed := time.now().unix() - start.unix()
if elapsed > timeout {
return none
}
// Exit code 0 = event, non-zero = skip
if result.exit_code != 0 {
return none
}
output := result.output.trim_space()
if output == '' {
return none
}
// Parse plugin output as JSON
doc := json.decode[json.Any](output, json.DecoderOptions{}) or { return none }
obj := doc.as_map()
priority := obj['priority'] or { json.Any(3) }.int()
mut tags := []string{}
if tags_any := obj['tags'] {
for tag in tags_any.as_array() {
tags << tag.str()
}
}
message := obj['message'] or { json.Any(output) }.str()
click_url := obj['click_url'] or { json.Any('') }.str()
mut actions := []Action{}
if actions_any := obj['actions'] {
for a in actions_any.as_array() {
am := a.as_map()
actions << Action{
action: am['action'] or { json.Any('view') }.str()
label: am['label'] or { json.Any('') }.str()
url: am['url'] or { json.Any('') }.str()
clear: am['clear'] or { json.Any(false) }.bool()
}
}
}
// Resolve topic: plugin output can override, or fall back to source config
mut topic := source.topic
if plugin_topic := obj['topic'] {
t := plugin_topic.str()
if t != '' {
topic = t
}
}
return Event{
source: 'plugin'
name: source.name
topic: topic
priority: int(priority)
tags: tags
message: message
raw: output
click_url: click_url
actions: actions
}
}