Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/pilot-protocol/webhook"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

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

// L11 plugin lifecycle (T7.1): composition root owns the
Expand Down
6 changes: 6 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ type Config struct {
// Feature flags — ablation testing. All default false (current behavior).
BeaconRTTProbe bool // probe beacon RTT; override hash pick when >2× slower than best

// Telemetry consent gate. When set to the telemetry endpoint URL,
// the daemon initialises a telemetry client that emits signed events
// (install, usage, view, review). When empty (default), the client
// is a hard no-op: no dial, no buffering, no goroutines.
TelemetryURL string

// Compat-mode transport. Default empty ("" or "udp") = today's
// behavior: bind a UDP socket via udpio.Listen. Set "compat" to
// dial WSS to BeaconURL instead (for daemons in UDP-blocked
Expand Down
169 changes: 169 additions & 0 deletions pkg/telemetry/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

// Package telemetry provides a consent-gated telemetry client that signs
// event POSTs with the node's Ed25519 identity and sends them to a
// configured telemetry endpoint. When the consent flag is off (empty URL),
// the client is a hard no-op: no dial, no buffering, no goroutines.
package telemetry

import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)

// DefaultEndpoint is the production telemetry ingestion URL.
const DefaultEndpoint = "https://telemetry.pilotprotocol.network/v1/events"

// Canonical signing header names, matching the telemetry server's
// internal/sig contract.
const (
HeaderTimestamp = "X-Pilot-Timestamp"
HeaderPubKey = "X-Pilot-Public-Key"
HeaderSignature = "X-Pilot-Signature"
)

// Event is the wire shape sent to the telemetry endpoint.
type Event struct {
EventID string `json:"event_id"`
Kind string `json:"kind"`
TS string `json:"ts"` // RFC3339; empty = server defaults to receive time
NodeID int64 `json:"node_id,omitempty"`
Payload json.RawMessage `json:"payload"`
}

// Client is a consent-gated telemetry sender. Zero value is a no-op.
type Client struct {
mu sync.Mutex
url string // empty = no-op
nodeID int64 // node ID included in events
sign signFunc // ed25519 signer (set via SetSigner)
pubKeyB string // base64-encoded public key
once sync.Once // lazy init guard
initErr error // capture init failures
disabled bool // true when url is empty
}

type signFunc func(msg []byte) []byte

// New creates a consent-gated telemetry client.
// When url is empty the client is a permanent no-op.
func New(url string, nodeID int64) *Client {
return &Client{
url: strings.TrimSpace(url),
nodeID: nodeID,
disabled: strings.TrimSpace(url) == "",
}
}

// SetSigner installs the Ed25519 signing function and the corresponding
// base64-encoded public key. Must be called before the first Send call.
// When signer is nil the client stays disabled.
func (c *Client) SetSigner(sign signFunc, pubKeyB64 string) {
c.mu.Lock()
defer c.mu.Unlock()
if sign == nil || pubKeyB64 == "" {
c.sign = nil
c.pubKeyB = ""
return
}
c.sign = sign
c.pubKeyB = pubKeyB64
}

// Send POSTs one or more events to the telemetry endpoint. Returns
// immediately (no-op) when the client is disabled (no consent) or
// no signer is configured.
//
// The request is Ed25519-signed with the node's identity, following
// the telemetry server's signing contract:
// - X-Pilot-Timestamp: unix seconds (decimal string)
// - X-Pilot-Public-Key: base64(std) of the 32-byte Ed25519 public key
// - X-Pilot-Signature: base64(std) of the Ed25519 signature over
// (timestamp + "\n" + body)
func (c *Client) Send(events ...Event) error {
c.mu.Lock()
disabled := c.disabled
url := c.url
sign := c.sign
pubKeyB := c.pubKeyB
c.mu.Unlock()

if disabled || url == "" {
slog.Debug("telemetry: consent off, dropping events", "count", len(events))
return nil
}
if sign == nil {
slog.Debug("telemetry: no signer configured, dropping events", "count", len(events))
return nil
}

if len(events) == 0 {
return nil
}

body, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("telemetry marshal: %w", err)
}

ts := strconv.FormatInt(time.Now().Unix(), 10)
// Build signed message as ts + newline + body.
// Avoid pre-allocated capacity hint: len() sum could overflow on 32-bit
// platforms with very large body values (CodeQL SAST).
message := make([]byte, 0, len(ts)+1)
message = append(message, ts...)
message = append(message, '\n')
message = append(message, body...)
sigB64 := base64.StdEncoding.EncodeToString(sign(message))

req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("telemetry new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(HeaderTimestamp, ts)
req.Header.Set(HeaderPubKey, pubKeyB)
req.Header.Set(HeaderSignature, sigB64)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("telemetry post: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telemetry server %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
}

// Drain body so the connection can be reused
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

// SignMessage implements the signing contract directly, without an HTTP
// POST. Useful for tests and for components that want to sign arbitrary
// byte payloads. Returns (timestamp, pubKeyB64, signatureB64, error).
func SignMessage(priv ed25519.PrivateKey, body []byte) (ts, pubB64, sigB64 string, err error) {
if len(body) == 0 {
return "", "", "", fmt.Errorf("telemetry: cannot sign empty body")
}
pub := priv.Public().(ed25519.PublicKey)
ts = strconv.FormatInt(time.Now().Unix(), 10)
message := make([]byte, 0, len(ts)+1+len(body))
message = append(message, ts...)
message = append(message, '\n')
message = append(message, body...)
sig := ed25519.Sign(priv, message)
return ts, base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(sig), nil
}
88 changes: 88 additions & 0 deletions pkg/telemetry/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package telemetry

import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"testing"
)

func TestNewNoop(t *testing.T) {
c := New("", 0)
if err := c.Send(); err != nil {
t.Fatal("no-op send should not error:", err)
}
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
t.Fatal("no-op send with events should not error:", err)
}
}

func TestNewNoSigner(t *testing.T) {
c := New("http://example.com/v1/events", 42)
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
t.Fatal("send without signer should be no-op, not error:", err)
}
}

func TestDisabledOnEmptyURL(t *testing.T) {
c1 := New("", 0)
if !c1.disabled {
t.Fatal("expected disabled for empty URL")
}
c2 := New(" ", 0)
if !c2.disabled {
t.Fatal("expected disabled for whitespace-only URL")
}
c3 := New("https://example.com/v1/events", 0)
if c3.disabled {
t.Fatal("expected enabled for valid URL")
}
}

func TestSendErrorsOnBadURL(t *testing.T) {
c := New("http://127.0.0.1:1/events", 1)
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
t.Fatal("no signer means no-op, not error:", err)
}
}

func TestSignMessageRoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
body := []byte(`{"kind":"test","ts":"2026-01-01T00:00:00Z","payload":{}}`)

ts, pubB64, sigB64, err := SignMessage(priv, body)
if err != nil {
t.Fatal("SignMessage failed:", err)
}
if ts == "" || pubB64 == "" || sigB64 == "" {
t.Fatal("expected non-empty outputs")
}

// Reconstruct the canonical message
msg := make([]byte, 0, len(ts)+1+len(body))
msg = append(msg, ts...)
msg = append(msg, '\n')
msg = append(msg, body...)

decodedPub, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatal("decode pubkey:", err)
}
decodedSig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
t.Fatal("decode sig:", err)
}
if !ed25519.Verify(ed25519.PublicKey(decodedPub), msg, decodedSig) {
t.Fatal("signature verification failed on round-trip")
}

// Also verify public key matches
if !ed25519.PublicKey(decodedPub).Equal(pub) {
t.Fatal("public key mismatch")
}
}
Loading