mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
feat(cli): add ctrld diag for provisioning support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user