From 0658683646d88dca2b6528bf4bfec13b9a1fd474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Wei=C3=9Fe?= Date: Thu, 11 Apr 2024 14:27:47 +0200 Subject: [PATCH] cli: add telemetry to CLI --- cli/cmd/common.go | 21 +++++++++ cli/cmd/generate.go | 2 +- cli/cmd/set.go | 2 +- cli/cmd/verify.go | 2 +- cli/telemetry/errors.go | 10 ++++ cli/telemetry/telemetry.go | 93 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 cli/telemetry/errors.go create mode 100644 cli/telemetry/telemetry.go diff --git a/cli/cmd/common.go b/cli/cmd/common.go index f89cb3c6cd..2d0ff8e9fc 100644 --- a/cli/cmd/common.go +++ b/cli/cmd/common.go @@ -1,9 +1,14 @@ package cmd import ( + "context" _ "embed" "os" "path/filepath" + "time" + + "github.com/edgelesssys/contrast/cli/telemetry" + "github.com/spf13/cobra" ) const ( @@ -44,3 +49,19 @@ func must(err error) { panic(err) } } + +func withTelemetry(runFunc func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cmdErr := runFunc(cmd, args) + + if os.Getenv("DO_NOT_TRACK") != "1" { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + cl := telemetry.NewClient() + _ = cl.SendTelemetry(ctx, cmd, cmdErr) + } + + return cmdErr + } +} diff --git a/cli/cmd/generate.go b/cli/cmd/generate.go index 66c6236e3f..851c69132a 100644 --- a/cli/cmd/generate.go +++ b/cli/cmd/generate.go @@ -48,7 +48,7 @@ If the Kubernetes YAML contains a Contrast Coordinator pod whose policy differs the embedded default, the generated policy will be printed to stdout, alongside a warning message on stderr. This hash needs to be passed to the set and verify subcommands.`, - RunE: runGenerate, + RunE: withTelemetry(runGenerate), } cmd.Flags().StringP("policy", "p", rulesFilename, "path to policy (.rego) file") diff --git a/cli/cmd/set.go b/cli/cmd/set.go index e8affc9c1c..c2f4a2e8f0 100644 --- a/cli/cmd/set.go +++ b/cli/cmd/set.go @@ -45,7 +45,7 @@ reference values embedded into the CLI. After the connection is established, the manifest is set. The Coordinator will re-generate the mesh root certificate and accept new workloads to issuer certificates.`, - RunE: runSet, + RunE: withTelemetry(runSet), } cmd.Flags().StringP("manifest", "m", manifestFilename, "path to manifest (.json) file") diff --git a/cli/cmd/verify.go b/cli/cmd/verify.go index ae9a5a4539..9521bef381 100644 --- a/cli/cmd/verify.go +++ b/cli/cmd/verify.go @@ -33,7 +33,7 @@ reference values embedded into the CLI. After the connection is established, the CLI will request the manifest history, all policies, and the certificates of the Coordinator certificate authority.`, - RunE: runVerify, + RunE: withTelemetry(runVerify), } // Override persistent workspace-dir flag with a default value. diff --git a/cli/telemetry/errors.go b/cli/telemetry/errors.go new file mode 100644 index 0000000000..8577acfa15 --- /dev/null +++ b/cli/telemetry/errors.go @@ -0,0 +1,10 @@ +package telemetry + +// classifyCmdErr maps errors to fixed strings to avoid leaking sensitive data inside error messages. +func classifyCmdErr(e error) string { + _ = e + switch { + default: + return "unknown error" + } +} diff --git a/cli/telemetry/telemetry.go b/cli/telemetry/telemetry.go new file mode 100644 index 0000000000..d41f23711e --- /dev/null +++ b/cli/telemetry/telemetry.go @@ -0,0 +1,93 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "runtime" + + "github.com/spf13/cobra" +) + +const ( + apiHost = "telemetry.confidential.cloud" + telemetryPath = "api/contrast/v1" +) + +// RequestV1 holds the information to be sent to the telemetry server. +type RequestV1 struct { + Version string `json:"version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Cmd string `json:"cmd"` + CmdErrString string `json:"cmderrstring"` + Test bool `json:"test" gorm:"-"` +} + +// IsTest checks if the request is used for testing. +func (r *RequestV1) IsTest() bool { + return r.Test +} + +// Client sends the telemetry. +type Client struct { + httpClient *http.Client +} + +// NewClient creates a new Client. +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{}, + } +} + +// SendTelemetry sends telemetry data to the telemetry server. +func (c *Client) SendTelemetry(ctx context.Context, cmd *cobra.Command, cmdErr error) error { + cmdErrString := classifyCmdErr(cmdErr) + + if cmd.Root().Version == "" { + return fmt.Errorf("no cli version found") + } + + telemetryRequest := RequestV1{ + Version: cmd.Root().Version, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Cmd: cmd.Name(), + CmdErrString: cmdErrString, + Test: false, + } + + reqBody, err := json.Marshal(telemetryRequest) + if err != nil { + return fmt.Errorf("marshalling input: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, telemetryURL().String(), bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("doing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http error %d", resp.StatusCode) + } + + return nil +} + +func telemetryURL() *url.URL { + return &url.URL{ + Scheme: "https", + Host: apiHost, + Path: telemetryPath, + } +}