Skip to content

Commit 8437fc1

Browse files
committed
feat(telemetry): consent-gated telemetry client with Ed25519 signing (PILOT-400)
Adds a self-contained telemetry client package that emits signed events to a configured endpoint only when consent is active (--telemetry-url non-empty). When the flag is empty (default), the client is a hard no-op: no dial, no buffering, no goroutines. The client signs all requests with the node's Ed25519 identity following the canonical telemetry signing contract (X-Pilot-Timestamp, X-Pilot-Public-Key, X-Pilot-Signature). This matches the telemetry server's internal/sig verification at pilot-protocol/telemetry. Changes: - pkg/telemetry/client.go: consent-gated telemetry client with Signed HTTP POST, lazy disabled init, configurable endpoint - pkg/telemetry/client_test.go: tests for no-op paths, disabled state, SignMessage round-trip verification - cmd/daemon/main.go: --telemetry-url flag (env PILOT_TELEMETRY_URL) - pkg/daemon/daemon.go: TelemetryURL field in Config struct Closes PILOT-400
1 parent 251d798 commit 8437fc1

4 files changed

Lines changed: 265 additions & 0 deletions

File tree

cmd/daemon/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"github.com/pilot-protocol/webhook"
3737

3838
"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
39+
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
3940
)
4041

4142
var version = "dev"
@@ -100,6 +101,9 @@ func main() {
100101
logFormat := flag.String("log-format", "text", "log format (text, json)")
101102
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
102103
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
104+
telemetryURL := flag.String("telemetry-url", os.Getenv("PILOT_TELEMETRY_URL"),
105+
"telemetry endpoint URL (empty = consent off, hard no-op). "+
106+
"Env: PILOT_TELEMETRY_URL. Default: "+telemetry.DefaultEndpoint+".")
103107
flag.Parse()
104108
if *adminToken == "" {
105109
if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" {
@@ -209,6 +213,7 @@ func main() {
209213
CompatTLSTrust: *tlsTrust,
210214
MOTDFeedURL: *motdFeedURL,
211215
MOTDInterval: *motdInterval,
216+
TelemetryURL: *telemetryURL,
212217
})
213218

214219
// L11 plugin lifecycle (T7.1): composition root owns the

pkg/daemon/daemon.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ type Config struct {
133133
// Feature flags — ablation testing. All default false (current behavior).
134134
BeaconRTTProbe bool // probe beacon RTT; override hash pick when >2× slower than best
135135

136+
// Telemetry consent gate. When set to the telemetry endpoint URL,
137+
// the daemon initialises a telemetry client that emits signed events
138+
// (install, usage, view, review). When empty (default), the client
139+
// is a hard no-op: no dial, no buffering, no goroutines.
140+
TelemetryURL string
141+
136142
// Compat-mode transport. Default empty ("" or "udp") = today's
137143
// behavior: bind a UDP socket via udpio.Listen. Set "compat" to
138144
// dial WSS to BeaconURL instead (for daemons in UDP-blocked

pkg/telemetry/client.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
// Package telemetry provides a consent-gated telemetry client that signs
4+
// event POSTs with the node's Ed25519 identity and sends them to a
5+
// configured telemetry endpoint. When the consent flag is off (empty URL),
6+
// the client is a hard no-op: no dial, no buffering, no goroutines.
7+
package telemetry
8+
9+
import (
10+
"bytes"
11+
"crypto/ed25519"
12+
"encoding/base64"
13+
"encoding/json"
14+
"fmt"
15+
"io"
16+
"log/slog"
17+
"net/http"
18+
"strconv"
19+
"strings"
20+
"sync"
21+
"time"
22+
)
23+
24+
// DefaultEndpoint is the production telemetry ingestion URL.
25+
const DefaultEndpoint = "https://telemetry.pilotprotocol.network/v1/events"
26+
27+
// Canonical signing header names, matching the telemetry server's
28+
// internal/sig contract.
29+
const (
30+
HeaderTimestamp = "X-Pilot-Timestamp"
31+
HeaderPubKey = "X-Pilot-Public-Key"
32+
HeaderSignature = "X-Pilot-Signature"
33+
)
34+
35+
// Event is the wire shape sent to the telemetry endpoint.
36+
type Event struct {
37+
EventID string `json:"event_id"`
38+
Kind string `json:"kind"`
39+
TS string `json:"ts"` // RFC3339; empty = server defaults to receive time
40+
NodeID int64 `json:"node_id,omitempty"`
41+
Payload json.RawMessage `json:"payload"`
42+
}
43+
44+
// Client is a consent-gated telemetry sender. Zero value is a no-op.
45+
type Client struct {
46+
mu sync.Mutex
47+
url string // empty = no-op
48+
nodeID int64 // node ID included in events
49+
sign signFunc // ed25519 signer (set via SetSigner)
50+
pubKeyB string // base64-encoded public key
51+
once sync.Once // lazy init guard
52+
initErr error // capture init failures
53+
disabled bool // true when url is empty
54+
}
55+
56+
type signFunc func(msg []byte) []byte
57+
58+
// New creates a consent-gated telemetry client.
59+
// When url is empty the client is a permanent no-op.
60+
func New(url string, nodeID int64) *Client {
61+
return &Client{
62+
url: strings.TrimSpace(url),
63+
nodeID: nodeID,
64+
disabled: strings.TrimSpace(url) == "",
65+
}
66+
}
67+
68+
// SetSigner installs the Ed25519 signing function and the corresponding
69+
// base64-encoded public key. Must be called before the first Send call.
70+
// When signer is nil the client stays disabled.
71+
func (c *Client) SetSigner(sign signFunc, pubKeyB64 string) {
72+
c.mu.Lock()
73+
defer c.mu.Unlock()
74+
if sign == nil || pubKeyB64 == "" {
75+
c.sign = nil
76+
c.pubKeyB = ""
77+
return
78+
}
79+
c.sign = sign
80+
c.pubKeyB = pubKeyB64
81+
}
82+
83+
// Send POSTs one or more events to the telemetry endpoint. Returns
84+
// immediately (no-op) when the client is disabled (no consent) or
85+
// no signer is configured.
86+
//
87+
// The request is Ed25519-signed with the node's identity, following
88+
// the telemetry server's signing contract:
89+
// - X-Pilot-Timestamp: unix seconds (decimal string)
90+
// - X-Pilot-Public-Key: base64(std) of the 32-byte Ed25519 public key
91+
// - X-Pilot-Signature: base64(std) of the Ed25519 signature over
92+
// (timestamp + "\n" + body)
93+
func (c *Client) Send(events ...Event) error {
94+
c.mu.Lock()
95+
disabled := c.disabled
96+
url := c.url
97+
sign := c.sign
98+
pubKeyB := c.pubKeyB
99+
c.mu.Unlock()
100+
101+
if disabled || url == "" {
102+
slog.Debug("telemetry: consent off, dropping events", "count", len(events))
103+
return nil
104+
}
105+
if sign == nil {
106+
slog.Debug("telemetry: no signer configured, dropping events", "count", len(events))
107+
return nil
108+
}
109+
110+
if len(events) == 0 {
111+
return nil
112+
}
113+
114+
body, err := json.Marshal(events)
115+
if err != nil {
116+
return fmt.Errorf("telemetry marshal: %w", err)
117+
}
118+
119+
ts := strconv.FormatInt(time.Now().Unix(), 10)
120+
message := make([]byte, 0, len(ts)+1+len(body))
121+
message = append(message, ts...)
122+
message = append(message, '\n')
123+
message = append(message, body...)
124+
sigB64 := base64.StdEncoding.EncodeToString(sign(message))
125+
126+
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
127+
if err != nil {
128+
return fmt.Errorf("telemetry new request: %w", err)
129+
}
130+
req.Header.Set("Content-Type", "application/json")
131+
req.Header.Set(HeaderTimestamp, ts)
132+
req.Header.Set(HeaderPubKey, pubKeyB)
133+
req.Header.Set(HeaderSignature, sigB64)
134+
135+
resp, err := http.DefaultClient.Do(req)
136+
if err != nil {
137+
return fmt.Errorf("telemetry post: %w", err)
138+
}
139+
defer resp.Body.Close()
140+
141+
if resp.StatusCode >= 300 {
142+
respBody, _ := io.ReadAll(resp.Body)
143+
return fmt.Errorf("telemetry server %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
144+
}
145+
146+
// Drain body so the connection can be reused
147+
_, _ = io.Copy(io.Discard, resp.Body)
148+
return nil
149+
}
150+
151+
// SignMessage implements the signing contract directly, without an HTTP
152+
// POST. Useful for tests and for components that want to sign arbitrary
153+
// byte payloads. Returns (timestamp, pubKeyB64, signatureB64, error).
154+
func SignMessage(priv ed25519.PrivateKey, body []byte) (ts, pubB64, sigB64 string, err error) {
155+
if len(body) == 0 {
156+
return "", "", "", fmt.Errorf("telemetry: cannot sign empty body")
157+
}
158+
pub := priv.Public().(ed25519.PublicKey)
159+
ts = strconv.FormatInt(time.Now().Unix(), 10)
160+
message := make([]byte, 0, len(ts)+1+len(body))
161+
message = append(message, ts...)
162+
message = append(message, '\n')
163+
message = append(message, body...)
164+
sig := ed25519.Sign(priv, message)
165+
return ts, base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(sig), nil
166+
}

pkg/telemetry/client_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package telemetry
4+
5+
import (
6+
"crypto/ed25519"
7+
"crypto/rand"
8+
"encoding/base64"
9+
"testing"
10+
)
11+
12+
func TestNewNoop(t *testing.T) {
13+
c := New("", 0)
14+
if err := c.Send(); err != nil {
15+
t.Fatal("no-op send should not error:", err)
16+
}
17+
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
18+
t.Fatal("no-op send with events should not error:", err)
19+
}
20+
}
21+
22+
func TestNewNoSigner(t *testing.T) {
23+
c := New("http://example.com/v1/events", 42)
24+
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
25+
t.Fatal("send without signer should be no-op, not error:", err)
26+
}
27+
}
28+
29+
func TestDisabledOnEmptyURL(t *testing.T) {
30+
c1 := New("", 0)
31+
if !c1.disabled {
32+
t.Fatal("expected disabled for empty URL")
33+
}
34+
c2 := New(" ", 0)
35+
if !c2.disabled {
36+
t.Fatal("expected disabled for whitespace-only URL")
37+
}
38+
c3 := New("https://example.com/v1/events", 0)
39+
if c3.disabled {
40+
t.Fatal("expected enabled for valid URL")
41+
}
42+
}
43+
44+
func TestSendErrorsOnBadURL(t *testing.T) {
45+
c := New("http://127.0.0.1:1/events", 1)
46+
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
47+
t.Fatal("no signer means no-op, not error:", err)
48+
}
49+
}
50+
51+
func TestSignMessageRoundTrip(t *testing.T) {
52+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
body := []byte(`{"kind":"test","ts":"2026-01-01T00:00:00Z","payload":{}}`)
57+
58+
ts, pubB64, sigB64, err := SignMessage(priv, body)
59+
if err != nil {
60+
t.Fatal("SignMessage failed:", err)
61+
}
62+
if ts == "" || pubB64 == "" || sigB64 == "" {
63+
t.Fatal("expected non-empty outputs")
64+
}
65+
66+
// Reconstruct the canonical message
67+
msg := make([]byte, 0, len(ts)+1+len(body))
68+
msg = append(msg, ts...)
69+
msg = append(msg, '\n')
70+
msg = append(msg, body...)
71+
72+
decodedPub, err := base64.StdEncoding.DecodeString(pubB64)
73+
if err != nil {
74+
t.Fatal("decode pubkey:", err)
75+
}
76+
decodedSig, err := base64.StdEncoding.DecodeString(sigB64)
77+
if err != nil {
78+
t.Fatal("decode sig:", err)
79+
}
80+
if !ed25519.Verify(ed25519.PublicKey(decodedPub), msg, decodedSig) {
81+
t.Fatal("signature verification failed on round-trip")
82+
}
83+
84+
// Also verify public key matches
85+
if !ed25519.PublicKey(decodedPub).Equal(pub) {
86+
t.Fatal("public key mismatch")
87+
}
88+
}

0 commit comments

Comments
 (0)