From be8c068fa92f0f84fb164c20ec71431b8858e39b Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Thu, 8 Oct 2026 07:58:23 +0000 Subject: [PATCH] Send full host metadata on managed startup --- cmd/cli/api_reload_test.go | 34 ++++++++++ cmd/cli/cli.go | 55 +++++++++++---- cmd/cli/cli_preflight_test.go | 49 ++++++++++++-- cmd/cli/dns_proxy.go | 2 +- cmd/cli/metadata_scope_test.go | 82 +++++++++++++++++++++++ cmd/cli/prog.go | 2 +- discover_user_darwin.go | 31 ++++++--- discover_user_linux.go | 21 ++++-- discover_user_others.go | 6 ++ discover_user_windows.go | 21 ++++-- docs/username-detection.md | 34 +++++++++- internal/controld/config.go | 39 ++++++++--- internal/controld/config_metadata_test.go | 41 ++++++++++++ metadata.go | 39 +++++++++-- metadata_test.go | 56 ++++++++++++++++ 15 files changed, 456 insertions(+), 56 deletions(-) create mode 100644 cmd/cli/metadata_scope_test.go create mode 100644 internal/controld/config_metadata_test.go diff --git a/cmd/cli/api_reload_test.go b/cmd/cli/api_reload_test.go index 5b1a08b..74e1647 100644 --- a/cmd/cli/api_reload_test.go +++ b/cmd/cli/api_reload_test.go @@ -244,6 +244,40 @@ func waitForCondition(t *testing.T, what string, cond func() bool) { t.Fatalf("timed out waiting for %s", what) } +func TestApiConfigReloadUsesRuntimeMetadata(t *testing.T) { + originalFetch, originalUID := fetchResolverConfigFn, cdUID + t.Cleanup(func() { + fetchResolverConfigFn = originalFetch + cdUID = originalUID + }) + cdUID = "test-uid" + + captured := make(chan *controld.ResolverConfigRequest, 1) + fetchResolverConfigFn = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) { + captured <- req + return &controld.ResolverConfig{}, nil + } + + p := progForRefresh() + p.cfg = &ctrld.Config{} + p.stopCh = make(chan struct{}) + p.apiForceReloadCh = make(chan struct{}) + startRefreshLoop(t, p) + p.apiForceReloadCh <- struct{}{} + + select { + case req := <-captured: + if _, ok := req.Metadata["username"]; ok { + t.Error("periodic/forced API refresh included username") + } + if req.IncludeHostnameHints { + t.Error("periodic/forced API refresh requested hostname hints") + } + case <-time.After(5 * time.Second): + t.Fatal("periodic/forced API refresh did not fetch resolver config") + } +} + // TestApiConfigReloadAppliesAllowedDestinations drives apiConfigReload itself - // the loop that owns the refresh ticker and the forced-reload channel - rather // than the handler it calls, so the wiring between them is covered too: removing diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 7405ff8..58bb121 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -823,7 +823,7 @@ type apiPreflight struct { // with no stop channel - as an operator stop. Reading the stop channel directly is also // independent of whether the context's watcher goroutine has been scheduled yet. func runAPIPreflight(stopCh <-chan struct{}, cfg *ctrld.Config) apiPreflight { - rc, err := fetchCDConfigBoundedBy(stopCh, cfg) + rc, err := fetchCDConfigBoundedBy(stopCh, cfg, processCDFlagsFn) return apiPreflight{rc: rc, err: err, stopRequested: stopRequested(stopCh)} } @@ -1000,25 +1000,32 @@ func handleAPIPreflightFailure(p *prog, err error, notify func()) { failProvision(newProvisionResult(code, fmt.Sprintf("failed to fetch resolver config: %v", err), nil, provisionSecrets()...), notify) } -// processCDFlagsFn is the API fetch, indirected so the lifetime binding around it can be -// tested without reaching the network. -var processCDFlagsFn = processCDFlags +// The startup (full metadata) and reload (runtime metadata) API fetches are indirected so +// their lifetime binding can be tested without reaching the network. +var ( + processCDFlagsFn = processCDFlags + processCDFlagsRuntimeFn = processCDFlagsRuntime +) -// fetchCDConfigBoundedBy runs the API fetch bounded by stopCh, so a fetch that cannot -// reach the API stops when the service is asked to stop instead of working on behalf of a -// service the OS already considers stopped. The derived context is always cancelled, which +// fetchCDConfigBoundedBy runs fetch bounded by stopCh, so a fetch that cannot reach the +// API stops when the service is asked to stop instead of working on behalf of a service +// the OS already considers stopped. The derived context is always cancelled, which // releases the goroutine watching stopCh. -func fetchCDConfigBoundedBy(stopCh <-chan struct{}, cfg *ctrld.Config) (*controld.ResolverConfig, error) { +func fetchCDConfigBoundedBy( + stopCh <-chan struct{}, + cfg *ctrld.Config, + fetch func(context.Context, *ctrld.Config) (*controld.ResolverConfig, error), +) (*controld.ResolverConfig, error) { ctx, cancel := contextFromStopCh(stopCh) defer cancel() - return processCDFlagsFn(ctx, cfg) + return fetch(ctx, cfg) } // fetchCDConfigBoundedByLifetime is the reload path's fetch. Reload binds the same stop // primitives as startup - it used to wire them up itself, where a dropped cancel or the // wrong channel would have failed nothing. func (p *prog) fetchCDConfigBoundedByLifetime(cfg *ctrld.Config) (*controld.ResolverConfig, error) { - return fetchCDConfigBoundedBy(p.stopCh, cfg) + return fetchCDConfigBoundedBy(p.stopCh, cfg, processCDFlagsRuntimeFn) } // stopRequested reports whether stopCh has been closed. A nil channel - mobile passes @@ -1057,6 +1064,23 @@ func contextFromStopCh(stopCh <-chan struct{}) (context.Context, context.CancelF // come up), so it must be cancellable: otherwise a stop request during preflight is // ignored and the process keeps retrying after the service reports itself stopped. func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverConfig, error) { + return processCDFlagsWithMetadata(ctx, cfg, startupMetadata, true) +} + +// startupMetadata is the daemon-start metadata source, indirected so tests can +// prove that startup, and only startup, uses it. +var startupMetadata = ctrld.SystemMetadataStartup + +func processCDFlagsRuntime(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverConfig, error) { + return processCDFlagsWithMetadata(ctx, cfg, ctrld.SystemMetadataRuntime, false) +} + +func processCDFlagsWithMetadata( + ctx context.Context, + cfg *ctrld.Config, + metadata func(context.Context) map[string]string, + includeHostnameHints bool, +) (*controld.ResolverConfig, error) { logger := mainLog.Load().With().Str("mode", "cd") logger.Info().Msgf("Fetching Controld D configuration from API: %s", cdUID) bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second) @@ -1066,9 +1090,10 @@ func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverC } ctx = ctrld.LoggerCtx(ctx, logger) req := &controld.ResolverConfigRequest{ - RawUID: cdUID, - Version: appVersion, - Metadata: ctrld.SystemMetadata(ctx), + RawUID: cdUID, + Version: appVersion, + Metadata: metadata(ctx), + IncludeHostnameHints: includeHostnameHints, } resolverConfig, err := fetchResolverConfig(ctx, req, cdDev) @@ -2673,10 +2698,12 @@ func apiRejectionMessage(err error) string { // itself, with no process exit. func doValidateCdRemoteConfig(cdUID string, fatal bool) error { loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load()) + // Install and restart validation use runtime metadata. The daemon start + // that follows sends the full snapshot. req := &controld.ResolverConfigRequest{ RawUID: cdUID, Version: appVersion, - Metadata: ctrld.SystemMetadata(loggerCtx), + Metadata: ctrld.SystemMetadataRuntime(loggerCtx), } rc, err := fetchResolverConfig(loggerCtx, req, cdDev) if err != nil { diff --git a/cmd/cli/cli_preflight_test.go b/cmd/cli/cli_preflight_test.go index c89b154..328585d 100644 --- a/cmd/cli/cli_preflight_test.go +++ b/cmd/cli/cli_preflight_test.go @@ -57,6 +57,44 @@ func TestContextFromStopCh(t *testing.T) { }) } +func TestManagedConfigFetchMetadataScope(t *testing.T) { + oldFetch, oldStartup := fetchResolverConfig, startupMetadata + oldUID := cdUID + t.Cleanup(func() { + fetchResolverConfig, startupMetadata = oldFetch, oldStartup + cdUID = oldUID + }) + cdUID = "testuid" + startupMetadata = func(context.Context) map[string]string { + return map[string]string{"username": "session-user"} + } + + requests := make(chan *controld.ResolverConfigRequest, 2) + fetchResolverConfig = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) { + requests <- req + return nil, errors.New("stop after capturing metadata") + } + + _, _ = processCDFlags(context.Background(), &ctrld.Config{}) + startup := <-requests + if got := startup.Metadata["username"]; got != "session-user" { + t.Errorf("managed startup username = %q, want the startup metadata source", got) + } + if !startup.IncludeHostnameHints { + t.Error("managed startup did not request hostname hints") + } + + p := &prog{stopCh: make(chan struct{})} + _, _ = p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}) + runtime := <-requests + if _, ok := runtime.Metadata["username"]; ok { + t.Error("runtime reload metadata included username") + } + if runtime.IncludeHostnameHints { + t.Error("runtime reload requested hostname hints") + } +} + // retryableNetworkErr is the shape processCDFlags treats as "retry with bootstrap // DNS": a url.Error wrapping a network failure. func retryableNetworkErr() error { @@ -353,17 +391,18 @@ func TestStopRequested(t *testing.T) { // Reload fetches the ControlD config too, and it used to build the bounded context // itself. Nothing tested that: the wrong channel, or a dropped cancel, would have left a // reload retrying against an unreachable API after "Service stopped" was logged, and no -// test would have failed. Both paths now go through one bounded fetch, so this pins it. +// test would have failed. The reload path still binds its runtime-only metadata fetch to +// the service lifetime, so this pins both cancellation and watcher cleanup. func TestReloadFetchIsBoundedByServiceLifetime(t *testing.T) { - original := processCDFlagsFn - t.Cleanup(func() { processCDFlagsFn = original }) + original := processCDFlagsRuntimeFn + t.Cleanup(func() { processCDFlagsRuntimeFn = original }) t.Run("a stop request cancels the reload fetch", func(t *testing.T) { stopCh := make(chan struct{}) close(stopCh) var sawCancelled bool - processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { + processCDFlagsRuntimeFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { select { case <-ctx.Done(): sawCancelled = true @@ -385,7 +424,7 @@ func TestReloadFetchIsBoundedByServiceLifetime(t *testing.T) { // stopCh stays open: the fetch's own cancel is what must end the watcher, or // every reload leaks a goroutine. var captured context.Context - processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { + processCDFlagsRuntimeFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { captured = ctx return nil, nil } diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 5094d41..f5555d3 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -1716,7 +1716,7 @@ func (p *prog) doSelfUninstall(pr *proxyResponse) { req := &controld.ResolverConfigRequest{ RawUID: cdUID, Version: appVersion, - Metadata: ctrld.SystemMetadata(loggerCtx), + Metadata: ctrld.SystemMetadataRuntime(loggerCtx), } _, err := fetchResolverConfig(loggerCtx, req, cdDev) logger.Debug().Msg("Maximum number of refused queries reached, checking device status") diff --git a/cmd/cli/metadata_scope_test.go b/cmd/cli/metadata_scope_test.go new file mode 100644 index 0000000..bb499e3 --- /dev/null +++ b/cmd/cli/metadata_scope_test.go @@ -0,0 +1,82 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/miekg/dns" + + "github.com/Control-D-Inc/ctrld/internal/controld" +) + +// requireRuntimeMetadata asserts the runtime half of the startup-versus-runtime +// wire contract in docs/username-detection.md: no username discovery and no +// hostname hints. +func requireRuntimeMetadata(t *testing.T, caller string, req *controld.ResolverConfigRequest) { + t.Helper() + if req == nil { + t.Fatalf("%s did not send a resolver config request", caller) + } + if _, ok := req.Metadata["username"]; ok { + t.Errorf("%s request included username", caller) + } + if req.IncludeHostnameHints { + t.Errorf("%s request asked for hostname hints", caller) + } +} + +func TestDeactivationPinRefreshUsesRuntimeMetadata(t *testing.T) { + oldFetch, oldUID := fetchResolverConfig, cdUID + oldPin, oldKnown := cdDeactivationPin.Load(), deactivationPinKnown.Load() + t.Cleanup(func() { + fetchResolverConfig, cdUID = oldFetch, oldUID + cdDeactivationPin.Store(oldPin) + deactivationPinKnown.Store(oldKnown) + deactivationLockedUntil.Store(0) + }) + cdUID = "test-uid" + deactivationLockedUntil.Store(0) + + var captured *controld.ResolverConfigRequest + fetchResolverConfig = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) { + captured = req + return &controld.ResolverConfig{}, nil + } + + p := &prog{cs: &controlServer{mux: http.NewServeMux()}, pinCodeValidCh: make(chan struct{}, 1)} + p.logger.Store(discardLogger()) + p.registerControlServerHandler() + body, _ := json.Marshal(&deactivationRequest{Pin: defaultDeactivationPin}) + req := httptest.NewRequest(http.MethodPost, deactivationPath, strings.NewReader(string(body))) + p.cs.mux.ServeHTTP(httptest.NewRecorder(), req) + + requireRuntimeMetadata(t, "deactivation-PIN refresh", captured) +} + +func TestSelfUninstallCheckUsesRuntimeMetadata(t *testing.T) { + oldFetch, oldUID := fetchResolverConfig, cdUID + t.Cleanup(func() { fetchResolverConfig, cdUID = oldFetch, oldUID }) + cdUID = "test-uid" + + var captured *controld.ResolverConfigRequest + fetchResolverConfig = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) { + captured = req + // A generic failure is not a device-deleted answer, so no uninstall runs. + return nil, errors.New("stop after capturing metadata") + } + + p := &prog{} + p.logger.Store(discardLogger()) + p.canSelfUninstall.Store(true) + p.refusedQueryCount = selfUninstallMaxQueries + 1 + refused := new(dns.Msg) + refused.SetRcode(newDnsMsgWithHostname("example.com.", dns.TypeA), dns.RcodeRefused) + p.doSelfUninstall(&proxyResponse{answer: refused}) + + requireRuntimeMetadata(t, "self-uninstall check", captured) +} diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 5b12630..a9e1e58 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -767,7 +767,7 @@ func (p *prog) apiConfigReload() { req := &controld.ResolverConfigRequest{ RawUID: cdUID, Version: appVersion, - Metadata: ctrld.SystemMetadata(loggerCtx), + Metadata: ctrld.SystemMetadataRuntime(loggerCtx), } resolverConfig, err := fetchResolverConfigFn(loggerCtx, req, cdDev) if ctx.Err() != nil { diff --git a/discover_user_darwin.go b/discover_user_darwin.go index 40854c7..0267343 100644 --- a/discover_user_darwin.go +++ b/discover_user_darwin.go @@ -20,6 +20,27 @@ import ( func DiscoverMainUser(ctx context.Context) string { logger := LoggerFromCtx(ctx).Debug() + // Methods 1 and 2: the logged-in console user. + if user := discoverSessionUser(ctx); user != "" { + return user + } + + // Method 3: Find lowest UID >= 501 from directory services + logger.Msg("attempting to discover user via dscl directory scan") + if user := getLowestRegularUser(ctx); user != "" { + logger.Str("method", "dscl").Str("user", user).Msg("found user via dscl scan") + return user + } + + logger.Msg("all user discovery methods failed") + return "unknown" +} + +// discoverSessionUser returns the user of an active login session, or "" when +// nobody is logged in. It never falls back to a guess from the account list. +func discoverSessionUser(ctx context.Context) string { + logger := LoggerFromCtx(ctx).Debug() + // Method 1: Check console owner via stat logger.Msg("attempting to discover user via console stat") if user := getConsoleUser(ctx); user != "" && user != "root" { @@ -34,15 +55,7 @@ func DiscoverMainUser(ctx context.Context) string { return user } - // Method 3: Find lowest UID >= 501 from directory services - logger.Msg("attempting to discover user via dscl directory scan") - if user := getLowestRegularUser(ctx); user != "" { - logger.Str("method", "dscl").Str("user", user).Msg("found user via dscl scan") - return user - } - - logger.Msg("all user discovery methods failed") - return "unknown" + return "" } // getConsoleUser uses stat to find the owner of /dev/console diff --git a/discover_user_linux.go b/discover_user_linux.go index 3b4cb70..ea6003f 100644 --- a/discover_user_linux.go +++ b/discover_user_linux.go @@ -22,10 +22,8 @@ import ( func DiscoverMainUser(ctx context.Context) string { logger := LoggerFromCtx(ctx).Debug() - // Method 1: Check active users via loginctl - logger.Msg("attempting to discover user via loginctl") - if user := getLoginctlUser(ctx); user != "" { - logger.Str("method", "loginctl").Str("user", user).Msg("found user via loginctl") + // Method 1: an active loginctl user. + if user := discoverSessionUser(ctx); user != "" { return user } @@ -47,6 +45,21 @@ func DiscoverMainUser(ctx context.Context) string { return "unknown" } +// discoverSessionUser returns the user of an active login session, or "" when +// nobody is logged in. It never falls back to a guess from the account list. +func discoverSessionUser(ctx context.Context) string { + logger := LoggerFromCtx(ctx).Debug() + + // Method 1: Check active users via loginctl + logger.Msg("attempting to discover user via loginctl") + if user := getLoginctlUser(ctx); user != "" { + logger.Str("method", "loginctl").Str("user", user).Msg("found user via loginctl") + return user + } + + return "" +} + // getLoginctlUser uses loginctl to find active users func getLoginctlUser(ctx context.Context) string { cmd := exec.CommandContext(ctx, "loginctl", "list-users", "--no-legend") diff --git a/discover_user_others.go b/discover_user_others.go index 5d3b416..afa00f4 100644 --- a/discover_user_others.go +++ b/discover_user_others.go @@ -11,3 +11,9 @@ func DiscoverMainUser(ctx context.Context) string { LoggerFromCtx(ctx).Debug().Msg("username discovery not implemented for this platform") return "unknown" } + +// discoverSessionUser returns "" because no session source is implemented for +// this platform. +func discoverSessionUser(ctx context.Context) string { + return "" +} diff --git a/discover_user_windows.go b/discover_user_windows.go index c187f0f..2ed2dbf 100644 --- a/discover_user_windows.go +++ b/discover_user_windows.go @@ -35,10 +35,8 @@ const ( func DiscoverMainUser(ctx context.Context) string { logger := LoggerFromCtx(ctx).Debug() - // Method 1: Check active console session - logger.Msg("attempting to discover user via active console session") - if user := getActiveConsoleUser(ctx); user != "" { - logger.Str("method", "console").Str("user", user).Msg("found user via active console session") + // Method 1: the active console session user. + if user := discoverSessionUser(ctx); user != "" { return user } @@ -60,6 +58,21 @@ func DiscoverMainUser(ctx context.Context) string { return "unknown" } +// discoverSessionUser returns the user of an active login session, or "" when +// nobody is logged in. It never falls back to a guess from the account list. +func discoverSessionUser(ctx context.Context) string { + logger := LoggerFromCtx(ctx).Debug() + + // Method 1: Check active console session + logger.Msg("attempting to discover user via active console session") + if user := getActiveConsoleUser(ctx); user != "" { + logger.Str("method", "console").Str("user", user).Msg("found user via active console session") + return user + } + + return "" +} + // getActiveConsoleUser gets the username of the active console session func getActiveConsoleUser(ctx context.Context) string { // Guard against missing WTS procedures (e.g., Windows Server Core). diff --git a/docs/username-detection.md b/docs/username-detection.md index ad1db72..8da71d7 100644 --- a/docs/username-detection.md +++ b/docs/username-detection.md @@ -98,16 +98,44 @@ System metadata (OS, chassis, username, domain) is sent to the Control D API via | Scenario | Metadata sent? | Username included? | |---|---|---| | `ctrld-client start` with `--cd-org` (provisioning via `cdUIDFromProvToken`) | ✅ Full | ✅ Yes | -| `ctrld-client run` startup (config validation / processCDFlags) | ✅ Lightweight | ❌ No | +| `ctrld-client run` managed startup (`processCDFlags`) | ✅ Full | ⚠️ Only from an active login session | +| Mobile library start (`RunMobile` → `processCDFlags`) | ✅ Full | ❌ No (see below) | +| `ctrld-client start --cd` install validation (`doValidateCdRemoteConfig`) | ✅ Lightweight | ❌ No | +| `ctrld-client restart` configuration validation (`doValidateCdRemoteConfig`) | ✅ Lightweight | ❌ No | | Runtime config reload (`doReloadApiConfig`) | ✅ Lightweight | ❌ No | | Runtime self-uninstall check | ✅ Lightweight | ❌ No | | Runtime deactivation pin refresh | ✅ Lightweight | ❌ No | -Username is only collected and sent once — during initial provisioning via `cdUIDFromProvToken()`. All other API calls use `SystemMetadataRuntime()` which omits username discovery entirely. +Provisioning runs the full `DiscoverMainUser` chain. Each managed daemon start runs only the active-session part of that chain (`SystemMetadataStartup`): the console user on macOS, the active console session on Windows, or an active `loginctl` user on Linux. The daemon usually starts at boot, before anyone logs in. When no session user is found, the start omits `username`, and the API keeps the stored value instead of replacing it with an account-list guess or `"unknown"`. Full daemon-start metadata also includes the supported hostname hints. Periodic, forced, self-uninstall, deactivation-PIN, and install/restart validation requests use `SystemMetadataRuntime()` and omit username discovery and hostname hints. + +A `--cd-org` install therefore sends two full snapshots: the token exchange (`cdUIDFromProvToken`), then the first check-in of the daemon that `start` launches with `--cd=`. Full discovery runs for the first and session discovery for the second. The API merges metadata objects, so the second snapshot does not erase fields from the first. + +The mobile library starts through `processCDFlags` too. iOS builds use `GOOS=darwin`, but the `stat` and `scutil` commands that macOS session discovery runs are not available, and Android has no `loginctl` session. Session discovery on mobile finds no user, so the mobile start omits `username`. + +### Host metadata wire contract (version 1) + +Every managed `/utility` request body carries `uid`, an optional `client_id`, and `metadata`. `metadata` is a JSON object of string values, or `null` when the caller supplies none. `UpdateCustomLastFailed` sends `null`, which the API treats as metadata omitted. All resolver-config callers supply metadata. + +Columns: **Provisioning** is the `--cd-org` token exchange; **Start** is the first check-in of each managed daemon start; **Runtime** is every other request in the table above. + +| Key | Source | Platforms | Provisioning | Start | Runtime | Value when the source is unavailable | +|---|---|---|---|---|---|---| +| `os` | `osinfo` | All | ✅ | ✅ | ✅ | Always present | +| `chassis_type` | `system.GetChassisInfo` (macOS model identifier) | All | ✅ | ✅ | ✅ | `""` | +| `chassis_vendor` | `system.GetChassisInfo` (`Apple Inc.` on macOS) | All | ✅ | ✅ | ✅ | `""` | +| `username` | Provisioning: `DiscoverMainUser`. Start: active login session only | All | ✅ | ⚠️ Session user only | ❌ | Provisioning: `"unknown"`. Start: key omitted | +| `domain_or_workgroup` | Windows domain-join status | Windows; `"false"` elsewhere | ✅ | ✅ | ✅ | `"false"` | +| `domain` | Active Directory domain | Windows; `""` elsewhere | ✅ | ✅ | ✅ | `""` | +| `hostname_ComputerName` | `scutil --get ComputerName` | macOS only | ✅ | ✅ | ❌ | Key omitted | +| `hostname_LocalHostName` | `scutil --get LocalHostName` | macOS only | ✅ | ✅ | ❌ | Key omitted | +| `hostname_HostName` | `scutil --get HostName` | macOS only | ✅ | ✅ | ❌ | Key omitted | +| `hostname_os.Hostname` | `os.Hostname()` | All | ✅ | ✅ | ❌ | Key omitted | + +The API accepts only these ten keys. It ignores unknown keys, non-string values, and any value longer than 255 bytes, field by field, without failing the request. An omitted key keeps the stored value and a present empty string clears it. The server side of this contract is [`docs/ctrld-utility-host-metadata.md` in controld-api](https://gitlab.int.windscribe.com/controld/backend/api/-/blob/master/docs/ctrld-utility-host-metadata.md). Bump the version above when a key, its platforms, or its unavailable value changes. ### Runtime metadata (`SystemMetadataRuntime`) -Runtime API calls (config reload, self-uninstall check, deactivation pin refresh) use `SystemMetadataRuntime()` which includes OS and chassis info but **skips username discovery**. This avoids: +Runtime API calls (config reload, self-uninstall check, deactivation pin refresh, install/restart validation) use `SystemMetadataRuntime()` which includes OS and chassis info but **skips username discovery**. This avoids: - **EDR false positives**: Repeated user enumeration (registry scans, WTS queries, loginctl calls) can trigger endpoint detection and response alerts - **Unnecessary work**: Username is unlikely to change while the service is running diff --git a/internal/controld/config.go b/internal/controld/config.go index 20f9db4..4e073dd 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -226,9 +226,36 @@ type UtilityOrgRequest struct { // ResolverConfigRequest contains request data for fetching resolver config. type ResolverConfigRequest struct { - RawUID string - Version string - Metadata map[string]string + RawUID string + Version string + Metadata map[string]string + IncludeHostnameHints bool +} + +var hostnameHintsFn = hostnameHints + +func resolverConfigUtilityRequest(req *ResolverConfigRequest, uid, clientID string) utilityRequest { + var metadata map[string]string + if req.Metadata != nil { + metadata = make(map[string]string, len(req.Metadata)) + for key, value := range req.Metadata { + metadata[key] = value + } + } + if req.IncludeHostnameHints { + if metadata == nil { + metadata = make(map[string]string) + } + for key, value := range hostnameHintsFn() { + metadata["hostname_"+key] = value + } + } + + return utilityRequest{ + UID: uid, + ClientID: clientID, + Metadata: metadata, + } } // LogsRequest contains request data for sending runtime logs to API. @@ -245,12 +272,8 @@ func FetchResolverConfig(ctx context.Context, req *ResolverConfigRequest, cdDev uid, clientID := ParseRawUID(req.RawUID) ctrld.Log(ctx, logger.Debug(), "Parsed UID: %s, ClientID: %s", uid, clientID) - uReq := utilityRequest{ - UID: uid, - Metadata: req.Metadata, - } + uReq := resolverConfigUtilityRequest(req, uid, clientID) if clientID != "" { - uReq.ClientID = clientID ctrld.Log(ctx, logger.Debug(), "Including client ID in request") } body, _ := json.Marshal(uReq) diff --git a/internal/controld/config_metadata_test.go b/internal/controld/config_metadata_test.go new file mode 100644 index 0000000..80e0d46 --- /dev/null +++ b/internal/controld/config_metadata_test.go @@ -0,0 +1,41 @@ +package controld + +import "testing" + +func TestResolverConfigUtilityRequestMetadataScope(t *testing.T) { + oldHostnameHints := hostnameHintsFn + t.Cleanup(func() { hostnameHintsFn = oldHostnameHints }) + hostnameHintsFn = func() map[string]string { + return map[string]string{"ComputerName": "workstation"} + } + + baseMetadata := map[string]string{"os": "test-os", "username": "test-user"} + full := resolverConfigUtilityRequest(&ResolverConfigRequest{ + Metadata: baseMetadata, + IncludeHostnameHints: true, + }, "uid", "client") + if got := full.Metadata["hostname_ComputerName"]; got != "workstation" { + t.Errorf("full metadata hostname hint = %q, want workstation", got) + } + if got := full.Metadata["username"]; got != "test-user" { + t.Errorf("full metadata username = %q, want test-user", got) + } + if _, ok := baseMetadata["hostname_ComputerName"]; ok { + t.Error("building the utility request mutated the caller metadata") + } + + runtime := resolverConfigUtilityRequest(&ResolverConfigRequest{ + Metadata: map[string]string{"os": "test-os"}, + }, "uid", "") + if _, ok := runtime.Metadata["hostname_ComputerName"]; ok { + t.Error("runtime metadata included hostname hints") + } + if _, ok := runtime.Metadata["username"]; ok { + t.Error("runtime metadata included username") + } + + empty := resolverConfigUtilityRequest(&ResolverConfigRequest{}, "uid", "") + if empty.Metadata != nil { + t.Error("request without metadata changed from null to an observed empty object") + } +} diff --git a/metadata.go b/metadata.go index ad861cf..dbf2e79 100644 --- a/metadata.go +++ b/metadata.go @@ -22,21 +22,41 @@ var ( chassisVendor string ) +// username discovery modes for systemMetadata. +const ( + usernameNone = iota // omit username + usernameSession // username only from an active login session + usernameFull // username with account-list fallbacks, or "unknown" +) + +var ( + discoverMainUserFn = DiscoverMainUser + discoverSessionUserFn = discoverSessionUser +) + // SystemMetadata collects full system metadata including username discovery. -// Use for initial provisioning and first-run config validation where full -// device identification is needed. +// Use for initial provisioning, where full device identification is needed. func SystemMetadata(ctx context.Context) map[string]string { - return systemMetadata(ctx, true) + return systemMetadata(ctx, usernameFull) +} + +// SystemMetadataStartup collects the metadata for the first managed check-in of +// each daemon start. The daemon usually starts at boot, before anyone logs in, +// so username is sent only when an active login session provides it. Otherwise +// the key is omitted and the API keeps the stored value instead of replacing it +// with an account-list guess or "unknown". +func SystemMetadataStartup(ctx context.Context) map[string]string { + return systemMetadata(ctx, usernameSession) } // SystemMetadataRuntime collects system metadata without username discovery. // Use for runtime API calls (config reload, self-uninstall check, deactivation // pin refresh) to avoid repeated user enumeration that can trigger EDR alerts. func SystemMetadataRuntime(ctx context.Context) map[string]string { - return systemMetadata(ctx, false) + return systemMetadata(ctx, usernameNone) } -func systemMetadata(ctx context.Context, includeUsername bool) map[string]string { +func systemMetadata(ctx context.Context, usernameMode int) map[string]string { logger := LoggerFromCtx(ctx) m := make(map[string]string) oi := osinfo.New() @@ -51,8 +71,13 @@ func systemMetadata(ctx context.Context, includeUsername bool) map[string]string } m[metadataChassisTypeKey] = chassisType m[metadataChassisVendorKey] = chassisVendor - if includeUsername { - m[metadataUsernameKey] = DiscoverMainUser(ctx) + switch usernameMode { + case usernameFull: + m[metadataUsernameKey] = discoverMainUserFn(ctx) + case usernameSession: + if user := discoverSessionUserFn(ctx); user != "" { + m[metadataUsernameKey] = user + } } m[metadataDomainOrWorkgroupKey] = partOfDomainOrWorkgroup(ctx) domain, err := system.GetActiveDirectoryDomain() diff --git a/metadata_test.go b/metadata_test.go index b832c7e..ab0df76 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -2,6 +2,7 @@ package ctrld import ( "context" + "reflect" "testing" ) @@ -9,3 +10,58 @@ func Test_metadata(t *testing.T) { m := SystemMetadata(context.Background()) t.Logf("metadata: %v", m) } + +func TestSystemMetadataUsernameScope(t *testing.T) { + oldMain, oldSession := discoverMainUserFn, discoverSessionUserFn + t.Cleanup(func() { discoverMainUserFn, discoverSessionUserFn = oldMain, oldSession }) + + var mainCalls int + discoverMainUserFn = func(context.Context) string { + mainCalls++ + return "lowest-uid-guess" + } + + t.Run("startup with no login session omits username", func(t *testing.T) { + mainCalls = 0 + discoverSessionUserFn = func(context.Context) string { return "" } + m := SystemMetadataStartup(context.Background()) + if v, ok := m[metadataUsernameKey]; ok { + t.Errorf("startup metadata sent username %q with no login session; the API would overwrite the stored value", v) + } + if mainCalls != 0 { + t.Error("startup ran the account-list fallbacks") + } + }) + + t.Run("startup with a login session sends that user", func(t *testing.T) { + discoverSessionUserFn = func(context.Context) string { return "session-user" } + if got := SystemMetadataStartup(context.Background())[metadataUsernameKey]; got != "session-user" { + t.Errorf("startup username = %q, want %q", got, "session-user") + } + }) + + t.Run("provisioning keeps the full discovery chain", func(t *testing.T) { + discoverSessionUserFn = func(context.Context) string { return "" } + if got := SystemMetadata(context.Background())[metadataUsernameKey]; got != "lowest-uid-guess" { + t.Errorf("provisioning username = %q, want the full-discovery result", got) + } + }) + + t.Run("runtime omits username", func(t *testing.T) { + mainCalls = 0 + discoverSessionUserFn = func(context.Context) string { + t.Error("runtime ran session discovery") + return "session-user" + } + if _, ok := SystemMetadataRuntime(context.Background())[metadataUsernameKey]; ok || mainCalls != 0 { + t.Error("runtime metadata included or discovered username") + } + }) + + t.Run("startup carries the same non-username keys as runtime", func(t *testing.T) { + discoverSessionUserFn = func(context.Context) string { return "" } + if s, r := SystemMetadataStartup(context.Background()), SystemMetadataRuntime(context.Background()); !reflect.DeepEqual(s, r) { + t.Errorf("startup %v and runtime %v differ beyond username", s, r) + } + }) +}