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
9 changes: 9 additions & 0 deletions pkg/httpclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

"github.com/docker/docker-agent/pkg/remote"
"github.com/docker/docker-agent/pkg/userid"
"github.com/docker/docker-agent/pkg/version"
)

Expand Down Expand Up @@ -71,6 +72,14 @@ func WithProxiedBaseURL(value string) Opt {
o.Header.Set("X-Cagent-Arch", runtime.GOARCH)
o.Header.Set("X-Cagent-Runtime", "cagent")
o.Header.Set("X-Cagent-Runtime-Version", version.Version)

// Stamp the persistent UUID identifying this cagent install so
// the gateway can correlate calls coming from the same client
// across sessions and processes. Same value as the `user_uuid`
// telemetry property; the gateway is free to ignore it.
if id := userid.Get(); id != "" {
o.Header.Set("X-Cagent-Id", id)
}
}
}

Expand Down
91 changes: 91 additions & 0 deletions pkg/httpclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,41 @@ import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/userid"
)

// TestMain redirects the config directory used by [userid.Get] to a
// throw-away temp dir so the package's tests, which exercise
// gateway-bound HTTP requests, never read or write the real user-uuid
// file in the developer's config dir. Individual tests can still
// override the directory and call [userid.ResetForTests] for finer
// control.
func TestMain(m *testing.M) {
//nolint:forbidigo // TestMain has no *testing.T, so t.TempDir is unavailable.
dir, err := os.MkdirTemp("", "httpclient-test-config-*")
if err != nil {
panic(err)
}

paths.SetConfigDir(dir)
userid.ResetForTests()

code := m.Run()

paths.SetConfigDir("")
_ = os.RemoveAll(dir)
os.Exit(code)
}

func TestHeaders(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -150,3 +179,65 @@ func TestContextWithSessionID_RoundTrip(t *testing.T) {
ctx := ContextWithSessionID(t.Context(), "sess-xyz")
assert.Equal(t, "sess-xyz", SessionIDFromContext(ctx))
}

func TestCagentIDHeader_GatewayBoundOnly(t *testing.T) {
// Pin the persistent UUID file to a temp dir so the test does
// not touch the real config dir and the value is deterministic.
// We do not call t.Parallel because we mutate the package-level
// paths override and the userid cache.
const stored = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
withStoredUserUUID(t, stored)

tests := []struct {
name string
opts []Opt
wantHeaderSent bool
}{
{
name: "gateway-bound (X-Cagent-Forward set) → X-Cagent-Id sent",
opts: []Opt{WithProxiedBaseURL("https://gateway.example/v1")},
wantHeaderSent: true,
},
{
name: "no X-Cagent-Forward → X-Cagent-Id skipped",
opts: nil,
wantHeaderSent: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
headers := doRequest(t, tt.opts...)

if tt.wantHeaderSent {
assert.Equal(t, stored, headers.Get("X-Cagent-Id"))
} else {
assert.Empty(t, headers.Get("X-Cagent-Id"))
}
})
}
}

// withStoredUserUUID seeds a fixed UUID into a temporary config dir for
// the duration of the test, so the persistent identifier surfaced by
// userid.Get is deterministic and isolated from other tests. The
// previous override is restored on cleanup so we keep the package-wide
// isolation set up by [TestMain].
func withStoredUserUUID(t *testing.T, id string) {
t.Helper()

_, err := uuid.Parse(id)
require.NoError(t, err, "seeded value must be a valid UUID")

previous := paths.GetConfigDir()

dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "user-uuid"), []byte(id), 0o600))

paths.SetConfigDir(dir)
userid.ResetForTests()
t.Cleanup(func() {
paths.SetConfigDir(previous)
userid.ResetForTests()
})
}
55 changes: 9 additions & 46 deletions pkg/telemetry/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/google/uuid"

"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/userid"
)

// getSystemInfo collects system information for events
Expand Down Expand Up @@ -41,48 +37,15 @@ func getTelemetryEnabledFromEnv() bool {
return true
}

// getUserUUIDFilePath returns the path to the user UUID file
func getUserUUIDFilePath() string {
configDir := paths.GetConfigDir()
return filepath.Join(configDir, "user-uuid")
}

// getUserUUID gets or creates a persistent user UUID
// getUserUUID returns the persistent UUID identifying this cagent
// installation, generating and persisting one on first use.
//
// It delegates to [userid.Get], which is also used by the HTTP
// transport so the same identifier appears as the `user_uuid`
// telemetry property and as the `X-Cagent-Id` header on gateway-bound
// requests.
func getUserUUID() string {
uuidFile := getUserUUIDFilePath()

// Try to read existing UUID
if data, err := os.ReadFile(uuidFile); err == nil {
existingUUID := strings.TrimSpace(string(data))
if existingUUID != "" {
return existingUUID
}
// UUID file exists but is empty/invalid - will generate new one
}

// Generate new UUID and save it
newUUID := uuid.New().String()
if err := saveUserUUID(newUUID); err != nil {
// If we can't save, still return a UUID for this session
// but it won't persist across runs
return newUUID
}

return newUUID
}

// saveUserUUID saves the UUID to disk
func saveUserUUID(newUUID string) error {
uuidFile := getUserUUIDFilePath()

// Ensure directory exists
dir := filepath.Dir(uuidFile)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}

// Write UUID to file (readable only by user)
return os.WriteFile(uuidFile, []byte(newUUID), 0o600)
return userid.Get()
}

// structToMap converts a struct to map[string]any using JSON marshaling
Expand Down
96 changes: 96 additions & 0 deletions pkg/userid/userid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Package userid exposes the persistent UUID identifying this cagent
// installation. The value is stored in `$configDir/user-uuid`, generated
// lazily on first use, and shared across cagent runs on the same machine.
//
// It is consumed both by telemetry (as the `user_uuid` event property)
// and by the HTTP transport (as the `X-Cagent-Id` header on
// gateway-bound requests) so that the gateway can correlate calls made
// by the same cagent install without having to invent a new identifier.
package userid

import (
"os"
"path/filepath"
"strings"
"sync"

"github.com/google/uuid"

"github.com/docker/docker-agent/pkg/paths"
)

// fileName is the basename of the file holding the persistent UUID,
// stored under [paths.GetConfigDir].
const fileName = "user-uuid"

var (
mu sync.Mutex
cached string
)

// Get returns the persistent UUID identifying this cagent installation.
//
// On the first call it tries to read the value from
// `$configDir/user-uuid`; if the file does not exist, is empty, or
// cannot be read, a fresh UUID is generated and persisted (best
// effort). The result is cached in memory for the lifetime of the
// process so subsequent calls do not touch the filesystem.
func Get() string {
mu.Lock()
defer mu.Unlock()

if cached != "" {
return cached
}

file := filePath()

if data, err := os.ReadFile(file); err == nil {
if existing := strings.TrimSpace(string(data)); existing != "" {
// Validate that the stored value is actually a valid UUID.
// If the file was manually edited or corrupted, regenerate
// rather than propagating invalid data to telemetry and
// the gateway.
if _, err := uuid.Parse(existing); err == nil {
cached = existing
return cached
}
// File contains invalid UUID — fall through and regenerate.
}
// File exists but is empty/whitespace — fall through and
// regenerate so we always return a valid UUID.
}

id := uuid.New().String()
// Best-effort persistence: even if we cannot save the value to
// disk we still cache it in memory so the same identifier is used
// for the rest of this process.
_ = save(file, id)
cached = id
return cached
}

// ResetForTests clears the in-memory cache. Tests in any package
// that rely on a deterministic config dir override should call this
// after [paths.SetConfigDir] to force the next [Get] call to re-read
// from disk.
func ResetForTests() {
mu.Lock()
defer mu.Unlock()
cached = ""
}

func filePath() string {
return filepath.Join(paths.GetConfigDir(), fileName)
}

func save(file, id string) error {
// Use 0o700 on the directory to match the 0o600 protection on the
// file itself: the per-install UUID is forwarded as `X-Cagent-Id`
// on every gateway request, so even directory-level enumeration on
// a shared host is a mild privacy leak we'd like to avoid.
if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil {
return err
}
return os.WriteFile(file, []byte(id), 0o600)
}
Loading
Loading