Skip to content

Commit 3d58819

Browse files
authored
Merge pull request docker#2653 from dgageot/board/c99857e5dc51e9c4
feat(httpclient): forward cagent install UUID on gateway-bound requests
2 parents 312a075 + deec8cb commit 3d58819

5 files changed

Lines changed: 307 additions & 46 deletions

File tree

pkg/httpclient/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
1313

1414
"github.com/docker/docker-agent/pkg/remote"
15+
"github.com/docker/docker-agent/pkg/userid"
1516
"github.com/docker/docker-agent/pkg/version"
1617
)
1718

@@ -71,6 +72,14 @@ func WithProxiedBaseURL(value string) Opt {
7172
o.Header.Set("X-Cagent-Arch", runtime.GOARCH)
7273
o.Header.Set("X-Cagent-Runtime", "cagent")
7374
o.Header.Set("X-Cagent-Runtime-Version", version.Version)
75+
76+
// Stamp the persistent UUID identifying this cagent install so
77+
// the gateway can correlate calls coming from the same client
78+
// across sessions and processes. Same value as the `user_uuid`
79+
// telemetry property; the gateway is free to ignore it.
80+
if id := userid.Get(); id != "" {
81+
o.Header.Set("X-Cagent-Id", id)
82+
}
7483
}
7584
}
7685

pkg/httpclient/client_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,41 @@ import (
44
"context"
55
"net/http"
66
"net/http/httptest"
7+
"os"
8+
"path/filepath"
79
"testing"
810

11+
"github.com/google/uuid"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
14+
15+
"github.com/docker/docker-agent/pkg/paths"
16+
"github.com/docker/docker-agent/pkg/userid"
1117
)
1218

19+
// TestMain redirects the config directory used by [userid.Get] to a
20+
// throw-away temp dir so the package's tests, which exercise
21+
// gateway-bound HTTP requests, never read or write the real user-uuid
22+
// file in the developer's config dir. Individual tests can still
23+
// override the directory and call [userid.ResetForTests] for finer
24+
// control.
25+
func TestMain(m *testing.M) {
26+
//nolint:forbidigo // TestMain has no *testing.T, so t.TempDir is unavailable.
27+
dir, err := os.MkdirTemp("", "httpclient-test-config-*")
28+
if err != nil {
29+
panic(err)
30+
}
31+
32+
paths.SetConfigDir(dir)
33+
userid.ResetForTests()
34+
35+
code := m.Run()
36+
37+
paths.SetConfigDir("")
38+
_ = os.RemoveAll(dir)
39+
os.Exit(code)
40+
}
41+
1342
func TestHeaders(t *testing.T) {
1443
t.Parallel()
1544

@@ -150,3 +179,65 @@ func TestContextWithSessionID_RoundTrip(t *testing.T) {
150179
ctx := ContextWithSessionID(t.Context(), "sess-xyz")
151180
assert.Equal(t, "sess-xyz", SessionIDFromContext(ctx))
152181
}
182+
183+
func TestCagentIDHeader_GatewayBoundOnly(t *testing.T) {
184+
// Pin the persistent UUID file to a temp dir so the test does
185+
// not touch the real config dir and the value is deterministic.
186+
// We do not call t.Parallel because we mutate the package-level
187+
// paths override and the userid cache.
188+
const stored = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
189+
withStoredUserUUID(t, stored)
190+
191+
tests := []struct {
192+
name string
193+
opts []Opt
194+
wantHeaderSent bool
195+
}{
196+
{
197+
name: "gateway-bound (X-Cagent-Forward set) → X-Cagent-Id sent",
198+
opts: []Opt{WithProxiedBaseURL("https://gateway.example/v1")},
199+
wantHeaderSent: true,
200+
},
201+
{
202+
name: "no X-Cagent-Forward → X-Cagent-Id skipped",
203+
opts: nil,
204+
wantHeaderSent: false,
205+
},
206+
}
207+
208+
for _, tt := range tests {
209+
t.Run(tt.name, func(t *testing.T) {
210+
headers := doRequest(t, tt.opts...)
211+
212+
if tt.wantHeaderSent {
213+
assert.Equal(t, stored, headers.Get("X-Cagent-Id"))
214+
} else {
215+
assert.Empty(t, headers.Get("X-Cagent-Id"))
216+
}
217+
})
218+
}
219+
}
220+
221+
// withStoredUserUUID seeds a fixed UUID into a temporary config dir for
222+
// the duration of the test, so the persistent identifier surfaced by
223+
// userid.Get is deterministic and isolated from other tests. The
224+
// previous override is restored on cleanup so we keep the package-wide
225+
// isolation set up by [TestMain].
226+
func withStoredUserUUID(t *testing.T, id string) {
227+
t.Helper()
228+
229+
_, err := uuid.Parse(id)
230+
require.NoError(t, err, "seeded value must be a valid UUID")
231+
232+
previous := paths.GetConfigDir()
233+
234+
dir := t.TempDir()
235+
require.NoError(t, os.WriteFile(filepath.Join(dir, "user-uuid"), []byte(id), 0o600))
236+
237+
paths.SetConfigDir(dir)
238+
userid.ResetForTests()
239+
t.Cleanup(func() {
240+
paths.SetConfigDir(previous)
241+
userid.ResetForTests()
242+
})
243+
}

pkg/telemetry/utils.go

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,9 @@ import (
66
"flag"
77
"fmt"
88
"os"
9-
"path/filepath"
109
"runtime"
11-
"strings"
1210

13-
"github.com/google/uuid"
14-
15-
"github.com/docker/docker-agent/pkg/paths"
11+
"github.com/docker/docker-agent/pkg/userid"
1612
)
1713

1814
// getSystemInfo collects system information for events
@@ -41,48 +37,15 @@ func getTelemetryEnabledFromEnv() bool {
4137
return true
4238
}
4339

44-
// getUserUUIDFilePath returns the path to the user UUID file
45-
func getUserUUIDFilePath() string {
46-
configDir := paths.GetConfigDir()
47-
return filepath.Join(configDir, "user-uuid")
48-
}
49-
50-
// getUserUUID gets or creates a persistent user UUID
40+
// getUserUUID returns the persistent UUID identifying this cagent
41+
// installation, generating and persisting one on first use.
42+
//
43+
// It delegates to [userid.Get], which is also used by the HTTP
44+
// transport so the same identifier appears as the `user_uuid`
45+
// telemetry property and as the `X-Cagent-Id` header on gateway-bound
46+
// requests.
5147
func getUserUUID() string {
52-
uuidFile := getUserUUIDFilePath()
53-
54-
// Try to read existing UUID
55-
if data, err := os.ReadFile(uuidFile); err == nil {
56-
existingUUID := strings.TrimSpace(string(data))
57-
if existingUUID != "" {
58-
return existingUUID
59-
}
60-
// UUID file exists but is empty/invalid - will generate new one
61-
}
62-
63-
// Generate new UUID and save it
64-
newUUID := uuid.New().String()
65-
if err := saveUserUUID(newUUID); err != nil {
66-
// If we can't save, still return a UUID for this session
67-
// but it won't persist across runs
68-
return newUUID
69-
}
70-
71-
return newUUID
72-
}
73-
74-
// saveUserUUID saves the UUID to disk
75-
func saveUserUUID(newUUID string) error {
76-
uuidFile := getUserUUIDFilePath()
77-
78-
// Ensure directory exists
79-
dir := filepath.Dir(uuidFile)
80-
if err := os.MkdirAll(dir, 0o755); err != nil {
81-
return err
82-
}
83-
84-
// Write UUID to file (readable only by user)
85-
return os.WriteFile(uuidFile, []byte(newUUID), 0o600)
48+
return userid.Get()
8649
}
8750

8851
// structToMap converts a struct to map[string]any using JSON marshaling

pkg/userid/userid.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Package userid exposes the persistent UUID identifying this cagent
2+
// installation. The value is stored in `$configDir/user-uuid`, generated
3+
// lazily on first use, and shared across cagent runs on the same machine.
4+
//
5+
// It is consumed both by telemetry (as the `user_uuid` event property)
6+
// and by the HTTP transport (as the `X-Cagent-Id` header on
7+
// gateway-bound requests) so that the gateway can correlate calls made
8+
// by the same cagent install without having to invent a new identifier.
9+
package userid
10+
11+
import (
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"sync"
16+
17+
"github.com/google/uuid"
18+
19+
"github.com/docker/docker-agent/pkg/paths"
20+
)
21+
22+
// fileName is the basename of the file holding the persistent UUID,
23+
// stored under [paths.GetConfigDir].
24+
const fileName = "user-uuid"
25+
26+
var (
27+
mu sync.Mutex
28+
cached string
29+
)
30+
31+
// Get returns the persistent UUID identifying this cagent installation.
32+
//
33+
// On the first call it tries to read the value from
34+
// `$configDir/user-uuid`; if the file does not exist, is empty, or
35+
// cannot be read, a fresh UUID is generated and persisted (best
36+
// effort). The result is cached in memory for the lifetime of the
37+
// process so subsequent calls do not touch the filesystem.
38+
func Get() string {
39+
mu.Lock()
40+
defer mu.Unlock()
41+
42+
if cached != "" {
43+
return cached
44+
}
45+
46+
file := filePath()
47+
48+
if data, err := os.ReadFile(file); err == nil {
49+
if existing := strings.TrimSpace(string(data)); existing != "" {
50+
// Validate that the stored value is actually a valid UUID.
51+
// If the file was manually edited or corrupted, regenerate
52+
// rather than propagating invalid data to telemetry and
53+
// the gateway.
54+
if _, err := uuid.Parse(existing); err == nil {
55+
cached = existing
56+
return cached
57+
}
58+
// File contains invalid UUID — fall through and regenerate.
59+
}
60+
// File exists but is empty/whitespace — fall through and
61+
// regenerate so we always return a valid UUID.
62+
}
63+
64+
id := uuid.New().String()
65+
// Best-effort persistence: even if we cannot save the value to
66+
// disk we still cache it in memory so the same identifier is used
67+
// for the rest of this process.
68+
_ = save(file, id)
69+
cached = id
70+
return cached
71+
}
72+
73+
// ResetForTests clears the in-memory cache. Tests in any package
74+
// that rely on a deterministic config dir override should call this
75+
// after [paths.SetConfigDir] to force the next [Get] call to re-read
76+
// from disk.
77+
func ResetForTests() {
78+
mu.Lock()
79+
defer mu.Unlock()
80+
cached = ""
81+
}
82+
83+
func filePath() string {
84+
return filepath.Join(paths.GetConfigDir(), fileName)
85+
}
86+
87+
func save(file, id string) error {
88+
// Use 0o700 on the directory to match the 0o600 protection on the
89+
// file itself: the per-install UUID is forwarded as `X-Cagent-Id`
90+
// on every gateway request, so even directory-level enumeration on
91+
// a shared host is a mild privacy leak we'd like to avoid.
92+
if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil {
93+
return err
94+
}
95+
return os.WriteFile(file, []byte(id), 0o600)
96+
}

0 commit comments

Comments
 (0)