From 6663925c4d576109df349d98b3907714f9afa0ce Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Mon, 30 Jun 2025 15:22:25 +0700 Subject: [PATCH] internal/router: support Merlin Guest Network Pro VLAN By looking for any additional dnsmasq configuration files under /tmp/etc, and handling them like default one. --- cmd/cli/prog.go | 7 ++ internal/router/dnsmasq/conf.go | 60 +++++++++++++ internal/router/dnsmasq/conf_test.go | 47 ++++++++++ internal/router/dnsmasq/dnsmasq.go | 10 ++- internal/router/merlin/merlin.go | 123 ++++++++++++++++++++------- 5 files changed, 212 insertions(+), 35 deletions(-) diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index dd8de9f..48a1e07 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -35,6 +35,7 @@ import ( "github.com/Control-D-Inc/ctrld/internal/controld" "github.com/Control-D-Inc/ctrld/internal/dnscache" "github.com/Control-D-Inc/ctrld/internal/router" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" ) const ( @@ -607,6 +608,12 @@ func (p *prog) setupClientInfoDiscover(selfIP string) { format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat) p.ciTable.AddLeaseFile(leaseFile, format) } + if leaseFiles := dnsmasq.AdditionalLeaseFiles(); len(leaseFiles) > 0 { + mainLog.Load().Debug().Msgf("watching additional lease files: %v", leaseFiles) + for _, leaseFile := range leaseFiles { + p.ciTable.AddLeaseFile(leaseFile, ctrld.Dnsmasq) + } + } } // runClientInfoDiscover runs the client info discover. diff --git a/internal/router/dnsmasq/conf.go b/internal/router/dnsmasq/conf.go index b168042..bb81d60 100644 --- a/internal/router/dnsmasq/conf.go +++ b/internal/router/dnsmasq/conf.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "strings" ) @@ -28,3 +29,62 @@ func interfaceNameFromReader(r io.Reader) (string, error) { } return "", errors.New("not found") } + +// AdditionalConfigFiles returns a list of Dnsmasq configuration files found in the "/tmp/etc" directory. +func AdditionalConfigFiles() []string { + if paths, err := filepath.Glob("/tmp/etc/dnsmasq-*.conf"); err == nil { + return paths + } + return nil +} + +// AdditionalLeaseFiles returns a list of lease file paths corresponding to the Dnsmasq configuration files. +func AdditionalLeaseFiles() []string { + cfgFiles := AdditionalConfigFiles() + if len(cfgFiles) == 0 { + return nil + } + leaseFiles := make([]string, 0, len(cfgFiles)) + for _, cfgFile := range cfgFiles { + if leaseFile := leaseFileFromConfigFileName(cfgFile); leaseFile != "" { + leaseFiles = append(leaseFiles, leaseFile) + + } else { + leaseFiles = append(leaseFiles, defaultLeaseFileFromConfigPath(cfgFile)) + } + } + return leaseFiles +} + +// leaseFileFromConfigFileName retrieves the DHCP lease file path by reading and parsing the provided configuration file. +func leaseFileFromConfigFileName(cfgFile string) string { + if f, err := os.Open(cfgFile); err == nil { + return leaseFileFromReader(f) + } + return "" +} + +// leaseFileFromReader parses the given io.Reader for the "dhcp-leasefile" configuration and returns its value as a string. +func leaseFileFromReader(r io.Reader) string { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + before, after, found := strings.Cut(line, "=") + if !found { + continue + } + if before == "dhcp-leasefile" { + return after + } + } + return "" +} + +// defaultLeaseFileFromConfigPath generates the default lease file path based on the provided configuration file path. +func defaultLeaseFileFromConfigPath(path string) string { + name := filepath.Base(path) + return filepath.Join("/var/lib/misc", strings.TrimSuffix(name, ".conf")+".leases") +} diff --git a/internal/router/dnsmasq/conf_test.go b/internal/router/dnsmasq/conf_test.go index 99a0710..9ca672b 100644 --- a/internal/router/dnsmasq/conf_test.go +++ b/internal/router/dnsmasq/conf_test.go @@ -1,6 +1,7 @@ package dnsmasq import ( + "io" "strings" "testing" ) @@ -44,3 +45,49 @@ interface=eth0 }) } } + +func Test_leaseFileFromReader(t *testing.T) { + tests := []struct { + name string + in io.Reader + expected string + }{ + { + "default", + strings.NewReader(` +dhcp-script=/sbin/dhcpc_lease +dhcp-leasefile=/var/lib/misc/dnsmasq-1.leases +script-arp +`), + "/var/lib/misc/dnsmasq-1.leases", + }, + { + "non-default", + strings.NewReader(` +dhcp-script=/sbin/dhcpc_lease +dhcp-leasefile=/tmp/var/lib/misc/dnsmasq-1.leases +script-arp +`), + "/tmp/var/lib/misc/dnsmasq-1.leases", + }, + { + "missing", + strings.NewReader(` +dhcp-script=/sbin/dhcpc_lease +script-arp +`), + "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := leaseFileFromReader(tc.in); got != tc.expected { + t.Errorf("leaseFileFromReader() = %v, want %v", got, tc.expected) + } + }) + } + +} diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 819bd59..a690ee4 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -26,9 +26,13 @@ max-cache-ttl=0 {{- end}} ` -const MerlinConfPath = "/tmp/etc/dnsmasq.conf" -const MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" -const MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" +const ( + MerlinConfPath = "/tmp/etc/dnsmasq.conf" + MerlinJffsConfDir = "/jffs/configs" + MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" + MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" +) + const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index cacc508..c1c6821 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "time" "unicode" @@ -20,10 +21,18 @@ import ( const Name = "merlin" +// 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 { cfg *ctrld.Config } @@ -33,18 +42,22 @@ func New(cfg *ctrld.Config) *Merlin { return &Merlin{cfg: cfg} } +// ConfigureService configures the service based on the provided configuration. It returns an error if the configuration fails. func (m *Merlin) ConfigureService(config *service.Config) error { return nil } +// Install sets up the necessary configurations and services required for the Merlin instance to function properly. func (m *Merlin) Install(_ *service.Config) error { return nil } +// Uninstall removes the ctrld-related configurations and services from the Merlin router and reverts to the original state. func (m *Merlin) Uninstall(_ *service.Config) error { return nil } +// PreRun prepares the Merlin instance for operation by waiting for essential services and directories to become available. func (m *Merlin) PreRun() error { // Wait NTP ready. _ = m.Cleanup() @@ -66,6 +79,7 @@ func (m *Merlin) PreRun() error { return nil } +// Setup initializes and configures the Merlin instance for use, including setting up dnsmasq and necessary nvram settings. func (m *Merlin) Setup() error { if m.cfg.FirstListener().IsDirectDnsListener() { return nil @@ -79,35 +93,10 @@ func (m *Merlin) Setup() error { return err } - // Copy current dnsmasq config to /jffs/configs/dnsmasq.conf, - // 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. - src, err := os.Open(dnsmasq.MerlinConfPath) - if err != nil { - return fmt.Errorf("failed to open dnsmasq config: %w", err) - } - defer src.Close() - - dst, err := os.Create(dnsmasq.MerlinJffsConfPath) - if err != nil { - return fmt.Errorf("failed to create %s: %w", dnsmasq.MerlinJffsConfPath, err) - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("failed to copy current dnsmasq config: %w", err) - } - if err := dst.Close(); err != nil { - return fmt.Errorf("failed to save %s: %w", dnsmasq.MerlinJffsConfPath, err) - } - - // Run postconf script on /jffs/configs/dnsmasq.conf directly. - cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, dnsmasq.MerlinJffsConfPath) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to run post conf: %s: %w", string(out), 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) + } } // Restart dnsmasq service. @@ -122,6 +111,7 @@ func (m *Merlin) Setup() error { return nil } +// Cleanup restores the original dnsmasq and nvram configurations and restarts dnsmasq if necessary. func (m *Merlin) Cleanup() error { if m.cfg.FirstListener().IsDirectDnsListener() { return nil @@ -143,9 +133,11 @@ func (m *Merlin) Cleanup() error { if err := os.WriteFile(dnsmasq.MerlinPostConfPath, merlinParsePostConf(buf), 0750); err != nil { return err } - // Remove /jffs/configs/dnsmasq.conf file. - if err := os.Remove(dnsmasq.MerlinJffsConfPath); err != nil && !os.IsNotExist(err) { - 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) + } } // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { @@ -154,6 +146,54 @@ func (m *Merlin) Cleanup() error { 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. + } + if err != nil { + return fmt.Errorf("failed to open dnsmasq config: %w", 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) + if err != nil { + return fmt.Errorf("failed to create %s: %w", cfg.jffsConfPath, err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("failed to copy current dnsmasq config: %w", err) + } + if err := dst.Close(); err != nil { + return fmt.Errorf("failed to save %s: %w", cfg.jffsConfPath, err) + } + + // Run postconf script on cfg.jffsConfPath directly. + cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, cfg.jffsConfPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to run post conf: %s: %w", string(out), 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) { + return err + } + return nil +} + +// 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. @@ -179,6 +219,8 @@ func (m *Merlin) writeDnsmasqPostconf() error { return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(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) @@ -186,6 +228,22 @@ func restartDNSMasq() error { return nil } +// getDnsmasqConfigs retrieves a list of dnsmasqConfig containing configuration and JFFS paths for dnsmasq operations. +func getDnsmasqConfigs() []*dnsmasqConfig { + cfgs := []*dnsmasqConfig{ + {dnsmasq.MerlinConfPath, dnsmasq.MerlinJffsConfPath}, + } + for _, path := range dnsmasq.AdditionalConfigFiles() { + jffsConfPath := filepath.Join(dnsmasq.MerlinJffsConfDir, filepath.Base(path)) + cfgs = append(cfgs, &dnsmasqConfig{path, jffsConfPath}) + } + + return cfgs +} + +// merlinParsePostConf parses the dnsmasq post configuration by removing content after the MerlinPostConfMarker, if present. +// If no marker is found, the original buffer is returned unmodified. +// Returns nil if the input buffer is empty. func merlinParsePostConf(buf []byte) []byte { if len(buf) == 0 { return nil @@ -197,6 +255,7 @@ func merlinParsePostConf(buf []byte) []byte { return buf } +// waitDirExists waits until the specified directory exists, polling its existence every second. func waitDirExists(dir string) { for { if _, err := os.Stat(dir); !os.IsNotExist(err) {