feat(cli): port provisioning hardening to v1.0

This commit is contained in:
Anthony Wong authored and Cuong Manh Le committed 2026-09-28 16:44:51 +07:00
1 parent e109c9be33
commit 3cd20a3eef
26 files changed
+3706 -672

No files matched your search

+259 -65
View File
@@ -24,6 +24,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/Masterminds/semver/v3"
"github.com/cuonglm/osinfo"
@@ -140,6 +141,7 @@ func initCLI() {
initClientsCmd()
initUpgradeCmd()
initLogCmd()
initDiagCmd()
}
// isMobile reports whether the current OS is a mobile platform.
@@ -183,8 +185,12 @@ func isStableVersion(vs string) bool {
// RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd)
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return
}
run(nil, make(chan struct{}))
}
@@ -223,7 +229,8 @@ func CheckDeactivationPin(pin int64, stopCh chan struct{}) int {
// run runs ctrld cli with given app callback and stop channel.
func run(appCallback *AppCallback, stopCh chan struct{}) {
if stopCh == nil {
mainLog.Load().Fatal().Msg("stopCh is nil")
failRunUnclassified(mainLog.Load().Error(), "run() called with a nil stop channel", nil)
return
}
waitCh := make(chan struct{})
p := &prog{
@@ -261,14 +268,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
} else {
mainLog.Load().Warn().Err(err).Msgf("unable to resolve socket address: %s", sockPath)
}
notifyExitToLogServer := func() {
if p.logConn != nil {
_, _ = p.logConn.Write([]byte(msgExit))
}
}
if daemon && runtime.GOOS == "windows" {
mainLog.Load().Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.")
failRunUnclassified(mainLog.Load().Error(), "cannot run in daemon mode; please install a Windows service", p.notifyExitToLogServer)
return
}
if !daemon {
@@ -277,7 +280,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
go func() {
s, err := newService(p, svcConfig)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed create new service")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to create new service: %v", err), p.notifyExitToLogServer)
return
}
if err := s.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start service")
@@ -288,18 +292,20 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
tryReadingConfig(writeDefaultConfig)
if err := readBase64Config(configBase64); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to read base64 config")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to read base64 config: %v", err), p.notifyExitToLogServer)
return
}
processNoConfigFlags(noConfigStart)
// After s.Run() was called, if ctrld is going to be terminated for any reason,
// write msgExit to p.logConn so others (like "ctrld start") won't have to wait for timeout.
p.mu.Lock()
if err := v.Unmarshal(&cfg); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
}
unmarshalErr := v.Unmarshal(&cfg)
p.mu.Unlock()
if unmarshalErr != nil {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", unmarshalErr), p.notifyExitToLogServer)
return
}
processLogAndCacheFlags(v, &cfg)
@@ -312,8 +318,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// Wait for network up.
if !ctrldnet.Up() {
notifyExitToLogServer()
mainLog.Load().Fatal().Msg("network is not up yet")
failRunUnclassified(mainLog.Load().Error(), "network is not up yet", p.notifyExitToLogServer)
return
}
p.router = router.New(&cfg, cdUID != "")
@@ -327,8 +333,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// time for validating server certificate. Some routers need NTP synchronization
// to set the current time, so this check must happen before processCDFlags.
if err := p.router.PreRun(); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Err(err).Msg("failed to perform router pre-run check")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to perform router pre-run check: %v", err), p.notifyExitToLogServer)
return
}
oldLogPath := cfg.Service.LogPath
@@ -336,7 +342,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
cdUID = uid
}
if cdUID != "" {
validateCdUpstreamProtocol()
if !validateCdUpstreamProtocol(p.notifyExitToLogServer) {
return
}
// Bound API preflight by the service lifetime. Without this, a stop request
// arriving while the API is unreachable leaves this retry/backoff loop running
// after "service stopped" was logged, so the process keeps working on behalf of
@@ -353,7 +361,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// not see a failed start and apply its restart policy to a service the
// operator just asked to stop.
mainLog.Load().Notice().Msg("stop requested while fetching resolver config, shutting down")
notifyExitToLogServer()
p.notifyExitToLogServer()
return
case pf.err != nil:
if isMobile() {
@@ -361,7 +369,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
return
}
handleAPIPreflightFailure(p, pf.err, notifyExitToLogServer)
handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
return
default:
p.mu.Lock()
@@ -370,7 +378,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
}
updated := updateListenerConfig(&cfg, notifyExitToLogServer)
updated := updateListenerConfig(&cfg, p.notifyExitToLogServer)
// Bootstrap and listener binding both succeeded, so an earlier run's
// recorded failure no longer describes this install.
@@ -390,11 +398,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
if updated {
if err := writeConfigFile(&cfg); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Err(err).Msg("failed to write config file")
} else {
mainLog.Load().Info().Msg("writing config file to: " + defaultConfigFile)
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer)
return
}
mainLog.Load().Info().Msg("writing config file to: " + defaultConfigFile)
}
if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
@@ -412,31 +419,28 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
if err := validateConfig(&cfg); err != nil {
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("invalid config: %v", err), p.notifyExitToLogServer)
return
}
initCache()
if daemon {
exe, err := os.Executable()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to find the binary")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to find the binary", p.notifyExitToLogServer)
return
}
curDir, err := os.Getwd()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to get current working directory")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to get current working directory", p.notifyExitToLogServer)
return
}
// If running as daemon, re-run the command in background, with daemon off.
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
cmd.Dir = curDir
if err := cmd.Start(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start process as daemon")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to start process as daemon", p.notifyExitToLogServer)
return
}
mainLog.Load().Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
os.Exit(0)
@@ -741,9 +745,10 @@ func permanentAPIRejection(err error) (*controld.ErrorResponse, bool) {
}
// apiFailureCode maps a bootstrap preflight error to its provisioning code.
// A deleted device gets its own code because it triggers self-uninstall;
// other permanent rejections are generic; anything else counts as
// reachability trouble worth retrying.
// A deleted device gets its own code because it triggers self-uninstall; a
// permanent rejection with a known token reason gets its own code; other
// permanent rejections are generic; anything else counts as reachability
// trouble worth retrying.
func apiFailureCode(err error) (provisionFailureCode, bool) {
if err == nil {
return "", false
@@ -752,12 +757,62 @@ func apiFailureCode(err error) (provisionFailureCode, bool) {
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
return provisionCodeAPIDeviceInvalid, true
}
if _, ok := permanentAPIRejection(err); ok {
if rejection, ok := permanentAPIRejection(err); ok {
if code, ok := tokenFailureCodeForReason(rejection.ErrorField.Metadata.Reason); ok {
return code, true
}
return provisionCodeAPIRejected, true
}
return provisionCodeAPIUnreachable, true
}
// tokenFailureCodeForReason maps error.metadata.reason on a provisioning-token
// rejection to its failure code. An empty or unrecognized reason is not
// classified here; the caller falls back to the generic rejection code.
func tokenFailureCodeForReason(reason string) (provisionFailureCode, bool) {
switch reason {
case controld.ReasonTokenInvalid:
return provisionCodeTokenInvalid, true
case controld.ReasonTokenExpired:
return provisionCodeTokenExpired, true
case controld.ReasonTokenLimitReached:
return provisionCodeTokenLimitReached, true
case controld.ReasonTokenDisabled:
return provisionCodeTokenDisabled, true
default:
return "", false
}
}
// tokenRejectionMessage returns the human message for a TOKEN_* code: what is
// wrong with the provisioning code and one concrete next action. Empty for
// any other code, so callers know to fall back to a generic message. Never
// includes the token itself.
func tokenRejectionMessage(code provisionFailureCode) string {
switch code {
case provisionCodeTokenInvalid:
return "the provisioning code is not valid; check the code and re-enter it exactly as given"
case provisionCodeTokenExpired:
return "the provisioning code has expired; get a new provisioning code from your administrator"
case provisionCodeTokenLimitReached:
return "the provisioning code reached its device limit; free up a device slot or use a different code"
case provisionCodeTokenDisabled:
return "the provisioning code was invalidated; download a profile from an active provisioning code"
default:
return ""
}
}
// provisionTokenFailureMessage returns the result-file message for a failed
// provision-token exchange: a token-specific message when the API gave a
// known reason, the generic exchange-failure summary otherwise.
func provisionTokenFailureMessage(code provisionFailureCode, err error) string {
if msg := tokenRejectionMessage(code); msg != "" {
return msg
}
return fmt.Sprintf("provision token exchange failed: %v", err)
}
// apiRejectionSummary reports the HTTP status only. The API's raw error body
// can echo back the value the caller sent, so it stays out of the artifact.
func apiRejectionSummary(statusCode int) string {
@@ -1215,7 +1270,15 @@ func selfCheckResolveDomain(ctx context.Context, addr, scope string, domain stri
return errSelfCheckNoAnswer
}
func userHomeDir() (string, error) {
// unixServiceHomeDir is where a ctrld service with root rights keeps its
// files on macOS, Linux, and the BSDs.
const unixServiceHomeDir = "/etc/controld"
// 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) {
dir, err := router.HomeDir()
if err != nil {
return "", err
@@ -1237,7 +1300,17 @@ func userHomeDir() (string, error) {
if isMobile() {
return homedir, nil
}
dir = "/etc/controld"
return unixServiceHomeDir, nil
}
// userHomeDir is serviceHomeDir with a fallback: when the current user
// cannot write to the unix service home, it returns the home directory of
// that user instead.
func userHomeDir() (string, error) {
dir, err := serviceHomeDir()
if err != nil || dir != unixServiceHomeDir {
return dir, err
}
if err := os.MkdirAll(dir, 0750); err != nil {
return os.UserHomeDir() // fallback to user home directory
}
@@ -1898,6 +1971,84 @@ func osVersion() string {
return oi.String()
}
// provisionTokenMinLen, provisionTokenMaxLen, and provisionTokenExpectedPrefix
// describe the shape of a --cd-org value the input stage checks before any
// network call.
const (
provisionTokenMinLen = 6
provisionTokenMaxLen = 64
provisionTokenExpectedPrefix = "org-v1-"
)
// provisionTokenShapeValid reports whether token looks like something the API
// could parse: a length between provisionTokenMinLen and provisionTokenMaxLen,
// with no whitespace or control characters. It does not require the
// "org-v1-" prefix - legacy provisioning codes predate that convention and
// are handled with a warning, not a failure.
func provisionTokenShapeValid(token string) bool {
if len(token) < provisionTokenMinLen || len(token) > provisionTokenMaxLen {
return false
}
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return false
}
}
return true
}
// validateProvisionTokenShape classifies an obviously malformed --cd-org
// value (PROVISION_TOKEN_MALFORMED) before any network call, so a typo or a
// pasted URL fails fast instead of going through a doomed API round trip. A
// token missing the "org-v1-" prefix only gets a logged warning: legacy
// provisioning codes may lack it and still work.
func validateProvisionTokenShape() bool {
if !provisionTokenShapeValid(cdOrg) {
msg := fmt.Sprintf("--cd-org provisioning code is malformed: must be %d-%d characters with no whitespace or control characters", provisionTokenMinLen, provisionTokenMaxLen)
failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
return false
}
if !strings.HasPrefix(cdOrg, provisionTokenExpectedPrefix) {
mainLog.Load().Warn().Msg("--cd-org provisioning code does not have the expected org-v1- prefix; continuing, since legacy codes may lack it")
}
return true
}
// validateCustomHostnameFlag classifies an invalid --custom-hostname value
// (CUSTOM_HOSTNAME_INVALID) before any network call. ctrld's accept/reject
// rule (validHostname) is unchanged - this only names the field, the
// offending character(s), and the allowed format instead of a bare fatal
// exit. ControlD's device-name formatting folds or strips some characters
// ctrld still accepts (dot, space, plus), so a hostname using one of those
// gets a one-line notice instead: the registered device name may differ from
// what was requested.
func validateCustomHostnameFlag() bool {
if customHostname == "" {
return true
}
if !validHostname(customHostname) {
failProvision(newProvisionResult(provisionCodeCustomHostnameInvalid, customHostnameFailureMessage(customHostname), nil, provisionSecrets()...), nil)
return false
}
if hostnameMayBeFoldedByServer(customHostname) {
mainLog.Load().Notice().Msgf("device name %q contains characters ControlD may fold or strip when it registers the device; the registered name may differ", customHostname)
}
return true
}
// validateInterceptModeFlag classifies an invalid --intercept-mode value
// (INTERCEPT_MODE_INVALID) on the provisioning boundary, so a typo fails with
// a stable code instead of a bare fatal exit an installer wrapper cannot tell
// apart from any other crash. An empty mode means the flag was not set.
func validateInterceptModeFlag(mode string) bool {
if mode == "" || validInterceptMode(mode) {
return true
}
msg := fmt.Sprintf("--intercept-mode %q is not valid: must be 'off', 'dns', or 'hard'", mode)
failProvision(newProvisionResult(provisionCodeInterceptModeInvalid, msg, nil, provisionSecrets()...), nil)
return false
}
// cdUIDFromProvToken fetch UID from ControlD API using provision token.
func cdUIDFromProvToken() string {
// --cd flag supersedes --cd-org, ignore it if both are supplied.
@@ -1908,9 +2059,11 @@ func cdUIDFromProvToken() string {
if cdOrg == "" {
return ""
}
// Validate custom hostname if provided.
if customHostname != "" && !validHostname(customHostname) {
mainLog.Load().Fatal().Msgf("invalid custom hostname: %q", customHostname)
if !validateProvisionTokenShape() {
return ""
}
if !validateCustomHostnameFlag() {
return ""
}
req := &controld.UtilityOrgRequest{
@@ -1926,7 +2079,7 @@ func cdUIDFromProvToken() string {
code, _ := apiFailureCode(err)
mainLog.Load().Error().Msgf("failed to fetch resolver uid with provision token: %s: %s",
redactToken(cdOrg), redactSecrets(err.Error(), provisionSecrets()...))
failProvision(newProvisionResult(code, fmt.Sprintf("provision token exchange failed: %v", err), nil, provisionSecrets()...), nil)
failProvision(newProvisionResult(code, provisionTokenFailureMessage(code, err), nil, provisionSecrets()...), nil)
return ""
}
return resolverConfig.UID
@@ -2032,32 +2185,55 @@ func newSocketControlClientMobile(dir string, stopCh chan struct{}) *controlClie
}
// checkStrFlagEmpty validates if a string flag was set to an empty string.
// If yes, emitting a fatal error message.
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) {
// An explicit empty --cd-org is a malformed provisioning token, classified on
// the provisioning boundary; every other flag keeps the bare fatal, since
// this helper is shared with flags outside that boundary. Returns false when
// the caller must stop: provisionExit is stubbed out under test, so nothing
// else stops execution from falling through.
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) bool {
fl := cmd.Flags().Lookup(flagName)
if !fl.Changed || fl.Value.Type() != "string" {
return
if !fl.Changed || fl.Value.Type() != "string" || fl.Value.String() != "" {
return true
}
if fl.Value.String() == "" {
mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
if flagName == cdOrgFlagName {
msg := fmt.Sprintf("--%s provisioning code is malformed: value must be non-empty", cdOrgFlagName)
failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
return false
}
mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
return false
}
func validateCdUpstreamProtocol() {
// validateCdUpstreamProtocol validates the Control D upstream protocol,
// classified on the provisioning boundary since it only runs once --cd is
// set. notify unblocks a waiting "ctrld start" on the daemon side; the parent
// process, which has no one waiting, passes nil. Returns false when the
// caller must stop.
func validateCdUpstreamProtocol(notify func()) bool {
if cdUID == "" {
return
return true
}
switch cdUpstreamProto {
case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
return true
default:
mainLog.Load().Fatal().Msg(`flag "--protocol" must be "doh" or "doh3"`)
msg := fmt.Sprintf("--proto %q is not valid: must be 'doh' or 'doh3'", cdUpstreamProto)
failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), notify)
return false
}
}
func validateCdAndNextDNSFlags() {
// validateCdAndNextDNSFlags validates that Control D and NextDNS flags are
// not used together, classified on the provisioning boundary since it only
// fires once --cd or --cd-org is set. Returns false when the caller must
// stop.
func validateCdAndNextDNSFlags() bool {
if (cdUID != "" || cdOrg != "") && nextdns != "" {
mainLog.Load().Fatal().Msgf("--%s/--%s could not be used with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
msg := fmt.Sprintf("--%s/--%s cannot be used together with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), nil)
return false
}
return true
}
// removeNextDNSFromArgs removes the --nextdns from command line arguments.
@@ -2274,7 +2450,25 @@ func runningIface(s service.Service) *ifaceResponse {
return nil
}
// apiRejectionMessage returns the result-file message for a permanent API
// rejection: the HTTP status for a device-invalid or generic rejection, or
// the raw error for anything else (network trouble, an unreachable API).
func apiRejectionMessage(err error) string {
var uer *controld.ErrorResponse
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
return apiRejectionSummary(uer.StatusCode)
}
if rejection, ok := permanentAPIRejection(err); ok {
return apiRejectionSummary(rejection.StatusCode)
}
return fmt.Sprintf("failed to fetch resolver config: %v", err)
}
// doValidateCdRemoteConfig fetches and validates custom config for cdUID.
// fatal distinguishes the two callers: a direct install-time caller passes
// true and classifies a fetch failure on the provisioning boundary; the
// restart path passes false and gets the error back to decide for itself,
// with no process exit.
func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
// Username is only sent during initial provisioning (cdUIDFromProvToken).
// All subsequent calls use lightweight metadata to avoid EDR triggers.
@@ -2283,16 +2477,16 @@ func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
Version: rootCmd.Version,
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
rc, err := controld.FetchResolverConfig(context.Background(), req, cdDev)
rc, err := fetchResolverConfig(context.Background(), req, cdDev)
if err != nil {
logger := mainLog.Load().Fatal()
if !fatal {
logger = mainLog.Load().Warn()
}
logger.Err(err).Err(err).Msgf("failed to fetch resolver uid: %s", cdUID)
if !fatal {
mainLog.Load().Warn().Err(err).Msgf("failed to fetch resolver config for %s", redactToken(cdUID))
return err
}
code, _ := apiFailureCode(err)
mainLog.Load().Error().Err(err).Msgf("failed to fetch resolver config for %s", redactToken(cdUID))
failProvision(newProvisionResult(code, apiRejectionMessage(err), nil, provisionSecrets()...), nil)
return err
}
// return earlier if there's no custom config.
+239
View File
@@ -2,6 +2,8 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -73,6 +75,140 @@ func TestApiFailureCode(t *testing.T) {
}
}
// TestApiFailureCodeMapsRejectionReason covers the token_* reasons the API sends in
// error.metadata.reason on a provisioning-token rejection. An absent or unknown
// reason must fall back to the generic API_REJECTED exactly as before this reason
// field existed.
func TestApiFailureCodeMapsRejectionReason(t *testing.T) {
rejectionWithReason := func(reason string) error {
e := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
e.ErrorField.Code = 40003
e.ErrorField.Message = "invalid token"
e.ErrorField.Metadata.Reason = reason
return e
}
tests := []struct {
name string
reason string
wantCode provisionFailureCode
}{
{name: "token_invalid", reason: "token_invalid", wantCode: provisionCodeTokenInvalid},
{name: "token_expired", reason: "token_expired", wantCode: provisionCodeTokenExpired},
{name: "token_limit_reached", reason: "token_limit_reached", wantCode: provisionCodeTokenLimitReached},
{name: "token_disabled", reason: "token_disabled", wantCode: provisionCodeTokenDisabled},
{name: "reason absent falls back", reason: "", wantCode: provisionCodeAPIRejected},
{name: "unknown reason falls back", reason: "some_future_reason", wantCode: provisionCodeAPIRejected},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
code, ok := apiFailureCode(rejectionWithReason(tc.reason))
if !ok {
t.Fatal("apiFailureCode() ok = false, want true")
}
if code != tc.wantCode {
t.Errorf("apiFailureCode() code = %s, want %s", code, tc.wantCode)
}
})
}
}
// TestApiFailureCodeSurvivesMalformedReasonType covers a rejection body whose
// metadata.reason is the wrong JSON type end to end: decode it exactly as
// internal/controld does (json.Unmarshal into the same exported type), then
// classify it. Before the metadata decode fix, this body failed the whole
// decode and apiFailureCode never saw an *ErrorResponse at all, so it fell
// back to API_UNREACHABLE - the retryable bootstrap code - instead of the
// permanent rejection this HTTP 400 with a known error code actually is.
func TestApiFailureCodeSurvivesMalformedReasonType(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "reason as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":12345}}}`},
{name: "reason as an object", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":{"inner":"value"}}}}`},
{name: "reason as null", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":null}}}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
e := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
if err := json.Unmarshal([]byte(tc.body), e); err != nil {
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
}
code, ok := apiFailureCode(e)
if !ok {
t.Fatal("apiFailureCode() ok = false, want true")
}
if code != provisionCodeAPIRejected {
t.Errorf("apiFailureCode() code = %s, want %s (not %s)", code, provisionCodeAPIRejected, provisionCodeAPIUnreachable)
}
})
}
}
// TestCdUIDFromProvTokenReasonCodes covers the full path from an API rejection
// reason to a persisted result file: each known reason gets its own code, exit
// code, and stage, with a message naming the field and a next action but never
// echoing the token. Absent and unknown reasons keep the generic rejection.
func TestCdUIDFromProvTokenReasonCodes(t *testing.T) {
const secretToken = "org-secret-token-999"
tests := []struct {
name string
reason string
wantCode provisionFailureCode
wantContains string
}{
{name: "token_invalid", reason: "token_invalid", wantCode: provisionCodeTokenInvalid, wantContains: "provisioning code"},
{name: "token_expired", reason: "token_expired", wantCode: provisionCodeTokenExpired, wantContains: "expired"},
{name: "token_limit_reached", reason: "token_limit_reached", wantCode: provisionCodeTokenLimitReached, wantContains: "limit"},
{name: "token_disabled", reason: "token_disabled", wantCode: provisionCodeTokenDisabled, wantContains: "invalidated"},
{name: "reason absent", reason: "", wantCode: provisionCodeAPIRejected, wantContains: ""},
{name: "unknown reason", reason: "brand_new_reason", wantCode: provisionCodeAPIRejected, wantContains: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
cdUID = ""
cdOrg = secretToken
customHostname = ""
rejected := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
rejected.ErrorField.Code = 40003
rejected.ErrorField.Message = "invalid token " + secretToken
rejected.ErrorField.Metadata.Reason = tc.reason
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
return nil, rejected
}
if got := cdUIDFromProvToken(); got != "" {
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
}
if *exitCode != provisionExitCodeForCode[tc.wantCode] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[tc.wantCode])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(tc.wantCode) {
t.Errorf("code = %q, want %q", r.Code, tc.wantCode)
}
if r.Stage != string(provisionStageBootstrap) {
t.Errorf("stage = %q, want bootstrap", r.Stage)
}
if tc.wantContains != "" && !strings.Contains(r.Message, tc.wantContains) {
t.Errorf("message = %q, want it to contain %q", r.Message, tc.wantContains)
}
if strings.Contains(r.Message, secretToken) {
t.Errorf("token leaked into result message: %q", r.Message)
}
})
}
}
func stubProvisionGlobals(t *testing.T) (exitCode *int, notified *bool) {
t.Helper()
oldCdUID, oldCdOrg := cdUID, cdOrg
@@ -179,6 +315,109 @@ func TestHandleAPIPreflightFailure(t *testing.T) {
})
}
// TestDoValidateCdRemoteConfigClassifiesAPIFailure covers the fatal=true
// caller: a fetch failure must classify on the provisioning boundary with
// the same per-class codes as the daemon-side preflight, instead of a bare
// fatal.
func TestDoValidateCdRemoteConfigClassifiesAPIFailure(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch := fetchResolverConfig
t.Cleanup(func() { fetchResolverConfig = oldFetch })
deviceInvalid := func() error {
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
e.ErrorField.Code = controld.InvalidConfigCode
e.ErrorField.Message = "device does not exist"
return e
}
rejected := func() error {
e := &controld.ErrorResponse{StatusCode: http.StatusUnauthorized}
e.ErrorField.Message = "bad token"
return e
}
tests := []struct {
name string
err error
wantCode provisionFailureCode
}{
{name: "device invalid", err: deviceInvalid(), wantCode: provisionCodeAPIDeviceInvalid},
{name: "permanent rejection", err: rejected(), wantCode: provisionCodeAPIRejected},
{name: "unreachable", err: retryableNetworkErr(), wantCode: provisionCodeAPIUnreachable},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
return nil, tc.err
}
if err := doValidateCdRemoteConfig("device-uid-123", true); err == nil {
t.Error("doValidateCdRemoteConfig() error = nil, want the fetch error back")
}
if *exitCode != provisionExitCodeForCode[tc.wantCode] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[tc.wantCode])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(tc.wantCode) {
t.Errorf("code = %q, want %q", r.Code, tc.wantCode)
}
})
}
}
// TestDoValidateCdRemoteConfigNonFatalReturnsError proves the restart path
// (fatal=false) is unaffected: it still just warns and hands the error back,
// with no process exit and no result file.
func TestDoValidateCdRemoteConfigNonFatalReturnsError(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch := fetchResolverConfig
t.Cleanup(func() { fetchResolverConfig = oldFetch })
wantErr := errors.New("network unreachable")
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
return nil, wantErr
}
if err := doValidateCdRemoteConfig("device-uid-123", false); !errors.Is(err, wantErr) {
t.Errorf("doValidateCdRemoteConfig() error = %v, want %v", err, wantErr)
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
if _, err := readProvisionResult(); err == nil {
t.Error("expected no provision result written for the non-fatal path")
}
}
// TestDoValidateCdRemoteConfigDoesNotSelfUninstall proves a device-invalid
// failure on this path never triggers self-uninstall: unlike the daemon-side
// bootstrap preflight, callers passing fatal=true run before any service is
// installed, so there is nothing to remove.
func TestDoValidateCdRemoteConfigDoesNotSelfUninstall(t *testing.T) {
_, _ = stubProvisionGlobals(t)
oldFetch, oldUninstall := fetchResolverConfig, uninstallInvalidCdUIDFn
t.Cleanup(func() { fetchResolverConfig, uninstallInvalidCdUIDFn = oldFetch, oldUninstall })
uninstallCalled := false
uninstallInvalidCdUIDFn = func(*prog, zerolog.Logger, bool) bool {
uninstallCalled = true
return true
}
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
e.ErrorField.Code = controld.InvalidConfigCode
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
return nil, e
}
_ = doValidateCdRemoteConfig("device-uid-123", true)
if uninstallCalled {
t.Error("doValidateCdRemoteConfig triggered self-uninstall; nothing is installed yet on this path")
}
}
func TestCdUIDFromProvTokenFailureEmitsCode(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
+43 -22
View File
@@ -342,9 +342,20 @@ NOTE: running "ctrld start" without any arguments will start already installed c
return nil
},
Run: func(cmd *cobra.Command, args []string) {
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
validateCdAndNextDNSFlags()
// Clear before any check runs, not just before doTasksE: a result from a
// previous attempt must never survive to mislead diag/postinstall on this
// one, even if this attempt fails before reaching doTasksE.
clearProvisionResult()
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return
}
if !validateCdAndNextDNSFlags() {
return
}
sc := &service.Config{}
*sc = *svcConfig
osArgs := os.Args[2:]
@@ -359,8 +370,8 @@ NOTE: running "ctrld start" without any arguments will start already installed c
// Without this, a typo like "--intercept-mode fds" would install the service,
// the child process would Fatal() on the invalid value, and the parent would
// then uninstall — confusing and destructive.
if interceptMode != "" && !validInterceptMode(interceptMode) {
mainLog.Load().Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
if !validateInterceptModeFlag(interceptMode) {
return
}
p := &prog{
@@ -370,6 +381,9 @@ NOTE: running "ctrld start" without any arguments will start already installed c
s, err := newService(p, sc)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
// A bare return would exit 0 with no result file, so support
// could not tell this failure from a start that never ran.
failProvisionUnclassified("initialize service: "+err.Error(), nil)
return
}
p.preRun()
@@ -397,7 +411,8 @@ NOTE: running "ctrld start" without any arguments will start already installed c
// An explicit "off" argument must override a previously persisted config
// value while the service clears that value on startup.
if err := removeServiceFlag("--intercept-mode"); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to remove existing intercept mode from service arguments")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to remove existing intercept mode from service arguments: %v", err), nil)
return
}
if interceptMode == "off" {
@@ -406,10 +421,12 @@ NOTE: running "ctrld start" without any arguments will start already installed c
mainLog.Load().Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
}
if err := appendServiceFlag("--intercept-mode"); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to append intercept flag to service arguments: %v", err), nil)
return
}
if err := appendServiceFlag(interceptMode); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to append intercept mode value to service arguments: %v", err), nil)
return
}
// Stop the service if running (bypasses ctrld pin — this is an
@@ -538,7 +555,8 @@ NOTE: running "ctrld start" without any arguments will start already installed c
if startOnly && isCtrldInstalled {
tryReadingConfigWithNotice(false, true)
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return
}
// if already running, dont restart
@@ -565,8 +583,6 @@ NOTE: running "ctrld start" without any arguments will start already installed c
{s.Start, true, "Start"},
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
}
// Any result found later must come from this attempt, not a stale run.
clearProvisionResult()
startAttemptAt := time.Now()
mainLog.Load().Notice().Msg("Starting existing ctrld service")
failedTask, taskErr := doTasksE(tasks)
@@ -575,12 +591,15 @@ NOTE: running "ctrld start" without any arguments will start already installed c
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return
}
os.Exit(1)
// Not a service-stage task. doTasksE already logged the cause; classify
// UNCLASSIFIED instead of the old silent fall-through that exited 1.
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
return
}
sockDir, err := socketDir()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("Failed to get socket directory")
os.Exit(1)
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to get socket directory: %v", err), nil)
return
}
// The daemon can start and still fail provisioning (for example a
@@ -623,11 +642,14 @@ NOTE: running "ctrld start" without any arguments will start already installed c
sc.Arguments = append(sc.Arguments, "--cd="+cdUID)
}
if cdUID != "" {
validateCdUpstreamProtocol()
if !validateCdUpstreamProtocol(nil) {
return
}
}
if err := p.router.ConfigureService(sc); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to configure service on router")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to configure service on router: %v", err), nil)
return
}
if configPath != "" {
@@ -637,7 +659,8 @@ NOTE: running "ctrld start" without any arguments will start already installed c
tryReadingConfigWithNotice(writeDefaultConfig, true)
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return
}
initInteractiveLogging()
@@ -682,8 +705,6 @@ NOTE: running "ctrld start" without any arguments will start already installed c
// generated after s.Start, so we notice users here for consistent with nextdns mode.
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
}
// Any result found later must come from this attempt, not a stale run.
clearProvisionResult()
startAttemptAt := time.Now()
mainLog.Load().Notice().Msg("Starting service")
failedTask, taskErr := doTasksE(tasks)
@@ -692,9 +713,9 @@ NOTE: running "ctrld start" without any arguments will start already installed c
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return
}
// Not a service-stage task. doTasksE already logged the cause; exit
// non-zero instead of the old silent fall-through that exited 0.
os.Exit(1)
// Not a service-stage task. doTasksE already logged the cause; classify
// UNCLASSIFIED instead of the old silent fall-through that exited 0.
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
return
}
+473
View File
@@ -0,0 +1,473 @@
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. The shared 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 looked at. 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: curVersion(),
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 the OS's own permission error rather than a
// wrong status.
func collectServiceStateReal() diagServiceState {
s, err := newService(&prog{}, svcConfig)
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() *cobra.Command {
diagCmd := newDiagCmd()
rootCmd.AddCommand(diagCmd)
return diagCmd
}
// newDiagCmd builds the "diag" command without registering it anywhere, so
// tests can attach it to an isolated root command instead of the package's
// shared one.
func newDiagCmd() *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")
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)
testRootCmd := &cobra.Command{Use: "ctrld"}
testRootCmd.AddCommand(newDiagCmd())
var buf bytes.Buffer
testRootCmd.SetOut(&buf)
testRootCmd.SetArgs([]string{"diag", "--json"})
if err := testRootCmd.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)
testRootCmd := &cobra.Command{Use: "ctrld"}
testRootCmd.AddCommand(newDiagCmd())
testRootCmd.SetOut(&writerFailingAfter{n: 10})
testRootCmd.SetArgs([]string{"diag", "--json"})
if err := testRootCmd.Execute(); err != nil {
t.Fatalf("diag --json with a failing writer returned error %v, want nil per the always-exit-0 contract", err)
}
}
+152
View File
@@ -1,10 +1,162 @@
package cli
import (
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
"time"
)
// startRunFuncSource extracts the source text of initStartCmd's "Run:"
// closure body from commands.go. Driving the closure itself end-to-end for
// every early-return branch is not practical in a unit test: within a few
// lines of any check failing, it reaches into the real OS service manager.
// Some invariants about its shape are cheaper and more reliable to pin by
// reading the source than by executing it.
func startRunFuncSource(t *testing.T) string {
t.Helper()
file := packageSourcePath(t, "commands.go")
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
t.Fatalf("could not parse %s: %v", file, err)
}
var runLit *ast.FuncLit
ast.Inspect(node, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "initStartCmd" {
return true
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
kv, ok := n.(*ast.KeyValueExpr)
if !ok {
return true
}
// initStartCmd defines a second, unrelated "Run:" field further down
// for a start command alias; take only the first match, which is the
// real start command's closure this test cares about.
if runLit == nil {
if ident, ok := kv.Key.(*ast.Ident); ok && ident.Name == "Run" {
if lit, ok := kv.Value.(*ast.FuncLit); ok {
runLit = lit
}
}
}
return true
})
return false
})
if runLit == nil {
t.Fatalf("Run: func literal not found in initStartCmd in %s", file)
}
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("could not read %s: %v", file, err)
}
start := fset.Position(runLit.Body.Lbrace).Offset
end := fset.Position(runLit.Body.Rbrace).Offset
return string(src[start:end])
}
// TestStartCommandClearsProvisionResultBeforeAnyCheck pins the ordering fix:
// clearProvisionResult() must run before every check in the start command's
// Run closure that can fail or return early, not just before doTasksE.
// Without this, a check between the top of the closure and the old call
// sites could return early (whether by writing its own classified failure
// or, like the "service already running" and service-manager-init-error
// paths, by writing nothing at all) while a previous attempt's result file
// was still sitting there to mislead diag/postinstall on retry.
func TestStartCommandClearsProvisionResultBeforeAnyCheck(t *testing.T) {
body := startRunFuncSource(t)
clearIdx := strings.Index(body, "clearProvisionResult()")
if clearIdx == -1 {
t.Fatal("start command no longer calls clearProvisionResult()")
}
// Every check or step that can return out of the closure before reaching
// doTasksE. Each must appear after the entry clear.
earlyChecks := []string{
"checkStrFlagEmpty(",
"validateCdAndNextDNSFlags(",
"validateInterceptModeFlag(",
"doTasksE(",
}
for _, check := range earlyChecks {
idx := strings.Index(body, check)
if idx == -1 {
t.Fatalf("expected the start command to still call %s", check)
}
if idx < clearIdx {
t.Errorf("%s appears before clearProvisionResult(): a failure there could leave a stale result file behind", check)
}
}
}
// TestStartCommandClassifiesServiceInitFailure pins the fix for a bare
// return: when newService fails in the start command, the closure must fail
// through failProvisionUnclassified, so a result file and the identifier
// line exist, instead of a plain return that exits 0.
func TestStartCommandClassifiesServiceInitFailure(t *testing.T) {
body := startRunFuncSource(t)
initIdx := strings.Index(body, "newService(p, sc)")
if initIdx == -1 {
t.Fatal("start command no longer calls newService(p, sc)")
}
branchEnd := strings.Index(body[initIdx:], "p.preRun()")
if branchEnd == -1 {
t.Fatal("could not find the end of the service init branch")
}
if !strings.Contains(body[initIdx:initIdx+branchEnd], "failProvisionUnclassified(") {
t.Error("service init failure does not fail through failProvisionUnclassified")
}
}
// TestStartCommandReplacesStaleResultOnEarlyClassifiedFailure is a
// behavioral companion to the structural test above: it drives the real
// start command through its earliest classified failure (an invalid
// --intercept-mode) and checks the file left behind names the new attempt,
// not a stale one seeded beforehand.
func TestStartCommandReplacesStaleResultOnEarlyClassifiedFailure(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldIntercept, oldCdUID, oldCdOrg, oldNextdns := interceptMode, cdUID, cdOrg, nextdns
t.Cleanup(func() {
interceptMode, cdUID, cdOrg, nextdns = oldIntercept, oldCdUID, oldCdOrg, oldNextdns
})
// initStartCmd binds these globals to flag defaults as it registers them
// (StringVarP writes the default straight into the pointer), so the
// command must exist before the test overrides the values it drives the
// closure with.
cmd := initStartCmd()
cdUID, cdOrg, nextdns = "", "", ""
interceptMode = "bogus" // fails validateInterceptModeFlag before any OS work
if err := writeProvisionResult(newProvisionResult(provisionCodeServiceStartFailed, "a previous failed attempt", nil)); err != nil {
t.Fatal(err)
}
cmd.Run(cmd, nil)
wantExit := provisionExitCodeForCode[provisionCodeInterceptModeInvalid]
if *exitCode != wantExit {
t.Fatalf("exit = %d, want %d (validateInterceptModeFlag should have run)", *exitCode, wantExit)
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code == string(provisionCodeServiceStartFailed) {
t.Fatal("stale result from a previous attempt survived the new attempt")
}
if r.Code != string(provisionCodeInterceptModeInvalid) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeInterceptModeInvalid)
}
}
func TestServiceStageFailureCode(t *testing.T) {
tests := []struct {
taskName string
+69 -1
View File
@@ -1,6 +1,10 @@
package cli
import "regexp"
import (
"fmt"
"regexp"
"strings"
)
// validHostname reports whether hostname is a valid hostname.
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
@@ -12,3 +16,67 @@ func validHostname(hostname string) bool {
validHostnameRfc1123 := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
return validHostnameRfc1123.MatchString(hostname)
}
// isHostnameChar reports whether r is part of validHostname's accepted
// charset (letters, digits, hyphen, dot). It does not check position, so a
// hostname can fail validHostname on structure (length, leading/trailing
// hyphen) while every one of its characters passes here.
func isHostnameChar(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return true
case r == '-' || r == '.':
return true
}
return false
}
// offendingHostnameChars returns the distinct characters in hostname that
// validHostname's charset does not accept, in first-seen order. Empty when
// every character is accepted - a rejection can still come from structure
// alone (too short, too long, a leading or trailing hyphen).
func offendingHostnameChars(hostname string) string {
seen := make(map[rune]bool)
var bad []rune
for _, r := range hostname {
if isHostnameChar(r) || seen[r] {
continue
}
seen[r] = true
bad = append(bad, r)
}
return string(bad)
}
// serverFoldedHostnameChars are the characters ControlD's
// DevicesTableModel.formatDeviceName folds to '-' (or strips) when it
// registers a device name server-side. Only '.' can actually reach
// hostnameMayBeFoldedByServer through validateCustomHostnameFlag's guarded
// path: validHostname runs first and already rejects any hostname
// containing a space or a '+' as CUSTOM_HOSTNAME_INVALID, so those two never
// get here from an explicit --custom-hostname value. They stay in this set
// for completeness: a mobile caller can set CustomHostname to an
// OS-derived default directly, without going through
// validateCustomHostnameFlag at all, so a space or '+' can still reach the
// API unvalidated by this client.
const serverFoldedHostnameChars = ". +"
// hostnameMayBeFoldedByServer reports whether hostname contains a character
// ControlD may fold or strip when it registers the device, so the name ctrld
// accepted may not be the name the dashboard ends up showing.
func hostnameMayBeFoldedByServer(hostname string) bool {
return strings.ContainsAny(hostname, serverFoldedHostnameChars)
}
// customHostnameFailureMessage names the field, the offending character(s)
// when there are any, and the allowed format for CUSTOM_HOSTNAME_INVALID.
// ctrld's accept/reject rule (validHostname) is unchanged - this only
// explains a rejection that used to be a bare fatal exit.
func customHostnameFailureMessage(hostname string) string {
const allowedFormat = "3-64 characters of letters, digits, hyphens, and dots (RFC1123 hostname format)"
reason := "is not a valid hostname"
if bad := offendingHostnameChars(hostname); bad != "" {
reason = fmt.Sprintf("contains characters a hostname cannot use: %q", bad)
}
return fmt.Sprintf("--custom-hostname (CustomHostname) %q %s; allowed format: %s", hostname, reason, allowedFormat)
}
+62
View File
@@ -33,3 +33,65 @@ func Test_validHostname(t *testing.T) {
})
}
}
// TestOffendingHostnameChars pins the characters surfaced in the
// CUSTOM_HOSTNAME_INVALID message, so the failure names what is actually
// wrong instead of a bare "invalid hostname".
func TestOffendingHostnameChars(t *testing.T) {
tests := []struct {
name string
hostname string
want string
}{
{"single offender", "foo@bar", "@"},
{"space", "foo bar", " "},
{"dot is allowed", "foo.bar", ""},
{"hyphen is allowed", "foo-bar", ""},
{"distinct offenders in order", "a!b!c#d", "!#"},
{"structurally invalid but no bad char", strings.Repeat("a", 65), ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := offendingHostnameChars(tc.hostname); got != tc.want {
t.Errorf("offendingHostnameChars(%q) = %q, want %q", tc.hostname, got, tc.want)
}
})
}
}
// TestHostnameMayBeFoldedByServer pins the characters ControlD's
// DevicesTableModel.formatDeviceName folds or strips when it registers a
// device, so a ctrld-accepted name using one gets a heads-up notice instead
// of silently registering under a different name.
func TestHostnameMayBeFoldedByServer(t *testing.T) {
tests := []struct {
name string
hostname string
want bool
}{
{"dot", "foo.bar", true},
{"space", "foo bar", true},
{"plus", "foo+bar", true},
{"plain", "foobar", false},
{"hyphen only", "foo-bar", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := hostnameMayBeFoldedByServer(tc.hostname); got != tc.want {
t.Errorf("hostnameMayBeFoldedByServer(%q) = %v, want %v", tc.hostname, got, tc.want)
}
})
}
}
// TestCustomHostnameFailureMessage pins the message shape the T6 contract
// requires: the flag/field name, the offending character(s), and the
// allowed format.
func TestCustomHostnameFailureMessage(t *testing.T) {
msg := customHostnameFailureMessage("foo@bar")
for _, want := range []string{"--custom-hostname", "CustomHostname", "@", "allowed format"} {
if !strings.Contains(msg, want) {
t.Errorf("customHostnameFailureMessage() = %q, want it to contain %q", msg, want)
}
}
}
+26 -4
View File
@@ -637,6 +637,25 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
p.ptrNameservers = ptrNameservers
}
// notifyExitToLogServer writes msgExit to the log connection, if one is
// open, so a waiting "ctrld start" sees this run ended instead of waiting
// out the full timeout. A terminal provisioning path calls this right
// before failing.
func (p *prog) notifyExitToLogServer() {
if p.logConn != nil {
_, _ = p.logConn.Write([]byte(msgExit))
}
}
// reportServeDNSFailure classifies a listener that bound successfully - the
// LISTENER_* codes already rule out a bind conflict - but failed to actually
// serve DNS. No dedicated code exists for this, so it falls back to
// UNCLASSIFIED. notifyExitToLogServer unblocks a waiting "ctrld start"
// before the process exits.
func (p *prog) reportServeDNSFailure(listenerNum string, err error) {
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("unable to start dns proxy on listener.%s: %v", listenerNum, err), p.notifyExitToLogServer)
}
// run runs the ctrld main components.
//
// The reload boolean indicates that the function is run when ctrld first start
@@ -754,7 +773,7 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Load().Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr)
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum)
p.reportServeDNSFailure(listenerNum, err)
}
mainLog.Load().Debug().Msgf("end of serveDNS listener.%s: %s", listenerNum, addr)
}(listenerNum)
@@ -927,8 +946,11 @@ var (
initializeOsResolverWithSystemNameserversFn = ctrld.InitializeOsResolverWithSystemNameservers
setDnsForRunningIfaceFn = (*prog).setDnsForRunningIface
resetDNSFn = (*prog).resetDNS
refuseFallbackFatal = func(format string, v ...any) {
mainLog.Load().Fatal().Msgf(format, v...)
// refuseFallbackFatal reports a startup failure the interface-DNS fallback
// cannot safely paper over, then exits. No dedicated code exists for this,
// so it falls back to UNCLASSIFIED.
refuseFallbackFatal = func(p *prog, format string, v ...any) {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf(format, v...), p.notifyExitToLogServer)
}
)
@@ -1030,7 +1052,7 @@ func (p *prog) setDNS(systemNameservers []string) {
// Leave the host resolvable: restore static settings or DHCP rather than
// exiting with an interface still pointed at a ctrld that is not serving.
resetDNSFn(p, false, true)
refuseFallbackFatal("Refusing to fall back to interface DNS: it cannot direct queries to %s:%d, which would leave this host with no working resolver. Free port 53 for ctrld, or resolve the intercept failure, then start again.", lc.IP, lc.Port)
refuseFallbackFatal(p, "Refusing to fall back to interface DNS: it cannot direct queries to %s:%d, which would leave this host with no working resolver. Free port 53 for ctrld, or resolve the intercept failure, then start again.", lc.IP, lc.Port)
// Unreachable in production - the line above exits - but returning
// explicitly keeps the refusal from depending on that, so nothing can
// fall through to installing the fallback this just rejected.
+1 -1
View File
@@ -135,7 +135,7 @@ func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interc
return nil
}
resetDNSFn = func(_ *prog, _ bool, _ bool) { h.resetCalls++ }
refuseFallbackFatal = func(format string, v ...any) {
refuseFallbackFatal = func(_ *prog, format string, v ...any) {
h.refusals = append(h.refusals, fmt.Sprintf(format, v...))
}
+243
View File
@@ -0,0 +1,243 @@
package cli
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"testing"
)
// packageSourcePath resolves a file in this package's source directory, or
// skips the test when the source is not available. The Windows CI job runs
// pre-built test binaries with no checkout, so neither the working directory
// nor the build-time path recorded by runtime.Caller reaches the source
// there. Skipping is safe for these static checks: they analyze the same
// source on every platform, and the jobs with a checkout still enforce them.
func packageSourcePath(t *testing.T, name string) string {
t.Helper()
if _, err := os.Stat(name); err == nil {
return name
}
if _, thisFile, _, ok := runtime.Caller(0); ok {
p := filepath.Join(filepath.Dir(thisFile), name)
if _, err := os.Stat(p); err == nil {
return p
}
}
t.Skipf("package source for %s not available (pre-built test binary without a checkout); this static check runs where source is present", name)
return ""
}
// provisionBoundaryFiles are the files this lane's provisioning-hardening
// work audited: every terminal path they can reach must report a classified
// failure (a result file, an output line, a stage-scoped exit code), not a
// bare crash installer tooling cannot read. TestProvisioningTerminalPathAudit
// documents the audit by scenario; this test enforces it structurally so a
// later change cannot reintroduce a bare fatal without either classifying it
// or adding it here with a reason.
//
// v1.0 keeps the start command, the run command, and every other top-level
// command (log, stop, reload, status, uninstall, clients, upgrade) in one
// commands.go, where master splits each into its own file. That collapses
// several master file names into "commands.go" here, and it means the
// allowlist below has one entry per out-of-scope command instead of one
// excluded file per command.
//
// Scope: the check sees direct calls (".Fatal()", "os.Exit", "panic") in the
// files listed here. A helper defined in one of these files is caught once,
// at the body that holds the call, so an indirect exit through it needs no
// second rule. A helper defined outside the list, such as
// checkHasElevatedPrivilege in service.go wired as the "start" PreRun, is
// outside the reach of this check. TestProvisioningTerminalPathAudit lists
// those by hand in its exclusion comment.
var provisionBoundaryFiles = []string{
"cli.go",
"commands.go",
"prog.go",
"provision_result.go",
}
// provisionBoundaryFatalAllowlist is every call in the files above that
// still reaches a bare ".Fatal()" (on any logger chain), "os.Exit", or
// "panic", keyed
// by "file.go:FuncName" (FuncName includes the receiver type for methods,
// e.g. "prog.setDNS"). Each entry names why it is not a provisioning
// failure, or why it predates and sits outside the --cd-org boundary this
// lane hardened.
var provisionBoundaryFatalAllowlist = map[string]string{
"cli.go:RunMobile": `panic on a nil AppConfig is a programming error in the mobile host ` +
`app, at an entry point the CLI command tree never calls. Not reachable from ` +
`"ctrld start --cd-org".`,
"cli.go:run": `os.Exit(0) is the successful exit of the daemon-respawn launcher, right ` +
`after it starts the real background process. Not a failure.`,
"cli.go:readConfigFile": `shared CLI config-parsing helper used the same way by every ` +
`invocation mode (config-file, no-config, nextdns, --cd, --cd-org). Predates and is ` +
`orthogonal to the --cd-org provisioning boundary.`,
"cli.go:processNoConfigFlags": `shared CLI helper enforcing --listen/--primary_upstream in ` +
`no-config mode. Applies uniformly across every invocation mode, not specifically to ` +
`provisioning.`,
"cli.go:processListenFlag": `shared CLI helper parsing --listen. Applies uniformly across ` +
`every invocation mode, not specifically to provisioning.`,
"cli.go:readConfigWithNotice": `shared CLI helper (userHomeDir failure while locating the ` +
`default config file). Applies uniformly across every invocation mode, not specifically ` +
`to provisioning.`,
"cli.go:checkStrFlagEmpty": `shared flag-emptiness helper. Only an explicit empty --cd-org ` +
`is a provisioning-token value, classified separately inside this function; every other ` +
`flag (currently --cd) keeps its bare fatal.`,
"cli.go:uninstall": `the "ctrld uninstall" command's own router re-configuration step. ` +
`Outside the --cd-org provisioning boundary this lane hardened.`,
"commands.go:initStartCmd": `two unrelated bare exits nested in the same top-level func: ` +
`the deactivation pin check's os.Exit(126) (out of scope for this lane by contract - ` +
`"pin-check 126 untouched"), and the hidden "router setup" alias command's own ` +
`os.Executable/re-exec failure handling (generic CLI plumbing for a re-exec helper, not ` +
`a provisioning outcome). Every check on the actual --cd-org boundary in this function ` +
`already classifies through failProvision/failRunUnclassified.`,
"commands.go:initLogCmd": `the "ctrld log" command. Outside the --cd-org provisioning ` +
`boundary this lane hardened.`,
"commands.go:initStopCmd": `the "ctrld stop" command. Outside the --cd-org provisioning ` +
`boundary this lane hardened.`,
"commands.go:initReloadCmd": `the "ctrld reload"/"ctrld restart" commands. Outside the ` +
`--cd-org provisioning boundary this lane hardened.`,
"commands.go:initStatusCmd": `the "ctrld status" command. Outside the --cd-org ` +
`provisioning boundary this lane hardened.`,
"commands.go:initUninstallCmd": `the "ctrld uninstall" command. Outside the --cd-org ` +
`provisioning boundary this lane hardened.`,
"commands.go:initClientsCmd": `the "ctrld clients" command. Outside the --cd-org ` +
`provisioning boundary this lane hardened.`,
"commands.go:initUpgradeCmd": `the "ctrld upgrade" command. Outside the --cd-org ` +
`provisioning boundary this lane hardened.`,
"prog.go:prog.Stop": `the deactivation pin check's os.Exit(126). Out of scope for this ` +
`lane by contract ("pin-check 126 untouched").`,
"prog.go:prog.setDNS": `the runtime --intercept-mode validation (validInterceptMode), ` +
`distinct from the early "ctrld start" check that already reports ` +
`INTERCEPT_MODE_INVALID. Left alone per T6's own doc comment.`,
}
// funcDeclKey names a function declaration the way
// provisionBoundaryFatalAllowlist keys it: "FuncName" for a plain function,
// "ReceiverType.FuncName" for a method.
func funcDeclKey(fn *ast.FuncDecl) string {
if fn.Recv == nil || len(fn.Recv.List) == 0 {
return fn.Name.Name
}
recvType := fn.Recv.List[0].Type
if star, ok := recvType.(*ast.StarExpr); ok {
recvType = star.X
}
if ident, ok := recvType.(*ast.Ident); ok {
return ident.Name + "." + fn.Name.Name
}
return fn.Name.Name
}
// isBareFatalOrExitCall reports whether call is "<expr>.Fatal(...)" on any
// logger chain, "os.Exit(...)", or a bare "panic(...)".
func isBareFatalOrExitCall(call *ast.CallExpr) bool {
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "panic" {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
if sel.Sel.Name == "Fatal" {
return true
}
if sel.Sel.Name == "Exit" {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "os" {
return true
}
}
return false
}
// TestProvisionBoundaryHasNoUnlistedBareFatal parses every provisioning
// boundary file and flags a bare Fatal/os.Exit that is not in the allowlist
// above. A new one appearing here means a change added a crash the
// provisioning boundary's contract does not allow: classify it through
// failProvision/failProvisionUnclassified, or add it to the allowlist with a
// reason if it genuinely sits outside the boundary.
func TestProvisionBoundaryHasNoUnlistedBareFatal(t *testing.T) {
fset := token.NewFileSet()
for _, file := range provisionBoundaryFiles {
node, err := parser.ParseFile(fset, packageSourcePath(t, file), nil, 0)
if err != nil {
t.Fatalf("could not parse %s: %v", file, err)
}
for _, decl := range node.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
key := file + ":" + funcDeclKey(fn)
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isBareFatalOrExitCall(call) {
return true
}
if _, allowed := provisionBoundaryFatalAllowlist[key]; !allowed {
pos := fset.Position(call.Pos())
t.Errorf("%s:%d: unlisted bare fatal/exit in %s - classify it through "+
"failProvision/failProvisionUnclassified, or add it to "+
"provisionBoundaryFatalAllowlist with a reason", file, pos.Line, key)
}
return true
})
}
}
}
// TestProvisionBoundaryAllowlistHasNoStaleEntries is the converse check: every
// allowlisted key must still name a real bare Fatal/os.Exit. A stale entry
// (the call was removed or reclassified) would silently widen the allowlist
// and hide a real regression the next time this test runs.
func TestProvisionBoundaryAllowlistHasNoStaleEntries(t *testing.T) {
fset := token.NewFileSet()
found := make(map[string]bool, len(provisionBoundaryFatalAllowlist))
for _, file := range provisionBoundaryFiles {
node, err := parser.ParseFile(fset, packageSourcePath(t, file), nil, 0)
if err != nil {
t.Fatalf("could not parse %s: %v", file, err)
}
for _, decl := range node.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
key := file + ":" + funcDeclKey(fn)
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isBareFatalOrExitCall(call) {
return true
}
found[key] = true
return true
})
}
}
for key := range provisionBoundaryFatalAllowlist {
if !found[key] {
t.Errorf("provisionBoundaryFatalAllowlist[%q] no longer matches any bare fatal/exit; remove the stale entry", key)
}
}
}
+417
View File
@@ -0,0 +1,417 @@
package cli
import (
"context"
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// captureMainLog swaps mainLog for a test-local buffer logger and restores
// the previous logger on cleanup. Asserting on the shared logOutput sink is
// order-dependent: a test that ran earlier can re-store mainLog (Windows'
// Test_validInterfaces calls initConsoleLogging), leaving the shared buffer
// stale for every later test.
func captureMainLog(t *testing.T) *syncBuffer {
t.Helper()
buf := &syncBuffer{}
l := zerolog.New(buf)
old := mainLog.Load()
mainLog.Store(&l)
t.Cleanup(func() { mainLog.Store(old) })
return buf
}
func TestProvisionTokenShapeValid(t *testing.T) {
tests := []struct {
name string
token string
want bool
}{
{"too short", "abcde", false},
{"minimum length", "abcdef", true},
{"maximum length", strings.Repeat("a", 64), true},
{"too long", strings.Repeat("a", 65), false},
{"contains space", "org-v1- abc", false},
{"contains tab", "org-v1-\tabc", false},
{"contains newline", "org-v1-\nabc", false},
{"contains control char", "org-v1-\x00abc", false},
{"sane with prefix", "org-v1-abcdef123456", true},
{"sane without prefix", "legacytoken123", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := provisionTokenShapeValid(tc.token); got != tc.want {
t.Errorf("provisionTokenShapeValid(%q) = %v, want %v", tc.token, got, tc.want)
}
})
}
}
// TestCdUIDFromProvTokenMalformedToken proves a malformed --cd-org value is
// classified before any network attempt: no call reaches fetchResolverUIDFn,
// and the malformed value itself never appears in the persisted message.
func TestCdUIDFromProvTokenMalformedToken(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
cdUID = ""
const malformed = "ab cd"
cdOrg = malformed
customHostname = ""
called := false
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
called = true
return nil, nil
}
if got := cdUIDFromProvToken(); got != "" {
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
}
if called {
t.Error("fetchResolverUIDFn was called; malformed token must be rejected before any network attempt")
}
if *exitCode != provisionExitCodeForCode[provisionCodeProvisionTokenMalformed] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeProvisionTokenMalformed])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeProvisionTokenMalformed) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeProvisionTokenMalformed)
}
if r.Stage != string(provisionStageInput) {
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
}
if strings.Contains(r.Message, malformed) {
t.Errorf("malformed token leaked into result message: %q", r.Message)
}
}
// TestCdUIDFromProvTokenPrefixlessTokenProceeds proves a token missing the
// "org-v1-" prefix is not rejected - only warned about - since legacy codes
// may lack it.
func TestCdUIDFromProvTokenPrefixlessTokenProceeds(t *testing.T) {
_, _ = stubProvisionGlobals(t)
logBuf := captureMainLog(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
cdUID = ""
cdOrg = "legacytoken123"
customHostname = ""
called := false
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
called = true
return &controld.ResolverConfig{UID: "resolved-uid"}, nil
}
if got := cdUIDFromProvToken(); got != "resolved-uid" {
t.Errorf("cdUIDFromProvToken() = %q, want resolved-uid", got)
}
if !called {
t.Error("fetchResolverUIDFn was not called; a prefixless-but-sane token must still proceed")
}
if !strings.Contains(logBuf.String(), "org-v1-") {
t.Error("expected a warning mentioning the org-v1- prefix")
}
}
// TestCdUIDFromProvTokenInvalidCustomHostname proves an invalid
// --custom-hostname value is classified before any network attempt, with a
// message that names the field and format, and never leaks the provision
// token.
func TestCdUIDFromProvTokenInvalidCustomHostname(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
cdUID = ""
const secretToken = "org-v1-secret-token-42"
cdOrg = secretToken
customHostname = "foo@bar"
called := false
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
called = true
return nil, nil
}
if got := cdUIDFromProvToken(); got != "" {
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
}
if called {
t.Error("fetchResolverUIDFn was called; invalid custom hostname must be rejected before any network attempt")
}
if *exitCode != provisionExitCodeForCode[provisionCodeCustomHostnameInvalid] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeCustomHostnameInvalid])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeCustomHostnameInvalid) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeCustomHostnameInvalid)
}
if !strings.Contains(r.Message, "allowed format") {
t.Errorf("message = %q, want it to contain the allowed format", r.Message)
}
if strings.Contains(r.Message, secretToken) {
t.Errorf("provision token leaked into result message: %q", r.Message)
}
}
// TestCdUIDFromProvTokenFoldableHostnameLogsNotice proves a hostname that
// ctrld accepts, but ControlD's device-name formatting would fold or strip,
// gets a notice - not a failure - and provisioning still proceeds.
func TestCdUIDFromProvTokenFoldableHostnameLogsNotice(t *testing.T) {
_, _ = stubProvisionGlobals(t)
logBuf := captureMainLog(t)
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
cdUID = ""
cdOrg = "org-v1-abcdef123456"
customHostname = "foo.bar"
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
return &controld.ResolverConfig{UID: "resolved-uid"}, nil
}
if got := cdUIDFromProvToken(); got != "resolved-uid" {
t.Errorf("cdUIDFromProvToken() = %q, want resolved-uid", got)
}
if !strings.Contains(logBuf.String(), "foo.bar") {
t.Error("expected a notice naming the foldable hostname")
}
}
func TestValidateInterceptModeFlag(t *testing.T) {
oldMode := interceptMode
t.Cleanup(func() { interceptMode = oldMode })
t.Run("valid values proceed", func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
for _, mode := range []string{"", "off", "dns", "hard"} {
interceptMode = mode
if !validateInterceptModeFlag(mode) {
t.Errorf("validateInterceptModeFlag(%q) = false, want true", mode)
}
if *exitCode != -1 {
t.Errorf("mode %q: provisionExit called with %d, want no exit", mode, *exitCode)
}
}
})
t.Run("invalid value is classified", func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
interceptMode = "fds"
if validateInterceptModeFlag("fds") {
t.Error("validateInterceptModeFlag(\"fds\") = true, want false")
}
if *exitCode != provisionExitCodeForCode[provisionCodeInterceptModeInvalid] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInterceptModeInvalid])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeInterceptModeInvalid) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeInterceptModeInvalid)
}
for _, want := range []string{"off", "dns", "hard"} {
if !strings.Contains(r.Message, want) {
t.Errorf("message = %q, want it to contain %q", r.Message, want)
}
}
})
}
// TestCheckStrFlagEmptyClassifiesEmptyCdOrg proves an explicit empty --cd-org
// is classified as a malformed provisioning token rather than a bare fatal.
func TestCheckStrFlagEmptyClassifiesEmptyCdOrg(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
cmd := &cobra.Command{}
cmd.Flags().String(cdOrgFlagName, "", "")
if err := cmd.Flags().Set(cdOrgFlagName, ""); err != nil {
t.Fatal(err)
}
if checkStrFlagEmpty(cmd, cdOrgFlagName) {
t.Error("checkStrFlagEmpty() = true, want false for an explicit empty --cd-org")
}
if *exitCode != provisionExitCodeForCode[provisionCodeProvisionTokenMalformed] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeProvisionTokenMalformed])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeProvisionTokenMalformed) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeProvisionTokenMalformed)
}
if r.Stage != string(provisionStageInput) {
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
}
if !strings.Contains(r.Message, cdOrgFlagName) {
t.Errorf("message = %q, want it to name --%s", r.Message, cdOrgFlagName)
}
}
// TestCheckStrFlagEmptyProceedsWhenNotChangedOrNotEmpty proves the two cases
// that must not classify: the flag was never set, and it was set to a
// non-empty value.
func TestCheckStrFlagEmptyProceedsWhenNotChangedOrNotEmpty(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
t.Run("flag never set", func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String(cdOrgFlagName, "", "")
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
t.Error("checkStrFlagEmpty() = false, want true when the flag was never set")
}
})
t.Run("flag set to a non-empty value", func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String(cdOrgFlagName, "", "")
if err := cmd.Flags().Set(cdOrgFlagName, "org-v1-abc"); err != nil {
t.Fatal(err)
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
t.Error("checkStrFlagEmpty() = false, want true for a non-empty value")
}
})
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
}
// TestValidateCdAndNextDNSFlagsClassifiesConflict proves --cd or --cd-org
// combined with --nextdns is classified as INVALID_FLAG_COMBINATION, naming
// every flag involved.
func TestValidateCdAndNextDNSFlagsClassifiesConflict(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldCdUID, oldCdOrg, oldNextdns := cdUID, cdOrg, nextdns
t.Cleanup(func() { cdUID, cdOrg, nextdns = oldCdUID, oldCdOrg, oldNextdns })
t.Run("non-conflicting combinations proceed", func(t *testing.T) {
cases := []struct{ cdUID, cdOrg, nextdns string }{
{"", "", ""},
{"uid123", "", ""},
{"", "org-v1-abc", ""},
{"", "", "nextdns-id"},
}
for _, tc := range cases {
cdUID, cdOrg, nextdns = tc.cdUID, tc.cdOrg, tc.nextdns
if !validateCdAndNextDNSFlags() {
t.Errorf("validateCdAndNextDNSFlags() = false for cdUID=%q cdOrg=%q nextdns=%q, want true", tc.cdUID, tc.cdOrg, tc.nextdns)
}
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
})
t.Run("cd-org with nextdns is classified", func(t *testing.T) {
cdUID, cdOrg, nextdns = "", "org-v1-abc", "nextdns-id"
if validateCdAndNextDNSFlags() {
t.Error("validateCdAndNextDNSFlags() = true, want false")
}
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeInvalidFlagCombination) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeInvalidFlagCombination)
}
if r.Stage != string(provisionStageInput) {
t.Errorf("stage = %q, want %q", r.Stage, provisionStageInput)
}
for _, want := range []string{cdUidFlagName, cdOrgFlagName, nextdnsFlagName} {
if !strings.Contains(r.Message, want) {
t.Errorf("message = %q, want it to name --%s", r.Message, want)
}
}
})
t.Run("cd with nextdns is classified", func(t *testing.T) {
cdUID, cdOrg, nextdns = "uid123", "", "nextdns-id"
if validateCdAndNextDNSFlags() {
t.Error("validateCdAndNextDNSFlags() = true, want false")
}
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
}
})
}
// TestValidateCdUpstreamProtocol proves an invalid --proto value is
// classified only once --cd is in play, and that notify runs when given.
func TestValidateCdUpstreamProtocol(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldCdUID, oldProto := cdUID, cdUpstreamProto
t.Cleanup(func() { cdUID, cdUpstreamProto = oldCdUID, oldProto })
t.Run("no --cd proceeds regardless of protocol", func(t *testing.T) {
cdUID = ""
cdUpstreamProto = "garbage"
if !validateCdUpstreamProtocol(nil) {
t.Error("validateCdUpstreamProtocol(nil) = false, want true when --cd is not set")
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
})
t.Run("valid protocols proceed", func(t *testing.T) {
cdUID = "uid123"
for _, proto := range []string{ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3} {
cdUpstreamProto = proto
if !validateCdUpstreamProtocol(nil) {
t.Errorf("validateCdUpstreamProtocol(nil) = false for proto %q, want true", proto)
}
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
})
t.Run("invalid protocol is classified and notifies", func(t *testing.T) {
cdUID = "uid123"
cdUpstreamProto = "quic"
notified := false
if validateCdUpstreamProtocol(func() { notified = true }) {
t.Error("validateCdUpstreamProtocol() = true, want false")
}
if !notified {
t.Error("notify not called")
}
if *exitCode != provisionExitCodeForCode[provisionCodeInvalidFlagCombination] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeInvalidFlagCombination])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeInvalidFlagCombination) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeInvalidFlagCombination)
}
if !strings.Contains(r.Message, "quic") || !strings.Contains(r.Message, "doh") {
t.Errorf("message = %q, want it to name the given and allowed values", r.Message)
}
})
}
+93 -28
View File
@@ -8,6 +8,8 @@ import (
"strings"
"time"
"unicode/utf8"
"github.com/rs/zerolog"
)
// A terminal provisioning failure reports the same stable code on three
@@ -20,6 +22,7 @@ import (
type provisionStage string
const (
provisionStageInput provisionStage = "input"
provisionStageBootstrap provisionStage = "bootstrap"
provisionStageListener provisionStage = "listener"
provisionStageService provisionStage = "service"
@@ -28,50 +31,87 @@ const (
type provisionFailureCode string
const (
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
provisionCodeListenerBindFailed provisionFailureCode = "LISTENER_BIND_FAILED"
provisionCodeListenerAddrUnavail provisionFailureCode = "LISTENER_CONFIGURED_ADDR_UNAVAILABLE"
provisionCodeServiceInstall provisionFailureCode = "SERVICE_INSTALL_FAILED"
provisionCodeServiceStartFailed provisionFailureCode = "SERVICE_START_FAILED"
provisionCodeServiceSelfCheck provisionFailureCode = "SERVICE_SELFCHECK_FAILED"
provisionCodeProvisionTokenMalformed provisionFailureCode = "PROVISION_TOKEN_MALFORMED"
provisionCodeCustomHostnameInvalid provisionFailureCode = "CUSTOM_HOSTNAME_INVALID"
provisionCodeInterceptModeInvalid provisionFailureCode = "INTERCEPT_MODE_INVALID"
provisionCodeInvalidFlagCombination provisionFailureCode = "INVALID_FLAG_COMBINATION"
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
provisionCodeTokenInvalid provisionFailureCode = "TOKEN_INVALID"
provisionCodeTokenExpired provisionFailureCode = "TOKEN_EXPIRED"
provisionCodeTokenLimitReached provisionFailureCode = "TOKEN_LIMIT_REACHED"
provisionCodeTokenDisabled provisionFailureCode = "TOKEN_DISABLED"
provisionCodeListenerBindFailed provisionFailureCode = "LISTENER_BIND_FAILED"
provisionCodeListenerAddrUnavail provisionFailureCode = "LISTENER_CONFIGURED_ADDR_UNAVAILABLE"
provisionCodeServiceInstall provisionFailureCode = "SERVICE_INSTALL_FAILED"
provisionCodeServiceStartFailed provisionFailureCode = "SERVICE_START_FAILED"
provisionCodeServiceSelfCheck provisionFailureCode = "SERVICE_SELFCHECK_FAILED"
provisionCodeUnclassified provisionFailureCode = "UNCLASSIFIED"
)
var allProvisionFailureCodes = []provisionFailureCode{
provisionCodeProvisionTokenMalformed,
provisionCodeCustomHostnameInvalid,
provisionCodeInterceptModeInvalid,
provisionCodeInvalidFlagCombination,
provisionCodeAPIUnreachable,
provisionCodeAPIRejected,
provisionCodeAPIDeviceInvalid,
provisionCodeTokenInvalid,
provisionCodeTokenExpired,
provisionCodeTokenLimitReached,
provisionCodeTokenDisabled,
provisionCodeListenerBindFailed,
provisionCodeListenerAddrUnavail,
provisionCodeServiceInstall,
provisionCodeServiceStartFailed,
provisionCodeServiceSelfCheck,
provisionCodeUnclassified,
}
var provisionStageForCode = map[provisionFailureCode]provisionStage{
provisionCodeAPIUnreachable: provisionStageBootstrap,
provisionCodeAPIRejected: provisionStageBootstrap,
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
provisionCodeListenerBindFailed: provisionStageListener,
provisionCodeListenerAddrUnavail: provisionStageListener,
provisionCodeServiceInstall: provisionStageService,
provisionCodeServiceStartFailed: provisionStageService,
provisionCodeServiceSelfCheck: provisionStageService,
provisionCodeProvisionTokenMalformed: provisionStageInput,
provisionCodeCustomHostnameInvalid: provisionStageInput,
provisionCodeInterceptModeInvalid: provisionStageInput,
provisionCodeInvalidFlagCombination: provisionStageInput,
provisionCodeAPIUnreachable: provisionStageBootstrap,
provisionCodeAPIRejected: provisionStageBootstrap,
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
provisionCodeTokenInvalid: provisionStageBootstrap,
provisionCodeTokenExpired: provisionStageBootstrap,
provisionCodeTokenLimitReached: provisionStageBootstrap,
provisionCodeTokenDisabled: provisionStageBootstrap,
provisionCodeListenerBindFailed: provisionStageListener,
provisionCodeListenerAddrUnavail: provisionStageListener,
provisionCodeServiceInstall: provisionStageService,
provisionCodeServiceStartFailed: provisionStageService,
provisionCodeServiceSelfCheck: provisionStageService,
provisionCodeUnclassified: provisionStageService,
}
// Exit codes are grouped by stage (bootstrap 30-39, listener 40-49, service
// 50-59) so the exit code alone names the failed stage. 0-3 belong to
// "ctrld status" and 126 to the deactivation pin check; never reuse those.
// Exit codes are grouped by stage (input 20-29, bootstrap 30-39, listener
// 40-49, service 50-59) so the exit code alone names the failed stage. 0-3
// belong to "ctrld status" and 126 to the deactivation pin check; never
// reuse those.
var provisionExitCodeForCode = map[provisionFailureCode]int{
provisionCodeAPIUnreachable: 30,
provisionCodeAPIRejected: 31,
provisionCodeAPIDeviceInvalid: 32,
provisionCodeListenerBindFailed: 41,
provisionCodeListenerAddrUnavail: 42,
provisionCodeServiceInstall: 51,
provisionCodeServiceStartFailed: 52,
provisionCodeServiceSelfCheck: 53,
provisionCodeProvisionTokenMalformed: 21,
provisionCodeCustomHostnameInvalid: 22,
provisionCodeInterceptModeInvalid: 23,
provisionCodeInvalidFlagCombination: 24,
provisionCodeAPIUnreachable: 30,
provisionCodeAPIRejected: 31,
provisionCodeAPIDeviceInvalid: 32,
provisionCodeTokenInvalid: 33,
provisionCodeTokenExpired: 34,
provisionCodeTokenLimitReached: 35,
provisionCodeTokenDisabled: 36,
provisionCodeListenerBindFailed: 41,
provisionCodeListenerAddrUnavail: 42,
provisionCodeServiceInstall: 51,
provisionCodeServiceStartFailed: 52,
provisionCodeServiceSelfCheck: 53,
provisionCodeUnclassified: 59,
}
const (
@@ -213,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
}
@@ -245,3 +289,24 @@ func failProvision(r *provisionResult, notify func()) {
}
provisionExit(r.ExitCode)
}
// failProvisionUnclassified persists an UNCLASSIFIED result (service stage,
// exit 59) and exits. It is the fallback for a terminal path that predates a
// stable code - a config unmarshal, a file-system or environment failure -
// so support still gets a result file and a stage-scoped exit code instead
// of a bare crash with nothing to read. No terminal path on the
// provisioning boundary may bypass this or an existing classified code.
func failProvisionUnclassified(message string, notify func()) {
failProvision(newProvisionResult(provisionCodeUnclassified, message, nil, provisionSecrets()...), notify)
}
// failRunUnclassified logs msg on ev, then fails provisioning as UNCLASSIFIED
// and unblocks a waiting "ctrld start" via notify (nil if none). Callers
// return immediately after this call: provisionExit is stubbed out under
// test, so nothing stops execution from falling through otherwise. ev
// carries whatever the caller already chained onto it (for example .Err()),
// so each call site keeps its own log fields.
func failRunUnclassified(ev *zerolog.Event, msg string, notify func()) {
ev.Msg(msg)
failProvisionUnclassified(msg, notify)
}
+20 -4
View File
@@ -20,8 +20,21 @@ func overrideProvisionResultPath(t *testing.T) string {
return path
}
// The result file must obey the same homedir override as the log and the
// config file, so a daemon started with --homedir writes it next to them.
func TestProvisionResultPathHonorsHomedir(t *testing.T) {
old := homedir
homedir = t.TempDir()
t.Cleanup(func() { homedir = old })
want := filepath.Join(homedir, provisionResultFileName)
if got := provisionResultPath(); got != want {
t.Errorf("provisionResultPath() = %q, want %q", got, want)
}
}
func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
stageRanges := map[provisionStage][2]int{
provisionStageInput: {20, 29},
provisionStageBootstrap: {30, 39},
provisionStageListener: {40, 49},
provisionStageService: {50, 59},
@@ -55,8 +68,8 @@ func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
}
seenExits[exit] = code
}
if len(allProvisionFailureCodes) != 8 {
t.Errorf("expected 8 codes, got %d", len(allProvisionFailureCodes))
if len(allProvisionFailureCodes) != 17 {
t.Errorf("expected 17 codes, got %d", len(allProvisionFailureCodes))
}
}
@@ -166,14 +179,17 @@ func TestFailureCodeDocTableMatchesConstants(t *testing.T) {
t.Fatalf("could not read the failure-code doc: %v", err)
}
doc := string(buf)
// Package-stage rows document identifiers scripts/pkg/postinstall emits
// on its own; they are not in this binary's failure-code registry, so
// they sit outside the one-row-per-code count below.
rows := 0
for _, line := range strings.Split(doc, "\n") {
if strings.HasPrefix(line, "| `") {
if strings.HasPrefix(line, "| `") && !strings.Contains(line, "| package |") {
rows++
}
}
if rows != len(allProvisionFailureCodes) {
t.Errorf("doc table has %d code rows, want %d", rows, len(allProvisionFailureCodes))
t.Errorf("doc table has %d non-package code rows, want %d", rows, len(allProvisionFailureCodes))
}
for _, code := range allProvisionFailureCodes {
row := "| `" + string(code) + "` | " + string(provisionStageForCode[code]) + " | " + strconv.Itoa(provisionExitCodeForCode[code]) + " |"
+244
View File
@@ -0,0 +1,244 @@
package cli
import (
"errors"
"net"
"strings"
"testing"
"time"
)
// TestFailProvisionUnclassified pins the fallback every terminal path on the
// provisioning boundary uses when it predates a stable code: it persists
// UNCLASSIFIED, calls notify, and exits at the code's exit number.
func TestFailProvisionUnclassified(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
notified := false
failProvisionUnclassified("something failed with no dedicated code", func() { notified = true })
if !notified {
t.Error("notify not called")
}
if *exitCode != provisionExitCodeForCode[provisionCodeUnclassified] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeUnclassified])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeUnclassified) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeUnclassified)
}
if r.Stage != string(provisionStageService) {
t.Errorf("stage = %q, want %q", r.Stage, provisionStageService)
}
if !strings.Contains(r.Message, "something failed") {
t.Errorf("message = %q, want it to contain the given message", r.Message)
}
}
// TestNotifyExitToLogServer pins the method every run()-side failure uses to
// unblock a waiting "ctrld start": it writes msgExit to the log connection
// exactly when one is set, and is a no-op otherwise.
func TestNotifyExitToLogServer(t *testing.T) {
t.Run("nil connection is a no-op", func(t *testing.T) {
p := &prog{}
p.notifyExitToLogServer() // must not panic
})
t.Run("writes msgExit to a set connection", func(t *testing.T) {
server, client := net.Pipe()
t.Cleanup(func() { _ = server.Close(); _ = client.Close() })
p := &prog{logConn: client}
received := make(chan string, 1)
go func() {
buf := make([]byte, 64)
n, _ := server.Read(buf)
received <- string(buf[:n])
}()
p.notifyExitToLogServer()
select {
case got := <-received:
if got != msgExit {
t.Errorf("wrote %q, want %q", got, msgExit)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for notifyExitToLogServer to write")
}
})
}
// TestReportServeDNSFailure covers the one prog.go path where a listener
// bound successfully (LISTENER_* already ruled that out) but the proxy
// itself failed to start serving - a distinct failure with no dedicated
// code.
func TestReportServeDNSFailure(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
server, client := net.Pipe()
t.Cleanup(func() { _ = server.Close(); _ = client.Close() })
go func() {
buf := make([]byte, 64)
_, _ = server.Read(buf)
}()
p := &prog{logConn: client}
p.reportServeDNSFailure("0", errors.New("bind lost mid-flight"))
if *exitCode != provisionExitCodeForCode[provisionCodeUnclassified] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeUnclassified])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeUnclassified) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeUnclassified)
}
if !strings.Contains(r.Message, "listener.0") {
t.Errorf("message = %q, want it to name the listener", r.Message)
}
}
// TestRefuseFallbackFatalDefault covers the production refuseFallbackFatal
// closure (not the test-harness override other tests install): it
// classifies UNCLASSIFIED and notifies through the given prog's log
// connection.
func TestRefuseFallbackFatalDefault(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
server, client := net.Pipe()
t.Cleanup(func() { _ = server.Close(); _ = client.Close() })
go func() {
buf := make([]byte, 64)
_, _ = server.Read(buf)
}()
p := &prog{logConn: client}
refuseFallbackFatal(p, "cannot fall back: %s", "port 53 is unavailable")
if *exitCode != provisionExitCodeForCode[provisionCodeUnclassified] {
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeUnclassified])
}
r, err := readProvisionResult()
if err != nil {
t.Fatalf("no provision result written: %v", err)
}
if r.Code != string(provisionCodeUnclassified) {
t.Errorf("code = %q, want %q", r.Code, provisionCodeUnclassified)
}
if !strings.Contains(r.Message, "port 53 is unavailable") {
t.Errorf("message = %q, want it to contain the formatted detail", r.Message)
}
}
// terminalPathCase documents one terminal path this lane audited under
// "ctrld start --cd-org": where it lives, what triggers it, and the code it
// now reports. reachable names a test that exercises the real call site
// end-to-end (through the seams this package already exposes); a path deep
// inside the start command's Run closure / run() with no seam to trigger it
// in isolation is documented here with reachable == "" and relies on the
// shared helper tests above (TestFailProvisionUnclassified,
// TestNotifyExitToLogServer, TestReportServeDNSFailure,
// TestRefuseFallbackFatalDefault) to prove the wiring it calls into.
type terminalPathCase struct {
file string
scenario string
code provisionFailureCode
reachable string // name of the test proving this exact site, or "" if only the shared helper is exercised
}
// TestProvisioningTerminalPathAudit is the committed enumeration required for
// "ctrld start --cd-org": every Fatal/os.Exit found by re-deriving the
// requirement (grepping commands.go's initStartCmd, cli.go's run() and its
// callers cdUIDFromProvToken/handleAPIPreflightFailure, and prog.go's
// pre-daemonization paths), reconciled against a code. None of them may
// reach a bare Fatal/os.Exit any more; each row's code is asserted against
// the known contract maps so a typo'd code name fails this test.
func TestProvisioningTerminalPathAudit(t *testing.T) {
cases := []terminalPathCase{
// commands.go initStartCmd (parent "ctrld start" process). v1.0 keeps
// what master splits into commands_service_start.go in the same
// commands.go as every other command.
{"commands.go (initStartCmd)", "--intercept-mode is not off/dns/hard", provisionCodeInterceptModeInvalid, "TestValidateInterceptModeFlag"},
{"commands.go (initStartCmd) / cli.go RunCobraCommand", "explicit empty --cd-org", provisionCodeProvisionTokenMalformed, "TestCheckStrFlagEmptyClassifiesEmptyCdOrg"},
{"commands.go (initStartCmd)", "--cd/--cd-org used together with --nextdns", provisionCodeInvalidFlagCombination, "TestValidateCdAndNextDNSFlagsClassifiesConflict"},
{"commands.go (initStartCmd)", "newService fails building the service handle", provisionCodeUnclassified, "TestStartCommandClassifiesServiceInitFailure"},
{"commands.go (initStartCmd) / cli.go run()", "--proto is not doh/doh3 once --cd is set", provisionCodeInvalidFlagCombination, "TestValidateCdUpstreamProtocol"},
{"commands.go (initStartCmd)", "removeServiceFlag fails while upgrading an existing service's intercept mode", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "appendServiceFlag(\"--intercept-mode\") fails during the same upgrade", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "appendServiceFlag(mode) fails during the same upgrade", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "config unmarshal fails restarting an already-installed service", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "doTasksE fails on a non-service-stage task restarting an existing service", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "socketDir() fails restarting an existing service", provisionCodeUnclassified, ""},
// v1.0-only: the DNS intercept-mode router abstraction (predates and is
// unrelated to issue-595) configures itself before a fresh install;
// already routed through failRunUnclassified, not a bare fatal.
{"commands.go (initStartCmd)", "router.ConfigureService fails configuring the router before a fresh install", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "config unmarshal fails on a fresh install", provisionCodeUnclassified, ""},
{"commands.go (initStartCmd)", "doTasksE fails on a non-service-stage task on a fresh install", provisionCodeUnclassified, ""},
// cli.go run() (the "ctrld run" child process --cd-org spawns, or a direct "ctrld run --cd-org" invocation)
{"cli.go run()", "called with a nil stop channel", provisionCodeUnclassified, ""},
{"cli.go run()", "--daemon on windows", provisionCodeUnclassified, ""},
{"cli.go run()", "newService fails building the background service handle", provisionCodeUnclassified, ""},
{"cli.go run()", "readBase64Config fails", provisionCodeUnclassified, ""},
{"cli.go run()", "config unmarshal fails", provisionCodeUnclassified, ""},
{"cli.go run()", "network is not up", provisionCodeUnclassified, ""},
// v1.0-only: same router abstraction as above, on the daemon side.
{"cli.go run()", "router.PreRun fails performing the router pre-run check", provisionCodeUnclassified, ""},
{"cli.go run()", "writeConfigFile fails", provisionCodeUnclassified, ""},
{"cli.go run()", "validateConfig fails", provisionCodeUnclassified, ""},
{"cli.go run()", "os.Executable fails in daemon respawn", provisionCodeUnclassified, ""},
{"cli.go run()", "os.Getwd fails in daemon respawn", provisionCodeUnclassified, ""},
{"cli.go run()", "cmd.Start fails in daemon respawn", provisionCodeUnclassified, ""},
// prog.go (pre-daemonization: listener startup and DNS-intercept setup)
{"prog.go (p *prog) run()", "serveDNS fails after the listener already bound", provisionCodeUnclassified, "TestReportServeDNSFailure"},
{"prog.go (p *prog) setDNS()", "DNS intercept fails and the interface-DNS fallback cannot reach a non-53 listener", provisionCodeUnclassified, "TestRefuseFallbackFatalDefault"},
}
for _, tc := range cases {
if _, ok := provisionStageForCode[tc.code]; !ok {
t.Errorf("%s: %s: code %s is not a known contract code", tc.file, tc.scenario, tc.code)
}
if tc.reachable != "" {
continue
}
t.Logf("documented (exercised only via shared helper wiring, not a standalone seam): %s: %s -> %s", tc.file, tc.scenario, tc.code)
}
// Out of scope, left as bare Fatal/os.Exit deliberately, with reasons:
//
// - cli.go run(): stopCh nil check is still bare-Fatal-free (converted
// above) but is genuinely unreachable from any CLI invocation - the
// CLI always constructs a fresh channel. Converted anyway above for
// consistency, not because it is reachable.
// - prog.go (p *prog) setDNS() line ~962's own --intercept-mode check:
// explicitly left alone per T6 - it is the "runtime validation in
// prog.go" validInterceptMode's own doc comment calls out as distinct
// from "the early start command check" that got INTERCEPT_MODE_INVALID.
// - prog.go (p *prog) Stop() and commands.go initStartCmd's own
// deactivation-pin os.Exit: explicitly out of scope per the lane
// contract ("pin-check 126 untouched").
// - cli.go: doValidateCdRemoteConfig's Fatal is not reachable from the
// --cd-org start path at all on v1.0 - the start command defers --cd
// validation to processCDFlags in the "ctrld run" child instead (see
// the comment in initStartCmd), a divergence from master that predates
// and is unrelated to this lane. General CLI/config-parsing helpers
// (readConfigFile, processNoConfigFlags, processListenFlag,
// readConfigWithNotice's userHomeDir failure): these predate and apply
// uniformly across every ctrld invocation mode (config-file mode,
// no-config mode, nextdns mode), not specifically to --cd-org
// provisioning. Out of scope for this lane's --cd-org enumeration.
// - checkStrFlagEmpty for --cd (cdUidFlagName) keeps its bare fatal:
// only the --cd-org case above is a provisioning-token value.
// - cli.go uninstall()'s bare fatal on a router re-configuration
// failure: the "ctrld uninstall" command, not "ctrld start --cd-org".
//
// No firewall-mode flag exists on v1.0, so master's INTERCEPT_MODE_INVALID-
// adjacent "--firewall-mode is not off/on" rows (parent and daemon-side)
// have no equivalent here.
}