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 }