Send full host metadata on managed startup

This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-10-08 15:00:04 +07:00
1 parent b0408e4d1c
commit be8c068fa9
15 files changed
+456 -56

No files matched your search

+34
View File
@@ -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
+41 -14
View File
@@ -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 {
+44 -5
View File
@@ -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
}
+1 -1
View File
@@ -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")
+82
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+22 -9
View File
@@ -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
+17 -4
View File
@@ -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")
+6
View File
@@ -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 ""
}
+17 -4
View File
@@ -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).
+31 -3
View File
@@ -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=<uid>`. 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
+31 -8
View File
@@ -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)
+41
View File
@@ -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")
}
}
+32 -7
View File
@@ -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()
+56
View File
@@ -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)
}
})
}