From 1ec4877775a59080d7e3455b119d88c679aafdc3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:41:03 +0100 Subject: [PATCH 01/15] Port Merlin service lifecycle fixes to v1.x --- internal/router/service_merlin.go | 276 ++++++++++++++++++++++++------ 1 file changed, 221 insertions(+), 55 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8ab6d6a7..af936987 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -61,7 +61,63 @@ 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 merlinShellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +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 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 { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() } func (s *merlinSvc) Install() error { @@ -81,9 +137,6 @@ func (s *merlinSvc) Install() error { } confPath := s.configPath() - if _, err := os.Stat(confPath); err == nil { - return fmt.Errorf("already installed: %s", confPath) - } var to = &struct { *service.Config @@ -93,20 +146,46 @@ func (s *merlinSvc) Install() error { exePath, } - f, err := os.Create(confPath) - if err != nil { - return fmt.Errorf("os.Create: %w", err) - } - defer f.Close() - - if err := s.template().Execute(f, to); err != nil { + // Render completely before touching the destination. A template error must + // not leave a truncated startup script which then looks "already installed". + var rendered bytes.Buffer + if err := s.template().Execute(&rendered, to); err != nil { return fmt.Errorf("s.template.Execute: %w", err) } - - if err = os.Chmod(confPath, 0755); err != nil { - return fmt.Errorf("os.Chmod: startup script: %w", err) + startupPublished := false + existing, err := os.ReadFile(confPath) + switch { + case err == nil: + if !bytes.Equal(existing, rendered.Bytes()) { + return fmt.Errorf("already installed with different startup script: %s", confPath) + } + // An interrupted previous install may have published the private startup + // script before adding both shared hooks. Identical bytes prove that this + // install can safely resume instead of getting stuck on "already installed". + if err := os.Chmod(confPath, 0755); err != nil { + return fmt.Errorf("os.Chmod: startup script: %w", err) + } + case os.IsNotExist(err): + startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) + if err != nil { + if startupPublished { + _ = os.Remove(confPath) + _ = syncMerlinServiceDir(filepath.Dir(confPath)) + } + return fmt.Errorf("publish startup script: %w", err) + } + default: + return fmt.Errorf("read startup script: %w", err) } + installComplete := false + defer func() { + if !installComplete && startupPublished { + _ = os.Remove(confPath) + _ = syncMerlinServiceDir(filepath.Dir(confPath)) + } + }() + if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { return fmt.Errorf("os.MkdirAll: %w", err) } @@ -124,38 +203,75 @@ func (s *merlinSvc) Install() error { if err := tmpScript.Close(); err != nil { return fmt.Errorf("tmpScript.Close: %w", err) } - addLineToScript := func(line, script string) error { + 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) + } + } + + addLineToScript := func(line, script string) (created bool, retErr error) { if _, err := os.Stat(script); os.IsNotExist(err) { if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return err + return false, err } + created = true + } else if err != nil { + return false, err } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) - } + defer func() { + if retErr != nil && created { + cleanupCreatedHook(line, 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. + if err := exec.Command("sh", tmpScript.Name(), line, script, "add").Run(); err != nil { + return created, fmt.Errorf("exec.Command: add startup script: %w", err) } - return nil + return created, 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 + } + hooks := []hookLine{ + {script: merlinJFFSScriptPath, line: s.configPath() + " start"}, + {script: merlinJFFSServiceEventScriptPath, line: s.configPath() + ` service_event "$1" "$2"`}, + } + installed := make([]hookLine, 0, len(hooks)) + for _, hook := range hooks { + created, 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 { + cleanupCreatedHook(prev.line, prev.script) + continue + } + _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() + } return err } + hook.created = created + installed = append(installed, hook) } + installComplete = true return nil } func (s *merlinSvc) Uninstall() error { - if err := os.Remove(s.configPath()); err != nil { - return fmt.Errorf("os.Remove: %w", err) - } tmpScript, err := os.CreateTemp("", "ctrld_uninstall") if err != nil { return fmt.Errorf("os.CreateTemp: %w", err) @@ -171,16 +287,15 @@ func (s *merlinSvc) Uninstall() error { } 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 - } - } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + // Shared Merlin hooks belong to the router/user. Uninstalling ctrld + // must not create a hook file that did not exist. + return nil + } else if err != nil { + return err } - 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 } @@ -194,6 +309,11 @@ func (s *merlinSvc) Uninstall() error { } } + // 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,16 +348,29 @@ 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)) } } @@ -259,16 +392,27 @@ func (s *merlinSvc) Restart() error { const merlinSvcScript = `#!/bin/sh -name="{{.Name}}" -cmd="{{.Path}}{{range .Arguments}} {{.}}{{end}}" +name={{shellQuote .Name}} +exe={{shellQuote .Path}} pid_file="/tmp/$name.pid" get_pid() { - cat "$pid_file" + [ -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() { - [ -f "$pid_file" ] && ps | grep -q "^ *$(get_pid) " + pid="$(get_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 } case "$1" in @@ -276,17 +420,27 @@ case "$1" in 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 - $cmd & + {{shellQuote .Path}}{{range .Arguments}} {{shellQuote .}}{{end}} & echo $! > "$pid_file" chmod 600 "$pid_file" - if ! is_running; then - logger -c "Failed to start $name" - exit 1 + 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 ;; @@ -308,11 +462,12 @@ case "$1" in logger -c "failed to stop $name" exit 1 fi + rm -f "$pid_file" exit 0 ;; restart) - $0 stop - $0 start + "$0" stop || exit $? + "$0" start ;; status) if is_running; then @@ -327,8 +482,17 @@ case "$1" in svc=$3 dnsmasq_pid_file=$(sed -n '/pid-file=/s///p' /etc/dnsmasq.conf) - if [ "$event" = "restart" ] && [ "$svc" = "diskmon" ]; then - kill "$(cat "$dnsmasq_pid_file")" >/dev/null 2>&1 + 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 ;; *) @@ -343,10 +507,12 @@ const merlinAddLineToScript = `#!/bin/sh line=$1 file=$2 +mode=$3 . /usr/sbin/helper.sh -pc_append "$line" "$file" +pc_delete "$line" "$file" +[ "$mode" = "remove" ] || pc_append "$line" "$file" ` const merlinRemoveLineFromScript = `#!/bin/sh From 2f2e2bfc567d3a7ef94e7fea6985d882aa9a04ec Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:41:12 +0100 Subject: [PATCH 02/15] Add Merlin service lifecycle regression tests --- internal/router/service_merlin_test.go | 189 +++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 internal/router/service_merlin_test.go diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go new file mode 100644 index 00000000..5305fd69 --- /dev/null +++ b/internal/router/service_merlin_test.go @@ -0,0 +1,189 @@ +package router + +import ( + "bytes" + "os" + "path/filepath" + "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 "*`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service script missing PID ownership check %q", want) + } + } +} + +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) { + deleteAt := strings.Index(merlinAddLineToScript, `pc_delete "$line" "$file"`) + appendAt := strings.Index(merlinAddLineToScript, `pc_append "$line" "$file"`) + if deleteAt < 0 || appendAt < 0 || deleteAt > appendAt { + t.Fatal("hook editor must delete an existing ctrld line before appending it") + } + if !strings.Contains(merlinAddLineToScript, `[ "$mode" = "remove" ] ||`) { + t.Fatal("hook editor must support remove-only rollback mode") + } +} + +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) { + 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) + } + } +} From 5b52481b98021bfe9d97e5b6f84fe6274b2dbd53 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:41:45 +0100 Subject: [PATCH 03/15] Make Merlin service directory sync CI-portable --- internal/router/service_merlin.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index af936987..0da0aec7 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" @@ -117,7 +118,15 @@ func syncMerlinServiceDir(dir string) error { return err } defer f.Close() - return f.Sync() + if err := f.Sync(); err != nil { + // Directory fsync is required on Merlin/Unix for rename/link durability. + // The Windows CI filesystem does not support syncing directory handles. + if runtime.GOOS == "windows" { + return nil + } + return err + } + return nil } func (s *merlinSvc) Install() error { From 708963a017e632e7018c31a0c99eed93aff343be Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:03:03 +0100 Subject: [PATCH 04/15] Sync latest service_merlin.go hardening --- internal/router/service_merlin.go | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 0da0aec7..29887036 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -8,7 +8,6 @@ import ( "os/exec" "os/signal" "path/filepath" - "runtime" "strings" "syscall" "text/template" @@ -118,15 +117,7 @@ func syncMerlinServiceDir(dir string) error { return err } defer f.Close() - if err := f.Sync(); err != nil { - // Directory fsync is required on Merlin/Unix for rename/link durability. - // The Windows CI filesystem does not support syncing directory handles. - if runtime.GOOS == "windows" { - return nil - } - return err - } - return nil + return f.Sync() } func (s *merlinSvc) Install() error { @@ -520,8 +511,12 @@ mode=$3 . /usr/sbin/helper.sh -pc_delete "$line" "$file" -[ "$mode" = "remove" ] || pc_append "$line" "$file" +if [ "$mode" = "remove" ]; then + pattern=$(_quote "$line") + sed -i "/^$pattern$/d" "$file" +else + grep -qxF "$line" "$file" || pc_append "$line" "$file" +fi ` const merlinRemoveLineFromScript = `#!/bin/sh @@ -531,5 +526,6 @@ file=$2 . /usr/sbin/helper.sh -pc_delete "$line" "$file" +pattern=$(_quote "$line") +sed -i "/^$pattern$/d" "$file" ` From 4fdf701d921a356d4c2e5e574c60bb5afa3807d6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:03:06 +0100 Subject: [PATCH 05/15] Sync latest service_merlin_test.go hardening --- internal/router/service_merlin_test.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 5305fd69..2dd28ca7 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -33,13 +33,16 @@ func TestMerlinServiceRestartStopsBeforeStarting(t *testing.T) { } func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { - deleteAt := strings.Index(merlinAddLineToScript, `pc_delete "$line" "$file"`) - appendAt := strings.Index(merlinAddLineToScript, `pc_append "$line" "$file"`) - if deleteAt < 0 || appendAt < 0 || deleteAt > appendAt { - t.Fatal("hook editor must delete an existing ctrld line before appending it") + if !strings.Contains(merlinAddLineToScript, `grep -qxF "$line" "$file" || pc_append "$line" "$file"`) { + t.Fatal("hook editor must append ctrld's exact line only when it is absent") } - if !strings.Contains(merlinAddLineToScript, `[ "$mode" = "remove" ] ||`) { - t.Fatal("hook editor must support remove-only rollback mode") + 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") } } From 8d8b640764d5cef1393ea8e623673bfadc0a13ce Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:16:53 +0100 Subject: [PATCH 06/15] Keep Merlin directory fsync Unix-specific on v1.x --- internal/router/service_merlin.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 29887036..38397d79 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" @@ -112,6 +113,13 @@ func writeMerlinStartupScript(path string, data []byte, mode os.FileMode) (publi } 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 From 5b1123a9cd896fbde297d00b0e5d62d29cd66811 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:31 +0100 Subject: [PATCH 07/15] Port final Merlin lifecycle service_merlin.go to v1.x --- internal/router/service_merlin.go | 565 +++++++++++++++++++++++++----- 1 file changed, 486 insertions(+), 79 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 38397d79..5eee837b 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -21,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 { @@ -67,10 +68,173 @@ func (s *merlinSvc) template() *template.Template { }).Parse(merlinSvcScript)) } +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) +} + +func refreshMerlinStartupScriptForExecutable(exePath string) error { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: exePath, + }, + } + + 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 + } + } + + installed, exists, err := readExistingMerlinStartupScript(s.configPath()) + if err != nil { + return err + } + if !exists { + return service.ErrNotInstalled + } + + // 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 + } + + // 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 +} + +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-*") @@ -128,7 +292,74 @@ func syncMerlinServiceDir(dir string) error { return f.Sync() } +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 + } + + // 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 @@ -146,54 +377,27 @@ func (s *merlinSvc) Install() error { confPath := s.configPath() - var to = &struct { - *service.Config - Path string - }{ - s.Config, - exePath, - } - - // Render completely before touching the destination. A template error must - // not leave a truncated startup script which then looks "already installed". - var rendered bytes.Buffer - if err := s.template().Execute(&rendered, to); err != nil { - return fmt.Errorf("s.template.Execute: %w", err) + // 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 } - startupPublished := false - existing, err := os.ReadFile(confPath) - switch { - case err == nil: - if !bytes.Equal(existing, rendered.Bytes()) { - return fmt.Errorf("already installed with different startup script: %s", confPath) - } - // An interrupted previous install may have published the private startup - // script before adding both shared hooks. Identical bytes prove that this - // install can safely resume instead of getting stuck on "already installed". - if err := os.Chmod(confPath, 0755); err != nil { - return fmt.Errorf("os.Chmod: startup script: %w", err) - } - case os.IsNotExist(err): - startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) - if err != nil { - if startupPublished { - _ = os.Remove(confPath) - _ = syncMerlinServiceDir(filepath.Dir(confPath)) - } + 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) } - default: - return fmt.Errorf("read startup script: %w", err) } - installComplete := false - defer func() { - if !installComplete && startupPublished { - _ = os.Remove(confPath) - _ = syncMerlinServiceDir(filepath.Dir(confPath)) - } - }() - if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { return fmt.Errorf("os.MkdirAll: %w", err) } @@ -222,41 +426,73 @@ func (s *merlinSvc) Install() error { } } - addLineToScript := func(line, script string) (created bool, retErr error) { - if _, err := os.Stat(script); os.IsNotExist(err) { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return false, 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 - } else if err != nil { - return false, err + break } defer func() { if retErr != nil && created { - cleanupCreatedHook(line, script) + // 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) + } } }() // A pre-existing shared hook owns its mode. Do not chmod it as a side - // effect of installing ctrld. - if err := exec.Command("sh", tmpScript.Name(), line, script, "add").Run(); err != nil { - return created, fmt.Errorf("exec.Command: add startup script: %w", err) + // 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 created, nil } type hookLine struct { script string line string created bool + added bool } + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) hooks := []hookLine{ - {script: merlinJFFSScriptPath, line: s.configPath() + " start"}, - {script: merlinJFFSServiceEventScriptPath, line: s.configPath() + ` service_event "$1" "$2"`}, + {script: merlinJFFSScriptPath, line: startLine}, + {script: merlinJFFSServiceEventScriptPath, line: serviceEventLine}, } installed := make([]hookLine, 0, len(hooks)) for _, hook := range hooks { - created, err := addLineToScript(hook.line, hook.script) + 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; @@ -264,22 +500,30 @@ func (s *merlinSvc) Install() error { for i := len(installed) - 1; i >= 0; i-- { prev := installed[i] if prev.created { - cleanupCreatedHook(prev.line, prev.script) + if prev.added { + cleanupCreatedHook(prev.line, prev.script) + } continue } - _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() + 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) } - installComplete = true return nil } func (s *merlinSvc) Uninstall() error { + 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) @@ -294,12 +538,14 @@ 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) { + exists, err := validateMerlinSharedHookPath(script, false) + if err != nil { + return 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 - } else if err != nil { - return err } if err := exec.Command("sh", tmpScript.Name(), line, script, "remove").Run(); err != nil { @@ -308,12 +554,19 @@ func (s *merlinSvc) Uninstall() error { 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 + } } } @@ -383,22 +636,160 @@ func merlinServiceStatus(out []byte, cmdErr error) (service.Status, error) { } 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) +} + +// 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}}" +pid_file="/tmp/$name.pid" + +get_pid() { + cat "$pid_file" +} + +is_running() { + [ -f "$pid_file" ] && ps | grep -q "^ *$(get_pid) " } +case "$1" in + start) + if is_running; then + logger -c "Already started" + else + logger -c "Starting $name" + if [ -f /rom/ca-bundle.crt ]; then + # For John’s fork + export SSL_CERT_FILE=/rom/ca-bundle.crt + fi + $cmd & + echo $! > "$pid_file" + chmod 600 "$pid_file" + if ! is_running; then + logger -c "Failed to start $name" + exit 1 + fi + fi + ;; + stop) + if is_running; then + logger -c "Stopping $name..." + kill "$(get_pid)" + for _ in 1 2 3 4 5; do + if ! is_running; 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 + exit 0 + ;; + restart) + $0 stop + $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" ]; then + kill "$(cat "$dnsmasq_pid_file")" >/dev/null 2>&1 + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +exit 0 +` + const merlinSvcScript = `#!/bin/sh +# GENERATED BY ctrld - Merlin service v2 name={{shellQuote .Name}} exe={{shellQuote .Path}} @@ -413,8 +804,9 @@ get_pid() { printf '%s\n' "$pid" } -is_running() { - pid="$(get_pid)" || return 1 +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 @@ -423,6 +815,10 @@ is_running() { esac } +is_running() { + pid="$(get_pid)" || return 1 + is_running_pid "$pid" +} case "$1" in start) if is_running; then @@ -453,11 +849,15 @@ case "$1" in fi ;; stop) - if is_running; then + pid="$(get_pid)" || { + rm -f "$pid_file" + exit 0 + } + if is_running_pid "$pid"; then logger -c "Stopping $name..." - kill "$(get_pid)" + kill "$pid" for _ in 1 2 3 4 5; do - if ! is_running; then + if ! is_running_pid "$pid"; then logger -c "stopped" if [ -f "$pid_file" ]; then rm "$pid_file" @@ -523,7 +923,14 @@ if [ "$mode" = "remove" ]; then pattern=$(_quote "$line") sed -i "/^$pattern$/d" "$file" else - grep -qxF "$line" "$file" || pc_append "$line" "$file" + 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 ` From d3051171a7b07689039da3492cc272c99dca3a53 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:34 +0100 Subject: [PATCH 08/15] Port final Merlin lifecycle service_merlin_test.go to v1.x --- internal/router/service_merlin_test.go | 396 ++++++++++++++++++++++++- 1 file changed, 394 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 2dd28ca7..545b3d0b 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "testing" @@ -19,11 +20,16 @@ func TestMerlinServiceScriptValidatesPidOwnership(t *testing.T) { `[ -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) { @@ -33,8 +39,16 @@ func TestMerlinServiceRestartStopsBeforeStarting(t *testing.T) { } func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { - if !strings.Contains(merlinAddLineToScript, `grep -qxF "$line" "$file" || pc_append "$line" "$file"`) { - t.Fatal("hook editor must append ctrld's exact line only when it is absent") + 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"`) { @@ -126,6 +140,9 @@ func TestMerlinServiceStatusRejectsUnexpectedOutput(t *testing.T) { 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") @@ -190,3 +207,378 @@ func TestMerlinServiceEventValidatesDnsmasqPidOwnership(t *testing.T) { } } } + + +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") + } +} From 17a0e597fd4010a4c226e1de402367edb4a2a04f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:37 +0100 Subject: [PATCH 09/15] Port final Merlin lifecycle service_merlin_lock_linux.go to v1.x --- internal/router/service_merlin_lock_linux.go | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 internal/router/service_merlin_lock_linux.go 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() +} From e15d23226669faa06d50d723f276b34916bd212f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:39 +0100 Subject: [PATCH 10/15] Port final Merlin lifecycle service_merlin_lock_other.go to v1.x --- internal/router/service_merlin_lock_other.go | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 internal/router/service_merlin_lock_other.go 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() +} From 9d70e05c4112e3fdbd482211186d19d60c6da078 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:41 +0100 Subject: [PATCH 11/15] Port final Merlin lifecycle service_merlin_startup_linux.go to v1.x --- .../router/service_merlin_startup_linux.go | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 internal/router/service_merlin_startup_linux.go 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 +} + From 50a3ad93824578e03b1f7a7432dc7f9e98dede44 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:44 +0100 Subject: [PATCH 12/15] Port final Merlin lifecycle service_merlin_startup_other.go to v1.x --- .../router/service_merlin_startup_other.go | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 internal/router/service_merlin_startup_other.go 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) +} From ec367491ac33ef1f11d44986a5a5d6fda2b54759 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:05:46 +0100 Subject: [PATCH 13/15] Port final Merlin lifecycle service_merlin_unix_test.go to v1.x --- internal/router/service_merlin_unix_test.go | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 internal/router/service_merlin_unix_test.go 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) + } +} From f3fbe90aa6fcbe86b5e7d23201e355ac212ed2d8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:11:22 +0100 Subject: [PATCH 14/15] Serialize Merlin restart and upgrade lifecycle on v1.x --- cmd/cli/commands.go | 162 ++++++++++++++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 45 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 6ebf5c71..3930cea0 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -926,6 +926,19 @@ 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) { @@ -971,49 +984,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 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() { @@ -1547,25 +1588,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 { @@ -1580,17 +1654,15 @@ 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 From 05ea81e6ea0a8f8b9364c543f48bd43ef4738287 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:11:26 +0100 Subject: [PATCH 15/15] Migrate Merlin startup script from new v1.x binary --- cmd/cli/cli.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 98dc7637..5021cdb1 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -442,6 +442,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) {