diff --git a/.github/workflows/deploy-telemetry.yml b/.github/workflows/deploy-telemetry.yml index 3d71a50..bef536e 100644 --- a/.github/workflows/deploy-telemetry.yml +++ b/.github/workflows/deploy-telemetry.yml @@ -65,6 +65,28 @@ jobs: --env-file deploy/telemetry/.env \ -f deploy/telemetry/docker-compose.yml \ restart caddy + public_host="$(sed -n 's/^TELEMETRY_PUBLIC_HOST=//p' deploy/telemetry/.env | tail -n 1)" + test -n "$public_host" + printf '%s' "$public_host" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' + for attempt in $(seq 1 30); do + if curl -fsS --connect-timeout 5 --max-time 15 "https://$public_host/readyz" >/dev/null; then + break + fi + test "$attempt" -lt 30 + sleep 2 + done + for user_agent in ti/deploy-smoke tdc/deploy-smoke; do + response="$(curl -sS --connect-timeout 5 --max-time 15 \ + -H 'Content-Type: application/json' \ + -H "User-Agent: $user_agent" \ + --data '{}' \ + -w '\n%{http_code}' \ + "https://$public_host/v1/telemetry/batch")" + status="${response##*$'\n'}" + body="${response%$'\n'*}" + test "$status" = "400" + printf '%s' "$body" | grep -F '"message":"schema validation failed"' >/dev/null + done docker compose \ --env-file deploy/telemetry/.env \ -f deploy/telemetry/docker-compose.yml \ diff --git a/README.md b/README.md index f0474a7..07079fa 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Use `TI_LOGGING=off` to disable logging for one process. Accepted values are `on Release builds collect minimal command usage and reliability telemetry through the ti-owned ingestion service. Events contain canonical command and explicitly supplied flag names, stable exit and error codes, duration, region, ti version, OS, architecture, install source, and a random installation ID. They never contain flag values, credentials, tokens, SQL text, paths, file contents, command output, API payloads, profile names, or cloud resource IDs. +During the v0.2 migration window, the ingestion backend accepts both current `ti/` and legacy `tdc/` User-Agent values. All other User-Agent prefixes are rejected before payload validation. + Telemetry is disabled by default for development builds and recognized CI environments. To disable it persistently for release builds, create or edit `~/.ti/.preferences`: ```toml diff --git a/docs/telemetry-backend-design.md b/docs/telemetry-backend-design.md index 2571528..0e122e3 100644 --- a/docs/telemetry-backend-design.md +++ b/docs/telemetry-backend-design.md @@ -94,6 +94,8 @@ Content-Type: application/json User-Agent: ti/ ``` +During the v0.2 migration window, the backend also accepts the legacy `User-Agent: tdc/` value. No other User-Agent prefix is accepted. Deployment smoke tests must verify both accepted prefixes against the public endpoint; a schema-invalid probe must reach payload validation instead of failing header validation. + Request limits: - Body size: default 64 KiB. diff --git a/internal/telemetrybackend/server.go b/internal/telemetrybackend/server.go index c88047b..895a617 100644 --- a/internal/telemetrybackend/server.go +++ b/internal/telemetrybackend/server.go @@ -263,10 +263,16 @@ func validContentType(raw string) bool { } func validUserAgent(value string) bool { - return strings.HasPrefix(value, "ti/") && - len(value) > len("ti/") && - len(value) <= 128 && - !strings.ContainsAny(value, "\r\n\t ") + prefixLength := 0 + switch { + case strings.HasPrefix(value, "ti/"): + prefixLength = len("ti/") + case strings.HasPrefix(value, "tdc/"): + prefixLength = len("tdc/") + default: + return false + } + return len(value) > prefixLength && len(value) <= 128 && !strings.ContainsAny(value, "\r\n\t ") } var errBodyTooLarge = errors.New("request body too large") diff --git a/internal/telemetrybackend/server_test.go b/internal/telemetrybackend/server_test.go index 900075b..d5ee231 100644 --- a/internal/telemetrybackend/server_test.go +++ b/internal/telemetrybackend/server_test.go @@ -37,6 +37,32 @@ func TestServerAcceptsValidBatchWith202BeforeFlush(t *testing.T) { } } +func TestServerAcceptsCurrentAndLegacyCLIUserAgents(t *testing.T) { + for _, userAgent := range []string{"ti/0.2.3", "tdc/0.1.7"} { + t.Run(userAgent, func(t *testing.T) { + cfg := testConfig() + batcher := NewBatcher(cfg, nil, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + request := newBatchRequest(validRequestBody()) + request.Header.Set("User-Agent", userAgent) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusAccepted, response.Body.String()) + } + }) + } +} + +func TestValidUserAgentRejectsMalformedOrUntrustedValues(t *testing.T) { + tooLong := "ti/" + strings.Repeat("v", 126) + for _, userAgent := range []string{"", "ti/", "tdc/", "curl/8", "ti/0.2.3 debug", "tdc/0.1.7\n", tooLong} { + if validUserAgent(userAgent) { + t.Errorf("validUserAgent(%q) = true", userAgent) + } + } +} + func TestServerRejectsInvalidRequestsWithGenericErrors(t *testing.T) { cfg := testConfig() cfg.MaxBodyBytes = int64(len(validRequestBody()))