From 5c356e8de09f178a35b9ba289d0f8754ab25db5c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:22:00 +0100 Subject: [PATCH 01/35] Preserve Merlin postconf execution --- internal/router/dnsmasq/dnsmasq.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 058b0b59..c3370a9a 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -28,18 +28,21 @@ max-cache-ttl=0 ` const ( - MerlinConfPath = "/tmp/etc/dnsmasq.conf" - MerlinJffsConfDir = "/jffs/configs" - MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" - MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" + MerlinConfPath = "/tmp/etc/dnsmasq.conf" + MerlinJffsConfDir = "/jffs/configs" + MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" + MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" + MerlinSdnPostConfPath = "/jffs/scripts/dnsmasq-sdn.postconf" ) -const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` -const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY - -#!/bin/sh +const ( + MerlinPostConfBeginMarker = `# BEGIN ctrld` + MerlinPostConfEndMarker = `# END ctrld` + // MerlinPostConfMarker is the legacy end marker used by ctrld <= 1.5.7. + MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` +) -config_file="$1" +const MerlinPostConfTmpl = `config_file="$1" . /usr/sbin/helper.sh pid=$(cat /tmp/ctrld.pid 2>/dev/null) @@ -66,8 +69,6 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then # Change /etc/resolv.conf, which may be changed by WAN DNS setup pc_delete "nameserver" /etc/resolv.conf pc_append "nameserver 127.0.0.1" /etc/resolv.conf - - exit 0 fi ` From 8fd8f308336551e69bbc99beaaf05df011119d9c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:22:25 +0100 Subject: [PATCH 02/35] Make Merlin postconf hooks coexist safely --- internal/router/merlin/merlin.go | 126 ++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index c1c68210..3ab66dcf 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -125,13 +125,10 @@ func (m *Merlin) Cleanup() error { return err } - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - if err != nil && !os.IsNotExist(err) { - return err - } - // Restore dnsmasq post conf file. - if err := os.WriteFile(dnsmasq.MerlinPostConfPath, merlinParsePostConf(buf), 0750); err != nil { - return err + for _, path := range []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} { + if err := cleanupDnsmasqPostconf(path); err != nil { + return err + } } for _, cfg := range getDnsmasqConfigs() { @@ -176,8 +173,12 @@ func (m *Merlin) setupDnsmasq(cfg *dnsmasqConfig) error { return fmt.Errorf("failed to save %s: %w", cfg.jffsConfPath, err) } - // Run postconf script on cfg.jffsConfPath directly. - cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, cfg.jffsConfPath) + // Run the appropriate postconf script on cfg.jffsConfPath directly. + postConfPath := dnsmasq.MerlinPostConfPath + if cfg.confPath != dnsmasq.MerlinConfPath { + postConfPath = dnsmasq.MerlinSdnPostConfPath + } + cmd := exec.Command("/bin/sh", postConfPath, cfg.jffsConfPath) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to run post conf: %s: %w", string(out), err) } @@ -193,13 +194,18 @@ func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { return nil } -// writeDnsmasqPostconf writes the requireddnsmasqConfigs post-configuration for dnsmasq to enable custom DNS settings with ctrld. +// writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN hooks while preserving unrelated content. func (m *Merlin) writeDnsmasqPostconf() error { - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - // Already setup. - if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { - return nil + for _, path := range []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} { + if err := m.writeDnsmasqPostconfFile(path); err != nil { + return err + } } + return nil +} + +func (m *Merlin) writeDnsmasqPostconfFile(path string) error { + buf, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return err } @@ -208,15 +214,34 @@ func (m *Merlin) writeDnsmasqPostconf() error { if err != nil { return err } - data = strings.Join([]string{ - data, - "\n", - dnsmasq.MerlinPostConfMarker, - "\n", - string(buf), + + block := strings.Join([]string{ + dnsmasq.MerlinPostConfBeginMarker, + strings.TrimSpace(data), + dnsmasq.MerlinPostConfEndMarker, }, "\n") - // Write dnsmasq post conf file. - return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750) + + return os.WriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) +} + +func cleanupDnsmasqPostconf(path string) error { + buf, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + clean := merlinParsePostConf(buf) + if len(bytes.TrimSpace(clean)) == 0 || bytes.Equal(bytes.TrimSpace(clean), []byte("#!/bin/sh")) { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + + return os.WriteFile(path, clean, 0750) } // restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". @@ -241,20 +266,67 @@ func getDnsmasqConfigs() []*dnsmasqConfig { return cfgs } -// merlinParsePostConf parses the dnsmasq post configuration by removing content after the MerlinPostConfMarker, if present. -// If no marker is found, the original buffer is returned unmodified. -// Returns nil if the input buffer is empty. +// merlinParsePostConf removes ctrld-owned postconf content while preserving unrelated hook logic. +// It understands both the current BEGIN/END block and the legacy ctrld <= 1.5.7 EOF marker format. func merlinParsePostConf(buf []byte) []byte { if len(buf) == 0 { return nil } - parts := bytes.Split(buf, []byte(dnsmasq.MerlinPostConfMarker)) - if len(parts) != 1 { + + begin := []byte(dnsmasq.MerlinPostConfBeginMarker) + end := []byte(dnsmasq.MerlinPostConfEndMarker) + if start := bytes.Index(buf, begin); start >= 0 { + if relEnd := bytes.Index(buf[start+len(begin):], end); relEnd >= 0 { + finish := start + len(begin) + relEnd + len(end) + for finish < len(buf) && (buf[finish] == '\r' || buf[finish] == '\n') { + finish++ + } + out := make([]byte, 0, len(buf)-(finish-start)) + out = append(out, buf[:start]...) + out = append(out, buf[finish:]...) + return bytes.TrimRight(out, "\r\n") + } + } + + // Legacy format put the ctrld-generated script before an EOF marker and + // preserved the previous hook content after that marker. + parts := bytes.SplitN(buf, []byte(dnsmasq.MerlinPostConfMarker), 2) + if len(parts) == 2 { return bytes.TrimLeftFunc(parts[1], unicode.IsSpace) } return buf } +// merlinUpsertPostConf replaces only ctrld's marked block and keeps other hook content. +// ctrld's block is inserted immediately after an existing shebang, otherwise a shell shebang is added. +func merlinUpsertPostConf(buf, block []byte) []byte { + clean := merlinParsePostConf(buf) + clean = bytes.TrimRight(clean, "\r\n") + + if len(clean) == 0 { + return []byte("#!/bin/sh\n\n" + string(block) + "\n") + } + + if bytes.HasPrefix(clean, []byte("#!")) { + if nl := bytes.IndexByte(clean, '\n'); nl >= 0 { + head := clean[:nl+1] + rest := bytes.TrimLeft(clean[nl+1:], "\r\n") + out := make([]byte, 0, len(clean)+len(block)+4) + out = append(out, head...) + out = append(out, '\n') + out = append(out, block...) + if len(rest) > 0 { + out = append(out, '\n', '\n') + out = append(out, rest...) + } + out = append(out, '\n') + return out + } + } + + return []byte("#!/bin/sh\n\n" + string(block) + "\n\n" + string(clean) + "\n") +} + // waitDirExists waits until the specified directory exists, polling its existence every second. func waitDirExists(dir string) { for { From 9a2cf7483e5ba79a26e218a2e0a544f96960e9fa Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:22:38 +0100 Subject: [PATCH 03/35] Test Merlin postconf coexistence --- internal/router/merlin/merlin_test.go | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 057628cd..f715a2a5 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -38,3 +38,41 @@ func Test_merlinParsePostConf(t *testing.T) { }) } } + + +func Test_merlinParsePostConfMarkedBlock(t *testing.T) { + input := "#!/bin/sh\n\n# BEGIN ctrld\necho ctrld\n# END ctrld\n\necho custom\n" + want := "#!/bin/sh\n\necho custom" + + if got := string(merlinParsePostConf([]byte(input))); got != want { + t.Fatalf("unexpected result, want %q, got %q", want, got) + } +} + +func Test_merlinUpsertPostConfPreservesExistingContent(t *testing.T) { + orig := []byte("#!/bin/sh\n\necho before\necho after\n") + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + + got := merlinUpsertPostConf(orig, block) + + if bytes.Count(got, []byte(dnsmasq.MerlinPostConfBeginMarker)) != 1 { + t.Fatalf("expected exactly one ctrld block, got:\n%s", got) + } + if !bytes.Contains(got, []byte("echo before\necho after")) { + t.Fatalf("existing hook content was not preserved:\n%s", got) + } + if !bytes.HasPrefix(got, []byte("#!/bin/sh\n")) { + t.Fatalf("existing shebang was not preserved:\n%s", got) + } + + gotAgain := merlinUpsertPostConf(got, block) + if !bytes.Equal(got, gotAgain) { + t.Fatalf("upsert is not idempotent:\nfirst:\n%s\nsecond:\n%s", got, gotAgain) + } +} + +func Test_merlinPostConfDoesNotExitHostHook(t *testing.T) { + if strings.Contains(dnsmasq.MerlinPostConfTmpl, "exit 0") { + t.Fatal("ctrld postconf block must not terminate the enclosing Merlin hook") + } +} From e16a7fc780316b0b7263bda4a439e71c980e5910 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:31:37 +0100 Subject: [PATCH 04/35] Harden Merlin postconf block lifecycle --- internal/router/merlin/merlin.go | 130 ++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 3ab66dcf..0957e556 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" "time" - "unicode" "github.com/kardianos/service" @@ -120,11 +119,6 @@ func (m *Merlin) Cleanup() error { return nil // was restored, nothing to do. } - // Restore old configs. - if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { - return err - } - for _, path := range []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} { if err := cleanupDnsmasqPostconf(path); err != nil { return err @@ -136,6 +130,13 @@ func (m *Merlin) Cleanup() error { return fmt.Errorf("failed to cleanup jffs dnsmasq: config: %s, error: %w", cfg.confPath, err) } } + + // Restore NVRAM only after ctrld-owned artifacts are cleaned successfully. + // Keeping ctrld_setup set until this point makes a failed cleanup retryable. + if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -221,7 +222,7 @@ func (m *Merlin) writeDnsmasqPostconfFile(path string) error { dnsmasq.MerlinPostConfEndMarker, }, "\n") - return os.WriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) + return atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) } func cleanupDnsmasqPostconf(path string) error { @@ -241,7 +242,7 @@ func cleanupDnsmasqPostconf(path string) error { return nil } - return os.WriteFile(path, clean, 0750) + return atomicWriteFile(path, clean, 0750) } // restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". @@ -266,43 +267,62 @@ func getDnsmasqConfigs() []*dnsmasqConfig { return cfgs } -// merlinParsePostConf removes ctrld-owned postconf content while preserving unrelated hook logic. -// It understands both the current BEGIN/END block and the legacy ctrld <= 1.5.7 EOF marker format. -func merlinParsePostConf(buf []byte) []byte { - if len(buf) == 0 { - return nil +// merlinPostConfBlock returns the ctrld-owned block bounds. +// It understands both the current BEGIN/END format and the legacy <= 1.5.7 +// GENERATED/EOF format. Legacy matching is deliberately bounded by the known +// ctrld header so content prepended by another addon is not treated as ours. +func merlinPostConfBlock(buf []byte) (start, end int, ok bool) { + begin := []byte(dnsmasq.MerlinPostConfBeginMarker) + endMarker := []byte(dnsmasq.MerlinPostConfEndMarker) + if start = bytes.Index(buf, begin); start >= 0 { + relEnd := bytes.Index(buf[start+len(begin):], endMarker) + if relEnd >= 0 { + end = start + len(begin) + relEnd + len(endMarker) + return start, end, true + } } - begin := []byte(dnsmasq.MerlinPostConfBeginMarker) - end := []byte(dnsmasq.MerlinPostConfEndMarker) - if start := bytes.Index(buf, begin); start >= 0 { - if relEnd := bytes.Index(buf[start+len(begin):], end); relEnd >= 0 { - finish := start + len(begin) + relEnd + len(end) - for finish < len(buf) && (buf[finish] == '\r' || buf[finish] == '\n') { - finish++ - } - out := make([]byte, 0, len(buf)-(finish-start)) - out = append(out, buf[:start]...) - out = append(out, buf[finish:]...) - return bytes.TrimRight(out, "\r\n") + legacyEnd := []byte(dnsmasq.MerlinPostConfMarker) + if marker := bytes.Index(buf, legacyEnd); marker >= 0 { + legacyBegin := []byte(dnsmasq.CtrldMarker) + if relStart := bytes.LastIndex(buf[:marker], legacyBegin); relStart >= 0 { + return relStart, marker + len(legacyEnd), true } } - // Legacy format put the ctrld-generated script before an EOF marker and - // preserved the previous hook content after that marker. - parts := bytes.SplitN(buf, []byte(dnsmasq.MerlinPostConfMarker), 2) - if len(parts) == 2 { - return bytes.TrimLeftFunc(parts[1], unicode.IsSpace) + return 0, 0, false +} + +// merlinParsePostConf removes only ctrld-owned postconf content while preserving +// unrelated hook logic before and after it. +func merlinParsePostConf(buf []byte) []byte { + if len(buf) == 0 { + return nil + } + start, end, ok := merlinPostConfBlock(buf) + if !ok { + return buf } - return buf + + out := make([]byte, 0, len(buf)-(end-start)) + out = append(out, buf[:start]...) + out = append(out, buf[end:]...) + return bytes.TrimRight(out, "\r\n") } -// merlinUpsertPostConf replaces only ctrld's marked block and keeps other hook content. -// ctrld's block is inserted immediately after an existing shebang, otherwise a shell shebang is added. +// merlinUpsertPostConf replaces an existing ctrld block in place, preserving +// ordering relative to other addons. On first install, it places ctrld directly +// after an existing shebang, or creates a shell shebang if one is absent. func merlinUpsertPostConf(buf, block []byte) []byte { - clean := merlinParsePostConf(buf) - clean = bytes.TrimRight(clean, "\r\n") + if start, end, ok := merlinPostConfBlock(buf); ok { + out := make([]byte, 0, len(buf)-(end-start)+len(block)) + out = append(out, buf[:start]...) + out = append(out, block...) + out = append(out, buf[end:]...) + return out + } + clean := bytes.TrimRight(buf, "\r\n") if len(clean) == 0 { return []byte("#!/bin/sh\n\n" + string(block) + "\n") } @@ -310,13 +330,13 @@ func merlinUpsertPostConf(buf, block []byte) []byte { if bytes.HasPrefix(clean, []byte("#!")) { if nl := bytes.IndexByte(clean, '\n'); nl >= 0 { head := clean[:nl+1] - rest := bytes.TrimLeft(clean[nl+1:], "\r\n") + rest := clean[nl+1:] out := make([]byte, 0, len(clean)+len(block)+4) out = append(out, head...) out = append(out, '\n') out = append(out, block...) if len(rest) > 0 { - out = append(out, '\n', '\n') + out = append(out, '\n') out = append(out, rest...) } out = append(out, '\n') @@ -327,6 +347,42 @@ func merlinUpsertPostConf(buf, block []byte) []byte { return []byte("#!/bin/sh\n\n" + string(block) + "\n\n" + string(clean) + "\n") } +// atomicWriteFile replaces path only after a complete sibling temporary file +// has been written, synced, closed and chmodded. This avoids truncating a shared +// Merlin hook if JFFS fills up or a short write occurs. +func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { + dir := filepath.Dir(path) + base := filepath.Base(path) + tmp, err := os.CreateTemp(dir, "."+base+".ctrld-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmpName) + } + }() + + if err = tmp.Chmod(mode); err != nil { + return err + } + if _, err = tmp.Write(data); err != nil { + return err + } + if err = tmp.Sync(); err != nil { + return err + } + if err = tmp.Close(); err != nil { + return err + } + if err = os.Rename(tmpName, path); err != nil { + return err + } + return nil +} + // waitDirExists waits until the specified directory exists, polling its existence every second. func waitDirExists(dir string) { for { From 3b0389a0968f571fe88479ef92a583bfffde78b0 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:31:52 +0100 Subject: [PATCH 05/35] Cover Merlin hook migration edge cases --- internal/router/merlin/merlin_test.go | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index f715a2a5..27d1363a 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -76,3 +76,49 @@ func Test_merlinPostConfDoesNotExitHostHook(t *testing.T) { t.Fatal("ctrld postconf block must not terminate the enclosing Merlin hook") } } + + +func Test_merlinLegacyMigrationPreservesPrependedContent(t *testing.T) { + legacy := strings.Join([]string{ + "echo addon-before", + dnsmasq.CtrldMarker, + "#!/bin/sh", + "echo ctrld-legacy", + dnsmasq.MerlinPostConfMarker, + "echo addon-after", + }, "\n") + block := []byte("# BEGIN ctrld\necho ctrld-new\n# END ctrld") + + got := string(merlinUpsertPostConf([]byte(legacy), block)) + want := "echo addon-before\n# BEGIN ctrld\necho ctrld-new\n# END ctrld\necho addon-after" + if got != want { + t.Fatalf("legacy migration changed unrelated content or position:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func Test_merlinUpsertPostConfKeepsExistingBlockPosition(t *testing.T) { + input := []byte("#!/bin/sh\n\necho addon-before\n# BEGIN ctrld\necho old\n# END ctrld\necho addon-after\n") + block := []byte("# BEGIN ctrld\necho new\n# END ctrld") + + got := string(merlinUpsertPostConf(input, block)) + want := "#!/bin/sh\n\necho addon-before\n# BEGIN ctrld\necho new\n# END ctrld\necho addon-after\n" + if got != want { + t.Fatalf("managed block moved relative to addon content:\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func Test_merlinParsePostConfPreservesLegacyPrefixAndSuffix(t *testing.T) { + input := strings.Join([]string{ + "echo addon-before", + dnsmasq.CtrldMarker, + "#!/bin/sh", + "echo ctrld-legacy", + dnsmasq.MerlinPostConfMarker, + "echo addon-after", + }, "\n") + want := "echo addon-before\necho addon-after" + + if got := string(merlinParsePostConf([]byte(input))); got != want { + t.Fatalf("legacy cleanup changed unrelated content:\nwant: %q\ngot: %q", want, got) + } +} From b34a1312de05eee1a1990878d997734e9e2df070 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:38:15 +0100 Subject: [PATCH 06/35] Preserve Merlin hook ownership and symlinks --- internal/router/merlin/merlin.go | 62 +++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 0957e556..33f53931 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -206,9 +206,18 @@ func (m *Merlin) writeDnsmasqPostconf() error { } func (m *Merlin) writeDnsmasqPostconfFile(path string) error { + _, statErr := os.Lstat(path) + createdByCtrld := os.IsNotExist(statErr) + if statErr != nil && !createdByCtrld { + return statErr + } + buf, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return err + if err != nil { + if !createdByCtrld || !os.IsNotExist(err) { + return err + } + buf = nil } data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) @@ -222,7 +231,15 @@ func (m *Merlin) writeDnsmasqPostconfFile(path string) error { dnsmasq.MerlinPostConfEndMarker, }, "\n") - return atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) + if err := atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750); err != nil { + return err + } + if createdByCtrld { + if err := atomicWriteFile(merlinPostConfCreatedMarker(path), []byte("created\n"), 0600); err != nil { + return err + } + } + return nil } func cleanupDnsmasqPostconf(path string) error { @@ -235,14 +252,24 @@ func cleanupDnsmasqPostconf(path string) error { } clean := merlinParsePostConf(buf) - if len(bytes.TrimSpace(clean)) == 0 || bytes.Equal(bytes.TrimSpace(clean), []byte("#!/bin/sh")) { + marker := merlinPostConfCreatedMarker(path) + if fileExists(marker) && (len(bytes.TrimSpace(clean)) == 0 || bytes.Equal(bytes.TrimSpace(clean), []byte("#!/bin/sh"))) { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } + if err := os.Remove(marker); err != nil && !os.IsNotExist(err) { + return err + } return nil } - return atomicWriteFile(path, clean, 0750) + if err := atomicWriteFile(path, clean, 0750); err != nil { + return err + } + if err := os.Remove(marker); err != nil && !os.IsNotExist(err) { + return err + } + return nil } // restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". @@ -347,12 +374,31 @@ func merlinUpsertPostConf(buf, block []byte) []byte { return []byte("#!/bin/sh\n\n" + string(block) + "\n\n" + string(clean) + "\n") } +func merlinPostConfCreatedMarker(path string) string { + return path + ".ctrld-created" +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + // atomicWriteFile replaces path only after a complete sibling temporary file // has been written, synced, closed and chmodded. This avoids truncating a shared // Merlin hook if JFFS fills up or a short write occurs. func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { - dir := filepath.Dir(path) - base := filepath.Base(path) + target := path + if info, lstatErr := os.Lstat(path); lstatErr == nil && info.Mode()&os.ModeSymlink != 0 { + target, err = filepath.EvalSymlinks(path) + if err != nil { + return err + } + } else if lstatErr != nil && !os.IsNotExist(lstatErr) { + return lstatErr + } + + dir := filepath.Dir(target) + base := filepath.Base(target) tmp, err := os.CreateTemp(dir, "."+base+".ctrld-*") if err != nil { return err @@ -377,7 +423,7 @@ func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { if err = tmp.Close(); err != nil { return err } - if err = os.Rename(tmpName, path); err != nil { + if err = os.Rename(tmpName, target); err != nil { return err } return nil From 7dddf371159acb78789507752105030f30ba1a12 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:38:32 +0100 Subject: [PATCH 07/35] Test Merlin hook ownership and symlink safety --- internal/router/merlin/merlin_test.go | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 27d1363a..b014c893 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -2,6 +2,8 @@ package merlin import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -122,3 +124,76 @@ func Test_merlinParsePostConfPreservesLegacyPrefixAndSuffix(t *testing.T) { t.Fatalf("legacy cleanup changed unrelated content:\nwant: %q\ngot: %q", want, got) } } + + +func Test_cleanupDnsmasqPostconfPreservesPreexistingStub(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + installed := merlinUpsertPostConf([]byte("#!/bin/sh\n"), block) + if err := os.WriteFile(path, installed, 0750); err != nil { + t.Fatal(err) + } + + if err := cleanupDnsmasqPostconf(path); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("pre-existing hook stub was removed: %v", err) + } + if string(got) != "#!/bin/sh" { + t.Fatalf("unexpected restored stub: %q", got) + } +} + +func Test_cleanupDnsmasqPostconfRemovesCtrldCreatedStub(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + if err := os.WriteFile(path, merlinUpsertPostConf(nil, block), 0750); err != nil { + t.Fatal(err) + } + marker := merlinPostConfCreatedMarker(path) + if err := os.WriteFile(marker, []byte("created\n"), 0600); err != nil { + t.Fatal(err) + } + + if err := cleanupDnsmasqPostconf(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("ctrld-created hook still exists: %v", err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("ctrld-created marker still exists: %v", err) + } +} + +func Test_atomicWriteFilePreservesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "dnsmasq.postconf") + if err := os.WriteFile(target, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", link); err != nil { + t.Fatal(err) + } + + if err := atomicWriteFile(link, []byte("new"), 0750); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("atomicWriteFile replaced the symlink instead of its target") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("target content = %q, want %q", got, "new") + } +} From 719844706f8516f4bdae595386fbd57b911fe85f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:44:42 +0100 Subject: [PATCH 08/35] Never delete shared Merlin hook files --- internal/router/merlin/merlin.go | 47 ++++++-------------------------- 1 file changed, 8 insertions(+), 39 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 33f53931..3b80a1f8 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -207,14 +207,14 @@ func (m *Merlin) writeDnsmasqPostconf() error { func (m *Merlin) writeDnsmasqPostconfFile(path string) error { _, statErr := os.Lstat(path) - createdByCtrld := os.IsNotExist(statErr) - if statErr != nil && !createdByCtrld { + pathMissing := os.IsNotExist(statErr) + if statErr != nil && !pathMissing { return statErr } buf, err := os.ReadFile(path) if err != nil { - if !createdByCtrld || !os.IsNotExist(err) { + if !pathMissing || !os.IsNotExist(err) { return err } buf = nil @@ -231,15 +231,7 @@ func (m *Merlin) writeDnsmasqPostconfFile(path string) error { dnsmasq.MerlinPostConfEndMarker, }, "\n") - if err := atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750); err != nil { - return err - } - if createdByCtrld { - if err := atomicWriteFile(merlinPostConfCreatedMarker(path), []byte("created\n"), 0600); err != nil { - return err - } - } - return nil + return atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) } func cleanupDnsmasqPostconf(path string) error { @@ -252,24 +244,10 @@ func cleanupDnsmasqPostconf(path string) error { } clean := merlinParsePostConf(buf) - marker := merlinPostConfCreatedMarker(path) - if fileExists(marker) && (len(bytes.TrimSpace(clean)) == 0 || bytes.Equal(bytes.TrimSpace(clean), []byte("#!/bin/sh"))) { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - if err := os.Remove(marker); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - - if err := atomicWriteFile(path, clean, 0750); err != nil { - return err - } - if err := os.Remove(marker); err != nil && !os.IsNotExist(err) { - return err - } - return nil + // Never delete a shared Merlin hook outright. We cannot safely prove + // persistent ownership across addon rewrites/reboots, so cleanup removes + // only ctrld's block and leaves any resulting stub in place. + return atomicWriteFile(path, clean, 0750) } // restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". @@ -374,15 +352,6 @@ func merlinUpsertPostConf(buf, block []byte) []byte { return []byte("#!/bin/sh\n\n" + string(block) + "\n\n" + string(clean) + "\n") } -func merlinPostConfCreatedMarker(path string) string { - return path + ".ctrld-created" -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // atomicWriteFile replaces path only after a complete sibling temporary file // has been written, synced, closed and chmodded. This avoids truncating a shared // Merlin hook if JFFS fills up or a short write occurs. From df2be2d67ec5f4cc4a994be158e913fb83a36462 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:44:52 +0100 Subject: [PATCH 09/35] Test conservative Merlin hook cleanup --- internal/router/merlin/merlin_test.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index b014c893..bdc3902f 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -146,25 +146,22 @@ func Test_cleanupDnsmasqPostconfPreservesPreexistingStub(t *testing.T) { } } -func Test_cleanupDnsmasqPostconfRemovesCtrldCreatedStub(t *testing.T) { +func Test_cleanupDnsmasqPostconfLeavesStubWhenCtrldCreatedHook(t *testing.T) { path := filepath.Join(t.TempDir(), "dnsmasq.postconf") block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") if err := os.WriteFile(path, merlinUpsertPostConf(nil, block), 0750); err != nil { t.Fatal(err) } - marker := merlinPostConfCreatedMarker(path) - if err := os.WriteFile(marker, []byte("created\n"), 0600); err != nil { - t.Fatal(err) - } if err := cleanupDnsmasqPostconf(path); err != nil { t.Fatal(err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("ctrld-created hook still exists: %v", err) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cleanup removed shared hook path: %v", err) } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatalf("ctrld-created marker still exists: %v", err) + if string(got) != "#!/bin/sh" { + t.Fatalf("unexpected cleaned stub: %q", got) } } From 518f1e6ff62eeb24d9c5f39f306cd028862c8b6d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:57:17 +0100 Subject: [PATCH 10/35] Harden Merlin hook block parsing --- internal/router/merlin/merlin.go | 140 ++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 30 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 3b80a1f8..f8b1e0ff 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -272,32 +272,107 @@ func getDnsmasqConfigs() []*dnsmasqConfig { return cfgs } +// merlinExactLineBounds finds marker only when it occupies a complete line. +// The returned end excludes the line ending so callers can decide whether to +// preserve or consume that separator. +func merlinExactLineBounds(buf, marker []byte, from int) (start, end int, ok bool) { + if from < 0 { + from = 0 + } + for pos := from; pos <= len(buf); { + lineStart := pos + relNL := bytes.IndexByte(buf[pos:], '\n') + lineEnd := len(buf) + next := len(buf) + 1 + if relNL >= 0 { + lineEnd = pos + relNL + next = lineEnd + 1 + } + + contentEnd := lineEnd + if contentEnd > lineStart && buf[contentEnd-1] == '\r' { + contentEnd-- + } + if bytes.Equal(buf[lineStart:contentEnd], marker) { + return lineStart, lineEnd, true + } + + if relNL < 0 { + break + } + pos = next + } + return 0, 0, false +} + +// merlinLastExactLineBefore returns the last complete marker line starting +// before limit. +func merlinLastExactLineBefore(buf, marker []byte, limit int) (start, end int, ok bool) { + from := 0 + for { + s, e, found := merlinExactLineBounds(buf, marker, from) + if !found || s >= limit { + break + } + start, end, ok = s, e, true + if e >= len(buf) { + break + } + from = e + 1 + } + return start, end, ok +} + // merlinPostConfBlock returns the ctrld-owned block bounds. // It understands both the current BEGIN/END format and the legacy <= 1.5.7 -// GENERATED/EOF format. Legacy matching is deliberately bounded by the known -// ctrld header so content prepended by another addon is not treated as ours. +// GENERATED/EOF format. Markers must occupy complete lines so shell variables, +// comments or unrelated strings containing the marker text are never claimed. func merlinPostConfBlock(buf []byte) (start, end int, ok bool) { begin := []byte(dnsmasq.MerlinPostConfBeginMarker) endMarker := []byte(dnsmasq.MerlinPostConfEndMarker) - if start = bytes.Index(buf, begin); start >= 0 { - relEnd := bytes.Index(buf[start+len(begin):], endMarker) - if relEnd >= 0 { - end = start + len(begin) + relEnd + len(endMarker) - return start, end, true + if blockStart, beginEnd, found := merlinExactLineBounds(buf, begin, 0); found { + from := beginEnd + if from < len(buf) && buf[from] == '\n' { + from++ + } + if blockEndStart, blockEnd, foundEnd := merlinExactLineBounds(buf, endMarker, from); foundEnd { + _ = blockEndStart + return blockStart, blockEnd, true } } legacyEnd := []byte(dnsmasq.MerlinPostConfMarker) - if marker := bytes.Index(buf, legacyEnd); marker >= 0 { + if legacyEndStart, legacyEndEnd, found := merlinExactLineBounds(buf, legacyEnd, 0); found { legacyBegin := []byte(dnsmasq.CtrldMarker) - if relStart := bytes.LastIndex(buf[:marker], legacyBegin); relStart >= 0 { - return relStart, marker + len(legacyEnd), true + if legacyBeginStart, _, foundBegin := merlinLastExactLineBefore(buf, legacyBegin, legacyEndStart); foundBegin { + return legacyBeginStart, legacyEndEnd, true } } return 0, 0, false } +func merlinConsumeLineEnding(buf []byte, pos int) int { + if pos >= len(buf) { + return pos + } + if buf[pos] == '\r' { + pos++ + if pos < len(buf) && buf[pos] == '\n' { + pos++ + } + return pos + } + if buf[pos] == '\n' { + return pos + 1 + } + return pos +} + +func merlinHasBlankLineSuffix(buf []byte) bool { + return bytes.HasSuffix(buf, []byte("\n\n")) || bytes.HasSuffix(buf, []byte("\r\n\r\n")) +} + // merlinParsePostConf removes only ctrld-owned postconf content while preserving // unrelated hook logic before and after it. func merlinParsePostConf(buf []byte) []byte { @@ -309,15 +384,24 @@ func merlinParsePostConf(buf []byte) []byte { return buf } - out := make([]byte, 0, len(buf)-(end-start)) + // The ctrld block owns the line ending immediately following its END/EOF + // marker. Older ctrld-generated hooks also placed a blank separator around + // the managed block; consume that second separator only when the prefix + // already ends in a blank line, restoring the original hook byte-for-byte. + after := merlinConsumeLineEnding(buf, end) + if merlinHasBlankLineSuffix(buf[:start]) { + after = merlinConsumeLineEnding(buf, after) + } + + out := make([]byte, 0, len(buf)-(after-start)) out = append(out, buf[:start]...) - out = append(out, buf[end:]...) - return bytes.TrimRight(out, "\r\n") + out = append(out, buf[after:]...) + return out } // merlinUpsertPostConf replaces an existing ctrld block in place, preserving -// ordering relative to other addons. On first install, it places ctrld directly -// after an existing shebang, or creates a shell shebang if one is absent. +// ordering relative to other addons. On first install it inserts directly after +// an existing shebang. Cleanup can therefore restore the original bytes. func merlinUpsertPostConf(buf, block []byte) []byte { if start, end, ok := merlinPostConfBlock(buf); ok { out := make([]byte, 0, len(buf)-(end-start)+len(block)) @@ -327,29 +411,25 @@ func merlinUpsertPostConf(buf, block []byte) []byte { return out } - clean := bytes.TrimRight(buf, "\r\n") - if len(clean) == 0 { - return []byte("#!/bin/sh\n\n" + string(block) + "\n") + if len(buf) == 0 { + return []byte("#!/bin/sh\n" + string(block) + "\n") } - if bytes.HasPrefix(clean, []byte("#!")) { - if nl := bytes.IndexByte(clean, '\n'); nl >= 0 { - head := clean[:nl+1] - rest := clean[nl+1:] - out := make([]byte, 0, len(clean)+len(block)+4) - out = append(out, head...) - out = append(out, '\n') + if bytes.HasPrefix(buf, []byte("#!")) { + if nl := bytes.IndexByte(buf, '\n'); nl >= 0 { + out := make([]byte, 0, len(buf)+len(block)+1) + out = append(out, buf[:nl+1]...) out = append(out, block...) - if len(rest) > 0 { - out = append(out, '\n') - out = append(out, rest...) - } out = append(out, '\n') + out = append(out, buf[nl+1:]...) return out } } - return []byte("#!/bin/sh\n\n" + string(block) + "\n\n" + string(clean) + "\n") + // Existing hook without a shebang: preserve its content verbatim after a + // shell shebang and the ctrld block. This is the only case where ctrld must + // add wrapper bytes so the hook remains directly executable by Merlin. + return []byte("#!/bin/sh\n" + string(block) + "\n" + string(buf)) } // atomicWriteFile replaces path only after a complete sibling temporary file From 397e19156962e55cc9c7fdd5a3eba3ee5e648595 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:57:36 +0100 Subject: [PATCH 11/35] Test exact Merlin hook ownership and round trips --- internal/router/merlin/merlin_test.go | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index bdc3902f..9c89bb9c 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -44,7 +44,7 @@ func Test_merlinParsePostConf(t *testing.T) { func Test_merlinParsePostConfMarkedBlock(t *testing.T) { input := "#!/bin/sh\n\n# BEGIN ctrld\necho ctrld\n# END ctrld\n\necho custom\n" - want := "#!/bin/sh\n\necho custom" + want := "#!/bin/sh\n\necho custom\n" if got := string(merlinParsePostConf([]byte(input))); got != want { t.Fatalf("unexpected result, want %q, got %q", want, got) @@ -141,7 +141,7 @@ func Test_cleanupDnsmasqPostconfPreservesPreexistingStub(t *testing.T) { if err != nil { t.Fatalf("pre-existing hook stub was removed: %v", err) } - if string(got) != "#!/bin/sh" { + if string(got) != "#!/bin/sh\n" { t.Fatalf("unexpected restored stub: %q", got) } } @@ -160,7 +160,7 @@ func Test_cleanupDnsmasqPostconfLeavesStubWhenCtrldCreatedHook(t *testing.T) { if err != nil { t.Fatalf("cleanup removed shared hook path: %v", err) } - if string(got) != "#!/bin/sh" { + if string(got) != "#!/bin/sh\n" { t.Fatalf("unexpected cleaned stub: %q", got) } } @@ -194,3 +194,42 @@ func Test_atomicWriteFilePreservesSymlink(t *testing.T) { t.Fatalf("target content = %q, want %q", got, "new") } } + + +func Test_merlinPostConfMarkersMustBeCompleteLines(t *testing.T) { + input := []byte("#!/bin/sh\npattern='# BEGIN ctrld'\nend='# END ctrld'\necho untouched\n") + if _, _, ok := merlinPostConfBlock(input); ok { + t.Fatal("marker substrings inside unrelated shell lines must not be treated as ctrld ownership") + } + if got := merlinParsePostConf(input); !bytes.Equal(got, input) { + t.Fatalf("unrelated hook content changed:\nwant: %q\ngot: %q", input, got) + } +} + +func Test_merlinLegacyMarkersMustBeCompleteLines(t *testing.T) { + input := []byte("#!/bin/sh\nheader='# GENERATED BY ctrld - DO NOT MODIFY'\neof='# GENERATED BY ctrld - EOF'\necho untouched\n") + if _, _, ok := merlinPostConfBlock(input); ok { + t.Fatal("legacy marker substrings inside unrelated shell lines must not be treated as ctrld ownership") + } + if got := merlinParsePostConf(input); !bytes.Equal(got, input) { + t.Fatalf("unrelated legacy-like hook content changed:\nwant: %q\ngot: %q", input, got) + } +} + +func Test_merlinPostConfRoundTripRestoresOriginalBytes(t *testing.T) { + tests := [][]byte{ + []byte("#!/bin/sh\n"), + []byte("#!/bin/sh\n\necho custom\n"), + []byte("#!/bin/sh\n# comment\necho one\necho two\n"), + []byte("#!/bin/sh\r\n\r\necho crlf\r\n"), + } + + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + for _, orig := range tests { + installed := merlinUpsertPostConf(orig, block) + cleaned := merlinParsePostConf(installed) + if !bytes.Equal(cleaned, orig) { + t.Fatalf("setup/cleanup did not restore original bytes:\norig: %q\ninstalled: %q\ncleaned: %q", orig, installed, cleaned) + } + } +} From d2e754089847ee63586483893e6fd6764b5c4644 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:58:15 +0100 Subject: [PATCH 12/35] Fix legacy Merlin postconf fixture --- internal/router/merlin/merlin_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 9c89bb9c..bf18c492 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -13,10 +13,11 @@ import ( func Test_merlinParsePostConf(t *testing.T) { origContent := "# foo" data := strings.Join([]string{ - dnsmasq.MerlinPostConfTmpl, - "\n", + dnsmasq.CtrldMarker, + "#!/bin/sh", + "echo ctrld-legacy", dnsmasq.MerlinPostConfMarker, - "\n", + "", }, "\n") tests := []struct { From 3fbfdf6604e7061c9b81a5c96ca6467e3f3298a4 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:04:19 +0100 Subject: [PATCH 13/35] Preserve legacy separators and hook metadata --- internal/router/merlin/merlin.go | 98 ++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index f8b1e0ff..b732ada2 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -219,6 +219,9 @@ func (m *Merlin) writeDnsmasqPostconfFile(path string) error { } buf = nil } + if !pathMissing && !merlinExistingHookHasValidShebang(buf) { + return fmt.Errorf("refusing to modify existing Merlin hook without a newline-terminated shebang: %s", path) + } data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) if err != nil { @@ -272,6 +275,13 @@ func getDnsmasqConfigs() []*dnsmasqConfig { return cfgs } +// merlinExistingHookHasValidShebang reports whether a pre-existing shared +// Merlin hook can be extended without synthesizing wrapper bytes that cleanup +// could not later prove belong to ctrld. +func merlinExistingHookHasValidShebang(buf []byte) bool { + return bytes.HasPrefix(buf, []byte("#!")) && bytes.IndexByte(buf, '\n') >= 0 +} + // merlinExactLineBounds finds marker only when it occupies a complete line. // The returned end excludes the line ending so callers can decide whether to // preserve or consume that separator. @@ -323,11 +333,19 @@ func merlinLastExactLineBefore(buf, marker []byte, limit int) (start, end int, o return start, end, ok } -// merlinPostConfBlock returns the ctrld-owned block bounds. +type merlinPostConfBlockKind uint8 + +const ( + merlinPostConfBlockNone merlinPostConfBlockKind = iota + merlinPostConfBlockCurrent + merlinPostConfBlockLegacy +) + +// merlinPostConfBlock returns the ctrld-owned block bounds and format. // It understands both the current BEGIN/END format and the legacy <= 1.5.7 // GENERATED/EOF format. Markers must occupy complete lines so shell variables, // comments or unrelated strings containing the marker text are never claimed. -func merlinPostConfBlock(buf []byte) (start, end int, ok bool) { +func merlinPostConfBlock(buf []byte) (start, end int, kind merlinPostConfBlockKind, ok bool) { begin := []byte(dnsmasq.MerlinPostConfBeginMarker) endMarker := []byte(dnsmasq.MerlinPostConfEndMarker) if blockStart, beginEnd, found := merlinExactLineBounds(buf, begin, 0); found { @@ -335,9 +353,8 @@ func merlinPostConfBlock(buf []byte) (start, end int, ok bool) { if from < len(buf) && buf[from] == '\n' { from++ } - if blockEndStart, blockEnd, foundEnd := merlinExactLineBounds(buf, endMarker, from); foundEnd { - _ = blockEndStart - return blockStart, blockEnd, true + if _, blockEnd, foundEnd := merlinExactLineBounds(buf, endMarker, from); foundEnd { + return blockStart, blockEnd, merlinPostConfBlockCurrent, true } } @@ -345,11 +362,11 @@ func merlinPostConfBlock(buf []byte) (start, end int, ok bool) { if legacyEndStart, legacyEndEnd, found := merlinExactLineBounds(buf, legacyEnd, 0); found { legacyBegin := []byte(dnsmasq.CtrldMarker) if legacyBeginStart, _, foundBegin := merlinLastExactLineBefore(buf, legacyBegin, legacyEndStart); foundBegin { - return legacyBeginStart, legacyEndEnd, true + return legacyBeginStart, legacyEndEnd, merlinPostConfBlockLegacy, true } } - return 0, 0, false + return 0, 0, merlinPostConfBlockNone, false } func merlinConsumeLineEnding(buf []byte, pos int) int { @@ -369,28 +386,33 @@ func merlinConsumeLineEnding(buf []byte, pos int) int { return pos } -func merlinHasBlankLineSuffix(buf []byte) bool { - return bytes.HasSuffix(buf, []byte("\n\n")) || bytes.HasSuffix(buf, []byte("\r\n\r\n")) -} - // merlinParsePostConf removes only ctrld-owned postconf content while preserving // unrelated hook logic before and after it. func merlinParsePostConf(buf []byte) []byte { if len(buf) == 0 { return nil } - start, end, ok := merlinPostConfBlock(buf) + start, end, kind, ok := merlinPostConfBlock(buf) if !ok { return buf } - // The ctrld block owns the line ending immediately following its END/EOF - // marker. Older ctrld-generated hooks also placed a blank separator around - // the managed block; consume that second separator only when the prefix - // already ends in a blank line, restoring the original hook byte-for-byte. - after := merlinConsumeLineEnding(buf, end) - if merlinHasBlankLineSuffix(buf[:start]) { - after = merlinConsumeLineEnding(buf, after) + // Current blocks own the line ending following END. ctrld <= 1.5.7 wrote + // its legacy wrapper with strings.Join(..., "\n"), producing three line + // endings between the EOF marker and the previously existing hook. Consume + // exactly those historical separators so the preserved hook returns to its + // original byte position, including a shebang at byte zero. + separatorCount := 1 + if kind == merlinPostConfBlockLegacy { + separatorCount = 3 + } + after := end + for i := 0; i < separatorCount; i++ { + next := merlinConsumeLineEnding(buf, after) + if next == after { + break + } + after = next } out := make([]byte, 0, len(buf)-(after-start)) @@ -400,10 +422,11 @@ func merlinParsePostConf(buf []byte) []byte { } // merlinUpsertPostConf replaces an existing ctrld block in place, preserving -// ordering relative to other addons. On first install it inserts directly after -// an existing shebang. Cleanup can therefore restore the original bytes. +// ordering relative to other addons. For a newly created hook it supplies the +// required shell shebang. Pre-existing hooks are validated before this helper is +// called, so no synthetic wrapper is ever added to third-party content. func merlinUpsertPostConf(buf, block []byte) []byte { - if start, end, ok := merlinPostConfBlock(buf); ok { + if start, end, _, ok := merlinPostConfBlock(buf); ok { out := make([]byte, 0, len(buf)-(end-start)+len(block)) out = append(out, buf[:start]...) out = append(out, block...) @@ -415,21 +438,17 @@ func merlinUpsertPostConf(buf, block []byte) []byte { return []byte("#!/bin/sh\n" + string(block) + "\n") } - if bytes.HasPrefix(buf, []byte("#!")) { - if nl := bytes.IndexByte(buf, '\n'); nl >= 0 { - out := make([]byte, 0, len(buf)+len(block)+1) - out = append(out, buf[:nl+1]...) - out = append(out, block...) - out = append(out, '\n') - out = append(out, buf[nl+1:]...) - return out - } + if nl := bytes.IndexByte(buf, '\n'); nl >= 0 { + out := make([]byte, 0, len(buf)+len(block)+1) + out = append(out, buf[:nl+1]...) + out = append(out, block...) + out = append(out, '\n') + out = append(out, buf[nl+1:]...) + return out } - // Existing hook without a shebang: preserve its content verbatim after a - // shell shebang and the ctrld block. This is the only case where ctrld must - // add wrapper bytes so the hook remains directly executable by Merlin. - return []byte("#!/bin/sh\n" + string(block) + "\n" + string(buf)) + // writeDnsmasqPostconfFile rejects this case for pre-existing hooks. + return buf } // atomicWriteFile replaces path only after a complete sibling temporary file @@ -446,6 +465,13 @@ func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { return lstatErr } + writeMode := mode + if info, statErr := os.Stat(target); statErr == nil { + writeMode = info.Mode() & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky) + } else if !os.IsNotExist(statErr) { + return statErr + } + dir := filepath.Dir(target) base := filepath.Base(target) tmp, err := os.CreateTemp(dir, "."+base+".ctrld-*") @@ -460,7 +486,7 @@ func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { } }() - if err = tmp.Chmod(mode); err != nil { + if err = tmp.Chmod(writeMode); err != nil { return err } if _, err = tmp.Write(data); err != nil { From 9306499c14ecb13124dda9910989fa9c8340abf4 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:04:49 +0100 Subject: [PATCH 14/35] Normalize legacy Merlin hook separators --- internal/router/merlin/merlin.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index b732ada2..da2b8565 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -426,7 +426,26 @@ func merlinParsePostConf(buf []byte) []byte { // required shell shebang. Pre-existing hooks are validated before this helper is // called, so no synthetic wrapper is ever added to third-party content. func merlinUpsertPostConf(buf, block []byte) []byte { - if start, end, _, ok := merlinPostConfBlock(buf); ok { + if start, end, kind, ok := merlinPostConfBlock(buf); ok { + if kind == merlinPostConfBlockLegacy { + after := end + for i := 0; i < 3; i++ { + next := merlinConsumeLineEnding(buf, after) + if next == after { + break + } + after = next + } + out := make([]byte, 0, len(buf)-(after-start)+len(block)+1) + out = append(out, buf[:start]...) + out = append(out, block...) + if after < len(buf) { + out = append(out, '\n') + } + out = append(out, buf[after:]...) + return out + } + out := make([]byte, 0, len(buf)-(end-start)+len(block)) out = append(out, buf[:start]...) out = append(out, block...) From fcae2470c1452ceb0af6fad85a3fcdb6cafc3795 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:05:14 +0100 Subject: [PATCH 15/35] Test legacy hook fidelity and permissions --- internal/router/merlin/merlin_test.go | 98 ++++++++++++++++++++------- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index bf18c492..bd24e892 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -10,15 +10,23 @@ import ( "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" ) -func Test_merlinParsePostConf(t *testing.T) { - origContent := "# foo" - data := strings.Join([]string{ +func legacyMerlinPostConf(orig string) string { + generated := strings.Join([]string{ dnsmasq.CtrldMarker, "#!/bin/sh", "echo ctrld-legacy", + }, "\n") + return strings.Join([]string{ + generated, + "\n", dnsmasq.MerlinPostConfMarker, - "", + "\n", + orig, }, "\n") +} + +func Test_merlinParsePostConf(t *testing.T) { + origContent := "# foo" tests := []struct { name string @@ -27,8 +35,8 @@ func Test_merlinParsePostConf(t *testing.T) { }{ {"empty", "", ""}, {"no ctrld", origContent, origContent}, - {"ctrld with data", data + origContent, origContent}, - {"ctrld without data", data, ""}, + {"ctrld with data", legacyMerlinPostConf(origContent), origContent}, + {"ctrld without data", legacyMerlinPostConf(""), ""}, } for _, tc := range tests { @@ -44,7 +52,7 @@ func Test_merlinParsePostConf(t *testing.T) { func Test_merlinParsePostConfMarkedBlock(t *testing.T) { - input := "#!/bin/sh\n\n# BEGIN ctrld\necho ctrld\n# END ctrld\n\necho custom\n" + input := "#!/bin/sh\n# BEGIN ctrld\necho ctrld\n# END ctrld\n\necho custom\n" want := "#!/bin/sh\n\necho custom\n" if got := string(merlinParsePostConf([]byte(input))); got != want { @@ -82,14 +90,7 @@ func Test_merlinPostConfDoesNotExitHostHook(t *testing.T) { func Test_merlinLegacyMigrationPreservesPrependedContent(t *testing.T) { - legacy := strings.Join([]string{ - "echo addon-before", - dnsmasq.CtrldMarker, - "#!/bin/sh", - "echo ctrld-legacy", - dnsmasq.MerlinPostConfMarker, - "echo addon-after", - }, "\n") + legacy := "echo addon-before\n" + legacyMerlinPostConf("echo addon-after") block := []byte("# BEGIN ctrld\necho ctrld-new\n# END ctrld") got := string(merlinUpsertPostConf([]byte(legacy), block)) @@ -111,14 +112,7 @@ func Test_merlinUpsertPostConfKeepsExistingBlockPosition(t *testing.T) { } func Test_merlinParsePostConfPreservesLegacyPrefixAndSuffix(t *testing.T) { - input := strings.Join([]string{ - "echo addon-before", - dnsmasq.CtrldMarker, - "#!/bin/sh", - "echo ctrld-legacy", - dnsmasq.MerlinPostConfMarker, - "echo addon-after", - }, "\n") + input := "echo addon-before\n" + legacyMerlinPostConf("echo addon-after") want := "echo addon-before\necho addon-after" if got := string(merlinParsePostConf([]byte(input))); got != want { @@ -199,7 +193,7 @@ func Test_atomicWriteFilePreservesSymlink(t *testing.T) { func Test_merlinPostConfMarkersMustBeCompleteLines(t *testing.T) { input := []byte("#!/bin/sh\npattern='# BEGIN ctrld'\nend='# END ctrld'\necho untouched\n") - if _, _, ok := merlinPostConfBlock(input); ok { + if _, _, _, ok := merlinPostConfBlock(input); ok { t.Fatal("marker substrings inside unrelated shell lines must not be treated as ctrld ownership") } if got := merlinParsePostConf(input); !bytes.Equal(got, input) { @@ -209,7 +203,7 @@ func Test_merlinPostConfMarkersMustBeCompleteLines(t *testing.T) { func Test_merlinLegacyMarkersMustBeCompleteLines(t *testing.T) { input := []byte("#!/bin/sh\nheader='# GENERATED BY ctrld - DO NOT MODIFY'\neof='# GENERATED BY ctrld - EOF'\necho untouched\n") - if _, _, ok := merlinPostConfBlock(input); ok { + if _, _, _, ok := merlinPostConfBlock(input); ok { t.Fatal("legacy marker substrings inside unrelated shell lines must not be treated as ctrld ownership") } if got := merlinParsePostConf(input); !bytes.Equal(got, input) { @@ -234,3 +228,57 @@ func Test_merlinPostConfRoundTripRestoresOriginalBytes(t *testing.T) { } } } + + +func Test_merlinLegacyCleanupRestoresShebangAtByteZero(t *testing.T) { + orig := "#!/bin/sh\necho original\n" + legacy := legacyMerlinPostConf(orig) + got := merlinParsePostConf([]byte(legacy)) + if string(got) != orig { + t.Fatalf("legacy cleanup did not restore original hook exactly:\nwant: %q\ngot: %q", orig, got) + } + if !bytes.HasPrefix(got, []byte("#!")) { + t.Fatalf("restored hook lost shebang at byte zero: %q", got) + } +} + +func Test_merlinExistingHookShebangValidation(t *testing.T) { + tests := []struct { + name string + buf []byte + want bool + }{ + {"valid", []byte("#!/bin/sh\necho ok\n"), true}, + {"empty", nil, false}, + {"no shebang", []byte("echo no\n"), false}, + {"unterminated shebang", []byte("#!/bin/sh"), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := merlinExistingHookHasValidShebang(tc.buf); got != tc.want { + t.Fatalf("validation = %v, want %v for %q", got, tc.want, tc.buf) + } + }) + } +} + +func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { + for _, mode := range []os.FileMode{0700, 0770} { + t.Run(mode.String(), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + if err := os.WriteFile(path, []byte("old"), mode); err != nil { + t.Fatal(err) + } + if err := atomicWriteFile(path, []byte("new"), 0750); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != mode.Perm() { + t.Fatalf("mode = %v, want %v", got, mode.Perm()) + } + }) + } +} From 9cd8802d046c5d787350b91b47001d2e5b34e047 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:13:56 +0100 Subject: [PATCH 16/35] Track ctrld-owned synthetic Merlin shebang --- internal/router/dnsmasq/dnsmasq.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index c3370a9a..db2d47e1 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -36,8 +36,9 @@ const ( ) const ( - MerlinPostConfBeginMarker = `# BEGIN ctrld` - MerlinPostConfEndMarker = `# END ctrld` + MerlinPostConfBeginMarker = `# BEGIN ctrld` + MerlinPostConfEndMarker = `# END ctrld` + MerlinSyntheticShebangMarker = `# ctrld: synthetic shebang` // MerlinPostConfMarker is the legacy end marker used by ctrld <= 1.5.7. MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` ) From 8d4335f9046a192c8c4b435325f540a4b29e75ca Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:14:43 +0100 Subject: [PATCH 17/35] Make Merlin hook installs reversible and preflighted --- internal/router/merlin/merlin.go | 194 +++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 63 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index da2b8565..8ebdd09c 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -195,46 +195,65 @@ func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { return nil } -// writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN hooks while preserving unrelated content. +// merlinHookUpdate is prepared entirely before any shared hook is modified. +// This lets us validate/read both Merlin hook paths before the first write. +type merlinHookUpdate struct { + path string + data []byte +} + +// writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN +// hooks while preserving unrelated content. Both paths are preflighted before +// either is modified, avoiding a half-installed integration when the second +// shared hook is unreadable or otherwise invalid. func (m *Merlin) writeDnsmasqPostconf() error { - for _, path := range []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} { - if err := m.writeDnsmasqPostconfFile(path); err != nil { + data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + if err != nil { + return err + } + block := []byte(strings.Join([]string{ + dnsmasq.MerlinPostConfBeginMarker, + strings.TrimSpace(data), + dnsmasq.MerlinPostConfEndMarker, + }, "\n")) + + paths := []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} + updates := make([]merlinHookUpdate, 0, len(paths)) + for _, path := range paths { + update, err := prepareMerlinHookUpdate(path, block) + if err != nil { + return err + } + updates = append(updates, update) + } + + for _, update := range updates { + if err := atomicWriteFile(update.path, update.data, 0750); err != nil { return err } } return nil } -func (m *Merlin) writeDnsmasqPostconfFile(path string) error { +func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error) { _, statErr := os.Lstat(path) pathMissing := os.IsNotExist(statErr) if statErr != nil && !pathMissing { - return statErr + return merlinHookUpdate{}, statErr } buf, err := os.ReadFile(path) if err != nil { if !pathMissing || !os.IsNotExist(err) { - return err + return merlinHookUpdate{}, err } buf = nil } - if !pathMissing && !merlinExistingHookHasValidShebang(buf) { - return fmt.Errorf("refusing to modify existing Merlin hook without a newline-terminated shebang: %s", path) - } - - data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) - if err != nil { - return err - } - - block := strings.Join([]string{ - dnsmasq.MerlinPostConfBeginMarker, - strings.TrimSpace(data), - dnsmasq.MerlinPostConfEndMarker, - }, "\n") - return atomicWriteFile(path, merlinUpsertPostConf(buf, []byte(block)), 0750) + return merlinHookUpdate{ + path: path, + data: merlinUpsertPostConf(buf, block), + }, nil } func cleanupDnsmasqPostconf(path string) error { @@ -249,7 +268,7 @@ func cleanupDnsmasqPostconf(path string) error { clean := merlinParsePostConf(buf) // Never delete a shared Merlin hook outright. We cannot safely prove // persistent ownership across addon rewrites/reboots, so cleanup removes - // only ctrld's block and leaves any resulting stub in place. + // only ctrld-owned bytes and leaves any resulting stub in place. return atomicWriteFile(path, clean, 0750) } @@ -275,13 +294,6 @@ func getDnsmasqConfigs() []*dnsmasqConfig { return cfgs } -// merlinExistingHookHasValidShebang reports whether a pre-existing shared -// Merlin hook can be extended without synthesizing wrapper bytes that cleanup -// could not later prove belong to ctrld. -func merlinExistingHookHasValidShebang(buf []byte) bool { - return bytes.HasPrefix(buf, []byte("#!")) && bytes.IndexByte(buf, '\n') >= 0 -} - // merlinExactLineBounds finds marker only when it occupies a complete line. // The returned end excludes the line ending so callers can decide whether to // preserve or consume that separator. @@ -386,8 +398,35 @@ func merlinConsumeLineEnding(buf []byte, pos int) int { return pos } +func merlinBlockHasSyntheticShebang(buf []byte, start, end int) bool { + if start < 0 || end < start || end > len(buf) { + return false + } + _, _, ok := merlinExactLineBounds( + buf[start:end], + []byte(dnsmasq.MerlinSyntheticShebangMarker), + 0, + ) + return ok +} + +func merlinBlockWithSyntheticShebang(block []byte) []byte { + prefix := []byte(dnsmasq.MerlinPostConfBeginMarker + "\n") + if !bytes.HasPrefix(block, prefix) { + return block + } + out := make([]byte, 0, len(block)+len(dnsmasq.MerlinSyntheticShebangMarker)+1) + out = append(out, prefix...) + out = append(out, dnsmasq.MerlinSyntheticShebangMarker...) + out = append(out, '\n') + out = append(out, block[len(prefix):]...) + return out +} + // merlinParsePostConf removes only ctrld-owned postconf content while preserving -// unrelated hook logic before and after it. +// unrelated hook logic before and after it. If ctrld had to synthesize the +// leading shebang for a pre-existing hook without one, that shebang is marked +// inside ctrld's block and removed together with the block. func merlinParsePostConf(buf []byte) []byte { if len(buf) == 0 { return nil @@ -397,11 +436,12 @@ func merlinParsePostConf(buf []byte) []byte { return buf } + syntheticShebang := kind == merlinPostConfBlockCurrent && + merlinBlockHasSyntheticShebang(buf, start, end) + // Current blocks own the line ending following END. ctrld <= 1.5.7 wrote // its legacy wrapper with strings.Join(..., "\n"), producing three line - // endings between the EOF marker and the previously existing hook. Consume - // exactly those historical separators so the preserved hook returns to its - // original byte position, including a shebang at byte zero. + // endings between the EOF marker and the previously existing hook. separatorCount := 1 if kind == merlinPostConfBlockLegacy { separatorCount = 3 @@ -415,59 +455,87 @@ func merlinParsePostConf(buf []byte) []byte { after = next } + if syntheticShebang { + const shebang = "#!/bin/sh\n" + if start == len(shebang) && bytes.Equal(buf[:start], []byte(shebang)) { + start = 0 + } + } + out := make([]byte, 0, len(buf)-(after-start)) out = append(out, buf[:start]...) out = append(out, buf[after:]...) return out } -// merlinUpsertPostConf replaces an existing ctrld block in place, preserving -// ordering relative to other addons. For a newly created hook it supplies the -// required shell shebang. Pre-existing hooks are validated before this helper is -// called, so no synthetic wrapper is ever added to third-party content. +// merlinUpsertPostConf replaces an existing ctrld block in place. New hooks, +// and existing hooks that do not already start with a usable shebang, receive a +// ctrld-owned synthetic shebang. The ownership marker lives inside the managed +// block so cleanup can remove that wrapper and restore the original bytes. func merlinUpsertPostConf(buf, block []byte) []byte { if start, end, kind, ok := merlinPostConfBlock(buf); ok { - if kind == merlinPostConfBlockLegacy { - after := end - for i := 0; i < 3; i++ { - next := merlinConsumeLineEnding(buf, after) - if next == after { - break - } - after = next + if kind == merlinPostConfBlockCurrent { + if merlinBlockHasSyntheticShebang(buf, start, end) { + block = merlinBlockWithSyntheticShebang(block) } - out := make([]byte, 0, len(buf)-(after-start)+len(block)+1) + out := make([]byte, 0, len(buf)-(end-start)+len(block)) out = append(out, buf[:start]...) out = append(out, block...) - if after < len(buf) { - out = append(out, '\n') + out = append(out, buf[end:]...) + return out + } + + // Legacy ctrld <= 1.5.7 wrote three separators after its EOF marker. + after := end + for i := 0; i < 3; i++ { + next := merlinConsumeLineEnding(buf, after) + if next == after { + break } + after = next + } + + if start == 0 { + marked := merlinBlockWithSyntheticShebang(block) + out := make([]byte, 0, len(marked)+len(buf[after:])+16) + out = append(out, "#!/bin/sh\n"...) + out = append(out, marked...) + out = append(out, '\n') out = append(out, buf[after:]...) return out } - out := make([]byte, 0, len(buf)-(end-start)+len(block)) + out := make([]byte, 0, len(buf)-(after-start)+len(block)+1) out = append(out, buf[:start]...) out = append(out, block...) - out = append(out, buf[end:]...) + if after < len(buf) { + out = append(out, '\n') + } + out = append(out, buf[after:]...) return out } - if len(buf) == 0 { - return []byte("#!/bin/sh\n" + string(block) + "\n") - } - - if nl := bytes.IndexByte(buf, '\n'); nl >= 0 { - out := make([]byte, 0, len(buf)+len(block)+1) - out = append(out, buf[:nl+1]...) - out = append(out, block...) - out = append(out, '\n') - out = append(out, buf[nl+1:]...) - return out + // Preserve a valid existing shebang and inject directly after it. + if bytes.HasPrefix(buf, []byte("#!")) { + if nl := bytes.IndexByte(buf, '\n'); nl >= 0 { + out := make([]byte, 0, len(buf)+len(block)+1) + out = append(out, buf[:nl+1]...) + out = append(out, block...) + out = append(out, '\n') + out = append(out, buf[nl+1:]...) + return out + } } - // writeDnsmasqPostconfFile rejects this case for pre-existing hooks. - return buf + // Missing/empty/non-shebang hooks are still safe to extend: the synthetic + // wrapper is explicitly marked as ctrld-owned and cleanup removes it. + marked := merlinBlockWithSyntheticShebang(block) + out := make([]byte, 0, len(buf)+len(marked)+16) + out = append(out, "#!/bin/sh\n"...) + out = append(out, marked...) + out = append(out, '\n') + out = append(out, buf...) + return out } // atomicWriteFile replaces path only after a complete sibling temporary file From 3a8505bf05c0f6253e34c64eb0e98b2d8f7497b9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:15:13 +0100 Subject: [PATCH 18/35] Preflight Merlin hooks before shared writes --- internal/router/merlin/merlin.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 8ebdd09c..d30db186 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -217,7 +217,13 @@ func (m *Merlin) writeDnsmasqPostconf() error { dnsmasq.MerlinPostConfEndMarker, }, "\n")) - paths := []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} + return writeMerlinHookUpdates( + []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath}, + block, + ) +} + +func writeMerlinHookUpdates(paths []string, block []byte) error { updates := make([]merlinHookUpdate, 0, len(paths)) for _, path := range paths { update, err := prepareMerlinHookUpdate(path, block) From 2b4be578cf0ba8b70d75a52fdebcba038890c00d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:15:33 +0100 Subject: [PATCH 19/35] Test reversible Merlin shebangs and hook preflight --- internal/router/merlin/merlin_test.go | 81 ++++++++++++++++++++------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index bd24e892..6d99cc5c 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -141,7 +141,7 @@ func Test_cleanupDnsmasqPostconfPreservesPreexistingStub(t *testing.T) { } } -func Test_cleanupDnsmasqPostconfLeavesStubWhenCtrldCreatedHook(t *testing.T) { +func Test_cleanupDnsmasqPostconfLeavesEmptyPathWhenCtrldCreatedHook(t *testing.T) { path := filepath.Join(t.TempDir(), "dnsmasq.postconf") block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") if err := os.WriteFile(path, merlinUpsertPostConf(nil, block), 0750); err != nil { @@ -155,8 +155,8 @@ func Test_cleanupDnsmasqPostconfLeavesStubWhenCtrldCreatedHook(t *testing.T) { if err != nil { t.Fatalf("cleanup removed shared hook path: %v", err) } - if string(got) != "#!/bin/sh\n" { - t.Fatalf("unexpected cleaned stub: %q", got) + if len(got) != 0 { + t.Fatalf("unexpected cleaned ctrld-created hook: %q", got) } } @@ -213,6 +213,10 @@ func Test_merlinLegacyMarkersMustBeCompleteLines(t *testing.T) { func Test_merlinPostConfRoundTripRestoresOriginalBytes(t *testing.T) { tests := [][]byte{ + nil, + []byte("\n\n"), + []byte("echo no-shebang\n"), + []byte("#!/bin/sh"), []byte("#!/bin/sh\n"), []byte("#!/bin/sh\n\necho custom\n"), []byte("#!/bin/sh\n# comment\necho one\necho two\n"), @@ -242,23 +246,62 @@ func Test_merlinLegacyCleanupRestoresShebangAtByteZero(t *testing.T) { } } -func Test_merlinExistingHookShebangValidation(t *testing.T) { - tests := []struct { - name string - buf []byte - want bool - }{ - {"valid", []byte("#!/bin/sh\necho ok\n"), true}, - {"empty", nil, false}, - {"no shebang", []byte("echo no\n"), false}, - {"unterminated shebang", []byte("#!/bin/sh"), false}, +func Test_merlinSyntheticShebangIsOwnedAndReversible(t *testing.T) { + orig := []byte("echo third-party-without-shebang\n") + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + installed := merlinUpsertPostConf(orig, block) + + if !bytes.HasPrefix(installed, []byte("#!/bin/sh\n")) { + t.Fatalf("synthetic shebang missing: %q", installed) } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := merlinExistingHookHasValidShebang(tc.buf); got != tc.want { - t.Fatalf("validation = %v, want %v for %q", got, tc.want, tc.buf) - } - }) + if !bytes.Contains(installed, []byte(dnsmasq.MerlinSyntheticShebangMarker)) { + t.Fatalf("synthetic shebang ownership marker missing: %q", installed) + } + if got := merlinParsePostConf(installed); !bytes.Equal(got, orig) { + t.Fatalf("cleanup did not restore original bytes:\nwant: %q\ngot: %q", orig, got) + } +} + +func Test_merlinLegacyEmptyHookCanBeReinstalled(t *testing.T) { + legacy := []byte(legacyMerlinPostConf("")) + clean := merlinParsePostConf(legacy) + if len(clean) != 0 { + t.Fatalf("legacy cleanup = %q, want empty hook", clean) + } + + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + reinstalled := merlinUpsertPostConf(clean, block) + if !bytes.HasPrefix(reinstalled, []byte("#!/bin/sh\n")) || + !bytes.Contains(reinstalled, []byte(dnsmasq.MerlinSyntheticShebangMarker)) { + t.Fatalf("empty legacy hook was not safely reinstalled: %q", reinstalled) + } + if got := merlinParsePostConf(reinstalled); len(got) != 0 { + t.Fatalf("reinstalled hook did not round-trip to empty: %q", got) + } +} + +func Test_writeMerlinHookUpdatesPreflightsAllPaths(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "dnsmasq.postconf") + second := filepath.Join(dir, "dnsmasq-sdn.postconf") + orig := []byte("#!/bin/sh\necho untouched\n") + if err := os.WriteFile(first, orig, 0750); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(second, 0755); err != nil { + t.Fatal(err) + } + + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + if err := writeMerlinHookUpdates([]string{first, second}, block); err == nil { + t.Fatal("expected second-path preflight error") + } + got, err := os.ReadFile(first) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, orig) { + t.Fatalf("first hook changed before second path passed preflight:\nwant: %q\ngot: %q", orig, got) } } From 05dccdde031da0d530d247d1a75f2669b9734711 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:17:18 +0100 Subject: [PATCH 20/35] Roll back partial Merlin hook writes --- internal/router/merlin/merlin.go | 43 ++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index d30db186..9d4daab7 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -198,8 +198,10 @@ func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { // merlinHookUpdate is prepared entirely before any shared hook is modified. // This lets us validate/read both Merlin hook paths before the first write. type merlinHookUpdate struct { - path string - data []byte + path string + data []byte + original []byte + existed bool } // writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN @@ -233,9 +235,37 @@ func writeMerlinHookUpdates(paths []string, block []byte) error { updates = append(updates, update) } + written := make([]merlinHookUpdate, 0, len(updates)) for _, update := range updates { if err := atomicWriteFile(update.path, update.data, 0750); err != nil { - return err + if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { + return fmt.Errorf("write Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) + } + return fmt.Errorf("write Merlin hook %s: %w", update.path, err) + } + written = append(written, update) + } + return nil +} + +func rollbackMerlinHookUpdates(updates []merlinHookUpdate) error { + for i := len(updates) - 1; i >= 0; i-- { + update := updates[i] + current, err := os.ReadFile(update.path) + if err != nil { + return fmt.Errorf("read %s during rollback: %w", update.path, err) + } + if !bytes.Equal(current, update.data) { + return fmt.Errorf("refusing to roll back %s after external modification", update.path) + } + if update.existed { + if err := atomicWriteFile(update.path, update.original, 0750); err != nil { + return fmt.Errorf("restore %s: %w", update.path, err) + } + continue + } + if err := os.Remove(update.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove newly created %s: %w", update.path, err) } } return nil @@ -256,9 +286,12 @@ func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error buf = nil } + original := append([]byte(nil), buf...) return merlinHookUpdate{ - path: path, - data: merlinUpsertPostConf(buf, block), + path: path, + data: merlinUpsertPostConf(buf, block), + original: original, + existed: !pathMissing, }, nil } From bcddf9795ab6c5345074f412a9702ae73a14d951 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:17:27 +0100 Subject: [PATCH 21/35] Test rollback after partial Merlin hook write --- internal/router/merlin/merlin_test.go | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 6d99cc5c..b35a05e6 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -305,6 +305,36 @@ func Test_writeMerlinHookUpdatesPreflightsAllPaths(t *testing.T) { } } +func Test_writeMerlinHookUpdatesRollsBackPartialWrites(t *testing.T) { + firstDir := t.TempDir() + secondDir := t.TempDir() + first := filepath.Join(firstDir, "dnsmasq.postconf") + second := filepath.Join(secondDir, "dnsmasq-sdn.postconf") + orig := []byte("#!/bin/sh\necho original\n") + if err := os.WriteFile(first, orig, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second, []byte("#!/bin/sh\necho second\n"), 0750); err != nil { + t.Fatal(err) + } + if err := os.Chmod(secondDir, 0555); err != nil { + t.Fatal(err) + } + defer os.Chmod(secondDir, 0755) + + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + if err := writeMerlinHookUpdates([]string{first, second}, block); err == nil { + t.Fatal("expected second write failure") + } + got, err := os.ReadFile(first) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, orig) { + t.Fatalf("first hook was not rolled back:\nwant: %q\ngot: %q", orig, got) + } +} + func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { for _, mode := range []os.FileMode{0700, 0770} { t.Run(mode.String(), func(t *testing.T) { From bc206cd1879442f24a137e1978b1dec214d774d2 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:29:09 +0100 Subject: [PATCH 22/35] Make Merlin hook rollback test deterministic --- internal/router/merlin/merlin.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 9d4daab7..4465b68c 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -226,6 +226,14 @@ func (m *Merlin) writeDnsmasqPostconf() error { } func writeMerlinHookUpdates(paths []string, block []byte) error { + return writeMerlinHookUpdatesWith(paths, block, atomicWriteFile) +} + +func writeMerlinHookUpdatesWith( + paths []string, + block []byte, + writeFile func(string, []byte, os.FileMode) error, +) error { updates := make([]merlinHookUpdate, 0, len(paths)) for _, path := range paths { update, err := prepareMerlinHookUpdate(path, block) @@ -237,7 +245,7 @@ func writeMerlinHookUpdates(paths []string, block []byte) error { written := make([]merlinHookUpdate, 0, len(updates)) for _, update := range updates { - if err := atomicWriteFile(update.path, update.data, 0750); err != nil { + if err := writeFile(update.path, update.data, 0750); err != nil { if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { return fmt.Errorf("write Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) } From f9782e612fe01b5e05ffddeb98e2d27c7d2f2fbb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:29:23 +0100 Subject: [PATCH 23/35] Inject Merlin hook write failure in regression test --- internal/router/merlin/merlin_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index b35a05e6..4d05b10b 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -306,10 +306,9 @@ func Test_writeMerlinHookUpdatesPreflightsAllPaths(t *testing.T) { } func Test_writeMerlinHookUpdatesRollsBackPartialWrites(t *testing.T) { - firstDir := t.TempDir() - secondDir := t.TempDir() - first := filepath.Join(firstDir, "dnsmasq.postconf") - second := filepath.Join(secondDir, "dnsmasq-sdn.postconf") + dir := t.TempDir() + first := filepath.Join(dir, "dnsmasq.postconf") + second := filepath.Join(dir, "dnsmasq-sdn.postconf") orig := []byte("#!/bin/sh\necho original\n") if err := os.WriteFile(first, orig, 0750); err != nil { t.Fatal(err) @@ -317,13 +316,18 @@ func Test_writeMerlinHookUpdatesRollsBackPartialWrites(t *testing.T) { if err := os.WriteFile(second, []byte("#!/bin/sh\necho second\n"), 0750); err != nil { t.Fatal(err) } - if err := os.Chmod(secondDir, 0555); err != nil { - t.Fatal(err) + + writes := 0 + writeFile := func(path string, data []byte, mode os.FileMode) error { + writes++ + if writes == 2 { + return os.ErrPermission + } + return atomicWriteFile(path, data, mode) } - defer os.Chmod(secondDir, 0755) block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") - if err := writeMerlinHookUpdates([]string{first, second}, block); err == nil { + if err := writeMerlinHookUpdatesWith([]string{first, second}, block, writeFile); err == nil { t.Fatal("expected second write failure") } got, err := os.ReadFile(first) From 81a6cb275d0eab6b6fb083ad9f7a4c4ff9ed95ee Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:33:15 +0100 Subject: [PATCH 24/35] Extend Merlin setup transaction and revalidate hooks --- internal/router/merlin/merlin.go | 99 +++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 4465b68c..328e1703 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -79,7 +79,7 @@ func (m *Merlin) PreRun() error { } // Setup initializes and configures the Merlin instance for use, including setting up dnsmasq and necessary nvram settings. -func (m *Merlin) Setup() error { +func (m *Merlin) Setup() (retErr error) { if m.cfg.FirstListener().IsDirectDnsListener() { return nil } @@ -88,6 +88,24 @@ func (m *Merlin) Setup() error { return nil } + // Publish the existing ctrld_setup transaction marker before touching shared + // hooks or persistent dnsmasq snapshots. Any later setup failure can then use + // the normal Cleanup path instead of leaving an untracked partial install. + // + // This also ensures dnspriv_enable is already in ctrld's desired state when + // Merlin regenerates dnsmasq during restart. + if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + defer func() { + if retErr == nil { + return + } + if cleanupErr := m.Cleanup(); cleanupErr != nil { + retErr = fmt.Errorf("%v; setup rollback failed: %w", retErr, cleanupErr) + } + }() + if err := m.writeDnsmasqPostconf(); err != nil { return err } @@ -103,10 +121,6 @@ func (m *Merlin) Setup() error { return err } - if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { - return err - } - return nil } @@ -198,10 +212,12 @@ func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { // merlinHookUpdate is prepared entirely before any shared hook is modified. // This lets us validate/read both Merlin hook paths before the first write. type merlinHookUpdate struct { - path string - data []byte - original []byte - existed bool + path string + data []byte + original []byte + existed bool + pathType os.FileMode + symlinkTarget string } // writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN @@ -245,6 +261,12 @@ func writeMerlinHookUpdatesWith( written := make([]merlinHookUpdate, 0, len(updates)) for _, update := range updates { + if err := revalidateMerlinHookUpdate(update); err != nil { + if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { + return fmt.Errorf("revalidate Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) + } + return fmt.Errorf("revalidate Merlin hook %s: %w", update.path, err) + } if err := writeFile(update.path, update.data, 0750); err != nil { if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { return fmt.Errorf("write Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) @@ -280,7 +302,7 @@ func rollbackMerlinHookUpdates(updates []merlinHookUpdate) error { } func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error) { - _, statErr := os.Lstat(path) + info, statErr := os.Lstat(path) pathMissing := os.IsNotExist(statErr) if statErr != nil && !pathMissing { return merlinHookUpdate{}, statErr @@ -294,13 +316,62 @@ func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error buf = nil } - original := append([]byte(nil), buf...) - return merlinHookUpdate{ + update := merlinHookUpdate{ path: path, data: merlinUpsertPostConf(buf, block), - original: original, + original: append([]byte(nil), buf...), existed: !pathMissing, - }, nil + } + if !pathMissing { + update.pathType = info.Mode().Type() + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return merlinHookUpdate{}, err + } + update.symlinkTarget = target + } + } + return update, nil +} + +func revalidateMerlinHookUpdate(update merlinHookUpdate) error { + info, err := os.Lstat(update.path) + if !update.existed { + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + return fmt.Errorf("shared hook appeared after preflight") + } + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("shared hook disappeared after preflight") + } + return err + } + if info.Mode().Type() != update.pathType { + return fmt.Errorf("shared hook type changed after preflight") + } + if update.pathType&os.ModeSymlink != 0 { + target, err := os.Readlink(update.path) + if err != nil { + return err + } + if target != update.symlinkTarget { + return fmt.Errorf("shared hook symlink target changed after preflight") + } + } + current, err := os.ReadFile(update.path) + if err != nil { + return err + } + if !bytes.Equal(current, update.original) { + return fmt.Errorf("shared hook content changed after preflight") + } + return nil } func cleanupDnsmasqPostconf(path string) error { From c77595c6b049c9772c0fce469de16b614a0c8330 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:33:41 +0100 Subject: [PATCH 25/35] Test Merlin hook precommit revalidation --- internal/router/merlin/merlin_test.go | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 4d05b10b..b11fa077 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -339,6 +339,58 @@ func Test_writeMerlinHookUpdatesRollsBackPartialWrites(t *testing.T) { } } +func Test_revalidateMerlinHookUpdateDetectsContentChange(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + orig := []byte("#!/bin/sh\necho original\n") + if err := os.WriteFile(path, orig, 0750); err != nil { + t.Fatal(err) + } + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + update, err := prepareMerlinHookUpdate(path, block) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\necho changed\n"), 0750); err != nil { + t.Fatal(err) + } + if err := revalidateMerlinHookUpdate(update); err == nil { + t.Fatal("expected revalidation failure after content change") + } +} + +func Test_revalidateMerlinHookUpdateDetectsAppearedPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + update, err := prepareMerlinHookUpdate(path, block) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\necho addon\n"), 0750); err != nil { + t.Fatal(err) + } + if err := revalidateMerlinHookUpdate(update); err == nil { + t.Fatal("expected revalidation failure when a missing hook appears") + } +} + +func Test_revalidateMerlinHookUpdateDetectsDisappearedPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0750); err != nil { + t.Fatal(err) + } + block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") + update, err := prepareMerlinHookUpdate(path, block) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := revalidateMerlinHookUpdate(update); err == nil { + t.Fatal("expected revalidation failure when a hook disappears") + } +} + func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { for _, mode := range []os.FileMode{0700, 0770} { t.Run(mode.String(), func(t *testing.T) { From b89540ec1627c3df1316c39071cd776a7bcb6189 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:41:54 +0100 Subject: [PATCH 26/35] Narrow Merlin hook coexistence to main postconf --- internal/router/merlin/merlin.go | 165 +++++++++++-------------------- 1 file changed, 55 insertions(+), 110 deletions(-) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 328e1703..d559dae6 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -79,7 +79,7 @@ func (m *Merlin) PreRun() error { } // Setup initializes and configures the Merlin instance for use, including setting up dnsmasq and necessary nvram settings. -func (m *Merlin) Setup() (retErr error) { +func (m *Merlin) Setup() error { if m.cfg.FirstListener().IsDirectDnsListener() { return nil } @@ -88,24 +88,6 @@ func (m *Merlin) Setup() (retErr error) { return nil } - // Publish the existing ctrld_setup transaction marker before touching shared - // hooks or persistent dnsmasq snapshots. Any later setup failure can then use - // the normal Cleanup path instead of leaving an untracked partial install. - // - // This also ensures dnspriv_enable is already in ctrld's desired state when - // Merlin regenerates dnsmasq during restart. - if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { - return err - } - defer func() { - if retErr == nil { - return - } - if cleanupErr := m.Cleanup(); cleanupErr != nil { - retErr = fmt.Errorf("%v; setup rollback failed: %w", retErr, cleanupErr) - } - }() - if err := m.writeDnsmasqPostconf(); err != nil { return err } @@ -121,6 +103,10 @@ func (m *Merlin) Setup() (retErr error) { return err } + if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + return nil } @@ -133,10 +119,13 @@ func (m *Merlin) Cleanup() error { return nil // was restored, nothing to do. } - for _, path := range []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath} { - if err := cleanupDnsmasqPostconf(path); err != nil { - return err - } + // Restore old configs. + if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + if err := cleanupDnsmasqPostconf(dnsmasq.MerlinPostConfPath); err != nil { + return err } for _, cfg := range getDnsmasqConfigs() { @@ -144,13 +133,6 @@ func (m *Merlin) Cleanup() error { return fmt.Errorf("failed to cleanup jffs dnsmasq: config: %s, error: %w", cfg.confPath, err) } } - - // Restore NVRAM only after ctrld-owned artifacts are cleaned successfully. - // Keeping ctrld_setup set until this point makes a failed cleanup retryable. - if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { - return err - } - // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -188,12 +170,8 @@ func (m *Merlin) setupDnsmasq(cfg *dnsmasqConfig) error { return fmt.Errorf("failed to save %s: %w", cfg.jffsConfPath, err) } - // Run the appropriate postconf script on cfg.jffsConfPath directly. - postConfPath := dnsmasq.MerlinPostConfPath - if cfg.confPath != dnsmasq.MerlinConfPath { - postConfPath = dnsmasq.MerlinSdnPostConfPath - } - cmd := exec.Command("/bin/sh", postConfPath, cfg.jffsConfPath) + // Run postconf script on cfg.jffsConfPath directly. + cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, cfg.jffsConfPath) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to run post conf: %s: %w", string(out), err) } @@ -209,8 +187,6 @@ func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { return nil } -// merlinHookUpdate is prepared entirely before any shared hook is modified. -// This lets us validate/read both Merlin hook paths before the first write. type merlinHookUpdate struct { path string data []byte @@ -220,10 +196,9 @@ type merlinHookUpdate struct { symlinkTarget string } -// writeDnsmasqPostconf installs ctrld-owned blocks in Merlin's main and SDN -// hooks while preserving unrelated content. Both paths are preflighted before -// either is modified, avoiding a half-installed integration when the second -// shared hook is unreadable or otherwise invalid. +// writeDnsmasqPostconf manages only ctrld's marked block in the shared main +// dnsmasq.postconf hook. Merlin 3006 SDN support is intentionally handled in a +// separate change so this patch does not alter router lifecycle semantics. func (m *Merlin) writeDnsmasqPostconf() error { data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) if err != nil { @@ -235,73 +210,43 @@ func (m *Merlin) writeDnsmasqPostconf() error { dnsmasq.MerlinPostConfEndMarker, }, "\n")) - return writeMerlinHookUpdates( - []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath}, - block, - ) + update, err := prepareMerlinHookUpdate(dnsmasq.MerlinPostConfPath, block) + if err != nil { + return err + } + if err := revalidateMerlinHookUpdate(update); err != nil { + return fmt.Errorf("revalidate Merlin hook %s: %w", update.path, err) + } + if err := atomicWriteFile(update.path, update.data, 0750); err != nil { + return fmt.Errorf("write Merlin hook %s: %w", update.path, err) + } + return nil } -func writeMerlinHookUpdates(paths []string, block []byte) error { - return writeMerlinHookUpdatesWith(paths, block, atomicWriteFile) +func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error) { + return prepareMerlinHookReplacement(path, func(buf []byte) []byte { + return merlinUpsertPostConf(buf, block) + }) } -func writeMerlinHookUpdatesWith( - paths []string, - block []byte, - writeFile func(string, []byte, os.FileMode) error, -) error { - updates := make([]merlinHookUpdate, 0, len(paths)) - for _, path := range paths { - update, err := prepareMerlinHookUpdate(path, block) - if err != nil { - return err - } - updates = append(updates, update) +func prepareMerlinHookCleanup(path string) (*merlinHookUpdate, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, nil } - - written := make([]merlinHookUpdate, 0, len(updates)) - for _, update := range updates { - if err := revalidateMerlinHookUpdate(update); err != nil { - if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { - return fmt.Errorf("revalidate Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) - } - return fmt.Errorf("revalidate Merlin hook %s: %w", update.path, err) - } - if err := writeFile(update.path, update.data, 0750); err != nil { - if rollbackErr := rollbackMerlinHookUpdates(written); rollbackErr != nil { - return fmt.Errorf("write Merlin hook %s: %w; rollback failed: %v", update.path, err, rollbackErr) - } - return fmt.Errorf("write Merlin hook %s: %w", update.path, err) - } - written = append(written, update) + if err != nil { + return nil, err } - return nil -} + _ = info -func rollbackMerlinHookUpdates(updates []merlinHookUpdate) error { - for i := len(updates) - 1; i >= 0; i-- { - update := updates[i] - current, err := os.ReadFile(update.path) - if err != nil { - return fmt.Errorf("read %s during rollback: %w", update.path, err) - } - if !bytes.Equal(current, update.data) { - return fmt.Errorf("refusing to roll back %s after external modification", update.path) - } - if update.existed { - if err := atomicWriteFile(update.path, update.original, 0750); err != nil { - return fmt.Errorf("restore %s: %w", update.path, err) - } - continue - } - if err := os.Remove(update.path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove newly created %s: %w", update.path, err) - } + update, err := prepareMerlinHookReplacement(path, merlinParsePostConf) + if err != nil { + return nil, err } - return nil + return &update, nil } -func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error) { +func prepareMerlinHookReplacement(path string, transform func([]byte) []byte) (merlinHookUpdate, error) { info, statErr := os.Lstat(path) pathMissing := os.IsNotExist(statErr) if statErr != nil && !pathMissing { @@ -318,7 +263,7 @@ func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error update := merlinHookUpdate{ path: path, - data: merlinUpsertPostConf(buf, block), + data: transform(buf), original: append([]byte(nil), buf...), existed: !pathMissing, } @@ -375,19 +320,19 @@ func revalidateMerlinHookUpdate(update merlinHookUpdate) error { } func cleanupDnsmasqPostconf(path string) error { - buf, err := os.ReadFile(path) - if os.IsNotExist(err) { + update, err := prepareMerlinHookCleanup(path) + if err != nil || update == nil { + return err + } + if bytes.Equal(update.original, update.data) { return nil } - if err != nil { - return err + if err := revalidateMerlinHookUpdate(*update); err != nil { + return fmt.Errorf("revalidate Merlin hook cleanup %s: %w", path, err) } - - clean := merlinParsePostConf(buf) - // Never delete a shared Merlin hook outright. We cannot safely prove - // persistent ownership across addon rewrites/reboots, so cleanup removes - // only ctrld-owned bytes and leaves any resulting stub in place. - return atomicWriteFile(path, clean, 0750) + // Never delete a shared Merlin hook outright. Cleanup removes only ctrld's + // owned bytes and preserves the path, content and mode belonging to others. + return atomicWriteFile(path, update.data, 0750) } // restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". From 1a9f978dd2d8967969abeea8cd1e4f0e5a403ca3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:42:08 +0100 Subject: [PATCH 27/35] Keep SDN hook support out of coexistence patch --- internal/router/dnsmasq/dnsmasq.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index db2d47e1..8787dcdc 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -32,7 +32,6 @@ const ( MerlinJffsConfDir = "/jffs/configs" MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" - MerlinSdnPostConfPath = "/jffs/scripts/dnsmasq-sdn.postconf" ) const ( From ac5906fbfe071b1a3c7bad69436b5173aca6c579 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:42:29 +0100 Subject: [PATCH 28/35] Keep coexistence tests scoped and portable --- internal/router/merlin/merlin_test.go | 84 ++++++++------------------- 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index b11fa077..585dc0b2 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -3,6 +3,7 @@ package merlin import ( "bytes" "os" + "runtime" "path/filepath" "strings" "testing" @@ -280,65 +281,6 @@ func Test_merlinLegacyEmptyHookCanBeReinstalled(t *testing.T) { } } -func Test_writeMerlinHookUpdatesPreflightsAllPaths(t *testing.T) { - dir := t.TempDir() - first := filepath.Join(dir, "dnsmasq.postconf") - second := filepath.Join(dir, "dnsmasq-sdn.postconf") - orig := []byte("#!/bin/sh\necho untouched\n") - if err := os.WriteFile(first, orig, 0750); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(second, 0755); err != nil { - t.Fatal(err) - } - - block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") - if err := writeMerlinHookUpdates([]string{first, second}, block); err == nil { - t.Fatal("expected second-path preflight error") - } - got, err := os.ReadFile(first) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, orig) { - t.Fatalf("first hook changed before second path passed preflight:\nwant: %q\ngot: %q", orig, got) - } -} - -func Test_writeMerlinHookUpdatesRollsBackPartialWrites(t *testing.T) { - dir := t.TempDir() - first := filepath.Join(dir, "dnsmasq.postconf") - second := filepath.Join(dir, "dnsmasq-sdn.postconf") - orig := []byte("#!/bin/sh\necho original\n") - if err := os.WriteFile(first, orig, 0750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(second, []byte("#!/bin/sh\necho second\n"), 0750); err != nil { - t.Fatal(err) - } - - writes := 0 - writeFile := func(path string, data []byte, mode os.FileMode) error { - writes++ - if writes == 2 { - return os.ErrPermission - } - return atomicWriteFile(path, data, mode) - } - - block := []byte("# BEGIN ctrld\necho managed\n# END ctrld") - if err := writeMerlinHookUpdatesWith([]string{first, second}, block, writeFile); err == nil { - t.Fatal("expected second write failure") - } - got, err := os.ReadFile(first) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, orig) { - t.Fatalf("first hook was not rolled back:\nwant: %q\ngot: %q", orig, got) - } -} - func Test_revalidateMerlinHookUpdateDetectsContentChange(t *testing.T) { path := filepath.Join(t.TempDir(), "dnsmasq.postconf") orig := []byte("#!/bin/sh\necho original\n") @@ -392,6 +334,9 @@ func Test_revalidateMerlinHookUpdateDetectsDisappearedPath(t *testing.T) { } func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode bits are not preserved by Windows filesystems") + } for _, mode := range []os.FileMode{0700, 0770} { t.Run(mode.String(), func(t *testing.T) { path := filepath.Join(t.TempDir(), "dnsmasq.postconf") @@ -411,3 +356,24 @@ func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { }) } } + +func Test_cleanupPreparationRevalidatesBeforeWrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.postconf") + managed := []byte("#!/bin/sh\n# BEGIN ctrld\necho managed\n# END ctrld\necho addon\n") + if err := os.WriteFile(path, managed, 0750); err != nil { + t.Fatal(err) + } + update, err := prepareMerlinHookCleanup(path) + if err != nil { + t.Fatal(err) + } + if update == nil { + t.Fatal("expected cleanup update") + } + if err := os.WriteFile(path, []byte("#!/bin/sh\necho addon-changed\n"), 0750); err != nil { + t.Fatal(err) + } + if err := revalidateMerlinHookUpdate(*update); err == nil { + t.Fatal("expected cleanup revalidation failure after external modification") + } +} From 9201f3ea8607d9da303900c032ca65e178def22a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:45:53 +0100 Subject: [PATCH 29/35] Sync Merlin hook directory after atomic rename --- internal/router/merlin/merlin.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index d559dae6..855cfe4a 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -651,6 +652,26 @@ func atomicWriteFile(path string, data []byte, mode os.FileMode) (err error) { if err = os.Rename(tmpName, target); err != nil { return err } + if err = syncParentDir(dir); err != nil { + return err + } + return nil +} + +func syncParentDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + if err := f.Sync(); err != nil { + // Directory fsync is not supported by the Windows CI filesystem. Merlin + // itself is Unix-only, where this is required for rename durability. + if runtime.GOOS == "windows" { + return nil + } + return err + } return nil } From 390142b363854c3e3c998d873158ebc1ed800fb5 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:45:56 +0100 Subject: [PATCH 30/35] Tidy Merlin coexistence test imports --- internal/router/merlin/merlin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 585dc0b2..f93137e0 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -3,8 +3,8 @@ package merlin import ( "bytes" "os" - "runtime" "path/filepath" + "runtime" "strings" "testing" From 37f0d7e7db1571fa9debd776411eb633b0da12bb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:51:39 +0100 Subject: [PATCH 31/35] Isolate ctrld Merlin hook variables --- internal/router/dnsmasq/dnsmasq.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 8787dcdc..d7024118 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -42,7 +42,7 @@ const ( MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` ) -const MerlinPostConfTmpl = `config_file="$1" +const MerlinPostConfTmpl = `(\nconfig_file="$1" . /usr/sbin/helper.sh pid=$(cat /tmp/ctrld.pid 2>/dev/null) @@ -70,6 +70,7 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then pc_delete "nameserver" /etc/resolv.conf pc_append "nameserver 127.0.0.1" /etc/resolv.conf fi +) ` type Upstream struct { From 57f4a5d00d70c0f8d383df08551c340ef191eb77 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:51:55 +0100 Subject: [PATCH 32/35] Test Merlin postconf namespace isolation --- internal/router/merlin/merlin_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index f93137e0..9d0f9bb4 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -87,9 +87,12 @@ func Test_merlinPostConfDoesNotExitHostHook(t *testing.T) { if strings.Contains(dnsmasq.MerlinPostConfTmpl, "exit 0") { t.Fatal("ctrld postconf block must not terminate the enclosing Merlin hook") } + trimmed := strings.TrimSpace(dnsmasq.MerlinPostConfTmpl) + if !strings.HasPrefix(trimmed, "(") || !strings.HasSuffix(trimmed, ")") { + t.Fatal("ctrld postconf block must run in a subshell to isolate shared-hook variables") + } } - func Test_merlinLegacyMigrationPreservesPrependedContent(t *testing.T) { legacy := "echo addon-before\n" + legacyMerlinPostConf("echo addon-after") block := []byte("# BEGIN ctrld\necho ctrld-new\n# END ctrld") From f6cd94eac7d994784479c4620658b13a96f5b32f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:55:56 +0100 Subject: [PATCH 33/35] Fix Merlin postconf subshell newline --- internal/router/dnsmasq/dnsmasq.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index d7024118..438a7cb7 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -42,7 +42,8 @@ const ( MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` ) -const MerlinPostConfTmpl = `(\nconfig_file="$1" +const MerlinPostConfTmpl = `( +config_file="$1" . /usr/sbin/helper.sh pid=$(cat /tmp/ctrld.pid 2>/dev/null) From 4de5592fe96673e14dbb0612027846fe7b60d03d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:56:10 +0100 Subject: [PATCH 34/35] Test real Merlin subshell line breaks --- internal/router/merlin/merlin_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 9d0f9bb4..ddcabf1c 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -88,8 +88,11 @@ func Test_merlinPostConfDoesNotExitHostHook(t *testing.T) { t.Fatal("ctrld postconf block must not terminate the enclosing Merlin hook") } trimmed := strings.TrimSpace(dnsmasq.MerlinPostConfTmpl) - if !strings.HasPrefix(trimmed, "(") || !strings.HasSuffix(trimmed, ")") { - t.Fatal("ctrld postconf block must run in a subshell to isolate shared-hook variables") + if !strings.HasPrefix(trimmed, "(\n") || !strings.HasSuffix(trimmed, "\n)") { + t.Fatal("ctrld postconf block must run in a newline-delimited subshell to isolate shared-hook variables") + } + if strings.Contains(dnsmasq.MerlinPostConfTmpl, `\nconfig_file`) { + t.Fatal("raw Merlin postconf template must contain a real newline, not a literal \\n escape") } } From 9418d0816386d926dccf6e3b273c1603a6b78c24 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:33:45 +0100 Subject: [PATCH 35/35] Fix Merlin mode-preservation test fixture --- internal/router/merlin/merlin_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index ddcabf1c..e71ebb37 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -346,7 +346,13 @@ func Test_atomicWriteFilePreservesExistingMode(t *testing.T) { for _, mode := range []os.FileMode{0700, 0770} { t.Run(mode.String(), func(t *testing.T) { path := filepath.Join(t.TempDir(), "dnsmasq.postconf") - if err := os.WriteFile(path, []byte("old"), mode); err != nil { + if err := os.WriteFile(path, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + // os.WriteFile is subject to the process umask; set the fixture mode + // explicitly so the assertion tests atomicWriteFile rather than the + // runner's umask. + if err := os.Chmod(path, mode); err != nil { t.Fatal(err) } if err := atomicWriteFile(path, []byte("new"), 0750); err != nil {