feat(cli): classify every provisioning failure with stable codes

This commit is contained in:
Anthony Wong
2026-09-04 16:46:55 +07:00
committed by Cuong Manh Le
parent dd77d865b6
commit 0f821a7907
18 changed files with 2044 additions and 136 deletions
+4
View File
@@ -14,3 +14,7 @@ ctrld-*
cmd/cli/rsrc_*.syso
ctrld
ctrld.exe
# Local planning artifacts - never commit
/SPEC.md
/tasks/
+268 -67
View File
@@ -24,6 +24,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/Masterminds/semver/v3"
"github.com/cuonglm/osinfo"
@@ -220,8 +221,12 @@ func isStableVersion(vs string) bool {
func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd)
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return
}
run(nil, make(chan struct{}))
}
@@ -260,7 +265,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{
@@ -298,14 +304,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
consoleWriter = newHumanReadableZapCore(io.MultiWriter(os.Stdout, hlc), consoleWriterLevel)
p.logConn = hlc
}
notifyExitToLogServer := func() {
if p.logConn != nil {
_ = p.logConn.Close()
}
}
if daemon && runtime.GOOS == "windows" {
p.Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.")
failRunUnclassified(p.Error(), "cannot run in daemon mode; please install a Windows service", p.notifyExitToLogServer)
return
}
if !daemon {
@@ -316,7 +318,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
svcConfig := svcCmd.createServiceConfig()
s, err := svcCmd.newService(p, svcConfig)
if err != nil {
p.Fatal().Err(err).Msg("Failed to create new service")
failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to create new service: %v", err), p.notifyExitToLogServer)
return
}
if err := s.Run(); err != nil {
p.Error().Err(err).Msg("Failed to start service")
@@ -327,18 +330,20 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
tryReadingConfig(writeDefaultConfig)
if err := readBase64Config(configBase64); err != nil {
p.Fatal().Err(err).Msg("Failed to read base64 config")
failRunUnclassified(p.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()
p.Fatal().Msgf("Failed to unmarshal config: %v", err)
}
unmarshalErr := v.Unmarshal(&cfg)
p.mu.Unlock()
if unmarshalErr != nil {
failRunUnclassified(p.Error(), fmt.Sprintf("failed to unmarshal config: %v", unmarshalErr), p.notifyExitToLogServer)
return
}
processLogAndCacheFlags(v, &cfg)
@@ -359,8 +364,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// Wait for network up.
if !ctrldnet.Up() {
notifyExitToLogServer()
p.Fatal().Msg("Network is not up yet")
failRunUnclassified(p.Error(), "network is not up yet", p.notifyExitToLogServer)
return
}
cs, err := newControlServer(filepath.Join(sockDir, ControlSocketName()))
@@ -374,7 +379,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
@@ -391,7 +398,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.
p.Notice().Msg("Stop requested while fetching resolver config — shutting down")
notifyExitToLogServer()
p.notifyExitToLogServer()
return
case pf.err != nil:
if isMobile() {
@@ -399,7 +406,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()
@@ -408,7 +415,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.
@@ -430,9 +437,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// The flag defaults to "off" for help output, so use Changed() to avoid
// clobbering a config-file value of firewall_mode = "on" on normal starts.
if firewallModeFlagChanged {
if !validFirewallMode(firewallMode) {
notifyExitToLogServer()
p.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
if !validateFirewallModeFlag(true, firewallMode, p.notifyExitToLogServer) {
return
}
if cfg.Service.FirewallMode != firewallMode {
cfg.Service.FirewallMode = firewallMode
@@ -448,11 +454,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
if updated {
if err := writeConfigFile(&cfg); err != nil {
notifyExitToLogServer()
p.Fatal().Err(err).Msg("Failed to write config file")
} else {
p.Info().Msg("Writing config file to: " + defaultConfigFile)
failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer)
return
}
p.Info().Msg("Writing config file to: " + defaultConfigFile)
}
if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
@@ -471,31 +476,28 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
if err := validateConfig(&cfg); err != nil {
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(p.Error().Err(err), fmt.Sprintf("invalid config: %v", err), p.notifyExitToLogServer)
return
}
initCache()
if daemon {
exe, err := os.Executable()
if err != nil {
p.Error().Err(err).Msg("Failed to find the binary")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(p.Error().Err(err), "failed to find the binary", p.notifyExitToLogServer)
return
}
curDir, err := os.Getwd()
if err != nil {
p.Error().Err(err).Msg("Failed to get current working directory")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(p.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 {
p.Error().Err(err).Msg("Failed to start process as daemon")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(p.Error().Err(err), "failed to start process as daemon", p.notifyExitToLogServer)
return
}
p.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
os.Exit(0)
@@ -814,9 +816,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
@@ -825,12 +828,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 {
@@ -1328,7 +1381,21 @@ func userHomeDir() (string, error) {
return ctrld.UserHomeDir()
}
// socketDir returns directory that ctrld will create socket file for running controlServer.
// absHomeDir returns the absolute path of filename in the ctrld home
// directory. A custom homedir wins, so a file lands next to the log and
// the config that obey the same override.
func absHomeDir(filename string) string {
if homedir != "" {
return filepath.Join(homedir, filename)
}
dir, err := userHomeDir()
if err != nil {
return filename
}
return filepath.Join(dir, filename)
}
// socketDir returns directory to use for the controlServer socket.
func socketDir() (string, error) {
switch {
case runtime.GOOS == "windows", isMobile():
@@ -1939,6 +2006,99 @@ 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
}
// validateFirewallModeFlag classifies an invalid --firewall-mode value on the
// provisioning boundary. Unlike --intercept-mode, no dedicated code exists
// for this flag, so a bad value falls back to UNCLASSIFIED rather than a bare
// fatal exit. changed must be the flag's Cobra Changed() state: the default
// value ("off") is not itself invalid, so an unset flag always proceeds.
// notify unblocks a waiting "ctrld start"; pass nil when there is none.
func validateFirewallModeFlag(changed bool, mode string, notify func()) bool {
if !changed || validFirewallMode(mode) {
return true
}
msg := fmt.Sprintf("--firewall-mode %q is not valid: must be 'off' or 'on'", mode)
failProvisionUnclassified(msg, notify)
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.
@@ -1949,9 +2109,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 ""
}
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
req := &controld.UtilityOrgRequest{
@@ -1967,7 +2129,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
@@ -2073,34 +2235,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
}
// validateCdUpstreamProtocol validates the Control D upstream protocol
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
}
}
// validateCdAndNextDNSFlags validates that Control D and NextDNS flags are not used together
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.
@@ -2306,7 +2489,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: the direct "--cd <uid>" install path
// 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 {
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
req := &controld.ResolverConfigRequest{
@@ -2314,16 +2515,16 @@ func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
Version: appVersion,
Metadata: ctrld.SystemMetadata(loggerCtx),
}
rc, err := controld.FetchResolverConfig(loggerCtx, req, cdDev)
rc, err := fetchResolverConfig(loggerCtx, 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.
+238
View File
@@ -2,6 +2,8 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -71,6 +73,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
@@ -177,6 +313,108 @@ func TestHandleAPIPreflightFailure(t *testing.T) {
})
}
// TestDoValidateCdRemoteConfigClassifiesAPIFailure covers the direct
// "--cd <uid>" install path (fatal=true): 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 the direct-cd
// install path never triggers self-uninstall on a device-invalid failure:
// this runs before the 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, *ctrld.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
+45 -25
View File
@@ -63,10 +63,21 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
logger := mainLog.Load()
logger.Debug().Msg("Service start command started")
// 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()
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
validateCdAndNextDNSFlags()
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return nil
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return nil
}
if !validateCdAndNextDNSFlags() {
return nil
}
svcConfig := sc.createServiceConfig()
osArgs := os.Args[2:]
@@ -81,18 +92,21 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
// 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) {
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
if !validateInterceptModeFlag(interceptMode) {
return nil
}
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
if !validateFirewallModeFlag(firewallModeFlagChanged, firewallMode, nil) {
return nil
}
// Initialize service manager with proper configuration
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
if err != nil {
logger.Error().Err(err).Msg("Failed to initialize service manager")
return err
// A bare error return would exit 1 with no result file, so support
// could not tell this failure from a start that never ran.
failProvisionUnclassified("initialize service manager: "+err.Error(), nil)
return nil
}
p.cfg = &cfg
@@ -121,7 +135,8 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
// 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 {
logger.Fatal().Err(err).Msg("failed to remove existing intercept mode from service arguments")
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to remove existing intercept mode from service arguments: %v", err), nil)
return nil
}
if interceptMode == "off" {
@@ -130,10 +145,12 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
}
if err := appendServiceFlag("--intercept-mode"); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to append intercept flag to service arguments: %v", err), nil)
return nil
}
if err := appendServiceFlag(interceptMode); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to append intercept mode value to service arguments: %v", err), nil)
return nil
}
// Stop the service if running (bypasses ctrld pin — this is an
@@ -250,7 +267,8 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
if startOnly && isCtrldInstalled {
tryReadingConfigWithNotice(false, true)
if err := v.Unmarshal(&cfg); err != nil {
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return nil
}
// if already running, dont restart
@@ -277,8 +295,6 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
{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()
logger.Notice().Msg("Starting existing ctrld service")
failedTask, taskErr := doTasksE(tasks)
@@ -287,12 +303,13 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return nil
}
os.Exit(1)
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
return nil
}
sockDir, err := socketDir()
if err != nil {
logger.Warn().Err(err).Msg("Failed to get socket directory")
os.Exit(1)
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to get socket directory: %v", err), nil)
return nil
}
// The daemon can start and still fail provisioning (for example a
@@ -325,7 +342,9 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
}
if cdUID != "" {
_ = doValidateCdRemoteConfig(cdUID, true)
if err := doValidateCdRemoteConfig(cdUID, true); err != nil {
return nil
}
} else if uid := cdUIDFromProvToken(); uid != "" {
cdUID = uid
logger.Debug().Msg("Using uid from provision token")
@@ -334,7 +353,9 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID)
}
if cdUID != "" {
validateCdUpstreamProtocol()
if !validateCdUpstreamProtocol(nil) {
return nil
}
}
if configPath != "" {
@@ -344,7 +365,8 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
tryReadingConfigWithNotice(writeDefaultConfig, true)
if err := v.Unmarshal(&cfg); err != nil {
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return nil
}
initInteractiveLogging()
@@ -384,8 +406,6 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
// 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()
logger.Notice().Msg("Starting service")
failedTask, taskErr := doTasksE(tasks)
@@ -394,9 +414,9 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return nil
}
// 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 nil
}
+153
View File
@@ -0,0 +1,153 @@
package cli
import (
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
// startFunctionSource extracts the source text of ServiceCommand.Start's body
// from commands_service_start.go. Driving Start() itself end-to-end for every
// early-return branch is not practical in a unit test: within a few lines of
// any check failing, Start() 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 startFunctionSource(t *testing.T) string {
t.Helper()
file := packageSourcePath(t, "commands_service_start.go")
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, 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.Name.Name != "Start" || fn.Recv == nil {
continue
}
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("could not read %s: %v", file, err)
}
start := fset.Position(fn.Body.Lbrace).Offset
end := fset.Position(fn.Body.Rbrace).Offset
return string(src[start:end])
}
t.Fatalf("ServiceCommand.Start not found in %s", file)
return ""
}
// TestServiceCommandStartClearsProvisionResultBeforeAnyCheck pins the
// ordering fix: clearProvisionResult() must run before every check in
// Start() that can fail or return early, not just before doTasksE. Without
// this, a check between the top of Start() 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 TestServiceCommandStartClearsProvisionResultBeforeAnyCheck(t *testing.T) {
body := startFunctionSource(t)
clearIdx := strings.Index(body, "clearProvisionResult()")
if clearIdx == -1 {
t.Fatal("Start() no longer calls clearProvisionResult()")
}
// Every check or step that can return out of Start() before reaching
// doTasksE. Each must appear after the entry clear.
earlyChecks := []string{
"checkStrFlagEmpty(",
"validateCdAndNextDNSFlags(",
"validateInterceptModeFlag(",
"validateFirewallModeFlag(",
"initializeServiceManagerWithServiceConfig(",
"doTasksE(",
}
for _, check := range earlyChecks {
idx := strings.Index(body, check)
if idx == -1 {
t.Fatalf("expected Start() 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)
}
}
}
// TestServiceCommandStartClassifiesServiceManagerInitFailure pins the fix for
// a bare error return: a service-manager init failure in Start() must fail
// through failProvisionUnclassified, so a result file and the identifier line
// exist, instead of returning the error for a plain exit 1.
func TestServiceCommandStartClassifiesServiceManagerInitFailure(t *testing.T) {
body := startFunctionSource(t)
initIdx := strings.Index(body, "initializeServiceManagerWithServiceConfig(")
if initIdx == -1 {
t.Fatal("Start() no longer calls initializeServiceManagerWithServiceConfig")
}
branchEnd := strings.Index(body[initIdx:], "p.cfg = &cfg")
if branchEnd == -1 {
t.Fatal("could not find the end of the service-manager init branch")
}
branch := body[initIdx : initIdx+branchEnd]
if !strings.Contains(branch, "failProvisionUnclassified(") {
t.Error("service-manager init failure does not fail through failProvisionUnclassified")
}
if strings.Contains(branch, "return err") {
t.Error("service-manager init failure still returns the bare error, which exits 1 with no result file")
}
}
// startTestCommand builds the minimal cobra.Command ServiceCommand.Start needs
// before it can reach its early --intercept-mode check: the --cd/--cd-org
// flags must exist (checkStrFlagEmpty looks them up unconditionally) but stay
// unchanged, so neither Fatals.
func startTestCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().String(cdUidFlagName, "", "")
cmd.Flags().String(cdOrgFlagName, "", "")
return cmd
}
// TestServiceCommandStartReplacesStaleResultOnEarlyClassifiedFailure is a
// behavioral companion to the structural test above: it drives the real
// Start() 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 TestServiceCommandStartReplacesStaleResultOnEarlyClassifiedFailure(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
oldIntercept, oldNextdns, oldFirewallChanged := interceptMode, nextdns, firewallModeFlagChanged
t.Cleanup(func() {
interceptMode, nextdns, firewallModeFlagChanged = oldIntercept, oldNextdns, oldFirewallChanged
})
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)
}
sc := NewServiceCommand()
if err := sc.Start(startTestCommand(), nil); err != nil {
t.Fatalf("Start() error = %v", err)
}
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)
}
}
+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.
@@ -16,3 +20,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)
}
}
}
+17 -4
View File
@@ -710,6 +710,16 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
p.ptrNameservers = ptrNameservers
}
// 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) {
msg := fmt.Sprintf("unable to start dns proxy on listener.%s: %v", listenerNum, err)
failRunUnclassified(p.Error().Err(err), msg, p.notifyExitToLogServer)
}
// run runs the ctrld main components.
//
// The reload boolean indicates that the function is run when ctrld first start
@@ -827,7 +837,7 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
// Changes to listeners config require a service restart, not just reload.
serveCtx := context.Background()
if err := p.serveDNS(serveCtx, listenerNum); err != nil {
p.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
p.reportServeDNSFailure(listenerNum, err)
}
p.Debug().Msgf("End of serveDNS listener.%s: %s", listenerNum, addr)
}(listenerNum)
@@ -994,8 +1004,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(p.Error(), fmt.Sprintf(format, v...), p.notifyExitToLogServer)
}
)
@@ -1081,7 +1094,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
@@ -121,7 +121,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...))
}
+9
View File
@@ -31,3 +31,12 @@ func (p *prog) Error() *ctrld.LogEvent {
func (p *prog) Notice() *ctrld.LogEvent {
return p.logger.Load().Notice()
}
// notifyExitToLogServer closes this run's connection to the HTTP log server,
// so a waiting "ctrld start" is not left waiting on it after this process
// exits. A nil connection (log server never started) is a no-op.
func (p *prog) notifyExitToLogServer() {
if p.logConn != nil {
_ = p.logConn.Close()
}
}
+209
View File
@@ -0,0 +1,209 @@
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.
//
// 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_service_start.go",
"commands_run.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.`,
"commands_service_start.go:ServiceCommand.Start": `the deactivation pin check's ` +
`os.Exit(126). Out of scope for this lane by contract ("pin-check 126 untouched").`,
"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)
}
}
}
+422
View File
@@ -0,0 +1,422 @@
package cli
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"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{}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
zapcore.AddSync(buf),
zap.DebugLevel,
)
old := mainLog.Load()
mainLog.Store(&ctrld.Logger{Logger: zap.New(core)})
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)
}
})
}
+87 -28
View File
@@ -22,6 +22,7 @@ import (
type provisionStage string
const (
provisionStageInput provisionStage = "input"
provisionStageBootstrap provisionStage = "bootstrap"
provisionStageListener provisionStage = "listener"
provisionStageService provisionStage = "service"
@@ -30,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 (
@@ -106,7 +144,7 @@ type provisionResult struct {
// provisionResultPath is a var so tests can point it at a temp dir.
var provisionResultPath = func() string {
return ctrld.AbsHomeDir(provisionResultFileName)
return absHomeDir(provisionResultFileName)
}
// provisionExit is a var so tests can observe the exit code instead of dying.
@@ -247,3 +285,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 *ctrld.LogEvent, msg string, notify func()) {
ev.Msg(msg)
failProvisionUnclassified(msg, notify)
}
+15 -2
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))
}
}
+277
View File
@@ -0,0 +1,277 @@
package cli
import (
"errors"
"strings"
"testing"
)
// 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 closes 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("closes a set connection", func(t *testing.T) {
p := &prog{}
fc := &fakeCloser{}
p.logConn = fc
p.notifyExitToLogServer()
if !fc.closed {
t.Error("expected logConn to be closed")
}
})
}
type fakeCloser struct{ closed bool }
func (f *fakeCloser) Write(p []byte) (int, error) { return len(p), nil }
func (f *fakeCloser) Close() error { f.closed = true; return nil }
// TestValidateFirewallModeFlag covers the shared validator behind both
// --firewall-mode call sites (the early "ctrld start" check and run()'s own
// check on the daemon side). Neither has a dedicated code, so both fall back
// to UNCLASSIFIED.
func TestValidateFirewallModeFlag(t *testing.T) {
t.Run("flag not changed proceeds regardless of value", func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
if !validateFirewallModeFlag(false, "garbage", nil) {
t.Error("validateFirewallModeFlag() = false, want true when the flag was not set")
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
})
t.Run("valid values proceed", func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
for _, mode := range []string{"off", "on"} {
if !validateFirewallModeFlag(true, mode, nil) {
t.Errorf("validateFirewallModeFlag(true, %q) = false, want true", mode)
}
}
if *exitCode != -1 {
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
}
})
t.Run("invalid value is classified UNCLASSIFIED and notifies", func(t *testing.T) {
exitCode, _ := stubProvisionGlobals(t)
notified := false
if validateFirewallModeFlag(true, "bogus", func() { notified = true }) {
t.Error("validateFirewallModeFlag(true, \"bogus\") = true, want false")
}
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 !strings.Contains(r.Message, "off") || !strings.Contains(r.Message, "on") {
t.Errorf("message = %q, want it to name the allowed values", r.Message)
}
})
}
// 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)
p := &prog{}
p.logger.Store(mainLog.Load())
notified := false
p.logConn = &fakeCloserNotify{fn: func() { notified = true }}
p.reportServeDNSFailure("0", errors.New("bind lost mid-flight"))
if !notified {
t.Error("expected the log connection to be closed")
}
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)
}
}
type fakeCloserNotify struct{ fn func() }
func (f *fakeCloserNotify) Write(p []byte) (int, error) { return len(p), nil }
func (f *fakeCloserNotify) Close() error { f.fn(); return nil }
// 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)
p := &prog{}
p.logger.Store(mainLog.Load())
notified := false
p.logConn = &fakeCloserNotify{fn: func() { notified = true }}
refuseFallbackFatal(p, "cannot fall back: %s", "port 53 is unavailable")
if !notified {
t.Error("expected the log connection to be closed")
}
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 Start()/run() with no seam to trigger it in isolation is documented
// here with reachable == "" and relies on the shared helper tests above
// (TestFailProvisionUnclassified, TestNotifyExitToLogServer,
// TestValidateFirewallModeFlag, 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_service_start.go, cli.go's run() and its
// callers cdUIDFromProvToken/handleAPIPreflightFailure, commands_run.go, 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_service_start.go (parent "ctrld start" process)
{"commands_service_start.go", "--intercept-mode is not off/dns/hard", provisionCodeInterceptModeInvalid, "TestValidateInterceptModeFlag"},
{"commands_service_start.go", "--firewall-mode is not off/on", provisionCodeUnclassified, "TestValidateFirewallModeFlag"},
{"commands_service_start.go / cli.go RunCobraCommand", "explicit empty --cd-org", provisionCodeProvisionTokenMalformed, "TestCheckStrFlagEmptyClassifiesEmptyCdOrg"},
{"commands_service_start.go", "--cd/--cd-org used together with --nextdns", provisionCodeInvalidFlagCombination, "TestValidateCdAndNextDNSFlagsClassifiesConflict"},
{"commands_service_start.go", "initializeServiceManagerWithServiceConfig fails", provisionCodeUnclassified, "TestServiceCommandStartClassifiesServiceManagerInitFailure"},
{"commands_service_start.go / cli.go run()", "--proto is not doh/doh3 once --cd is set", provisionCodeInvalidFlagCombination, "TestValidateCdUpstreamProtocol"},
{"commands_service_start.go", "removeServiceFlag fails while upgrading an existing service's intercept mode", provisionCodeUnclassified, ""},
{"commands_service_start.go", "appendServiceFlag(\"--intercept-mode\") fails during the same upgrade", provisionCodeUnclassified, ""},
{"commands_service_start.go", "appendServiceFlag(mode) fails during the same upgrade", provisionCodeUnclassified, ""},
{"commands_service_start.go", "config unmarshal fails restarting an already-installed service", provisionCodeUnclassified, ""},
{"commands_service_start.go", "doTasksE fails on a non-service-stage task restarting an existing service", provisionCodeUnclassified, ""},
{"commands_service_start.go", "socketDir() fails restarting an existing service", provisionCodeUnclassified, ""},
{"commands_service_start.go", "config unmarshal fails on a fresh install", provisionCodeUnclassified, ""},
{"commands_service_start.go", "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, ""},
{"cli.go run()", "--firewall-mode is not off/on (daemon-side check)", provisionCodeUnclassified, "TestValidateFirewallModeFlag"},
{"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 ~995'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 line ~912 (deactivation pin) and commands_service_start.go's
// own deactivation-pin os.Exit: explicitly out of scope per the lane
// contract ("pin-check 126 untouched").
// - service.go: checkHasElevatedPrivilege's os.Exit(1) in the "start"
// PreRun. It runs before RunE, in a process that cannot write to
// /etc/controld, so no result file is possible there. The postinstall
// always runs as root, and the message names the fix.
// - cli.go: general CLI/config-parsing helpers (readConfigFile,
// decoderErrorFromTomlFile, processNoConfigFlags, processListenFlag,
// tryReadingConfigWithNotice'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.
}
+15 -6
View File
@@ -14,9 +14,9 @@ stable code on three surfaces:
Installer or MDM wrappers can extract exactly this line (fixed charset:
`stage=[a-z]* code=[A-Z_]* (exit [0-9]*)`) into their own logs without
risking token leakage from other output.
- **Exit code** — stage-scoped: bootstrap 3039, listener 4049,
service 5059. Unrelated existing contracts are unchanged
(`ctrld-client status` exits 03; invalid deactivation pin exits 126).
- **Exit code** — stage-scoped: input 2029, bootstrap 3039,
listener 4049, service 5059. Unrelated existing contracts are
unchanged (`ctrld-client status` exits 03; invalid deactivation pin exits 126).
A customer or administrator only needs to report the code (or the whole
output line). The table below is the maintained support mapping; it must
@@ -26,14 +26,23 @@ stay in sync with `cmd/cli/provision_result.go` and changes in the same MR.
| Code | Stage | Exit | Failure scenario | Next action / evidence |
|---|---|---|---|---|
| `PROVISION_TOKEN_MALFORMED` | input | 21 | The `--cd-org` value is clearly not a provisioning code: explicitly empty, too short, too long, or containing whitespace or control characters. Checked before any network call. A missing `org-v1-` prefix is not this code — that only logs a warning, since legacy codes may lack it. | Check the provisioning code was copied in full, with no extra whitespace. The result file never echoes the value. |
| `CUSTOM_HOSTNAME_INVALID` | input | 22 | The `--custom-hostname` value fails ctrld's own hostname rule (`validHostname`): not 364 characters, or not RFC1123 hostname format. Checked before any network call. The API itself does not reject a bad hostname during provisioning — ControlD folds/strips characters like space, `+`, and `.` when it registers the device name, so a value ctrld accepts may still register under an adjusted name (logged as a notice, not a failure). | Fix `--custom-hostname` per the message: it names the offending character(s) and the allowed format. |
| `INTERCEPT_MODE_INVALID` | input | 23 | The `--intercept-mode` value is not one of `off`, `dns`, or `hard`. Checked before installing the service. | Re-run with a valid `--intercept-mode` value. |
| `INVALID_FLAG_COMBINATION` | input | 24 | `--cd`/`--cd-org` used together with `--nextdns`, or `--proto` set to anything other than `doh`/`doh3` once `--cd` is in play. Checked before any network call or service install. | Re-run without the conflicting flag, or fix the invalid value. The message names the exact flags involved. |
| `API_UNREACHABLE` | bootstrap | 30 | The Control D API could not be reached or answered with a retryable error (network failure, proxy interference, 5xx, timeout) and retries ran out. The service manager may retry the service later. | Check the device's network path to `api.controld.com` (DNS, proxy, firewall, captive portal). Ask for the result file's `message` and whether other TLS traffic works. |
| `API_REJECTED` | bootstrap | 31 | The API answered and permanently rejected the configuration (4xx other than 408/429): bad or revoked token, malformed request. ctrld exits without burning service-manager restarts because retrying cannot change the answer. | Verify the provision token / org configuration in the Control D dashboard. Re-push after fixing credentials. Evidence: HTTP status in the result file `message`. |
| `API_DEVICE_INVALID` | bootstrap | 32 | The API reports the device/resolver no longer exists (error code 40402). ctrld self-uninstalls its service because the identity is gone server-side. | Confirm the device was deleted or re-provisioned in the dashboard; re-provision with a current token. No local evidence needed beyond the code. |
| `LISTENER_BIND_FAILED` | listener | 41 | No listen address could be bound after all fallbacks (configured address, 0.0.0.0:53, localhost:53, port 5354, random) were exhausted. `detail.attempts` records each tried address with the UDP/TCP OS error, e.g. `address already in use` (another DNS service owns the port) or `can't assign requested address` (address not on any interface). | Read `detail.attempts`: `address already in use` → find the process owning the port (`sudo lsof -i :53 -nP`); `can't assign requested address` → the configured IP is not present on the device. Then fix the conflict or the listener config. |
| `API_DEVICE_INVALID` | bootstrap | 32 | The API reports the device/resolver no longer exists (error code 40402). When the daemon's own bootstrap preflight discovers this, it self-uninstalls because the identity is gone server-side. The direct `--cd <uid>` install path (`ctrld-client start --cd`) can also hit this code, before the service exists; there is nothing to uninstall yet on that path. | Confirm the device was deleted or re-provisioned in the dashboard; re-provision with a current token. No local evidence needed beyond the code. |
| `TOKEN_INVALID` | bootstrap | 33 | The API rejected the `--cd-org` provisioning code with reason `token_invalid`: the code is not recognized. | Check the code and re-enter it exactly as given. |
| `TOKEN_EXPIRED` | bootstrap | 34 | The API rejected the `--cd-org` provisioning code with reason `token_expired`. | Get a new provisioning code from your administrator. |
| `TOKEN_LIMIT_REACHED` | bootstrap | 35 | The API rejected the `--cd-org` provisioning code with reason `token_limit_reached`: it has reached its device limit. | Free up a device slot or use a different provisioning code. |
| `TOKEN_DISABLED` | bootstrap | 36 | The API rejected the `--cd-org` provisioning code with reason `token_disabled`: the code was invalidated. | Download a profile from an active provisioning code. |
| `LISTENER_BIND_FAILED` | listener | 41 | No listen address could be bound after all fallbacks (configured address, 0.0.0.0:53, localhost:53, random) were exhausted. `detail.attempts` records each tried address with the UDP/TCP OS error, e.g. `address already in use` (another DNS service owns the port) or `can't assign requested address` (address not on any interface). | Read `detail.attempts`: `address already in use` → find the process owning the port (`sudo lsof -i :53 -nP`); `can't assign requested address` → the configured IP is not present on the device. Then fix the conflict or the listener config. |
| `LISTENER_CONFIGURED_ADDR_UNAVAILABLE` | listener | 42 | An explicitly configured listener address could not be bound and configuration checks forbid falling back to another address, or (macOS intercept mode) the required explicit address is unavailable. | The configured `ip:port` in the listener config is wrong for this device or occupied. Verify the address exists on an interface and nothing else binds it; correct the config rather than expecting fallback. |
| `SERVICE_INSTALL_FAILED` | service | 51 | The OS service manager refused to install the service (launchd/systemd/SCM registration failed). | Check OS-level constraints: permissions/elevation, MDM policy blocking daemon installation, corrupted previous install. Evidence: result file `message` (service manager error), plus `launchctl print system/ctrld-client` / `systemctl status ctrld-client` / SCM state. |
| `SERVICE_START_FAILED` | service | 52 | The service installed but the service manager could not start it. | Check the service manager's own log for the start error, then the ctrld home dir `ctrld.log`. Often permissions or a binary quarantined by security tooling. |
| `SERVICE_SELFCHECK_FAILED` | service | 53 | The service started but never became healthy: no fresher failure was reported by the daemon, and the post-install DNS self-check failed. The just-installed service is rolled back (uninstalled). If the daemon itself recorded a more specific failure (e.g. a listener code), that code is reported instead of this one. | Ask for the drained service log printed by `ctrld-client start` and the result file. If the service was running but unreachable, check host firewall rules intercepting DNS to the listener. |
| `SERVICE_SELFCHECK_FAILED` | service | 53 | The service started but never became healthy: no fresher failure was reported by the daemon, and the post-install DNS self-check failed. On a fresh install or an upgrade, the just-installed service is rolled back (uninstalled). A restart of an already installed service keeps that service installed. If the daemon itself recorded a more specific failure (e.g. a listener code), that code is reported instead of this one. | Ask for the drained service log printed by `ctrld-client start` and the result file. If the service was running but unreachable, check host firewall rules intercepting DNS to the listener. |
| `UNCLASSIFIED` | service | 59 | A terminal failure on the provisioning boundary that predates a dedicated code: a config unmarshal, a file-system or environment failure (writing the config file, reading `socketDir`, respawning as a daemon), a service-argument update failing mid-upgrade, or a DNS-intercept failure the interface-DNS fallback cannot safely take over from. Every one of these used to be a bare crash with nothing to read; now they all persist a result file. | Read the result file `message`: it names the specific operation that failed and the underlying OS error. Treat this the same as any other stage-scoped failure when reporting it. |
## Reading the result file
+43 -2
View File
@@ -37,6 +37,16 @@ const (
sendLogTimeout = 300 * time.Second
)
// Provisioning-token rejection reasons the API sends in error.metadata.reason
// (HTTP 400, code 40003). This list can grow; a value outside it is not an
// error, just one cmd/cli does not classify yet.
const (
ReasonTokenInvalid = "token_invalid"
ReasonTokenExpired = "token_expired"
ReasonTokenLimitReached = "token_limit_reached"
ReasonTokenDisabled = "token_disabled"
)
// ResolverConfig represents Control D resolver data.
type ResolverConfig struct {
DOH string `json:"doh"`
@@ -64,10 +74,41 @@ type utilityResponse struct {
} `json:"body"`
}
// errorMetadata carries additive, optional detail on top of Code/Message.
// Older API deployments omit it, so it must decode to its zero value rather
// than fail the whole response. Its custom UnmarshalJSON gives the same
// tolerance to a malformed value: a metadata that is not an object, or a
// Reason that is not a string (a number, an object, or null), degrades to
// the zero value rather than failing the response that contains it.
type errorMetadata struct {
// Reason is a machine-readable rejection reason sent on provisioning-token
// errors (HTTP 400, code 40003): token_invalid, token_expired,
// token_limit_reached, or token_disabled. Empty when absent or malformed;
// callers must treat any other value as unknown rather than reject the
// response.
Reason string `json:"reason"`
}
func (m *errorMetadata) UnmarshalJSON(data []byte) error {
var raw struct {
Reason json.RawMessage `json:"reason"`
}
// Best-effort: a metadata that is not an object, or a reason that is not
// a string (number, object, null), leaves the zero value instead of
// failing this decode. Code and Message still classify the failure.
if err := json.Unmarshal(data, &raw); err != nil {
*m = errorMetadata{}
return nil
}
_ = json.Unmarshal(raw.Reason, &m.Reason)
return nil
}
type ErrorResponse struct {
ErrorField struct {
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Metadata errorMetadata `json:"metadata"`
} `json:"error"`
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
// body: this type is built for *any* non-200 whose body decodes, so the body alone
+110
View File
@@ -98,6 +98,116 @@ func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
})
}
// TestAPIErrorDecodesRejectionReason pins the additive metadata.reason field the
// API sends on provisioning-token rejections. cmd/cli maps known reasons to their
// own failure codes, and must fall back cleanly when the field is absent or holds
// a value this build does not recognize yet.
func TestAPIErrorDecodesRejectionReason(t *testing.T) {
tests := []struct {
name string
body string
wantReason string
}{
{
name: "known reason",
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"token_disabled"}}}`,
wantReason: "token_disabled",
},
{
name: "reason absent",
body: `{"error":{"message":"invalid token","code":40003}}`,
wantReason: "",
},
{
name: "unknown reason value",
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"something_new"}}}`,
wantReason: "something_new",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if errResp.ErrorField.Metadata.Reason != tc.wantReason {
t.Errorf("reason = %q, want %q", errResp.ErrorField.Metadata.Reason, tc.wantReason)
}
})
}
}
// TestAPIErrorToleratesMalformedRejectionReason pins the fix for a decode error
// confined to metadata.reason: a reason sent as the wrong JSON type must not
// discard the rest of the response. Before this fix, apiErrorFromResponse
// returned the raw decode error and nothing else, which cmd/cli's
// apiFailureCode cannot recognize as an *ErrorResponse - it falls back to
// API_UNREACHABLE (a retryable bootstrap failure) instead of the permanent
// rejection the HTTP status and code actually describe.
func TestAPIErrorToleratesMalformedRejectionReason(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) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
}
if errResp.ErrorField.Message != "invalid token" {
t.Errorf("message = %q, want it to survive the malformed reason", errResp.ErrorField.Message)
}
if errResp.ErrorField.Code != 40003 {
t.Errorf("code = %d, want it to survive the malformed reason", errResp.ErrorField.Code)
}
if errResp.ErrorField.Metadata.Reason != "" {
t.Errorf("reason = %q, want empty for a malformed value", errResp.ErrorField.Metadata.Reason)
}
})
}
}
// TestAPIErrorToleratesMalformedMetadata pins the same tolerance one level
// up: a metadata field that is not a JSON object must not discard the rest
// of the response. Code and Message still classify the failure, and Reason
// stays empty.
func TestAPIErrorToleratesMalformedMetadata(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "metadata as a string", body: `{"error":{"message":"invalid token","code":40003,"metadata":"foo"}}`},
{name: "metadata as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":7}}`},
{name: "metadata as an array", body: `{"error":{"message":"invalid token","code":40003,"metadata":["reason"]}}`},
{name: "metadata as a boolean", body: `{"error":{"message":"invalid token","code":40003,"metadata":true}}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if errResp.ErrorField.Code != 40003 {
t.Errorf("code = %d, want 40003", errResp.ErrorField.Code)
}
if errResp.ErrorField.Message != "invalid token" {
t.Errorf("message = %q, want %q", errResp.ErrorField.Message, "invalid token")
}
if errResp.ErrorField.Metadata.Reason != "" {
t.Errorf("reason = %q, want empty for a malformed metadata", errResp.ErrorField.Metadata.Reason)
}
})
}
}
// TestUtilityResponseDecodesDestinationIPs pins the API field that carries the
// organization's effective Allowed Destination IP list. The list is enforced as a
// set of Firewall Mode exceptions, so a silent decode change - a renamed field, a