From e8e04ae094acebe612de25a073e97ebf05c40b38 Mon Sep 17 00:00:00 2001 From: Anthony Wong Date: Thu, 3 Sep 2026 16:21:42 +0000 Subject: [PATCH] feat(cli): add ctrld diag for provisioning support --- cmd/cli/cli.go | 12 + cmd/cli/commands_diag.go | 466 +++++++++++++++++++++++++ cmd/cli/commands_diag_test.go | 429 +++++++++++++++++++++++ cmd/cli/provision_result.go | 6 +- docs/provisioning-failure-codes.md | 13 + internal/controld/reachability.go | 33 ++ internal/controld/reachability_test.go | 14 + staticdns.go | 19 +- 8 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 cmd/cli/commands_diag.go create mode 100644 cmd/cli/commands_diag_test.go create mode 100644 internal/controld/reachability.go create mode 100644 internal/controld/reachability_test.go diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 20f2b5e..24980db 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -175,6 +175,7 @@ func initCLI() *cobra.Command { InitClientsCmd(rootCmd) InitUpgradeCmd(rootCmd) InitLogCmd(rootCmd) + InitDiagCmd(rootCmd) return rootCmd } @@ -1381,6 +1382,17 @@ func userHomeDir() (string, error) { return ctrld.UserHomeDir() } +// serviceHomeDir returns the directory where a ctrld service with root or +// administrator rights keeps its files. Unlike userHomeDir, it has no +// fallback to the home directory of the current user, so a caller without +// root that only reads looks where the service wrote. +func serviceHomeDir() (string, error) { + if isMobile() { + return homedir, nil + } + return ctrld.ServiceHomeDir() +} + // absHomeDir returns the absolute path of filename in the ctrld home // directory. A custom homedir wins, so a file lands next to the log and // the config that obey the same override. diff --git a/cmd/cli/commands_diag.go b/cmd/cli/commands_diag.go new file mode 100644 index 0000000..b4cd17f --- /dev/null +++ b/cmd/cli/commands_diag.go @@ -0,0 +1,466 @@ +package cli + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + "unicode/utf8" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/Control-D-Inc/ctrld/internal/controld" +) + +// diag reports the facts that support asks for on every provisioning +// ticket. A customer or admin can then paste the output of one command +// instead of a hunt through logs and preference panes. It runs without +// root. A section that needs data the current user cannot read reports +// that, instead of a failure of the whole command. On an installed device +// those are the provisioning-result and the service-state sections, because +// the service wrote the file as root and the service manager answers only +// root. +const diagCmdLong = `Collect diagnostics for a provisioning failure. + +Reports the client version, MDM-managed preferences (macOS only), the last +provisioning result, service state, and whether the Control D API is +reachable. Safe to paste into a support ticket: it never prints the +provisioning token itself, only whether one is present. + +Run it with root or administrator rights on an installed device to include +the last provisioning result and the service state. Without those rights, +the two sections report permission denied.` + +// diagAPIProbeTimeout bounds the API reachability check. diag must return +// promptly even when the network is unreachable. +const diagAPIProbeTimeout = 5 * time.Second + +// diagOverallTimeout bounds the whole report. No single probe, however +// wedged, may keep "ctrld diag" from returning. +const diagOverallTimeout = 15 * time.Second + +// diagServiceStateTimeout bounds one service-state probe. systemctl or +// launchctl can hang against a wedged service manager; the kardianos +// service package gives us no way to cancel that call, so we race it +// against this timer in a goroutine instead. +const diagServiceStateTimeout = 5 * time.Second + +// diagFieldMaxLen bounds any single field pulled from outside ctrld's own +// control (a managed-preferences value), so a misconfigured profile cannot +// blow up the report's size. +const diagFieldMaxLen = 256 + +// managedPrefsDomain is the MDM-managed preferences domain ctrld reads its +// provisioning settings from. +const managedPrefsDomain = "/Library/Managed Preferences/com.controld.ctrld" + +// managedPrefsBin is invoked with an absolute path so diag never depends on +// PATH. +const managedPrefsBin = "/usr/bin/defaults" + +// managedPrefsSupported reports whether this platform has managed +// preferences to read. A var so tests can exercise the macOS-shaped report +// on any OS. +var managedPrefsSupported = func() bool { return runtime.GOOS == "darwin" } + +// managedPrefsRead runs `defaults read [key]` and returns the +// trimmed value, or ok=false if the domain or key does not exist. A var so +// tests seam it instead of shelling out for real. It honors the caller's +// ctx so a near-expired overall deadline cuts this short too. +var managedPrefsRead = func(ctx context.Context, domain, key string) (string, bool) { + args := []string{"read", domain} + if key != "" { + args = append(args, key) + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + out, err := exec.CommandContext(probeCtx, managedPrefsBin, args...).Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(out)), true +} + +// diagProbeReachability makes the one HTTPS probe api_reachability reports +// on. A var so tests replace it instead of hitting the network. +var diagProbeReachability = controld.ProbeReachability + +type diagReport struct { + ClientVersion string `json:"client_version"` + Commit string `json:"commit"` + ManagedPreferences diagManagedPreferences `json:"managed_preferences"` + ProvisionResult diagProvisionResult `json:"provision_result"` + ServiceState diagServiceState `json:"service_state"` + APIReachability diagAPIReachability `json:"api_reachability"` +} + +type diagManagedPreferences struct { + Applicable bool `json:"applicable"` + ProfilePresent bool `json:"profile_present"` + ProvisionToken string `json:"provision_token"` // "present" or "absent"; never the value + CustomHostname string `json:"custom_hostname"` + InterceptMode string `json:"intercept_mode"` + Note string `json:"note"` +} + +// diagProvisionResult mirrors the on-disk provision_result.json for report +// purposes. AgeSeconds is -1 when no age applies (nothing recorded, or the +// timestamp did not parse). Message and Attempts are re-bounded on read: the +// file was written bounded, but diag must not trust that a file on disk +// still is (a mismatched version, or hand-edited). +type diagProvisionResult struct { + Status string `json:"status"` // none, recorded, untrusted, unreadable, corrupt + Stage string `json:"stage"` + Code string `json:"code"` + ExitCode int `json:"exit_code"` + Message string `json:"message"` + Attempts []provisionBindAttempt `json:"attempts,omitempty"` + AgeSeconds int64 `json:"age_seconds"` +} + +type diagServiceState struct { + Status string `json:"status"` // running, stopped, not_installed, unknown + Note string `json:"note"` +} + +type diagAPIReachability struct { + Reachable bool `json:"reachable"` + ErrorClass string `json:"error_class"` // empty when reachable +} + +// diagProvisionResultPath locates the result file where the service wrote +// it, with the same homedir override the writer obeys. The user home-dir +// resolver has a fallback to the home directory of the current user when +// /etc/controld is not writable, and every run without root hits that +// fallback. A diag that used it would report "none recorded" for a file it +// never opened. A var so tests can point it at a temp dir. +var diagProvisionResultPath = func() string { + if homedir != "" { + return filepath.Join(homedir, provisionResultFileName) + } + dir, err := serviceHomeDir() + if err != nil { + return provisionResultPath() + } + return filepath.Join(dir, provisionResultFileName) +} + +// diagServiceStateFn collects service_state. A var so tests can supply a +// fixed state instead of depending on whatever service happens to be +// installed on the machine running the tests. +var diagServiceStateFn = collectServiceStateReal + +// buildDiagReport bounds the whole report at diagOverallTimeout: whatever +// deadline the caller passed in, a single probe wedging past this must not +// keep "ctrld diag" from returning. +func buildDiagReport(ctx context.Context) diagReport { + ctx, cancel := context.WithTimeout(ctx, diagOverallTimeout) + defer cancel() + return diagReport{ + ClientVersion: appVersion, + Commit: commit, + ManagedPreferences: collectManagedPreferences(ctx), + ProvisionResult: collectProvisionResultDiag(), + ServiceState: collectServiceStateBounded(ctx), + APIReachability: collectAPIReachability(ctx), + } +} + +func collectManagedPreferences(ctx context.Context) diagManagedPreferences { + if !managedPrefsSupported() { + return diagManagedPreferences{Note: "not applicable on this platform"} + } + m := diagManagedPreferences{Applicable: true} + if _, ok := managedPrefsRead(ctx, managedPrefsDomain, ""); !ok { + m.Note = "configuration profile not found" + return m + } + m.ProfilePresent = true + // An empty value reads as absent: the postinstall refuses to provision + // on an empty ProvisionToken, so "present" would send support the wrong + // way. + if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "ProvisionToken"); ok && v != "" { + m.ProvisionToken = "present" + } else { + m.ProvisionToken = "absent" + } + if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "CustomHostname"); ok { + m.CustomHostname = boundedDiagField(v) + } + if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "InterceptMode"); ok { + m.InterceptMode = boundedDiagField(v) + } + return m +} + +// boundedDiagField caps a value from outside ctrld's control to a size that +// keeps the report bounded, cutting on a rune boundary so it never splits a +// multi-byte character. +func boundedDiagField(s string) string { + if utf8.RuneCountInString(s) <= diagFieldMaxLen { + return s + } + runes := []rune(s) + return string(runes[:diagFieldMaxLen]) +} + +// collectProvisionResultDiag reads provision_result.json from the service +// home through the same trusted reader "ctrld start" uses, so diag never +// reports a code that contract validation would reject. A read the current +// user is not permitted to make is its own status: the file is not +// corrupt, the reader lacks root. +func collectProvisionResultDiag() diagProvisionResult { + r, err := readProvisionResultAt(diagProvisionResultPath()) + if err != nil { + if os.IsNotExist(err) { + return diagProvisionResult{Status: "none", AgeSeconds: -1} + } + if os.IsPermission(err) { + return diagProvisionResult{Status: "unreadable", AgeSeconds: -1} + } + return diagProvisionResult{Status: "corrupt", AgeSeconds: -1} + } + if !provisionResultTrusted(r) { + return diagProvisionResult{Status: "untrusted", AgeSeconds: -1} + } + age := int64(-1) + if ts, parseErr := time.Parse(time.RFC3339, r.Timestamp); parseErr == nil { + if d := time.Since(ts); d >= 0 { + age = int64(d.Round(time.Second).Seconds()) + } else { + age = 0 + } + } + var attempts []provisionBindAttempt + if r.Detail != nil { + attempts = boundedDiagAttempts(r.Detail.Attempts) + } + return diagProvisionResult{ + Status: "recorded", + Stage: r.Stage, + Code: r.Code, + ExitCode: r.ExitCode, + Message: boundedDiagField(r.Message), + Attempts: attempts, + AgeSeconds: age, + } +} + +// boundedDiagAttempts re-applies the same attempt-count cap the file was +// written with, and bounds each attempt's fields, so a result file from a +// mismatched or tampered version cannot make the report unbounded. +func boundedDiagAttempts(attempts []provisionBindAttempt) []provisionBindAttempt { + if len(attempts) > maxProvisionBindAttempts { + attempts = attempts[:maxProvisionBindAttempts] + } + bounded := make([]provisionBindAttempt, len(attempts)) + for i, a := range attempts { + bounded[i] = provisionBindAttempt{ + Addr: boundedDiagField(a.Addr), + Proto: boundedDiagField(a.Proto), + OSError: boundedDiagField(a.OSError), + } + } + return bounded +} + +// collectServiceStateReal is diagServiceStateFn's production implementation. +// It reuses the same service-manager wrapper "ctrld status" does, so a +// permission-limited run reports "requires elevated privileges" rather than +// a wrong status (see the launchd wrapper in service.go). +func collectServiceStateReal() diagServiceState { + sc := NewServiceCommand() + s, _, err := sc.initializeServiceManager() + if err != nil { + return diagServiceState{Status: "unknown", Note: "could not set up the service manager"} + } + status, statusErr := s.Status() + switch { + case errors.Is(statusErr, service.ErrNotInstalled): + return diagServiceState{Status: "not_installed"} + case statusErr != nil: + return diagServiceState{Status: "unknown", Note: boundedDiagField(statusErr.Error())} + case status == service.StatusRunning: + return diagServiceState{Status: "running"} + case status == service.StatusStopped: + return diagServiceState{Status: "stopped"} + default: + return diagServiceState{Status: "unknown"} + } +} + +// collectServiceStateBounded runs diagServiceStateFn in the background and +// races it against ctx and diagServiceStateTimeout, so a wedged service +// manager reports "timed out" instead of hanging the whole diag report. The +// goroutine is left running if the probe never returns; that is harmless +// since the process exits shortly after diag prints its report. +func collectServiceStateBounded(ctx context.Context) diagServiceState { + done := make(chan diagServiceState, 1) + go func() { done <- diagServiceStateFn() }() + + timer := time.NewTimer(diagServiceStateTimeout) + defer timer.Stop() + + select { + case s := <-done: + return s + case <-ctx.Done(): + return diagServiceState{Status: "unknown", Note: "timed out"} + case <-timer.C: + return diagServiceState{Status: "unknown", Note: "timed out"} + } +} + +func collectAPIReachability(ctx context.Context) diagAPIReachability { + probeCtx, cancel := context.WithTimeout(ctx, diagAPIProbeTimeout) + defer cancel() + if err := diagProbeReachability(probeCtx, cdDev); err != nil { + return diagAPIReachability{Reachable: false, ErrorClass: classifyReachabilityError(err)} + } + return diagAPIReachability{Reachable: true} +} + +// classifyReachabilityError turns a probe failure into a coarse class safe +// to print: no host, no address, no request details, just what kind of +// failure it was. +func classifyReachabilityError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "timeout" + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "dns" + } + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + return "tls" + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return "connection" + } + return "other" +} + +// diagElevateHint names the step that gets root or administrator rights on +// this OS, for the text report. +func diagElevateHint() string { + if runtime.GOOS == "windows" { + return "run again from an administrator prompt" + } + return "run again with sudo" +} + +func displayOrDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +// renderDiagText writes the text-mode report in a fixed section order, so +// output stays stable across runs and safe to diff or paste into a ticket. +func renderDiagText(w io.Writer, r diagReport) { + fmt.Fprintf(w, "client version: %s (commit %s)\n\n", r.ClientVersion, r.Commit) + + fmt.Fprintln(w, "managed preferences:") + switch { + case !r.ManagedPreferences.Applicable: + fmt.Fprintf(w, " %s\n", displayOrDefault(r.ManagedPreferences.Note, "not applicable")) + case !r.ManagedPreferences.ProfilePresent: + fmt.Fprintf(w, " %s\n", r.ManagedPreferences.Note) + default: + fmt.Fprintf(w, " provision token: %s\n", r.ManagedPreferences.ProvisionToken) + fmt.Fprintf(w, " custom hostname: %s\n", displayOrDefault(r.ManagedPreferences.CustomHostname, "(not set)")) + fmt.Fprintf(w, " intercept mode: %s\n", displayOrDefault(r.ManagedPreferences.InterceptMode, "(not set)")) + } + fmt.Fprintln(w) + + fmt.Fprintln(w, "last provisioning result:") + switch r.ProvisionResult.Status { + case "none": + fmt.Fprintln(w, " none recorded") + case "untrusted": + fmt.Fprintln(w, " result file present but not trusted (contents ignored)") + case "unreadable": + fmt.Fprintf(w, " permission denied (%s)\n", diagElevateHint()) + case "corrupt": + fmt.Fprintln(w, " result file present but could not be read (contents ignored)") + default: + fmt.Fprintf(w, " stage: %s\n", r.ProvisionResult.Stage) + fmt.Fprintf(w, " code: %s\n", r.ProvisionResult.Code) + fmt.Fprintf(w, " exit code: %d\n", r.ProvisionResult.ExitCode) + fmt.Fprintf(w, " message: %s\n", r.ProvisionResult.Message) + for _, a := range r.ProvisionResult.Attempts { + fmt.Fprintf(w, " attempt: %s/%s: %s\n", a.Addr, a.Proto, a.OSError) + } + fmt.Fprintf(w, " age: %s\n", (time.Duration(r.ProvisionResult.AgeSeconds) * time.Second).String()) + } + fmt.Fprintln(w) + + fmt.Fprintln(w, "service state:") + fmt.Fprintf(w, " status: %s\n", r.ServiceState.Status) + if r.ServiceState.Note != "" { + fmt.Fprintf(w, " note: %s\n", r.ServiceState.Note) + } + fmt.Fprintln(w) + + fmt.Fprintln(w, "api reachability:") + fmt.Fprintf(w, " reachable: %t\n", r.APIReachability.Reachable) + if r.APIReachability.ErrorClass != "" { + fmt.Fprintf(w, " error class: %s\n", r.APIReachability.ErrorClass) + } +} + +func writeDiagJSON(w io.Writer, r diagReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(r) +} + +// InitDiagCmd registers "ctrld diag" alongside the other top-level commands. +func InitDiagCmd(rootCmd *cobra.Command) *cobra.Command { + var asJSON bool + diagCmd := &cobra.Command{ + Use: "diag", + Short: "Collect diagnostics for a provisioning failure", + Long: diagCmdLong, + Args: cobra.NoArgs, + // diag always exits 0 once it ran: a failure it finds is reported, + // not turned into a nonzero exit. RunE returning an error would exit + // 1 (see Main), so every branch below reports instead of erroring. + RunE: func(cmd *cobra.Command, args []string) error { + report := buildDiagReport(context.Background()) + if asJSON { + if err := writeDiagJSON(cmd.OutOrStdout(), report); err != nil { + // A closed pipe (e.g. `ctrld diag --json | head -1`) must + // not turn into a nonzero exit; the report already ran. + mainLog.Load().Debug().Err(err).Msg("could not write diag JSON report") + } + return nil + } + renderDiagText(cmd.OutOrStdout(), report) + return nil + }, + } + diagCmd.Flags().BoolVar(&asJSON, "json", false, "print the report as JSON") + rootCmd.AddCommand(diagCmd) + return diagCmd +} diff --git a/cmd/cli/commands_diag_test.go b/cmd/cli/commands_diag_test.go new file mode 100644 index 0000000..baf6a44 --- /dev/null +++ b/cmd/cli/commands_diag_test.go @@ -0,0 +1,429 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/spf13/cobra" +) + +// fakeProvisionToken stands in for a real provisioning code. Tests assert it +// never reaches either output mode. +const fakeProvisionToken = "org-v1-FAKE00000000000000000000TOKEN" + +func withManagedPrefsSeam(t *testing.T, supported bool, values map[string]string) { + t.Helper() + oldSupported := managedPrefsSupported + oldRead := managedPrefsRead + managedPrefsSupported = func() bool { return supported } + managedPrefsRead = func(_ context.Context, _, key string) (string, bool) { + v, ok := values[key] + return v, ok + } + t.Cleanup(func() { + managedPrefsSupported = oldSupported + managedPrefsRead = oldRead + }) +} + +// overrideDiagProvisionResultPath points the writer and diag at one temp +// file, so a test can write a result and read it back through diag. +func overrideDiagProvisionResultPath(t *testing.T) string { + t.Helper() + path := overrideProvisionResultPath(t) + old := diagProvisionResultPath + diagProvisionResultPath = func() string { return path } + t.Cleanup(func() { diagProvisionResultPath = old }) + return path +} + +func withServiceStateSeam(t *testing.T, state diagServiceState) { + t.Helper() + old := diagServiceStateFn + diagServiceStateFn = func() diagServiceState { return state } + t.Cleanup(func() { diagServiceStateFn = old }) +} + +func withAPIProbeSeam(t *testing.T, err error) { + t.Helper() + old := diagProbeReachability + diagProbeReachability = func(context.Context, bool) error { return err } + t.Cleanup(func() { diagProbeReachability = old }) +} + +func seedTrustedProvisionResult(t *testing.T, age time.Duration) { + t.Helper() + overrideDiagProvisionResultPath(t) + r := newProvisionResult(provisionCodeTokenExpired, "the provisioning code has expired", nil) + r.Timestamp = time.Now().Add(-age).UTC().Format(time.RFC3339) + if err := writeProvisionResult(r); err != nil { + t.Fatal(err) + } +} + +// seedOversizedProvisionResult writes a trusted result file straight to disk +// (bypassing newProvisionResult's own bounding), standing in for a file left +// by a mismatched or tampered version of ctrld. +func seedOversizedProvisionResult(t *testing.T) { + t.Helper() + overrideDiagProvisionResultPath(t) + attempts := make([]provisionBindAttempt, maxProvisionBindAttempts*3) + for i := range attempts { + attempts[i] = provisionBindAttempt{ + Addr: "0.0.0.0:53", + Proto: "udp", + OSError: strings.Repeat("e", diagFieldMaxLen*3), + } + } + r := &provisionResult{ + Version: 1, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Stage: string(provisionStageListener), + Code: string(provisionCodeListenerBindFailed), + ExitCode: provisionExitCodeForCode[provisionCodeListenerBindFailed], + Message: strings.Repeat("m", diagFieldMaxLen*3), + Detail: &provisionDetail{Attempts: attempts}, + } + if err := writeProvisionResult(r); err != nil { + t.Fatal(err) + } +} + +func TestDiagProvisionResultBoundsOversizedFields(t *testing.T) { + seedOversizedProvisionResult(t) + + got := collectProvisionResultDiag() + + if n := utf8.RuneCountInString(got.Message); n > diagFieldMaxLen { + t.Errorf("message length = %d, want <= %d", n, diagFieldMaxLen) + } + if len(got.Attempts) > maxProvisionBindAttempts { + t.Errorf("attempts length = %d, want <= %d", len(got.Attempts), maxProvisionBindAttempts) + } + for _, a := range got.Attempts { + if n := utf8.RuneCountInString(a.OSError); n > diagFieldMaxLen { + t.Errorf("attempt os_error length = %d, want <= %d", n, diagFieldMaxLen) + } + } +} + +func TestDiagTextReportNeverLeaksToken(t *testing.T) { + withManagedPrefsSeam(t, true, map[string]string{ + "": "", // domain probe: profile present + "ProvisionToken": fakeProvisionToken, + "CustomHostname": "corp-laptop.example.com", + "InterceptMode": "intercept-dns", + }) + seedTrustedProvisionResult(t, 3*time.Minute+12*time.Second) + withServiceStateSeam(t, diagServiceState{Status: "stopped"}) + withAPIProbeSeam(t, nil) + + report := buildDiagReport(context.Background()) + var buf bytes.Buffer + renderDiagText(&buf, report) + out := buf.String() + + if strings.Contains(out, fakeProvisionToken) { + t.Fatalf("text output leaked the provision token: %s", out) + } + wantLines := []string{ + "provision token: present", + "custom hostname: corp-laptop.example.com", + "intercept mode: intercept-dns", + "stage: bootstrap", + "code: TOKEN_EXPIRED", + "exit code: 34", + "status: stopped", + "reachable: true", + } + for _, want := range wantLines { + if !strings.Contains(out, want) { + t.Errorf("text output missing %q, got:\n%s", want, out) + } + } +} + +func TestDiagJSONReportNeverLeaksToken(t *testing.T) { + withManagedPrefsSeam(t, true, map[string]string{ + "": "", + "ProvisionToken": fakeProvisionToken, + "CustomHostname": "corp-laptop.example.com", + "InterceptMode": "standard", + }) + seedTrustedProvisionResult(t, time.Minute) + withServiceStateSeam(t, diagServiceState{Status: "running"}) + withAPIProbeSeam(t, errors.New("dial tcp: connect: connection refused")) + + report := buildDiagReport(context.Background()) + var buf bytes.Buffer + if err := writeDiagJSON(&buf, report); err != nil { + t.Fatal(err) + } + out := buf.String() + if strings.Contains(out, fakeProvisionToken) { + t.Fatalf("JSON output leaked the provision token: %s", out) + } + + var decoded diagReport + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("could not decode JSON report: %v", err) + } + if decoded.ManagedPreferences.ProvisionToken != "present" { + t.Errorf("provision_token = %q, want present", decoded.ManagedPreferences.ProvisionToken) + } + if decoded.ManagedPreferences.CustomHostname != "corp-laptop.example.com" { + t.Errorf("custom_hostname = %q", decoded.ManagedPreferences.CustomHostname) + } + if decoded.ProvisionResult.Status != "recorded" || decoded.ProvisionResult.Code != "TOKEN_EXPIRED" { + t.Errorf("provision_result = %+v", decoded.ProvisionResult) + } + if decoded.ServiceState.Status != "running" { + t.Errorf("service_state = %+v", decoded.ServiceState) + } + if decoded.APIReachability.Reachable { + t.Error("api_reachability.reachable = true, want false") + } + if decoded.APIReachability.ErrorClass == "" { + t.Error("api_reachability.error_class empty for an unreachable API") + } +} + +func TestDiagEmptyMachine(t *testing.T) { + overrideDiagProvisionResultPath(t) // temp dir, no result file written + withManagedPrefsSeam(t, false, nil) + withServiceStateSeam(t, diagServiceState{Status: "not_installed"}) + withAPIProbeSeam(t, context.DeadlineExceeded) + + report := buildDiagReport(context.Background()) + + if report.ManagedPreferences.Applicable { + t.Error("managed preferences reported applicable with no profile on this platform") + } + if report.ProvisionResult.Status != "none" { + t.Errorf("provision result status = %q, want none", report.ProvisionResult.Status) + } + if report.ServiceState.Status != "not_installed" { + t.Errorf("service state = %q, want not_installed", report.ServiceState.Status) + } + if report.APIReachability.Reachable { + t.Error("api reachability reported reachable with a forced timeout") + } + if report.APIReachability.ErrorClass != "timeout" { + t.Errorf("error class = %q, want timeout", report.APIReachability.ErrorClass) + } + + var buf bytes.Buffer + renderDiagText(&buf, report) + if !strings.Contains(buf.String(), "none recorded") { + t.Errorf("text output missing 'none recorded': %s", buf.String()) + } +} + +// Without root, diag must look where the root-run service wrote the result +// file, not in the home directory of the current user. +func TestDiagProvisionResultPathIgnoresUserHome(t *testing.T) { + want := "/etc/controld/" + provisionResultFileName + if runtime.GOOS == "windows" { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + want = filepath.Join(filepath.Dir(exe), provisionResultFileName) + } + if got := diagProvisionResultPath(); got != want { + t.Errorf("diag provision result path = %q, want %q", got, want) + } +} + +// Diag must read where a daemon started with --homedir wrote, so it obeys +// the same override as the writer. +func TestDiagProvisionResultPathHonorsHomedir(t *testing.T) { + old := homedir + homedir = t.TempDir() + t.Cleanup(func() { homedir = old }) + want := filepath.Join(homedir, provisionResultFileName) + if got := diagProvisionResultPath(); got != want { + t.Errorf("diag provision result path = %q, want %q", got, want) + } +} + +// A result file the current user cannot read must report that, not +// "corrupt": the file is fine, the reader lacks root. +func TestDiagProvisionResultUnreadable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file modes do not deny reads on Windows") + } + if os.Geteuid() == 0 { + t.Skip("root can read a 0000 file") + } + path := overrideDiagProvisionResultPath(t) + if err := os.WriteFile(path, []byte("{}"), 0o000); err != nil { + t.Fatal(err) + } + + r := collectProvisionResultDiag() + if r.Status != "unreadable" { + t.Errorf("status = %q, want unreadable", r.Status) + } + if r.AgeSeconds != -1 { + t.Errorf("age_seconds = %d, want -1", r.AgeSeconds) + } + + var buf bytes.Buffer + renderDiagText(&buf, diagReport{ProvisionResult: r}) + if !strings.Contains(buf.String(), "permission denied ("+diagElevateHint()+")") { + t.Errorf("text output does not name permission denied and the elevation step: %s", buf.String()) + } +} + +func TestDiagServiceStateHangYieldsTimeout(t *testing.T) { + old := diagServiceStateFn + // started closes the instant the background probe goroutine reads and + // invokes our stub. Cleanup waits for that before restoring the global: + // otherwise a slow-to-schedule goroutine can still be reading + // diagServiceStateFn when Cleanup writes to it, a data race on the shared + // package var (this test's stub is left running past the test's own + // return, same as production - see collectServiceStateBounded's doc). + started := make(chan struct{}) + diagServiceStateFn = func() diagServiceState { + close(started) + time.Sleep(2 * time.Second) // stand in for a wedged systemctl/launchctl + return diagServiceState{Status: "running"} + } + t.Cleanup(func() { + <-started + diagServiceStateFn = old + }) + + withManagedPrefsSeam(t, false, nil) + overrideDiagProvisionResultPath(t) + withAPIProbeSeam(t, nil) + + // A short deadline stands in for the overall 15s budget already having + // run low; the probe must still yield within it instead of hanging. + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + start := time.Now() + report := buildDiagReport(ctx) + elapsed := time.Since(start) + + if elapsed > time.Second { + t.Fatalf("buildDiagReport took %s, want bounded well under the 2s hang", elapsed) + } + if report.ServiceState.Status != "unknown" { + t.Errorf("service state status = %q, want unknown", report.ServiceState.Status) + } + if !strings.Contains(report.ServiceState.Note, "timed out") { + t.Errorf("service state note = %q, want it to mention timing out", report.ServiceState.Note) + } +} + +func TestDiagManagedPrefsProfileAbsent(t *testing.T) { + withManagedPrefsSeam(t, true, map[string]string{}) // domain read fails: profile absent + m := collectManagedPreferences(context.Background()) + if m.ProfilePresent { + t.Error("profile reported present when the domain read failed") + } + if m.Note == "" { + t.Error("expected a note explaining the absent profile") + } +} + +func TestDiagManagedPrefsTokenAbsent(t *testing.T) { + withManagedPrefsSeam(t, true, map[string]string{"": ""}) // profile present, no keys set + m := collectManagedPreferences(context.Background()) + if !m.ProfilePresent { + t.Fatal("profile should be present") + } + if m.ProvisionToken != "absent" { + t.Errorf("provision token = %q, want absent", m.ProvisionToken) + } +} + +// An empty ProvisionToken value must read as absent: the postinstall refuses +// to provision on an empty token, so diag must not call it present. +func TestDiagManagedPrefsTokenEmpty(t *testing.T) { + withManagedPrefsSeam(t, true, map[string]string{"": "", "ProvisionToken": ""}) + m := collectManagedPreferences(context.Background()) + if !m.ProfilePresent { + t.Fatal("profile should be present") + } + if m.ProvisionToken != "absent" { + t.Errorf("provision token = %q, want absent", m.ProvisionToken) + } +} + +func TestClassifyReachabilityError(t *testing.T) { + if got := classifyReachabilityError(nil); got != "" { + t.Errorf("nil error class = %q, want empty", got) + } + if got := classifyReachabilityError(context.DeadlineExceeded); got != "timeout" { + t.Errorf("deadline exceeded class = %q, want timeout", got) + } +} + +func TestDiagCommandJSONFlag(t *testing.T) { + withManagedPrefsSeam(t, false, nil) + overrideDiagProvisionResultPath(t) + withServiceStateSeam(t, diagServiceState{Status: "not_installed"}) + withAPIProbeSeam(t, nil) + + rootCmd := &cobra.Command{Use: "ctrld"} + InitDiagCmd(rootCmd) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"diag", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("diag --json returned error: %v", err) + } + var decoded diagReport + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("diag --json did not print valid JSON: %v\n%s", err, buf.String()) + } +} + +// writerFailingAfter accepts the first n bytes written to it, then fails +// every write after that - standing in for a pipe closed by a downstream +// reader (`ctrld diag --json | head -1`). +type writerFailingAfter struct { + n int + written int +} + +func (w *writerFailingAfter) Write(p []byte) (int, error) { + if w.written >= w.n { + return 0, errors.New("write: broken pipe") + } + remaining := w.n - w.written + if len(p) > remaining { + w.written += remaining + return remaining, errors.New("write: broken pipe") + } + w.written += len(p) + return len(p), nil +} + +func TestDiagJSONWriteErrorStillExitsZero(t *testing.T) { + withManagedPrefsSeam(t, false, nil) + overrideDiagProvisionResultPath(t) + withServiceStateSeam(t, diagServiceState{Status: "not_installed"}) + withAPIProbeSeam(t, nil) + + rootCmd := &cobra.Command{Use: "ctrld"} + InitDiagCmd(rootCmd) + rootCmd.SetOut(&writerFailingAfter{n: 10}) + rootCmd.SetArgs([]string{"diag", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("diag --json with a failing writer returned error %v, want nil per the always-exit-0 contract", err) + } +} diff --git a/cmd/cli/provision_result.go b/cmd/cli/provision_result.go index 8945343..478c83e 100644 --- a/cmd/cli/provision_result.go +++ b/cmd/cli/provision_result.go @@ -253,7 +253,11 @@ func writeProvisionResult(r *provisionResult) error { } func readProvisionResult() (*provisionResult, error) { - buf, err := os.ReadFile(provisionResultPath()) + return readProvisionResultAt(provisionResultPath()) +} + +func readProvisionResultAt(path string) (*provisionResult, error) { + buf, err := os.ReadFile(path) if err != nil { return nil, err } diff --git a/docs/provisioning-failure-codes.md b/docs/provisioning-failure-codes.md index f807b7d..0bb2222 100644 --- a/docs/provisioning-failure-codes.md +++ b/docs/provisioning-failure-codes.md @@ -60,6 +60,19 @@ The file sits in the same directory as the persisted internal log (`ctrld.log`) for the user the service runs as. On a healthy install the file is absent. +## Support: `ctrld-client diag` + +Run `sudo ctrld-client diag` (or `sudo ctrld-client diag --json`) to collect the facts +support needs for a provisioning ticket in one copy-paste-safe command: +client version, MDM-managed preferences (macOS only, token reported as +present/absent only, an empty value counts as absent), the last +provisioning result, service state, and whether the Control D API is +reachable. It needs no config and always exits 0. A failure it finds is +part of the report, not a command failure. It also runs without root, but +then the last-provisioning-result and the service-state sections report +permission denied. Never asks for or prints the provisioning +token itself. + ## Rules for maintainers - Codes are append-only once released. Never rename, renumber, or reuse a diff --git a/internal/controld/reachability.go b/internal/controld/reachability.go new file mode 100644 index 0000000..27cd9e7 --- /dev/null +++ b/internal/controld/reachability.go @@ -0,0 +1,33 @@ +package controld + +import ( + "context" + "io" + "net/http" +) + +// ProbeReachability makes one lightweight request to the ControlD API host, +// reusing the same transport and IP-fallback logic real provisioning traffic +// takes. Any HTTP response, even an error status, counts as reachable: this +// checks the network path, not whether the endpoint accepts the request. +// +// The caller controls how long to wait via ctx; there is no timeout here +// beyond what ctx enforces. +func ProbeReachability(ctx context.Context, cdDev bool) error { + apiURL := apiURLCom + if cdDev { + apiURL = apiURLDev + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return err + } + client := &http.Client{Transport: apiTransport(ctx, cdDev)} + resp, err := doWithFallback(ctx, client, req, apiServerIP(cdDev)) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/internal/controld/reachability_test.go b/internal/controld/reachability_test.go new file mode 100644 index 0000000..9f39ec7 --- /dev/null +++ b/internal/controld/reachability_test.go @@ -0,0 +1,14 @@ +package controld + +import ( + "context" + "testing" +) + +func TestProbeReachabilityRespectsCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := ProbeReachability(ctx, false); err == nil { + t.Error("expected an error for an already-cancelled context") + } +} diff --git a/staticdns.go b/staticdns.go index b1de8ec..5ca20a0 100644 --- a/staticdns.go +++ b/staticdns.go @@ -26,9 +26,11 @@ func dirWritable(dir string) (bool, error) { return true, f.Close() } -// UserHomeDir returns the home directory for user who is running ctrld. -func UserHomeDir() (string, error) { - // viper will expand for us. +// ServiceHomeDir returns the directory where a ctrld service with root or +// administrator rights keeps its files. It has no fallback to the home +// directory of the current user. A caller without root that only reads can +// thus look where the service wrote. +func ServiceHomeDir() (string, error) { if runtime.GOOS == "windows" { // If we're on windows, use the install path for this. exePath, err := os.Executable() @@ -38,7 +40,16 @@ func UserHomeDir() (string, error) { return filepath.Dir(exePath), nil } - dir := "/etc/controld" + return "/etc/controld", nil +} + +// UserHomeDir returns the home directory for user who is running ctrld. +func UserHomeDir() (string, error) { + // viper will expand for us. + dir, err := ServiceHomeDir() + if err != nil || runtime.GOOS == "windows" { + return dir, err + } if err := os.MkdirAll(dir, 0750); err != nil { return os.UserHomeDir() // fallback to user home directory }