Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,26 @@ jobs:
- name: scripts/sync-schema.sh --check
run: ./scripts/sync-schema.sh --check

installer:
timeout-minutes: 10
name: Installer (shell)
# The curl|sh installer is the most privileged code we ship (it places the
# binary on PATH) and had NO automated test until R8. shellcheck it under
# the POSIX sh dialect it actually runs as, parse it with dash, and run the
# functional harness that asserts cosign verification is mandatory / fails
# closed when cosign is absent (RFC-0001 R8, backend#889).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: shellcheck + dash parse
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck dash
shellcheck --shell=sh --severity=error scripts/install.sh
dash -n scripts/install.sh
bash -n scripts/tests/install-verify.sh
- name: Verification harness (mandatory cosign / fail-closed)
run: bash scripts/tests/install-verify.sh

test:
timeout-minutes: 15
name: Test
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,15 @@ tracebloc dataset push ./my-data \

> **`tracebloc: command not found` after installing?** The binary installs to `~/.local/bin` when `/usr/local/bin` isn't writable, and an already-running shell won't see the new PATH entry until you open a new terminal (or `. ~/.bashrc`). See **[Troubleshooting installation](docs/troubleshooting.md)**.

> **Signature verification is mandatory.** The installer verifies the binary's
> SHA256 **and** its cosign signature before installing. If `cosign` isn't on
> PATH it bootstraps a pinned, checksum-verified copy; if it can't, the install
> **fails closed** rather than trusting the same-channel checksum alone (it no
> longer silently skips the signature). The one escape, for a genuinely
> constrained environment, is to re-run with `TRACEBLOC_ALLOW_UNVERIFIED=1` —
> which prints a loud warning. For the highest trust, pre-install `cosign`
> (`brew install cosign`, your package manager, or the [released binary](https://github.com/sigstore/cosign/releases)) before running the installer. (RFC-0001 R8.)

What that runs under the curtain:

1. Reads kubeconfig, discovers the parent `tracebloc/client` release in the cluster
Expand Down
112 changes: 110 additions & 2 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import (
"net/http"
"net/url"
"os"
"runtime"
"strings"
"time"
)
Expand All@@ -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/<version> (<os>/<arch>)
//
// 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.
Expand DownExpand Up@@ -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}},
},
}
}
Expand All@@ -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
Expand All@@ -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
}

Expand DownExpand Up@@ -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
}

Expand DownExpand Up@@ -251,6 +342,23 @@ func (c *Client) WhoAmI(ctx context.Context) (*Identity, error) {
return &id, nil
}

// RevokeToken revokes the presenting credential server-side via POST /auth/revoke
// (backend#887, shipped in backend#903): Bearer in, 204 out, idempotent. `logout`
// calls this so a copied/leaked token stops authenticating after sign-out — local
// clearing alone left it valid (RFC-0001 §7.5 / R2). Requires Token. A non-2xx is
// returned as an *APIError; callers treat the call as best-effort.
func (c *Client) RevokeToken(ctx context.Context) error {
url := c.BaseURL + "/auth/revoke"
status, raw, err := c.post(ctx, "/auth/revoke", nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return &APIError{StatusCode: status, Body: string(raw), URL: url}
}
return nil
}

// ── Client provisioning (Bearer-authed) — backend#836, /edge-device/ ──

// ProvisionedClient is a tracebloc client (machine), as returned by the
Expand Down
141 changes: 141 additions & 0 deletions internal/api/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
)

Expand DownExpand Up@@ -253,3 +255,142 @@ 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)
}
}

// ── cli#112: logout server-side revoke (POST /auth/revoke, backend#887) ──

// TestRevokeToken: a 204 from the endpoint → nil, with Bearer + POST on the wire.
func TestRevokeToken(t *testing.T) {
var sawAuth, sawMethod, sawPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth, sawMethod, sawPath = r.Header.Get("Authorization"), r.Method, r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "usertoken123"
if err := c.RevokeToken(context.Background()); err != nil {
t.Fatalf("revoke: %v", err)
}
if sawMethod != http.MethodPost || sawPath != "/auth/revoke" {
t.Errorf("revoke hit %s %s, want POST /auth/revoke", sawMethod, sawPath)
}
if sawAuth != "Bearer usertoken123" {
t.Errorf("revoke auth header = %q, want %q", sawAuth, "Bearer usertoken123")
}
}

// TestRevokeTokenServerError: a non-2xx surfaces as *APIError so logout can log
// it (then clear local state regardless — see the cli-package logout tests).
func TestRevokeTokenServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"detail":"boom"}`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "t"
var ae *APIError
if err := c.RevokeToken(context.Background()); !errors.As(err, &ae) || ae.StatusCode != http.StatusInternalServerError {
t.Errorf("want APIError 500, got %v", err)
}
}
Loading
Loading