Skip to content

Commit f7e83ff

Browse files
committed
webhook: per-topic filter on the bridge + pilotctl --topics
The webhook plugin subscribed to "*" and forwarded every bus event, including high-volume internal topics (tunnel.relay_activated, conn.established, security.*, pubsub.*). Operators who only wanted the human-meaningful events (message.received, file.received, handshake.received, trust.changed) had no way to filter at the source. This change: - Service gains a topics map<string, struct{}> read by the bridge goroutine before each Emit. Nil = forward all = pre-filter default (backwards-compatible — existing webhook configs unchanged). - Persisted to ~/.pilot/webhook_topics (one topic per line, # comments and blank lines ignored). Survives daemon restart. - WebhookManager interface extended: SetTopics / Topics. All four existing test stubs updated. - New IPC opcodes CmdSetWebhookTopics / CmdSetWebhookTopicsOK (0x1D / 0x1E). Driver gets a SetWebhookTopics method. - pilotctl set-webhook accepts --topics t1,t2,... and --clear-topics. The URL and topic filter are independent IPCs so neither flag mutates the other. Tests cover topicSet normalization, persist round-trip, comment handling, and the SetTopics/Topics operator surface. Existing webhook tests still pass.
1 parent 32808dd commit f7e83ff

14 files changed

Lines changed: 401 additions & 15 deletions

File tree

cmd/daemon/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ func main() {
230230
// than in the plugin to keep the plugin free of pkg/daemon imports.
231231
type webhookManagerAdapter struct{ svc *webhook.Service }
232232

233-
func (a webhookManagerAdapter) SetURL(url string) { a.svc.SetURL(url) }
233+
func (a webhookManagerAdapter) SetURL(url string) { a.svc.SetURL(url) }
234+
func (a webhookManagerAdapter) SetTopics(topics []string) { a.svc.SetTopics(topics) }
235+
func (a webhookManagerAdapter) Topics() []string { return a.svc.Topics() }
234236
func (a webhookManagerAdapter) Stats() daemon.WebhookStats {
235237
s := a.svc.Stats()
236238
return daemon.WebhookStats{Dropped: s.Dropped, CircuitSkips: s.CircuitSkips}

cmd/pilotctl/main.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2896,17 +2896,48 @@ func cmdClearHostname() {
28962896
}
28972897

28982898
func cmdSetWebhook(args []string) {
2899-
if len(args) < 1 {
2900-
fatalCode("invalid_argument", "usage: pilotctl set-webhook <url>")
2899+
// Flags accepted after the positional <url>:
2900+
// --topics <a,b,c> only forward events with these topics (empty = forward all)
2901+
// --clear-topics clear any previously-set topic filter (= forward all)
2902+
var url string
2903+
var topics []string
2904+
var topicsSet, clearTopics bool
2905+
for i := 0; i < len(args); i++ {
2906+
switch args[i] {
2907+
case "--topics":
2908+
if i+1 >= len(args) {
2909+
fatalCode("invalid_argument", "--topics requires a comma-separated list")
2910+
}
2911+
topicsSet = true
2912+
for _, t := range strings.Split(args[i+1], ",") {
2913+
if tt := strings.TrimSpace(t); tt != "" {
2914+
topics = append(topics, tt)
2915+
}
2916+
}
2917+
i++
2918+
case "--clear-topics":
2919+
clearTopics = true
2920+
default:
2921+
if url == "" && !strings.HasPrefix(args[i], "--") {
2922+
url = args[i]
2923+
}
2924+
}
2925+
}
2926+
if url == "" {
2927+
fatalCode("invalid_argument", "usage: pilotctl set-webhook <url> [--topics t1,t2,...] [--clear-topics]")
29012928
}
2902-
url := args[0]
29032929
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
29042930
fatalCode("invalid_argument", "webhook URL must start with http:// or https://")
29052931
}
29062932

29072933
// Persist to config so it survives daemon restart
29082934
cfg := loadConfig()
29092935
cfg["webhook"] = url
2936+
if topicsSet {
2937+
cfg["webhook_topics"] = topics
2938+
} else if clearTopics {
2939+
delete(cfg, "webhook_topics")
2940+
}
29102941
if err := saveConfig(cfg); err != nil {
29112942
fatalCode("internal", "save config: %v", err)
29122943
}
@@ -2916,19 +2947,38 @@ func cmdSetWebhook(args []string) {
29162947
d, err := driver.Connect(getSocket())
29172948
if err == nil {
29182949
_, err = d.SetWebhook(url)
2919-
d.Close()
29202950
if err == nil {
29212951
applied = true
2952+
// Topic filter is a separate IPC call; apply when --topics
2953+
// or --clear-topics was given. Don't touch the daemon's
2954+
// current topic filter if neither flag was set.
2955+
if topicsSet {
2956+
_, _ = d.SetWebhookTopics(topics)
2957+
} else if clearTopics {
2958+
_, _ = d.SetWebhookTopics(nil)
2959+
}
29222960
}
2961+
d.Close()
29232962
}
29242963

29252964
if jsonOutput {
2926-
outputOK(map[string]interface{}{
2965+
out := map[string]interface{}{
29272966
"webhook": url,
29282967
"applied": applied,
2929-
})
2968+
}
2969+
if topicsSet {
2970+
out["topics"] = topics
2971+
} else if clearTopics {
2972+
out["topics_cleared"] = true
2973+
}
2974+
outputOK(out)
29302975
} else {
29312976
fmt.Printf("webhook set: %s\n", url)
2977+
if topicsSet {
2978+
fmt.Printf("topic filter: %s\n", strings.Join(topics, ","))
2979+
} else if clearTopics {
2980+
fmt.Printf("topic filter cleared (forwarding all events)\n")
2981+
}
29322982
if applied {
29332983
fmt.Printf("applied to running daemon\n")
29342984
} else {

pkg/daemon/contract.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ type WebhookManager interface {
145145
// delivery (no-op until set again).
146146
SetURL(url string)
147147

148+
// SetTopics hot-swaps the event-topic allow-list. Empty/nil =
149+
// forward every event (legacy pre-filter behavior). A non-empty
150+
// list filters at the bridge before each Emit.
151+
SetTopics(topics []string)
152+
153+
// Topics returns a snapshot of the current allow-list for
154+
// introspection. Nil/empty = "forward all."
155+
Topics() []string
156+
148157
// Stats returns dispatcher counters for daemon Info. All-zero
149158
// when no client is configured (nil-safe at the plugin level).
150159
Stats() WebhookStats

pkg/daemon/daemon.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,24 @@ func (d *Daemon) SetWebhookURL(url string) {
17721772
d.webhookManager.SetURL(url)
17731773
}
17741774

1775+
// SetWebhookTopics hot-swaps the event-topic allow-list. Empty/nil =
1776+
// forward all events (legacy). No-op if no manager is registered.
1777+
func (d *Daemon) SetWebhookTopics(topics []string) {
1778+
if d.webhookManager == nil {
1779+
return
1780+
}
1781+
d.webhookManager.SetTopics(topics)
1782+
}
1783+
1784+
// WebhookTopics returns the currently-configured topic allow-list (or
1785+
// nil for "forward all"). Used by daemon Info / pilotctl introspection.
1786+
func (d *Daemon) WebhookTopics() []string {
1787+
if d.webhookManager == nil {
1788+
return nil
1789+
}
1790+
return d.webhookManager.Topics()
1791+
}
1792+
17751793
// RegisterWebhookManager wires the L11 webhook plugin into the daemon
17761794
// (T4.1). cmd/daemon (composition root) constructs the plugin and
17771795
// calls this with an adapter satisfying the daemon-local interface.

pkg/daemon/ipc.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ const (
4747
CmdDeregisterOK byte = 0x18
4848
CmdSetTags byte = 0x19
4949
CmdSetTagsOK byte = 0x1A
50-
CmdSetWebhook byte = 0x1B
51-
CmdSetWebhookOK byte = 0x1C
52-
CmdNetwork byte = 0x1F
50+
CmdSetWebhook byte = 0x1B
51+
CmdSetWebhookOK byte = 0x1C
52+
CmdSetWebhookTopics byte = 0x1D
53+
CmdSetWebhookTopicsOK byte = 0x1E
54+
CmdNetwork byte = 0x1F
5355
CmdNetworkOK byte = 0x20
5456
CmdHealth byte = 0x21
5557
CmdHealthOK byte = 0x22
@@ -630,6 +632,8 @@ func (s *IPCServer) dispatch(conn *ipcConn, cmd byte, reqID uint64, payload []by
630632
s.handleSetTags(conn, reqID, payload)
631633
case CmdSetWebhook:
632634
s.handleSetWebhook(conn, reqID, payload)
635+
case CmdSetWebhookTopics:
636+
s.handleSetWebhookTopics(conn, reqID, payload)
633637
case CmdNetwork:
634638
s.handleNetwork(conn, reqID, payload)
635639
case CmdHealth:
@@ -1179,6 +1183,25 @@ func (s *IPCServer) handleSetWebhook(conn *ipcConn, reqID uint64, payload []byte
11791183
}
11801184
}
11811185

1186+
// handleSetWebhookTopics swaps the event-topic allow-list. Payload is
1187+
// JSON: either an array of strings (the topic names) or empty/null
1188+
// (clears the filter — forwards every event, legacy default).
1189+
func (s *IPCServer) handleSetWebhookTopics(conn *ipcConn, reqID uint64, payload []byte) {
1190+
var topics []string
1191+
if len(payload) > 0 {
1192+
if err := json.Unmarshal(payload, &topics); err != nil {
1193+
s.sendError(conn, reqID, fmt.Sprintf("invalid topics payload: %v", err))
1194+
return
1195+
}
1196+
}
1197+
s.daemon.SetWebhookTopics(topics)
1198+
result := map[string]interface{}{"topics": topics}
1199+
data, _ := json.Marshal(result)
1200+
if err := conn.writeReply(CmdSetWebhookTopicsOK, reqID, data); err != nil {
1201+
slog.Debug("IPC set_webhook_topics reply failed", "err", err)
1202+
}
1203+
}
1204+
11821205
// Handshake IPC sub-commands
11831206
const (
11841207
SubHandshakeSend byte = 0x01

pkg/daemon/zz_info_health_metrics_bug_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
type infoFakeWebhookManager struct{ stats WebhookStats }
2323

2424
func (f *infoFakeWebhookManager) SetURL(string) {}
25+
func (f *infoFakeWebhookManager) SetTopics([]string) {}
26+
func (f *infoFakeWebhookManager) Topics() []string { return nil }
2527
func (f *infoFakeWebhookManager) Stats() WebhookStats { return f.stats }
2628

2729
func TestInfoZerosWebhookCountersWhenNoManagerRegistered(t *testing.T) {

pkg/daemon/zz_ipc_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ type fakeWebhookManager struct {
357357
}
358358

359359
func (f *fakeWebhookManager) SetURL(url string) { f.urls = append(f.urls, url) }
360+
func (f *fakeWebhookManager) SetTopics([]string) {}
361+
func (f *fakeWebhookManager) Topics() []string { return nil }
360362
func (f *fakeWebhookManager) Stats() WebhookStats { return WebhookStats{} }
361363

362364
func TestHandleSetWebhookEmptyPayloadClearsWebhookAndRepliesOK(t *testing.T) {

pkg/daemon/zz_retx_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
type retxFakeWebhookManager struct{ urls []string }
2222

2323
func (f *retxFakeWebhookManager) SetURL(url string) { f.urls = append(f.urls, url) }
24+
func (f *retxFakeWebhookManager) SetTopics([]string) {}
25+
func (f *retxFakeWebhookManager) Topics() []string { return nil }
2426
func (f *retxFakeWebhookManager) Stats() WebhookStats { return WebhookStats{} }
2527

2628
func TestSetWebhookURLDelegatesToRegisteredManager(t *testing.T) {

pkg/driver/driver.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,20 @@ func (d *Driver) SetWebhook(url string) (map[string]interface{}, error) {
317317
return d.jsonRPC(msg, cmdSetWebhookOK, "set_webhook")
318318
}
319319

320+
// SetWebhookTopics sets or clears the daemon's webhook event-topic
321+
// allow-list. Empty / nil slice clears the filter (forward every
322+
// event, legacy default). Non-empty slice filters at the bridge.
323+
func (d *Driver) SetWebhookTopics(topics []string) (map[string]interface{}, error) {
324+
body, err := json.Marshal(topics)
325+
if err != nil {
326+
return nil, err
327+
}
328+
msg := make([]byte, 1+len(body))
329+
msg[0] = cmdSetWebhookTopics
330+
copy(msg[1:], body)
331+
return d.jsonRPC(msg, cmdSetWebhookTopicsOK, "set_webhook_topics")
332+
}
333+
320334
// RotateKey asks the daemon to rotate its Ed25519 identity at the registry.
321335
// The daemon generates a new keypair, signs proof of the current key, calls
322336
// registry.RotateKey, then atomically swaps and persists the new identity.

pkg/driver/ipc.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ const (
4141
cmdDeregisterOK byte = 0x18
4242
cmdSetTags byte = 0x19
4343
cmdSetTagsOK byte = 0x1A
44-
cmdSetWebhook byte = 0x1B
45-
cmdSetWebhookOK byte = 0x1C
46-
cmdNetwork byte = 0x1F
44+
cmdSetWebhook byte = 0x1B
45+
cmdSetWebhookOK byte = 0x1C
46+
cmdSetWebhookTopics byte = 0x1D
47+
cmdSetWebhookTopicsOK byte = 0x1E
48+
cmdNetwork byte = 0x1F
4749
cmdNetworkOK byte = 0x20
4850
cmdHealth byte = 0x21
4951
cmdHealthOK byte = 0x22
@@ -183,7 +185,7 @@ func (c *ipcClient) readLoop() {
183185
c.dispatchPush(cmd, payload)
184186
case cmdBindOK, cmdDialOK, cmdError, cmdInfoOK, cmdHandshakeOK,
185187
cmdResolveHostnameOK, cmdSetHostnameOK, cmdSetVisibilityOK,
186-
cmdDeregisterOK, cmdSetTagsOK, cmdSetWebhookOK, cmdNetworkOK,
188+
cmdDeregisterOK, cmdSetTagsOK, cmdSetWebhookOK, cmdSetWebhookTopicsOK, cmdNetworkOK,
187189
cmdHealthOK, cmdManagedOK, cmdRotateKeyOK, cmdBroadcastOK:
188190
// Known response cmds: route to pending for the in-flight sendAndWait.
189191
select {

0 commit comments

Comments
 (0)