From 7c8ef7ae02ce4cc8eecb08d07c32cce956005b39 Mon Sep 17 00:00:00 2001 From: Asad Iqbal Date: Mon, 29 Jun 2026 15:00:46 +0500 Subject: [PATCH] feat(auth): send User-Agent version header + handle 426 Upgrade Required (cli#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0001 §13 / §14 R11 / Appendix C.1 — the CLI half of the minimum-supported-CLI-version handshake (server half: backend#888, shipped in backend#916). - Every backend request now carries `User-Agent: tracebloc-cli/ (/)`, injected by a transport wrapper on the shared internal/api client so login + provisioning are both covered without threading the version through each command. The version is the ldflags build info, recorded once via api.SetUserAgent in NewRootCmd; a build with no version reports "dev" (unparseable server-side → fails open). - A 426 from any endpoint is detected centrally in post/get and surfaced as a typed *UpgradeRequiredError carrying min_version, so every command degrades to the same actionable "your CLI is too old — upgrade to >= X" message and a clean non-zero exit (no stack trace), in both interactive and --plain modes. Tests: UA on the wire, dev fallback, 426 → UpgradeRequiredError (GET + POST), unparseable-426 body, message actionability. Closes #98. Co-Authored-By: Claude Opus 4.8 --- internal/api/client.go | 95 +++++++++++++++++++++++++++++++++- internal/api/client_test.go | 100 ++++++++++++++++++++++++++++++++++++ internal/cli/root.go | 5 ++ 3 files changed, 198 insertions(+), 2 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index f0e8187c..a04bd1a5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -16,6 +16,7 @@ import ( "net/http" "net/url" "os" + "runtime" "strings" "time" ) @@ -29,6 +30,56 @@ const ( const defaultTimeout = 30 * time.Second +// ── User-Agent: minimum-CLI-version handshake (RFC-0001 §13 / §14 R11 / C.1) ── +// +// Every backend request announces the CLI build as +// +// User-Agent: tracebloc-cli/ (/) +// +// so the backend's MIN_SUPPORTED_CLI_VERSION gate (backend#888) can answer a +// too-old client with 426 Upgrade Required. The version is the ldflags-injected +// build version, recorded once at startup; until then (e.g. a bare `go run`) it +// reports "dev", which the backend can't parse and therefore lets through — the +// right fail-open for local development. + +// userAgent is the formatted header value, set once via SetUserAgent at startup. +// A package var (not a constructor arg) so every Client built anywhere — login, +// client provisioning — carries it without threading the version through each +// command; it mirrors the build metadata that already lives as a global in main. +var userAgent string + +// SetUserAgent records the CLI version used in the User-Agent on every backend +// request. Call once from cli.NewRootCmd with the ldflags-injected version. +func SetUserAgent(version string) { + if version == "" { + version = "dev" + } + userAgent = fmt.Sprintf("tracebloc-cli/%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH) +} + +// currentUserAgent is the header value to send, with a "dev" fallback for builds +// that never called SetUserAgent (tests, `go run`). +func currentUserAgent() string { + if userAgent != "" { + return userAgent + } + return fmt.Sprintf("tracebloc-cli/dev (%s/%s)", runtime.GOOS, runtime.GOARCH) +} + +// userAgentTransport injects the CLI User-Agent on every request that doesn't +// already set one. It wraps the real transport (which keeps proxy + system-CA +// behavior). Per the http.RoundTripper contract it clones the request before +// mutating headers rather than modifying the caller's request in place. +type userAgentTransport struct{ base http.RoundTripper } + +func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", currentUserAgent()) + } + return t.base.RoundTrip(req) +} + // BaseURL maps a CLIENT_ENV value to the backend base URL — kept in lock-step // with the installer's `_backend_url` and client-runtime's CLIENT_ENV→backend // mapping. Unknown / empty → prod. @@ -73,8 +124,10 @@ func New(env string) *Client { return &Client{ BaseURL: strings.TrimRight(BaseURL(env), "/"), HTTP: &http.Client{ - Timeout: defaultTimeout, - Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + Timeout: defaultTimeout, + // Wrap the proxy/CA-aware transport so every request carries the + // tracebloc-cli User-Agent (RFC-0001 §14 R11 / backend#888). + Transport: userAgentTransport{base: &http.Transport{Proxy: http.ProxyFromEnvironment}}, }, } } @@ -90,6 +143,38 @@ func (e *APIError) Error() string { return fmt.Sprintf("%s returned HTTP %d: %s", e.URL, e.StatusCode, strings.TrimSpace(e.Body)) } +// UpgradeRequiredError is returned for an HTTP 426 from any endpoint: the CLI is +// below the backend's MIN_SUPPORTED_CLI_VERSION floor (RFC-0001 §14 R11 / +// backend#888). It's detected centrally (in post/get) so every call degrades to +// the same actionable "upgrade your CLI" message instead of a raw HTTP error. +type UpgradeRequiredError struct { + MinVersion string // the server's minimum supported version, when it tells us +} + +func (e *UpgradeRequiredError) Error() string { + floor := "a newer version" + if e.MinVersion != "" { + floor = ">= " + e.MinVersion + } + return fmt.Sprintf( + "this tracebloc CLI is too old for the server (requires %s). Upgrade to the "+ + "latest release — re-run the tracebloc install script, or download it from "+ + "https://github.com/tracebloc/cli/releases/latest — then retry.", + floor, + ) +} + +// parseUpgradeRequired builds an *UpgradeRequiredError from a 426 body +// ({"error":"upgrade_required","min_version":"X"}). min_version is best-effort: +// the 426 status is the contract, so a body we can't parse still upgrades. +func parseUpgradeRequired(raw []byte) *UpgradeRequiredError { + var body struct { + MinVersion string `json:"min_version"` + } + _ = json.Unmarshal(raw, &body) + return &UpgradeRequiredError{MinVersion: body.MinVersion} +} + // post sends an optional JSON body and returns the status code + raw response. func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, error) { var rdr io.Reader @@ -116,6 +201,9 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, if err != nil { return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err) } + if resp.StatusCode == http.StatusUpgradeRequired { + return resp.StatusCode, raw, parseUpgradeRequired(raw) + } return resp.StatusCode, raw, nil } @@ -146,6 +234,9 @@ func (c *Client) get(ctx context.Context, path string) (int, []byte, error) { if err != nil { return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err) } + if resp.StatusCode == http.StatusUpgradeRequired { + return resp.StatusCode, raw, parseUpgradeRequired(raw) + } return resp.StatusCode, raw, nil } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index e091b79c..08a00f19 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/http/httptest" + "runtime" + "strings" "testing" ) @@ -253,3 +255,101 @@ func TestListClients_BareArrayUnpaginated(t *testing.T) { t.Fatalf("want 2 clients [1,2] from a bare array, got %+v", got) } } + +// ── cli#98: User-Agent version header + 426 Upgrade Required (RFC-0001 §14 R11) ── + +// TestUserAgentHeaderSent proves the transport wrapper puts the configured +// version on the wire for a real request (here a GET via WhoAmI). +func TestUserAgentHeaderSent(t *testing.T) { + old := userAgent + defer func() { userAgent = old }() + SetUserAgent("7.7.7") + + want := "tracebloc-cli/7.7.7 (" + runtime.GOOS + "/" + runtime.GOARCH + ")" + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{"email":"x","type":"y","account":"z"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "t" + if _, err := c.WhoAmI(context.Background()); err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("User-Agent = %q, want %q", got, want) + } +} + +// TestUserAgentDevFallback: a build that never called SetUserAgent (or with an +// empty version) reports "dev" — which the backend can't parse, so it fails open. +func TestUserAgentDevFallback(t *testing.T) { + old := userAgent + defer func() { userAgent = old }() + + want := "tracebloc-cli/dev (" + runtime.GOOS + "/" + runtime.GOARCH + ")" + userAgent = "" + if got := currentUserAgent(); got != want { + t.Errorf("unset → currentUserAgent() = %q, want %q", got, want) + } + SetUserAgent("") + if got := currentUserAgent(); got != want { + t.Errorf(`SetUserAgent("") → %q, want %q`, got, want) + } +} + +// TestUpgradeRequired426 pins the central 426 handling: a 426 from any endpoint +// (GET or POST) surfaces as a typed *UpgradeRequiredError carrying min_version, +// not a raw *APIError — so every command degrades to the same upgrade message. +func TestUpgradeRequired426(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUpgradeRequired) // 426 + _, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"0.4.0"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "t" + + var ue *UpgradeRequiredError + if _, err := c.WhoAmI(context.Background()); !errors.As(err, &ue) || ue.MinVersion != "0.4.0" { + t.Errorf("GET on 426: want *UpgradeRequiredError{0.4.0}, got %v", err) + } + ue = nil + if _, err := c.RequestDeviceCode(context.Background()); !errors.As(err, &ue) || ue.MinVersion != "0.4.0" { + t.Errorf("POST on 426: want *UpgradeRequiredError{0.4.0}, got %v", err) + } +} + +// TestUpgradeRequiredErrorMessage: the message is actionable (names the floor) +// and stays sensible when the server didn't send a min_version. +func TestUpgradeRequiredErrorMessage(t *testing.T) { + msg := (&UpgradeRequiredError{MinVersion: "0.4.0"}).Error() + if !strings.Contains(msg, "0.4.0") || !strings.Contains(msg, "too old") { + t.Errorf("message not actionable: %q", msg) + } + if got := (&UpgradeRequiredError{}).Error(); strings.Contains(got, ">=") { + t.Errorf("empty min_version should not print a bare '>=': %q", got) + } +} + +// TestUpgradeRequired426_UnparseableBody: a 426 whose body doesn't parse still +// yields an *UpgradeRequiredError (the status is the contract), with no min_version. +func TestUpgradeRequired426_UnparseableBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUpgradeRequired) + _, _ = w.Write([]byte(`not json`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + var ue *UpgradeRequiredError + if _, err := c.RequestDeviceCode(context.Background()); !errors.As(err, &ue) { + t.Fatalf("want *UpgradeRequiredError even on unparseable body, got %v", err) + } + if ue.MinVersion != "" { + t.Errorf("MinVersion = %q, want empty", ue.MinVersion) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0bf6f8c0..6c21011c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/tracebloc/cli/internal/api" "github.com/tracebloc/cli/internal/ui" ) @@ -32,6 +33,10 @@ type BuildInfo struct { // global state has historically been a source of test interference, // and constructing fresh trees per call is cheap. func NewRootCmd(info BuildInfo) *cobra.Command { + // Record the CLI version for the User-Agent sent on every backend request + // (RFC-0001 §14 R11 / backend#888): "tracebloc-cli/ (/)". + api.SetUserAgent(info.Version) + root := &cobra.Command{ Use: "tracebloc", Short: "tracebloc — interactive data ingestion for your cluster",