diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 96ed656..2a9a4e9 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -25,7 +25,7 @@ import ( "sync/atomic" "time" - "github.com/Masterminds/semver" + "github.com/Masterminds/semver/v3" "github.com/cuonglm/osinfo" "github.com/go-playground/validator/v10" "github.com/kardianos/service" diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index be9b0ae..9c2fb11 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -11,6 +11,7 @@ import ( "net/netip" "net/url" "os" + "os/exec" "runtime" "slices" "sort" @@ -21,6 +22,7 @@ import ( "syscall" "time" + "github.com/Masterminds/semver/v3" "github.com/kardianos/service" "github.com/rs/zerolog" "github.com/spf13/viper" @@ -304,6 +306,16 @@ func (p *prog) apiConfigReload() { logger := mainLog.Load().With().Str("mode", "api-reload").Logger() logger.Debug().Msg("starting custom config reload timer") lastUpdated := time.Now().Unix() + curVerStr := curVersion() + curVer, err := semver.NewVersion(curVerStr) + isStable := curVer != nil && curVer.Prerelease() == "" + if err != nil || !isStable { + l := mainLog.Load().Warn() + if err != nil { + l = l.Err(err) + } + l.Msgf("current version is not stable, skipping self-upgrade: %s", curVerStr) + } doReloadApiConfig := func(forced bool, logger zerolog.Logger) { resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) @@ -313,6 +325,11 @@ func (p *prog) apiConfigReload() { return } + // Performing self-upgrade check for production version. + if isStable { + selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger) + } + if resolverConfig.DeactivationPin != nil { newDeactivationPin := *resolverConfig.DeactivationPin curDeactivationPin := cdDeactivationPin.Load() @@ -1422,6 +1439,46 @@ func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) { } } +// selfUpgradeCheck checks if the version target vt is greater +// than the current one cv, perform self-upgrade then. +// +// The callers must ensure curVer and logger are non-nil. +func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) { + if vt == "" { + logger.Debug().Msg("no version target set, skipped checking self-upgrade") + return + } + vts := vt + if !strings.HasPrefix(vts, "v") { + vts = "v" + vts + } + targetVer, err := semver.NewVersion(vts) + if err != nil { + logger.Warn().Err(err).Msgf("invalid target version, skipped self-upgrade: %s", vt) + return + } + if !targetVer.GreaterThan(cv) { + logger.Debug(). + Str("target", vt). + Str("current", cv.String()). + Msgf("target version is not greater than current one, skipped self-upgrade") + return + } + + exe, err := os.Executable() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade") + return + } + cmd := exec.Command(exe, "upgrade", "prod", "-vv") + cmd.SysProcAttr = sysProcAttrForSelfUpgrade() + if err := cmd.Start(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade") + return + } + mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vts) +} + // leakOnUpstreamFailure reports whether ctrld should initiate a recovery flow // when upstream failures occur. func (p *prog) leakOnUpstreamFailure() bool { diff --git a/cmd/cli/self_upgrade_others.go b/cmd/cli/self_upgrade_others.go new file mode 100644 index 0000000..f1ff140 --- /dev/null +++ b/cmd/cli/self_upgrade_others.go @@ -0,0 +1,12 @@ +//go:build !windows + +package cli + +import ( + "syscall" +) + +// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/cmd/cli/self_upgrade_windows.go b/cmd/cli/self_upgrade_windows.go new file mode 100644 index 0000000..213aec9 --- /dev/null +++ b/cmd/cli/self_upgrade_windows.go @@ -0,0 +1,18 @@ +package cli + +import ( + "syscall" +) + +// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN + +// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window. +const SYSCALL_CREATE_NO_WINDOW = 0x08000000 + +// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW, + HideWindow: true, + } +} diff --git a/cmd/cli/service.go b/cmd/cli/service.go index f03146d..f75ee55 100644 --- a/cmd/cli/service.go +++ b/cmd/cli/service.go @@ -4,10 +4,12 @@ import ( "bytes" "errors" "fmt" + "io" "os" "os/exec" "runtime" + "github.com/coreos/go-systemd/v22/unit" "github.com/kardianos/service" "github.com/Control-D-Inc/ctrld/internal/router" @@ -132,6 +134,59 @@ func (s *systemd) Status() (service.Status, error) { return s.Service.Status() } +func (s *systemd) Start() error { + const systemdUnitFile = "/etc/systemd/system/ctrld.service" + f, err := os.Open(systemdUnitFile) + if err != nil { + return err + } + defer f.Close() + if opts, change := ensureSystemdKillMode(f); change { + mode := os.FileMode(0644) + buf, err := io.ReadAll(unit.Serialize(opts)) + if err != nil { + return err + } + if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil { + return err + } + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out)) + } + mainLog.Load().Debug().Msg("set KillMode=process successfully") + } + return s.Service.Start() +} + +// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process. +// This is necessary for running self-upgrade flow. +func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) { + opts, err := unit.DeserializeOptions(r) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to deserialize options") + return + } + change = true + needKillModeOpt := true + killModeOpt := unit.NewUnitOption("Service", "KillMode", "process") + for _, opt := range opts { + if opt.Match(killModeOpt) { + needKillModeOpt = false + change = false + break + } + if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name { + opt.Value = killModeOpt.Value + needKillModeOpt = false + break + } + } + if needKillModeOpt { + opts = append(opts, killModeOpt) + } + return opts, change +} + func newLaunchd(s service.Service) *launchd { return &launchd{ Service: s, diff --git a/cmd/cli/service_test.go b/cmd/cli/service_test.go new file mode 100644 index 0000000..155bd3e --- /dev/null +++ b/cmd/cli/service_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "strings" + "testing" +) + +func Test_ensureSystemdKillMode(t *testing.T) { + tests := []struct { + name string + unitFile string + wantChange bool + }{ + {"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true}, + {"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true}, + {"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false}, + {"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change { + t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange) + } + }) + } +} diff --git a/go.mod b/go.mod index b545941..dd80ffe 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 toolchain go1.23.7 require ( - github.com/Masterminds/semver v1.5.0 + github.com/Masterminds/semver/v3 v3.2.1 github.com/ameshkov/dnsstamps v1.0.3 github.com/coreos/go-systemd/v22 v22.5.0 github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf diff --git a/go.sum b/go.sum index 9687f26..149cb5f 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c h1:UqFsxmwiCh/DBvwJB0m7KQ2QFDd6DdUkosznfMppdhE= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= diff --git a/internal/controld/config.go b/internal/controld/config.go index 23542c7..595e758 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -45,6 +45,7 @@ type ResolverConfig struct { Ctrld struct { CustomConfig string `json:"custom_config"` CustomLastUpdate int64 `json:"custom_last_update"` + VersionTarget string `json:"version_target"` } `json:"ctrld"` Exclude []string `json:"exclude"` UID string `json:"uid"`