diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index eac13755..d61d97b0 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -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. @@ -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, diff --git a/pkg/telemetry/client.go b/pkg/telemetry/client.go index e823e4af..10368801 100644 --- a/pkg/telemetry/client.go +++ b/pkg/telemetry/client.go @@ -19,6 +19,8 @@ import ( "strings" "sync" "time" + + "github.com/pilot-protocol/common/crypto" ) // DefaultEndpoint is the production telemetry ingestion URL. @@ -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). diff --git a/pkg/telemetry/client_test.go b/pkg/telemetry/client_test.go index df86cd07..5683d573 100644 --- a/pkg/telemetry/client_test.go +++ b/pkg/telemetry/client_test.go @@ -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) { @@ -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 {