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

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

No files matched your search

+259 -65
View File
@@ -24,6 +24,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/Masterminds/semver/v3"
"github.com/cuonglm/osinfo"
@@ -140,6 +141,7 @@ func initCLI() {
initClientsCmd()
initUpgradeCmd()
initLogCmd()
initDiagCmd()
}
// isMobile reports whether the current OS is a mobile platform.
@@ -183,8 +185,12 @@ func isStableVersion(vs string) bool {
// RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd)
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return
}
run(nil, make(chan struct{}))
}
@@ -223,7 +229,8 @@ func CheckDeactivationPin(pin int64, stopCh chan struct{}) int {
// run runs ctrld cli with given app callback and stop channel.
func run(appCallback *AppCallback, stopCh chan struct{}) {
if stopCh == nil {
mainLog.Load().Fatal().Msg("stopCh is nil")
failRunUnclassified(mainLog.Load().Error(), "run() called with a nil stop channel", nil)
return
}
waitCh := make(chan struct{})
p := &prog{
@@ -261,14 +268,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
} else {
mainLog.Load().Warn().Err(err).Msgf("unable to resolve socket address: %s", sockPath)
}
notifyExitToLogServer := func() {
if p.logConn != nil {
_, _ = p.logConn.Write([]byte(msgExit))
}
}
if daemon && runtime.GOOS == "windows" {
mainLog.Load().Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.")
failRunUnclassified(mainLog.Load().Error(), "cannot run in daemon mode; please install a Windows service", p.notifyExitToLogServer)
return
}
if !daemon {
@@ -277,7 +280,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
go func() {
s, err := newService(p, svcConfig)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed create new service")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to create new service: %v", err), p.notifyExitToLogServer)
return
}
if err := s.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start service")
@@ -288,18 +292,20 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
tryReadingConfig(writeDefaultConfig)
if err := readBase64Config(configBase64); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to read base64 config")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to read base64 config: %v", err), p.notifyExitToLogServer)
return
}
processNoConfigFlags(noConfigStart)
// After s.Run() was called, if ctrld is going to be terminated for any reason,
// write msgExit to p.logConn so others (like "ctrld start") won't have to wait for timeout.
p.mu.Lock()
if err := v.Unmarshal(&cfg); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
}
unmarshalErr := v.Unmarshal(&cfg)
p.mu.Unlock()
if unmarshalErr != nil {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", unmarshalErr), p.notifyExitToLogServer)
return
}
processLogAndCacheFlags(v, &cfg)
@@ -312,8 +318,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// Wait for network up.
if !ctrldnet.Up() {
notifyExitToLogServer()
mainLog.Load().Fatal().Msg("network is not up yet")
failRunUnclassified(mainLog.Load().Error(), "network is not up yet", p.notifyExitToLogServer)
return
}
p.router = router.New(&cfg, cdUID != "")
@@ -327,8 +333,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// time for validating server certificate. Some routers need NTP synchronization
// to set the current time, so this check must happen before processCDFlags.
if err := p.router.PreRun(); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Err(err).Msg("failed to perform router pre-run check")
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to perform router pre-run check: %v", err), p.notifyExitToLogServer)
return
}
oldLogPath := cfg.Service.LogPath
@@ -336,7 +342,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
cdUID = uid
}
if cdUID != "" {
validateCdUpstreamProtocol()
if !validateCdUpstreamProtocol(p.notifyExitToLogServer) {
return
}
// Bound API preflight by the service lifetime. Without this, a stop request
// arriving while the API is unreachable leaves this retry/backoff loop running
// after "service stopped" was logged, so the process keeps working on behalf of
@@ -353,7 +361,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
// not see a failed start and apply its restart policy to a service the
// operator just asked to stop.
mainLog.Load().Notice().Msg("stop requested while fetching resolver config, shutting down")
notifyExitToLogServer()
p.notifyExitToLogServer()
return
case pf.err != nil:
if isMobile() {
@@ -361,7 +369,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
return
}
handleAPIPreflightFailure(p, pf.err, notifyExitToLogServer)
handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
return
default:
p.mu.Lock()
@@ -370,7 +378,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
}
updated := updateListenerConfig(&cfg, notifyExitToLogServer)
updated := updateListenerConfig(&cfg, p.notifyExitToLogServer)
// Bootstrap and listener binding both succeeded, so an earlier run's
// recorded failure no longer describes this install.
@@ -390,11 +398,10 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
if updated {
if err := writeConfigFile(&cfg); err != nil {
notifyExitToLogServer()
mainLog.Load().Fatal().Err(err).Msg("failed to write config file")
} else {
mainLog.Load().Info().Msg("writing config file to: " + defaultConfigFile)
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer)
return
}
mainLog.Load().Info().Msg("writing config file to: " + defaultConfigFile)
}
if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
@@ -412,31 +419,28 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
if err := validateConfig(&cfg); err != nil {
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("invalid config: %v", err), p.notifyExitToLogServer)
return
}
initCache()
if daemon {
exe, err := os.Executable()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to find the binary")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to find the binary", p.notifyExitToLogServer)
return
}
curDir, err := os.Getwd()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to get current working directory")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to get current working directory", p.notifyExitToLogServer)
return
}
// If running as daemon, re-run the command in background, with daemon off.
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
cmd.Dir = curDir
if err := cmd.Start(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start process as daemon")
notifyExitToLogServer()
os.Exit(1)
failRunUnclassified(mainLog.Load().Error().Err(err), "failed to start process as daemon", p.notifyExitToLogServer)
return
}
mainLog.Load().Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
os.Exit(0)
@@ -741,9 +745,10 @@ func permanentAPIRejection(err error) (*controld.ErrorResponse, bool) {
}
// apiFailureCode maps a bootstrap preflight error to its provisioning code.
// A deleted device gets its own code because it triggers self-uninstall;
// other permanent rejections are generic; anything else counts as
// reachability trouble worth retrying.
// A deleted device gets its own code because it triggers self-uninstall; a
// permanent rejection with a known token reason gets its own code; other
// permanent rejections are generic; anything else counts as reachability
// trouble worth retrying.
func apiFailureCode(err error) (provisionFailureCode, bool) {
if err == nil {
return "", false
@@ -752,12 +757,62 @@ func apiFailureCode(err error) (provisionFailureCode, bool) {
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
return provisionCodeAPIDeviceInvalid, true
}
if _, ok := permanentAPIRejection(err); ok {
if rejection, ok := permanentAPIRejection(err); ok {
if code, ok := tokenFailureCodeForReason(rejection.ErrorField.Metadata.Reason); ok {
return code, true
}
return provisionCodeAPIRejected, true
}
return provisionCodeAPIUnreachable, true
}
// tokenFailureCodeForReason maps error.metadata.reason on a provisioning-token
// rejection to its failure code. An empty or unrecognized reason is not
// classified here; the caller falls back to the generic rejection code.
func tokenFailureCodeForReason(reason string) (provisionFailureCode, bool) {
switch reason {
case controld.ReasonTokenInvalid:
return provisionCodeTokenInvalid, true
case controld.ReasonTokenExpired:
return provisionCodeTokenExpired, true
case controld.ReasonTokenLimitReached:
return provisionCodeTokenLimitReached, true
case controld.ReasonTokenDisabled:
return provisionCodeTokenDisabled, true
default:
return "", false
}
}
// tokenRejectionMessage returns the human message for a TOKEN_* code: what is
// wrong with the provisioning code and one concrete next action. Empty for
// any other code, so callers know to fall back to a generic message. Never
// includes the token itself.
func tokenRejectionMessage(code provisionFailureCode) string {
switch code {
case provisionCodeTokenInvalid:
return "the provisioning code is not valid; check the code and re-enter it exactly as given"
case provisionCodeTokenExpired:
return "the provisioning code has expired; get a new provisioning code from your administrator"
case provisionCodeTokenLimitReached:
return "the provisioning code reached its device limit; free up a device slot or use a different code"
case provisionCodeTokenDisabled:
return "the provisioning code was invalidated; download a profile from an active provisioning code"
default:
return ""
}
}
// provisionTokenFailureMessage returns the result-file message for a failed
// provision-token exchange: a token-specific message when the API gave a
// known reason, the generic exchange-failure summary otherwise.
func provisionTokenFailureMessage(code provisionFailureCode, err error) string {
if msg := tokenRejectionMessage(code); msg != "" {
return msg
}
return fmt.Sprintf("provision token exchange failed: %v", err)
}
// apiRejectionSummary reports the HTTP status only. The API's raw error body
// can echo back the value the caller sent, so it stays out of the artifact.
func apiRejectionSummary(statusCode int) string {
@@ -1215,7 +1270,15 @@ func selfCheckResolveDomain(ctx context.Context, addr, scope string, domain stri
return errSelfCheckNoAnswer
}
func userHomeDir() (string, error) {
// unixServiceHomeDir is where a ctrld service with root rights keeps its
// files on macOS, Linux, and the BSDs.
const unixServiceHomeDir = "/etc/controld"
// serviceHomeDir returns the directory where a ctrld service with root or
// administrator rights keeps its files. It has no fallback to the home
// directory of the current user. A caller without root that only reads can
// thus look where the service wrote.
func serviceHomeDir() (string, error) {
dir, err := router.HomeDir()
if err != nil {
return "", err
@@ -1237,7 +1300,17 @@ func userHomeDir() (string, error) {
if isMobile() {
return homedir, nil
}
dir = "/etc/controld"
return unixServiceHomeDir, nil
}
// userHomeDir is serviceHomeDir with a fallback: when the current user
// cannot write to the unix service home, it returns the home directory of
// that user instead.
func userHomeDir() (string, error) {
dir, err := serviceHomeDir()
if err != nil || dir != unixServiceHomeDir {
return dir, err
}
if err := os.MkdirAll(dir, 0750); err != nil {
return os.UserHomeDir() // fallback to user home directory
}
@@ -1898,6 +1971,84 @@ func osVersion() string {
return oi.String()
}
// provisionTokenMinLen, provisionTokenMaxLen, and provisionTokenExpectedPrefix
// describe the shape of a --cd-org value the input stage checks before any
// network call.
const (
provisionTokenMinLen = 6
provisionTokenMaxLen = 64
provisionTokenExpectedPrefix = "org-v1-"
)
// provisionTokenShapeValid reports whether token looks like something the API
// could parse: a length between provisionTokenMinLen and provisionTokenMaxLen,
// with no whitespace or control characters. It does not require the
// "org-v1-" prefix - legacy provisioning codes predate that convention and
// are handled with a warning, not a failure.
func provisionTokenShapeValid(token string) bool {
if len(token) < provisionTokenMinLen || len(token) > provisionTokenMaxLen {
return false
}
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return false
}
}
return true
}
// validateProvisionTokenShape classifies an obviously malformed --cd-org
// value (PROVISION_TOKEN_MALFORMED) before any network call, so a typo or a
// pasted URL fails fast instead of going through a doomed API round trip. A
// token missing the "org-v1-" prefix only gets a logged warning: legacy
// provisioning codes may lack it and still work.
func validateProvisionTokenShape() bool {
if !provisionTokenShapeValid(cdOrg) {
msg := fmt.Sprintf("--cd-org provisioning code is malformed: must be %d-%d characters with no whitespace or control characters", provisionTokenMinLen, provisionTokenMaxLen)
failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
return false
}
if !strings.HasPrefix(cdOrg, provisionTokenExpectedPrefix) {
mainLog.Load().Warn().Msg("--cd-org provisioning code does not have the expected org-v1- prefix; continuing, since legacy codes may lack it")
}
return true
}
// validateCustomHostnameFlag classifies an invalid --custom-hostname value
// (CUSTOM_HOSTNAME_INVALID) before any network call. ctrld's accept/reject
// rule (validHostname) is unchanged - this only names the field, the
// offending character(s), and the allowed format instead of a bare fatal
// exit. ControlD's device-name formatting folds or strips some characters
// ctrld still accepts (dot, space, plus), so a hostname using one of those
// gets a one-line notice instead: the registered device name may differ from
// what was requested.
func validateCustomHostnameFlag() bool {
if customHostname == "" {
return true
}
if !validHostname(customHostname) {
failProvision(newProvisionResult(provisionCodeCustomHostnameInvalid, customHostnameFailureMessage(customHostname), nil, provisionSecrets()...), nil)
return false
}
if hostnameMayBeFoldedByServer(customHostname) {
mainLog.Load().Notice().Msgf("device name %q contains characters ControlD may fold or strip when it registers the device; the registered name may differ", customHostname)
}
return true
}
// validateInterceptModeFlag classifies an invalid --intercept-mode value
// (INTERCEPT_MODE_INVALID) on the provisioning boundary, so a typo fails with
// a stable code instead of a bare fatal exit an installer wrapper cannot tell
// apart from any other crash. An empty mode means the flag was not set.
func validateInterceptModeFlag(mode string) bool {
if mode == "" || validInterceptMode(mode) {
return true
}
msg := fmt.Sprintf("--intercept-mode %q is not valid: must be 'off', 'dns', or 'hard'", mode)
failProvision(newProvisionResult(provisionCodeInterceptModeInvalid, msg, nil, provisionSecrets()...), nil)
return false
}
// cdUIDFromProvToken fetch UID from ControlD API using provision token.
func cdUIDFromProvToken() string {
// --cd flag supersedes --cd-org, ignore it if both are supplied.
@@ -1908,9 +2059,11 @@ func cdUIDFromProvToken() string {
if cdOrg == "" {
return ""
}
// Validate custom hostname if provided.
if customHostname != "" && !validHostname(customHostname) {
mainLog.Load().Fatal().Msgf("invalid custom hostname: %q", customHostname)
if !validateProvisionTokenShape() {
return ""
}
if !validateCustomHostnameFlag() {
return ""
}
req := &controld.UtilityOrgRequest{
@@ -1926,7 +2079,7 @@ func cdUIDFromProvToken() string {
code, _ := apiFailureCode(err)
mainLog.Load().Error().Msgf("failed to fetch resolver uid with provision token: %s: %s",
redactToken(cdOrg), redactSecrets(err.Error(), provisionSecrets()...))
failProvision(newProvisionResult(code, fmt.Sprintf("provision token exchange failed: %v", err), nil, provisionSecrets()...), nil)
failProvision(newProvisionResult(code, provisionTokenFailureMessage(code, err), nil, provisionSecrets()...), nil)
return ""
}
return resolverConfig.UID
@@ -2032,32 +2185,55 @@ func newSocketControlClientMobile(dir string, stopCh chan struct{}) *controlClie
}
// checkStrFlagEmpty validates if a string flag was set to an empty string.
// If yes, emitting a fatal error message.
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) {
// An explicit empty --cd-org is a malformed provisioning token, classified on
// the provisioning boundary; every other flag keeps the bare fatal, since
// this helper is shared with flags outside that boundary. Returns false when
// the caller must stop: provisionExit is stubbed out under test, so nothing
// else stops execution from falling through.
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) bool {
fl := cmd.Flags().Lookup(flagName)
if !fl.Changed || fl.Value.Type() != "string" {
return
if !fl.Changed || fl.Value.Type() != "string" || fl.Value.String() != "" {
return true
}
if fl.Value.String() == "" {
mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
if flagName == cdOrgFlagName {
msg := fmt.Sprintf("--%s provisioning code is malformed: value must be non-empty", cdOrgFlagName)
failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
return false
}
mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
return false
}
func validateCdUpstreamProtocol() {
// validateCdUpstreamProtocol validates the Control D upstream protocol,
// classified on the provisioning boundary since it only runs once --cd is
// set. notify unblocks a waiting "ctrld start" on the daemon side; the parent
// process, which has no one waiting, passes nil. Returns false when the
// caller must stop.
func validateCdUpstreamProtocol(notify func()) bool {
if cdUID == "" {
return
return true
}
switch cdUpstreamProto {
case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
return true
default:
mainLog.Load().Fatal().Msg(`flag "--protocol" must be "doh" or "doh3"`)
msg := fmt.Sprintf("--proto %q is not valid: must be 'doh' or 'doh3'", cdUpstreamProto)
failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), notify)
return false
}
}
func validateCdAndNextDNSFlags() {
// validateCdAndNextDNSFlags validates that Control D and NextDNS flags are
// not used together, classified on the provisioning boundary since it only
// fires once --cd or --cd-org is set. Returns false when the caller must
// stop.
func validateCdAndNextDNSFlags() bool {
if (cdUID != "" || cdOrg != "") && nextdns != "" {
mainLog.Load().Fatal().Msgf("--%s/--%s could not be used with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
msg := fmt.Sprintf("--%s/--%s cannot be used together with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), nil)
return false
}
return true
}
// removeNextDNSFromArgs removes the --nextdns from command line arguments.
@@ -2274,7 +2450,25 @@ func runningIface(s service.Service) *ifaceResponse {
return nil
}
// apiRejectionMessage returns the result-file message for a permanent API
// rejection: the HTTP status for a device-invalid or generic rejection, or
// the raw error for anything else (network trouble, an unreachable API).
func apiRejectionMessage(err error) string {
var uer *controld.ErrorResponse
if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
return apiRejectionSummary(uer.StatusCode)
}
if rejection, ok := permanentAPIRejection(err); ok {
return apiRejectionSummary(rejection.StatusCode)
}
return fmt.Sprintf("failed to fetch resolver config: %v", err)
}
// doValidateCdRemoteConfig fetches and validates custom config for cdUID.
// fatal distinguishes the two callers: a direct install-time caller passes
// true and classifies a fetch failure on the provisioning boundary; the
// restart path passes false and gets the error back to decide for itself,
// with no process exit.
func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
// Username is only sent during initial provisioning (cdUIDFromProvToken).
// All subsequent calls use lightweight metadata to avoid EDR triggers.
@@ -2283,16 +2477,16 @@ func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
Version: rootCmd.Version,
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
rc, err := controld.FetchResolverConfig(context.Background(), req, cdDev)
rc, err := fetchResolverConfig(context.Background(), req, cdDev)
if err != nil {
logger := mainLog.Load().Fatal()
if !fatal {
logger = mainLog.Load().Warn()
}
logger.Err(err).Err(err).Msgf("failed to fetch resolver uid: %s", cdUID)
if !fatal {
mainLog.Load().Warn().Err(err).Msgf("failed to fetch resolver config for %s", redactToken(cdUID))
return err
}
code, _ := apiFailureCode(err)
mainLog.Load().Error().Err(err).Msgf("failed to fetch resolver config for %s", redactToken(cdUID))
failProvision(newProvisionResult(code, apiRejectionMessage(err), nil, provisionSecrets()...), nil)
return err
}
// return earlier if there's no custom config.