diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index f8239afe..ef59768e 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -2969,9 +2969,10 @@ func cmdSetWebhook(args []string) { // Apply to running daemon (best-effort — daemon may not be running) applied := false + adminToken := getAdminToken() d, err := driver.Connect(getSocket()) if err == nil { - _, err = d.SetWebhook(url) + _, err = d.SetWebhook(url, adminToken) d.Close() if err == nil { applied = true @@ -3002,9 +3003,10 @@ func cmdClearWebhook() { // Apply to running daemon (best-effort) applied := false + adminToken := getAdminToken() d, err := driver.Connect(getSocket()) if err == nil { - _, err = d.SetWebhook("") + _, err = d.SetWebhook("", adminToken) d.Close() if err == nil { applied = true diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index b695d3c1..a70df4fd 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -4,6 +4,7 @@ package daemon import ( "context" + "crypto/subtle" "encoding/binary" "encoding/json" "errors" @@ -192,6 +193,8 @@ var ErrIPCClosed = errors.New("ipc: connection closed") // code paths. var ErrIPCBackpressure = errors.New("ipc: backpressure (client too slow)") +var errSetWebhookAuth = errors.New("ipc: set_webhook requires admin token") + // IPCEnvelopeHeaderSize is the size of the per-message header that sits // inside the ipcutil length-framed envelope: 1 byte cmd. const IPCEnvelopeHeaderSize = 1 @@ -1171,8 +1174,30 @@ func (s *IPCServer) handleRotateKey(conn *ipcConn, reqID uint64) { } } +// handleSetWebhook services CmdSetWebhook — admin-token-gated webhook URL change. +// Wire payload: [tokenLen(2)][token...][url...] +// +// On success, replies with CmdSetWebhookOK carrying {"webhook": url}. +// On failure, replies with CmdError. func (s *IPCServer) handleSetWebhook(conn *ipcConn, reqID uint64, payload []byte) { - url := string(payload) // empty string = clear webhook + // Admin token required: [tokenLen(2)][token...][url...] + if len(payload) < 2 { + s.sendError(conn, reqID, "set_webhook: missing admin token") + return + } + tokenLen := binary.BigEndian.Uint16(payload[0:2]) + if len(payload) < 2+int(tokenLen) { + s.sendError(conn, reqID, "set_webhook: truncated admin token") + return + } + if s.daemon.config.AdminToken != "" { + token := string(payload[2 : 2+tokenLen]) + if subtle.ConstantTimeCompare([]byte(token), []byte(s.daemon.config.AdminToken)) != 1 { + s.sendError(conn, reqID, "set_webhook: invalid admin token") + return + } + } + url := string(payload[2+tokenLen:]) // empty string = clear webhook if url != "" { if err := ValidateWebhookURL(url); err != nil { s.sendError(conn, reqID, err.Error()) diff --git a/pkg/daemon/zz_ipc_simple_handlers_test.go b/pkg/daemon/zz_ipc_simple_handlers_test.go index b044e90b..c9e6dbd9 100644 --- a/pkg/daemon/zz_ipc_simple_handlers_test.go +++ b/pkg/daemon/zz_ipc_simple_handlers_test.go @@ -437,7 +437,7 @@ func TestHandleSetWebhookEmptyPayloadClearsWebhookReturnsOK(t *testing.T) { t.Parallel() _, s := newSimpleHandlerDaemon(t, nil) ic, client := newIPCTestConn(t) - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte{}) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte{0x00, 0x00}) }) if reply[0] != CmdSetWebhookOK { t.Fatalf("opcode 0x%02X, want CmdSetWebhookOK", reply[0]) @@ -455,7 +455,7 @@ func TestHandleSetWebhookInvalidURLReturnsError(t *testing.T) { t.Parallel() _, s := newSimpleHandlerDaemon(t, nil) ic, client := newIPCTestConn(t) - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte("ftp://example.com/hook")) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, append([]byte{0x00, 0x00}, []byte("ftp://example.com/hook")...)) }) if reply[0] != CmdError { t.Fatalf("opcode 0x%02X, want CmdError on invalid URL", reply[0]) } @@ -466,7 +466,7 @@ func TestHandleSetWebhookValidHTTPSURLAcceptedAndEchoedBack(t *testing.T) { _, s := newSimpleHandlerDaemon(t, nil) ic, client := newIPCTestConn(t) url := "https://example.com/hook" - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte(url)) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, append([]byte{0x00, 0x00}, []byte(url)...)) }) if reply[0] != CmdSetWebhookOK { t.Fatalf("opcode 0x%02X, want CmdSetWebhookOK", reply[0]) diff --git a/pkg/daemon/zz_ipc_test.go b/pkg/daemon/zz_ipc_test.go index 71d05420..94d3601d 100644 --- a/pkg/daemon/zz_ipc_test.go +++ b/pkg/daemon/zz_ipc_test.go @@ -367,7 +367,7 @@ func TestHandleSetWebhookEmptyPayloadClearsWebhookAndRepliesOK(t *testing.T) { s := d.ipc ic, client := newIPCTestConn(t) - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, nil) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte{0x00, 0x00}) }) if reply[0] != CmdSetWebhookOK { t.Fatalf("reply[0] = 0x%02X, want CmdSetWebhookOK", reply[0]) @@ -390,7 +390,7 @@ func TestHandleSetWebhookInvalidSchemeSendsError(t *testing.T) { s := d.ipc ic, client := newIPCTestConn(t) - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte("ftp://evil.example.com/hook")) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, append([]byte{0x00, 0x00}, []byte("ftp://evil.example.com/hook")...)) }) assertErrorReply(t, reply, "scheme") } @@ -402,7 +402,7 @@ func TestHandleSetWebhookValidHTTPSConfiguresAndRepliesOK(t *testing.T) { s := d.ipc ic, client := newIPCTestConn(t) - reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, []byte("https://example.com/hook")) }) + reply := runHandler(t, client, func() { s.handleSetWebhook(ic, 0, append([]byte{0x00, 0x00}, []byte("https://example.com/hook")...)) }) if reply[0] != CmdSetWebhookOK { t.Fatalf("reply[0] = 0x%02X, want CmdSetWebhookOK", reply[0]) diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index 5d2b6208..b01c5bbf 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -323,11 +323,16 @@ func (d *Driver) SetTags(tags []string) (map[string]interface{}, error) { } // SetWebhook sets or clears the daemon's webhook URL at runtime. -// An empty URL disables the webhook. -func (d *Driver) SetWebhook(url string) (map[string]interface{}, error) { - msg := make([]byte, 1+len(url)) +// An empty URL disables the webhook. The adminToken must match the +// daemon's configured AdminToken (empty passes through). +// Wire format: [cmd(1)][tokenLen(2)][token...][url...] +func (d *Driver) SetWebhook(url, adminToken string) (map[string]interface{}, error) { + tokenLen := len(adminToken) + msg := make([]byte, 3+tokenLen+len(url)) msg[0] = cmdSetWebhook - copy(msg[1:], url) + binary.BigEndian.PutUint16(msg[1:3], uint16(tokenLen)) + copy(msg[3:], adminToken) + copy(msg[3+tokenLen:], url) return d.jsonRPC(msg, cmdSetWebhookOK, "set_webhook") } diff --git a/pkg/driver/zz_driver_test.go b/pkg/driver/zz_driver_test.go index 7d2070f9..0922f95f 100644 --- a/pkg/driver/zz_driver_test.go +++ b/pkg/driver/zz_driver_test.go @@ -550,7 +550,7 @@ func TestRegistryWrappersEncodeCorrectly(t *testing.T) { if _, err := drv.SetTags([]string{"a", "b"}); err != nil { t.Fatalf("SetTags: %v", err) } - if _, err := drv.SetWebhook("https://x/y"); err != nil { + if _, err := drv.SetWebhook("https://x/y", ""); err != nil { t.Fatalf("SetWebhook: %v", err) } @@ -566,8 +566,10 @@ func TestRegistryWrappersEncodeCorrectly(t *testing.T) { t.Errorf("ResolveHostname host = %q", f[1:]) } case cmdSetWebhook: - if string(f[1:]) != "https://x/y" { - t.Errorf("SetWebhook url = %q", f[1:]) + // wire format: [cmd(1)][tokenLen(2)][token...][url...] + // tokenLen=0, so url starts at f[3:] + if string(f[3:]) != "https://x/y" { + t.Errorf("SetWebhook url = %q", f[3:]) } } } diff --git a/tests/zz_ipc_ops_test.go b/tests/zz_ipc_ops_test.go index e3bbae48..b0f86234 100644 --- a/tests/zz_ipc_ops_test.go +++ b/tests/zz_ipc_ops_test.go @@ -160,7 +160,7 @@ func TestSetWebhookViaIPC(t *testing.T) { di := env.AddDaemon() // Set webhook - result, err := di.Driver.SetWebhook("http://localhost:9999/hooks") + result, err := di.Driver.SetWebhook("http://localhost:9999/hooks", "") if err != nil { t.Fatalf("set webhook: %v", err) } @@ -169,7 +169,7 @@ func TestSetWebhookViaIPC(t *testing.T) { } // Clear webhook - result, err = di.Driver.SetWebhook("") + result, err = di.Driver.SetWebhook("", "") if err != nil { t.Fatalf("clear webhook: %v", err) }