diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 98dc7637..5021cdb1 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -442,6 +442,18 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { os.Exit(0) } + // On Merlin, self-upgrade replaces the executable before the old process + // invokes Service.Start. The newly launched `ctrld run` process is therefore + // the first guaranteed execution point for new service-lifecycle code. + // Migrate an exact ctrld-owned legacy startup script here. Failure is + // deliberately non-fatal: the legacy script remains atomically intact and + // the next daemon start retries the migration. + if router.Name() == "merlin" { + if err := router.RefreshMerlinStartupScriptFromRunningBinary(); err != nil { + mainLog.Load().Warn().Err(err).Msg("could not refresh Merlin startup script") + } + } + p.onStarted = append(p.onStarted, func() { for _, lc := range p.cfg.Listener { if shouldAllocateLoopbackIP(lc.IP) { diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 6ebf5c71..3930cea0 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -926,6 +926,19 @@ func initStopCmd() *cobra.Command { return stopCmd } + +type restartWithBetweenService interface { + RestartWith(func() error) error +} + +func restartServiceTransaction(s service.Service, between func() error) (bool, error) { + tx, ok := s.(restartWithBetweenService) + if !ok { + return false, nil + } + return true, tx.RestartWith(between) +} + func initRestartCmd() *cobra.Command { restartCmd := &cobra.Command{ PreRun: func(cmd *cobra.Command, args []string) { @@ -971,49 +984,77 @@ func initRestartCmd() *cobra.Command { } doRestart := func() bool { - tasks := []task{ - {s.Stop, true, "Stop"}, - {func() error { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - return nil - }, false, "Cleanup"}, - {func() error { - time.Sleep(time.Second * 1) - return nil - }, false, "Waiting for service to stop"}, - } - if doTasks(tasks) { + between := func() error { + p.router.Cleanup() + // restore static DNS settings or DHCP + p.resetDNS(false, true) + time.Sleep(time.Second) if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - loop: for { select { case <-ctx.Done(): mainLog.Load().Error().Msg("timeout while waiting for service to stop") - break loop + return nil default: } - time.Sleep(time.Second) if status, _ := s.Status(); status == service.StatusStopped { break } + time.Sleep(time.Second) } } - } else { - return false + return nil } - tasks = []task{ - {s.Start, true, "Start"}, + if _, ok := s.(restartWithBetweenService); ok { + return doTasks([]task{ + {func() error { + _, err := restartServiceTransaction(s, between) + return err + }, true, "Restart"}, + }) } - return doTasks(tasks) + // Preserve the existing restart behavior for non-Merlin service + // backends. Only Merlin opts into RestartWith. + tasks := []task{ + {s.Stop, true, "Stop"}, + {func() error { + p.router.Cleanup() + p.resetDNS(false, true) + return nil + }, false, "Cleanup"}, + {func() error { + time.Sleep(time.Second) + return nil + }, false, "Waiting for service to stop"}, + } + if !doTasks(tasks) { + return false + } + if router.WaitProcessExited() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + loop: + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + break loop + default: + } + time.Sleep(time.Second) + if status, _ := s.Status(); status == service.StatusStopped { + break + } + } + } + return doTasks([]task{{s.Start, true, "Start"}}) } if doRestart() { @@ -1547,25 +1588,58 @@ func initUpgradeCmd() *cobra.Command { if !svcInstalled { return true } - tasks := []task{ - {s.Stop, true, "Stop"}, - {func() error { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - return nil - }, false, "Cleanup"}, - {func() error { - time.Sleep(time.Second * 1) - return nil - }, false, "Waiting for service to stop"}, - } - if doTasks(tasks) { + + between := func() error { + p.router.Cleanup() + // restore static DNS settings or DHCP + p.resetDNS(false, true) + time.Sleep(time.Second) if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() + for { + select { + case <-ctx.Done(): + mainLog.Load().Error().Msg("timeout while waiting for service to stop") + return nil + default: + } + if status, _ := s.Status(); status == service.StatusStopped { + break + } + time.Sleep(time.Second) + } + } + return nil + } + + if _, ok := s.(restartWithBetweenService); ok { + _, err := restartServiceTransaction(s, between) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to restart ctrld service") + return false + } + } else { + // Keep the historical upgrade restart path for every non-Merlin + // backend; they do not participate in the new Merlin flock. + tasks := []task{ + {s.Stop, true, "Stop"}, + {func() error { + p.router.Cleanup() + p.resetDNS(false, true) + return nil + }, false, "Cleanup"}, + {func() error { + time.Sleep(time.Second) + return nil + }, false, "Waiting for service to stop"}, + } + if doTasks(tasks) && router.WaitProcessExited() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + loop: for { select { @@ -1580,17 +1654,15 @@ func initUpgradeCmd() *cobra.Command { } } } + if !doTasks([]task{{s.Start, true, "Start"}}) { + return false + } } - tasks = []task{ - {s.Start, true, "Start"}, - } - if doTasks(tasks) { - if dir, err := socketDir(); err == nil { - if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil { - _, _ = cc.post(ifacePath, nil) - return true - } + if dir, err := socketDir(); err == nil { + if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil { + _, _ = cc.post(ifacePath, nil) + return true } } return false diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 61b321d6..00cd0a00 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -2,11 +2,11 @@ package dnsmasq import ( "errors" + "text/template" "net" "os" "path/filepath" "strings" - "text/template" "github.com/Control-D-Inc/ctrld" ) @@ -28,22 +28,44 @@ 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` + MerlinSyntheticShebangMarker = `# ctrld: synthetic shebang` + // MerlinPostConfMarker is the legacy end marker used by ctrld <= 1.5.7. + MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` +) +const MerlinPostConfTmpl = `( config_file="$1" . /usr/sbin/helper.sh pid=$(cat /tmp/ctrld.pid 2>/dev/null) -if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then +ctrld_cmd="" +case "$pid" in + ''|*[!0-9]*) ;; + *) + if [ -r "/proc/${pid}/cmdline" ]; then + ctrld_cmd=$(tr '\000' '\n' < "/proc/${pid}/cmdline" 2>/dev/null | sed -n '1p') + fi + ;; +esac +case "$ctrld_cmd" in + ctrld|*/ctrld) + ctrld_running=1 + ;; + *) + ctrld_running=0 + ;; +esac +if [ "$ctrld_running" -eq 1 ]; then pc_delete "servers-file" "$config_file" # no WAN DNS settings pc_append "no-resolv" "$config_file" # do not read /etc/resolv.conf # use ctrld as upstream @@ -66,9 +88,8 @@ 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 +) ` type Upstream struct { diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index c1c68210..7169e6ed 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -2,14 +2,17 @@ package merlin import ( "bytes" + "crypto/sha256" + "encoding/hex" "fmt" "io" "os" "os/exec" + pathpkg "path" "path/filepath" + "runtime" "strings" "time" - "unicode" "github.com/kardianos/service" @@ -21,16 +24,20 @@ import ( const Name = "merlin" +const ( + merlinStateDir = "/jffs/controld" + merlinManagedStatePath = merlinStateDir + "/.merlin-dnsmasq-hooks-v2" + merlinSnapshotStatePath = "/jffs/controld/.merlin-dnsmasq-snapshot" + merlinSnapshotAnchorPath = "/jffs/configs/.dnsmasq.conf.ctrld-anchor" + merlinSnapshotQuarantinePath = "/jffs/configs/.dnsmasq.conf.ctrld-quarantine" + merlinCleanupPendingPath = merlinStateDir + "/.merlin-dnsmasq-cleanup-pending" +) + // nvramKvMap is a map of NVRAM key-value pairs used to configure and manage Merlin-specific settings. var nvramKvMap = map[string]string{ "dnspriv_enable": "0", // Ensure Merlin native DoT disabled. } -// dnsmasqConfig represents configuration paths for dnsmasq operations in Merlin firmware. -type dnsmasqConfig struct { - confPath string - jffsConfPath string -} // Merlin represents a configuration handler for setting up and managing ctrld on Merlin routers. type Merlin struct { @@ -59,8 +66,26 @@ func (m *Merlin) Uninstall(_ *service.Config) error { // PreRun prepares the Merlin instance for operation by waiting for essential services and directories to become available. func (m *Merlin) PreRun() error { + if err := ensureMerlinStateDir(); err != nil { + return err + } + // Reconcile any previous Merlin integration before starting again. Router + // startup is a one-shot launch, so retry transient JFFS/NVRAM/dnsmasq errors + // here instead of abandoning DNS in a partially reconciled state. + var cleanupErr error + for attempt := 1; attempt <= 3; attempt++ { + cleanupErr = m.Cleanup() + if cleanupErr == nil { + break + } + if attempt < 3 { + time.Sleep(time.Second) + } + } + if cleanupErr != nil { + return fmt.Errorf("failed to cleanup previous Merlin integration after retries: %w", cleanupErr) + } // Wait NTP ready. - _ = m.Cleanup() if err := ntp.WaitNvram(); err != nil { return err } @@ -79,180 +104,1867 @@ func (m *Merlin) PreRun() error { return nil } +func ensureMerlinStateDir() error { + if info, err := os.Stat(merlinStateDir); err == nil { + if !info.IsDir() { + return fmt.Errorf("Merlin state path is not a directory: %s", merlinStateDir) + } + // Always re-sync the parent before relying on state below this + // directory. This also closes the retry window where mkdir succeeded + // previously but the parent fsync reported a transient failure. + if err := syncParentDir(filepath.Dir(merlinStateDir)); err != nil { + return fmt.Errorf("sync parent of existing Merlin state directory %s: %w", merlinStateDir, err) + } + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat Merlin state directory %s: %w", merlinStateDir, err) + } + + // A newly created JFFS directory entry is not crash-durable until its + // parent has been synced. State files below this directory are used to + // distinguish current hook-based installs from legacy snapshot ownership, + // so losing only the directory across power loss would be unsafe. + if err := os.MkdirAll(merlinStateDir, 0755); err != nil { + return fmt.Errorf("create Merlin state directory %s: %w", merlinStateDir, err) + } + if err := syncParentDir(filepath.Dir(merlinStateDir)); err != nil { + return fmt.Errorf("sync parent of Merlin state directory %s: %w", merlinStateDir, err) + } + return nil +} + // 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 err := ensureMerlinStateDir(); err != nil { + return err + } if m.cfg.FirstListener().IsDirectDnsListener() { return nil } // Already setup. - if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { + setupVal, err := nvram.Run("get", nvram.CtrldSetupKey) + if err != nil { + return fmt.Errorf("read Merlin setup state: %w", err) + } + if setupVal == "1" { return nil } - if err := m.writeDnsmasqPostconf(); err != nil { - return err + // Mark this as the hook-based integration before changing NVRAM so any + // failure after this point is rollback-visible. + if err := atomicWriteFile(merlinManagedStatePath, []byte("hooks-v2\n"), 0600); err != nil { + return fmt.Errorf("failed to write Merlin integration state: %w", err) } - for _, cfg := range getDnsmasqConfigs() { - if err := m.setupDnsmasq(cfg); err != nil { - return fmt.Errorf("failed to setup dnsmasq: config: %s, error: %w", cfg.confPath, err) + defer func() { + if retErr == nil { + return + } + if cleanupErr := m.Cleanup(); cleanupErr != nil { + retErr = fmt.Errorf("%v; rollback failed: %w", retErr, cleanupErr) } + }() + + // Apply NVRAM changes before regenerating dnsmasq so the firmware-generated + // config reflects ctrld's desired DNS Privacy state. nvram.SetKV rolls back + // its own partial mutations on failure. + if err := nvram.SetKVWithVolatileRetryMarker(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + if err := m.writeDnsmasqPostconf(); err != nil { + return err } - // Restart dnsmasq service. + // On Merlin 3006 this restarts the main and all SDN dnsmasq instances, + // causing dnsmasq.postconf and dnsmasq-sdn.postconf to run. if err := restartDNSMasq(); err != nil { return err } - if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + mainOK, err := m.dnsmasqConfigUsesCtrld(dnsmasq.MerlinConfPath) + if err != nil { return err } + if !mainOK { + // Compatibility fallback for older Merlin devices where postconf is + // known not to execute. Never overwrite an unowned user config. + if err := m.setupMainDnsmasqFallback(); err != nil { + return err + } + if err := restartDNSMasq(); err != nil { + return err + } + mainOK, err = m.dnsmasqConfigUsesCtrld(dnsmasq.MerlinConfPath) + if err != nil { + return err + } + if !mainOK { + return fmt.Errorf("Merlin dnsmasq integration was not applied to %s", dnsmasq.MerlinConfPath) + } + } + + // Additional dnsmasq instances are Guest Network Pro / SDN instances on + // Merlin 3006. Their supported extension point is dnsmasq-sdn.postconf; + // full /jffs/configs/dnsmasq-N.conf files are not consumed by Merlin. + for _, path := range dnsmasq.AdditionalConfigFiles() { + ok, err := m.dnsmasqConfigUsesCtrld(path) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("Merlin SDN dnsmasq integration was not applied to %s", path) + } + } return nil } +const ( + legacyCleanupPhase = "cleanup-v2" + legacyFinalizePhase = "finalize-v2" + + legacyEntryPending = "pending" + legacyEntryDelete = "delete" + legacyEntryRestore = "restore" +) + +type legacySnapshotEntry struct { + state string + path string + hash string +} + +type legacyCleanupJournal struct { + phase string + entries []legacySnapshotEntry +} + // Cleanup restores the original dnsmasq and nvram configurations and restarts dnsmasq if necessary. func (m *Merlin) Cleanup() error { + if err := ensureMerlinStateDir(); err != nil { + return err + } + // Preserve the existing direct-listener lifecycle. A direct listener needs + // port 53 itself; restarting dnsmasq here would reclaim that port before + // ctrld binds. Transitioning between forwarding and direct-listener modes + // requires a separate port-ownership design and is outside this change. if m.cfg.FirstListener().IsDirectDnsListener() { return nil } - if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" { - return nil // was restored, nothing to do. + + setupVal, err := nvram.Run("get", nvram.CtrldSetupKey) + if err != nil { + return fmt.Errorf("read Merlin setup state during cleanup: %w", err) + } + managed, err := pathExists(merlinManagedStatePath) + if err != nil { + return fmt.Errorf("stat Merlin managed state: %w", err) } - // Restore old configs. - if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { - return err + journal, err := readLegacyCleanupJournal() + if err != nil { + return fmt.Errorf("read Merlin legacy cleanup state: %w", err) + } + if setupVal != "1" && !managed && journal.phase == "" { + return nil } - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - if err != nil && !os.IsNotExist(err) { - return err + legacy := journal.phase != "" || (setupVal == "1" && !managed) + if legacy && journal.phase == "" { + journal, err = buildLegacyCleanupJournal() + if err != nil { + return err + } + if err := writeLegacyCleanupJournal(journal); err != nil { + if cleanupErr := cleanupLegacyCaptureAnchors(journal); cleanupErr != nil { + return fmt.Errorf("journal legacy dnsmasq snapshots: %w; cleanup captured anchors: %v", err, cleanupErr) + } + return fmt.Errorf("journal legacy dnsmasq snapshots: %w", 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 + } + } + + if legacy { + if err := cleanupLegacySnapshots(&journal); err != nil { + return err + } + } else { + if err := cleanupOwnedMainSnapshot(); err != nil { + return err + } } - for _, cfg := range getDnsmasqConfigs() { - if err := m.cleanupDnsmasqJffs(cfg); err != nil { - 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. + if setupVal == "1" { + if err := nvram.RestoreWithVolatileRetryMarker(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err } } - // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { return err } + + if err := removeFileDurable(merlinManagedStatePath); err != nil { + return fmt.Errorf("remove Merlin managed state: %w", err) + } + if journal.phase != "" { + if err := removeFileDurable(merlinCleanupPendingPath); err != nil { + return fmt.Errorf("remove Merlin legacy cleanup state: %w", err) + } + } return nil } -// setupDnsmasq sets up dnsmasq configuration by writing postconf, copying configuration, and running a postconf script. -func (m *Merlin) setupDnsmasq(cfg *dnsmasqConfig) error { - src, err := os.Open(cfg.confPath) - if os.IsNotExist(err) { - return nil // nothing to do if conf file does not exist. +func legacySnapshotAnchorPath(path string) string { + return pathpkg.Join( + merlinStateDir, + "."+pathpkg.Base(path)+".ctrld-legacy-anchor", + ) +} + +func removeLegacySnapshotAnchor(path string) error { + if err := removeFileDurable(legacySnapshotAnchorPath(path)); err != nil { + return fmt.Errorf("remove legacy snapshot anchor for %s: %w", path, err) } + return nil +} + +func cleanupLegacyCaptureAnchors(journal legacyCleanupJournal) error { + for _, entry := range journal.entries { + if err := removeLegacySnapshotAnchor(entry.path); err != nil { + return err + } + } + return nil +} + +func buildLegacyCleanupJournal() (journal legacyCleanupJournal, retErr error) { + // A quarantine is meaningful only together with a durable journal. Never + // adopt an unjournaled private-looking pathname as ctrld-owned data. + orphans, err := filepath.Glob(filepath.Join( + dnsmasq.MerlinJffsConfDir, + ".dnsmasq*.ctrld-legacy-quarantine", + )) if err != nil { - return fmt.Errorf("failed to open dnsmasq config: %w", err) + return legacyCleanupJournal{}, err + } + if len(orphans) != 0 { + return legacyCleanupJournal{}, fmt.Errorf( + "unjournaled Merlin legacy quarantine requires manual reconciliation: %s", + strings.Join(orphans, ", "), + ) + } + + // With no durable journal present, any ctrld-private legacy anchors can only + // be leftovers from a crash before journal publication. Removing an anchor + // drops only ctrld's extra hard link; it never touches the public snapshot. + anchorOrphans, err := filepath.Glob(filepath.Join(merlinStateDir, ".dnsmasq*.ctrld-legacy-anchor")) + if err != nil { + return legacyCleanupJournal{}, err + } + for _, anchor := range anchorOrphans { + if err := removeFileDurable(anchor); err != nil { + return legacyCleanupJournal{}, fmt.Errorf("remove orphaned legacy snapshot anchor %s: %w", anchor, err) + } } - defer src.Close() - // Copy current dnsmasq config to cfg.jffsConfPath, - // Then we will run postconf script on this file. - // - // Normally, adding postconf script is enough. However, we see - // reports on some Merlin devices that postconf scripts does not - // work, but manipulating the config directly via /jffs/configs does. - dst, err := os.Create(cfg.jffsConfPath) + paths := []string{dnsmasq.MerlinJffsConfPath} + matches, err := filepath.Glob(filepath.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-*.conf")) if err != nil { - return fmt.Errorf("failed to create %s: %w", cfg.jffsConfPath, err) + return legacyCleanupJournal{}, err } - defer dst.Close() + paths = append(paths, matches...) + + journal = legacyCleanupJournal{phase: legacyCleanupPhase} + captured := make([]string, 0, len(paths)) + defer func() { + if retErr == nil { + return + } + for _, path := range captured { + _ = removeLegacySnapshotAnchor(path) + } + }() + + for _, path := range paths { + if !isLegacySnapshotPath(path) { + continue + } + + f, err := os.Open(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return legacyCleanupJournal{}, fmt.Errorf("open legacy dnsmasq snapshot %s: %w", path, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return legacyCleanupJournal{}, fmt.Errorf("stat legacy dnsmasq snapshot %s: %w", path, err) + } + buf, err := io.ReadAll(f) + closeErr := f.Close() + if err != nil { + return legacyCleanupJournal{}, fmt.Errorf("read legacy dnsmasq snapshot %s: %w", path, err) + } + if closeErr != nil { + return legacyCleanupJournal{}, fmt.Errorf("close legacy dnsmasq snapshot %s: %w", path, closeErr) + } + hash := merlinSnapshotHash(buf) + + anchor := legacySnapshotAnchorPath(path) + if err := os.Link(path, anchor); err != nil { + if os.IsNotExist(err) { + continue + } + return legacyCleanupJournal{}, fmt.Errorf("capture legacy snapshot identity %s: %w", path, err) + } + captured = append(captured, path) + if err := syncParentDir(filepath.Dir(anchor)); err != nil { + return legacyCleanupJournal{}, fmt.Errorf("sync legacy snapshot anchor %s: %w", anchor, err) + } + + anchorInfo, err := os.Stat(anchor) + if err != nil { + return legacyCleanupJournal{}, fmt.Errorf("stat legacy snapshot anchor %s: %w", anchor, err) + } + if !os.SameFile(info, anchorInfo) { + // The public pathname was replaced between Open and Link. Drop only + // our private link and never claim the replacement. + if err := removeLegacySnapshotAnchor(path); err != nil { + return legacyCleanupJournal{}, err + } + captured = captured[:len(captured)-1] + continue + } + anchorBuf, err := os.ReadFile(anchor) + if err != nil { + return legacyCleanupJournal{}, fmt.Errorf("read legacy snapshot anchor %s: %w", anchor, err) + } + if merlinSnapshotHash(anchorBuf) != hash { + // Same inode was modified while identity was being captured. Treat it + // as no longer safely attributable to ctrld. + if err := removeLegacySnapshotAnchor(path); err != nil { + return legacyCleanupJournal{}, err + } + captured = captured[:len(captured)-1] + continue + } - if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("failed to copy current dnsmasq config: %w", err) + journal.entries = append(journal.entries, legacySnapshotEntry{ + state: legacyEntryPending, + path: path, + hash: hash, + }) } - if err := dst.Close(); err != nil { - return fmt.Errorf("failed to save %s: %w", cfg.jffsConfPath, err) + return journal, nil +} + +func writeLegacyCleanupJournal(journal legacyCleanupJournal) error { + buf, err := encodeLegacyCleanupJournal(journal) + if err != nil { + return err } + return atomicWriteFile(merlinCleanupPendingPath, buf, 0600) +} - // 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) +func encodeLegacyCleanupJournal(journal legacyCleanupJournal) ([]byte, error) { + var b strings.Builder + b.WriteString(journal.phase) + b.WriteByte('\n') + if journal.phase == legacyFinalizePhase { + if len(journal.entries) != 0 { + return nil, fmt.Errorf("finalize journal contains snapshot entries") + } + return []byte(b.String()), nil + } + if journal.phase != legacyCleanupPhase { + return nil, fmt.Errorf("unknown legacy cleanup phase %q", journal.phase) + } + for _, entry := range journal.entries { + if !isLegacySnapshotPath(entry.path) { + return nil, fmt.Errorf("invalid legacy snapshot path %q", entry.path) + } + switch entry.state { + case legacyEntryPending, legacyEntryDelete, legacyEntryRestore: + default: + return nil, fmt.Errorf("invalid legacy snapshot state %q for %s", entry.state, entry.path) + } + if _, err := hex.DecodeString(entry.hash); err != nil || len(entry.hash) != sha256.Size*2 { + return nil, fmt.Errorf("invalid legacy snapshot hash for %s", entry.path) + } + b.WriteString(entry.state) + b.WriteByte('\t') + b.WriteString(entry.path) + b.WriteByte('\t') + b.WriteString(entry.hash) + b.WriteByte('\n') + } + return []byte(b.String()), nil +} + +func readLegacyCleanupJournal() (legacyCleanupJournal, error) { + buf, err := os.ReadFile(merlinCleanupPendingPath) + if os.IsNotExist(err) { + return legacyCleanupJournal{}, nil + } + if err != nil { + return legacyCleanupJournal{}, err + } + return parseLegacyCleanupJournal(buf) +} + +func parseLegacyCleanupJournal(buf []byte) (legacyCleanupJournal, error) { + lines := strings.Split(strings.TrimSpace(string(buf)), "\n") + if len(lines) == 0 || lines[0] == "" { + return legacyCleanupJournal{}, fmt.Errorf("empty legacy cleanup journal") + } + journal := legacyCleanupJournal{phase: lines[0]} + if journal.phase == legacyFinalizePhase { + if len(lines) != 1 { + return legacyCleanupJournal{}, fmt.Errorf("finalize journal contains snapshot entries") + } + return journal, nil + } + if journal.phase != legacyCleanupPhase { + return legacyCleanupJournal{}, fmt.Errorf("unknown legacy cleanup phase %q", journal.phase) + } + for _, line := range lines[1:] { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) != 3 || !isLegacySnapshotPath(parts[1]) { + return legacyCleanupJournal{}, fmt.Errorf("invalid legacy cleanup journal entry %q", line) + } + switch parts[0] { + case legacyEntryPending, legacyEntryDelete, legacyEntryRestore: + default: + return legacyCleanupJournal{}, fmt.Errorf("invalid legacy snapshot state %q", parts[0]) + } + if len(parts[2]) != sha256.Size*2 { + return legacyCleanupJournal{}, fmt.Errorf("invalid legacy snapshot hash for %s", parts[1]) + } + if _, err := hex.DecodeString(parts[2]); err != nil { + return legacyCleanupJournal{}, fmt.Errorf("invalid legacy snapshot hash for %s: %w", parts[1], err) + } + journal.entries = append(journal.entries, legacySnapshotEntry{state: parts[0], path: parts[1], hash: parts[2]}) + } + return journal, nil +} + +func legacySnapshotQuarantinePath(path string) string { + return pathpkg.Join( + dnsmasq.MerlinJffsConfDir, + "."+pathpkg.Base(path)+".ctrld-legacy-quarantine", + ) +} + +func removeLegacyJournalEntry(journal *legacyCleanupJournal, index int) error { + journal.entries = append(journal.entries[:index], journal.entries[index+1:]...) + return writeLegacyCleanupJournal(*journal) +} + +func cleanupLegacySnapshots(journal *legacyCleanupJournal) error { + switch journal.phase { + case legacyFinalizePhase: + return nil + case legacyCleanupPhase: + default: + return fmt.Errorf("unknown Merlin legacy cleanup phase %q", journal.phase) + } + + for len(journal.entries) > 0 { + entry := journal.entries[0] + var err error + switch entry.state { + case legacyEntryPending: + err = cleanupPendingLegacySnapshot(journal, 0) + case legacyEntryDelete: + err = finalizeLegacySnapshotDelete(journal, 0) + case legacyEntryRestore: + err = restoreLegacySnapshot(journal, 0) + default: + err = fmt.Errorf("unknown legacy snapshot state %q", entry.state) + } + if err != nil { + return err + } + } + + journal.phase = legacyFinalizePhase + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("advance Merlin legacy cleanup state: %w", err) } return nil } -// cleanupDnsmasqJffs removes the JFFS configuration file specified in the given dnsmasqConfig, if it exists. -func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error { - // Remove cfg.jffsConfPath file. - if err := os.Remove(cfg.jffsConfPath); err != nil && !os.IsNotExist(err) { +// cleanupPendingLegacySnapshot uses the private hard-link captured before +// journal publication as the durable identity proof for the original legacy +// snapshot. Public-path hashes alone are never treated as ownership. A rename +// race is detected by comparing the quarantined inode with that anchor; any +// ambiguous or unproven quarantined data is restored rather than deleted. +func cleanupPendingLegacySnapshot(journal *legacyCleanupJournal, index int) error { + entry := journal.entries[index] + qpath := legacySnapshotQuarantinePath(entry.path) + anchor := legacySnapshotAnchorPath(entry.path) + + qExists, err := pathExists(qpath) + if err != nil { + return err + } + anchorExists, err := pathExists(anchor) + if err != nil { + return err + } + + if qExists { + if !anchorExists { + // A prior attempt already moved data out of the public pathname but + // the private identity proof is gone. Preservation wins: restore the + // quarantined data rather than orphaning it. + journal.entries[index].state = legacyEntryRestore + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal anchorless legacy snapshot restoration: %w", err) + } + return restoreLegacySnapshot(journal, index) + } + owned, err := snapshotFileStillOwned(qpath, anchor, entry.hash) + if err != nil { + return err + } + if owned { + journal.entries[index].state = legacyEntryDelete + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal quarantined legacy snapshot deletion: %w", err) + } + return finalizeLegacySnapshotDelete(journal, index) + } + journal.entries[index].state = legacyEntryRestore + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal ambiguous legacy snapshot restoration: %w", err) + } + return restoreLegacySnapshot(journal, index) + } + + if !anchorExists { + // No destructive operation has occurred and the durable identity proof is + // gone. Never infer ownership from public bytes alone. + return removeLegacyJournalEntry(journal, index) + } + + targetExists, err := pathExists(entry.path) + if err != nil { return err } - return nil + if !targetExists { + if err := removeLegacySnapshotAnchor(entry.path); err != nil { + return err + } + return removeLegacyJournalEntry(journal, index) + } + + owned, err := snapshotFileStillOwned(entry.path, anchor, entry.hash) + if err != nil { + return err + } + if !owned { + // The public pathname was replaced or modified after journal creation. + // Drop only ctrld's private anchor and leave the public file untouched. + if err := removeLegacySnapshotAnchor(entry.path); err != nil { + return err + } + return removeLegacyJournalEntry(journal, index) + } + + if err := os.Rename(entry.path, qpath); err != nil { + if os.IsNotExist(err) { + return cleanupPendingLegacySnapshot(journal, index) + } + return fmt.Errorf("quarantine legacy snapshot %s: %w", entry.path, err) + } + if err := syncParentDir(dnsmasq.MerlinJffsConfDir); err != nil { + return fmt.Errorf("sync legacy snapshot quarantine: %w", err) + } + + owned, err = snapshotFileStillOwned(qpath, anchor, entry.hash) + if err != nil { + return err + } + if !owned { + // A pathname replacement won the race between the pre-rename identity + // check and rename(2). Restore the captured replacement; the original + // ctrld inode remains proven by the private anchor only. + journal.entries[index].state = legacyEntryRestore + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal replaced legacy snapshot restoration: %w", err) + } + return restoreLegacySnapshot(journal, index) + } + + journal.entries[index].state = legacyEntryDelete + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal legacy snapshot deletion: %w", err) + } + return finalizeLegacySnapshotDelete(journal, index) } -// writeDnsmasqPostconf writes the requireddnsmasqConfigs post-configuration for dnsmasq to enable custom DNS settings with ctrld. -func (m *Merlin) writeDnsmasqPostconf() error { - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - // Already setup. - if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { - return nil +func finalizeLegacySnapshotDelete(journal *legacyCleanupJournal, index int) error { + entry := journal.entries[index] + qpath := legacySnapshotQuarantinePath(entry.path) + anchor := legacySnapshotAnchorPath(entry.path) + + qExists, err := pathExists(qpath) + if err != nil { + return err + } + if qExists { + owned, err := snapshotFileStillOwned(qpath, anchor, entry.hash) + if err != nil { + return err + } + if !owned { + journal.entries[index].state = legacyEntryRestore + if err := writeLegacyCleanupJournal(*journal); err != nil { + return fmt.Errorf("journal modified legacy snapshot restoration: %w", err) + } + return restoreLegacySnapshot(journal, index) + } + if err := removeFileDurable(qpath); err != nil { + return fmt.Errorf("remove quarantined legacy snapshot %s: %w", qpath, err) + } } - if err != nil && !os.IsNotExist(err) { + if err := removeLegacySnapshotAnchor(entry.path); err != nil { return err } + return removeLegacyJournalEntry(journal, index) +} - data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) +func restoreLegacySnapshot(journal *legacyCleanupJournal, index int) error { + entry := journal.entries[index] + qpath := legacySnapshotQuarantinePath(entry.path) + + qExists, err := pathExists(qpath) + if err != nil { + return err + } + if !qExists { + // Restoration/removal completed before the journal update, or no + // destructive operation occurred. Never infer ownership from public state. + if err := removeLegacySnapshotAnchor(entry.path); err != nil { + return err + } + return removeLegacyJournalEntry(journal, index) + } + + targetExists, err := pathExists(entry.path) if err != nil { return err } - data = strings.Join([]string{ - data, - "\n", - dnsmasq.MerlinPostConfMarker, - "\n", - string(buf), - }, "\n") - // Write dnsmasq post conf file. - return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750) + if targetExists { + same, err := sameFilePaths(entry.path, qpath) + if err != nil { + return err + } + if !same { + return fmt.Errorf( + "cannot restore captured legacy snapshot because %s was recreated; preserved capture at %s", + entry.path, qpath, + ) + } + } else { + if err := os.Link(qpath, entry.path); err != nil { + return fmt.Errorf("restore captured legacy snapshot %s: %w", entry.path, err) + } + } + if err := syncParentDir(dnsmasq.MerlinJffsConfDir); err != nil { + return fmt.Errorf("sync restored legacy snapshot: %w", err) + } + if err := removeFileDurable(qpath); err != nil { + return fmt.Errorf("remove restored legacy snapshot quarantine: %w", err) + } + if err := removeLegacySnapshotAnchor(entry.path); err != nil { + return err + } + return removeLegacyJournalEntry(journal, index) } -// restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service". -// Returns an error if the command fails or if there is an issue processing the command output. -func restartDNSMasq() error { - if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { - return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) +func isLegacySnapshotPath(path string) bool { + if path == dnsmasq.MerlinJffsConfPath { + return true } - return nil + if pathpkg.Dir(path) != dnsmasq.MerlinJffsConfDir { + return false + } + base := pathpkg.Base(path) + if !strings.HasPrefix(base, "dnsmasq-") || !strings.HasSuffix(base, ".conf") { + return false + } + middle := strings.TrimSuffix(strings.TrimPrefix(base, "dnsmasq-"), ".conf") + if middle == "" { + return false + } + for _, r := range middle { + if r < '0' || r > '9' { + return false + } + } + return true } -// getDnsmasqConfigs retrieves a list of dnsmasqConfig containing configuration and JFFS paths for dnsmasq operations. -func getDnsmasqConfigs() []*dnsmasqConfig { - cfgs := []*dnsmasqConfig{ - {dnsmasq.MerlinConfPath, dnsmasq.MerlinJffsConfPath}, +// dnsmasqConfigUsesCtrld reports whether a generated dnsmasq config has the +// complete ctrld forwarding shape, not merely one matching server line. +func (m *Merlin) dnsmasqConfigUsesCtrld(path string) (bool, error) { + buf, err := os.ReadFile(path) + if os.IsNotExist(err) { + return false, nil } - for _, path := range dnsmasq.AdditionalConfigFiles() { - jffsConfPath := filepath.Join(dnsmasq.MerlinJffsConfDir, filepath.Base(path)) - cfgs = append(cfgs, &dnsmasqConfig{path, jffsConfPath}) + if err != nil { + return false, err + } + + listener := m.cfg.FirstListener() + if listener == nil { + return false, fmt.Errorf("missing ctrld listener") + } + ip := listener.IP + if ip == "" || ip == "0.0.0.0" || ip == "::" { + ip = "127.0.0.1" } + expectedServer := fmt.Sprintf("server=%s#%d", ip, listener.Port) - return cfgs + var ( + serverCount int + expectedSeen bool + noResolv bool + addMAC bool + addSubnet bool + cacheOff bool + ) + for _, raw := range strings.Split(string(buf), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + switch { + case strings.HasPrefix(line, "server="): + serverCount++ + if line == expectedServer { + expectedSeen = true + } + case strings.HasPrefix(line, "servers-file="), + strings.HasPrefix(line, "resolv-file="), + strings.HasPrefix(line, "conf-file="), + strings.HasPrefix(line, "conf-dir="), + strings.HasPrefix(line, "conf-script="), + line == "dnssec", + strings.HasPrefix(line, "trust-anchor="): + return false, nil + case line == "no-resolv": + noResolv = true + case line == "add-mac": + addMAC = true + case line == "add-subnet=32,128": + addSubnet = true + case line == "cache-size=0": + cacheOff = true + } + } + + return serverCount == 1 && expectedSeen && noResolv && addMAC && addSubnet && cacheOff, nil } -// 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. -func merlinParsePostConf(buf []byte) []byte { - if len(buf) == 0 { - return nil +// setupMainDnsmasqFallback retains the old full-config mechanism only for the +// main dnsmasq when the supported postconf hook demonstrably did not apply. +// Ownership is represented by a private hard-link anchor to the exact inode +// ctrld published, plus a durable phase/hash journal. +func (m *Merlin) setupMainDnsmasqFallback() error { + // Reconcile any interrupted ctrld fallback transaction first. Private ctrld + // artifacts are handled without inferring ownership from public-path bytes. + if err := cleanupOwnedMainSnapshot(); err != nil { + return err } - parts := bytes.Split(buf, []byte(dnsmasq.MerlinPostConfMarker)) - if len(parts) != 1 { - return bytes.TrimLeftFunc(parts[1], unicode.IsSpace) + + snapshotExists, err := pathExists(dnsmasq.MerlinJffsConfPath) + if err != nil { + return fmt.Errorf("stat Merlin fallback config: %w", err) + } + if snapshotExists { + return fmt.Errorf("refusing to overwrite unowned Merlin custom config: %s", dnsmasq.MerlinJffsConfPath) + } + + buf, err := os.ReadFile(dnsmasq.MerlinConfPath) + if err != nil { + return fmt.Errorf("failed to read dnsmasq config for fallback: %w", err) + } + built, err := m.buildMainDnsmasqFallback(buf) + if err != nil { + return err + } + if err := publishMainSnapshot(built, 0644); err != nil { + return fmt.Errorf("publish ctrld dnsmasq fallback: %w", err) + } + return nil +} + +// publishMainSnapshot publishes ctrld's fallback with identity-based ownership. +// +// The private anchor is a hard link to the exact inode prepared by ctrld. A +// pending journal is made durable before the public hard link is attempted, so +// cleanup can distinguish a failed no-clobber publication from a ctrld-owned +// target even after power loss. +func publishMainSnapshot(data []byte, mode os.FileMode) error { + dir := dnsmasq.MerlinJffsConfDir + base := filepath.Base(dnsmasq.MerlinJffsConfPath) + tmp, err := os.CreateTemp(dir, "."+base+".ctrld-publish-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + // The anchor must be created before the journal/public link. It gives + // cleanup an inode identity that hashes alone cannot provide. + if err := os.Link(tmpPath, merlinSnapshotAnchorPath); err != nil { + return fmt.Errorf("create fallback ownership anchor: %w", err) + } + if err := syncParentDir(dir); err != nil { + return fmt.Errorf("sync fallback ownership anchor: %w", err) + } + + state := mainSnapshotState{phase: snapshotPhasePending, hash: merlinSnapshotHash(data)} + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("journal pending fallback publication: %w", err) + } + + // Hard-link publication is atomic and no-clobber: a concurrent user/addon + // file at dnsmasq.conf causes EEXIST rather than replacement. + if err := os.Link(tmpPath, dnsmasq.MerlinJffsConfPath); err != nil { + return err + } + if err := syncParentDir(dir); err != nil { + return fmt.Errorf("sync published fallback: %w", err) + } + + state.phase = snapshotPhasePublished + if err := writeMainSnapshotState(state); err != nil { + // Pending state plus the anchor is sufficient for cleanup to prove that + // the public inode is ours, so this remains safely retryable. + return fmt.Errorf("journal published fallback: %w", err) + } + return nil +} + +func (m *Merlin) buildMainDnsmasqFallback(buf []byte) ([]byte, error) { + tmp, err := os.CreateTemp(dnsmasq.MerlinJffsConfDir, ".dnsmasq.conf.ctrld-build-*") + if err != nil { + return nil, fmt.Errorf("create ctrld dnsmasq fallback build file: %w", err) + } + path := tmp.Name() + defer os.Remove(path) + + if err := tmp.Chmod(0644); err != nil { + _ = tmp.Close() + return nil, err + } + if _, err := tmp.Write(buf); err != nil { + _ = tmp.Close() + return nil, err + } + if err := tmp.Close(); err != nil { + return nil, err + } + + script, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + if err != nil { + return nil, fmt.Errorf("render ctrld fallback postconf: %w", err) + } + // Apply only ctrld's managed logic. Executing the shared Merlin hook here + // would also execute unrelated addon/user blocks a second time against a + // temporary file. + cmd := exec.Command("/bin/sh", "-c", script, "ctrld-fallback", path) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to apply ctrld fallback postconf: %s: %w", string(out), err) + } + built, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read ctrld dnsmasq fallback build: %w", err) + } + return built, nil +} + +const ( + snapshotPhasePending = "pending-v2" + snapshotPhasePublished = "published-v2" + snapshotPhaseQuarantine = "quarantine-v2" + snapshotPhaseDelete = "delete-v2" + snapshotPhaseRestore = "restore-v2" + snapshotPhaseRestored = "restored-v2" +) + +type mainSnapshotState struct { + phase string + hash string +} + +func merlinSnapshotHash(buf []byte) string { + sum := sha256.Sum256(buf) + return hex.EncodeToString(sum[:]) +} + +func encodeMainSnapshotState(state mainSnapshotState) ([]byte, error) { + if state.phase == "" { + return nil, fmt.Errorf("empty Merlin snapshot phase") + } + switch state.phase { + case snapshotPhasePending, snapshotPhasePublished, snapshotPhaseQuarantine, + snapshotPhaseDelete, snapshotPhaseRestore, snapshotPhaseRestored: + default: + return nil, fmt.Errorf("unknown Merlin snapshot phase %q", state.phase) + } + if len(state.hash) != sha256.Size*2 { + return nil, fmt.Errorf("invalid Merlin snapshot hash length") + } + if _, err := hex.DecodeString(state.hash); err != nil { + return nil, fmt.Errorf("invalid Merlin snapshot hash: %w", err) + } + return []byte("snapshot-v2\nphase=" + state.phase + "\nsha256=" + state.hash + "\n"), nil +} + +func parseMainSnapshotState(buf []byte) (mainSnapshotState, error) { + lines := strings.Split(strings.TrimSpace(string(buf)), "\n") + if len(lines) == 1 && strings.HasPrefix(lines[0], "sha256=") { + return mainSnapshotState{}, fmt.Errorf("unsafe hash-only Merlin snapshot state") + } + if len(lines) != 3 || lines[0] != "snapshot-v2" || + !strings.HasPrefix(lines[1], "phase=") || + !strings.HasPrefix(lines[2], "sha256=") { + return mainSnapshotState{}, fmt.Errorf("invalid Merlin snapshot state") + } + state := mainSnapshotState{ + phase: strings.TrimPrefix(lines[1], "phase="), + hash: strings.TrimPrefix(lines[2], "sha256="), + } + if _, err := encodeMainSnapshotState(state); err != nil { + return mainSnapshotState{}, err + } + return state, nil +} + +func writeMainSnapshotState(state mainSnapshotState) error { + buf, err := encodeMainSnapshotState(state) + if err != nil { + return err + } + return atomicWriteFile(merlinSnapshotStatePath, buf, 0600) +} + +func readMainSnapshotState() (mainSnapshotState, bool, error) { + buf, err := os.ReadFile(merlinSnapshotStatePath) + if os.IsNotExist(err) { + return mainSnapshotState{}, false, nil + } + if err != nil { + return mainSnapshotState{}, false, err + } + state, err := parseMainSnapshotState(buf) + if err != nil { + return mainSnapshotState{}, true, fmt.Errorf("%w: %s", err, merlinSnapshotStatePath) + } + return state, true, nil +} + +func sameFilePaths(a, b string) (bool, error) { + ai, err := os.Stat(a) + if err != nil { + return false, err + } + bi, err := os.Stat(b) + if err != nil { + return false, err + } + return os.SameFile(ai, bi), nil +} + +func cleanupSnapshotPrivateState() error { + if err := removeFileDurable(merlinSnapshotAnchorPath); err != nil { + return fmt.Errorf("remove Merlin snapshot anchor: %w", err) + } + if err := removeFileDurable(merlinSnapshotStatePath); err != nil { + return fmt.Errorf("remove Merlin snapshot state: %w", err) + } + return nil +} + +// cleanupOwnedMainSnapshot reconciles ctrld's fallback transaction without ever +// deleting a pathname based on content alone. The anchor proves inode identity; +// the quarantine phases make every move resumable after power loss. +func cleanupOwnedMainSnapshot() error { + state, marked, err := readMainSnapshotState() + if err != nil { + return err + } + if !marked { + quarantineExists, err := pathExists(merlinSnapshotQuarantinePath) + if err != nil { + return err + } + if quarantineExists { + return fmt.Errorf( + "unjournaled Merlin fallback quarantine requires manual reconciliation: %s", + merlinSnapshotQuarantinePath, + ) + } + // A private anchor without a journal can only precede public publication: + // publishMainSnapshot makes the journal durable before linking dnsmasq.conf. + if err := removeFileDurable(merlinSnapshotAnchorPath); err != nil { + return fmt.Errorf("remove orphaned Merlin snapshot anchor: %w", err) + } + return nil + } + + switch state.phase { + case snapshotPhasePending: + return cleanupPendingMainSnapshot(state) + case snapshotPhasePublished: + state.phase = snapshotPhaseQuarantine + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("journal Merlin fallback quarantine: %w", err) + } + return cleanupOwnedMainSnapshot() + case snapshotPhaseQuarantine: + return cleanupQuarantinedMainSnapshot(state) + case snapshotPhaseDelete: + return finalizeOwnedMainSnapshotDelete(state) + case snapshotPhaseRestore: + return restoreQuarantinedMainSnapshot(state) + case snapshotPhaseRestored: + return finalizeRestoredMainSnapshot() + default: + return fmt.Errorf("unknown Merlin snapshot phase %q", state.phase) + } +} + +func cleanupPendingMainSnapshot(state mainSnapshotState) error { + anchorExists, err := pathExists(merlinSnapshotAnchorPath) + if err != nil { + return err + } + targetExists, err := pathExists(dnsmasq.MerlinJffsConfPath) + if err != nil { + return err + } + + if !anchorExists { + // Without the private inode proof, never touch a public target. + if err := removeFileDurable(merlinSnapshotStatePath); err != nil { + return err + } + if targetExists { + return fmt.Errorf( + "Merlin fallback publication lost its ownership anchor; left %s untouched", + dnsmasq.MerlinJffsConfPath, + ) + } + return nil + } + if !targetExists { + return cleanupSnapshotPrivateState() + } + + same, err := sameFilePaths(dnsmasq.MerlinJffsConfPath, merlinSnapshotAnchorPath) + if err != nil { + return err + } + if !same { + // Publication lost a no-clobber race. The public file belongs to someone + // else regardless of whether its bytes happen to match ctrld's snapshot. + return cleanupSnapshotPrivateState() + } + + state.phase = snapshotPhasePublished + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("promote pending Merlin fallback ownership: %w", err) + } + return cleanupOwnedMainSnapshot() +} + +func cleanupQuarantinedMainSnapshot(state mainSnapshotState) error { + quarantineExists, err := pathExists(merlinSnapshotQuarantinePath) + if err != nil { + return err + } + if !quarantineExists { + targetExists, err := pathExists(dnsmasq.MerlinJffsConfPath) + if err != nil { + return err + } + if !targetExists { + // Nothing remains at the public pathname. Drop only ctrld-private proof. + return cleanupSnapshotPrivateState() + } + if err := os.Rename(dnsmasq.MerlinJffsConfPath, merlinSnapshotQuarantinePath); err != nil { + if os.IsNotExist(err) { + return cleanupOwnedMainSnapshot() + } + return fmt.Errorf("quarantine Merlin fallback: %w", err) + } + } + // Always sync before advancing the journal. On a retry, quarantine may + // already exist because rename succeeded previously while its directory + // fsync failed; observing the pathname is not proof that the rename is + // durable across power loss. + if err := syncParentDir(dnsmasq.MerlinJffsConfDir); err != nil { + return fmt.Errorf("sync Merlin fallback quarantine: %w", err) + } + + anchorExists, err := pathExists(merlinSnapshotAnchorPath) + if err != nil { + return err + } + if !anchorExists { + state.phase = snapshotPhaseRestore + if err := writeMainSnapshotState(state); err != nil { + return err + } + return cleanupOwnedMainSnapshot() + } + + owned, err := snapshotFileStillOwned( + merlinSnapshotQuarantinePath, + merlinSnapshotAnchorPath, + state.hash, + ) + if err != nil { + return err + } + if owned { + state.phase = snapshotPhaseDelete + } else { + // Different inode or changed bytes mean user/addon ownership may have + // superseded ctrld. Preserve and restore the captured file. + state.phase = snapshotPhaseRestore + } + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("advance Merlin fallback quarantine state: %w", err) + } + return cleanupOwnedMainSnapshot() +} + +func snapshotFileStillOwned(path, anchor, expectedHash string) (bool, error) { + pathExistsNow, err := pathExists(path) + if err != nil { + return false, err + } + if !pathExistsNow { + return false, nil + } + anchorExists, err := pathExists(anchor) + if err != nil { + return false, err + } + if !anchorExists { + return false, nil + } + same, err := sameFilePaths(path, anchor) + if err != nil { + return false, err + } + if !same { + return false, nil + } + buf, err := os.ReadFile(path) + if err != nil { + return false, err + } + return merlinSnapshotHash(buf) == expectedHash, nil +} + +func finalizeOwnedMainSnapshotDelete(state mainSnapshotState) error { + quarantineExists, err := pathExists(merlinSnapshotQuarantinePath) + if err != nil { + return err + } + if quarantineExists { + // Revalidate immediately before finalization. A process may have kept a + // writable descriptor open across the public->quarantine rename and + // changed the inode after the earlier classification. + owned, err := snapshotFileStillOwned( + merlinSnapshotQuarantinePath, + merlinSnapshotAnchorPath, + state.hash, + ) + if err != nil { + return err + } + if !owned { + state.phase = snapshotPhaseRestore + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("journal late-modified Merlin fallback restoration: %w", err) + } + return cleanupOwnedMainSnapshot() + } + } + if err := removeFileDurable(merlinSnapshotQuarantinePath); err != nil { + return fmt.Errorf("remove quarantined ctrld fallback: %w", err) + } + return cleanupSnapshotPrivateState() +} + +func restoreQuarantinedMainSnapshot(state mainSnapshotState) error { + quarantineExists, err := pathExists(merlinSnapshotQuarantinePath) + if err != nil { + return err + } + if !quarantineExists { + return fmt.Errorf( + "Merlin fallback marked for restoration but quarantine is missing: %s", + merlinSnapshotQuarantinePath, + ) + } + + targetExists, err := pathExists(dnsmasq.MerlinJffsConfPath) + if err != nil { + return err + } + if targetExists { + same, err := sameFilePaths(dnsmasq.MerlinJffsConfPath, merlinSnapshotQuarantinePath) + if err != nil { + return err + } + if !same { + return fmt.Errorf( + "cannot restore captured Merlin fallback because %s was recreated; preserved capture at %s", + dnsmasq.MerlinJffsConfPath, merlinSnapshotQuarantinePath, + ) + } + } else { + // Restore with no-clobber hard-link semantics. If another actor wins the + // pathname race, the quarantine remains durably journaled and untouched. + if err := os.Link(merlinSnapshotQuarantinePath, dnsmasq.MerlinJffsConfPath); err != nil { + return fmt.Errorf("restore quarantined Merlin fallback: %w", err) + } + } + // Re-sync even when the public hard link already exists. It may be the + // result of a previous attempt where link(2) succeeded but directory fsync + // failed, so the pathname alone is not enough to publish restored-v2. + if err := syncParentDir(dnsmasq.MerlinJffsConfDir); err != nil { + return fmt.Errorf("sync restored Merlin fallback: %w", err) + } + + state.phase = snapshotPhaseRestored + if err := writeMainSnapshotState(state); err != nil { + return fmt.Errorf("journal restored Merlin fallback: %w", err) + } + return cleanupOwnedMainSnapshot() +} + +func finalizeRestoredMainSnapshot() error { + // Restoration was durably recorded only after a public hard link existed. + // Therefore a retry never needs to infer ownership from the current target. + if err := removeFileDurable(merlinSnapshotQuarantinePath); err != nil { + return fmt.Errorf("remove restored Merlin fallback quarantine: %w", err) + } + return cleanupSnapshotPrivateState() +} + +// 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 + pathType os.FileMode + 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. +func (m *Merlin) writeDnsmasqPostconf() error { + 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")) + + return writeMerlinHookUpdates( + []string{dnsmasq.MerlinPostConfPath, dnsmasq.MerlinSdnPostConfPath}, + block, + ) +} + +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) + if err != nil { + return err + } + updates = append(updates, update) + } + + 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) + } + return nil +} + +func rollbackMerlinHookUpdates(updates []merlinHookUpdate) error { + for i := len(updates) - 1; i >= 0; i-- { + update := updates[i] + if err := revalidateMerlinHookRollback(update); err != nil { + return fmt.Errorf("refusing to roll back %s: %w", update.path, err) + } + 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 := removeFileDurable(update.path); err != nil { + return fmt.Errorf("remove newly created %s: %w", update.path, err) + } + } + return nil +} + +func revalidateMerlinHookRollback(update merlinHookUpdate) error { + info, err := os.Lstat(update.path) + if err != nil { + return err + } + if update.existed && info.Mode().Type() != update.pathType { + return fmt.Errorf("shared hook type changed after ctrld write") + } + if update.existed && 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 ctrld write") + } + } + current, err := os.ReadFile(update.path) + if err != nil { + return err + } + if !bytes.Equal(current, update.data) { + return fmt.Errorf("shared hook content changed after ctrld write") + } + return nil +} + +func prepareMerlinHookUpdate(path string, block []byte) (merlinHookUpdate, error) { + return prepareMerlinHookReplacement(path, func(buf []byte) []byte { + return merlinUpsertPostConf(buf, block) + }) +} + +func prepareMerlinHookCleanup(path string) (*merlinHookUpdate, error) { + _, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + update, err := prepareMerlinHookReplacement(path, merlinParsePostConf) + if err != nil { + return nil, err + } + return &update, nil +} + +func prepareMerlinHookReplacement(path string, transform func([]byte) []byte) (merlinHookUpdate, error) { + info, statErr := os.Lstat(path) + pathMissing := os.IsNotExist(statErr) + if statErr != nil && !pathMissing { + return merlinHookUpdate{}, statErr + } + + buf, err := os.ReadFile(path) + if err != nil { + if !pathMissing || !os.IsNotExist(err) { + return merlinHookUpdate{}, err + } + buf = nil + } + + update := merlinHookUpdate{ + path: path, + data: transform(buf), + original: append([]byte(nil), buf...), + existed: !pathMissing, + } + 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 { + update, err := prepareMerlinHookCleanup(path) + if err != nil || update == nil { + return err + } + if bytes.Equal(update.original, update.data) { + return nil + } + if err := revalidateMerlinHookUpdate(*update); err != nil { + return fmt.Errorf("revalidate Merlin hook cleanup %s: %w", path, err) + } + // 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". +// Returns an error if the command fails or if there is an issue processing the command output. +func restartDNSMasq() error { + if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { + return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) + } + return nil +} + +// 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 +} + +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, kind merlinPostConfBlockKind, ok bool) { + begin := []byte(dnsmasq.MerlinPostConfBeginMarker) + endMarker := []byte(dnsmasq.MerlinPostConfEndMarker) + if blockStart, beginEnd, found := merlinExactLineBounds(buf, begin, 0); found { + from := beginEnd + if from < len(buf) && buf[from] == '\n' { + from++ + } + if _, blockEnd, foundEnd := merlinExactLineBounds(buf, endMarker, from); foundEnd { + return blockStart, blockEnd, merlinPostConfBlockCurrent, true + } + } + + legacyEnd := []byte(dnsmasq.MerlinPostConfMarker) + if legacyEndStart, legacyEndEnd, found := merlinExactLineBounds(buf, legacyEnd, 0); found { + legacyBegin := []byte(dnsmasq.CtrldMarker) + if legacyBeginStart, _, foundBegin := merlinLastExactLineBefore(buf, legacyBegin, legacyEndStart); foundBegin { + return legacyBeginStart, legacyEndEnd, merlinPostConfBlockLegacy, true + } + } + + return 0, 0, merlinPostConfBlockNone, 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 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. 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 + } + start, end, kind, ok := merlinPostConfBlock(buf) + if !ok { + 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. + 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 + } + + 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. 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 == merlinPostConfBlockCurrent { + if merlinBlockHasSyntheticShebang(buf, start, end) { + block = merlinBlockWithSyntheticShebang(block) + } + 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 + } + + // 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)-(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 + } + + // 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 + } + } + + // 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 +// 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) { + 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 + } + + 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-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmpName) + } + }() + + if err = tmp.Chmod(writeMode); 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, 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 { + if runtime.GOOS == "windows" { + return nil + } + return err + } + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func removeFileDurable(path string) error { + err := os.Remove(path) + if os.IsNotExist(err) { + // The previous attempt may have unlinked the file successfully but + // failed while syncing the directory. Re-sync even on ENOENT so a retry + // can make that already-visible deletion durable before its journal is + // advanced or removed. + return syncParentDir(filepath.Dir(path)) + } + if err != nil { + return err } - return buf + return syncParentDir(filepath.Dir(path)) } // waitDirExists waits until the specified directory exists, polling its existence every second. diff --git a/internal/router/merlin/merlin_test.go b/internal/router/merlin/merlin_test.go index 057628cd..4fed479c 100644 --- a/internal/router/merlin/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -2,20 +2,34 @@ package merlin import ( "bytes" + "os" + pathpkg "path" + "path/filepath" + "runtime" "strings" "testing" + "github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" ) -func Test_merlinParsePostConf(t *testing.T) { - origContent := "# foo" - data := strings.Join([]string{ - dnsmasq.MerlinPostConfTmpl, +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 @@ -24,8 +38,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 { @@ -38,3 +52,805 @@ func Test_merlinParsePostConf(t *testing.T) { }) } } + + +func Test_merlinParsePostConfMarkedBlock(t *testing.T) { + 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 { + 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") + } + trimmed := strings.TrimSpace(dnsmasq.MerlinPostConfTmpl) + 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") + } +} + + +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") + + 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 := "echo addon-before\n" + legacyMerlinPostConf("echo addon-after") + 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) + } +} + + +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\n" { + t.Fatalf("unexpected restored stub: %q", got) + } +} + +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 { + t.Fatal(err) + } + + if err := cleanupDnsmasqPostconf(path); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cleanup removed shared hook path: %v", err) + } + if len(got) != 0 { + t.Fatalf("unexpected cleaned ctrld-created hook: %q", got) + } +} + +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") + } +} + + +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{ + 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"), + []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) + } + } +} + + +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_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) + } + 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) + } +} + +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") + 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) { + 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") + 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 { + 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()) + } + }) + } +} + +func Test_dnsmasqConfigUsesCtrld(t *testing.T) { + good := func(server string) string { + return strings.Join([]string{ + "no-resolv", + server, + "add-mac", + "add-subnet=32,128", + "cache-size=0", + "", + }, "\n") + } + + tests := []struct { + name string + ip string + port int + content string + want bool + }{ + { + name: "wildcard listener maps to loopback", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354"), + want: true, + }, + { + name: "explicit listener", + ip: "127.0.0.2", + port: 5355, + content: good("server=127.0.0.2#5355"), + want: true, + }, + { + name: "wrong upstream", + ip: "0.0.0.0", + port: 5354, + content: good("server=1.1.1.1#53"), + want: false, + }, + { + name: "additional server bypass", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "server=1.1.1.1#53\n", + want: false, + }, + { + name: "servers file bypass", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "servers-file=/tmp/resolv.dnsmasq\n", + want: false, + }, + { + name: "resolv file bypass", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "resolv-file=/tmp/resolv.conf\n", + want: false, + }, + { + name: "conf file include is unresolved", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "conf-file=/jffs/configs/extra-dnsmasq.conf\n", + want: false, + }, + { + name: "conf dir include is unresolved", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "conf-dir=/jffs/configs/dnsmasq.d\n", + want: false, + }, + { + name: "conf script include is unresolved", + ip: "0.0.0.0", + port: 5354, + content: good("server=127.0.0.1#5354") + + "conf-script=/jffs/scripts/dnsmasq-extra.sh\n", + want: false, + }, + { + name: "missing metadata directives", + ip: "0.0.0.0", + port: 5354, + content: "no-resolv\nserver=127.0.0.1#5354\ncache-size=0\n", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "dnsmasq.conf") + if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil { + t.Fatal(err) + } + m := New(&ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{ + "0": {IP: tc.ip, Port: tc.port}, + }}) + got, err := m.dnsmasqConfigUsesCtrld(path) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("dnsmasqConfigUsesCtrld() = %v, want %v", got, tc.want) + } + }) + } +} + +func Test_legacyCleanupJournalRoundTrip(t *testing.T) { + entries := []legacySnapshotEntry{ + {state: legacyEntryPending, path: dnsmasq.MerlinJffsConfPath, hash: merlinSnapshotHash([]byte("main"))}, + {state: legacyEntryDelete, path: pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-1.conf"), hash: merlinSnapshotHash([]byte("sdn1"))}, + {state: legacyEntryRestore, path: pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-3.conf"), hash: merlinSnapshotHash([]byte("sdn3"))}, + } + want := legacyCleanupJournal{phase: legacyCleanupPhase, entries: entries} + buf, err := encodeLegacyCleanupJournal(want) + if err != nil { + t.Fatal(err) + } + got, err := parseLegacyCleanupJournal(buf) + if err != nil { + t.Fatal(err) + } + if got.phase != want.phase || len(got.entries) != len(want.entries) { + t.Fatalf("journal mismatch: want %#v, got %#v", want, got) + } + for i := range want.entries { + if got.entries[i] != want.entries[i] { + t.Fatalf("entry %d mismatch: want %#v, got %#v", i, want.entries[i], got.entries[i]) + } + } +} + +func Test_parseLegacyCleanupJournalRejectsUnsafePath(t *testing.T) { + hash := merlinSnapshotHash([]byte("x")) + buf := []byte(legacyCleanupPhase + "\n" + legacyEntryPending + "\t/jffs/configs/profile.add\t" + hash + "\n") + if _, err := parseLegacyCleanupJournal(buf); err == nil { + t.Fatal("expected unsafe legacy snapshot path to be rejected") + } +} + +func Test_parseLegacyCleanupJournalFinalizeHasNoEntries(t *testing.T) { + if _, err := parseLegacyCleanupJournal([]byte(legacyFinalizePhase + "\n")); err != nil { + t.Fatalf("valid finalize journal rejected: %v", err) + } + hash := merlinSnapshotHash([]byte("x")) + buf := []byte(legacyFinalizePhase + "\n" + legacyEntryPending + "\t" + dnsmasq.MerlinJffsConfPath + "\t" + hash + "\n") + if _, err := parseLegacyCleanupJournal(buf); err == nil { + t.Fatal("expected finalize journal with entries to be rejected") + } +} + +func Test_parseLegacyCleanupJournalRejectsUnknownEntryState(t *testing.T) { + hash := merlinSnapshotHash([]byte("x")) + buf := []byte(legacyCleanupPhase + "\nsurprise\t" + dnsmasq.MerlinJffsConfPath + "\t" + hash + "\n") + if _, err := parseLegacyCleanupJournal(buf); err == nil { + t.Fatal("expected unknown legacy entry state to be rejected") + } +} + +func Test_isLegacySnapshotPath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {dnsmasq.MerlinJffsConfPath, true}, + {pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-1.conf"), true}, + {pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-123.conf"), true}, + {pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq.conf.add"), false}, + {pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-sdn.conf"), false}, + {"/tmp/dnsmasq-1.conf", false}, + } + for _, tc := range tests { + if got := isLegacySnapshotPath(tc.path); got != tc.want { + t.Fatalf("isLegacySnapshotPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +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") + } +} + + + +func Test_mainSnapshotStateRoundTrip(t *testing.T) { + want := mainSnapshotState{ + phase: snapshotPhaseQuarantine, + hash: merlinSnapshotHash([]byte("owned snapshot")), + } + buf, err := encodeMainSnapshotState(want) + if err != nil { + t.Fatal(err) + } + got, err := parseMainSnapshotState(buf) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("snapshot state mismatch: want %#v, got %#v", want, got) + } +} + +func Test_parseMainSnapshotStateRejectsHashOnlyOwnership(t *testing.T) { + buf := []byte("sha256=" + merlinSnapshotHash([]byte("legacy")) + "\n") + if _, err := parseMainSnapshotState(buf); err == nil { + t.Fatal("expected hash-only ownership state to be rejected") + } +} + +func Test_parseMainSnapshotStateRejectsUnknownPhase(t *testing.T) { + buf := []byte( + "snapshot-v2\nphase=surprise\nsha256=" + + merlinSnapshotHash([]byte("owned")) + "\n", + ) + if _, err := parseMainSnapshotState(buf); err == nil { + t.Fatal("expected unknown snapshot phase to be rejected") + } +} + +func Test_sameFilePathsDistinguishesHardLinkFromIdenticalCopy(t *testing.T) { + dir := t.TempDir() + original := filepath.Join(dir, "original") + hardLink := filepath.Join(dir, "hard-link") + copyPath := filepath.Join(dir, "copy") + content := []byte("same bytes\n") + + if err := os.WriteFile(original, content, 0644); err != nil { + t.Fatal(err) + } + if err := os.Link(original, hardLink); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copyPath, content, 0644); err != nil { + t.Fatal(err) + } + + same, err := sameFilePaths(original, hardLink) + if err != nil { + t.Fatal(err) + } + if !same { + t.Fatal("hard link to same inode was not recognized") + } + same, err = sameFilePaths(original, copyPath) + if err != nil { + t.Fatal(err) + } + if same { + t.Fatal("byte-identical copy must not be treated as the owned inode") + } +} + +func Test_encodeMainSnapshotStateAcceptsEveryTransactionPhase(t *testing.T) { + hash := merlinSnapshotHash([]byte("owned")) + for _, phase := range []string{ + snapshotPhasePending, + snapshotPhasePublished, + snapshotPhaseQuarantine, + snapshotPhaseDelete, + snapshotPhaseRestore, + snapshotPhaseRestored, + } { + if _, err := encodeMainSnapshotState(mainSnapshotState{phase: phase, hash: hash}); err != nil { + t.Fatalf("phase %q rejected: %v", phase, err) + } + } +} + + +func Test_snapshotFileStillOwnedDetectsLateInodeModification(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "quarantine") + anchor := filepath.Join(dir, "anchor") + original := []byte("ctrld-owned\n") + if err := os.WriteFile(path, original, 0644); err != nil { + t.Fatal(err) + } + if err := os.Link(path, anchor); err != nil { + t.Fatal(err) + } + expected := merlinSnapshotHash(original) + + owned, err := snapshotFileStillOwned(path, anchor, expected) + if err != nil { + t.Fatal(err) + } + if !owned { + t.Fatal("unchanged hard-linked snapshot should be recognized as owned") + } + + // Simulate a process which kept the inode open across quarantine and wrote + // new contents before final deletion. + if err := os.WriteFile(path, []byte("modified-through-same-inode\n"), 0644); err != nil { + t.Fatal(err) + } + owned, err = snapshotFileStillOwned(path, anchor, expected) + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("late modification of the same inode must invalidate ownership for deletion") + } +} + +func Test_removeFileDurableAllowsRetryAfterFileAlreadyGone(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "already-gone") + if err := removeFileDurable(path); err != nil { + t.Fatalf("durability retry for absent file failed: %v", err) + } +} + + +func Test_merlinPostConfValidatesCtrldPidOwnership(t *testing.T) { + for _, want := range []string{ + `case "$pid" in`, + `[ -r "/proc/${pid}/cmdline" ]`, + `tr '\000' '\n'`, + `ctrld|*/ctrld)`, + `ctrld_running=1`, + `if [ "$ctrld_running" -eq 1 ]; then`, + } { + if !strings.Contains(dnsmasq.MerlinPostConfTmpl, want) { + t.Fatalf("Merlin postconf is missing ctrld PID ownership check %q", want) + } + } + if strings.Contains(dnsmasq.MerlinPostConfTmpl, `[ -f "/proc/${pid}/cmdline" ]; then`) { + t.Fatal("Merlin postconf must not treat PID existence alone as ctrld ownership") + } +} + + +func Test_snapshotFileStillOwnedRejectsByteIdenticalReplacement(t *testing.T) { + dir := t.TempDir() + public := filepath.Join(dir, "dnsmasq.conf") + anchor := filepath.Join(dir, "anchor") + original := []byte("same bytes\n") + if err := os.WriteFile(public, original, 0644); err != nil { + t.Fatal(err) + } + if err := os.Link(public, anchor); err != nil { + t.Fatal(err) + } + hash := merlinSnapshotHash(original) + + // Replace the public pathname with a different inode containing identical + // bytes. Hash-only ownership would misclassify this as ctrld-owned. + replacement := filepath.Join(dir, "replacement") + if err := os.WriteFile(replacement, original, 0644); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, public); err != nil { + t.Fatal(err) + } + + owned, err := snapshotFileStillOwned(public, anchor, hash) + if err != nil { + t.Fatal(err) + } + if owned { + t.Fatal("byte-identical replacement must not be treated as the captured inode") + } +} + +func Test_snapshotFileStillOwnedAcceptsCapturedHardLink(t *testing.T) { + dir := t.TempDir() + public := filepath.Join(dir, "dnsmasq.conf") + anchor := filepath.Join(dir, "anchor") + quarantine := filepath.Join(dir, "quarantine") + content := []byte("legacy snapshot\n") + if err := os.WriteFile(public, content, 0644); err != nil { + t.Fatal(err) + } + if err := os.Link(public, anchor); err != nil { + t.Fatal(err) + } + if err := os.Rename(public, quarantine); err != nil { + t.Fatal(err) + } + + owned, err := snapshotFileStillOwned(quarantine, anchor, merlinSnapshotHash(content)) + if err != nil { + t.Fatal(err) + } + if !owned { + t.Fatal("quarantined hard link to captured inode should remain ctrld-owned") + } +} + +func Test_legacySnapshotAnchorPathUsesCtrldPrivateDir(t *testing.T) { + got := legacySnapshotAnchorPath(pathpkg.Join(dnsmasq.MerlinJffsConfDir, "dnsmasq-2.conf")) + want := "/jffs/controld/.dnsmasq-2.conf.ctrld-legacy-anchor" + if got != want { + t.Fatalf("anchor path = %q, want %q", got, want) + } +} diff --git a/internal/router/nvram/nvram.go b/internal/router/nvram/nvram.go index e76c0171..0dd1eb87 100644 --- a/internal/router/nvram/nvram.go +++ b/internal/router/nvram/nvram.go @@ -2,6 +2,7 @@ package nvram import ( "bytes" + "errors" "fmt" "os/exec" "strings" @@ -36,9 +37,11 @@ NOTE: */ // SetKV writes the given key/value from map to nvram. -// The given setupKey is set to 1 to indicates key/value set. +// The given setupKey is set to 1 to indicate key/value set. +// +// Keep the long-standing generic semantics for router backends which do not +// have Merlin's retrying PreRun reconciliation. func SetKV(m map[string]string, setupKey string) error { - // Backup current value, store ctrld's configs. for key, value := range m { old, err := Run("get", key) if err != nil { @@ -51,28 +54,121 @@ func SetKV(m map[string]string, setupKey string) error { return fmt.Errorf("%s: %w", out, err) } } - if out, err := Run("set", setupKey+"=1"); err != nil { return fmt.Errorf("%s: %w", out, err) } - // Commit. if out, err := Run("commit"); err != nil { return fmt.Errorf("%s: %w", out, err) } return nil } -// Restore restores the old value of given key from map m. -// The given setupKey is set to 0 to indicates key/value restored. +// SetKVWithVolatileRetryMarker is Merlin's transactional SetKV variant. +// It rolls back partially-applied values on failure. If that rollback cannot +// be made durable, setupKey is re-armed only in volatile NVRAM so Merlin's +// deferred/retrying Cleanup can reconcile the same boot without persisting a +// false "setup completed" marker for the next boot. +func SetKVWithVolatileRetryMarker(m map[string]string, setupKey string) error { + modified := make([]string, 0, len(m)) + + rearmVolatile := func(cause error) error { + if out, err := Run("set", setupKey+"=1"); err != nil { + return errors.Join( + cause, + fmt.Errorf("failed to re-arm volatile %s: %s: %w", setupKey, out, err), + ) + } + return cause + } + + rollback := func(cause error) error { + var restoreErr error + for i := len(modified) - 1; i >= 0; i-- { + key := modified[i] + ctrldKey := CtrldKeyPrefix + key + old, err := Run("get", ctrldKey) + if err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("read rollback %s: %w", ctrldKey, err)) + continue + } + if out, err := Run("set", key+"="+old); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("%s: %w", out, err)) + } + } + if restoreErr != nil { + return rearmVolatile(errors.Join( + cause, + fmt.Errorf("nvram rollback incomplete: %w", restoreErr), + )) + } + + if out, err := Run("unset", setupKey); err != nil { + return rearmVolatile(errors.Join(cause, fmt.Errorf("%s: %w", out, err))) + } + if out, err := Run("commit"); err != nil { + return rearmVolatile(errors.Join( + cause, + fmt.Errorf("nvram rollback commit failed: %s: %w", out, err), + )) + } + return cause + } + + fail := func(err error) error { + if len(modified) == 0 { + return err + } + return rollback(err) + } + + for key, value := range m { + old, err := Run("get", key) + if err != nil { + return fail(fmt.Errorf("%s: %w", old, err)) + } + if out, err := Run("set", CtrldKeyPrefix+key+"="+old); err != nil { + return fail(fmt.Errorf("%s: %w", out, err)) + } + modified = append(modified, key) + if out, err := Run("set", key+"="+value); err != nil { + return rollback(fmt.Errorf("%s: %w", out, err)) + } + } + + if out, err := Run("set", setupKey+"=1"); err != nil { + return rollback(fmt.Errorf("%s: %w", out, err)) + } + if out, err := Run("commit"); err != nil { + return rollback(fmt.Errorf("%s: %w", out, err)) + } + return nil +} + +// Restore restores the old value of each key from ctrld's backup NVRAM. +// Backup keys are deliberately retained after the restore commit. They are tiny, +// harmless, and keeping them avoids a second non-transactional "cleanup commit" +// that could destroy the only rollback copy after a transient nvram failure. +// A future SetKV overwrites each backup with the then-current value. func Restore(m map[string]string, setupKey string) error { - // Restore old configs. + return restore(m, setupKey, false) +} + +// RestoreWithVolatileRetryMarker is Merlin's retry-aware Restore variant. +// If the persistent restore commit fails after setupKey was unset in the +// volatile NVRAM view, setupKey is re-armed only in RAM so Merlin PreRun can +// retry Cleanup in the same boot. Other router backends intentionally use +// Restore and keep their existing one-shot semantics. +func RestoreWithVolatileRetryMarker(m map[string]string, setupKey string) error { + return restore(m, setupKey, true) +} + +func restore(m map[string]string, setupKey string, rearmVolatile bool) error { for key := range m { ctrldKey := CtrldKeyPrefix + key old, err := Run("get", ctrldKey) if err != nil { return fmt.Errorf("%s: %w", old, err) } - _, _ = Run("unset", ctrldKey) if out, err := Run("set", key+"="+old); err != nil { return fmt.Errorf("%s: %w", out, err) } @@ -81,9 +177,24 @@ func Restore(m map[string]string, setupKey string) error { if out, err := Run("unset", setupKey); err != nil { return fmt.Errorf("%s: %w", out, err) } - // Commit. if out, err := Run("commit"); err != nil { - return fmt.Errorf("%s: %w", out, err) + commitErr := fmt.Errorf("%s: %w", out, err) + if !rearmVolatile { + return commitErr + } + + // Merlin retries Cleanup during PreRun. The persistent commit may have + // failed while the volatile NVRAM view already has setupKey unset. + // Re-arm setupKey only in RAM so that same-boot retry remains possible, + // but do not commit it: if the restored values actually reached flash + // despite the reported error, reboot must not resurrect a completed setup. + if markerOut, markerErr := Run("set", setupKey+"=1"); markerErr != nil { + return errors.Join( + commitErr, + fmt.Errorf("failed to re-arm volatile %s after restore commit failure: %s: %w", setupKey, markerOut, markerErr), + ) + } + return commitErr } return nil } diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 8ab6d6a7..5eee837b 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "text/template" @@ -20,6 +21,7 @@ import ( const ( merlinJFFSScriptPath = "/jffs/scripts/services-start" merlinJFFSServiceEventScriptPath = "/jffs/scripts/service-event" + merlinSvcScriptMarker = "# GENERATED BY ctrld - Merlin service v2" ) type merlinSvc struct { @@ -61,50 +63,339 @@ func (s *merlinSvc) configPath() string { } func (s *merlinSvc) template() *template.Template { - return template.Must(template.New("").Parse(merlinSvcScript)) + return template.Must(template.New("").Funcs(template.FuncMap{ + "shellQuote": merlinShellQuote, + }).Parse(merlinSvcScript)) } -func (s *merlinSvc) Install() error { +func (s *merlinSvc) legacyTemplate() *template.Template { + return template.Must(template.New("").Parse(merlinLegacySvcScript)) +} + +func (s *merlinSvc) renderStartupScripts(exePath string) (current, legacy []byte, err error) { + data := &struct { + *service.Config + Path string + }{ + s.Config, + exePath, + } + + var currentBuf bytes.Buffer + if err := s.template().Execute(¤tBuf, data); err != nil { + return nil, nil, fmt.Errorf("render current Merlin startup script: %w", err) + } + var legacyBuf bytes.Buffer + if err := s.legacyTemplate().Execute(&legacyBuf, data); err != nil { + return nil, nil, fmt.Errorf("render legacy Merlin startup script: %w", err) + } + return currentBuf.Bytes(), legacyBuf.Bytes(), nil +} + +// RefreshMerlinStartupScriptFromRunningBinary migrates an exact ctrld-owned +// legacy startup script from code executed by the newly launched binary. +// +// This is intentionally separate from merlinSvc.Start: during self-upgrade the +// process that calls Service.Start is still the old binary even after the +// executable on disk has been replaced. The new `ctrld run` process is the +// first reliable place where new lifecycle code is guaranteed to execute. +// +// Missing service state and user-custom startup scripts are harmless no-ops. +// Transient migration errors are returned to the caller, which should log and +// continue starting ctrld so the next daemon start can retry safely. +func RefreshMerlinStartupScriptFromRunningBinary() error { exePath, err := os.Executable() if err != nil { return err } + return refreshMerlinStartupScriptForExecutable(exePath) +} - if !strings.HasPrefix(exePath, "/jffs/") { - return errors.New("could not install service outside /jffs") +func refreshMerlinStartupScriptForExecutable(exePath string) error { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: exePath, + }, } - if _, err := nvram.Run("set", "jffs2_scripts=1"); err != nil { + + return withMerlinServiceLock(func() error { + err := s.refreshMerlinStartupScript() + if errors.Is(err, service.ErrNotInstalled) { + return nil + } return err + }) +} + +type merlinStartupMigrationError struct { + err error + legacy []byte +} + +func (e *merlinStartupMigrationError) Error() string { return e.err.Error() } +func (e *merlinStartupMigrationError) Unwrap() error { return e.err } + +func (s *merlinSvc) refreshMerlinStartupScript() error { + exePath := s.Config.Executable + if exePath == "" { + var err error + exePath, err = os.Executable() + if err != nil { + return err + } } - if _, err := nvram.Run("commit"); err != nil { + + installed, exists, err := readExistingMerlinStartupScript(s.configPath()) + if err != nil { return err } + if !exists { + return service.ErrNotInstalled + } - confPath := s.configPath() - if _, err := os.Stat(confPath); err == nil { - return fmt.Errorf("already installed: %s", confPath) + // Current ctrld-owned scripts carry an explicit marker. Start/restart paths + // are often constructed from svcConfig without the original install + // Arguments, so never re-render a current script from that incomplete + // lifecycle config. + if merlinStartupScriptHasMarker(installed) { + return nil } - var to = &struct { - *service.Config - Path string - }{ - s.Config, - exePath, + // v1.5.x scripts predate the marker. Recover the exact argv encoded in the + // legacy cmd= line, then render both templates with those installed + // arguments. Exact legacy byte equality is still required before migration; + // anything else is treated as user-owned/custom and left untouched. + recovered, ok := recoverLegacyMerlinServiceConfig(installed, s.Config, exePath) + if !ok { + return nil + } + clone := *s + clone.Config = recovered + current, legacy, err := clone.renderStartupScripts(exePath) + if err != nil { + return err } + if !bytes.Equal(installed, legacy) { + return nil + } + exists, err = prepareExistingMerlinStartupScript(s.configPath(), current, legacy) + if err != nil { + return &merlinStartupMigrationError{ + err: err, + legacy: append([]byte(nil), legacy...), + } + } + if !exists { + return service.ErrNotInstalled + } + return nil +} - f, err := os.Create(confPath) +func merlinStartupScriptHasMarker(buf []byte) bool { + for _, line := range strings.Split(string(buf), "\n") { + if strings.TrimSuffix(line, "\r") == merlinSvcScriptMarker { + return true + } + } + return false +} + +func recoverLegacyMerlinServiceConfig(buf []byte, base *service.Config, exePath string) (*service.Config, bool) { + var cmdLine string + for _, line := range strings.Split(string(buf), "\n") { + if strings.HasPrefix(line, `cmd="`) && strings.HasSuffix(line, `"`) { + cmdLine = strings.TrimSuffix(strings.TrimPrefix(line, `cmd="`), `"`) + break + } + } + if cmdLine == "" { + return nil, false + } + fields := strings.Fields(cmdLine) + if len(fields) == 0 || fields[0] != exePath { + return nil, false + } + cfg := *base + cfg.Executable = exePath + cfg.Arguments = append([]string(nil), fields[1:]...) + return &cfg, true +} + +func merlinShellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func merlinStartupHookLines(configPath string) (startLine, serviceEventLine string) { + path := merlinShellQuote(configPath) + return path + " start", path + ` service_event "$1" "$2"` +} + +func merlinLegacyStartupHookLines(configPath string) (startLine, serviceEventLine string) { + return configPath + " start", configPath + ` service_event "$1" "$2"` +} + +func writeMerlinStartupScript(path string, data []byte, mode os.FileMode) (published bool, retErr error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-*") if err != nil { - return fmt.Errorf("os.Create: %w", err) + return false, err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(mode); err != nil { + return false, err + } + if _, err := tmp.Write(data); err != nil { + return false, err + } + if err := tmp.Sync(); err != nil { + return false, err + } + if err := tmp.Close(); err != nil { + return false, err + } + + // Publish without replacement semantics. If another actor creates the + // startup script after our preflight, leave that file untouched. + if err := os.Link(tmpPath, path); err != nil { + return false, err + } + published = true + + if err := os.Remove(tmpPath); err != nil { + return true, err + } + if err := syncMerlinServiceDir(dir); err != nil { + return true, err + } + return true, nil +} + +func syncMerlinServiceDir(dir string) error { + // Merlin runs on Unix/Linux where fsyncing the parent directory is the + // durability barrier for link/unlink publication. Windows does not permit + // syncing directory handles; skip this Unix-specific barrier there so the + // cross-platform package tests exercise the publication logic correctly. + if runtime.GOOS == "windows" { + return nil + } + f, err := os.Open(dir) + if err != nil { + return err } defer f.Close() + return f.Sync() +} - if err := s.template().Execute(f, to); err != nil { - return fmt.Errorf("s.template.Execute: %w", err) +func createMerlinSharedHookStub(path string) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + // Build the complete executable stub privately first. Publishing only after + // chmod/write/fsync succeed means a short write or ENOSPC can never leave a + // partial shared hook at the public path. + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write([]byte("#!/bin/sh\n")); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err } - if err = os.Chmod(confPath, 0755); err != nil { - return fmt.Errorf("os.Chmod: startup script: %w", err) + // Publish with no-clobber semantics. If another addon creates or replaces + // the hook while we are preparing the stub, Link returns EEXIST and the + // caller revalidates that actor's file instead of truncating or deleting it. + if err := os.Link(tmpPath, path); err != nil { + return err + } + if err := os.Remove(tmpPath); err != nil { + return err + } + if err := syncMerlinServiceDir(dir); err != nil { + // The public stub may already be durable. Keep it in place so a retry can + // revalidate/reuse it; never roll back a shared pathname after publish. + return err + } + return nil +} +func validateMerlinSharedHookPath(path string, requireExecutable bool) (exists bool, err error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + // sed -i may replace a symlink rather than edit its target. Shared Merlin + // hooks must therefore be real regular files before ctrld edits them. + if !info.Mode().IsRegular() { + return true, fmt.Errorf("shared Merlin hook is not a regular file: %s", path) + } + if requireExecutable && info.Mode().Perm()&0111 == 0 { + return true, fmt.Errorf("shared Merlin hook is not executable: %s", path) + } + return true, nil +} + +func (s *merlinSvc) Install() error { + return withMerlinServiceLock(s.installLocked) +} + +func (s *merlinSvc) installLocked() error { + exePath, err := os.Executable() + if err != nil { + return err + } + + if !strings.HasPrefix(exePath, "/jffs/") { + return errors.New("could not install service outside /jffs") + } + if _, err := nvram.Run("set", "jffs2_scripts=1"); err != nil { + return err + } + if _, err := nvram.Run("commit"); err != nil { + return err + } + + confPath := s.configPath() + + // Render both current and last-known legacy templates before touching the + // destination. An exact legacy match can be migrated safely during install + // or upgrade; any other differing file is treated as user-owned. + currentStartup, legacyStartup, err := s.renderStartupScripts(exePath) + if err != nil { + return err + } + startupExists, err := prepareExistingMerlinStartupScript(confPath, currentStartup, legacyStartup) + if err != nil { + return fmt.Errorf("prepare existing startup script: %w", err) + } + if !startupExists { + if _, err := writeMerlinStartupScript(confPath, currentStartup, 0755); err != nil { + // A successfully linked private startup script is intentionally kept + // even if a later durability/hook step fails. It does not register the + // service by itself, is safe to recognize on retry, and avoiding + // pathname-based rollback prevents deleting an external replacement. + return fmt.Errorf("publish startup script: %w", err) + } } if err := os.MkdirAll(filepath.Dir(merlinJFFSScriptPath), 0755); err != nil { @@ -124,38 +415,115 @@ func (s *merlinSvc) Install() error { if err := tmpScript.Close(); err != nil { return fmt.Errorf("tmpScript.Close: %w", err) } - addLineToScript := func(line, script string) error { - if _, err := os.Stat(script); os.IsNotExist(err) { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return err - } + cleanupCreatedHook := func(line, script string) { + // Remove only ctrld's exact managed line first. If the file is still the + // pristine stub ctrld created, remove it; otherwise preserve any content + // another addon/user added concurrently. + _ = exec.Command("sh", tmpScript.Name(), line, script, "remove").Run() + buf, err := os.ReadFile(script) + if err == nil && bytes.Equal(buf, []byte("#!/bin/sh\n")) { + _ = os.Remove(script) } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + } + + addLineToScript := func(line, script string) (created, added bool, retErr error) { + for { + exists, err := validateMerlinSharedHookPath(script, true) + if err != nil { + return false, false, err + } + if exists { + break + } + + // Create a missing shared hook without following a raced symlink or + // truncating a file another addon created after our Lstat. + if err := createMerlinSharedHookStub(script); err != nil { + if os.IsExist(err) { + // Another actor won the race. Re-run Lstat validation against + // the now-existing path before deciding whether it is safe. + continue + } + return false, false, err + } + created = true + break } + defer func() { + if retErr != nil && created { + // On an editor failure ownership of any non-stub content is + // ambiguous. Remove only the untouched stub; otherwise leave the + // file for a safe resumable retry. + buf, err := os.ReadFile(script) + if err == nil && bytes.Equal(buf, []byte("#!/bin/sh\n")) { + _ = os.Remove(script) + } + } + }() - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { - return fmt.Errorf("exec.Command: add startup script: %w", err) + // A pre-existing shared hook owns its mode. Do not chmod it as a side + // effect of installing ctrld. The editor reports whether this invocation + // actually appended the line so rollback never removes a pre-existing + // ctrld hook line. + out, err := exec.Command("sh", tmpScript.Name(), line, script, "add").CombinedOutput() + if err != nil { + return created, false, fmt.Errorf("exec.Command: add startup script: %s: %w", strings.TrimSpace(string(out)), err) + } + switch strings.TrimSpace(string(out)) { + case "": + return created, false, nil + case "added": + return created, true, nil + default: + return created, false, fmt.Errorf("unexpected hook editor output: %q", strings.TrimSpace(string(out))) } - return nil } - for script, line := range map[string]string{ - merlinJFFSScriptPath: s.configPath() + " start", - merlinJFFSServiceEventScriptPath: s.configPath() + ` service_event "$1" "$2"`, - } { - if err := addLineToScript(line, script); err != nil { + type hookLine struct { + script string + line string + created bool + added bool + } + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) + hooks := []hookLine{ + {script: merlinJFFSScriptPath, line: startLine}, + {script: merlinJFFSServiceEventScriptPath, line: serviceEventLine}, + } + installed := make([]hookLine, 0, len(hooks)) + for _, hook := range hooks { + created, added, err := addLineToScript(hook.line, hook.script) + if err != nil { + // Best-effort rollback: remove only lines successfully installed by + // this attempt. Shared hook files created by ctrld are removed again; + // pre-existing hooks keep all unrelated content. + for i := len(installed) - 1; i >= 0; i-- { + prev := installed[i] + if prev.created { + if prev.added { + cleanupCreatedHook(prev.line, prev.script) + } + continue + } + if prev.added { + _ = exec.Command("sh", tmpScript.Name(), prev.line, prev.script, "remove").Run() + } + } return err } + hook.created = created + hook.added = added + installed = append(installed, hook) } return nil } func (s *merlinSvc) Uninstall() error { - if err := os.Remove(s.configPath()); err != nil { - return fmt.Errorf("os.Remove: %w", err) - } + return withMerlinServiceLock(s.uninstallLocked) +} + +func (s *merlinSvc) uninstallLocked() error { tmpScript, err := os.CreateTemp("", "ctrld_uninstall") if err != nil { return fmt.Errorf("os.CreateTemp: %w", err) @@ -170,30 +538,43 @@ func (s *merlinSvc) Uninstall() error { return fmt.Errorf("tmpScript.Close: %w", err) } removeLineFromScript := func(line, script string) error { - if _, err := os.Stat(script); os.IsNotExist(err) { - if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0755); err != nil { - return err - } + exists, err := validateMerlinSharedHookPath(script, false) + if err != nil { + return err } - if err := os.Chmod(script, 0755); err != nil { - return fmt.Errorf("os.Chmod: jffs script: %w", err) + if !exists { + // Shared Merlin hooks belong to the router/user. Uninstalling ctrld + // must not create a hook file that did not exist. + return nil } - if err := exec.Command("sh", tmpScript.Name(), line, script).Run(); err != nil { - return fmt.Errorf("exec.Command: add startup script: %w", err) + if err := exec.Command("sh", tmpScript.Name(), line, script, "remove").Run(); err != nil { + return fmt.Errorf("exec.Command: remove startup script: %w", err) } return nil } - for script, line := range map[string]string{ - merlinJFFSScriptPath: s.configPath() + " start", - merlinJFFSServiceEventScriptPath: s.configPath() + ` service_event "$1" "$2"`, + startLine, serviceEventLine := merlinStartupHookLines(s.configPath()) + legacyStartLine, legacyServiceEventLine := merlinLegacyStartupHookLines(s.configPath()) + for _, hook := range []struct { + script string + lines []string + }{ + {merlinJFFSScriptPath, []string{startLine, legacyStartLine}}, + {merlinJFFSServiceEventScriptPath, []string{serviceEventLine, legacyServiceEventLine}}, } { - if err := removeLineFromScript(line, script); err != nil { - return err + for _, line := range hook.lines { + if err := removeLineFromScript(line, hook.script); err != nil { + return err + } } } + // Remove ctrld's private startup script only after all shared hook + // references have been removed successfully. + if err := os.Remove(s.configPath()); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("os.Remove: %w", err) + } return nil } @@ -228,36 +609,104 @@ func (s *merlinSvc) Run() (err error) { func (s *merlinSvc) Status() (service.Status, error) { if _, err := os.Stat(s.configPath()); os.IsNotExist(err) { return service.StatusUnknown, service.ErrNotInstalled - } - out, err := exec.Command(s.configPath(), "status").CombinedOutput() - if err != nil { + } else if err != nil { return service.StatusUnknown, err } + out, err := exec.Command(s.configPath(), "status").CombinedOutput() + return merlinServiceStatus(out, err) +} + +func merlinServiceStatus(out []byte, cmdErr error) (service.Status, error) { switch string(bytes.TrimSpace(out)) { case "running": + if cmdErr != nil { + return service.StatusUnknown, cmdErr + } return service.StatusRunning, nil - default: + case "stopped": + // The generated BusyBox script intentionally exits 1 for "stopped". + // That is a state, not a failure to determine the state. return service.StatusStopped, nil + default: + if cmdErr != nil { + return service.StatusUnknown, cmdErr + } + return service.StatusUnknown, fmt.Errorf("unexpected Merlin service status output: %q", bytes.TrimSpace(out)) } } func (s *merlinSvc) Start() error { + return withMerlinServiceLock(s.startLocked) +} + +func (s *merlinSvc) startLocked() error { + // Upgrades replace only the binary before restarting the service. Refresh + // an exact ctrld-owned legacy startup script here as a secondary path; the + // newly launched binary also performs this migration from ctrld run. + if err := s.refreshMerlinStartupScript(); err != nil { + var migrationErr *merlinStartupMigrationError + if !errors.As(err, &migrationErr) { + return err + } + + // Migration can fail before publication because JFFS is full/read-only or + // reports an I/O error. The exact legacy script is intentionally left + // intact in that case. Re-read it before fallback; if it is still the + // recognized legacy bytes (or the migration actually published the + // current marked script before a durability error), start it rather than + // leaving DNS down. Anything else remains a hard error. + canStart, readErr := canStartMerlinStartupAfterMigrationError(s.configPath(), migrationErr.legacy) + if readErr != nil { + return errors.Join(err, readErr) + } + if !canStart { + return err + } + } return exec.Command(s.configPath(), "start").Run() } +func canStartMerlinStartupAfterMigrationError(path string, legacy []byte) (bool, error) { + installed, exists, err := readExistingMerlinStartupScript(path) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + return bytes.Equal(installed, legacy) || merlinStartupScriptHasMarker(installed), nil +} + func (s *merlinSvc) Stop() error { + return withMerlinServiceLock(s.stopLocked) +} + +func (s *merlinSvc) stopLocked() error { return exec.Command(s.configPath(), "stop").Run() } func (s *merlinSvc) Restart() error { - err := s.Stop() - if err != nil { - return err - } - return s.Start() + return s.RestartWith(nil) } -const merlinSvcScript = `#!/bin/sh +// RestartWith serializes the complete Merlin stop/between/start transaction. +// The callback lets the CLI keep router cleanup, DNS restoration and process +// wait semantics inside the same cross-process lifecycle lock. +func (s *merlinSvc) RestartWith(between func() error) error { + return withMerlinServiceLock(func() error { + if err := s.stopLocked(); err != nil { + return err + } + if between != nil { + if err := between(); err != nil { + return err + } + } + return s.startLocked() + }) +} + +const merlinLegacySvcScript = `#!/bin/sh name="{{.Name}}" cmd="{{.Path}}{{range .Arguments}} {{.}}{{end}}" @@ -339,14 +788,150 @@ esac exit 0 ` +const merlinSvcScript = `#!/bin/sh +# GENERATED BY ctrld - Merlin service v2 + +name={{shellQuote .Name}} +exe={{shellQuote .Path}} +pid_file="/tmp/$name.pid" + +get_pid() { + [ -r "$pid_file" ] || return 1 + pid="$(cat "$pid_file" 2>/dev/null)" || return 1 + case "$pid" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$pid" +} + +is_running_pid() { + pid=$1 + [ -n "$pid" ] || return 1 + [ -r "/proc/$pid/cmdline" ] || return 1 + process_cmd="$(tr '\000' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" || return 1 + case "$process_cmd" in + "$exe"|"$exe "*) return 0 ;; + *) return 1 ;; + esac +} + +is_running() { + pid="$(get_pid)" || return 1 + is_running_pid "$pid" +} +case "$1" in + start) + if is_running; then + logger -c "Already started" + else + rm -f "$pid_file" + logger -c "Starting $name" + if [ -f /rom/ca-bundle.crt ]; then + # For John’s fork + export SSL_CERT_FILE=/rom/ca-bundle.crt + fi + {{shellQuote .Path}}{{range .Arguments}} {{shellQuote .}}{{end}} & + echo $! > "$pid_file" + chmod 600 "$pid_file" + started=0 + for _ in 1 2 3 4 5; do + if is_running; then + started=1 + break + fi + sleep 1 + done + if [ "$started" -ne 1 ]; then + logger -c "Failed to start $name" + rm -f "$pid_file" + exit 1 + fi + fi + ;; + stop) + pid="$(get_pid)" || { + rm -f "$pid_file" + exit 0 + } + if is_running_pid "$pid"; then + logger -c "Stopping $name..." + kill "$pid" + for _ in 1 2 3 4 5; do + if ! is_running_pid "$pid"; then + logger -c "stopped" + if [ -f "$pid_file" ]; then + rm "$pid_file" + fi + exit 0 + fi + printf "." + sleep 2 + done + logger -c "failed to stop $name" + exit 1 + fi + rm -f "$pid_file" + exit 0 + ;; + restart) + "$0" stop || exit $? + "$0" start + ;; + status) + if is_running; then + echo "running" + else + echo "stopped" + exit 1 + fi + ;; + service_event) + event=$2 + svc=$3 + dnsmasq_pid_file=$(sed -n '/pid-file=/s///p' /etc/dnsmasq.conf) + + if [ "$event" = "restart" ] && [ "$svc" = "diskmon" ] && [ -r "$dnsmasq_pid_file" ]; then + dnsmasq_pid="$(cat "$dnsmasq_pid_file" 2>/dev/null)" + case "$dnsmasq_pid" in + ''|*[!0-9]*) dnsmasq_pid="" ;; + esac + if [ -n "$dnsmasq_pid" ] && [ -r "/proc/$dnsmasq_pid/cmdline" ]; then + dnsmasq_exe="$(tr '\000' '\n' < "/proc/$dnsmasq_pid/cmdline" 2>/dev/null | sed -n '1p')" + case "$dnsmasq_exe" in + dnsmasq|*/dnsmasq) kill "$dnsmasq_pid" >/dev/null 2>&1 ;; + esac + fi + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +exit 0 +` + const merlinAddLineToScript = `#!/bin/sh line=$1 file=$2 +mode=$3 . /usr/sbin/helper.sh -pc_append "$line" "$file" +if [ "$mode" = "remove" ]; then + pattern=$(_quote "$line") + sed -i "/^$pattern$/d" "$file" +else + if grep -qxF "$line" "$file"; then + exit 0 + else + grep_status=$? + [ "$grep_status" -eq 1 ] || exit "$grep_status" + fi + pc_append "$line" "$file" || exit $? + printf 'added\n' +fi ` const merlinRemoveLineFromScript = `#!/bin/sh @@ -356,5 +941,6 @@ file=$2 . /usr/sbin/helper.sh -pc_delete "$line" "$file" +pattern=$(_quote "$line") +sed -i "/^$pattern$/d" "$file" ` diff --git a/internal/router/service_merlin_lock_linux.go b/internal/router/service_merlin_lock_linux.go new file mode 100644 index 00000000..5641abb0 --- /dev/null +++ b/internal/router/service_merlin_lock_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package router + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +const merlinServiceLockPath = "/tmp/ctrld-merlin-service.lock" + +func withMerlinServiceLock(fn func() error) error { + fd, err := unix.Open( + merlinServiceLockPath, + unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0600, + ) + if err != nil { + return fmt.Errorf("open Merlin service lifecycle lock: %w", err) + } + f := os.NewFile(uintptr(fd), merlinServiceLockPath) + if f == nil { + _ = unix.Close(fd) + return fmt.Errorf("wrap Merlin service lifecycle lock descriptor") + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat Merlin service lifecycle lock: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("merlin service lifecycle lock is not a regular file: %s", merlinServiceLockPath) + } + if err := f.Chmod(0600); err != nil { + return fmt.Errorf("chmod Merlin service lifecycle lock: %w", err) + } + + if err := unix.Flock(fd, unix.LOCK_EX); err != nil { + return fmt.Errorf("lock Merlin service lifecycle: %w", err) + } + defer unix.Flock(fd, unix.LOCK_UN) + + return fn() +} diff --git a/internal/router/service_merlin_lock_other.go b/internal/router/service_merlin_lock_other.go new file mode 100644 index 00000000..6d8cdc1c --- /dev/null +++ b/internal/router/service_merlin_lock_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package router + +func withMerlinServiceLock(fn func() error) error { + return fn() +} diff --git a/internal/router/service_merlin_startup_linux.go b/internal/router/service_merlin_startup_linux.go new file mode 100644 index 00000000..0f5c7609 --- /dev/null +++ b/internal/router/service_merlin_startup_linux.go @@ -0,0 +1,226 @@ +//go:build linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func readExistingMerlinStartupScript(path string) ([]byte, bool, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if err == unix.ENOENT { + return nil, false, nil + } + if err == unix.ELOOP { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + return nil, false, err + } + f := os.NewFile(uintptr(fd), path) + if f == nil { + _ = unix.Close(fd) + return nil, true, fmt.Errorf("could not wrap startup script descriptor: %s", path) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, true, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + buf, err := io.ReadAll(f) + if err != nil { + return nil, true, err + } + return buf, true, nil +} + +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { + fd, err := unix.Open(path, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if err == unix.ENOENT { + return false, nil + } + if err == unix.ELOOP { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + return false, err + } + + f := os.NewFile(uintptr(fd), path) + if f == nil { + _ = unix.Close(fd) + return true, fmt.Errorf("could not wrap startup script descriptor: %s", path) + } + defer func() { + if err := f.Close(); retErr == nil && err != nil { + retErr = err + } + }() + + info, err := f.Stat() + if err != nil { + return true, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + + got, err := io.ReadAll(f) + if err != nil { + return true, err + } + needsMigration := bytes.Equal(got, legacy) && !bytes.Equal(got, expected) + if !bytes.Equal(got, expected) && !needsMigration { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + + if needsMigration { + // Build the replacement completely in the same directory and publish it + // with rename(2). The legacy inode is never truncated, so ENOSPC/EIO while + // writing the new script leaves the previously working service intact. + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { + return true, err + } + return true, nil + } + + // Current script: mode/content validation stays descriptor-pinned. + if err := f.Chmod(0755); err != nil { + return true, err + } + if err := f.Sync(); err != nil { + return true, err + } + pathInfo, err := os.Lstat(path) + if err != nil { + return true, err + } + if !os.SameFile(info, pathInfo) { + return true, fmt.Errorf("startup script changed during preparation: %s", path) + } + return true, nil +} + +func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, originalInfo os.FileInfo) (retErr error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-migrate-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + removeTmp := true + defer func() { + _ = tmp.Close() + if removeTmp { + _ = os.Remove(tmpPath) + } + }() + + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write(expected); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + preparedInfo, err := tmp.Stat() + if err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + + // Atomically exchange the prepared replacement with whatever currently + // occupies path. This avoids the TOCTOU window of "Lstat then Rename": after + // the exchange, tmpPath names the exact previous target and can be validated + // by inode and bytes. If another actor changed/replaced the legacy script, + // swap the files back and leave their content untouched. + if err := unix.Renameat2( + unix.AT_FDCWD, tmpPath, + unix.AT_FDCWD, path, + unix.RENAME_EXCHANGE, + ); err != nil { + return fmt.Errorf("atomic startup-script exchange: %w", err) + } + + rollbackExchange := func(cause error) error { + // Do not exchange back by pathname unless path still names the exact + // prepared inode and bytes that this invocation installed. An external + // actor may have replaced or edited path after our first exchange; blindly + // exchanging in that case would overwrite their change with the legacy + // file. If ownership is no longer provable, preserve the captured legacy + // inode at tmpPath as a quarantine artifact instead. + pathInfo, statErr := os.Lstat(path) + if statErr != nil || !pathInfo.Mode().IsRegular() || !os.SameFile(preparedInfo, pathInfo) { + removeTmp = false + _ = syncMerlinServiceDir(dir) + if statErr != nil { + return fmt.Errorf("%v; cannot safely restore startup script after exchange: %w; legacy preserved at %s", cause, statErr, tmpPath) + } + return fmt.Errorf("%v; startup script changed after exchange; legacy preserved at %s", cause, tmpPath) + } + pathBytes, exists, readErr := readExistingMerlinStartupScript(path) + if readErr != nil || !exists || !bytes.Equal(pathBytes, expected) { + removeTmp = false + _ = syncMerlinServiceDir(dir) + if readErr != nil { + return fmt.Errorf("%v; cannot verify replacement before rollback: %w; legacy preserved at %s", cause, readErr, tmpPath) + } + return fmt.Errorf("%v; replacement changed after exchange; legacy preserved at %s", cause, tmpPath) + } + + if err := unix.Renameat2( + unix.AT_FDCWD, tmpPath, + unix.AT_FDCWD, path, + unix.RENAME_EXCHANGE, + ); err != nil { + removeTmp = false + _ = syncMerlinServiceDir(dir) + return fmt.Errorf("%v; failed to restore startup script after exchange: %w; legacy preserved at %s", cause, err, tmpPath) + } + if err := syncMerlinServiceDir(dir); err != nil { + return fmt.Errorf("%v; startup script restored but directory sync failed: %w", cause, err) + } + return cause + } + + oldInfo, err := os.Lstat(tmpPath) + if err != nil { + return rollbackExchange(err) + } + if !oldInfo.Mode().IsRegular() || !os.SameFile(originalInfo, oldInfo) { + return rollbackExchange(fmt.Errorf("startup script changed before migration publish: %s", path)) + } + + oldBytes, exists, err := readExistingMerlinStartupScript(tmpPath) + if err != nil { + return rollbackExchange(err) + } + if !exists || !bytes.Equal(oldBytes, legacy) { + return rollbackExchange(fmt.Errorf("startup script contents changed before migration publish: %s", path)) + } + + // The exchange is now proven to have replaced exactly the ctrld-owned + // legacy inode/bytes. Remove the old hard target and make the directory + // updates durable. + if err := os.Remove(tmpPath); err != nil { + return err + } + if err := syncMerlinServiceDir(dir); err != nil { + return err + } + return nil +} + diff --git a/internal/router/service_merlin_startup_other.go b/internal/router/service_merlin_startup_other.go new file mode 100644 index 00000000..b810abfa --- /dev/null +++ b/internal/router/service_merlin_startup_other.go @@ -0,0 +1,119 @@ +//go:build !linux + +package router + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" +) + +func readExistingMerlinStartupScript(path string) ([]byte, bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("startup script is not a regular file: %s", path) + } + buf, err := os.ReadFile(path) + if err != nil { + return nil, true, err + } + return buf, true, nil +} + +func prepareExistingMerlinStartupScript(path string, expected, legacy []byte) (exists bool, retErr error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return true, err + } + defer func() { + if err := f.Close(); retErr == nil && err != nil { + retErr = err + } + }() + + info, err = f.Stat() + if err != nil { + return true, err + } + if !info.Mode().IsRegular() { + return true, fmt.Errorf("startup script is not a regular file: %s", path) + } + got, err := io.ReadAll(f) + if err != nil { + return true, err + } + needsMigration := bytes.Equal(got, legacy) && !bytes.Equal(got, expected) + if !bytes.Equal(got, expected) && !needsMigration { + return true, fmt.Errorf("already installed with different startup script: %s", path) + } + if needsMigration { + if err := replaceMerlinStartupScriptAtomically(path, expected, got, info); err != nil { + return true, err + } + return true, nil + } + if err := f.Chmod(0755); err != nil { + return true, err + } + return true, nil +} + +func replaceMerlinStartupScriptAtomically(path string, expected, legacy []byte, originalInfo os.FileInfo) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".ctrld-migrate-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(0755); err != nil { + return err + } + if _, err := tmp.Write(expected); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + pathInfo, err := os.Lstat(path) + if err != nil { + return err + } + if !os.SameFile(originalInfo, pathInfo) { + return fmt.Errorf("startup script changed before migration publish: %s", path) + } + current, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.Equal(current, legacy) { + return fmt.Errorf("startup script contents changed before migration publish: %s", path) + } + return os.Rename(tmpPath, path) +} diff --git a/internal/router/service_merlin_test.go b/internal/router/service_merlin_test.go new file mode 100644 index 00000000..545b3d0b --- /dev/null +++ b/internal/router/service_merlin_test.go @@ -0,0 +1,584 @@ +package router + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kardianos/service" +) + +func TestMerlinServiceScriptValidatesPidOwnership(t *testing.T) { + if strings.Contains(merlinSvcScript, "ps | grep") { + t.Fatal("Merlin service status must not trust PID existence alone") + } + for _, want := range []string{ + `case "$pid" in`, + `[ -r "/proc/$pid/cmdline" ]`, + `tr '\000' ' '`, + `"$exe"|"$exe "*`, + `is_running_pid "$pid"`, + `kill "$pid"`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service script missing PID ownership check %q", want) + } + } + if strings.Contains(merlinSvcScript, `kill "$(get_pid)"`) { + t.Fatal("stop must kill the same PID whose ctrld ownership was validated") + } +} + +func TestMerlinServiceRestartStopsBeforeStarting(t *testing.T) { + if !strings.Contains(merlinSvcScript, `"$0" stop || exit $?`) { + t.Fatal("restart must not start a second instance when stop fails") + } +} + +func TestMerlinServiceHookEditorIsIdempotent(t *testing.T) { + for _, want := range []string{ + `if grep -qxF "$line" "$file"; then`, + `grep_status=$?`, + `[ "$grep_status" -eq 1 ] || exit "$grep_status"`, + `pc_append "$line" "$file" || exit $?`, + `printf 'added\n'`, + } { + if !strings.Contains(merlinAddLineToScript, want) { + t.Fatalf("hook editor missing idempotent append signal %q", want) + } + } + for _, script := range []string{merlinAddLineToScript, merlinRemoveLineFromScript} { + if !strings.Contains(script, `sed -i "/^$pattern$/d" "$file"`) { + t.Fatal("hook removal must match only ctrld's complete line") + } + } + if strings.Contains(merlinAddLineToScript, `pc_delete "$line" "$file"`) { + t.Fatal("install path must not destructively delete a working hook before append") + } +} + +func TestMerlinServiceTemplateRendersExecutableIdentity(t *testing.T) { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--cd", "example"}, + }, + } + var buf bytes.Buffer + if err := s.template().Execute(&buf, struct { + *service.Config + Path string + }{s.Config, s.Config.Executable}); err != nil { + t.Fatal(err) + } + got := buf.String() + for _, want := range []string{ + `exe='/jffs/controld/ctrld'`, + `'/jffs/controld/ctrld' 'run' '--cd' 'example' &`, + } { + if !strings.Contains(got, want) { + t.Fatalf("rendered Merlin startup script missing %q", want) + } + } +} + +func TestMerlinServiceTemplatePreservesArgumentBoundaries(t *testing.T) { + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--log", "/jffs/log dir/it's.log"}, + }, + } + var buf bytes.Buffer + if err := s.template().Execute(&buf, struct { + *service.Config + Path string + }{s.Config, s.Config.Executable}); err != nil { + t.Fatal(err) + } + want := `'/jffs/controld/ctrld' 'run' '--log' '/jffs/log dir/it'"'"'s.log' &` + if !strings.Contains(buf.String(), want) { + t.Fatalf("rendered command lost shell argument boundaries:\nwant substring: %q\ngot:\n%s", want, buf.String()) + } +} + + +func TestMerlinServiceStartWaitsForChildExec(t *testing.T) { + for _, want := range []string{ + "for _ in 1 2 3 4 5; do", + "if is_running; then", + "sleep 1", + `[ "$started" -ne 1 ]`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service start is missing exec-wait safeguard %q", want) + } + } +} + + +func TestMerlinServiceStatusTreatsStoppedAsState(t *testing.T) { + status, err := merlinServiceStatus([]byte("stopped\n"), os.ErrProcessDone) + if err != nil { + t.Fatalf("stopped status returned error: %v", err) + } + if status != service.StatusStopped { + t.Fatalf("status = %v, want %v", status, service.StatusStopped) + } +} + +func TestMerlinServiceStatusRejectsUnexpectedOutput(t *testing.T) { + if _, err := merlinServiceStatus([]byte("mystery\n"), nil); err == nil { + t.Fatal("expected unexpected status output to return an error") + } +} + + +func TestWriteMerlinStartupScriptPublishesAndPreservesMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows filesystems do not expose POSIX executable mode semantics") + } + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + want := []byte("#!/bin/sh\necho ok\n") + + published, err := writeMerlinStartupScript(path, want, 0755) + if err != nil { + t.Fatal(err) + } + if !published { + t.Fatal("expected startup script to be published") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("startup script contents mismatch: got %q want %q", got, want) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0755 { + t.Fatalf("startup script mode = %o, want 755", fi.Mode().Perm()) + } +} + +func TestWriteMerlinStartupScriptDoesNotClobberExistingTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + original := []byte("user-owned\n") + if err := os.WriteFile(path, original, 0700); err != nil { + t.Fatal(err) + } + + published, err := writeMerlinStartupScript(path, []byte("ctrld-owned\n"), 0755) + if err == nil { + t.Fatal("expected publication to fail for existing target") + } + if published { + t.Fatal("existing target must not be reported as ctrld-published") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("existing target was modified: got %q want %q", got, original) + } +} + + +func TestMerlinServiceEventValidatesDnsmasqPidOwnership(t *testing.T) { + for _, want := range []string{ + `case "$dnsmasq_pid" in`, + `[ -r "/proc/$dnsmasq_pid/cmdline" ]`, + `tr '\000' '\n'`, + `dnsmasq|*/dnsmasq) kill "$dnsmasq_pid"`, + } { + if !strings.Contains(merlinSvcScript, want) { + t.Fatalf("Merlin service_event missing dnsmasq PID ownership check %q", want) + } + } +} + + +func TestValidateMerlinSharedHookPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin shared hooks are POSIX shell files and executable-bit checks are Unix-specific") + } + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + + exists, err := validateMerlinSharedHookPath(path, true) + if err != nil || exists { + t.Fatalf("missing hook = (%v, %v), want (false, nil)", exists, err) + } + + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := validateMerlinSharedHookPath(path, true); err == nil { + t.Fatal("expected non-executable shared hook to be rejected") + } + if exists, err := validateMerlinSharedHookPath(path, false); err != nil || !exists { + t.Fatalf("regular non-executable hook should be editable for uninstall: (%v, %v)", exists, err) + } + + if err := os.Chmod(path, 0755); err != nil { + t.Fatal(err) + } + if exists, err := validateMerlinSharedHookPath(path, true); err != nil || !exists { + t.Fatalf("executable shared hook rejected: (%v, %v)", exists, err) + } + + link := filepath.Join(dir, "services-start-link") + if err := os.Symlink(path, link); err == nil { + if _, err := validateMerlinSharedHookPath(link, false); err == nil { + t.Fatal("expected shared-hook symlink to be rejected") + } + } +} + + +func TestMerlinStartupHookLinesQuoteConfigPath(t *testing.T) { + start, event := merlinStartupHookLines("/jffs/my dir/it's ctrld.startup") + wantPrefix := `'/jffs/my dir/it'"'"'s ctrld.startup'` + if start != wantPrefix+" start" { + t.Fatalf("start hook = %q", start) + } + if event != wantPrefix+` service_event "$1" "$2"` { + t.Fatalf("service-event hook = %q", event) + } +} + + +func TestMerlinLegacyStartupHookLinesRemainRemovable(t *testing.T) { + path := "/jffs/controld/ctrld.startup" + start, event := merlinLegacyStartupHookLines(path) + if start != path+" start" { + t.Fatalf("legacy start hook = %q", start) + } + if event != path+` service_event "$1" "$2"` { + t.Fatalf("legacy service-event hook = %q", event) + } +} + + +func TestPrepareExistingMerlinStartupScriptRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require elevated privileges on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "ctrld.startup") + if err := os.WriteFile(target, []byte("private\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if exists, err := prepareExistingMerlinStartupScript(link, []byte("private\n"), nil); err == nil || !exists { + t.Fatalf("symlink startup script = (exists %v, err %v), want exists=true and error", exists, err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "private\n" { + t.Fatalf("symlink target was modified: %q", got) + } +} + +func TestPrepareExistingMerlinStartupScriptRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + want := []byte("#!/bin/sh\n") + if err := os.WriteFile(path, want, 0600); err != nil { + t.Fatal(err) + } + exists, err := prepareExistingMerlinStartupScript(path, want, want) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("regular startup script reported missing") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("regular startup script = %q, want %q", got, want) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if gotMode := info.Mode().Perm(); gotMode != 0755 { + t.Fatalf("regular startup script mode = %o, want 755", gotMode) + } + } +} + +func TestPrepareExistingMerlinStartupScriptMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + exists, err := prepareExistingMerlinStartupScript(path, []byte("#!/bin/sh\n"), nil) + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("missing startup script reported as existing") + } +} + + +func TestPrepareExistingMerlinStartupScriptMigratesExactLegacyTemplate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + s := &merlinSvc{ + Config: &service.Config{ + Name: "ctrld", + Executable: "/jffs/controld/ctrld", + Arguments: []string{"run", "--cd", "example"}, + }, + } + current, legacy, err := s.renderStartupScripts(s.Config.Executable) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(current, legacy) { + t.Fatal("current and legacy templates unexpectedly match") + } + + path := filepath.Join(t.TempDir(), "ctrld.startup") + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + exists, err := prepareExistingMerlinStartupScript(path, current, legacy) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("legacy startup script reported missing") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, current) { + t.Fatalf("legacy startup script was not migrated exactly") + } +} + +func TestPrepareExistingMerlinStartupScriptRejectsUnknownCustomization(t *testing.T) { + path := filepath.Join(t.TempDir(), "ctrld.startup") + custom := []byte("#!/bin/sh\necho user-custom\n") + if err := os.WriteFile(path, custom, 0755); err != nil { + t.Fatal(err) + } + if exists, err := prepareExistingMerlinStartupScript( + path, + []byte("#!/bin/sh\necho current\n"), + []byte("#!/bin/sh\necho legacy\n"), + ); err == nil || !exists { + t.Fatalf("custom startup script = (exists %v, err %v), want exists=true and error", exists, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, custom) { + t.Fatalf("custom startup script was modified: got %q want %q", got, custom) + } +} + + +func TestRefreshMerlinStartupScriptRecoversInstalledLegacyArguments(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + + installedSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + Arguments: []string{"run", "--cd=abcd1234", "--config=/jffs/controld/ctrld.toml"}, + }} + _, legacy, err := installedSvc.renderStartupScripts(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe+".startup", legacy, 0755); err != nil { + t.Fatal(err) + } + + // Normal restart/upgrade service construction does not carry the original + // installed Arguments. + lifecycleSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + }} + if err := lifecycleSvc.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(exe + ".startup") + if err != nil { + t.Fatal(err) + } + if !merlinStartupScriptHasMarker(got) { + t.Fatal("migrated startup script is missing the current ctrld marker") + } + for _, want := range []string{ + merlinShellQuote("--cd=abcd1234"), + merlinShellQuote("--config=/jffs/controld/ctrld.toml"), + } { + if !bytes.Contains(got, []byte(want)) { + t.Fatalf("migrated startup script lost installed argument %q", want) + } + } +} + +func TestRefreshMerlinStartupScriptForExecutableMigratesFromNewBinaryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + installedSvc := &merlinSvc{Config: &service.Config{ + Name: "ctrld", + Executable: exe, + Arguments: []string{"run", "--cd=upgrade-device", "--config=/jffs/controld/ctrld.toml"}, + }} + current, legacy, err := installedSvc.renderStartupScripts(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe+".startup", legacy, 0755); err != nil { + t.Fatal(err) + } + + // This models the first process started from the just-downloaded binary: + // it has only its executable path, not the old process's service.Config. + if err := refreshMerlinStartupScriptForExecutable(exe); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(exe + ".startup") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, current) { + t.Fatalf("new-binary migration mismatch\nwant:\n%s\ngot:\n%s", current, got) + } +} + +func TestRefreshMerlinStartupScriptLeavesCurrentMarkedScriptUntouched(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + path := exe + ".startup" + original := []byte("#!/bin/sh\n" + merlinSvcScriptMarker + "\necho custom-current-body\n") + if err := os.WriteFile(path, original, 0700); err != nil { + t.Fatal(err) + } + + s := &merlinSvc{Config: &service.Config{Name: "ctrld", Executable: exe}} + if err := s.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("current marked script was rewritten:\nwant: %q\ngot: %q", original, got) + } +} + +func TestRefreshMerlinStartupScriptLeavesUnknownCustomizationUntouched(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ctrld") + path := exe + ".startup" + original := []byte("#!/bin/sh\necho user-custom-startup\n") + if err := os.WriteFile(path, original, 0755); err != nil { + t.Fatal(err) + } + + s := &merlinSvc{Config: &service.Config{Name: "ctrld", Executable: exe}} + if err := s.refreshMerlinStartupScript(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("unknown customized script was modified:\nwant: %q\ngot: %q", original, got) + } +} + +func TestRecoverLegacyMerlinServiceConfigRejectsWrongExecutable(t *testing.T) { + base := &service.Config{Name: "ctrld", Executable: "/jffs/controld/ctrld"} + buf := []byte("#!/bin/sh\ncmd=\"/tmp/other run --cd=abc\"\n") + if _, ok := recoverLegacyMerlinServiceConfig(buf, base, base.Executable); ok { + t.Fatal("legacy config recovery accepted a different executable") + } +} + + +func TestCanStartMerlinStartupAfterMigrationError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + + tests := []struct { + name string + body []byte + want bool + }{ + {"exact legacy", legacy, true}, + {"current marked", []byte("#!/bin/sh\n" + merlinSvcScriptMarker + "\necho current\n"), true}, + {"custom", []byte("#!/bin/sh\necho custom\n"), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(path, tc.body, 0755); err != nil { + t.Fatal(err) + } + got, err := canStartMerlinStartupAfterMigrationError(path, legacy) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("canStart = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCanStartMerlinStartupAfterMigrationErrorMissingPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Merlin startup scripts are Linux-specific") + } + got, err := canStartMerlinStartupAfterMigrationError(filepath.Join(t.TempDir(), "missing.startup"), []byte("legacy")) + if err != nil { + t.Fatal(err) + } + if got { + t.Fatal("missing startup path must not be considered safe to start") + } +} diff --git a/internal/router/service_merlin_unix_test.go b/internal/router/service_merlin_unix_test.go new file mode 100644 index 00000000..c0cb1826 --- /dev/null +++ b/internal/router/service_merlin_unix_test.go @@ -0,0 +1,126 @@ +//go:build linux + +package router + +import ( + "bytes" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCreateMerlinSharedHookStubIgnoresRestrictiveUmask(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + + oldUmask := syscall.Umask(0777) + defer syscall.Umask(oldUmask) + + if err := createMerlinSharedHookStub(path); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0755 { + t.Fatalf("shared hook mode = %o, want 755", got) + } + + buf, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(buf) != "#!/bin/sh\n" { + t.Fatalf("shared hook contents = %q", buf) + } +} + + +func TestCreateMerlinSharedHookStubDoesNotClobberExistingTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "services-start") + want := []byte("#!/bin/sh\necho addon\n") + if err := os.WriteFile(path, want, 0755); err != nil { + t.Fatal(err) + } + + if err := createMerlinSharedHookStub(path); !os.IsExist(err) { + t.Fatalf("createMerlinSharedHookStub error = %v, want already-exists", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("existing shared hook was modified:\nwant %q\ngot %q", want, got) + } +} + +func TestReplaceMerlinStartupScriptAtomicallyPreservesReplacedTarget(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + current := []byte("#!/bin/sh\necho current\n") + replacement := []byte("#!/bin/sh\necho user replacement\n") + + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + replacementPath := filepath.Join(dir, "replacement") + if err := os.WriteFile(replacementPath, replacement, 0755); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacementPath, path); err != nil { + t.Fatal(err) + } + + if err := replaceMerlinStartupScriptAtomically(path, current, legacy, originalInfo); err == nil { + t.Fatal("expected concurrent inode replacement to abort migration") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(replacement) { + t.Fatalf("replacement target changed: got %q want %q", got, replacement) + } +} + +func TestReplaceMerlinStartupScriptAtomicallyPreservesInPlaceEdit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ctrld.startup") + legacy := []byte("#!/bin/sh\necho legacy\n") + current := []byte("#!/bin/sh\necho current\n") + edited := []byte("#!/bin/sh\necho edited in place\n") + + if err := os.WriteFile(path, legacy, 0755); err != nil { + t.Fatal(err) + } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, edited, 0755); err != nil { + t.Fatal(err) + } + + if err := replaceMerlinStartupScriptAtomically(path, current, legacy, originalInfo); err == nil { + t.Fatal("expected concurrent in-place edit to abort migration") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(edited) { + t.Fatalf("in-place edit changed: got %q want %q", got, edited) + } +}