feat(cli): add ctrld diag for provisioning support

This commit is contained in:
Anthony Wong
2026-09-04 16:47:29 +07:00
committed by Cuong Manh Le
parent 0f821a7907
commit e8e04ae094
8 changed files with 987 additions and 5 deletions
+12
View File
@@ -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.
+466
View File
@@ -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 <domain> [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
}
+429
View File
@@ -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)
}
}
+5 -1
View File
@@ -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
}
+13
View File
@@ -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
+33
View File
@@ -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
}
+14
View File
@@ -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")
}
}
+15 -4
View File
@@ -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
}