Skip to content

Commit dc0e2f7

Browse files
committed
export deviceId
1 parent ac9832f commit dc0e2f7

9 files changed

Lines changed: 387 additions & 0 deletions

File tree

doit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"time"
3232

3333
"github.com/blang/semver"
34+
"github.com/digitalocean/doctl/internal/deviceid"
3435
"github.com/digitalocean/doctl/pkg/listen"
3536
"github.com/digitalocean/doctl/pkg/runner"
3637
"github.com/digitalocean/doctl/pkg/ssh"
@@ -299,6 +300,10 @@ func (c *LiveConfig) GetGodoClient(trace, allowRetries bool, accessToken string)
299300
client.HTTPClient.Transport = r
300301
}
301302

303+
// Stamp the host UUID on hosted-agents requests. Wrapped after the trace
304+
// recorder so --trace surfaces the header; no-op when Get() is empty.
305+
client.HTTPClient.Transport = deviceid.NewTransport(client.HTTPClient.Transport, deviceid.Get())
306+
302307
return client, nil
303308
}
304309

internal/deviceid/deviceid.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Package deviceid resolves the hardware UUID of the host running doctl for
2+
// the hosted-agents API. Get returns "" on any failure; doctl never fails on
3+
// a missing UUID. The lookup is cached per process.
4+
package deviceid
5+
6+
import (
7+
"os"
8+
"strings"
9+
"sync"
10+
)
11+
12+
// EnvDisable disables the lookup when set to "1", "true", or "yes".
13+
const EnvDisable = "DOCTL_DISABLE_DEVICE_ID"
14+
15+
var (
16+
once sync.Once
17+
cached string
18+
)
19+
20+
// Get returns the host hardware UUID, or "" if unavailable / opted out.
21+
func Get() string {
22+
once.Do(func() {
23+
if isDisabled() {
24+
return
25+
}
26+
cached = strings.TrimSpace(read())
27+
})
28+
return cached
29+
}
30+
31+
// reset clears the cache. Test hook only.
32+
func reset() {
33+
once = sync.Once{}
34+
cached = ""
35+
}
36+
37+
func isDisabled() bool {
38+
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvDisable))) {
39+
case "1", "true", "yes":
40+
return true
41+
}
42+
return false
43+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//go:build darwin
2+
3+
package deviceid
4+
5+
import (
6+
"context"
7+
"os/exec"
8+
"regexp"
9+
"time"
10+
)
11+
12+
var ioregPath = "/usr/sbin/ioreg"
13+
14+
// ioreg output line: `"IOPlatformUUID" = "<uuid>"`
15+
var ioPlatformUUID = regexp.MustCompile(`"IOPlatformUUID"\s*=\s*"([^"]+)"`)
16+
17+
func read() string {
18+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19+
defer cancel()
20+
21+
out, err := exec.CommandContext(ctx, ioregPath, "-d2", "-c", "IOPlatformExpertDevice").Output()
22+
if err != nil {
23+
return ""
24+
}
25+
m := ioPlatformUUID.FindSubmatch(out)
26+
if len(m) < 2 {
27+
return ""
28+
}
29+
return string(m[1])
30+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//go:build linux
2+
3+
package deviceid
4+
5+
import "os"
6+
7+
// Note: this is the systemd install UUID, not the SMBIOS hardware UUID
8+
// (which is root-only on Linux at /sys/class/dmi/id/product_uuid).
9+
var machineIDPaths = []string{
10+
"/etc/machine-id",
11+
"/var/lib/dbus/machine-id",
12+
}
13+
14+
func read() string {
15+
for _, p := range machineIDPaths {
16+
b, err := os.ReadFile(p)
17+
if err != nil {
18+
continue
19+
}
20+
if len(b) > 0 {
21+
return string(b)
22+
}
23+
}
24+
return ""
25+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build !darwin && !linux && !windows
2+
3+
package deviceid
4+
5+
func read() string { return "" }

internal/deviceid/deviceid_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package deviceid
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestGet_DisabledByEnv(t *testing.T) {
10+
t.Setenv(EnvDisable, "1")
11+
reset()
12+
assert.Equal(t, "", Get(), "Get must return empty when DOCTL_DISABLE_DEVICE_ID is set")
13+
}
14+
15+
func TestGet_DisabledByEnv_VariantsAreCaseInsensitive(t *testing.T) {
16+
for _, v := range []string{"1", "true", "TRUE", "Yes", " yes "} {
17+
t.Run(v, func(t *testing.T) {
18+
t.Setenv(EnvDisable, v)
19+
reset()
20+
assert.Equal(t, "", Get())
21+
})
22+
}
23+
}
24+
25+
func TestGet_NotDisabledByEnv_FalsyValuesIgnored(t *testing.T) {
26+
// "0", "false", "" must not be treated as opt-out. We can't assert what
27+
// Get returns (it depends on the host), but we can assert isDisabled
28+
// behavior directly, which is the only env-driven branch in Get.
29+
for _, v := range []string{"", "0", "false", "no", "off", "anything-else"} {
30+
t.Run(v, func(t *testing.T) {
31+
t.Setenv(EnvDisable, v)
32+
assert.False(t, isDisabled(), "value %q must not trip opt-out", v)
33+
})
34+
}
35+
}
36+
37+
func TestGet_Cached(t *testing.T) {
38+
// Two calls must yield the same value; sync.Once guarantees read() is
39+
// invoked at most once. We rely on Get being deterministic for the
40+
// lifetime of the process.
41+
t.Setenv(EnvDisable, "")
42+
reset()
43+
first := Get()
44+
second := Get()
45+
assert.Equal(t, first, second)
46+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//go:build windows
2+
3+
package deviceid
4+
5+
import (
6+
"context"
7+
"os/exec"
8+
"regexp"
9+
"time"
10+
)
11+
12+
var uuidLine = regexp.MustCompile(`[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}`)
13+
14+
// read tries wmic (deprecated, removed from Win 11 24H2 fresh installs)
15+
// then falls back to PowerShell's Get-CimInstance.
16+
func read() string {
17+
if id := readViaWmic(); id != "" {
18+
return id
19+
}
20+
return readViaPowerShell()
21+
}
22+
23+
func readViaWmic() string {
24+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
25+
defer cancel()
26+
27+
out, err := exec.CommandContext(ctx, "wmic", "csproduct", "get", "UUID").Output()
28+
if err != nil {
29+
return ""
30+
}
31+
return string(uuidLine.Find(out))
32+
}
33+
34+
// 5 s ceiling to absorb powershell.exe cold-start (~1–2 s).
35+
func readViaPowerShell() string {
36+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
37+
defer cancel()
38+
39+
out, err := exec.CommandContext(ctx, "powershell",
40+
"-NoProfile",
41+
"-NonInteractive",
42+
"-Command",
43+
"(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID",
44+
).Output()
45+
if err != nil {
46+
return ""
47+
}
48+
return string(uuidLine.Find(out))
49+
}

internal/deviceid/transport.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package deviceid
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
)
7+
8+
// HeaderName is coordinated with the harness-api server. Rename in lockstep.
9+
const HeaderName = "X-Device-UUID"
10+
11+
// agentsSessionsBase mirrors godo's hostedAgentsSessionsBasePath.
12+
const agentsSessionsBase = "/v2/agents/sessions"
13+
14+
// Transport stamps HeaderName on requests under agentsSessionsBase.
15+
type Transport struct {
16+
Base http.RoundTripper
17+
ID string
18+
}
19+
20+
// NewTransport returns base unchanged when id is empty so callers can chain
21+
// unconditionally.
22+
func NewTransport(base http.RoundTripper, id string) http.RoundTripper {
23+
if base == nil {
24+
base = http.DefaultTransport
25+
}
26+
if id == "" {
27+
return base
28+
}
29+
return &Transport{Base: base, ID: id}
30+
}
31+
32+
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
33+
if t.ID == "" || !inAgentsScope(req) {
34+
return t.Base.RoundTrip(req)
35+
}
36+
// RoundTripper contract: don't mutate the caller's request.
37+
r := req.Clone(req.Context())
38+
if r.Header == nil {
39+
r.Header = make(http.Header)
40+
}
41+
r.Header.Set(HeaderName, t.ID)
42+
return t.Base.RoundTrip(r)
43+
}
44+
45+
// inAgentsScope matches the base path exactly or any sub-resource, but not
46+
// look-alikes like "/v2/agents/sessionsfoo".
47+
func inAgentsScope(req *http.Request) bool {
48+
if req == nil || req.URL == nil {
49+
return false
50+
}
51+
p := req.URL.Path
52+
return p == agentsSessionsBase || strings.HasPrefix(p, agentsSessionsBase+"/")
53+
}

0 commit comments

Comments
 (0)