Skip to content
Closed
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
32 changes: 32 additions & 0 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (
"github.com/pilot-protocol/app-store/pkg/ipc"
"github.com/pilot-protocol/app-store/pkg/manifest"
"github.com/pilot-protocol/common/crypto"

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

// cryptoSHA256 is named so the sha256 import isn't ambiguous-looking.
Expand Down Expand Up @@ -1209,6 +1211,36 @@ func cmdAppStoreInstall(args []string) {
Reason: reason,
})

// Emit a telemetry event for the successful install (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort: a send failure is logged but not fatal — the install
// itself already succeeded on disk.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
sourceStr := "catalogue"
if source == installSourceLocal {
sourceStr = "local"
}
payload, _ := json.Marshal(map[string]string{
"app_id": m.ID,
"version": m.AppVersion,
"source": sourceStr,
})
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "app_installed",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
if err != nil {
slog.Warn("telemetry send failed, install still successful", "app", m.ID, "err", err)
}
}

report := installReport{
AppID: m.ID,
AppVersion: m.AppVersion,
Expand Down
28 changes: 28 additions & 0 deletions pkg/telemetry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"strings"
"sync"
"time"

"github.com/pilot-protocol/common/crypto"
)

// DefaultEndpoint is the production telemetry ingestion URL.
Expand Down Expand Up @@ -151,6 +153,32 @@ func (c *Client) Send(events ...Event) error {
return nil
}

// NewClientFromIdentity creates a consent-gated telemetry client from an
// Ed25519 identity file on disk and a telemetry URL. When url is empty
// the client is a permanent no-op. Returns nil if the identity file does
// not exist (first run).
func NewClientFromIdentity(url, identityPath string, nodeID int64) *Client {
c := New(url, nodeID)
if c.disabled || url == "" {
return c
}

id, err := crypto.LoadIdentity(identityPath)
if err != nil {
slog.Warn("telemetry: can't load identity, staying disabled", "path", identityPath, "err", err)
return c
}
if id == nil {
slog.Debug("telemetry: no identity file yet, staying disabled", "path", identityPath)
return c
}

slog.Debug("telemetry: identity loaded, enabling client", "path", identityPath,
"pubkey", crypto.EncodePublicKey(id.PublicKey))
c.SetSigner(id.Sign, crypto.EncodePublicKey(id.PublicKey))
return c
}

// 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).
Expand Down
62 changes: 62 additions & 0 deletions pkg/telemetry/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"testing"

"github.com/pilot-protocol/common/crypto"
)

func TestNewNoop(t *testing.T) {
Expand Down Expand Up @@ -48,6 +52,64 @@ func TestSendErrorsOnBadURL(t *testing.T) {
}
}

func TestNewClientFromIdentity(t *testing.T) {
// Empty URL = no-op, no identity file needed
c := NewClientFromIdentity("", "/nonexistent/identity.json", 0)
if err := c.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
t.Fatal("no-op with no url should not error:", err)
}

// Non-empty URL with no identity file = noop (file doesn't exist = first run)
c2 := NewClientFromIdentity("http://example.com/v1/events", "/nonexistent/identity.json", 42)
if err := c2.Send(Event{Kind: "test", Payload: []byte(`{}`)}); err != nil {
t.Fatal("no identity file should be no-op, not error:", err)
}

// Valid identity file
dir := t.TempDir()
idPath := filepath.Join(dir, "identity.json")
id, err := crypto.GenerateIdentity()
if err != nil {
t.Fatal(err)
}
if err := crypto.SaveIdentity(idPath, id); err != nil {
t.Fatal(err)
}

c3 := NewClientFromIdentity("http://example.com/v1/events", idPath, 7)
if c3.disabled {
t.Fatal("expected enabled for valid identity + URL")
}
if c3.sign == nil {
t.Fatal("expected signer to be set")
}
if c3.pubKeyB == "" {
t.Fatal("expected public key to be set")
}
}

func TestNewClientFromIdentityLoosePerms(t *testing.T) {
dir := t.TempDir()
idPath := filepath.Join(dir, "identity.json")
id, err := crypto.GenerateIdentity()
if err != nil {
t.Fatal(err)
}
if err := crypto.SaveIdentity(idPath, id); err != nil {
t.Fatal(err)
}
// Make the identity file world-readable — simulates a manual restore
if err := os.Chmod(idPath, 0644); err != nil {
t.Fatal(err)
}

// LoadIdentity refuses loose perms; client should warn and stay noop
c := NewClientFromIdentity("http://example.com/v1/events", idPath, 7)
if c.sign != nil {
t.Fatal("expected no signer for loose-permissions identity file")
}
}

func TestSignMessageRoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
Expand Down
Loading