From 983de495b81c062b23ea3a7a44d691fbe114e48f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:35:44 +0100 Subject: [PATCH 01/82] Render Merlin startup script safely --- internal/router/service_merlin.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8ab6d6a7..f4d75c23 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -83,6 +83,8 @@ func (s *merlinSvc) Install() error { confPath := s.configPath() if _, err := os.Stat(confPath); err == nil { return fmt.Errorf("already installed: %s", confPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("os.Stat: startup script: %w", err) } var to = &struct { @@ -93,18 +95,14 @@ 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) + if err := os.WriteFile(confPath, rendered.Bytes(), 0755); err != nil { + return fmt.Errorf("os.WriteFile: startup script: %w", err) } if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { @@ -129,6 +127,8 @@ func (s *merlinSvc) Install() error { if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { return err } + } else if err != nil { + return err } if err := os.Chmod(script, 0755); err != nil { return fmt.Errorf("os.Chmod: jffs script: %w", err) From 5fc9304458281e76ede69ddb8d33cecc6b42fe67 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:36:00 +0100 Subject: [PATCH 02/82] Make Merlin hooks idempotent and uninstall safe --- internal/router/service_merlin.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index f4d75c23..0d2b5807 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -153,7 +153,7 @@ func (s *merlinSvc) Install() error { } func (s *merlinSvc) Uninstall() error { - if err := os.Remove(s.configPath()); err != nil { + if err := os.Remove(s.configPath()); err != nil && !os.IsNotExist(err) { return fmt.Errorf("os.Remove: %w", err) } tmpScript, err := os.CreateTemp("", "ctrld_uninstall") @@ -171,16 +171,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) + return fmt.Errorf("exec.Command: remove startup script: %w", err) } return nil } @@ -346,7 +345,8 @@ file=$2 . /usr/sbin/helper.sh -pc_append "$line" "$file" +pc_delete "$line" "$file" +pc_append "$line" "$file" ` const merlinRemoveLineFromScript = `#!/bin/sh From ab7277a4c5fe11c66f0b202960f826cc8c1cae0c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:36:13 +0100 Subject: [PATCH 03/82] Validate Merlin service PID ownership --- internal/router/service_merlin.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 0d2b5807..5f6ca61c 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -259,15 +259,27 @@ func (s *merlinSvc) Restart() error { const merlinSvcScript = `#!/bin/sh name="{{.Name}}" +exe="{{.Path}}" cmd="{{.Path}}{{range .Arguments}} {{.}}{{end}}" 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 @@ -275,6 +287,7 @@ 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 @@ -307,11 +320,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 From ef63cedbed69ed003f4219a00dff98d05f117778 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:37:09 +0100 Subject: [PATCH 04/82] Make Merlin service install and uninstall reversible --- internal/router/service_merlin.go | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 5f6ca61c..196fa292 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -134,28 +134,39 @@ func (s *merlinSvc) Install() error { return fmt.Errorf("os.Chmod: jffs script: %w", err) } - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { + if err := exec.Command("sh", tmpScript.Name(), line, script, "add").Run(); err != nil { return fmt.Errorf("exec.Command: add startup script: %w", err) } 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 + } + hooks := []hookLine{ + {merlinJFFSScriptPath, s.configPath() + " start"}, + {merlinJFFSServiceEventScriptPath, s.configPath() + ` service_event "$1" "$2"`}, + } + installed := make([]hookLine, 0, len(hooks)) + for _, hook := range hooks { + if err := addLineToScript(hook.line, hook.script); err != nil { + // Best-effort rollback: remove only lines successfully installed by + // this attempt, then remove the startup script so a retry can start + // from a clean state. + for i := len(installed) - 1; i >= 0; i-- { + _ = exec.Command("sh", tmpScript.Name(), installed[i].line, installed[i].script, "remove").Run() + } + _ = os.Remove(confPath) return err } + installed = append(installed, hook) } return nil } func (s *merlinSvc) Uninstall() error { - if err := os.Remove(s.configPath()); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("os.Remove: %w", err) - } tmpScript, err := os.CreateTemp("", "ctrld_uninstall") if err != nil { return fmt.Errorf("os.CreateTemp: %w", err) @@ -178,7 +189,7 @@ func (s *merlinSvc) Uninstall() error { return err } - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { + 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 @@ -193,6 +204,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 } @@ -356,11 +372,12 @@ const merlinAddLineToScript = `#!/bin/sh line=$1 file=$2 +mode=$3 . /usr/sbin/helper.sh pc_delete "$line" "$file" -pc_append "$line" "$file" +[ "$mode" = "remove" ] || pc_append "$line" "$file" ` const merlinRemoveLineFromScript = `#!/bin/sh From e67e7b6a9be15c3ba0d61b1b6818f0ae88afd303 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:38:03 +0100 Subject: [PATCH 05/82] Rollback partial Merlin service installs cleanly --- internal/router/service_merlin.go | 50 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 196fa292..cba96593 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -104,6 +104,12 @@ func (s *merlinSvc) Install() error { if err := os.WriteFile(confPath, rendered.Bytes(), 0755); err != nil { return fmt.Errorf("os.WriteFile: startup script: %w", err) } + installComplete := false + defer func() { + if !installComplete { + _ = os.Remove(confPath) + } + }() if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { return fmt.Errorf("os.MkdirAll: %w", err) @@ -122,47 +128,61 @@ func (s *merlinSvc) Install() error { if err := tmpScript.Close(); err != nil { return fmt.Errorf("tmpScript.Close: %w", err) } - addLineToScript := func(line, script string) 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 err + return false, err } + created = true } else if err != nil { - return err + return false, err } + defer func() { + if retErr != nil && created { + _ = os.Remove(script) + } + }() if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + return created, fmt.Errorf("os.Chmod: jffs script: %w", err) } if err := exec.Command("sh", tmpScript.Name(), line, script, "add").Run(); err != nil { - return fmt.Errorf("exec.Command: add startup script: %w", err) + return created, fmt.Errorf("exec.Command: add startup script: %w", err) } - return nil + return created, nil } type hookLine struct { - script string - line string + script string + line string + created bool } hooks := []hookLine{ - {merlinJFFSScriptPath, s.configPath() + " start"}, - {merlinJFFSServiceEventScriptPath, s.configPath() + ` service_event "$1" "$2"`}, + {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 { - if err := addLineToScript(hook.line, hook.script); err != nil { + created, err := addLineToScript(hook.line, hook.script) + if err != nil { // Best-effort rollback: remove only lines successfully installed by - // this attempt, then remove the startup script so a retry can start - // from a clean state. + // 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-- { - _ = exec.Command("sh", tmpScript.Name(), installed[i].line, installed[i].script, "remove").Run() + prev := installed[i] + if prev.created { + _ = os.Remove(prev.script) + continue + } + _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() } - _ = os.Remove(confPath) return err } + hook.created = created installed = append(installed, hook) } + installComplete = true return nil } From 4dd7d053d0064937d55d7fbbafff834e8943bc95 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:38:41 +0100 Subject: [PATCH 06/82] Test Merlin service lifecycle safeguards --- internal/router/service_merlin_test.go | 68 ++++++++++++++++++++++++++ 1 file changed, 68 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..4da7f5c8 --- /dev/null +++ b/internal/router/service_merlin_test.go @@ -0,0 +1,68 @@ +package router + +import ( + "bytes" + "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"`, + `cmd="/jffs/controld/ctrld run --cd example"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("rendered Merlin startup script missing %q", want) + } + } +} From c1463c23ba09e7f0dab160ee38bfc38c6ea59cb6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:44:37 +0100 Subject: [PATCH 07/82] Fix Merlin PID ownership test escape --- internal/router/service_merlin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 4da7f5c8..6e244adf 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -15,7 +15,7 @@ func TestMerlinServiceScriptValidatesPidOwnership(t *testing.T) { for _, want := range []string{ `case "$pid" in`, `[ -r "/proc/$pid/cmdline" ]`, - `tr '\\000' ' '`, + `tr '\000' ' '`, `"$exe"|"$exe "*`, } { if !strings.Contains(merlinSvcScript, want) { From e00ac4afdc02092b96116ddccbea7ce05865eaad Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:50:03 +0100 Subject: [PATCH 08/82] Wait for Merlin service child exec --- internal/router/service_merlin.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index cba96593..74a762b5 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -332,9 +332,18 @@ case "$1" in $cmd & 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 ;; From 5f24b682fc6813851aeb01bbf79e3f3410edf50f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:50:13 +0100 Subject: [PATCH 09/82] Test Merlin child exec startup wait --- internal/router/service_merlin_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 6e244adf..0b0f7732 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -66,3 +66,17 @@ func TestMerlinServiceTemplateRendersExecutableIdentity(t *testing.T) { } } } + + +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) + } + } +} From 104fd0b23e9fdc9eeff5d0b410286ff297ef96d2 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:51:25 +0100 Subject: [PATCH 10/82] Preserve shared Merlin hook ownership on install rollback --- internal/router/service_merlin.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 74a762b5..3fe37edd 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -128,6 +128,17 @@ func (s *merlinSvc) Install() error { if err := tmpScript.Close(); err != nil { return fmt.Errorf("tmpScript.Close: %w", 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) + } + } + 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 { @@ -139,13 +150,12 @@ func (s *merlinSvc) Install() error { } defer func() { if retErr != nil && created { - _ = os.Remove(script) + cleanupCreatedHook(line, script) } }() - if err := os.Chmod(script, 0755); err != nil { - return created, fmt.Errorf("os.Chmod: jffs 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) } @@ -171,7 +181,7 @@ func (s *merlinSvc) Install() error { for i := len(installed) - 1; i >= 0; i-- { prev := installed[i] if prev.created { - _ = os.Remove(prev.script) + cleanupCreatedHook(prev.line, prev.script) continue } _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() From 74e0dc2ec2c95d2a8c346d71f37581dac3bcba1a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:52:05 +0100 Subject: [PATCH 11/82] Report stopped Merlin service state correctly --- internal/router/service_merlin.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 3fe37edd..43661b87 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -273,16 +273,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)) } } From eff5a93960c22a8915dbbcfafb53d4d18e68f163 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:52:14 +0100 Subject: [PATCH 12/82] Test Merlin stopped service status handling --- internal/router/service_merlin_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 0b0f7732..aa8580d8 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -2,6 +2,7 @@ package router import ( "bytes" + "os" "strings" "testing" @@ -80,3 +81,20 @@ func TestMerlinServiceStartWaitsForChildExec(t *testing.T) { } } } + + +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") + } +} From 19ae9cb8063e2b81b8b45596eab30de8cea5cb27 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:25:32 +0100 Subject: [PATCH 13/82] Preserve Merlin service argument boundaries --- internal/router/service_merlin.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 43661b87..c2f0695b 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -61,7 +61,13 @@ 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 (s *merlinSvc) Install() error { @@ -317,9 +323,8 @@ func (s *merlinSvc) Restart() error { const merlinSvcScript = `#!/bin/sh -name="{{.Name}}" -exe="{{.Path}}" -cmd="{{.Path}}{{range .Arguments}} {{.}}{{end}}" +name={{shellQuote .Name}} +exe={{shellQuote .Path}} pid_file="/tmp/$name.pid" get_pid() { @@ -352,7 +357,7 @@ case "$1" in # 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" started=0 From a5758e1e86d773a03505517a67bc1c99436da677 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:25:57 +0100 Subject: [PATCH 14/82] Test Merlin service shell quoting --- internal/router/service_merlin_test.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index aa8580d8..2a7877ac 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -59,8 +59,8 @@ func TestMerlinServiceTemplateRendersExecutableIdentity(t *testing.T) { } got := buf.String() for _, want := range []string{ - `exe="/jffs/controld/ctrld"`, - `cmd="/jffs/controld/ctrld run --cd example"`, + `exe='/jffs/controld/ctrld'`, + `'/jffs/controld/ctrld' 'run' '--cd' 'example' &`, } { if !strings.Contains(got, want) { t.Fatalf("rendered Merlin startup script missing %q", want) @@ -68,6 +68,27 @@ func TestMerlinServiceTemplateRendersExecutableIdentity(t *testing.T) { } } +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{ From 604a36742893aeb5579a6703186cc6bcd6399e32 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:32:47 +0100 Subject: [PATCH 15/82] Publish Merlin startup script atomically --- internal/router/service_merlin.go | 60 +++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index c2f0695b..1120346d 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -70,6 +70,56 @@ 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 { exePath, err := os.Executable() if err != nil { @@ -107,13 +157,19 @@ func (s *merlinSvc) Install() error { if err := s.template().Execute(&rendered, to); err != nil { return fmt.Errorf("s.template.Execute: %w", err) } - if err := os.WriteFile(confPath, rendered.Bytes(), 0755); err != nil { - return fmt.Errorf("os.WriteFile: startup script: %w", 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) } installComplete := false defer func() { if !installComplete { _ = os.Remove(confPath) + _ = syncMerlinServiceDir(filepath.Dir(confPath)) } }() From f067b46e8e13ff1870334255f07f640998e1dc4d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:33:03 +0100 Subject: [PATCH 16/82] Test atomic Merlin startup publication --- internal/router/service_merlin_test.go | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 2a7877ac..ac0c1675 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -3,6 +3,7 @@ package router import ( "bytes" "os" + "path/filepath" "strings" "testing" @@ -119,3 +120,56 @@ func TestMerlinServiceStatusRejectsUnexpectedOutput(t *testing.T) { 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) + } +} From 1d6666181626d9d30b1ebe28f6b27bd5a536b751 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:33:46 +0100 Subject: [PATCH 17/82] Resume interrupted Merlin service installs safely --- internal/router/service_merlin.go | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 1120346d..47df832a 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -137,11 +137,6 @@ func (s *merlinSvc) Install() error { } confPath := s.configPath() - if _, err := os.Stat(confPath); err == nil { - return fmt.Errorf("already installed: %s", confPath) - } else if !os.IsNotExist(err) { - return fmt.Errorf("os.Stat: startup script: %w", err) - } var to = &struct { *service.Config @@ -157,17 +152,35 @@ func (s *merlinSvc) Install() error { if err := s.template().Execute(&rendered, to); err != nil { return fmt.Errorf("s.template.Execute: %w", err) } - startupPublished, err := writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) - if err != nil { - if startupPublished { - _ = os.Remove(confPath) - _ = syncMerlinServiceDir(filepath.Dir(confPath)) + 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) } - return fmt.Errorf("publish 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 { + if !installComplete && startupPublished { _ = os.Remove(confPath) _ = syncMerlinServiceDir(filepath.Dir(confPath)) } From 5824840d31736fe8a2f26784d0b46fe3d605d860 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:35:47 +0100 Subject: [PATCH 18/82] Validate dnsmasq PID before diskmon signal --- internal/router/service_merlin.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 47df832a..af936987 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -482,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 ;; *) From 412ea6f1b513acdecf3defd6e52c0ea2d297252a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:35:57 +0100 Subject: [PATCH 19/82] Test diskmon dnsmasq PID ownership check --- internal/router/service_merlin_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index ac0c1675..5305fd69 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -173,3 +173,17 @@ func TestWriteMerlinStartupScriptDoesNotClobberExistingTarget(t *testing.T) { 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 28720b8d61a06b5e83ba91348bce857e79e7f49d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:57:10 +0100 Subject: [PATCH 20/82] Avoid destructive Merlin hook reappend --- internal/router/service_merlin.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index af936987..29887036 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -511,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 @@ -522,5 +526,6 @@ file=$2 . /usr/sbin/helper.sh -pc_delete "$line" "$file" +pattern=$(_quote "$line") +sed -i "/^$pattern$/d" "$file" ` From 5eeefb89faaf17ec975ad5d08171df93511f4007 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:57:28 +0100 Subject: [PATCH 21/82] Test non-destructive Merlin hook editing --- 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 fd8bae3d84cb1272b58c043ae01b7a532a163cba Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:16:44 +0100 Subject: [PATCH 22/82] Keep Merlin directory fsync Unix-specific --- 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 88808260df8c85e9e2aa313d89cbd28cb083ae8c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:37:29 +0100 Subject: [PATCH 23/82] Preserve pre-existing Merlin hook lines on rollback --- internal/router/service_merlin.go | 38 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 38397d79..f47fdaad 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -222,14 +222,14 @@ func (s *merlinSvc) Install() error { } } - addLineToScript := func(line, script string) (created bool, retErr error) { + addLineToScript := func(line, script string) (created, added 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 + return false, false, err } created = true } else if err != nil { - return false, err + return false, false, err } defer func() { if retErr != nil && created { @@ -238,17 +238,28 @@ func (s *merlinSvc) Install() error { }() // 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 } hooks := []hookLine{ {script: merlinJFFSScriptPath, line: s.configPath() + " start"}, @@ -256,7 +267,7 @@ func (s *merlinSvc) Install() error { } 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; @@ -267,11 +278,14 @@ func (s *merlinSvc) Install() error { 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) } @@ -523,7 +537,11 @@ 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 + fi + pc_append "$line" "$file" + printf 'added\n' fi ` From b96a920d1ea8327faf1609e312381be12f38166a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:37:44 +0100 Subject: [PATCH 24/82] Test Merlin hook append provenance --- internal/router/service_merlin_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 2dd28ca7..913aced9 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -33,8 +33,14 @@ 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`, + `pc_append "$line" "$file"`, + `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"`) { From 43107b9ae129067023557189aeb57f0202acd8da Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:42:05 +0100 Subject: [PATCH 25/82] Fix Merlin hook editor test escape --- internal/router/service_merlin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 913aced9..54872d36 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -36,7 +36,7 @@ func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { for _, want := range []string{ `if grep -qxF "$line" "$file"; then`, `pc_append "$line" "$file"`, - `printf 'added\\n'`, + `printf 'added\n'`, } { if !strings.Contains(merlinAddLineToScript, want) { t.Fatalf("hook editor missing idempotent append signal %q", want) From 0c6a4ce17eb5415cec82d048de61b9d2fc1ca173 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:44:36 +0100 Subject: [PATCH 26/82] Validate Merlin shared hooks and preserve concurrent lines --- internal/router/service_merlin.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index f47fdaad..76d1bacd 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -128,6 +128,16 @@ func syncMerlinServiceDir(dir string) error { return f.Sync() } +func validateMerlinSharedHook(path string, info os.FileInfo) error { + if !info.Mode().IsRegular() { + return fmt.Errorf("shared Merlin hook is not a regular file: %s", path) + } + if info.Mode().Perm()&0111 == 0 { + return fmt.Errorf("shared Merlin hook is not executable: %s", path) + } + return nil +} + func (s *merlinSvc) Install() error { exePath, err := os.Executable() if err != nil { @@ -223,17 +233,25 @@ func (s *merlinSvc) Install() error { } addLineToScript := func(line, script string) (created, added bool, retErr error) { - if _, err := os.Stat(script); os.IsNotExist(err) { + if info, err := os.Stat(script); os.IsNotExist(err) { if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { return false, false, err } created = true } else if err != nil { return false, false, err + } else if err := validateMerlinSharedHook(script, info); err != nil { + return false, false, err } 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) + } } }() @@ -275,7 +293,9 @@ 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 } if prev.added { From a4f5f7536089c95e79fcdd75062a96371bb25b9f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:44:48 +0100 Subject: [PATCH 27/82] Test Merlin shared hook executability --- internal/router/service_merlin_test.go | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 54872d36..10409814 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -196,3 +196,30 @@ func TestMerlinServiceEventValidatesDnsmasqPidOwnership(t *testing.T) { } } } + + +func TestValidateMerlinSharedHookRequiresExecutable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0644); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := validateMerlinSharedHook(path, info); err == nil { + t.Fatal("expected non-executable shared hook to be rejected") + } + + if err := os.Chmod(path, 0755); err != nil { + t.Fatal(err) + } + info, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := validateMerlinSharedHook(path, info); err != nil { + t.Fatalf("executable shared hook rejected: %v", err) + } +} From d27e84bbdd57d67136b5f31d7004d6c489fad70f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:47:42 +0100 Subject: [PATCH 28/82] Propagate Merlin shared hook append failures --- internal/router/service_merlin.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 76d1bacd..9a3d9dbc 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -559,8 +559,11 @@ if [ "$mode" = "remove" ]; then else if grep -qxF "$line" "$file"; then exit 0 + else + grep_status=$? + [ "$grep_status" -eq 1 ] || exit "$grep_status" fi - pc_append "$line" "$file" + pc_append "$line" "$file" || exit $? printf 'added\n' fi ` From 8390ac93eeb41927bd6c9dd1c7cb1e93d65033a6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:47:53 +0100 Subject: [PATCH 29/82] Test Merlin hook append failure propagation --- internal/router/service_merlin_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 10409814..f3711806 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -35,7 +35,9 @@ func TestMerlinServiceRestartStopsBeforeStarting(t *testing.T) { func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { for _, want := range []string{ `if grep -qxF "$line" "$file"; then`, - `pc_append "$line" "$file"`, + `grep_status=$?`, + `[ "$grep_status" -eq 1 ] || exit "$grep_status"`, + `pc_append "$line" "$file" || exit $?`, `printf 'added\n'`, } { if !strings.Contains(merlinAddLineToScript, want) { From 228244b541b1e1fee3bd8e7c6ee2cb8ed4ad03c3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:49:02 +0100 Subject: [PATCH 30/82] Quote Merlin startup hook paths safely --- internal/router/service_merlin.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 9a3d9dbc..f4aa3190 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -279,9 +279,10 @@ func (s *merlinSvc) Install() error { created bool added bool } + startupPath := merlinShellQuote(s.configPath()) hooks := []hookLine{ - {script: merlinJFFSScriptPath, line: s.configPath() + " start"}, - {script: merlinJFFSServiceEventScriptPath, line: s.configPath() + ` service_event "$1" "$2"`}, + {script: merlinJFFSScriptPath, line: startupPath + " start"}, + {script: merlinJFFSServiceEventScriptPath, line: startupPath + ` service_event "$1" "$2"`}, } installed := make([]hookLine, 0, len(hooks)) for _, hook := range hooks { @@ -342,9 +343,10 @@ func (s *merlinSvc) Uninstall() error { return nil } + startupPath := merlinShellQuote(s.configPath()) for script, line := range map[string]string{ - merlinJFFSScriptPath: s.configPath() + " start", - merlinJFFSServiceEventScriptPath: s.configPath() + ` service_event "$1" "$2"`, + merlinJFFSScriptPath: startupPath + " start", + merlinJFFSServiceEventScriptPath: startupPath + ` service_event "$1" "$2"`, } { if err := removeLineFromScript(line, script); err != nil { return err From 18ce907cae2758fe47e454d52a24e87bb1aeaf69 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:49:20 +0100 Subject: [PATCH 31/82] Share quoted Merlin startup hook lines --- internal/router/service_merlin.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index f4aa3190..05a89f99 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -71,6 +71,11 @@ 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 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-*") @@ -279,10 +284,10 @@ func (s *merlinSvc) Install() error { created bool added bool } - startupPath := merlinShellQuote(s.configPath()) + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) hooks := []hookLine{ - {script: merlinJFFSScriptPath, line: startupPath + " start"}, - {script: merlinJFFSServiceEventScriptPath, line: startupPath + ` service_event "$1" "$2"`}, + {script: merlinJFFSScriptPath, line: startLine}, + {script: merlinJFFSServiceEventScriptPath, line: serviceEventLine}, } installed := make([]hookLine, 0, len(hooks)) for _, hook := range hooks { @@ -343,10 +348,10 @@ func (s *merlinSvc) Uninstall() error { return nil } - startupPath := merlinShellQuote(s.configPath()) + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) for script, line := range map[string]string{ - merlinJFFSScriptPath: startupPath + " start", - merlinJFFSServiceEventScriptPath: startupPath + ` service_event "$1" "$2"`, + merlinJFFSScriptPath: startLine, + merlinJFFSServiceEventScriptPath: serviceEventLine, } { if err := removeLineFromScript(line, script); err != nil { return err From a76f486db89b695c8bfe495082b94e3314d93d4c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:49:29 +0100 Subject: [PATCH 32/82] Test quoting of Merlin shared hook paths --- internal/router/service_merlin_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index f3711806..86350b4c 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -225,3 +225,15 @@ func TestValidateMerlinSharedHookRequiresExecutable(t *testing.T) { t.Fatalf("executable shared hook rejected: %v", err) } } + + +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) + } +} From 79e469267a4c17331f9d570a5be3b0c982741283 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:52:45 +0100 Subject: [PATCH 33/82] Reject unsafe Merlin shared-hook symlinks --- internal/router/service_merlin.go | 37 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 05a89f99..9715bb34 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -133,14 +133,23 @@ func syncMerlinServiceDir(dir string) error { return f.Sync() } -func validateMerlinSharedHook(path string, info os.FileInfo) error { +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 fmt.Errorf("shared Merlin hook is not a regular file: %s", path) + return true, fmt.Errorf("shared Merlin hook is not a regular file: %s", path) } - if info.Mode().Perm()&0111 == 0 { - return fmt.Errorf("shared Merlin hook is not executable: %s", path) + if requireExecutable && info.Mode().Perm()&0111 == 0 { + return true, fmt.Errorf("shared Merlin hook is not executable: %s", path) } - return nil + return true, nil } func (s *merlinSvc) Install() error { @@ -238,15 +247,15 @@ func (s *merlinSvc) Install() error { } addLineToScript := func(line, script string) (created, added bool, retErr error) { - if info, err := os.Stat(script); os.IsNotExist(err) { + exists, err := validateMerlinSharedHookPath(script, true) + if err != nil { + return false, false, err + } + if !exists { if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { return false, false, err } created = true - } else if err != nil { - return false, false, err - } else if err := validateMerlinSharedHook(script, info); err != nil { - return false, false, err } defer func() { if retErr != nil && created { @@ -334,12 +343,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 { From 98a179dd7fabdc3dc0c1f86ad7b43164e5e5e529 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:53:07 +0100 Subject: [PATCH 34/82] Test safe Merlin shared-hook path validation --- internal/router/service_merlin_test.go | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 86350b4c..ba731f4d 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -200,29 +200,37 @@ func TestMerlinServiceEventValidatesDnsmasqPidOwnership(t *testing.T) { } -func TestValidateMerlinSharedHookRequiresExecutable(t *testing.T) { +func TestValidateMerlinSharedHookPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "services-start") - if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0644); err != nil { - t.Fatal(err) + + exists, err := validateMerlinSharedHookPath(path, true) + if err != nil || exists { + t.Fatalf("missing hook = (%v, %v), want (false, nil)", exists, err) } - info, err := os.Stat(path) - if err != nil { + + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0644); err != nil { t.Fatal(err) } - if err := validateMerlinSharedHook(path, info); err == nil { + 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) } - info, err = os.Stat(path) - if err != nil { - t.Fatal(err) + if exists, err := validateMerlinSharedHookPath(path, true); err != nil || !exists { + t.Fatalf("executable shared hook rejected: (%v, %v)", exists, err) } - if err := validateMerlinSharedHook(path, info); err != nil { - t.Fatalf("executable shared hook rejected: %v", 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") + } } } From 85c82391f4d62dae71b6fc92545b1b10eeee4635 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:57:39 +0100 Subject: [PATCH 35/82] Make Merlin hook creation race-safe and clean legacy lines --- internal/router/service_merlin.go | 68 ++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 9715bb34..873136f1 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -76,6 +76,10 @@ func merlinStartupHookLines(configPath string) (startLine, serviceEventLine stri 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-*") @@ -247,15 +251,51 @@ func (s *merlinSvc) Install() error { } addLineToScript := func(line, script string) (created, added bool, retErr error) { - exists, err := validateMerlinSharedHookPath(script, true) - if err != nil { - return false, false, err - } - if !exists { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { + 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. + stub, err := os.OpenFile(script, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) + 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 + } + if err != nil { return false, false, err } created = true + stubData := []byte("#!/bin/sh\n") + if _, err := stub.Write(stubData); err != nil { + _ = stub.Close() + _ = os.Remove(script) + _ = syncMerlinServiceDir(filepath.Dir(script)) + return false, false, err + } + if err := stub.Sync(); err != nil { + _ = stub.Close() + _ = os.Remove(script) + _ = syncMerlinServiceDir(filepath.Dir(script)) + return false, false, err + } + if err := stub.Close(); err != nil { + _ = os.Remove(script) + _ = syncMerlinServiceDir(filepath.Dir(script)) + return false, false, err + } + if err := syncMerlinServiceDir(filepath.Dir(script)); err != nil { + _ = os.Remove(script) + _ = syncMerlinServiceDir(filepath.Dir(script)) + return false, false, err + } + break } defer func() { if retErr != nil && created { @@ -360,12 +400,18 @@ func (s *merlinSvc) Uninstall() error { } startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) - for script, line := range map[string]string{ - merlinJFFSScriptPath: startLine, - merlinJFFSServiceEventScriptPath: serviceEventLine, + 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 + } } } From 894b84af842b8e9e963447200f00ac99a70b1ccd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:57:52 +0100 Subject: [PATCH 36/82] Test legacy Merlin hook line cleanup --- internal/router/service_merlin_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index ba731f4d..5154ba72 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -245,3 +245,15 @@ func TestMerlinStartupHookLinesQuoteConfigPath(t *testing.T) { 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) + } +} From 870c5ee2a08a281edc7676d759ce10dcb04de3d6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:33:31 +0100 Subject: [PATCH 37/82] Set executable mode explicitly on new Merlin hooks --- internal/router/service_merlin.go | 74 ++++++++++++++++++------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 873136f1..91e860eb 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -137,6 +137,44 @@ func syncMerlinServiceDir(dir string) error { return f.Sync() } +func createMerlinSharedHookStub(path string) (retErr error) { + stub, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + closed := false + defer func() { + if !closed { + _ = stub.Close() + } + if retErr != nil { + _ = os.Remove(path) + _ = syncMerlinServiceDir(filepath.Dir(path)) + } + }() + + // OpenFile creation mode is filtered through the process umask. Merlin must + // execute services-start/service-event at boot, so set the final mode + // explicitly on the already-open inode before making it durable. + if err := stub.Chmod(0755); err != nil { + return err + } + if _, err := stub.Write([]byte("#!/bin/sh\n")); err != nil { + return err + } + if err := stub.Sync(); err != nil { + return err + } + if err := stub.Close(); err != nil { + return err + } + closed = true + if err := syncMerlinServiceDir(filepath.Dir(path)); err != nil { + return err + } + return nil +} + func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists bool, err error) { info, err := os.Lstat(path) if os.IsNotExist(err) { @@ -262,39 +300,15 @@ func (s *merlinSvc) Install() error { // Create a missing shared hook without following a raced symlink or // truncating a file another addon created after our Lstat. - stub, err := os.OpenFile(script, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) - 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 - } - if err != nil { + 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 - stubData := []byte("#!/bin/sh\n") - if _, err := stub.Write(stubData); err != nil { - _ = stub.Close() - _ = os.Remove(script) - _ = syncMerlinServiceDir(filepath.Dir(script)) - return false, false, err - } - if err := stub.Sync(); err != nil { - _ = stub.Close() - _ = os.Remove(script) - _ = syncMerlinServiceDir(filepath.Dir(script)) - return false, false, err - } - if err := stub.Close(); err != nil { - _ = os.Remove(script) - _ = syncMerlinServiceDir(filepath.Dir(script)) - return false, false, err - } - if err := syncMerlinServiceDir(filepath.Dir(script)); err != nil { - _ = os.Remove(script) - _ = syncMerlinServiceDir(filepath.Dir(script)) - return false, false, err - } break } defer func() { From 599f294ef60143700752aa4de122fb5345856edd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:33:39 +0100 Subject: [PATCH 38/82] Test Merlin hook mode under restrictive umask --- internal/router/service_merlin_unix_test.go | 38 +++++++++++++++++++++ 1 file changed, 38 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..a7516c10 --- /dev/null +++ b/internal/router/service_merlin_unix_test.go @@ -0,0 +1,38 @@ +//go:build !windows + +package router + +import ( + "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) + } +} From 639a9fd89bd1bbbddc1fbff4538b0d3fa9ad01db Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:34:31 +0100 Subject: [PATCH 39/82] Scope restrictive umask regression to Linux --- internal/router/service_merlin_unix_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_unix_test.go b/internal/router/service_merlin_unix_test.go index a7516c10..46ad1c30 100644 --- a/internal/router/service_merlin_unix_test.go +++ b/internal/router/service_merlin_unix_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build linux package router From 97b08f472b164d27aaefbb4952b809da2f1b8eb6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:40:02 +0100 Subject: [PATCH 40/82] Scope Merlin POSIX mode tests off Windows --- internal/router/service_merlin_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 5154ba72..4701491e 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" @@ -134,6 +135,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") @@ -201,6 +205,9 @@ 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") From cd368d4a6f7a6ebcf3b4f3458b353e4490a14d87 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:49:24 +0100 Subject: [PATCH 41/82] Reject symlinked Merlin startup scripts --- internal/router/service_merlin.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 91e860eb..40e7f66d 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -227,9 +227,18 @@ func (s *merlinSvc) Install() error { return fmt.Errorf("s.template.Execute: %w", err) } startupPublished := false - existing, err := os.ReadFile(confPath) + startupInfo, statErr := os.Lstat(confPath) switch { - case err == nil: + case statErr == nil: + // ctrld.startup is private to ctrld. Never follow a pre-existing symlink + // (or accept another special file) while running installer logic as root. + if !startupInfo.Mode().IsRegular() { + return fmt.Errorf("existing startup script is not a regular file: %s", confPath) + } + existing, err := os.ReadFile(confPath) + if err != nil { + return fmt.Errorf("read startup script: %w", err) + } if !bytes.Equal(existing, rendered.Bytes()) { return fmt.Errorf("already installed with different startup script: %s", confPath) } @@ -239,7 +248,7 @@ func (s *merlinSvc) Install() error { if err := os.Chmod(confPath, 0755); err != nil { return fmt.Errorf("os.Chmod: startup script: %w", err) } - case os.IsNotExist(err): + case os.IsNotExist(statErr): startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) if err != nil { if startupPublished { @@ -249,7 +258,7 @@ func (s *merlinSvc) Install() error { return fmt.Errorf("publish startup script: %w", err) } default: - return fmt.Errorf("read startup script: %w", err) + return fmt.Errorf("lstat startup script: %w", statErr) } installComplete := false From 79b0499944d5064b55a6c70a34f59a0931c040f1 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:49:48 +0100 Subject: [PATCH 42/82] Make Merlin startup path validation testable --- internal/router/service_merlin.go | 41 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 40e7f66d..e6909818 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -194,6 +194,24 @@ func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists b return true, nil } +func readMerlinStartupScript(path string) (data []byte, exists bool, err 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 (s *merlinSvc) Install() error { exePath, err := os.Executable() if err != nil { @@ -227,18 +245,13 @@ func (s *merlinSvc) Install() error { return fmt.Errorf("s.template.Execute: %w", err) } startupPublished := false - startupInfo, statErr := os.Lstat(confPath) - switch { - case statErr == nil: - // ctrld.startup is private to ctrld. Never follow a pre-existing symlink - // (or accept another special file) while running installer logic as root. - if !startupInfo.Mode().IsRegular() { - return fmt.Errorf("existing startup script is not a regular file: %s", confPath) - } - existing, err := os.ReadFile(confPath) - if err != nil { - return fmt.Errorf("read startup script: %w", err) - } + existing, startupExists, err := readMerlinStartupScript(confPath) + if err != nil { + return fmt.Errorf("read existing startup script: %w", err) + } + if startupExists { + // ctrld.startup is private to ctrld. readMerlinStartupScript uses Lstat, + // so this resumable path never follows a pre-existing symlink. if !bytes.Equal(existing, rendered.Bytes()) { return fmt.Errorf("already installed with different startup script: %s", confPath) } @@ -248,7 +261,7 @@ func (s *merlinSvc) Install() error { if err := os.Chmod(confPath, 0755); err != nil { return fmt.Errorf("os.Chmod: startup script: %w", err) } - case os.IsNotExist(statErr): + } else { startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) if err != nil { if startupPublished { @@ -257,8 +270,6 @@ func (s *merlinSvc) Install() error { } return fmt.Errorf("publish startup script: %w", err) } - default: - return fmt.Errorf("lstat startup script: %w", statErr) } installComplete := false From 4cab0f16daf06388c054a5f7759843304eaebc33 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:50:00 +0100 Subject: [PATCH 43/82] Test Merlin startup symlink rejection --- internal/router/service_merlin_test.go | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 4701491e..d5162ad7 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -264,3 +264,45 @@ func TestMerlinLegacyStartupHookLinesRemainRemovable(t *testing.T) { t.Fatalf("legacy service-event hook = %q", event) } } + + +func TestReadMerlinStartupScriptRejectsSymlink(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 := readMerlinStartupScript(link); 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 TestReadMerlinStartupScriptReadsRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + want := []byte("#!/bin/sh\n") + if err := os.WriteFile(path, want, 0700); err != nil { + t.Fatal(err) + } + got, exists, err := readMerlinStartupScript(path) + if err != nil { + t.Fatal(err) + } + if !exists || !bytes.Equal(got, want) { + t.Fatalf("regular startup script = (%q, %v), want (%q, true)", got, exists, want) + } +} From 01d063d4327a33894fb094db909cc364a7318b3a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:56:16 +0100 Subject: [PATCH 44/82] Pin Merlin startup validation to one descriptor --- .../router/service_merlin_startup_linux.go | 63 +++++++++++++++++++ 1 file changed, 63 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..f78878c0 --- /dev/null +++ b/internal/router/service_merlin_startup_linux.go @@ -0,0 +1,63 @@ +//go:build linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +func prepareExistingMerlinStartupScript(path string, expected []byte) (exists bool, retErr 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 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 + } + if !bytes.Equal(got, expected) { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + + // Keep validation, content comparison and chmod pinned to the same inode. + // O_NOFOLLOW prevents a pathname race from redirecting any of these + // privileged operations through a symlink. + if err := f.Chmod(0755); err != nil { + return true, err + } + if err := f.Sync(); err != nil { + return true, err + } + return true, nil +} From 9680e703e709b923908b3022ae7c62a8fc74cc7a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:56:18 +0100 Subject: [PATCH 45/82] Keep Merlin startup tests portable off Linux --- .../router/service_merlin_startup_other.go | 52 +++++++++++++++++++ 1 file changed, 52 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..882a77fa --- /dev/null +++ b/internal/router/service_merlin_startup_other.go @@ -0,0 +1,52 @@ +//go:build !linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" +) + +func prepareExistingMerlinStartupScript(path string, expected []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.Open(path) + 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 + } + if !bytes.Equal(got, expected) { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + if err := f.Chmod(0755); err != nil { + return true, err + } + return true, nil +} From b6114449d49c9daaa7974526776e8ac9b51f778d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:56:32 +0100 Subject: [PATCH 46/82] Use descriptor-pinned Merlin startup preparation --- internal/router/service_merlin.go | 36 +++---------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index e6909818..92f7ad67 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -194,24 +194,6 @@ func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists b return true, nil } -func readMerlinStartupScript(path string) (data []byte, exists bool, err 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 (s *merlinSvc) Install() error { exePath, err := os.Executable() if err != nil { @@ -245,23 +227,11 @@ func (s *merlinSvc) Install() error { return fmt.Errorf("s.template.Execute: %w", err) } startupPublished := false - existing, startupExists, err := readMerlinStartupScript(confPath) + startupExists, err := prepareExistingMerlinStartupScript(confPath, rendered.Bytes()) if err != nil { - return fmt.Errorf("read existing startup script: %w", err) + return fmt.Errorf("prepare existing startup script: %w", err) } - if startupExists { - // ctrld.startup is private to ctrld. readMerlinStartupScript uses Lstat, - // so this resumable path never follows a pre-existing symlink. - 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) - } - } else { + if !startupExists { startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) if err != nil { if startupPublished { From 20a2f835a5995671841824c3de43c69d645e7ac9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:56:59 +0100 Subject: [PATCH 47/82] Test descriptor-pinned Merlin startup preparation --- internal/router/service_merlin_test.go | 41 +++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index d5162ad7..acc25591 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -266,7 +266,7 @@ func TestMerlinLegacyStartupHookLinesRemainRemovable(t *testing.T) { } -func TestReadMerlinStartupScriptRejectsSymlink(t *testing.T) { +func TestPrepareExistingMerlinStartupScriptRejectsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink creation may require elevated privileges on Windows") } @@ -280,7 +280,7 @@ func TestReadMerlinStartupScriptRejectsSymlink(t *testing.T) { t.Fatal(err) } - if _, exists, err := readMerlinStartupScript(link); err == nil || !exists { + if exists, err := prepareExistingMerlinStartupScript(link, []byte("private\n")); err == nil || !exists { t.Fatalf("symlink startup script = (exists %v, err %v), want exists=true and error", exists, err) } got, err := os.ReadFile(target) @@ -292,17 +292,44 @@ func TestReadMerlinStartupScriptRejectsSymlink(t *testing.T) { } } -func TestReadMerlinStartupScriptReadsRegularFile(t *testing.T) { +func TestPrepareExistingMerlinStartupScriptRegularFile(t *testing.T) { path := filepath.Join(t.TempDir(), "ctrld.startup") want := []byte("#!/bin/sh\n") - if err := os.WriteFile(path, want, 0700); err != nil { + if err := os.WriteFile(path, want, 0600); err != nil { t.Fatal(err) } - got, exists, err := readMerlinStartupScript(path) + exists, err := prepareExistingMerlinStartupScript(path, want) if err != nil { t.Fatal(err) } - if !exists || !bytes.Equal(got, want) { - t.Fatalf("regular startup script = (%q, %v), want (%q, true)", got, exists, want) + 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")) + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("missing startup script reported as existing") } } From ae44e5e0ba4f7347654f1812b3b3c01a074980e4 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:23 +0100 Subject: [PATCH 48/82] Serialize Merlin service lifecycle mutations --- internal/router/service_merlin_lock_linux.go | 27 ++++++++++++++++++++ 1 file changed, 27 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..66cbb44a --- /dev/null +++ b/internal/router/service_merlin_lock_linux.go @@ -0,0 +1,27 @@ +//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 { + f, err := os.OpenFile(merlinServiceLockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer f.Close() + + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("lock Merlin service lifecycle: %w", err) + } + defer unix.Flock(int(f.Fd()), unix.LOCK_UN) + + return fn() +} From 4722c738abee60e9e89c1cee839bef2f7b1fd84d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:26 +0100 Subject: [PATCH 49/82] Serialize Merlin service lifecycle mutations --- 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 a043c2accf2a2d29bcfb3afdc4b9eba03fe75b6c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:40 +0100 Subject: [PATCH 50/82] Migrate recognized legacy Merlin startup script safely --- .../router/service_merlin_startup_linux.go | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go index f78878c0..7374b225 100644 --- a/internal/router/service_merlin_startup_linux.go +++ b/internal/router/service_merlin_startup_linux.go @@ -11,7 +11,7 @@ import ( "golang.org/x/sys/unix" ) -func prepareExistingMerlinStartupScript(path string, expected []byte) (exists bool, retErr error) { +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr 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 { @@ -46,18 +46,41 @@ func prepareExistingMerlinStartupScript(path string, expected []byte) (exists bo if err != nil { return true, err } - if !bytes.Equal(got, expected) { + 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) } - // Keep validation, content comparison and chmod pinned to the same inode. - // O_NOFOLLOW prevents a pathname race from redirecting any of these - // privileged operations through a symlink. + // Keep validation, content comparison, optional legacy migration and chmod + // pinned to the same inode. O_NOFOLLOW prevents a pathname race from + // redirecting privileged operations through a symlink. + if needsMigration { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return true, err + } + if err := f.Truncate(0); err != nil { + return true, err + } + if _, err := f.Write(expected); err != nil { + return true, err + } + } if err := f.Chmod(0755); err != nil { return true, err } if err := f.Sync(); err != nil { return true, err } + + // Confirm the pathname still names the same inode we validated/migrated. + // If another lifecycle operation replaced it, leave that replacement alone + // and report the race to the caller. + 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 } From dc708c673a4d2dbc5dbfcf947cdb8b431dfbd1be Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:51 +0100 Subject: [PATCH 51/82] Migrate recognized legacy Merlin startup script portably --- internal/router/service_merlin_startup_other.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go index 882a77fa..a23399a9 100644 --- a/internal/router/service_merlin_startup_other.go +++ b/internal/router/service_merlin_startup_other.go @@ -9,7 +9,7 @@ import ( "os" ) -func prepareExistingMerlinStartupScript(path string, expected []byte) (exists bool, retErr error) { +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { info, err := os.Lstat(path) if os.IsNotExist(err) { return false, nil @@ -42,9 +42,21 @@ func prepareExistingMerlinStartupScript(path string, expected []byte) (exists bo if err != nil { return true, err } - if !bytes.Equal(got, expected) { + 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 := f.Seek(0, io.SeekStart); err != nil { + return true, err + } + if err := f.Truncate(0); err != nil { + return true, err + } + if _, err := f.Write(expected); err != nil { + return true, err + } + } if err := f.Chmod(0755); err != nil { return true, err } From 847c1a542e8477ea307f1028cec420074f8de6d6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:10:28 +0100 Subject: [PATCH 52/82] Migrate legacy Merlin startup scripts on service start --- internal/router/service_merlin.go | 170 +++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 16 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 92f7ad67..ed92c41d 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -67,6 +67,53 @@ 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 +} + +func (s *merlinSvc) refreshMerlinStartupScript() error { + exePath := s.Config.Executable + if exePath == "" { + var err error + exePath, err = os.Executable() + if err != nil { + return err + } + } + current, legacy, err := s.renderStartupScripts(exePath) + if err != nil { + return err + } + exists, err := prepareExistingMerlinStartupScript(s.configPath(), current, legacy) + if err != nil { + return err + } + if !exists { + return service.ErrNotInstalled + } + return nil +} + func merlinShellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } @@ -195,6 +242,10 @@ func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists b } func (s *merlinSvc) Install() error { + return withMerlinServiceLock(s.installLocked) +} + +func (s *merlinSvc) installLocked() error { exePath, err := os.Executable() if err != nil { return err @@ -212,27 +263,20 @@ 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 - startupExists, err := prepareExistingMerlinStartupScript(confPath, rendered.Bytes()) + startupExists, err := prepareExistingMerlinStartupScript(confPath, currentStartup, legacyStartup) if err != nil { return fmt.Errorf("prepare existing startup script: %w", err) } if !startupExists { - startupPublished, err = writeMerlinStartupScript(confPath, rendered.Bytes(), 0755) + startupPublished, err = writeMerlinStartupScript(confPath, currentStartup, 0755) if err != nil { if startupPublished { _ = os.Remove(confPath) @@ -373,6 +417,10 @@ func (s *merlinSvc) Install() error { } 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) @@ -485,7 +533,15 @@ func merlinServiceStatus(out []byte, cmdErr error) (service.Status, error) { } func (s *merlinSvc) Start() error { - return exec.Command(s.configPath(), "start").Run() + return withMerlinServiceLock(func() error { + // Upgrades replace only the binary before restarting the service. Refresh + // an exact ctrld-owned legacy startup script here so lifecycle/security + // fixes reach existing installations without requiring a reinstall. + if err := s.refreshMerlinStartupScript(); err != nil { + return err + } + return exec.Command(s.configPath(), "start").Run() + }) } func (s *merlinSvc) Stop() error { @@ -500,6 +556,88 @@ func (s *merlinSvc) Restart() error { return s.Start() } +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 name={{shellQuote .Name}} From e4ecdf5b0af894a47da9ef7692e5c5c39c2cba2c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:11:08 +0100 Subject: [PATCH 53/82] Test safe Merlin startup migration on upgrade --- internal/router/service_merlin_test.go | 68 ++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index acc25591..1ab11170 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -280,7 +280,7 @@ func TestPrepareExistingMerlinStartupScriptRejectsSymlink(t *testing.T) { t.Fatal(err) } - if exists, err := prepareExistingMerlinStartupScript(link, []byte("private\n")); err == nil || !exists { + 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) @@ -298,7 +298,7 @@ func TestPrepareExistingMerlinStartupScriptRegularFile(t *testing.T) { if err := os.WriteFile(path, want, 0600); err != nil { t.Fatal(err) } - exists, err := prepareExistingMerlinStartupScript(path, want) + exists, err := prepareExistingMerlinStartupScript(path, want, want) if err != nil { t.Fatal(err) } @@ -325,7 +325,7 @@ func TestPrepareExistingMerlinStartupScriptRegularFile(t *testing.T) { func TestPrepareExistingMerlinStartupScriptMissing(t *testing.T) { path := filepath.Join(t.TempDir(), "ctrld.startup") - exists, err := prepareExistingMerlinStartupScript(path, []byte("#!/bin/sh\n")) + exists, err := prepareExistingMerlinStartupScript(path, []byte("#!/bin/sh\n"), nil) if err != nil { t.Fatal(err) } @@ -333,3 +333,65 @@ func TestPrepareExistingMerlinStartupScriptMissing(t *testing.T) { 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) + } +} From ac0626fe31343162ab2a50d7bf63f6c5cc8572a0 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:13:11 +0100 Subject: [PATCH 54/82] Open Merlin startup script writable for legacy migration --- internal/router/service_merlin_startup_linux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go index 7374b225..3ee2086c 100644 --- a/internal/router/service_merlin_startup_linux.go +++ b/internal/router/service_merlin_startup_linux.go @@ -12,7 +12,7 @@ import ( ) func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + 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 From 2365644e320f4ff31ae5553be8a54f86b3376f4f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:13:13 +0100 Subject: [PATCH 55/82] Open Merlin startup script writable for legacy migration --- internal/router/service_merlin_startup_other.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go index a23399a9..12112e62 100644 --- a/internal/router/service_merlin_startup_other.go +++ b/internal/router/service_merlin_startup_other.go @@ -21,7 +21,7 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, fmt.Errorf("startup script is not a regular file: %s", path) } - f, err := os.Open(path) + f, err := os.OpenFile(path, os.O_RDWR, 0) if err != nil { return true, err } From 3da0f98801c931c8dd1ef2aa93f6852422ca441f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:16:01 +0100 Subject: [PATCH 56/82] Harden Merlin lifecycle lock path --- internal/router/service_merlin_lock_linux.go | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/router/service_merlin_lock_linux.go b/internal/router/service_merlin_lock_linux.go index 66cbb44a..9843a50b 100644 --- a/internal/router/service_merlin_lock_linux.go +++ b/internal/router/service_merlin_lock_linux.go @@ -12,16 +12,36 @@ import ( const merlinServiceLockPath = "/tmp/ctrld-merlin-service.lock" func withMerlinServiceLock(fn func() error) error { - f, err := os.OpenFile(merlinServiceLockPath, os.O_CREATE|os.O_RDWR, 0600) + fd, err := unix.Open( + merlinServiceLockPath, + unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0600, + ) if err != nil { - return err + 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() - if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + 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(int(f.Fd()), unix.LOCK_UN) + defer unix.Flock(fd, unix.LOCK_UN) return fn() } From ab2ce892a3c5278b2b65c59077b689557393c03c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:19:32 +0100 Subject: [PATCH 57/82] Fix Merlin lock error style --- internal/router/service_merlin_lock_linux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_merlin_lock_linux.go b/internal/router/service_merlin_lock_linux.go index 9843a50b..5641abb0 100644 --- a/internal/router/service_merlin_lock_linux.go +++ b/internal/router/service_merlin_lock_linux.go @@ -32,7 +32,7 @@ func withMerlinServiceLock(fn func() error) error { 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) + 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) From ccf61feaf8297ac41af0e4e9ee5916e2a1c0fa98 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:59:37 +0100 Subject: [PATCH 58/82] Preserve installed Merlin startup arguments during refresh --- internal/router/service_merlin.go | 66 ++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index ed92c41d..ddad6146 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 { @@ -100,11 +101,41 @@ func (s *merlinSvc) refreshMerlinStartupScript() error { return err } } - current, legacy, err := s.renderStartupScripts(exePath) + + 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 } - exists, err := prepareExistingMerlinStartupScript(s.configPath(), current, legacy) + if !bytes.Equal(installed, legacy) { + return nil + } + exists, err = prepareExistingMerlinStartupScript(s.configPath(), current, legacy) if err != nil { return err } @@ -114,6 +145,36 @@ func (s *merlinSvc) refreshMerlinStartupScript() error { 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, "'", "'\"'\"'") + "'" } @@ -639,6 +700,7 @@ exit 0 ` const merlinSvcScript = `#!/bin/sh +# GENERATED BY ctrld - Merlin service v2 name={{shellQuote .Name}} exe={{shellQuote .Path}} From 9fe809f7beae72e9c71d1bee1f61450e809c9bb6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:00:02 +0100 Subject: [PATCH 59/82] Migrate Merlin startup scripts atomically --- .../router/service_merlin_startup_linux.go | 98 ++++++++++++++++--- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go index 3ee2086c..a04f3c83 100644 --- a/internal/router/service_merlin_startup_linux.go +++ b/internal/router/service_merlin_startup_linux.go @@ -7,10 +7,42 @@ import ( "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 { @@ -51,30 +83,23 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, fmt.Errorf("already installed with different startup script: %s", path) } - // Keep validation, content comparison, optional legacy migration and chmod - // pinned to the same inode. O_NOFOLLOW prevents a pathname race from - // redirecting privileged operations through a symlink. if needsMigration { - if _, err := f.Seek(0, io.SeekStart); err != nil { - return true, err - } - if err := f.Truncate(0); err != nil { - return true, err - } - if _, err := f.Write(expected); err != nil { + // 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, 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 } - - // Confirm the pathname still names the same inode we validated/migrated. - // If another lifecycle operation replaced it, leave that replacement alone - // and report the race to the caller. pathInfo, err := os.Lstat(path) if err != nil { return true, err @@ -84,3 +109,48 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e } return true, nil } + +func replaceMerlinStartupScriptAtomically(path string, expected []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() + 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 + } + + // Revalidate ownership immediately before publication. The service-level + // flock serializes ctrld lifecycle operations; this check additionally + // refuses to overwrite a pathname another actor replaced during migration. + 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) + } + + if err := os.Rename(tmpPath, path); err != nil { + return err + } + if err := syncMerlinServiceDir(dir); err != nil { + return err + } + return nil +} From 289e6097d0118d8aceefea90bc53583f328dc401 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:00:16 +0100 Subject: [PATCH 60/82] Keep portable Merlin startup migration atomic --- .../router/service_merlin_startup_other.go | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go index 12112e62..619ecc86 100644 --- a/internal/router/service_merlin_startup_other.go +++ b/internal/router/service_merlin_startup_other.go @@ -7,8 +7,27 @@ import ( "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) { @@ -47,18 +66,47 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, fmt.Errorf("already installed with different startup script: %s", path) } if needsMigration { - if _, err := f.Seek(0, io.SeekStart); err != nil { - return true, err - } - if err := f.Truncate(0); err != nil { - return true, err - } - if _, err := f.Write(expected); err != nil { + if err := replaceMerlinStartupScriptAtomically(path, expected, 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 []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) + } + return os.Rename(tmpPath, path) +} From 5f3349999c07c120741f290b0e5359df0baca7dd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:00:39 +0100 Subject: [PATCH 61/82] Test Merlin startup argument recovery and conservative refresh --- internal/router/service_merlin_test.go | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 1ab11170..1479ec6c 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -395,3 +395,103 @@ func TestPrepareExistingMerlinStartupScriptRejectsUnknownCustomization(t *testin 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 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") + } +} From 09b4279e2b1566721cbf944d321046fb2a5d1a39 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:12:30 +0100 Subject: [PATCH 62/82] Migrate Merlin startup from the new running binary --- internal/router/service_merlin.go | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index ddad6146..3367b009 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -92,6 +92,39 @@ func (s *merlinSvc) renderStartupScripts(exePath string) (current, legacy []byte 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 + } + + 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 + }) +} + func (s *merlinSvc) refreshMerlinStartupScript() error { exePath := s.Config.Executable if exePath == "" { From f846a1ca3685e5fcc17aa1ad6ab26dbc85b72e43 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:12:47 +0100 Subject: [PATCH 63/82] Run Merlin startup migration from upgraded binary --- cmd/cli/cli.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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) { From 99968eba555e4ca20cfaa1964cc7e6434c9f1f91 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:14:29 +0100 Subject: [PATCH 64/82] Serialize Merlin stop and restart lifecycle --- internal/router/service_merlin.go | 35 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 3367b009..8e11c7af 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -627,27 +627,34 @@ func merlinServiceStatus(out []byte, cmdErr error) (service.Status, error) { } func (s *merlinSvc) Start() error { - return withMerlinServiceLock(func() error { - // Upgrades replace only the binary before restarting the service. Refresh - // an exact ctrld-owned legacy startup script here so lifecycle/security - // fixes reach existing installations without requiring a reinstall. - if err := s.refreshMerlinStartupScript(); err != nil { - return err - } - return exec.Command(s.configPath(), "start").Run() - }) + 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 { + return err + } + return exec.Command(s.configPath(), "start").Run() } 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 withMerlinServiceLock(func() error { + if err := s.stopLocked(); err != nil { + return err + } + return s.startLocked() + }) } const merlinLegacySvcScript = `#!/bin/sh From 802998736d16a2206c095db0f8cc40f0a218d596 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:15:01 +0100 Subject: [PATCH 65/82] Make upgraded-binary Merlin migration testable --- internal/router/service_merlin.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8e11c7af..04493152 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -108,7 +108,10 @@ func RefreshMerlinStartupScriptFromRunningBinary() error { if err != nil { return err } + return refreshMerlinStartupScriptForExecutable(exePath) +} +func refreshMerlinStartupScriptForExecutable(exePath string) error { s := &merlinSvc{ Config: &service.Config{ Name: "ctrld", From 1d5b7567aee2f7d8ef3f2c7ce2ca4ab5a1f51a8b Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:15:25 +0100 Subject: [PATCH 66/82] Test first upgraded-binary Merlin migration --- internal/router/service_merlin_test.go | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 1479ec6c..1793e3d4 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -444,6 +444,40 @@ func TestRefreshMerlinStartupScriptRecoversInstalledLegacyArguments(t *testing.T } } +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") From 51f2f938f9c118d108e6a29115153729de353f9a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:23 +0100 Subject: [PATCH 67/82] Start safely when legacy Merlin migration cannot publish --- internal/router/service_merlin.go | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 04493152..b101fd84 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -128,6 +128,14 @@ func refreshMerlinStartupScriptForExecutable(exePath string) error { }) } +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 == "" { @@ -173,7 +181,10 @@ func (s *merlinSvc) refreshMerlinStartupScript() error { } exists, err = prepareExistingMerlinStartupScript(s.configPath(), current, legacy) if err != nil { - return err + return &merlinStartupMigrationError{ + err: err, + legacy: append([]byte(nil), legacy...), + } } if !exists { return service.ErrNotInstalled @@ -638,7 +649,24 @@ func (s *merlinSvc) startLocked() error { // 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 { - return err + 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. + installed, exists, readErr := readExistingMerlinStartupScript(s.configPath()) + if readErr != nil { + return errors.Join(err, readErr) + } + if !exists || (!bytes.Equal(installed, migrationErr.legacy) && !merlinStartupScriptHasMarker(installed)) { + return err + } } return exec.Command(s.configPath(), "start").Run() } From 55c3548c0e4e679f1b96bb15368ac932bcee8180 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:52 +0100 Subject: [PATCH 68/82] Publish Merlin startup migration with atomic target verification --- .../router/service_merlin_startup_linux.go | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go index a04f3c83..0ccf1025 100644 --- a/internal/router/service_merlin_startup_linux.go +++ b/internal/router/service_merlin_startup_linux.go @@ -87,7 +87,7 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e // 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, info); err != nil { + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { return true, err } return true, nil @@ -110,7 +110,7 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, nil } -func replaceMerlinStartupScriptAtomically(path string, expected []byte, originalInfo os.FileInfo) (retErr error) { +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 { @@ -135,18 +135,50 @@ func replaceMerlinStartupScriptAtomically(path string, expected []byte, original return err } - // Revalidate ownership immediately before publication. The service-level - // flock serializes ctrld lifecycle operations; this check additionally - // refuses to overwrite a pathname another actor replaced during migration. - pathInfo, err := os.Lstat(path) + // 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 { + if err := unix.Renameat2( + unix.AT_FDCWD, tmpPath, + unix.AT_FDCWD, path, + unix.RENAME_EXCHANGE, + ); err != nil { + return fmt.Errorf("%v; failed to restore startup script after exchange: %w", cause, err) + } + return cause + } + + oldInfo, err := os.Lstat(tmpPath) if err != nil { - return err + 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 !os.SameFile(originalInfo, pathInfo) { - return fmt.Errorf("startup script changed before migration publish: %s", path) + if !exists || !bytes.Equal(oldBytes, legacy) { + return rollbackExchange(fmt.Errorf("startup script contents changed before migration publish: %s", path)) } - if err := os.Rename(tmpPath, path); err != nil { + // 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 { @@ -154,3 +186,4 @@ func replaceMerlinStartupScriptAtomically(path string, expected []byte, original } return nil } + From 6aba9a3db876d3d199a8661a1ca8ff13a25089fd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:55 +0100 Subject: [PATCH 69/82] Revalidate portable Merlin startup migration content --- internal/router/service_merlin_startup_other.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go index 619ecc86..b810abfa 100644 --- a/internal/router/service_merlin_startup_other.go +++ b/internal/router/service_merlin_startup_other.go @@ -66,7 +66,7 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, fmt.Errorf("already installed with different startup script: %s", path) } if needsMigration { - if err := replaceMerlinStartupScriptAtomically(path, expected, info); err != nil { + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { return true, err } return true, nil @@ -77,7 +77,7 @@ func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (e return true, nil } -func replaceMerlinStartupScriptAtomically(path string, expected []byte, originalInfo os.FileInfo) error { +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 { @@ -108,5 +108,12 @@ func replaceMerlinStartupScriptAtomically(path string, expected []byte, original 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 cc594d2827e66ead6931935e03b78bd5cbc156c1 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:20:02 +0100 Subject: [PATCH 70/82] Make Merlin migration fallback decision testable --- internal/router/service_merlin.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index b101fd84..8722ef50 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -660,17 +660,28 @@ func (s *merlinSvc) startLocked() error { // 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. - installed, exists, readErr := readExistingMerlinStartupScript(s.configPath()) + canStart, readErr := canStartMerlinStartupAfterMigrationError(s.configPath(), migrationErr.legacy) if readErr != nil { return errors.Join(err, readErr) } - if !exists || (!bytes.Equal(installed, migrationErr.legacy) && !merlinStartupScriptHasMarker(installed)) { + 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) } From dc8520d37577c318d4ca276f7581bdc601f131b2 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:20:22 +0100 Subject: [PATCH 71/82] Test safe fallback after Merlin migration failure --- internal/router/service_merlin_test.go | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 1793e3d4..58573721 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -529,3 +529,51 @@ func TestRecoverLegacyMerlinServiceConfigRejectsWrongExecutable(t *testing.T) { 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 1541952cf5d6a0fffe4d5caba3e62d29c1a127f3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:20:24 +0100 Subject: [PATCH 72/82] Test atomic Merlin migration race preservation --- internal/router/service_merlin_unix_test.go | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/internal/router/service_merlin_unix_test.go b/internal/router/service_merlin_unix_test.go index 46ad1c30..d002b5bc 100644 --- a/internal/router/service_merlin_unix_test.go +++ b/internal/router/service_merlin_unix_test.go @@ -36,3 +36,69 @@ func TestCreateMerlinSharedHookStubIgnoresRestrictiveUmask(t *testing.T) { t.Fatalf("shared hook contents = %q", buf) } } + + +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 793e3774c9ffc7365849e7179dc3694b631f6c1e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:21:25 +0100 Subject: [PATCH 73/82] Keep published Merlin startup script for resumable install --- internal/router/service_merlin.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8722ef50..5750ebb4 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -378,30 +378,20 @@ func (s *merlinSvc) installLocked() error { if err != nil { return err } - startupPublished := false startupExists, err := prepareExistingMerlinStartupScript(confPath, currentStartup, legacyStartup) if err != nil { return fmt.Errorf("prepare existing startup script: %w", err) } if !startupExists { - startupPublished, err = writeMerlinStartupScript(confPath, currentStartup, 0755) - if err != nil { - if startupPublished { - _ = os.Remove(confPath) - _ = syncMerlinServiceDir(filepath.Dir(confPath)) - } + 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) } } - 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) } @@ -520,7 +510,6 @@ func (s *merlinSvc) installLocked() error { installed = append(installed, hook) } - installComplete = true return nil } From 643c5742362b875e0a57e2de5554affc12974fb8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:24:11 +0100 Subject: [PATCH 74/82] Expose atomic Merlin restart transaction --- internal/router/service_merlin.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 5750ebb4..f1fab60d 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -680,10 +680,22 @@ func (s *merlinSvc) stopLocked() error { } func (s *merlinSvc) Restart() error { + 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() }) } From 4f43ebc8928917ffe6ab04529d842c8428ccb135 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:24:37 +0100 Subject: [PATCH 75/82] Serialize real CLI restart transactions on Merlin --- cmd/cli/commands.go | 100 +++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index a1074f29..1dc4263f 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -681,6 +681,25 @@ func initStopCmd() *cobra.Command { return stopCmd } +type restartWithBetweenService interface { + RestartWith(func() error) error +} + +func restartServiceTransaction(s service.Service, between func() error) error { + if tx, ok := s.(restartWithBetweenService); ok { + return tx.RestartWith(between) + } + if err := s.Stop(); err != nil { + return err + } + if between != nil { + if err := between(); err != nil { + return err + } + } + return s.Start() +} + func initRestartCmd() *cobra.Command { restartCmd := &cobra.Command{ PreRun: func(cmd *cobra.Command, args []string) { @@ -726,49 +745,34 @@ 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 fmt.Errorf("timeout while waiting for service to stop") default: } - time.Sleep(time.Second) if status, _ := s.Status(); status == service.StatusStopped { break } + time.Sleep(time.Second) } } - } else { - return false - } - - tasks = []task{ - {s.Start, true, "Start"}, + return nil } - return doTasks(tasks) - + return doTasks([]task{ + {func() error { return restartServiceTransaction(s, between) }, true, "Restart"}, + }) } if doRestart() { @@ -1288,54 +1292,46 @@ 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() - loop: for { select { case <-ctx.Done(): - mainLog.Load().Error().Msg("timeout while waiting for service to stop") - break loop + return fmt.Errorf("timeout while waiting for service to stop") default: } - time.Sleep(time.Second) if status, _ := s.Status(); status == service.StatusStopped { break } + time.Sleep(time.Second) } } + return nil } - tasks = []task{ - {s.Start, true, "Start"}, + if err := restartServiceTransaction(s, between); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to restart ctrld service") + return false } - 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") } From d87063f38bb175989e636fc96c0e090b2d621e6a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:25:00 +0100 Subject: [PATCH 76/82] Preserve restart behavior after stop wait timeout --- cmd/cli/commands.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 1dc4263f..28ab85cc 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -758,7 +758,8 @@ func initRestartCmd() *cobra.Command { for { select { case <-ctx.Done(): - return fmt.Errorf("timeout while waiting for service to stop") + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + return nil default: } if status, _ := s.Status(); status == service.StatusStopped { @@ -1306,7 +1307,8 @@ func initUpgradeCmd() *cobra.Command { for { select { case <-ctx.Done(): - return fmt.Errorf("timeout while waiting for service to stop") + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + return nil default: } if status, _ := s.Status(); status == service.StatusStopped { From 14cf31728192c72c68fff1b1543ca123b3658ac3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:26:30 +0100 Subject: [PATCH 77/82] Make Merlin migration rollback non-clobbering --- .../router/service_merlin_startup_linux.go | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go index 0ccf1025..0f5c7609 100644 --- a/internal/router/service_merlin_startup_linux.go +++ b/internal/router/service_merlin_startup_linux.go @@ -117,9 +117,12 @@ func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, return err } tmpPath := tmp.Name() + removeTmp := true defer func() { _ = tmp.Close() - _ = os.Remove(tmpPath) + if removeTmp { + _ = os.Remove(tmpPath) + } }() if err := tmp.Chmod(0755); err != nil { @@ -131,6 +134,10 @@ func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, 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 } @@ -149,12 +156,42 @@ func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, } 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 { - return fmt.Errorf("%v; failed to restore startup script after exchange: %w", cause, err) + 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 } From 9fff4c64b6f916dad9a22b5e20463da2d8b03961 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:29:46 +0100 Subject: [PATCH 78/82] Limit atomic restart transaction to Merlin --- cmd/cli/commands.go | 108 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 18 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 28ab85cc..32b94688 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -685,19 +685,12 @@ type restartWithBetweenService interface { RestartWith(func() error) error } -func restartServiceTransaction(s service.Service, between func() error) error { - if tx, ok := s.(restartWithBetweenService); ok { - return tx.RestartWith(between) +func restartServiceTransaction(s service.Service, between func() error) (bool, error) { + tx, ok := s.(restartWithBetweenService) + if !ok { + return false, nil } - if err := s.Stop(); err != nil { - return err - } - if between != nil { - if err := between(); err != nil { - return err - } - } - return s.Start() + return true, tx.RestartWith(between) } func initRestartCmd() *cobra.Command { @@ -771,9 +764,50 @@ func initRestartCmd() *cobra.Command { return nil } - return doTasks([]task{ - {func() error { return restartServiceTransaction(s, between) }, true, "Restart"}, - }) + if _, ok := s.(restartWithBetweenService); ok { + return doTasks([]task{ + {func() error { + _, err := restartServiceTransaction(s, between) + return err + }, true, "Restart"}, + }) + } + + // 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() + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + goto normalStart + default: + } + time.Sleep(time.Second) + if status, _ := s.Status(); status == service.StatusStopped { + break + } + } + } + normalStart: + return doTasks([]task{{s.Start, true, "Start"}}) } if doRestart() { @@ -1320,9 +1354,47 @@ func initUpgradeCmd() *cobra.Command { return nil } - if err := restartServiceTransaction(s, between); err != nil { - mainLog.Load().Error().Err(err).Msg("failed to restart ctrld service") - return false + 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() + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + goto upgradeStart + default: + } + time.Sleep(time.Second) + if status, _ := s.Status(); status == service.StatusStopped { + break + } + } + } + upgradeStart: + if !doTasks([]task{{s.Start, true, "Start"}}) { + return false + } } if dir, err := socketDir(); err == nil { From ca315796d4c9ae2de994f810ee15920d5defff52 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:30:13 +0100 Subject: [PATCH 79/82] Keep non-Merlin restart wait flow unchanged --- cmd/cli/commands.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 32b94688..a0ea6ec3 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -793,11 +793,13 @@ func initRestartCmd() *cobra.Command { 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") - goto normalStart + break loop default: } time.Sleep(time.Second) @@ -806,7 +808,6 @@ func initRestartCmd() *cobra.Command { } } } - normalStart: return doTasks([]task{{s.Start, true, "Start"}}) } @@ -1378,11 +1379,13 @@ func initUpgradeCmd() *cobra.Command { if doTasks(tasks) && 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") - goto upgradeStart + break loop default: } time.Sleep(time.Second) @@ -1391,7 +1394,6 @@ func initUpgradeCmd() *cobra.Command { } } } - upgradeStart: if !doTasks([]task{{s.Start, true, "Start"}}) { return false } From d6db326e2ca18188cb189e7d931e22c05db1659c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:47 +0100 Subject: [PATCH 80/82] Harden Merlin hook publication and PID stop handling --- internal/router/service_merlin.go | 65 +++++++++++++++++++------------ 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index f1fab60d..5eee837b 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -292,44 +292,50 @@ func syncMerlinServiceDir(dir string) error { return f.Sync() } -func createMerlinSharedHookStub(path string) (retErr error) { - stub, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) +func createMerlinSharedHookStub(path string) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-*") if err != nil { return err } - closed := false + tmpPath := tmp.Name() defer func() { - if !closed { - _ = stub.Close() - } - if retErr != nil { - _ = os.Remove(path) - _ = syncMerlinServiceDir(filepath.Dir(path)) - } + _ = tmp.Close() + _ = os.Remove(tmpPath) }() - // OpenFile creation mode is filtered through the process umask. Merlin must - // execute services-start/service-event at boot, so set the final mode - // explicitly on the already-open inode before making it durable. - if err := stub.Chmod(0755); err != nil { + // 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 := stub.Write([]byte("#!/bin/sh\n")); err != nil { + if _, err := tmp.Write([]byte("#!/bin/sh\n")); err != nil { return err } - if err := stub.Sync(); err != nil { + if err := tmp.Sync(); err != nil { return err } - if err := stub.Close(); err != nil { + if err := tmp.Close(); err != nil { return err } - closed = true - if err := syncMerlinServiceDir(filepath.Dir(path)); err != nil { + + // 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) { @@ -798,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 @@ -808,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 @@ -838,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" From 4f2e88c7ab9263f626a5a6d26234500d5a0a761e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:59 +0100 Subject: [PATCH 81/82] Test Merlin stop uses validated PID --- internal/router/service_merlin_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go index 58573721..545b3d0b 100644 --- a/internal/router/service_merlin_test.go +++ b/internal/router/service_merlin_test.go @@ -20,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) { From 6db0253d1bde62ff6188d5b5bace0b62a5b6bb29 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:59:01 +0100 Subject: [PATCH 82/82] Test shared Merlin hook publication is no-clobber --- internal/router/service_merlin_unix_test.go | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/router/service_merlin_unix_test.go b/internal/router/service_merlin_unix_test.go index d002b5bc..c0cb1826 100644 --- a/internal/router/service_merlin_unix_test.go +++ b/internal/router/service_merlin_unix_test.go @@ -3,6 +3,7 @@ package router import ( + "bytes" "os" "path/filepath" "syscall" @@ -38,6 +39,27 @@ func TestCreateMerlinSharedHookStubIgnoresRestrictiveUmask(t *testing.T) { } +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")