diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 1984f702..4b576bba 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -399,6 +399,18 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { os.Exit(0) } + // On Merlin, self-upgrade replaces the executable before the old process + // invokes Service.Start. The newly launched `ctrld run` process is therefore + // the first guaranteed execution point for new service-lifecycle code. + // Migrate an exact ctrld-owned legacy startup script here. Failure is + // deliberately non-fatal: the legacy script remains atomically intact and + // the next daemon start retries the migration. + if router.Name() == "merlin" { + if err := router.RefreshMerlinStartupScriptFromRunningBinary(); err != nil { + mainLog.Load().Warn().Err(err).Msg("could not refresh Merlin startup script") + } + } + p.onStarted = append(p.onStarted, func() { for _, lc := range p.cfg.Listener { if shouldAllocateLoopbackIP(lc.IP) { diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index a1074f29..a0ea6ec3 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -681,6 +681,18 @@ func initStopCmd() *cobra.Command { return stopCmd } +type restartWithBetweenService interface { + RestartWith(func() error) error +} + +func restartServiceTransaction(s service.Service, between func() error) (bool, error) { + tx, ok := s.(restartWithBetweenService) + if !ok { + return false, nil + } + return true, tx.RestartWith(between) +} + func initRestartCmd() *cobra.Command { restartCmd := &cobra.Command{ PreRun: func(cmd *cobra.Command, args []string) { @@ -726,49 +738,77 @@ func initRestartCmd() *cobra.Command { } doRestart := func() bool { - tasks := []task{ - {s.Stop, true, "Stop"}, - {func() error { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - return nil - }, false, "Cleanup"}, - {func() error { - time.Sleep(time.Second * 1) - return nil - }, false, "Waiting for service to stop"}, - } - if doTasks(tasks) { + between := func() error { + p.router.Cleanup() + // restore static DNS settings or DHCP + p.resetDNS(false, true) + time.Sleep(time.Second) if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - loop: for { select { case <-ctx.Done(): mainLog.Load().Error().Msg("timeout while waiting for service to stop") - break loop + return nil default: } - time.Sleep(time.Second) if status, _ := s.Status(); status == service.StatusStopped { break } + time.Sleep(time.Second) } } - } else { - return false + return nil } - tasks = []task{ - {s.Start, true, "Start"}, + if _, ok := s.(restartWithBetweenService); ok { + return doTasks([]task{ + {func() error { + _, err := restartServiceTransaction(s, between) + return err + }, true, "Restart"}, + }) } - return doTasks(tasks) + // Preserve the existing restart behavior byte-for-byte in spirit for + // non-Merlin service backends. Only Merlin opts into RestartWith. + tasks := []task{ + {s.Stop, true, "Stop"}, + {func() error { + p.router.Cleanup() + p.resetDNS(false, true) + return nil + }, false, "Cleanup"}, + {func() error { + time.Sleep(time.Second) + return nil + }, false, "Waiting for service to stop"}, + } + if !doTasks(tasks) { + return false + } + if router.WaitProcessExited() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + loop: + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + break loop + default: + } + time.Sleep(time.Second) + if status, _ := s.Status(); status == service.StatusStopped { + break + } + } + } + return doTasks([]task{{s.Start, true, "Start"}}) } if doRestart() { @@ -1288,25 +1328,58 @@ func initUpgradeCmd() *cobra.Command { if !svcInstalled { return true } - tasks := []task{ - {s.Stop, true, "Stop"}, - {func() error { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - return nil - }, false, "Cleanup"}, - {func() error { - time.Sleep(time.Second * 1) - return nil - }, false, "Waiting for service to stop"}, - } - if doTasks(tasks) { + + between := func() error { + p.router.Cleanup() + // restore static DNS settings or DHCP + p.resetDNS(false, true) + time.Sleep(time.Second) if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + return nil + default: + } + if status, _ := s.Status(); status == service.StatusStopped { + break + } + time.Sleep(time.Second) + } + } + return nil + } + + if _, ok := s.(restartWithBetweenService); ok { + _, err := restartServiceTransaction(s, between) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to restart ctrld service") + return false + } + } else { + // Keep the historical upgrade restart path for every non-Merlin + // backend; they do not participate in the new Merlin flock. + tasks := []task{ + {s.Stop, true, "Stop"}, + {func() error { + p.router.Cleanup() + p.resetDNS(false, true) + return nil + }, false, "Cleanup"}, + {func() error { + time.Sleep(time.Second) + return nil + }, false, "Waiting for service to stop"}, + } + if doTasks(tasks) && router.WaitProcessExited() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + loop: for { select { @@ -1321,21 +1394,20 @@ func initUpgradeCmd() *cobra.Command { } } } + if !doTasks([]task{{s.Start, true, "Start"}}) { + return false + } } - tasks = []task{ - {s.Start, true, "Start"}, - } - if doTasks(tasks) { - if dir, err := socketDir(); err == nil { - if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil { - _, _ = cc.post(ifacePath, nil) - return true - } + if dir, err := socketDir(); err == nil { + if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil { + _, _ = cc.post(ifacePath, nil) + return true } } return false } + if svcInstalled { mainLog.Load().Debug().Msg("Restarting ctrld service using new binary") } diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8ab6d6a7..5eee837b 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "text/template" @@ -20,6 +21,7 @@ import ( const ( merlinJFFSScriptPath = "/jffs/scripts/services-start" merlinJFFSServiceEventScriptPath = "/jffs/scripts/service-event" + merlinSvcScriptMarker = "# GENERATED BY ctrld - Merlin service v2" ) type merlinSvc struct { @@ -61,50 +63,339 @@ func (s *merlinSvc) configPath() string { } func (s *merlinSvc) template() *template.Template { - return template.Must(template.New("").Parse(merlinSvcScript)) + return template.Must(template.New("").Funcs(template.FuncMap{ + "shellQuote": merlinShellQuote, + }).Parse(merlinSvcScript)) } -func (s *merlinSvc) Install() error { +func (s *merlinSvc) legacyTemplate() *template.Template { + return template.Must(template.New("").Parse(merlinLegacySvcScript)) +} + +func (s *merlinSvc) renderStartupScripts(exePath string) (current, legacy []byte, err error) { + data := &struct { + *service.Config + Path string + }{ + s.Config, + exePath, + } + + var currentBuf bytes.Buffer + if err := s.template().Execute(¤tBuf, data); err != nil { + return nil, nil, fmt.Errorf("render current Merlin startup script: %w", err) + } + var legacyBuf bytes.Buffer + if err := s.legacyTemplate().Execute(&legacyBuf, data); err != nil { + return nil, nil, fmt.Errorf("render legacy Merlin startup script: %w", err) + } + return currentBuf.Bytes(), legacyBuf.Bytes(), nil +} + +// RefreshMerlinStartupScriptFromRunningBinary migrates an exact ctrld-owned +// legacy startup script from code executed by the newly launched binary. +// +// This is intentionally separate from merlinSvc.Start: during self-upgrade the +// process that calls Service.Start is still the old binary even after the +// executable on disk has been replaced. The new `ctrld run` process is the +// first reliable place where new lifecycle code is guaranteed to execute. +// +// Missing service state and user-custom startup scripts are harmless no-ops. +// Transient migration errors are returned to the caller, which should log and +// continue starting ctrld so the next daemon start can retry safely. +func RefreshMerlinStartupScriptFromRunningBinary() error { exePath, err := os.Executable() if err != nil { return err } + return refreshMerlinStartupScriptForExecutable(exePath) +} - if !strings.HasPrefix(exePath, "/jffs/") { - return errors.New("could not install service outside /jffs") +func refreshMerlinStartupScriptForExecutable(exePath string) error { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: exePath, + }, } - if _, err := nvram.Run("set", "jffs2_scripts=1"); err != nil { + + return withMerlinServiceLock(func() error { + err := s.refreshMerlinStartupScript() + if errors.Is(err, service.ErrNotInstalled) { + return nil + } return err + }) +} + +type merlinStartupMigrationError struct { + err error + legacy []byte +} + +func (e *merlinStartupMigrationError) Error() string { return e.err.Error() } +func (e *merlinStartupMigrationError) Unwrap() error { return e.err } + +func (s *merlinSvc) refreshMerlinStartupScript() error { + exePath := s.Config.Executable + if exePath == "" { + var err error + exePath, err = os.Executable() + if err != nil { + return err + } } - if _, err := nvram.Run("commit"); err != nil { + + installed, exists, err := readExistingMerlinStartupScript(s.configPath()) + if err != nil { return err } + if !exists { + return service.ErrNotInstalled + } - confPath := s.configPath() - if _, err := os.Stat(confPath); err == nil { - return fmt.Errorf("already installed: %s", confPath) + // Current ctrld-owned scripts carry an explicit marker. Start/restart paths + // are often constructed from svcConfig without the original install + // Arguments, so never re-render a current script from that incomplete + // lifecycle config. + if merlinStartupScriptHasMarker(installed) { + return nil } - var to = &struct { - *service.Config - Path string - }{ - s.Config, - exePath, + // v1.5.x scripts predate the marker. Recover the exact argv encoded in the + // legacy cmd= line, then render both templates with those installed + // arguments. Exact legacy byte equality is still required before migration; + // anything else is treated as user-owned/custom and left untouched. + recovered, ok := recoverLegacyMerlinServiceConfig(installed, s.Config, exePath) + if !ok { + return nil + } + clone := *s + clone.Config = recovered + current, legacy, err := clone.renderStartupScripts(exePath) + if err != nil { + return err } + if !bytes.Equal(installed, legacy) { + return nil + } + exists, err = prepareExistingMerlinStartupScript(s.configPath(), current, legacy) + if err != nil { + return &merlinStartupMigrationError{ + err: err, + legacy: append([]byte(nil), legacy...), + } + } + if !exists { + return service.ErrNotInstalled + } + return nil +} - f, err := os.Create(confPath) +func merlinStartupScriptHasMarker(buf []byte) bool { + for _, line := range strings.Split(string(buf), "\n") { + if strings.TrimSuffix(line, "\r") == merlinSvcScriptMarker { + return true + } + } + return false +} + +func recoverLegacyMerlinServiceConfig(buf []byte, base *service.Config, exePath string) (*service.Config, bool) { + var cmdLine string + for _, line := range strings.Split(string(buf), "\n") { + if strings.HasPrefix(line, `cmd="`) && strings.HasSuffix(line, `"`) { + cmdLine = strings.TrimSuffix(strings.TrimPrefix(line, `cmd="`), `"`) + break + } + } + if cmdLine == "" { + return nil, false + } + fields := strings.Fields(cmdLine) + if len(fields) == 0 || fields[0] != exePath { + return nil, false + } + cfg := *base + cfg.Executable = exePath + cfg.Arguments = append([]string(nil), fields[1:]...) + return &cfg, true +} + +func merlinShellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func merlinStartupHookLines(configPath string) (startLine, serviceEventLine string) { + path := merlinShellQuote(configPath) + return path + " start", path + ` service_event "$1" "$2"` +} + +func merlinLegacyStartupHookLines(configPath string) (startLine, serviceEventLine string) { + return configPath + " start", configPath + ` service_event "$1" "$2"` +} + +func writeMerlinStartupScript(path string, data []byte, mode os.FileMode) (published bool, retErr error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-*") if err != nil { - return fmt.Errorf("os.Create: %w", err) + return false, err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(mode); err != nil { + return false, err + } + if _, err := tmp.Write(data); err != nil { + return false, err + } + if err := tmp.Sync(); err != nil { + return false, err + } + if err := tmp.Close(); err != nil { + return false, err + } + + // Publish without replacement semantics. If another actor creates the + // startup script after our preflight, leave that file untouched. + if err := os.Link(tmpPath, path); err != nil { + return false, err + } + published = true + + if err := os.Remove(tmpPath); err != nil { + return true, err + } + if err := syncMerlinServiceDir(dir); err != nil { + return true, err + } + return true, nil +} + +func syncMerlinServiceDir(dir string) error { + // Merlin runs on Unix/Linux where fsyncing the parent directory is the + // durability barrier for link/unlink publication. Windows does not permit + // syncing directory handles; skip this Unix-specific barrier there so the + // cross-platform package tests exercise the publication logic correctly. + if runtime.GOOS == "windows" { + return nil + } + f, err := os.Open(dir) + if err != nil { + return err } defer f.Close() + return f.Sync() +} - if err := s.template().Execute(f, to); err != nil { - return fmt.Errorf("s.template.Execute: %w", err) +func createMerlinSharedHookStub(path string) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + // Build the complete executable stub privately first. Publishing only after + // chmod/write/fsync succeed means a short write or ENOSPC can never leave a + // partial shared hook at the public path. + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write([]byte("#!/bin/sh\n")); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err } - if err = os.Chmod(confPath, 0755); err != nil { - return fmt.Errorf("os.Chmod: startup script: %w", err) + // Publish with no-clobber semantics. If another addon creates or replaces + // the hook while we are preparing the stub, Link returns EEXIST and the + // caller revalidates that actor's file instead of truncating or deleting it. + if err := os.Link(tmpPath, path); err != nil { + return err + } + if err := os.Remove(tmpPath); err != nil { + return err + } + if err := syncMerlinServiceDir(dir); err != nil { + // The public stub may already be durable. Keep it in place so a retry can + // revalidate/reuse it; never roll back a shared pathname after publish. + return err + } + return nil +} +func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists bool, err error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + // sed -i may replace a symlink rather than edit its target. Shared Merlin + // hooks must therefore be real regular files before ctrld edits them. + if !info.Mode().IsRegular() { + return true, fmt.Errorf("shared Merlin hook is not a regular file: %s", path) + } + if requireExecutable && info.Mode().Perm()&0111 == 0 { + return true, fmt.Errorf("shared Merlin hook is not executable: %s", path) + } + return true, nil +} + +func (s *merlinSvc) Install() error { + return withMerlinServiceLock(s.installLocked) +} + +func (s *merlinSvc) installLocked() error { + exePath, err := os.Executable() + if err != nil { + return err + } + + if !strings.HasPrefix(exePath, "/jffs/") { + return errors.New("could not install service outside /jffs") + } + if _, err := nvram.Run("set", "jffs2_scripts=1"); err != nil { + return err + } + if _, err := nvram.Run("commit"); err != nil { + return err + } + + confPath := s.configPath() + + // Render both current and last-known legacy templates before touching the + // destination. An exact legacy match can be migrated safely during install + // or upgrade; any other differing file is treated as user-owned. + currentStartup, legacyStartup, err := s.renderStartupScripts(exePath) + if err != nil { + return err + } + startupExists, err := prepareExistingMerlinStartupScript(confPath, currentStartup, legacyStartup) + if err != nil { + return fmt.Errorf("prepare existing startup script: %w", err) + } + if !startupExists { + if _, err := writeMerlinStartupScript(confPath, currentStartup, 0755); err != nil { + // A successfully linked private startup script is intentionally kept + // even if a later durability/hook step fails. It does not register the + // service by itself, is safe to recognize on retry, and avoiding + // pathname-based rollback prevents deleting an external replacement. + return fmt.Errorf("publish startup script: %w", err) + } } if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { @@ -124,38 +415,115 @@ func (s *merlinSvc) Install() error { if err := tmpScript.Close(); err != nil { return fmt.Errorf("tmpScript.Close: %w", err) } - addLineToScript := func(line, script string) error { - if _, err := os.Stat(script); os.IsNotExist(err) { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return err - } + cleanupCreatedHook := func(line, script string) { + // Remove only ctrld's exact managed line first. If the file is still the + // pristine stub ctrld created, remove it; otherwise preserve any content + // another addon/user added concurrently. + _ = exec.Command("sh", tmpScript.Name(), line, script, "remove").Run() + buf, err := os.ReadFile(script) + if err == nil && bytes.Equal(buf, []byte("#!/bin/sh\n")) { + _ = os.Remove(script) } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + } + + addLineToScript := func(line, script string) (created, added bool, retErr error) { + for { + exists, err := validateMerlinSharedHookPath(script, true) + if err != nil { + return false, false, err + } + if exists { + break + } + + // Create a missing shared hook without following a raced symlink or + // truncating a file another addon created after our Lstat. + if err := createMerlinSharedHookStub(script); err != nil { + if os.IsExist(err) { + // Another actor won the race. Re-run Lstat validation against + // the now-existing path before deciding whether it is safe. + continue + } + return false, false, err + } + created = true + break } + defer func() { + if retErr != nil && created { + // On an editor failure ownership of any non-stub content is + // ambiguous. Remove only the untouched stub; otherwise leave the + // file for a safe resumable retry. + buf, err := os.ReadFile(script) + if err == nil && bytes.Equal(buf, []byte("#!/bin/sh\n")) { + _ = os.Remove(script) + } + } + }() - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { - return fmt.Errorf("exec.Command: add startup script: %w", err) + // A pre-existing shared hook owns its mode. Do not chmod it as a side + // effect of installing ctrld. The editor reports whether this invocation + // actually appended the line so rollback never removes a pre-existing + // ctrld hook line. + out, err := exec.Command("sh", tmpScript.Name(), line, script, "add").CombinedOutput() + if err != nil { + return created, false, fmt.Errorf("exec.Command: add startup script: %s: %w", strings.TrimSpace(string(out)), err) + } + switch strings.TrimSpace(string(out)) { + case "": + return created, false, nil + case "added": + return created, true, nil + default: + return created, false, fmt.Errorf("unexpected hook editor output: %q", strings.TrimSpace(string(out))) } - return nil } - for script, line := range map[string]string{ - merlinJFFSScriptPath: s.configPath() + " start", - merlinJFFSServiceEventScriptPath: s.configPath() + ` service_event "$1" "$2"`, - } { - if err := addLineToScript(line, script); err != nil { + type hookLine struct { + script string + line string + created bool + added bool + } + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) + hooks := []hookLine{ + {script: merlinJFFSScriptPath, line: startLine}, + {script: merlinJFFSServiceEventScriptPath, line: serviceEventLine}, + } + installed := make([]hookLine, 0, len(hooks)) + for _, hook := range hooks { + created, added, err := addLineToScript(hook.line, hook.script) + if err != nil { + // Best-effort rollback: remove only lines successfully installed by + // this attempt. Shared hook files created by ctrld are removed again; + // pre-existing hooks keep all unrelated content. + for i := len(installed) - 1; i >= 0; i-- { + prev := installed[i] + if prev.created { + if prev.added { + cleanupCreatedHook(prev.line, prev.script) + } + continue + } + if prev.added { + _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() + } + } return err } + hook.created = created + hook.added = added + installed = append(installed, hook) } return nil } func (s *merlinSvc) Uninstall() error { - if err := os.Remove(s.configPath()); err != nil { - return fmt.Errorf("os.Remove: %w", err) - } + return withMerlinServiceLock(s.uninstallLocked) +} + +func (s *merlinSvc) uninstallLocked() error { tmpScript, err := os.CreateTemp("", "ctrld_uninstall") if err != nil { return fmt.Errorf("os.CreateTemp: %w", err) @@ -170,30 +538,43 @@ func (s *merlinSvc) Uninstall() error { return fmt.Errorf("tmpScript.Close: %w", err) } removeLineFromScript := func(line, script string) error { - if _, err := os.Stat(script); os.IsNotExist(err) { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return err - } + exists, err := validateMerlinSharedHookPath(script, false) + if err != nil { + return err } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + if !exists { + // Shared Merlin hooks belong to the router/user. Uninstalling ctrld + // must not create a hook file that did not exist. + return nil } - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { - return fmt.Errorf("exec.Command: add startup script: %w", err) + if err := exec.Command("sh", tmpScript.Name(), line, script, "remove").Run(); err != nil { + return fmt.Errorf("exec.Command: remove startup script: %w", err) } return nil } - for script, line := range map[string]string{ - merlinJFFSScriptPath: s.configPath() + " start", - merlinJFFSServiceEventScriptPath: s.configPath() + ` service_event "$1" "$2"`, + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) + legacyStartLine, legacyServiceEventLine := merlinLegacyStartupHookLines(s.configPath()) + for _, hook := range []struct { + script string + lines []string + }{ + {merlinJFFSScriptPath, []string{startLine, legacyStartLine}}, + {merlinJFFSServiceEventScriptPath, []string{serviceEventLine, legacyServiceEventLine}}, } { - if err := removeLineFromScript(line, script); err != nil { - return err + for _, line := range hook.lines { + if err := removeLineFromScript(line, hook.script); err != nil { + return err + } } } + // Remove ctrld's private startup script only after all shared hook + // references have been removed successfully. + if err := os.Remove(s.configPath()); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("os.Remove: %w", err) + } return nil } @@ -228,36 +609,104 @@ func (s *merlinSvc) Run() (err error) { func (s *merlinSvc) Status() (service.Status, error) { if _, err := os.Stat(s.configPath()); os.IsNotExist(err) { return service.StatusUnknown, service.ErrNotInstalled - } - out, err := exec.Command(s.configPath(), "status").CombinedOutput() - if err != nil { + } else if err != nil { return service.StatusUnknown, err } + out, err := exec.Command(s.configPath(), "status").CombinedOutput() + return merlinServiceStatus(out, err) +} + +func merlinServiceStatus(out []byte, cmdErr error) (service.Status, error) { switch string(bytes.TrimSpace(out)) { case "running": + if cmdErr != nil { + return service.StatusUnknown, cmdErr + } return service.StatusRunning, nil - default: + case "stopped": + // The generated BusyBox script intentionally exits 1 for "stopped". + // That is a state, not a failure to determine the state. return service.StatusStopped, nil + default: + if cmdErr != nil { + return service.StatusUnknown, cmdErr + } + return service.StatusUnknown, fmt.Errorf("unexpected Merlin service status output: %q", bytes.TrimSpace(out)) } } func (s *merlinSvc) Start() error { + return withMerlinServiceLock(s.startLocked) +} + +func (s *merlinSvc) startLocked() error { + // Upgrades replace only the binary before restarting the service. Refresh + // an exact ctrld-owned legacy startup script here as a secondary path; the + // newly launched binary also performs this migration from ctrld run. + if err := s.refreshMerlinStartupScript(); err != nil { + var migrationErr *merlinStartupMigrationError + if !errors.As(err, &migrationErr) { + return err + } + + // Migration can fail before publication because JFFS is full/read-only or + // reports an I/O error. The exact legacy script is intentionally left + // intact in that case. Re-read it before fallback; if it is still the + // recognized legacy bytes (or the migration actually published the + // current marked script before a durability error), start it rather than + // leaving DNS down. Anything else remains a hard error. + canStart, readErr := canStartMerlinStartupAfterMigrationError(s.configPath(), migrationErr.legacy) + if readErr != nil { + return errors.Join(err, readErr) + } + if !canStart { + return err + } + } return exec.Command(s.configPath(), "start").Run() } +func canStartMerlinStartupAfterMigrationError(path string, legacy []byte) (bool, error) { + installed, exists, err := readExistingMerlinStartupScript(path) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + return bytes.Equal(installed, legacy) || merlinStartupScriptHasMarker(installed), nil +} + func (s *merlinSvc) Stop() error { + return withMerlinServiceLock(s.stopLocked) +} + +func (s *merlinSvc) stopLocked() error { return exec.Command(s.configPath(), "stop").Run() } func (s *merlinSvc) Restart() error { - err := s.Stop() - if err != nil { - return err - } - return s.Start() + return s.RestartWith(nil) } -const merlinSvcScript = `#!/bin/sh +// RestartWith serializes the complete Merlin stop/between/start transaction. +// The callback lets the CLI keep router cleanup, DNS restoration and process +// wait semantics inside the same cross-process lifecycle lock. +func (s *merlinSvc) RestartWith(between func() error) error { + return withMerlinServiceLock(func() error { + if err := s.stopLocked(); err != nil { + return err + } + if between != nil { + if err := between(); err != nil { + return err + } + } + return s.startLocked() + }) +} + +const merlinLegacySvcScript = `#!/bin/sh name="{{.Name}}" cmd="{{.Path}}{{range .Arguments}} {{.}}{{end}}" @@ -339,14 +788,150 @@ esac exit 0 ` +const merlinSvcScript = `#!/bin/sh +# GENERATED BY ctrld - Merlin service v2 + +name={{shellQuote .Name}} +exe={{shellQuote .Path}} +pid_file="/tmp/$name.pid" + +get_pid() { + [ -r "$pid_file" ] || return 1 + pid="$(cat "$pid_file" 2>/dev/null)" || return 1 + case "$pid" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$pid" +} + +is_running_pid() { + pid=$1 + [ -n "$pid" ] || return 1 + [ -r "/proc/$pid/cmdline" ] || return 1 + process_cmd="$(tr '\000' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" || return 1 + case "$process_cmd" in + "$exe"|"$exe "*) return 0 ;; + *) return 1 ;; + esac +} + +is_running() { + pid="$(get_pid)" || return 1 + is_running_pid "$pid" +} +case "$1" in + start) + if is_running; then + logger -c "Already started" + else + rm -f "$pid_file" + logger -c "Starting $name" + if [ -f /rom/ca-bundle.crt ]; then + # For John’s fork + export SSL_CERT_FILE=/rom/ca-bundle.crt + fi + {{shellQuote .Path}}{{range .Arguments}} {{shellQuote .}}{{end}} & + echo $! > "$pid_file" + chmod 600 "$pid_file" + started=0 + for _ in 1 2 3 4 5; do + if is_running; then + started=1 + break + fi + sleep 1 + done + if [ "$started" -ne 1 ]; then + logger -c "Failed to start $name" + rm -f "$pid_file" + exit 1 + fi + fi + ;; + stop) + pid="$(get_pid)" || { + rm -f "$pid_file" + exit 0 + } + if is_running_pid "$pid"; then + logger -c "Stopping $name..." + kill "$pid" + for _ in 1 2 3 4 5; do + if ! is_running_pid "$pid"; then + logger -c "stopped" + if [ -f "$pid_file" ]; then + rm "$pid_file" + fi + exit 0 + fi + printf "." + sleep 2 + done + logger -c "failed to stop $name" + exit 1 + fi + rm -f "$pid_file" + exit 0 + ;; + restart) + "$0" stop || exit $? + "$0" start + ;; + status) + if is_running; then + echo "running" + else + echo "stopped" + exit 1 + fi + ;; + service_event) + event=$2 + svc=$3 + dnsmasq_pid_file=$(sed -n '/pid-file=/s///p' /etc/dnsmasq.conf) + + if [ "$event" = "restart" ] && [ "$svc" = "diskmon" ] && [ -r "$dnsmasq_pid_file" ]; then + dnsmasq_pid="$(cat "$dnsmasq_pid_file" 2>/dev/null)" + case "$dnsmasq_pid" in + ''|*[!0-9]*) dnsmasq_pid="" ;; + esac + if [ -n "$dnsmasq_pid" ] && [ -r "/proc/$dnsmasq_pid/cmdline" ]; then + dnsmasq_exe="$(tr '\000' '\n' < "/proc/$dnsmasq_pid/cmdline" 2>/dev/null | sed -n '1p')" + case "$dnsmasq_exe" in + dnsmasq|*/dnsmasq) kill "$dnsmasq_pid" >/dev/null 2>&1 ;; + esac + fi + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +exit 0 +` + const merlinAddLineToScript = `#!/bin/sh line=$1 file=$2 +mode=$3 . /usr/sbin/helper.sh -pc_append "$line" "$file" +if [ "$mode" = "remove" ]; then + pattern=$(_quote "$line") + sed -i "/^$pattern$/d" "$file" +else + if grep -qxF "$line" "$file"; then + exit 0 + else + grep_status=$? + [ "$grep_status" -eq 1 ] || exit "$grep_status" + fi + pc_append "$line" "$file" || exit $? + printf 'added\n' +fi ` const merlinRemoveLineFromScript = `#!/bin/sh @@ -356,5 +941,6 @@ file=$2 . /usr/sbin/helper.sh -pc_delete "$line" "$file" +pattern=$(_quote "$line") +sed -i "/^$pattern$/d" "$file" ` diff --git a/internal/router/service_merlin_lock_linux.go b/internal/router/service_merlin_lock_linux.go new file mode 100644 index 00000000..5641abb0 --- /dev/null +++ b/internal/router/service_merlin_lock_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package router + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +const merlinServiceLockPath = "/tmp/ctrld-merlin-service.lock" + +func withMerlinServiceLock(fn func() error) error { + fd, err := unix.Open( + merlinServiceLockPath, + unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0600, + ) + if err != nil { + return fmt.Errorf("open Merlin service lifecycle lock: %w", err) + } + f := os.NewFile(uintptr(fd), merlinServiceLockPath) + if f == nil { + _ = unix.Close(fd) + return fmt.Errorf("wrap Merlin service lifecycle lock descriptor") + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat Merlin service lifecycle lock: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("merlin service lifecycle lock is not a regular file: %s", merlinServiceLockPath) + } + if err := f.Chmod(0600); err != nil { + return fmt.Errorf("chmod Merlin service lifecycle lock: %w", err) + } + + if err := unix.Flock(fd, unix.LOCK_EX); err != nil { + return fmt.Errorf("lock Merlin service lifecycle: %w", err) + } + defer unix.Flock(fd, unix.LOCK_UN) + + return fn() +} diff --git a/internal/router/service_merlin_lock_other.go b/internal/router/service_merlin_lock_other.go new file mode 100644 index 00000000..6d8cdc1c --- /dev/null +++ b/internal/router/service_merlin_lock_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package router + +func withMerlinServiceLock(fn func() error) error { + return fn() +} diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go new file mode 100644 index 00000000..0f5c7609 --- /dev/null +++ b/internal/router/service_merlin_startup_linux.go @@ -0,0 +1,226 @@ +//go:build linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func readExistingMerlinStartupScript(path string) ([]byte, bool, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if err == unix.ENOENT { + return nil, false, nil + } + if err == unix.ELOOP { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + return nil, false, err + } + f := os.NewFile(uintptr(fd), path) + if f == nil { + _ = unix.Close(fd) + return nil, true, fmt.Errorf("could not wrap startup script descriptor: %s", path) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, true, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + buf, err := io.ReadAll(f) + if err != nil { + return nil, true, err + } + return buf, true, nil +} + +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { + fd, err := unix.Open(path, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if err == unix.ENOENT { + return false, nil + } + if err == unix.ELOOP { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + return false, err + } + + f := os.NewFile(uintptr(fd), path) + if f == nil { + _ = unix.Close(fd) + return true, fmt.Errorf("could not wrap startup script descriptor: %s", path) + } + defer func() { + if err := f.Close(); retErr == nil && err != nil { + retErr = err + } + }() + + info, err := f.Stat() + if err != nil { + return true, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + + got, err := io.ReadAll(f) + if err != nil { + return true, err + } + needsMigration := bytes.Equal(got, legacy) && !bytes.Equal(got, expected) + if !bytes.Equal(got, expected) && !needsMigration { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + + if needsMigration { + // Build the replacement completely in the same directory and publish it + // with rename(2). The legacy inode is never truncated, so ENOSPC/EIO while + // writing the new script leaves the previously working service intact. + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { + return true, err + } + return true, nil + } + + // Current script: mode/content validation stays descriptor-pinned. + if err := f.Chmod(0755); err != nil { + return true, err + } + if err := f.Sync(); err != nil { + return true, err + } + pathInfo, err := os.Lstat(path) + if err != nil { + return true, err + } + if !os.SameFile(info, pathInfo) { + return true, fmt.Errorf("startup script changed during preparation: %s", path) + } + return true, nil +} + +func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, originalInfo os.FileInfo) (retErr error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-migrate-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + removeTmp := true + defer func() { + _ = tmp.Close() + if removeTmp { + _ = os.Remove(tmpPath) + } + }() + + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write(expected); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + preparedInfo, err := tmp.Stat() + if err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + + // Atomically exchange the prepared replacement with whatever currently + // occupies path. This avoids the TOCTOU window of "Lstat then Rename": after + // the exchange, tmpPath names the exact previous target and can be validated + // by inode and bytes. If another actor changed/replaced the legacy script, + // swap the files back and leave their content untouched. + if err := unix.Renameat2( + unix.AT_FDCWD, tmpPath, + unix.AT_FDCWD, path, + unix.RENAME_EXCHANGE, + ); err != nil { + return fmt.Errorf("atomic startup-script exchange: %w", err) + } + + rollbackExchange := func(cause error) error { + // Do not exchange back by pathname unless path still names the exact + // prepared inode and bytes that this invocation installed. An external + // actor may have replaced or edited path after our first exchange; blindly + // exchanging in that case would overwrite their change with the legacy + // file. If ownership is no longer provable, preserve the captured legacy + // inode at tmpPath as a quarantine artifact instead. + pathInfo, statErr := os.Lstat(path) + if statErr != nil || !pathInfo.Mode().IsRegular() || !os.SameFile(preparedInfo, pathInfo) { + removeTmp = false + _ = syncMerlinServiceDir(dir) + if statErr != nil { + return fmt.Errorf("%v; cannot safely restore startup script after exchange: %w; legacy preserved at %s", cause, statErr, tmpPath) + } + return fmt.Errorf("%v; startup script changed after exchange; legacy preserved at %s", cause, tmpPath) + } + pathBytes, exists, readErr := readExistingMerlinStartupScript(path) + if readErr != nil || !exists || !bytes.Equal(pathBytes, expected) { + removeTmp = false + _ = syncMerlinServiceDir(dir) + if readErr != nil { + return fmt.Errorf("%v; cannot verify replacement before rollback: %w; legacy preserved at %s", cause, readErr, tmpPath) + } + return fmt.Errorf("%v; replacement changed after exchange; legacy preserved at %s", cause, tmpPath) + } + + if err := unix.Renameat2( + unix.AT_FDCWD, tmpPath, + unix.AT_FDCWD, path, + unix.RENAME_EXCHANGE, + ); err != nil { + removeTmp = false + _ = syncMerlinServiceDir(dir) + return fmt.Errorf("%v; failed to restore startup script after exchange: %w; legacy preserved at %s", cause, err, tmpPath) + } + if err := syncMerlinServiceDir(dir); err != nil { + return fmt.Errorf("%v; startup script restored but directory sync failed: %w", cause, err) + } + return cause + } + + oldInfo, err := os.Lstat(tmpPath) + if err != nil { + return rollbackExchange(err) + } + if !oldInfo.Mode().IsRegular() || !os.SameFile(originalInfo, oldInfo) { + return rollbackExchange(fmt.Errorf("startup script changed before migration publish: %s", path)) + } + + oldBytes, exists, err := readExistingMerlinStartupScript(tmpPath) + if err != nil { + return rollbackExchange(err) + } + if !exists || !bytes.Equal(oldBytes, legacy) { + return rollbackExchange(fmt.Errorf("startup script contents changed before migration publish: %s", path)) + } + + // The exchange is now proven to have replaced exactly the ctrld-owned + // legacy inode/bytes. Remove the old hard target and make the directory + // updates durable. + if err := os.Remove(tmpPath); err != nil { + return err + } + if err := syncMerlinServiceDir(dir); err != nil { + return err + } + return nil +} + diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go new file mode 100644 index 00000000..b810abfa --- /dev/null +++ b/internal/router/service_merlin_startup_other.go @@ -0,0 +1,119 @@ +//go:build !linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" +) + +func readExistingMerlinStartupScript(path string) ([]byte, bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + buf, err := os.ReadFile(path) + if err != nil { + return nil, true, err + } + return buf, true, nil +} + +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return true, err + } + defer func() { + if err := f.Close(); retErr == nil && err != nil { + retErr = err + } + }() + + info, err = f.Stat() + if err != nil { + return true, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + got, err := io.ReadAll(f) + if err != nil { + return true, err + } + needsMigration := bytes.Equal(got, legacy) && !bytes.Equal(got, expected) + if !bytes.Equal(got, expected) && !needsMigration { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + if needsMigration { + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { + return true, err + } + return true, nil + } + if err := f.Chmod(0755); err != nil { + return true, err + } + return true, nil +} + +func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, originalInfo os.FileInfo) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-migrate-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write(expected); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + pathInfo, err := os.Lstat(path) + if err != nil { + return err + } + if !os.SameFile(originalInfo, pathInfo) { + return fmt.Errorf("startup script changed before migration publish: %s", path) + } + current, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.Equal(current, legacy) { + return fmt.Errorf("startup script contents changed before migration publish: %s", path) + } + return os.Rename(tmpPath, path) +} diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go new file mode 100644 index 00000000..545b3d0b --- /dev/null +++ b/internal/router/service_merlin_test.go @@ -0,0 +1,584 @@ +package router + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kardianos/service" +) + +func TestMerlinServiceScriptValidatesPidOwnership(t *testing.T) { + if strings.Contains(merlinSvcScript, "ps | grep") { + t.Fatal("Merlin service status must not trust PID existence alone") + } + for _, want := range []string{ + `case "$pid" in`, + `[ -r "/proc/$pid/cmdline" ]`, + `tr '\000' ' '`, + `"$exe"|"$exe "*`, + `is_running_pid "$pid"`, + `kill "$pid"`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service script missing PID ownership check %q", want) + } + } + if strings.Contains(merlinSvcScript, `kill "$(get_pid)"`) { + t.Fatal("stop must kill the same PID whose ctrld ownership was validated") + } +} + +func TestMerlinServiceRestartStopsBeforeStarting(t *testing.T) { + if !strings.Contains(merlinSvcScript, `"$0" stop || exit $?`) { + t.Fatal("restart must not start a second instance when stop fails") + } +} + +func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { + for _, want := range []string{ + `if grep -qxF "$line" "$file"; then`, + `grep_status=$?`, + `[ "$grep_status" -eq 1 ] || exit "$grep_status"`, + `pc_append "$line" "$file" || exit $?`, + `printf 'added\n'`, + } { + if !strings.Contains(merlinAddLineToScript, want) { + t.Fatalf("hook editor missing idempotent append signal %q", want) + } + } + for _, script := range []string{merlinAddLineToScript, merlinRemoveLineFromScript} { + if !strings.Contains(script, `sed -i "/^$pattern$/d" "$file"`) { + t.Fatal("hook removal must match only ctrld's complete line") + } + } + if strings.Contains(merlinAddLineToScript, `pc_delete "$line" "$file"`) { + t.Fatal("install path must not destructively delete a working hook before append") + } +} + +func TestMerlinServiceTemplateRendersExecutableIdentity(t *testing.T) { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--cd", "example"}, + }, + } + var buf bytes.Buffer + if err := s.template().Execute(&buf, struct { + *service.Config + Path string + }{s.Config, s.Config.Executable}); err != nil { + t.Fatal(err) + } + got := buf.String() + for _, want := range []string{ + `exe='/jffs/controld/ctrld'`, + `'/jffs/controld/ctrld' 'run' '--cd' 'example' &`, + } { + if !strings.Contains(got, want) { + t.Fatalf("rendered Merlin startup script missing %q", want) + } + } +} + +func TestMerlinServiceTemplatePreservesArgumentBoundaries(t *testing.T) { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--log", "/jffs/log dir/it's.log"}, + }, + } + var buf bytes.Buffer + if err := s.template().Execute(&buf, struct { + *service.Config + Path string + }{s.Config, s.Config.Executable}); err != nil { + t.Fatal(err) + } + want := `'/jffs/controld/ctrld' 'run' '--log' '/jffs/log dir/it'"'"'s.log' &` + if !strings.Contains(buf.String(), want) { + t.Fatalf("rendered command lost shell argument boundaries:\nwant substring: %q\ngot:\n%s", want, buf.String()) + } +} + + +func TestMerlinServiceStartWaitsForChildExec(t *testing.T) { + for _, want := range []string{ + "for _ in 1 2 3 4 5; do", + "if is_running; then", + "sleep 1", + `[ "$started" -ne 1 ]`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service start is missing exec-wait safeguard %q", want) + } + } +} + + +func TestMerlinServiceStatusTreatsStoppedAsState(t *testing.T) { + status, err := merlinServiceStatus([]byte("stopped\n"), os.ErrProcessDone) + if err != nil { + t.Fatalf("stopped status returned error: %v", err) + } + if status != service.StatusStopped { + t.Fatalf("status = %v, want %v", status, service.StatusStopped) + } +} + +func TestMerlinServiceStatusRejectsUnexpectedOutput(t *testing.T) { + if _, err := merlinServiceStatus([]byte("mystery\n"), nil); err == nil { + t.Fatal("expected unexpected status output to return an error") + } +} + + +func TestWriteMerlinStartupScriptPublishesAndPreservesMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filesystems do not expose POSIX executable mode semantics") + } + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + want := []byte("#!/bin/sh\necho ok\n") + + published, err := writeMerlinStartupScript(path, want, 0755) + if err != nil { + t.Fatal(err) + } + if !published { + t.Fatal("expected startup script to be published") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("startup script contents mismatch: got %q want %q", got, want) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0755 { + t.Fatalf("startup script mode = %o, want 755", fi.Mode().Perm()) + } +} + +func TestWriteMerlinStartupScriptDoesNotClobberExistingTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + original := []byte("user-owned\n") + if err := os.WriteFile(path, original, 0700); err != nil { + t.Fatal(err) + } + + published, err := writeMerlinStartupScript(path, []byte("ctrld-owned\n"), 0755) + if err == nil { + t.Fatal("expected publication to fail for existing target") + } + if published { + t.Fatal("existing target must not be reported as ctrld-published") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("existing target was modified: got %q want %q", got, original) + } +} + + +func TestMerlinServiceEventValidatesDnsmasqPidOwnership(t *testing.T) { + for _, want := range []string{ + `case "$dnsmasq_pid" in`, + `[ -r "/proc/$dnsmasq_pid/cmdline" ]`, + `tr '\000' '\n'`, + `dnsmasq|*/dnsmasq) kill "$dnsmasq_pid"`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service_event missing dnsmasq PID ownership check %q", want) + } + } +} + + +func TestValidateMerlinSharedHookPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin shared hooks are POSIX shell files and executable-bit checks are Unix-specific") + } + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + + exists, err := validateMerlinSharedHookPath(path, true) + if err != nil || exists { + t.Fatalf("missing hook = (%v, %v), want (false, nil)", exists, err) + } + + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := validateMerlinSharedHookPath(path, true); err == nil { + t.Fatal("expected non-executable shared hook to be rejected") + } + if exists, err := validateMerlinSharedHookPath(path, false); err != nil || !exists { + t.Fatalf("regular non-executable hook should be editable for uninstall: (%v, %v)", exists, err) + } + + if err := os.Chmod(path, 0755); err != nil { + t.Fatal(err) + } + if exists, err := validateMerlinSharedHookPath(path, true); err != nil || !exists { + t.Fatalf("executable shared hook rejected: (%v, %v)", exists, err) + } + + link := filepath.Join(dir, "services-start-link") + if err := os.Symlink(path, link); err == nil { + if _, err := validateMerlinSharedHookPath(link, false); err == nil { + t.Fatal("expected shared-hook symlink to be rejected") + } + } +} + + +func TestMerlinStartupHookLinesQuoteConfigPath(t *testing.T) { + start, event := merlinStartupHookLines("/jffs/my dir/it's ctrld.startup") + wantPrefix := `'/jffs/my dir/it'"'"'s ctrld.startup'` + if start != wantPrefix+" start" { + t.Fatalf("start hook = %q", start) + } + if event != wantPrefix+` service_event "$1" "$2"` { + t.Fatalf("service-event hook = %q", event) + } +} + + +func TestMerlinLegacyStartupHookLinesRemainRemovable(t *testing.T) { + path := "/jffs/controld/ctrld.startup" + start, event := merlinLegacyStartupHookLines(path) + if start != path+" start" { + t.Fatalf("legacy start hook = %q", start) + } + if event != path+` service_event "$1" "$2"` { + t.Fatalf("legacy service-event hook = %q", event) + } +} + + +func TestPrepareExistingMerlinStartupScriptRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require elevated privileges on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "ctrld.startup") + if err := os.WriteFile(target, []byte("private\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if exists, err := prepareExistingMerlinStartupScript(link, []byte("private\n"), nil); err == nil || !exists { + t.Fatalf("symlink startup script = (exists %v, err %v), want exists=true and error", exists, err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "private\n" { + t.Fatalf("symlink target was modified: %q", got) + } +} + +func TestPrepareExistingMerlinStartupScriptRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + want := []byte("#!/bin/sh\n") + if err := os.WriteFile(path, want, 0600); err != nil { + t.Fatal(err) + } + exists, err := prepareExistingMerlinStartupScript(path, want, want) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("regular startup script reported missing") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("regular startup script = %q, want %q", got, want) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if gotMode := info.Mode().Perm(); gotMode != 0755 { + t.Fatalf("regular startup script mode = %o, want 755", gotMode) + } + } +} + +func TestPrepareExistingMerlinStartupScriptMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + exists, err := prepareExistingMerlinStartupScript(path, []byte("#!/bin/sh\n"), nil) + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("missing startup script reported as existing") + } +} + + +func TestPrepareExistingMerlinStartupScriptMigratesExactLegacyTemplate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--cd", "example"}, + }, + } + current, legacy, err := s.renderStartupScripts(s.Config.Executable) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(current, legacy) { + t.Fatal("current and legacy templates unexpectedly match") + } + + path := filepath.Join(t.TempDir(), "ctrld.startup") + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + exists, err := prepareExistingMerlinStartupScript(path, current, legacy) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("legacy startup script reported missing") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, current) { + t.Fatalf("legacy startup script was not migrated exactly") + } +} + +func TestPrepareExistingMerlinStartupScriptRejectsUnknownCustomization(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + custom := []byte("#!/bin/sh\necho user-custom\n") + if err := os.WriteFile(path, custom, 0755); err != nil { + t.Fatal(err) + } + if exists, err := prepareExistingMerlinStartupScript( + path, + []byte("#!/bin/sh\necho current\n"), + []byte("#!/bin/sh\necho legacy\n"), + ); err == nil || !exists { + t.Fatalf("custom startup script = (exists %v, err %v), want exists=true and error", exists, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, custom) { + t.Fatalf("custom startup script was modified: got %q want %q", got, custom) + } +} + + +func TestRefreshMerlinStartupScriptRecoversInstalledLegacyArguments(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + + installedSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + Arguments: []string{"run", "--cd=abcd1234", "--config=/jffs/controld/ctrld.toml"}, + }} + _, legacy, err := installedSvc.renderStartupScripts(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe+".startup", legacy, 0755); err != nil { + t.Fatal(err) + } + + // Normal restart/upgrade service construction does not carry the original + // installed Arguments. + lifecycleSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + }} + if err := lifecycleSvc.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(exe + ".startup") + if err != nil { + t.Fatal(err) + } + if !merlinStartupScriptHasMarker(got) { + t.Fatal("migrated startup script is missing the current ctrld marker") + } + for _, want := range []string{ + merlinShellQuote("--cd=abcd1234"), + merlinShellQuote("--config=/jffs/controld/ctrld.toml"), + } { + if !bytes.Contains(got, []byte(want)) { + t.Fatalf("migrated startup script lost installed argument %q", want) + } + } +} + +func TestRefreshMerlinStartupScriptForExecutableMigratesFromNewBinaryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + installedSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + Arguments: []string{"run", "--cd=upgrade-device", "--config=/jffs/controld/ctrld.toml"}, + }} + current, legacy, err := installedSvc.renderStartupScripts(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe+".startup", legacy, 0755); err != nil { + t.Fatal(err) + } + + // This models the first process started from the just-downloaded binary: + // it has only its executable path, not the old process's service.Config. + if err := refreshMerlinStartupScriptForExecutable(exe); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(exe + ".startup") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, current) { + t.Fatalf("new-binary migration mismatch\nwant:\n%s\ngot:\n%s", current, got) + } +} + +func TestRefreshMerlinStartupScriptLeavesCurrentMarkedScriptUntouched(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + path := exe + ".startup" + original := []byte("#!/bin/sh\n" + merlinSvcScriptMarker + "\necho custom-current-body\n") + if err := os.WriteFile(path, original, 0700); err != nil { + t.Fatal(err) + } + + s := &merlinSvc{Config: &service.Config{Name: "ctrld", Executable: exe}} + if err := s.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("current marked script was rewritten:\nwant: %q\ngot: %q", original, got) + } +} + +func TestRefreshMerlinStartupScriptLeavesUnknownCustomizationUntouched(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + path := exe + ".startup" + original := []byte("#!/bin/sh\necho user-custom-startup\n") + if err := os.WriteFile(path, original, 0755); err != nil { + t.Fatal(err) + } + + s := &merlinSvc{Config: &service.Config{Name: "ctrld", Executable: exe}} + if err := s.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("unknown customized script was modified:\nwant: %q\ngot: %q", original, got) + } +} + +func TestRecoverLegacyMerlinServiceConfigRejectsWrongExecutable(t *testing.T) { + base := &service.Config{Name: "ctrld", Executable: "/jffs/controld/ctrld"} + buf := []byte("#!/bin/sh\ncmd=\"/tmp/other run --cd=abc\"\n") + if _, ok := recoverLegacyMerlinServiceConfig(buf, base, base.Executable); ok { + t.Fatal("legacy config recovery accepted a different executable") + } +} + + +func TestCanStartMerlinStartupAfterMigrationError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + + tests := []struct { + name string + body []byte + want bool + }{ + {"exact legacy", legacy, true}, + {"current marked", []byte("#!/bin/sh\n" + merlinSvcScriptMarker + "\necho current\n"), true}, + {"custom", []byte("#!/bin/sh\necho custom\n"), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(path, tc.body, 0755); err != nil { + t.Fatal(err) + } + got, err := canStartMerlinStartupAfterMigrationError(path, legacy) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("canStart = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCanStartMerlinStartupAfterMigrationErrorMissingPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + got, err := canStartMerlinStartupAfterMigrationError(filepath.Join(t.TempDir(), "missing.startup"), []byte("legacy")) + if err != nil { + t.Fatal(err) + } + if got { + t.Fatal("missing startup path must not be considered safe to start") + } +} diff --git a/internal/router/service_merlin_unix_test.go b/internal/router/service_merlin_unix_test.go new file mode 100644 index 00000000..c0cb1826 --- /dev/null +++ b/internal/router/service_merlin_unix_test.go @@ -0,0 +1,126 @@ +//go:build linux + +package router + +import ( + "bytes" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCreateMerlinSharedHookStubIgnoresRestrictiveUmask(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + + oldUmask := syscall.Umask(0777) + defer syscall.Umask(oldUmask) + + if err := createMerlinSharedHookStub(path); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0755 { + t.Fatalf("shared hook mode = %o, want 755", got) + } + + buf, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(buf) != "#!/bin/sh\n" { + t.Fatalf("shared hook contents = %q", buf) + } +} + + +func TestCreateMerlinSharedHookStubDoesNotClobberExistingTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + want := []byte("#!/bin/sh\necho addon\n") + if err := os.WriteFile(path, want, 0755); err != nil { + t.Fatal(err) + } + + if err := createMerlinSharedHookStub(path); !os.IsExist(err) { + t.Fatalf("createMerlinSharedHookStub error = %v, want already-exists", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("existing shared hook was modified:\nwant %q\ngot %q", want, got) + } +} + +func TestReplaceMerlinStartupScriptAtomicallyPreservesReplacedTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + current := []byte("#!/bin/sh\necho current\n") + replacement := []byte("#!/bin/sh\necho user replacement\n") + + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + replacementPath := filepath.Join(dir, "replacement") + if err := os.WriteFile(replacementPath, replacement, 0755); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacementPath, path); err != nil { + t.Fatal(err) + } + + if err := replaceMerlinStartupScriptAtomically(path, current, legacy, originalInfo); err == nil { + t.Fatal("expected concurrent inode replacement to abort migration") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(replacement) { + t.Fatalf("replacement target changed: got %q want %q", got, replacement) + } +} + +func TestReplaceMerlinStartupScriptAtomicallyPreservesInPlaceEdit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + current := []byte("#!/bin/sh\necho current\n") + edited := []byte("#!/bin/sh\necho edited in place\n") + + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, edited, 0755); err != nil { + t.Fatal(err) + } + + if err := replaceMerlinStartupScriptAtomically(path, current, legacy, originalInfo); err == nil { + t.Fatal("expected concurrent in-place edit to abort migration") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(edited) { + t.Fatalf("in-place edit changed: got %q want %q", got, edited) + } +}