mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
feat(cli): classify every provisioning failure with stable codes
This commit is contained in:
@@ -14,3 +14,7 @@ ctrld-*
|
|||||||
cmd/cli/rsrc_*.syso
|
cmd/cli/rsrc_*.syso
|
||||||
ctrld
|
ctrld
|
||||||
ctrld.exe
|
ctrld.exe
|
||||||
|
|
||||||
|
# Local planning artifacts - never commit
|
||||||
|
/SPEC.md
|
||||||
|
/tasks/
|
||||||
|
|||||||
+268
-67
@@ -24,6 +24,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
"github.com/Masterminds/semver/v3"
|
||||||
"github.com/cuonglm/osinfo"
|
"github.com/cuonglm/osinfo"
|
||||||
@@ -220,8 +221,12 @@ func isStableVersion(vs string) bool {
|
|||||||
func RunCobraCommand(cmd *cobra.Command) {
|
func RunCobraCommand(cmd *cobra.Command) {
|
||||||
noConfigStart = isNoConfigStart(cmd)
|
noConfigStart = isNoConfigStart(cmd)
|
||||||
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||||
checkStrFlagEmpty(cmd, cdUidFlagName)
|
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
|
||||||
checkStrFlagEmpty(cmd, cdOrgFlagName)
|
return
|
||||||
|
}
|
||||||
|
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
run(nil, make(chan struct{}))
|
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.
|
// run runs ctrld cli with given app callback and stop channel.
|
||||||
func run(appCallback *AppCallback, stopCh chan struct{}) {
|
func run(appCallback *AppCallback, stopCh chan struct{}) {
|
||||||
if stopCh == nil {
|
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{})
|
waitCh := make(chan struct{})
|
||||||
p := &prog{
|
p := &prog{
|
||||||
@@ -298,14 +304,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
consoleWriter = newHumanReadableZapCore(io.MultiWriter(os.Stdout, hlc), consoleWriterLevel)
|
consoleWriter = newHumanReadableZapCore(io.MultiWriter(os.Stdout, hlc), consoleWriterLevel)
|
||||||
p.logConn = hlc
|
p.logConn = hlc
|
||||||
}
|
}
|
||||||
notifyExitToLogServer := func() {
|
|
||||||
if p.logConn != nil {
|
|
||||||
_ = p.logConn.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if daemon && runtime.GOOS == "windows" {
|
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 {
|
if !daemon {
|
||||||
@@ -316,7 +318,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
svcConfig := svcCmd.createServiceConfig()
|
svcConfig := svcCmd.createServiceConfig()
|
||||||
s, err := svcCmd.newService(p, svcConfig)
|
s, err := svcCmd.newService(p, svcConfig)
|
||||||
if err != nil {
|
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 {
|
if err := s.Run(); err != nil {
|
||||||
p.Error().Err(err).Msg("Failed to start service")
|
p.Error().Err(err).Msg("Failed to start service")
|
||||||
@@ -327,18 +330,20 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
tryReadingConfig(writeDefaultConfig)
|
tryReadingConfig(writeDefaultConfig)
|
||||||
|
|
||||||
if err := readBase64Config(configBase64); err != nil {
|
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)
|
processNoConfigFlags(noConfigStart)
|
||||||
|
|
||||||
// After s.Run() was called, if ctrld is going to be terminated for any reason,
|
// 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.
|
// write msgExit to p.logConn so others (like "ctrld start") won't have to wait for timeout.
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
unmarshalErr := v.Unmarshal(&cfg)
|
||||||
notifyExitToLogServer()
|
|
||||||
p.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
if unmarshalErr != nil {
|
||||||
|
failRunUnclassified(p.Error(), fmt.Sprintf("failed to unmarshal config: %v", unmarshalErr), p.notifyExitToLogServer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
processLogAndCacheFlags(v, &cfg)
|
processLogAndCacheFlags(v, &cfg)
|
||||||
|
|
||||||
@@ -359,8 +364,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
|
|
||||||
// Wait for network up.
|
// Wait for network up.
|
||||||
if !ctrldnet.Up() {
|
if !ctrldnet.Up() {
|
||||||
notifyExitToLogServer()
|
failRunUnclassified(p.Error(), "network is not up yet", p.notifyExitToLogServer)
|
||||||
p.Fatal().Msg("Network is not up yet")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cs, err := newControlServer(filepath.Join(sockDir, ControlSocketName()))
|
cs, err := newControlServer(filepath.Join(sockDir, ControlSocketName()))
|
||||||
@@ -374,7 +379,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
cdUID = uid
|
cdUID = uid
|
||||||
}
|
}
|
||||||
if cdUID != "" {
|
if cdUID != "" {
|
||||||
validateCdUpstreamProtocol()
|
if !validateCdUpstreamProtocol(p.notifyExitToLogServer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Bound API preflight by the service lifetime. Without this, a stop request
|
// Bound API preflight by the service lifetime. Without this, a stop request
|
||||||
// arriving while the API is unreachable leaves this retry/backoff loop running
|
// 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
|
// 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
|
// not see a failed start and apply its restart policy to a service the
|
||||||
// operator just asked to stop.
|
// operator just asked to stop.
|
||||||
p.Notice().Msg("Stop requested while fetching resolver config — shutting down")
|
p.Notice().Msg("Stop requested while fetching resolver config — shutting down")
|
||||||
notifyExitToLogServer()
|
p.notifyExitToLogServer()
|
||||||
return
|
return
|
||||||
case pf.err != nil:
|
case pf.err != nil:
|
||||||
if isMobile() {
|
if isMobile() {
|
||||||
@@ -399,7 +406,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAPIPreflightFailure(p, pf.err, notifyExitToLogServer)
|
handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
p.mu.Lock()
|
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
|
// Bootstrap and listener binding both succeeded, so an earlier run's
|
||||||
// recorded failure no longer describes this install.
|
// 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
|
// 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.
|
// clobbering a config-file value of firewall_mode = "on" on normal starts.
|
||||||
if firewallModeFlagChanged {
|
if firewallModeFlagChanged {
|
||||||
if !validFirewallMode(firewallMode) {
|
if !validateFirewallModeFlag(true, firewallMode, p.notifyExitToLogServer) {
|
||||||
notifyExitToLogServer()
|
return
|
||||||
p.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
|
|
||||||
}
|
}
|
||||||
if cfg.Service.FirewallMode != firewallMode {
|
if cfg.Service.FirewallMode != firewallMode {
|
||||||
cfg.Service.FirewallMode = firewallMode
|
cfg.Service.FirewallMode = firewallMode
|
||||||
@@ -448,11 +454,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
|
|
||||||
if updated {
|
if updated {
|
||||||
if err := writeConfigFile(&cfg); err != nil {
|
if err := writeConfigFile(&cfg); err != nil {
|
||||||
notifyExitToLogServer()
|
failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer)
|
||||||
p.Fatal().Err(err).Msg("Failed to write config file")
|
return
|
||||||
} else {
|
|
||||||
p.Info().Msg("Writing config file to: " + defaultConfigFile)
|
|
||||||
}
|
}
|
||||||
|
p.Info().Msg("Writing config file to: " + defaultConfigFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
|
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 {
|
if err := validateConfig(&cfg); err != nil {
|
||||||
notifyExitToLogServer()
|
failRunUnclassified(p.Error().Err(err), fmt.Sprintf("invalid config: %v", err), p.notifyExitToLogServer)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
initCache()
|
initCache()
|
||||||
|
|
||||||
if daemon {
|
if daemon {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.Error().Err(err).Msg("Failed to find the binary")
|
failRunUnclassified(p.Error().Err(err), "failed to find the binary", p.notifyExitToLogServer)
|
||||||
notifyExitToLogServer()
|
return
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
curDir, err := os.Getwd()
|
curDir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.Error().Err(err).Msg("Failed to get current working directory")
|
failRunUnclassified(p.Error().Err(err), "failed to get current working directory", p.notifyExitToLogServer)
|
||||||
notifyExitToLogServer()
|
return
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
// If running as daemon, re-run the command in background, with daemon off.
|
// If running as daemon, re-run the command in background, with daemon off.
|
||||||
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
|
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
|
||||||
cmd.Dir = curDir
|
cmd.Dir = curDir
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
p.Error().Err(err).Msg("Failed to start process as daemon")
|
failRunUnclassified(p.Error().Err(err), "failed to start process as daemon", p.notifyExitToLogServer)
|
||||||
notifyExitToLogServer()
|
return
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
p.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
|
p.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -814,9 +816,10 @@ func permanentAPIRejection(err error) (*controld.ErrorResponse, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiFailureCode maps a bootstrap preflight error to its provisioning code.
|
// apiFailureCode maps a bootstrap preflight error to its provisioning code.
|
||||||
// A deleted device gets its own code because it triggers self-uninstall;
|
// A deleted device gets its own code because it triggers self-uninstall; a
|
||||||
// other permanent rejections are generic; anything else counts as
|
// permanent rejection with a known token reason gets its own code; other
|
||||||
// reachability trouble worth retrying.
|
// permanent rejections are generic; anything else counts as reachability
|
||||||
|
// trouble worth retrying.
|
||||||
func apiFailureCode(err error) (provisionFailureCode, bool) {
|
func apiFailureCode(err error) (provisionFailureCode, bool) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -825,12 +828,62 @@ func apiFailureCode(err error) (provisionFailureCode, bool) {
|
|||||||
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
|
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
|
||||||
return provisionCodeAPIDeviceInvalid, true
|
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 provisionCodeAPIRejected, true
|
||||||
}
|
}
|
||||||
return provisionCodeAPIUnreachable, 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
|
// 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.
|
// can echo back the value the caller sent, so it stays out of the artifact.
|
||||||
func apiRejectionSummary(statusCode int) string {
|
func apiRejectionSummary(statusCode int) string {
|
||||||
@@ -1328,7 +1381,21 @@ func userHomeDir() (string, error) {
|
|||||||
return ctrld.UserHomeDir()
|
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) {
|
func socketDir() (string, error) {
|
||||||
switch {
|
switch {
|
||||||
case runtime.GOOS == "windows", isMobile():
|
case runtime.GOOS == "windows", isMobile():
|
||||||
@@ -1939,6 +2006,99 @@ func osVersion() string {
|
|||||||
return oi.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.
|
// cdUIDFromProvToken fetch UID from ControlD API using provision token.
|
||||||
func cdUIDFromProvToken() string {
|
func cdUIDFromProvToken() string {
|
||||||
// --cd flag supersedes --cd-org, ignore it if both are supplied.
|
// --cd flag supersedes --cd-org, ignore it if both are supplied.
|
||||||
@@ -1949,9 +2109,11 @@ func cdUIDFromProvToken() string {
|
|||||||
if cdOrg == "" {
|
if cdOrg == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Validate custom hostname if provided.
|
if !validateProvisionTokenShape() {
|
||||||
if customHostname != "" && !validHostname(customHostname) {
|
return ""
|
||||||
mainLog.Load().Fatal().Msgf("Invalid custom hostname: %q", customHostname)
|
}
|
||||||
|
if !validateCustomHostnameFlag() {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
||||||
req := &controld.UtilityOrgRequest{
|
req := &controld.UtilityOrgRequest{
|
||||||
@@ -1967,7 +2129,7 @@ func cdUIDFromProvToken() string {
|
|||||||
code, _ := apiFailureCode(err)
|
code, _ := apiFailureCode(err)
|
||||||
mainLog.Load().Error().Msgf("Failed to fetch resolver uid with provision token: %s: %s",
|
mainLog.Load().Error().Msgf("Failed to fetch resolver uid with provision token: %s: %s",
|
||||||
redactToken(cdOrg), redactSecrets(err.Error(), provisionSecrets()...))
|
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 ""
|
||||||
}
|
}
|
||||||
return resolverConfig.UID
|
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.
|
// checkStrFlagEmpty validates if a string flag was set to an empty string.
|
||||||
// If yes, emitting a fatal error message.
|
// An explicit empty --cd-org is a malformed provisioning token, classified on
|
||||||
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) {
|
// 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)
|
fl := cmd.Flags().Lookup(flagName)
|
||||||
if !fl.Changed || fl.Value.Type() != "string" {
|
if !fl.Changed || fl.Value.Type() != "string" || fl.Value.String() != "" {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
if fl.Value.String() == "" {
|
if flagName == cdOrgFlagName {
|
||||||
mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
|
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
|
// validateCdUpstreamProtocol validates the Control D upstream protocol,
|
||||||
func validateCdUpstreamProtocol() {
|
// 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 == "" {
|
if cdUID == "" {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
switch cdUpstreamProto {
|
switch cdUpstreamProto {
|
||||||
case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
|
case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
|
||||||
|
return true
|
||||||
default:
|
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
|
// validateCdAndNextDNSFlags validates that Control D and NextDNS flags are
|
||||||
func validateCdAndNextDNSFlags() {
|
// 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 != "" {
|
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.
|
// removeNextDNSFromArgs removes the --nextdns from command line arguments.
|
||||||
@@ -2306,7 +2489,25 @@ func runningIface(s service.Service) *ifaceResponse {
|
|||||||
return nil
|
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.
|
// 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 {
|
func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
|
||||||
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
||||||
req := &controld.ResolverConfigRequest{
|
req := &controld.ResolverConfigRequest{
|
||||||
@@ -2314,16 +2515,16 @@ func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
|
|||||||
Version: appVersion,
|
Version: appVersion,
|
||||||
Metadata: ctrld.SystemMetadata(loggerCtx),
|
Metadata: ctrld.SystemMetadata(loggerCtx),
|
||||||
}
|
}
|
||||||
rc, err := controld.FetchResolverConfig(loggerCtx, req, cdDev)
|
rc, err := fetchResolverConfig(loggerCtx, req, cdDev)
|
||||||
if err != nil {
|
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 {
|
if !fatal {
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("Failed to fetch resolver config for %s", redactToken(cdUID))
|
||||||
return err
|
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.
|
// return earlier if there's no custom config.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"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) {
|
func stubProvisionGlobals(t *testing.T) (exitCode *int, notified *bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
oldCdUID, oldCdOrg := cdUID, cdOrg
|
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) {
|
func TestCdUIDFromProvTokenFailureEmitsCode(t *testing.T) {
|
||||||
exitCode, _ := stubProvisionGlobals(t)
|
exitCode, _ := stubProvisionGlobals(t)
|
||||||
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
||||||
|
|||||||
@@ -63,10 +63,21 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
|||||||
logger := mainLog.Load()
|
logger := mainLog.Load()
|
||||||
logger.Debug().Msg("Service start command started")
|
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")
|
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||||
checkStrFlagEmpty(cmd, cdUidFlagName)
|
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
|
||||||
checkStrFlagEmpty(cmd, cdOrgFlagName)
|
return nil
|
||||||
validateCdAndNextDNSFlags()
|
}
|
||||||
|
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !validateCdAndNextDNSFlags() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
svcConfig := sc.createServiceConfig()
|
svcConfig := sc.createServiceConfig()
|
||||||
osArgs := os.Args[2:]
|
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,
|
// 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
|
// the child process would Fatal() on the invalid value, and the parent would
|
||||||
// then uninstall — confusing and destructive.
|
// then uninstall — confusing and destructive.
|
||||||
if interceptMode != "" && !validInterceptMode(interceptMode) {
|
if !validateInterceptModeFlag(interceptMode) {
|
||||||
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
|
return nil
|
||||||
}
|
}
|
||||||
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
|
if !validateFirewallModeFlag(firewallModeFlagChanged, firewallMode, nil) {
|
||||||
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize service manager with proper configuration
|
// Initialize service manager with proper configuration
|
||||||
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
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
|
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
|
// An explicit "off" argument must override a previously persisted config
|
||||||
// value while the service clears that value on startup.
|
// value while the service clears that value on startup.
|
||||||
if err := removeServiceFlag("--intercept-mode"); err != nil {
|
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" {
|
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)
|
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
||||||
}
|
}
|
||||||
if err := appendServiceFlag("--intercept-mode"); err != nil {
|
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 {
|
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
|
// 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 {
|
if startOnly && isCtrldInstalled {
|
||||||
tryReadingConfigWithNotice(false, true)
|
tryReadingConfigWithNotice(false, true)
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
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
|
// if already running, dont restart
|
||||||
@@ -277,8 +295,6 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
|||||||
{s.Start, true, "Start"},
|
{s.Start, true, "Start"},
|
||||||
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
}
|
}
|
||||||
// Any result found later must come from this attempt, not a stale run.
|
|
||||||
clearProvisionResult()
|
|
||||||
startAttemptAt := time.Now()
|
startAttemptAt := time.Now()
|
||||||
logger.Notice().Msg("Starting existing ctrld service")
|
logger.Notice().Msg("Starting existing ctrld service")
|
||||||
failedTask, taskErr := doTasksE(tasks)
|
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)
|
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
sockDir, err := socketDir()
|
sockDir, err := socketDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("Failed to get socket directory")
|
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to get socket directory: %v", err), nil)
|
||||||
os.Exit(1)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// The daemon can start and still fail provisioning (for example a
|
// 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 != "" {
|
if cdUID != "" {
|
||||||
_ = doValidateCdRemoteConfig(cdUID, true)
|
if err := doValidateCdRemoteConfig(cdUID, true); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
} else if uid := cdUIDFromProvToken(); uid != "" {
|
} else if uid := cdUIDFromProvToken(); uid != "" {
|
||||||
cdUID = uid
|
cdUID = uid
|
||||||
logger.Debug().Msg("Using uid from provision token")
|
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)
|
svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID)
|
||||||
}
|
}
|
||||||
if cdUID != "" {
|
if cdUID != "" {
|
||||||
validateCdUpstreamProtocol()
|
if !validateCdUpstreamProtocol(nil) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
@@ -344,7 +365,8 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
|||||||
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
||||||
|
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
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()
|
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.
|
// generated after s.Start, so we notice users here for consistent with nextdns mode.
|
||||||
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
}
|
}
|
||||||
// Any result found later must come from this attempt, not a stale run.
|
|
||||||
clearProvisionResult()
|
|
||||||
startAttemptAt := time.Now()
|
startAttemptAt := time.Now()
|
||||||
logger.Notice().Msg("Starting service")
|
logger.Notice().Msg("Starting service")
|
||||||
failedTask, taskErr := doTasksE(tasks)
|
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)
|
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Not a service-stage task. doTasksE already logged the cause; exit
|
// Not a service-stage task. doTasksE already logged the cause; classify
|
||||||
// non-zero instead of the old silent fall-through that exited 0.
|
// UNCLASSIFIED instead of the old silent fall-through that exited 0.
|
||||||
os.Exit(1)
|
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// validHostname reports whether hostname is a valid hostname.
|
// validHostname reports whether hostname is a valid hostname.
|
||||||
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
|
// 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])$`)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -710,6 +710,16 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
|
|||||||
p.ptrNameservers = ptrNameservers
|
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.
|
// run runs the ctrld main components.
|
||||||
//
|
//
|
||||||
// The reload boolean indicates that the function is run when ctrld first start
|
// 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.
|
// Changes to listeners config require a service restart, not just reload.
|
||||||
serveCtx := context.Background()
|
serveCtx := context.Background()
|
||||||
if err := p.serveDNS(serveCtx, listenerNum); err != nil {
|
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)
|
p.Debug().Msgf("End of serveDNS listener.%s: %s", listenerNum, addr)
|
||||||
}(listenerNum)
|
}(listenerNum)
|
||||||
@@ -994,8 +1004,11 @@ var (
|
|||||||
initializeOsResolverWithSystemNameserversFn = ctrld.InitializeOsResolverWithSystemNameservers
|
initializeOsResolverWithSystemNameserversFn = ctrld.InitializeOsResolverWithSystemNameservers
|
||||||
setDnsForRunningIfaceFn = (*prog).setDnsForRunningIface
|
setDnsForRunningIfaceFn = (*prog).setDnsForRunningIface
|
||||||
resetDNSFn = (*prog).resetDNS
|
resetDNSFn = (*prog).resetDNS
|
||||||
refuseFallbackFatal = func(format string, v ...any) {
|
// refuseFallbackFatal reports a startup failure the interface-DNS fallback
|
||||||
mainLog.Load().Fatal().Msgf(format, v...)
|
// 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
|
// Leave the host resolvable: restore static settings or DHCP rather than
|
||||||
// exiting with an interface still pointed at a ctrld that is not serving.
|
// exiting with an interface still pointed at a ctrld that is not serving.
|
||||||
resetDNSFn(p, false, true)
|
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
|
// Unreachable in production - the line above exits - but returning
|
||||||
// explicitly keeps the refusal from depending on that, so nothing can
|
// explicitly keeps the refusal from depending on that, so nothing can
|
||||||
// fall through to installing the fallback this just rejected.
|
// fall through to installing the fallback this just rejected.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resetDNSFn = func(_ *prog, _ bool, _ bool) { h.resetCalls++ }
|
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...))
|
h.refusals = append(h.refusals, fmt.Sprintf(format, v...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,12 @@ func (p *prog) Error() *ctrld.LogEvent {
|
|||||||
func (p *prog) Notice() *ctrld.LogEvent {
|
func (p *prog) Notice() *ctrld.LogEvent {
|
||||||
return p.logger.Load().Notice()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -22,6 +22,7 @@ import (
|
|||||||
type provisionStage string
|
type provisionStage string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
provisionStageInput provisionStage = "input"
|
||||||
provisionStageBootstrap provisionStage = "bootstrap"
|
provisionStageBootstrap provisionStage = "bootstrap"
|
||||||
provisionStageListener provisionStage = "listener"
|
provisionStageListener provisionStage = "listener"
|
||||||
provisionStageService provisionStage = "service"
|
provisionStageService provisionStage = "service"
|
||||||
@@ -30,50 +31,87 @@ const (
|
|||||||
type provisionFailureCode string
|
type provisionFailureCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
|
provisionCodeProvisionTokenMalformed provisionFailureCode = "PROVISION_TOKEN_MALFORMED"
|
||||||
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
|
provisionCodeCustomHostnameInvalid provisionFailureCode = "CUSTOM_HOSTNAME_INVALID"
|
||||||
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
|
provisionCodeInterceptModeInvalid provisionFailureCode = "INTERCEPT_MODE_INVALID"
|
||||||
provisionCodeListenerBindFailed provisionFailureCode = "LISTENER_BIND_FAILED"
|
provisionCodeInvalidFlagCombination provisionFailureCode = "INVALID_FLAG_COMBINATION"
|
||||||
provisionCodeListenerAddrUnavail provisionFailureCode = "LISTENER_CONFIGURED_ADDR_UNAVAILABLE"
|
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
|
||||||
provisionCodeServiceInstall provisionFailureCode = "SERVICE_INSTALL_FAILED"
|
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
|
||||||
provisionCodeServiceStartFailed provisionFailureCode = "SERVICE_START_FAILED"
|
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
|
||||||
provisionCodeServiceSelfCheck provisionFailureCode = "SERVICE_SELFCHECK_FAILED"
|
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{
|
var allProvisionFailureCodes = []provisionFailureCode{
|
||||||
|
provisionCodeProvisionTokenMalformed,
|
||||||
|
provisionCodeCustomHostnameInvalid,
|
||||||
|
provisionCodeInterceptModeInvalid,
|
||||||
|
provisionCodeInvalidFlagCombination,
|
||||||
provisionCodeAPIUnreachable,
|
provisionCodeAPIUnreachable,
|
||||||
provisionCodeAPIRejected,
|
provisionCodeAPIRejected,
|
||||||
provisionCodeAPIDeviceInvalid,
|
provisionCodeAPIDeviceInvalid,
|
||||||
|
provisionCodeTokenInvalid,
|
||||||
|
provisionCodeTokenExpired,
|
||||||
|
provisionCodeTokenLimitReached,
|
||||||
|
provisionCodeTokenDisabled,
|
||||||
provisionCodeListenerBindFailed,
|
provisionCodeListenerBindFailed,
|
||||||
provisionCodeListenerAddrUnavail,
|
provisionCodeListenerAddrUnavail,
|
||||||
provisionCodeServiceInstall,
|
provisionCodeServiceInstall,
|
||||||
provisionCodeServiceStartFailed,
|
provisionCodeServiceStartFailed,
|
||||||
provisionCodeServiceSelfCheck,
|
provisionCodeServiceSelfCheck,
|
||||||
|
provisionCodeUnclassified,
|
||||||
}
|
}
|
||||||
|
|
||||||
var provisionStageForCode = map[provisionFailureCode]provisionStage{
|
var provisionStageForCode = map[provisionFailureCode]provisionStage{
|
||||||
provisionCodeAPIUnreachable: provisionStageBootstrap,
|
provisionCodeProvisionTokenMalformed: provisionStageInput,
|
||||||
provisionCodeAPIRejected: provisionStageBootstrap,
|
provisionCodeCustomHostnameInvalid: provisionStageInput,
|
||||||
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
|
provisionCodeInterceptModeInvalid: provisionStageInput,
|
||||||
provisionCodeListenerBindFailed: provisionStageListener,
|
provisionCodeInvalidFlagCombination: provisionStageInput,
|
||||||
provisionCodeListenerAddrUnavail: provisionStageListener,
|
provisionCodeAPIUnreachable: provisionStageBootstrap,
|
||||||
provisionCodeServiceInstall: provisionStageService,
|
provisionCodeAPIRejected: provisionStageBootstrap,
|
||||||
provisionCodeServiceStartFailed: provisionStageService,
|
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
|
||||||
provisionCodeServiceSelfCheck: provisionStageService,
|
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
|
// Exit codes are grouped by stage (input 20-29, bootstrap 30-39, listener
|
||||||
// 50-59) so the exit code alone names the failed stage. 0-3 belong to
|
// 40-49, service 50-59) so the exit code alone names the failed stage. 0-3
|
||||||
// "ctrld status" and 126 to the deactivation pin check; never reuse those.
|
// belong to "ctrld status" and 126 to the deactivation pin check; never
|
||||||
|
// reuse those.
|
||||||
var provisionExitCodeForCode = map[provisionFailureCode]int{
|
var provisionExitCodeForCode = map[provisionFailureCode]int{
|
||||||
provisionCodeAPIUnreachable: 30,
|
provisionCodeProvisionTokenMalformed: 21,
|
||||||
provisionCodeAPIRejected: 31,
|
provisionCodeCustomHostnameInvalid: 22,
|
||||||
provisionCodeAPIDeviceInvalid: 32,
|
provisionCodeInterceptModeInvalid: 23,
|
||||||
provisionCodeListenerBindFailed: 41,
|
provisionCodeInvalidFlagCombination: 24,
|
||||||
provisionCodeListenerAddrUnavail: 42,
|
provisionCodeAPIUnreachable: 30,
|
||||||
provisionCodeServiceInstall: 51,
|
provisionCodeAPIRejected: 31,
|
||||||
provisionCodeServiceStartFailed: 52,
|
provisionCodeAPIDeviceInvalid: 32,
|
||||||
provisionCodeServiceSelfCheck: 53,
|
provisionCodeTokenInvalid: 33,
|
||||||
|
provisionCodeTokenExpired: 34,
|
||||||
|
provisionCodeTokenLimitReached: 35,
|
||||||
|
provisionCodeTokenDisabled: 36,
|
||||||
|
provisionCodeListenerBindFailed: 41,
|
||||||
|
provisionCodeListenerAddrUnavail: 42,
|
||||||
|
provisionCodeServiceInstall: 51,
|
||||||
|
provisionCodeServiceStartFailed: 52,
|
||||||
|
provisionCodeServiceSelfCheck: 53,
|
||||||
|
provisionCodeUnclassified: 59,
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -106,7 +144,7 @@ type provisionResult struct {
|
|||||||
|
|
||||||
// provisionResultPath is a var so tests can point it at a temp dir.
|
// provisionResultPath is a var so tests can point it at a temp dir.
|
||||||
var provisionResultPath = func() string {
|
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.
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,21 @@ func overrideProvisionResultPath(t *testing.T) string {
|
|||||||
return path
|
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) {
|
func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
|
||||||
stageRanges := map[provisionStage][2]int{
|
stageRanges := map[provisionStage][2]int{
|
||||||
|
provisionStageInput: {20, 29},
|
||||||
provisionStageBootstrap: {30, 39},
|
provisionStageBootstrap: {30, 39},
|
||||||
provisionStageListener: {40, 49},
|
provisionStageListener: {40, 49},
|
||||||
provisionStageService: {50, 59},
|
provisionStageService: {50, 59},
|
||||||
@@ -55,8 +68,8 @@ func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
seenExits[exit] = code
|
seenExits[exit] = code
|
||||||
}
|
}
|
||||||
if len(allProvisionFailureCodes) != 8 {
|
if len(allProvisionFailureCodes) != 17 {
|
||||||
t.Errorf("expected 8 codes, got %d", len(allProvisionFailureCodes))
|
t.Errorf("expected 17 codes, got %d", len(allProvisionFailureCodes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ stable code on three surfaces:
|
|||||||
Installer or MDM wrappers can extract exactly this line (fixed charset:
|
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
|
`stage=[a-z]* code=[A-Z_]* (exit [0-9]*)`) into their own logs without
|
||||||
risking token leakage from other output.
|
risking token leakage from other output.
|
||||||
- **Exit code** — stage-scoped: bootstrap 30–39, listener 40–49,
|
- **Exit code** — stage-scoped: input 20–29, bootstrap 30–39,
|
||||||
service 50–59. Unrelated existing contracts are unchanged
|
listener 40–49, service 50–59. Unrelated existing contracts are
|
||||||
(`ctrld-client status` exits 0–3; invalid deactivation pin exits 126).
|
unchanged (`ctrld-client status` exits 0–3; invalid deactivation pin exits 126).
|
||||||
|
|
||||||
A customer or administrator only needs to report the code (or the whole
|
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
|
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 |
|
| 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 3–64 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_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_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. |
|
| `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. |
|
||||||
| `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. |
|
| `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. |
|
| `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_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_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
|
## Reading the result file
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const (
|
|||||||
sendLogTimeout = 300 * time.Second
|
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.
|
// ResolverConfig represents Control D resolver data.
|
||||||
type ResolverConfig struct {
|
type ResolverConfig struct {
|
||||||
DOH string `json:"doh"`
|
DOH string `json:"doh"`
|
||||||
@@ -64,10 +74,41 @@ type utilityResponse struct {
|
|||||||
} `json:"body"`
|
} `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 {
|
type ErrorResponse struct {
|
||||||
ErrorField struct {
|
ErrorField struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
|
Metadata errorMetadata `json:"metadata"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
|
// 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
|
// body: this type is built for *any* non-200 whose body decodes, so the body alone
|
||||||
|
|||||||
@@ -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
|
// TestUtilityResponseDecodesDestinationIPs pins the API field that carries the
|
||||||
// organization's effective Allowed Destination IP list. The list is enforced as a
|
// 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
|
// set of Firewall Mode exceptions, so a silent decode change - a renamed field, a
|
||||||
|
|||||||
Reference in New Issue
Block a user