From b471adfb09b5dfbc741c1ebcc932b064252442cc Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 17 May 2023 22:36:49 +0700 Subject: [PATCH 01/21] Fix split mode for all protocols but DoH In split mode, the code must check for ipv6 availability to return the correct network stack. Otherwise, we may end up using "tcp6-tls" even though the upstream IP is an ipv4. --- config.go | 14 +++++++++----- dot.go | 1 + net.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ resolver.go | 9 ++++++++- 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 net.go diff --git a/config.go b/config.go index bef32e3..b06f09f 100644 --- a/config.go +++ b/config.go @@ -347,9 +347,7 @@ func (uc *UpstreamConfig) setupDOHTransportWithoutPingUpstream() { uc.transport = uc.newDOHTransport(uc.bootstrapIPs6) case IpStackSplit: uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if ctrldnet.IPv6Available(ctx) { + if hasIPv6() { uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6) } else { uc.transport6 = uc.transport4 @@ -419,7 +417,10 @@ func (uc *UpstreamConfig) bootstrapIPForDNSType(dnsType uint16) string { case dns.TypeA: return pick(uc.bootstrapIPs4) default: - return pick(uc.bootstrapIPs6) + if hasIPv6() { + return pick(uc.bootstrapIPs6) + } + return pick(uc.bootstrapIPs4) } } return pick(uc.bootstrapIPs) @@ -438,7 +439,10 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) { case dns.TypeA: return "tcp4-tls", "udp4" default: - return "tcp6-tls", "udp6" + if hasIPv6() { + return "tcp6-tls", "udp6" + } + return "tcp4-tls", "udp4" } } return "tcp-tls", "udp" diff --git a/dot.go b/dot.go index 68cf2e1..1fef409 100644 --- a/dot.go +++ b/dot.go @@ -33,6 +33,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro endpoint := r.uc.Endpoint if r.uc.BootstrapIP != "" { dnsClient.TLSConfig.ServerName = r.uc.Domain + dnsClient.Net = "tcp-tls" _, port, _ := net.SplitHostPort(endpoint) endpoint = net.JoinHostPort(r.uc.BootstrapIP, port) } diff --git a/net.go b/net.go new file mode 100644 index 0000000..110d67e --- /dev/null +++ b/net.go @@ -0,0 +1,46 @@ +package ctrld + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "tailscale.com/logtail/backoff" + + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" +) + +var ( + hasIPv6Once sync.Once + ipv6Available atomic.Bool +) + +func hasIPv6() bool { + hasIPv6Once.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + val := ctrldnet.IPv6Available(ctx) + ipv6Available.Store(val) + go probingIPv6(val) + }) + return ipv6Available.Load() +} + +// TODO(cuonglm): doing poll check natively for supported platforms. +func probingIPv6(old bool) { + b := backoff.NewBackoff("probingIPv6", func(format string, args ...any) {}, 30*time.Second) + bCtx := context.Background() + for { + func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cur := ctrldnet.IPv6Available(ctx) + if ipv6Available.CompareAndSwap(old, cur) { + old = cur + } + }() + b.BackOff(bCtx, errors.New("no change")) + } +} diff --git a/resolver.go b/resolver.go index befa298..391a4e8 100644 --- a/resolver.go +++ b/resolver.go @@ -125,7 +125,14 @@ func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, e Net: udpNet, Dialer: dialer, } - answer, _, err := dnsClient.ExchangeContext(ctx, msg, r.uc.Endpoint) + endpoint := r.uc.Endpoint + if r.uc.BootstrapIP != "" { + dnsClient.Net = "udp" + _, port, _ := net.SplitHostPort(endpoint) + endpoint = net.JoinHostPort(r.uc.BootstrapIP, port) + } + + answer, _, err := dnsClient.ExchangeContext(ctx, msg, endpoint) return answer, err } From 15d397d8a62d85c0edaf707f7d27decd719d3c7b Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 17 May 2023 23:56:04 +0700 Subject: [PATCH 02/21] cmd/ctrld: fix problem with default iface name on WSL 1 On WSL 1, the routing table do not contain default route, causing ctrld failed to get the default iface for setting DNS. However, WSL 1 only use /etc/resolv.conf for setting up DNS, so the interface does not matter, because the setting is applied global anyway. To fix it, just return "lo" as the default interface name on WSL 1. While at it, also removing the useless service.Logger call, which is not unified with the current logger, and may cause false positive on system where syslog is not configured properly (like WSL 1). Also passing the real error when doing sel-check to backoff, so we don't have to use a place holder error. --- cmd/ctrld/cli.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index a0304da..811ac56 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -138,16 +138,7 @@ func initCLI() { mainLog.Fatal().Err(err).Msg("failed create new service") } s = newService(s) - serviceLogger, err := s.Logger(nil) - if err != nil { - mainLog.Error().Err(err).Msg("failed to get service logger") - return - } - if err := s.Run(); err != nil { - if sErr := serviceLogger.Error(err); sErr != nil { - mainLog.Error().Err(sErr).Msg("failed to write service log") - } mainLog.Error().Err(err).Msg("failed to start service") } }() @@ -854,6 +845,11 @@ func netInterface(ifaceName string) (*net.Interface, error) { func defaultIfaceName() string { dri, err := interfaces.DefaultRouteInterface() if err != nil { + // On WSL 1, the route table does not have any default route. But the fact that + // it only uses /etc/resolv.conf for setup DNS, so we can use "lo" here. + if oi := osinfo.New(); strings.Contains(oi.String(), "Microsoft") { + return "lo" + } mainLog.Fatal().Err(err).Msg("failed to get default route interface") } return dri @@ -864,7 +860,6 @@ func selfCheckStatus(status service.Status) service.Status { bo := backoff.NewBackoff("self-check", logf, 10*time.Second) bo.LogLongerThan = 500 * time.Millisecond ctx := context.Background() - err := errors.New("query failed") maxAttempts := 20 mainLog.Debug().Msg("Performing self-check") var ( @@ -890,7 +885,7 @@ func selfCheckStatus(status service.Status) service.Status { m := new(dns.Msg) m.SetQuestion(selfCheckFQDN+".", dns.TypeA) m.RecursionDesired = true - r, _, _ := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))) + r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))) if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 { mainLog.Debug().Msgf("self-check against %q succeeded", selfCheckFQDN) return status From 7ac5555a848d0ad0bf3aa686646b8aa4f4141f9d Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 18 May 2023 21:11:20 +0700 Subject: [PATCH 03/21] internal/router: fix wrong platform check in PreStart The NTP workaround is intended to be run on Merlin only. --- internal/router/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/router.go b/internal/router/router.go index c8beac7..05d7f24 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -95,7 +95,7 @@ func ConfigureService(sc *service.Config) error { // PreStart blocks until the router is ready for running ctrld. func PreStart() (err error) { - if Name() != DDWrt { + if Name() != Merlin { return nil } From d2fc5303166ae96b5b62a4470866357c18fdad14 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 18 May 2023 23:03:03 +0700 Subject: [PATCH 04/21] all: add support for Synology router --- cmd/ctrld/cli_router_linux.go | 2 +- internal/router/dnsmasq.go | 2 +- internal/router/openwrt.go | 1 - internal/router/router.go | 43 +++++++++++++++++---------- internal/router/synology.go | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 internal/router/synology.go diff --git a/cmd/ctrld/cli_router_linux.go b/cmd/ctrld/cli_router_linux.go index 3bd70f1..5791a7e 100644 --- a/cmd/ctrld/cli_router_linux.go +++ b/cmd/ctrld/cli_router_linux.go @@ -43,7 +43,7 @@ func initRouterCLI() { platform = router.Name() } switch platform { - case router.DDWrt, router.Merlin, router.OpenWrt, router.Ubios: + case router.DDWrt, router.Merlin, router.OpenWrt, router.Ubios, router.Synology: default: unsupportedPlatformHelp(cmd) os.Exit(1) diff --git a/internal/router/dnsmasq.go b/internal/router/dnsmasq.go index b8fad8c..77f47d7 100644 --- a/internal/router/dnsmasq.go +++ b/internal/router/dnsmasq.go @@ -49,7 +49,7 @@ func dnsMasqConf() (string, error) { var sb strings.Builder var tmplText string switch Name() { - case DDWrt, OpenWrt, Ubios: + case DDWrt, OpenWrt, Ubios, Synology: tmplText = dnsMasqConfigContentTmpl case Merlin: tmplText = merlinDNSMasqPostConfTmpl diff --git a/internal/router/openwrt.go b/internal/router/openwrt.go index 6e5881d..701913b 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt.go @@ -28,7 +28,6 @@ func setupOpenWrt() error { if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) { return err } - // Disable dnsmasq as DNS server. dnsMasqConfigContent, err := dnsMasqConf() if err != nil { return err diff --git a/internal/router/router.go b/internal/router/router.go index 05d7f24..6b33a4c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -19,10 +19,11 @@ import ( ) const ( - OpenWrt = "openwrt" - DDWrt = "ddwrt" - Merlin = "merlin" - Ubios = "ubios" + OpenWrt = "openwrt" + DDWrt = "ddwrt" + Merlin = "merlin" + Ubios = "ubios" + Synology = "synology" ) // ErrNotSupported reports the current router is not supported error. @@ -39,21 +40,22 @@ type router struct { // SupportedPlatforms return all platforms that can be configured to run with ctrld. func SupportedPlatforms() []string { - return []string{DDWrt, Merlin, OpenWrt, Ubios} + return []string{DDWrt, Merlin, OpenWrt, Ubios, Synology} } var configureFunc = map[string]func() error{ - DDWrt: setupDDWrt, - Merlin: setupMerlin, - OpenWrt: setupOpenWrt, - Ubios: setupUbiOS, + DDWrt: setupDDWrt, + Merlin: setupMerlin, + OpenWrt: setupOpenWrt, + Ubios: setupUbiOS, + Synology: setupSynology, } // Configure configures things for running ctrld on the router. func Configure(c *ctrld.Config) error { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios: + case DDWrt, Merlin, OpenWrt, Ubios, Synology: if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true @@ -88,7 +90,7 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case Merlin, Ubios: + case Merlin, Ubios, Synology: } return nil } @@ -151,6 +153,8 @@ func PostInstall() error { return postInstallOpenWrt() case Ubios: return postInstallUbiOS() + case Synology: + return postInstallSynology() } return nil } @@ -167,6 +171,8 @@ func Cleanup() error { return cleanupOpenWrt() case Ubios: return cleanupUbiOS() + case Synology: + return cleanupSynology() } return nil } @@ -175,7 +181,7 @@ func Cleanup() error { func ListenAddress() string { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios: + case DDWrt, Merlin, OpenWrt, Ubios, Synology: return "127.0.0.1:5354" } return "" @@ -194,14 +200,16 @@ func Name() string { func distroName() string { switch { - case bytes.HasPrefix(uname(), []byte("DD-WRT")): + case bytes.HasPrefix(unameO(), []byte("DD-WRT")): return DDWrt - case bytes.HasPrefix(uname(), []byte("ASUSWRT-Merlin")): + case bytes.HasPrefix(unameO(), []byte("ASUSWRT-Merlin")): return Merlin case haveFile("/etc/openwrt_version"): return OpenWrt case haveDir("/data/unifi"): return Ubios + case bytes.HasPrefix(unameU(), []byte("synology")): + return Synology } return "" } @@ -216,7 +224,12 @@ func haveDir(dir string) bool { return fi != nil && fi.IsDir() } -func uname() []byte { +func unameO() []byte { out, _ := exec.Command("uname", "-o").Output() return out } + +func unameU() []byte { + out, _ := exec.Command("uname", "-u").Output() + return out +} diff --git a/internal/router/synology.go b/internal/router/synology.go new file mode 100644 index 0000000..b221be8 --- /dev/null +++ b/internal/router/synology.go @@ -0,0 +1,55 @@ +package router + +import ( + "fmt" + "os" + "os/exec" +) + +const ( + synologyDNSMasqConfigPath = "/etc/dhcpd/dhcpd-zzz-ctrld.conf" + synologyDhcpdInfoPath = "/etc/dhcpd/dhcpd-zzz-ctrld.info" +) + +func setupSynology() error { + dnsMasqConfigContent, err := dnsMasqConf() + if err != nil { + return err + } + if err := os.WriteFile(synologyDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { + return err + } + if err := os.WriteFile(synologyDhcpdInfoPath, []byte(`enable="yes"`), 0600); err != nil { + return err + } + if err := synologyRestartDNSMasq(); err != nil { + return err + } + return nil +} + +func cleanupSynology() error { + // Remove the custom config files. + for _, f := range []string{synologyDNSMasqConfigPath, synologyDhcpdInfoPath} { + if err := os.Remove(f); err != nil { + return err + } + } + + // Restart dnsmasq service. + if err := synologyRestartDNSMasq(); err != nil { + return err + } + return nil +} + +func postInstallSynology() error { + return nil +} + +func synologyRestartDNSMasq() error { + if out, err := exec.Command("/etc/rc.network", "nat-restart-dhcp").CombinedOutput(); err != nil { + return fmt.Errorf("synologyRestartDNSMasq: %s - %w", string(out), err) + } + return nil +} From 20eae82f115f0148ae054d199dbb248e7f68c8ec Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 24 May 2023 18:53:10 +0700 Subject: [PATCH 05/21] cmd/ctrld: ensure error passed to backoff is wrapped in self-check In commit 670879d1, the backoff is changed to be passed a real error, instead of a place holder. However, the test query may return a failed response with a nil error, causing the backoff never fire. Fixing this by ensuring the error is wrapped, so the backoff always see a non-nil error. --- cmd/ctrld/cli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index 811ac56..b9d3f3e 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -890,7 +890,7 @@ func selfCheckStatus(status service.Status) service.Status { mainLog.Debug().Msgf("self-check against %q succeeded", selfCheckFQDN) return status } - bo.BackOff(ctx, err) + bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err)) } mainLog.Debug().Msgf("self-check against %q failed", selfCheckFQDN) return service.StatusUnknown From fc502b920b1c52023a33e7d8ad626d98ffc5d784 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 24 May 2023 23:15:03 +0700 Subject: [PATCH 06/21] internal/router: add Synology client info file --- internal/router/client_info.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/router/client_info.go b/internal/router/client_info.go index f6a13fb..3a809b6 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -21,6 +21,7 @@ var clientInfoFiles = []string{ "/var/lib/misc/dnsmasq.leases", // merlin "/mnt/data/udapi-config/dnsmasq.lease", // UDM Pro "/data/udapi-config/dnsmasq.lease", // UDR + "/etc/dhcpd/dhcpd-leases.log", // Synology } func (r *router) watchClientInfoTable() { From ee53db1e35f973a7eee3a6f5fac7177b72d4114e Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 25 May 2023 09:52:44 +0700 Subject: [PATCH 07/21] all: add support for freshtomato --- cmd/ctrld/cli_router_linux.go | 5 +- cmd/ctrld/prog.go | 2 +- internal/router/client_info.go | 1 + internal/router/ddwrt.go | 17 +- internal/router/dnsmasq.go | 20 ++- internal/router/merlin.go | 53 +++++- internal/router/nvram.go | 27 ++- internal/router/openwrt.go | 4 +- internal/router/router.go | 76 +++----- internal/router/service.go | 9 + internal/router/service_tomato.go | 278 ++++++++++++++++++++++++++++++ internal/router/synology.go | 4 +- internal/router/tomato.go | 108 ++++++++++++ internal/router/ubios.go | 4 +- 14 files changed, 532 insertions(+), 76 deletions(-) create mode 100644 internal/router/service_tomato.go create mode 100644 internal/router/tomato.go diff --git a/cmd/ctrld/cli_router_linux.go b/cmd/ctrld/cli_router_linux.go index 5791a7e..b6a4ffc 100644 --- a/cmd/ctrld/cli_router_linux.go +++ b/cmd/ctrld/cli_router_linux.go @@ -42,12 +42,11 @@ func initRouterCLI() { if platform == "auto" { platform = router.Name() } - switch platform { - case router.DDWrt, router.Merlin, router.OpenWrt, router.Ubios, router.Synology: - default: + if !router.IsSupported(platform) { unsupportedPlatformHelp(cmd) os.Exit(1) } + exe, err := os.Executable() if err != nil { mainLog.Fatal().Msgf("could not find executable path: %v", err) diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index f19aeda..1c59530 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -175,7 +175,7 @@ func (p *prog) setDNS() { switch router.Name() { case router.DDWrt, router.OpenWrt, router.Ubios: // On router, ctrld run as a DNS forwarder, it does not have to change system DNS. - // Except for Merlin, which has WAN DNS setup on boot for NTP. + // Except for Merlin/Tomato, which has WAN DNS setup on boot for NTP. return } if cfg.Listener == nil || cfg.Listener["0"] == nil { diff --git a/internal/router/client_info.go b/internal/router/client_info.go index 3a809b6..04af7ac 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -22,6 +22,7 @@ var clientInfoFiles = []string{ "/mnt/data/udapi-config/dnsmasq.lease", // UDM Pro "/data/udapi-config/dnsmasq.lease", // UDR "/etc/dhcpd/dhcpd-leases.log", // Synology + "/tmp/var/lib/misc/dnsmasq.leases", // Tomato } func (r *router) watchClientInfoTable() { diff --git a/internal/router/ddwrt.go b/internal/router/ddwrt.go index b0be098..92318b1 100644 --- a/internal/router/ddwrt.go +++ b/internal/router/ddwrt.go @@ -7,9 +7,10 @@ import ( ) const ( - nvramCtrldKeyPrefix = "ctrld_" - nvramCtrldSetupKey = "ctrld_setup" - nvramRCStartupKey = "rc_startup" + nvramCtrldKeyPrefix = "ctrld_" + nvramCtrldSetupKey = "ctrld_setup" + nvramCtrldInstallKey = "ctrld_install" + nvramRCStartupKey = "rc_startup" ) //lint:ignore ST1005 This error is for human. @@ -29,14 +30,14 @@ func setupDDWrt() error { return err } - nvramKvMap := nvramKV() + nvramKvMap := nvramSetupKV() nvramKvMap["dnsmasq_options"] = data - if err := nvramSetup(nvramKvMap); err != nil { + if err := nvramSetKV(nvramKvMap, nvramCtrldSetupKey); err != nil { return err } // Restart dnsmasq service. - if err := ddwrtRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -44,11 +45,11 @@ func setupDDWrt() error { func cleanupDDWrt() error { // Restore old configs. - if err := nvramRestore(nvramKV()); err != nil { + if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { return err } // Restart dnsmasq service. - if err := ddwrtRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil diff --git a/internal/router/dnsmasq.go b/internal/router/dnsmasq.go index 77f47d7..17c4879 100644 --- a/internal/router/dnsmasq.go +++ b/internal/router/dnsmasq.go @@ -49,7 +49,7 @@ func dnsMasqConf() (string, error) { var sb strings.Builder var tmplText string switch Name() { - case DDWrt, OpenWrt, Ubios, Synology: + case DDWrt, OpenWrt, Ubios, Synology, Tomato: tmplText = dnsMasqConfigContentTmpl case Merlin: tmplText = merlinDNSMasqPostConfTmpl @@ -65,3 +65,21 @@ func dnsMasqConf() (string, error) { } return sb.String(), nil } + +func restartDNSMasq() error { + switch Name() { + case DDWrt: + return ddwrtRestartDNSMasq() + case Merlin: + return merlinRestartDNSMasq() + case OpenWrt: + return openwrtRestartDNSMasq() + case Ubios: + return ubiosRestartDNSMasq() + case Synology: + return synologyRestartDNSMasq() + case Tomato: + return tomatoRestartService(tomatoDNSMasqSvcName) + } + panic("not supported platform") +} diff --git a/internal/router/merlin.go b/internal/router/merlin.go index ca739cb..aab05e7 100644 --- a/internal/router/merlin.go +++ b/internal/router/merlin.go @@ -2,11 +2,16 @@ package router import ( "bytes" + "context" + "errors" "fmt" "os" "os/exec" "strings" + "time" "unicode" + + "tailscale.com/logtail/backoff" ) func setupMerlin() error { @@ -35,11 +40,11 @@ func setupMerlin() error { return err } // Restart dnsmasq service. - if err := merlinRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } - if err := nvramSetup(nvramKV()); err != nil { + if err := nvramSetKV(nvramSetupKV(), nvramCtrldSetupKey); err != nil { return err } @@ -48,7 +53,7 @@ func setupMerlin() error { func cleanupMerlin() error { // Restore old configs. - if err := nvramRestore(nvramKV()); err != nil { + if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { return err } buf, err := os.ReadFile(merlinDNSMasqPostConfPath) @@ -60,7 +65,7 @@ func cleanupMerlin() error { return err } // Restart dnsmasq service. - if err := merlinRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -87,3 +92,43 @@ func merlinParsePostConf(buf []byte) []byte { } return buf } + +func merlinPreStart() (err error) { + pidFile := "/tmp/ctrld.pid" + + // Remove pid file and trigger dnsmasq restart, so NTP can resolve + // server name and perform time synchronization. + pid, err := os.ReadFile(pidFile) + if err != nil { + return fmt.Errorf("PreStart: os.Readfile: %w", err) + } + if err := os.Remove(pidFile); err != nil { + return fmt.Errorf("PreStart: os.Remove: %w", err) + } + defer func() { + if werr := os.WriteFile(pidFile, pid, 0600); werr != nil { + err = errors.Join(err, werr) + return + } + if rerr := restartDNSMasq(); rerr != nil { + err = errors.Join(err, rerr) + return + } + }() + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("PreStart: restartDNSMasqFn: %w", err) + } + + // Wait until `ntp_ready=1` set. + b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) + for { + out, err := nvram("get", "ntp_ready") + if err != nil { + return fmt.Errorf("PreStart: nvram: %w", err) + } + if out == "1" { + return nil + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } +} diff --git a/internal/router/nvram.go b/internal/router/nvram.go index b66fcdb..de3400e 100644 --- a/internal/router/nvram.go +++ b/internal/router/nvram.go @@ -26,7 +26,7 @@ NOTE: +https://community.ui.com/questions/Implement-DNSSEC-into-UniFi/951c72b0-4d88-4c86-9174-45417bd2f9ca +https://community.ui.com/questions/Enable-DNSSEC-for-Unifi-Dream-Machine-FW-updates/e68e367c-d09b-4459-9444-18908f7c1ea1 */ -func nvramKV() map[string]string { +func nvramSetupKV() map[string]string { switch Name() { case DDWrt: return map[string]string{ @@ -39,11 +39,28 @@ func nvramKV() map[string]string { return map[string]string{ "dnspriv_enable": "0", // Ensure Merlin native DoT disabled. } + case Tomato: + return map[string]string{ + "dnsmasq_custom": "", // Configuration of dnsmasq set by ctrld, filled by setupTomato. + "dnscrypt_proxy": "0", // Disable DNSCrypt. + "dnssec_enable": "0", // Disable DNSSEC. + "stubby_proxy": "0", // Disable Stubby + } } return nil } -func nvramSetup(m map[string]string) error { +func nvramInstallKV() map[string]string { + switch Name() { + case Tomato: + return map[string]string{ + tomatoNvramScriptWanupKey: "", // script to start ctrld, filled by tomatoSvc.Install method. + } + } + return nil +} + +func nvramSetKV(m map[string]string, setupKey string) error { // Backup current value, store ctrld's configs. for key, value := range m { old, err := nvram("get", key) @@ -58,7 +75,7 @@ func nvramSetup(m map[string]string) error { } } - if out, err := nvram("set", nvramCtrldSetupKey+"=1"); err != nil { + if out, err := nvram("set", setupKey+"=1"); err != nil { return fmt.Errorf("%s: %w", out, err) } // Commit. @@ -68,7 +85,7 @@ func nvramSetup(m map[string]string) error { return nil } -func nvramRestore(m map[string]string) error { +func nvramRestore(m map[string]string, setupKey string) error { // Restore old configs. for key := range m { ctrldKey := nvramCtrldKeyPrefix + key @@ -82,7 +99,7 @@ func nvramRestore(m map[string]string) error { } } - if out, err := nvram("unset", "ctrld_setup"); err != nil { + if out, err := nvram("unset", setupKey); err != nil { return fmt.Errorf("%s: %w", out, err) } // Commit. diff --git a/internal/router/openwrt.go b/internal/router/openwrt.go index 701913b..0fbe5f2 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt.go @@ -40,7 +40,7 @@ func setupOpenWrt() error { return err } // Restart dnsmasq service. - if err := openwrtRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -52,7 +52,7 @@ func cleanupOpenWrt() error { return err } // Restart dnsmasq service. - if err := openwrtRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil diff --git a/internal/router/router.go b/internal/router/router.go index 6b33a4c..aea6202 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,18 +2,14 @@ package router import ( "bytes" - "context" "errors" - "fmt" "os" "os/exec" "sync" "sync/atomic" - "time" "github.com/fsnotify/fsnotify" "github.com/kardianos/service" - "tailscale.com/logtail/backoff" "github.com/Control-D-Inc/ctrld" ) @@ -24,6 +20,7 @@ const ( Merlin = "merlin" Ubios = "ubios" Synology = "synology" + Tomato = "tomato" ) // ErrNotSupported reports the current router is not supported error. @@ -38,9 +35,18 @@ type router struct { watcher *fsnotify.Watcher } +// IsSupported reports whether the given platform is supported by ctrld. +func IsSupported(platform string) bool { + switch platform { + case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: + return true + } + return false +} + // SupportedPlatforms return all platforms that can be configured to run with ctrld. func SupportedPlatforms() []string { - return []string{DDWrt, Merlin, OpenWrt, Ubios, Synology} + return []string{DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato} } var configureFunc = map[string]func() error{ @@ -49,13 +55,14 @@ var configureFunc = map[string]func() error{ OpenWrt: setupOpenWrt, Ubios: setupUbiOS, Synology: setupSynology, + Tomato: setupTomato, } // Configure configures things for running ctrld on the router. func Configure(c *ctrld.Config) error { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios, Synology: + case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true @@ -90,55 +97,22 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case Merlin, Ubios, Synology: + case Merlin, Ubios, Synology, Tomato: } return nil } // PreStart blocks until the router is ready for running ctrld. func PreStart() (err error) { - if Name() != Merlin { + // On some routers, NTP may out of sync, so waiting for it to be ready. + switch Name() { + case Merlin: + return merlinPreStart() + case Tomato: + return tomatoPreStart() + default: return nil } - - pidFile := "/tmp/ctrld.pid" - // On Merlin, NTP may out of sync, so waiting for it to be ready. - // - // Remove pid file and trigger dnsmasq restart, so NTP can resolve - // server name and perform time synchronization. - pid, err := os.ReadFile(pidFile) - if err != nil { - return fmt.Errorf("PreStart: os.Readfile: %w", err) - } - if err := os.Remove(pidFile); err != nil { - return fmt.Errorf("PreStart: os.Remove: %w", err) - } - defer func() { - if werr := os.WriteFile(pidFile, pid, 0600); werr != nil { - err = errors.Join(err, werr) - return - } - if rerr := merlinRestartDNSMasq(); rerr != nil { - err = errors.Join(err, rerr) - return - } - }() - if err := merlinRestartDNSMasq(); err != nil { - return fmt.Errorf("PreStart: merlinRestartDNSMasq: %w", err) - } - - // Wait until `ntp_read=1` set. - b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) - for { - out, err := nvram("get", "ntp_ready") - if err != nil { - return fmt.Errorf("PreStart: nvram: %w", err) - } - if out == "1" { - return nil - } - b.BackOff(context.Background(), errors.New("ntp not ready")) - } } // PostInstall performs task after installing ctrld on router. @@ -155,6 +129,8 @@ func PostInstall() error { return postInstallUbiOS() case Synology: return postInstallSynology() + case Tomato: + return postInstallTomato() } return nil } @@ -173,6 +149,8 @@ func Cleanup() error { return cleanupUbiOS() case Synology: return cleanupSynology() + case Tomato: + return cleanupTomato() } return nil } @@ -181,7 +159,7 @@ func Cleanup() error { func ListenAddress() string { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios, Synology: + case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: return "127.0.0.1:5354" } return "" @@ -210,6 +188,8 @@ func distroName() string { return Ubios case bytes.HasPrefix(unameU(), []byte("synology")): return Synology + case bytes.HasPrefix(unameO(), []byte("Tomato")): + return Tomato } return "" } diff --git a/internal/router/service.go b/internal/router/service.go index e404a2d..d9476e9 100644 --- a/internal/router/service.go +++ b/internal/router/service.go @@ -48,6 +48,15 @@ func init() { }, new: newUbiosService, }, + &linuxSystemService{ + name: "tomato", + detect: func() bool { return Name() == Tomato }, + interactive: func() bool { + is, _ := isInteractive() + return is + }, + new: newTomatoService, + }, } systems = append(systems, service.AvailableSystems()...) service.ChooseSystem(systems...) diff --git a/internal/router/service_tomato.go b/internal/router/service_tomato.go new file mode 100644 index 0000000..8bfcf73 --- /dev/null +++ b/internal/router/service_tomato.go @@ -0,0 +1,278 @@ +package router + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "text/template" + + "github.com/kardianos/service" +) + +const tomatoNvramScriptWanupKey = "script_wanup" + +type tomatoSvc struct { + i service.Interface + platform string + *service.Config +} + +func newTomatoService(i service.Interface, platform string, c *service.Config) (service.Service, error) { + s := &tomatoSvc{ + i: i, + platform: platform, + Config: c, + } + return s, nil +} + +func (s *tomatoSvc) String() string { + if len(s.DisplayName) > 0 { + return s.DisplayName + } + return s.Name +} + +func (s *tomatoSvc) Platform() string { + return s.platform +} + +func (s *tomatoSvc) configPath() string { + path, err := os.Executable() + if err != nil { + return "" + } + return path + ".startup" +} + +func (s *tomatoSvc) template() *template.Template { + return template.Must(template.New("").Parse(tomatoSvcScript)) +} + +func (s *tomatoSvc) Install() 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("set", "jffs2_on=1"); err != nil { + return err + } + if _, err := nvram("commit"); err != nil { + return err + } + + confPath := s.configPath() + if _, err := os.Stat(confPath); err == nil { + return fmt.Errorf("already installed: %s", confPath) + } + + var to = &struct { + *service.Config + Path string + }{ + s.Config, + exePath, + } + + f, err := os.Create(confPath) + if err != nil { + return fmt.Errorf("os.Create: %w", err) + } + defer f.Close() + + if err := s.template().Execute(f, to); err != nil { + return fmt.Errorf("s.template.Execute: %w", err) + } + + if err = os.Chmod(confPath, 0755); err != nil { + return fmt.Errorf("os.Chmod: startup script: %w", err) + } + + nvramKvMap := nvramInstallKV() + old, err := nvram("get", tomatoNvramScriptWanupKey) + if err != nil { + return fmt.Errorf("nvram: %w", err) + } + nvramKvMap[tomatoNvramScriptWanupKey] = strings.Join([]string{old, s.configPath() + " start"}, "\n") + if err := nvramSetKV(nvramKvMap, nvramCtrldInstallKey); err != nil { + return err + } + return nil +} + +func (s *tomatoSvc) Uninstall() error { + if err := os.Remove(s.configPath()); err != nil { + return fmt.Errorf("os.Remove: %w", err) + } + // Restore old configs. + if err := nvramRestore(nvramInstallKV(), nvramCtrldInstallKey); err != nil { + return err + } + return nil +} + +func (s *tomatoSvc) Logger(errs chan<- error) (service.Logger, error) { + if service.Interactive() { + return service.ConsoleLogger, nil + } + return s.SystemLogger(errs) +} + +func (s *tomatoSvc) SystemLogger(errs chan<- error) (service.Logger, error) { + return newSysLogger(s.Name, errs) +} + +func (s *tomatoSvc) Run() (err error) { + err = s.i.Start(s) + if err != nil { + return err + } + + if interactice, _ := isInteractive(); !interactice { + signal.Ignore(syscall.SIGHUP) + } + + var sigChan = make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, os.Interrupt) + <-sigChan + + return s.i.Stop(s) +} + +func (s *tomatoSvc) 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 { + return service.StatusUnknown, err + } + switch string(bytes.TrimSpace(out)) { + case "running": + return service.StatusRunning, nil + default: + return service.StatusStopped, nil + } +} + +func (s *tomatoSvc) Start() error { + return exec.Command(s.configPath(), "start").Run() +} + +func (s *tomatoSvc) Stop() error { + return exec.Command(s.configPath(), "stop").Run() +} + +func (s *tomatoSvc) Restart() error { + return exec.Command(s.configPath(), "restart").Run() +} + +// https://wiki.freshtomato.org/doku.php/freshtomato_zerotier?s[]=%2Aservice%2A +const tomatoSvcScript = `#!/bin/sh + + +NAME="{{.Name}}" +CMD="{{.Path}}{{range .Arguments}} {{.}}{{end}}" +LOG_FILE="/var/log/${NAME}.log" +PID_FILE="/tmp/$NAME.pid" + + +alias elog="logger -t $NAME -s" + + +COND=$1 +[ $# -eq 0 ] && COND="start" + +get_pid() { + cat "$PID_FILE" +} + +is_running() { + [ -f "$PID_FILE" ] && ps | grep -q "^ *$(get_pid) " +} + +start() { + if is_running; then + elog "$NAME is already running." + exit 1 + fi + elog "Starting $NAME Services: " + $CMD & + echo $! > "$PID_FILE" + chmod 600 "$PID_FILE" + if is_running; then + elog "succeeded." + else + elog "failed." + fi +} + + +stop() { + if ! is_running; then + elog "$NAME is not running." + exit 1 + fi + elog "Shutting down $NAME Services: " + kill -SIGTERM "$(get_pid)" + for _ in 1 2 3 4 5; do + if ! is_running; then + if [ -f "$pid_file" ]; then + rm "$pid_file" + fi + exit 0 + fi + printf "." + sleep 2 + done + if ! is_running; then + elog "succeeded." + else + elog "failed." + fi +} + + +do_restart() { + stop + start +} + + +do_status() { + if ! is_running; then + echo "stopped" + else + echo "running" + fi +} + + +case "$COND" in +start) + start + ;; +stop) + stop + ;; +restart) + do_restart + ;; +status) + do_status + ;; +*) + elog "Usage: $0 (start|stop|restart|status)" + ;; +esac +exit 0 +` diff --git a/internal/router/synology.go b/internal/router/synology.go index b221be8..8c1d1d6 100644 --- a/internal/router/synology.go +++ b/internal/router/synology.go @@ -22,7 +22,7 @@ func setupSynology() error { if err := os.WriteFile(synologyDhcpdInfoPath, []byte(`enable="yes"`), 0600); err != nil { return err } - if err := synologyRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -37,7 +37,7 @@ func cleanupSynology() error { } // Restart dnsmasq service. - if err := synologyRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil diff --git a/internal/router/tomato.go b/internal/router/tomato.go new file mode 100644 index 0000000..50f13ba --- /dev/null +++ b/internal/router/tomato.go @@ -0,0 +1,108 @@ +package router + +import ( + "context" + "errors" + "fmt" + "os/exec" + "time" + + "tailscale.com/logtail/backoff" +) + +const ( + tomatoDnsCryptProxySvcName = "dnscrypt-proxy" + tomatoStubbySvcName = "stubby" + tomatoDNSMasqSvcName = "dnsmasq" +) + +func setupTomato() error { + // Already setup. + if val, _ := nvram("get", nvramCtrldSetupKey); val == "1" { + return nil + } + + data, err := dnsMasqConf() + if err != nil { + return err + } + + nvramKvMap := nvramSetupKV() + nvramKvMap["dnsmasq_custom"] = data + if err := nvramSetKV(nvramKvMap, nvramCtrldSetupKey); err != nil { + return err + } + + // Restart dnscrypt-proxy service. + if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { + return err + } + // Restart stubby service. + if err := tomatoRestartService(tomatoStubbySvcName); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func postInstallTomato() error { + return nil +} + +func cleanupTomato() error { + // Restore old configs. + if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { + return err + } + // Restart dnscrypt-proxy service. + if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { + return err + } + // Restart stubby service. + if err := tomatoRestartService(tomatoStubbySvcName); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func tomatoPreStart() (err error) { + // cleanup to trigger dnsmasq restart, so NTP can resolve + // server name and perform time synchronization. + if err = cleanupTomato(); err != nil { + return err + } + + // Wait until `ntp_ready=1` set. + b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) + for { + out, err := nvram("get", "ntp_ready") + if err != nil { + return fmt.Errorf("PreStart: nvram: %w", err) + } + if out == "1" { + return nil + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } +} + +func tomatoRestartService(name string) error { + return tomatoRestartServiceWithKill(name, false) +} + +func tomatoRestartServiceWithKill(name string, killBeforeRestart bool) error { + if killBeforeRestart { + _, _ = exec.Command("killall", name).CombinedOutput() + } + if out, err := exec.Command("service", name, "restart").CombinedOutput(); err != nil { + return fmt.Errorf("service restart %s: %s, %w", name, string(out), err) + } + return nil +} diff --git a/internal/router/ubios.go b/internal/router/ubios.go index 80fe04b..8be261f 100644 --- a/internal/router/ubios.go +++ b/internal/router/ubios.go @@ -20,7 +20,7 @@ func setupUbiOS() error { return err } // Restart dnsmasq service. - if err := ubiosRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -32,7 +32,7 @@ func cleanupUbiOS() error { return err } // Restart dnsmasq service. - if err := ubiosRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil From 54e63ccf9b33cc1610a560a0ba808c44882368c3 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 25 May 2023 22:05:39 +0700 Subject: [PATCH 08/21] all: add support for EdgeOS --- cmd/ctrld/prog.go | 5 +- cmd/ctrld/prog_linux.go | 9 ++++ internal/router/client_info.go | 84 ++++++++++++++++++++++++----- internal/router/client_info_test.go | 43 ++++++++++++--- internal/router/dnsmasq.go | 4 +- internal/router/edgeos.go | 48 +++++++++++++++++ internal/router/router.go | 34 +++++++----- 7 files changed, 194 insertions(+), 33 deletions(-) create mode 100644 internal/router/edgeos.go diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index 1c59530..807d22c 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -175,7 +175,10 @@ func (p *prog) setDNS() { switch router.Name() { case router.DDWrt, router.OpenWrt, router.Ubios: // On router, ctrld run as a DNS forwarder, it does not have to change system DNS. - // Except for Merlin/Tomato, which has WAN DNS setup on boot for NTP. + // Except for: + // + EdgeOS, which /etc/resolv.conf could be managed by vyatta_update_resolv.pl script. + // + Merlin/Tomato, which has WAN DNS setup on boot for NTP. + // + Synology, which /etc/resolv.conf is not configured to point to localhost. return } if cfg.Listener == nil || cfg.Listener["0"] == nil { diff --git a/cmd/ctrld/prog_linux.go b/cmd/ctrld/prog_linux.go index 5cc5d6f..0b49a33 100644 --- a/cmd/ctrld/prog_linux.go +++ b/cmd/ctrld/prog_linux.go @@ -2,6 +2,8 @@ package main import ( "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router" ) func (p *prog) preRun() { @@ -17,6 +19,13 @@ func setDependencies(svc *service.Config) { "Wants=NetworkManager-wait-online.service", "After=NetworkManager-wait-online.service", } + // On EdeOS, ctrld needs to start after vyatta-dhcpd, so it can read leases file. + if router.Name() == router.EdgeOS { + svc.Dependencies = append(svc.Dependencies, "Wants=vyatta-dhcpd.service") + svc.Dependencies = append(svc.Dependencies, "After=vyatta-dhcpd.service") + svc.Dependencies = append(svc.Dependencies, "Wants=dnsmasq.service") + svc.Dependencies = append(svc.Dependencies, "After=dnsmasq.service") + } } func setWorkingDirectory(svc *service.Config, dir string) { diff --git a/internal/router/client_info.go b/internal/router/client_info.go index 04af7ac..db6294f 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -1,6 +1,7 @@ package router import ( + "bufio" "bytes" "io" "log" @@ -15,14 +16,18 @@ import ( "github.com/Control-D-Inc/ctrld" ) -var clientInfoFiles = []string{ - "/tmp/dnsmasq.leases", // ddwrt - "/tmp/dhcp.leases", // openwrt - "/var/lib/misc/dnsmasq.leases", // merlin - "/mnt/data/udapi-config/dnsmasq.lease", // UDM Pro - "/data/udapi-config/dnsmasq.lease", // UDR - "/etc/dhcpd/dhcpd-leases.log", // Synology - "/tmp/var/lib/misc/dnsmasq.leases", // Tomato +type readClientInfoFunc func(name string) error + +var clientInfoFiles = map[string]readClientInfoFunc{ + "/tmp/dnsmasq.leases": dnsmasqReadClientInfoFile, // ddwrt + "/tmp/dhcp.leases": dnsmasqReadClientInfoFile, // openwrt + "/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // merlin + "/mnt/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDM Pro + "/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDR + "/etc/dhcpd/dhcpd-leases.log": dnsmasqReadClientInfoFile, // Synology + "/tmp/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // Tomato + "/run/dnsmasq-dhcp.leases": dnsmasqReadClientInfoFile, // EdgeOS + "/run/dhcpd.leases": iscDHCPReadClientInfoFile, // EdgeOS } func (r *router) watchClientInfoTable() { @@ -34,14 +39,19 @@ func (r *router) watchClientInfoTable() { select { case <-timer.C: for _, name := range r.watcher.WatchList() { - _ = readClientInfoFile(name) + _ = clientInfoFiles[name](name) } case event, ok := <-r.watcher.Events: if !ok { return } if event.Has(fsnotify.Write) { - if err := readClientInfoFile(event.Name); err != nil && !os.IsNotExist(err) { + readFunc := clientInfoFiles[event.Name] + if readFunc == nil { + log.Println("unknown file format:", event.Name) + continue + } + if err := readFunc(event.Name); err != nil && !os.IsNotExist(err) { log.Println("could not read client info file:", err) } } @@ -80,17 +90,17 @@ func GetClientInfoByMac(mac string) *ctrld.ClientInfo { return val.(*ctrld.ClientInfo) } -func readClientInfoFile(name string) error { +func dnsmasqReadClientInfoFile(name string) error { f, err := os.Open(name) if err != nil { return err } defer f.Close() - return readClientInfoReader(f) + return dnsmasqReadClientInfoReader(f) } -func readClientInfoReader(reader io.Reader) error { +func dnsmasqReadClientInfoReader(reader io.Reader) error { r := routerPlatform.Load() return lineread.Reader(reader, func(line []byte) error { fields := bytes.Fields(line) @@ -113,6 +123,54 @@ func readClientInfoReader(reader io.Reader) error { }) } +func iscDHCPReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return iscDHCPReadClientInfoReader(f) +} + +func iscDHCPReadClientInfoReader(reader io.Reader) error { + r := routerPlatform.Load() + s := bufio.NewScanner(reader) + var ip, mac, hostname string + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "}") { + if mac != "" { + r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) + ip, mac, hostname = "", "", "" + } + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "lease": + ip = normalizeIP(strings.ToLower(fields[1])) + if net.ParseIP(ip) == nil { + log.Printf("invalid ip address entry: %q", ip) + ip = "" + } + case "hardware": + if len(fields) >= 3 { + mac = strings.ToLower(strings.TrimRight(fields[2], ";")) + if _, err := net.ParseMAC(mac); err != nil { + // Invalid mac, skip. + mac = "" + } + } + case "client-hostname": + hostname = strings.Trim(fields[1], `";`) + } + } + return nil +} + func normalizeIP(in string) string { // dnsmasq may put ip with interface index in lease file, strip it here. ip, _, found := strings.Cut(in, "%") diff --git a/internal/router/client_info_test.go b/internal/router/client_info_test.go index 2b228c2..5ea8b65 100644 --- a/internal/router/client_info_test.go +++ b/internal/router/client_info_test.go @@ -1,6 +1,7 @@ package router import ( + "io" "strings" "testing" @@ -31,31 +32,59 @@ func Test_normalizeIP(t *testing.T) { func Test_readClientInfoReader(t *testing.T) { tests := []struct { - name string - in string - mac string + name string + in string + readFunc func(r io.Reader) error + mac string }{ { - "good", + "good dnsmasq", `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 * 01:e6:20:59:b8:c1:6d `, + dnsmasqReadClientInfoReader, "e6:20:59:b8:c1:6d", }, { - "bad seen on UDMdream machine", + "bad dnsmasq seen on UDMdream machine", `1683329857 e6:20:59:b8:c1:6e 192.168.1.111 * 01:e6:20:59:b8:c1:6e duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c 1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 `, + dnsmasqReadClientInfoReader, "e6:20:59:b8:c1:6e", }, + { + "isc-dhcpd good", + `lease 192.168.1.1 { + hardware ethernet 00:00:00:00:00:01; + client-hostname "host-1"; +} +`, + iscDHCPReadClientInfoReader, + "00:00:00:00:00:01", + }, + { + "isc-dhcpd bad mac", + `lease 192.168.1.1 { + hardware ethernet invalid-mac; + client-hostname "host-1"; +} + +lease 192.168.1.2 { + hardware ethernet 00:00:00:00:00:02; + client-hostname "host-2"; +} +`, + iscDHCPReadClientInfoReader, + "00:00:00:00:00:02", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { r := routerPlatform.Load() r.mac.Delete(tc.mac) - if err := readClientInfoReader(strings.NewReader(tc.in)); err != nil { + if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { t.Errorf("readClientInfoReader() error = %v", err) } info, existed := r.mac.Load(tc.mac) @@ -64,6 +93,8 @@ duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c } if ci, ok := info.(*ctrld.ClientInfo); ok && existed && ci.Mac != tc.mac { t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac) + } else { + t.Log(ci) } }) } diff --git a/internal/router/dnsmasq.go b/internal/router/dnsmasq.go index 17c4879..4d43d20 100644 --- a/internal/router/dnsmasq.go +++ b/internal/router/dnsmasq.go @@ -49,7 +49,7 @@ func dnsMasqConf() (string, error) { var sb strings.Builder var tmplText string switch Name() { - case DDWrt, OpenWrt, Ubios, Synology, Tomato: + case EdgeOS, DDWrt, OpenWrt, Ubios, Synology, Tomato: tmplText = dnsMasqConfigContentTmpl case Merlin: tmplText = merlinDNSMasqPostConfTmpl @@ -68,6 +68,8 @@ func dnsMasqConf() (string, error) { func restartDNSMasq() error { switch Name() { + case EdgeOS: + return edgeOSRestartDNSMasq() case DDWrt: return ddwrtRestartDNSMasq() case Merlin: diff --git a/internal/router/edgeos.go b/internal/router/edgeos.go new file mode 100644 index 0000000..4a8e57e --- /dev/null +++ b/internal/router/edgeos.go @@ -0,0 +1,48 @@ +package router + +import ( + "fmt" + "os" + "os/exec" +) + +const edgeOSDNSMasqConfigPath = "/etc/dnsmasq.d/dnsmasq-zzz-ctrld.conf" + +func setupEdgeOS() error { + // Disable dnsmasq as DNS server. + dnsMasqConfigContent, err := dnsMasqConf() + if err != nil { + return err + } + if err := os.WriteFile(edgeOSDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func cleanupEdgeOS() error { + // Remove the custom dnsmasq config + if err := os.Remove(edgeOSDNSMasqConfigPath); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func postInstallEdgeOS() error { + return nil +} + +func edgeOSRestartDNSMasq() error { + if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil { + return fmt.Errorf("edgeosRestartDNSMasq: %s, %w", string(out), err) + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index aea6202..b5269f1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -21,6 +21,7 @@ const ( Ubios = "ubios" Synology = "synology" Tomato = "tomato" + EdgeOS = "edgeos" ) // ErrNotSupported reports the current router is not supported error. @@ -38,7 +39,7 @@ type router struct { // IsSupported reports whether the given platform is supported by ctrld. func IsSupported(platform string) bool { switch platform { - case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: + case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: return true } return false @@ -46,23 +47,24 @@ func IsSupported(platform string) bool { // SupportedPlatforms return all platforms that can be configured to run with ctrld. func SupportedPlatforms() []string { - return []string{DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato} + return []string{EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios} } var configureFunc = map[string]func() error{ + EdgeOS: setupEdgeOS, DDWrt: setupDDWrt, Merlin: setupMerlin, OpenWrt: setupOpenWrt, - Ubios: setupUbiOS, Synology: setupSynology, Tomato: setupTomato, + Ubios: setupUbiOS, } // Configure configures things for running ctrld on the router. func Configure(c *ctrld.Config) error { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: + case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true @@ -72,8 +74,8 @@ func Configure(c *ctrld.Config) error { } r.watcher = watcher go r.watchClientInfoTable() - for _, file := range clientInfoFiles { - _ = readClientInfoFile(file) + for file, readClienInfoFunc := range clientInfoFiles { + _ = readClienInfoFunc(file) _ = r.watcher.Add(file) } } @@ -97,7 +99,7 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case Merlin, Ubios, Synology, Tomato: + case EdgeOS, Merlin, Synology, Tomato, Ubios: } return nil } @@ -119,18 +121,20 @@ func PreStart() (err error) { func PostInstall() error { name := Name() switch name { + case EdgeOS: + return postInstallEdgeOS() case DDWrt: return postInstallDDWrt() case Merlin: return postInstallMerlin() case OpenWrt: return postInstallOpenWrt() - case Ubios: - return postInstallUbiOS() case Synology: return postInstallSynology() case Tomato: return postInstallTomato() + case Ubios: + return postInstallUbiOS() } return nil } @@ -139,18 +143,20 @@ func PostInstall() error { func Cleanup() error { name := Name() switch name { + case EdgeOS: + return cleanupEdgeOS() case DDWrt: return cleanupDDWrt() case Merlin: return cleanupMerlin() case OpenWrt: return cleanupOpenWrt() - case Ubios: - return cleanupUbiOS() case Synology: return cleanupSynology() case Tomato: return cleanupTomato() + case Ubios: + return cleanupUbiOS() } return nil } @@ -159,7 +165,7 @@ func Cleanup() error { func ListenAddress() string { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios, Synology, Tomato: + case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: return "127.0.0.1:5354" } return "" @@ -190,6 +196,10 @@ func distroName() string { return Synology case bytes.HasPrefix(unameO(), []byte("Tomato")): return Tomato + case haveDir("/config/scripts/post-config.d"): + return EdgeOS + case haveFile("/etc/ubnt/init/vyatta-router"): + return EdgeOS // For 2.x } return "" } From 8fda856e245579effa1be3c0b2f6ce7268bac800 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 26 May 2023 21:33:24 +0700 Subject: [PATCH 09/21] all: add UpstreamConfig.VerifyDomain So the self-check process is only done for ControlD upstream, and can be distinguished between .com and .dev resolvers. --- cmd/ctrld/cli.go | 21 ++++++++++++++------- cmd/ctrld/dns_proxy_test.go | 2 +- config.go | 29 ++++++++++++++++++++++++++++- config_internal_test.go | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index b9d3f3e..27e009c 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -36,8 +36,6 @@ import ( "github.com/Control-D-Inc/ctrld/internal/router" ) -const selfCheckFQDN = "verify.controld.com" - var ( version = "dev" commit = "none" @@ -289,6 +287,10 @@ func initCLI() { processCDFlags() + if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil { + mainLog.Fatal().Msgf("invalid config: %v", err) + } + // Explicitly passing config, so on system where home directory could not be obtained, // or sub-process env is different with the parent, we still behave correctly and use // the expected config file. @@ -320,7 +322,8 @@ func initCLI() { return } - status = selfCheckStatus(status) + domain := cfg.Upstream["0"].VerifyDomain() + status = selfCheckStatus(status, domain) switch status { case service.StatusRunning: mainLog.Notice().Msg("Service started") @@ -855,7 +858,11 @@ func defaultIfaceName() string { return dri } -func selfCheckStatus(status service.Status) service.Status { +func selfCheckStatus(status service.Status, domain string) service.Status { + if domain == "" { + // Nothing to do, return the status as-is. + return status + } c := new(dns.Client) bo := backoff.NewBackoff("self-check", logf, 10*time.Second) bo.LogLongerThan = 500 * time.Millisecond @@ -883,16 +890,16 @@ func selfCheckStatus(status service.Status) service.Status { } mu.Unlock() m := new(dns.Msg) - m.SetQuestion(selfCheckFQDN+".", dns.TypeA) + m.SetQuestion(domain+".", dns.TypeA) m.RecursionDesired = true r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))) if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 { - mainLog.Debug().Msgf("self-check against %q succeeded", selfCheckFQDN) + mainLog.Debug().Msgf("self-check against %q succeeded", domain) return status } bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err)) } - mainLog.Debug().Msgf("self-check against %q failed", selfCheckFQDN) + mainLog.Debug().Msgf("self-check against %q failed", domain) return service.StatusUnknown } diff --git a/cmd/ctrld/dns_proxy_test.go b/cmd/ctrld/dns_proxy_test.go index c9ff9d9..c0e8443 100644 --- a/cmd/ctrld/dns_proxy_test.go +++ b/cmd/ctrld/dns_proxy_test.go @@ -174,7 +174,7 @@ func Test_macFromMsg(t *testing.T) { t.Fatal(err) } m := new(dns.Msg) - m.SetQuestion(selfCheckFQDN+".", dns.TypeA) + m.SetQuestion("example.com.", dns.TypeA) o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} if tc.wantMac { ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw} diff --git a/config.go b/config.go index b06f09f..5f37998 100644 --- a/config.go +++ b/config.go @@ -29,9 +29,19 @@ const ( IpStackV4 = "v4" IpStackV6 = "v6" IpStackSplit = "split" + + controlDComDomain = "controld.com" + controlDNetDomain = "controld.net" + controlDDevDomain = "controld.dev" ) -var controldParentDomains = []string{"controld.com", "controld.net", "controld.dev"} +var ( + controldParentDomains = []string{controlDComDomain, controlDNetDomain, controlDDevDomain} + controldVerifiedDomain = map[string]string{ + controlDComDomain: "verify.controld.com", + controlDDevDomain: "verify.controld.dev", + } +) // SetConfigName set the config name that ctrld will look for. // DEPRECATED: use SetConfigNameWithPath instead. @@ -201,6 +211,23 @@ func (uc *UpstreamConfig) Init() { } } +// VerifyDomain returns the domain name that could be resolved by the upstream endpoint. +// It returns empty for non-ControlD upstream endpoint. +func (uc *UpstreamConfig) VerifyDomain() string { + domain := uc.Domain + if domain == "" { + if u, err := url.Parse(uc.Endpoint); err == nil { + domain = u.Hostname() + } + } + for _, parent := range controldParentDomains { + if dns.IsSubDomain(parent, domain) { + return controldVerifiedDomain[parent] + } + } + return "" +} + // UpstreamSendClientInfo reports whether the upstream is // configured to send client info to Control D DNS server. // diff --git a/config_internal_test.go b/config_internal_test.go index bf310f9..fb3692e 100644 --- a/config_internal_test.go +++ b/config_internal_test.go @@ -190,6 +190,39 @@ func TestUpstreamConfig_Init(t *testing.T) { } } +func TestUpstreamConfig_VerifyDomain(t *testing.T) { + tests := []struct { + name string + uc *UpstreamConfig + verifyDomain string + }{ + { + controlDComDomain, + &UpstreamConfig{Endpoint: "https://freedns.controld.com/p2"}, + controldVerifiedDomain[controlDComDomain], + }, + { + controlDDevDomain, + &UpstreamConfig{Endpoint: "https://freedns.controld.dev/p2"}, + controldVerifiedDomain[controlDDevDomain], + }, + { + "non-ControlD upstream", + &UpstreamConfig{Endpoint: "https://dns.google/dns-query"}, + "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.uc.VerifyDomain(); got != tc.verifyDomain { + t.Errorf("unexpected verify domain, want: %q, got: %q", tc.verifyDomain, got) + } + }) + } +} func ptrBool(b bool) *bool { return &b } From b143e46eb0ce80436331649d4d2bee097441cb96 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Mon, 29 May 2023 23:16:23 +0700 Subject: [PATCH 10/21] all: add support for pfsense --- cmd/ctrld/cli.go | 4 +- .../{cli_router_linux.go => cli_router.go} | 2 + cmd/ctrld/cli_router_others.go | 2 +- internal/router/client_info.go | 1 + internal/router/pfsense.go | 56 +++++++++++++++++++ internal/router/router.go | 27 +++++++-- 6 files changed, 83 insertions(+), 9 deletions(-) rename cmd/ctrld/{cli_router_linux.go => cli_router.go} (99%) create mode 100644 internal/router/pfsense.go diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index 27e009c..1d07863 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -312,7 +312,7 @@ func initCLI() { {s.Start, true}, } if doTasks(tasks) { - if err := router.PostInstall(); err != nil { + if err := router.PostInstall(svcConfig); err != nil { mainLog.Warn().Err(err).Msg("post installation failed, please check system/service log for details error") return } @@ -468,7 +468,7 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`, } prog.resetDNS() mainLog.Debug().Msg("Router cleanup") - if err := router.Cleanup(); err != nil { + if err := router.Cleanup(svcConfig); err != nil { mainLog.Warn().Err(err).Msg("could not cleanup router") } mainLog.Notice().Msg("Service uninstalled") diff --git a/cmd/ctrld/cli_router_linux.go b/cmd/ctrld/cli_router.go similarity index 99% rename from cmd/ctrld/cli_router_linux.go rename to cmd/ctrld/cli_router.go index b6a4ffc..3d54c1c 100644 --- a/cmd/ctrld/cli_router_linux.go +++ b/cmd/ctrld/cli_router.go @@ -1,3 +1,5 @@ +//go:build linux || freebsd + package main import ( diff --git a/cmd/ctrld/cli_router_others.go b/cmd/ctrld/cli_router_others.go index 4a7b8c7..4934b5c 100644 --- a/cmd/ctrld/cli_router_others.go +++ b/cmd/ctrld/cli_router_others.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !freebsd package main diff --git a/internal/router/client_info.go b/internal/router/client_info.go index db6294f..1548c67 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -28,6 +28,7 @@ var clientInfoFiles = map[string]readClientInfoFunc{ "/tmp/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // Tomato "/run/dnsmasq-dhcp.leases": dnsmasqReadClientInfoFile, // EdgeOS "/run/dhcpd.leases": iscDHCPReadClientInfoFile, // EdgeOS + "/var/dhcpd/var/db/dhcpd.leases": iscDHCPReadClientInfoFile, // Pfsense } func (r *router) watchClientInfoTable() { diff --git a/internal/router/pfsense.go b/internal/router/pfsense.go new file mode 100644 index 0000000..bae3dc2 --- /dev/null +++ b/internal/router/pfsense.go @@ -0,0 +1,56 @@ +package router + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/kardianos/service" +) + +const ( + rcPath = "/usr/local/etc/rc.d" + unboundRcPath = rcPath + "/unbound" +) + +func setupPfsense() error { + // If Pfsense is in DNS Resolver mode, ensure no unbound processes running. + if _, err := exec.Command("service", "unbound", "onestatus").CombinedOutput(); err == nil { + if out, err := exec.Command("killall", "unbound").CombinedOutput(); err != nil { + return fmt.Errorf("could not killall unbound: %s: %w", string(out), err) + } + } + // If Pfsense is in DNS Forwarder mode, ensure no dnsmasq processes running. + if _, err := exec.Command("service", "dnsmasq", "onestatus").CombinedOutput(); err == nil { + if out, err := exec.Command("killall", "dnsmasq").CombinedOutput(); err != nil { + return fmt.Errorf("could not killall unbound: %s: %w", string(out), err) + } + } + return nil +} + +func cleanupPfsense(svc *service.Config) error { + if err := os.Remove(filepath.Join(rcPath, svc.Name+".sh")); err != nil { + return fmt.Errorf("os.Remove: %w", err) + } + if out, err := exec.Command(unboundRcPath, "onerestart").CombinedOutput(); err != nil { + return fmt.Errorf("could not restart unbound: %s: %w", string(out), err) + } + if out, err := exec.Command(unboundRcPath, "onerestart").CombinedOutput(); err != nil { + return fmt.Errorf("could not restart unbound: %s: %w", string(out), err) + } + return nil +} + +func postInstallPfsense(svc *service.Config) error { + // pfsense need ".sh" extension for script to be run at boot. + // See: https://docs.netgate.com/pfsense/en/latest/development/boot-commands.html#shell-script-option + oldname := filepath.Join(rcPath, svc.Name) + newname := filepath.Join(rcPath, svc.Name+".sh") + _ = os.Remove(newname) + if err := os.Symlink(oldname, newname); err != nil { + return fmt.Errorf("os.Symlink: %w", err) + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index b5269f1..81246e4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,6 +22,7 @@ const ( Synology = "synology" Tomato = "tomato" EdgeOS = "edgeos" + Pfsense = "pfsense" ) // ErrNotSupported reports the current router is not supported error. @@ -39,7 +40,7 @@ type router struct { // IsSupported reports whether the given platform is supported by ctrld. func IsSupported(platform string) bool { switch platform { - case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: + case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: return true } return false @@ -47,7 +48,7 @@ func IsSupported(platform string) bool { // SupportedPlatforms return all platforms that can be configured to run with ctrld. func SupportedPlatforms() []string { - return []string{EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios} + return []string{EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios} } var configureFunc = map[string]func() error{ @@ -55,6 +56,7 @@ var configureFunc = map[string]func() error{ DDWrt: setupDDWrt, Merlin: setupMerlin, OpenWrt: setupOpenWrt, + Pfsense: setupPfsense, Synology: setupSynology, Tomato: setupTomato, Ubios: setupUbiOS, @@ -64,7 +66,7 @@ var configureFunc = map[string]func() error{ func Configure(c *ctrld.Config) error { name := Name() switch name { - case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: + case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true @@ -99,7 +101,7 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case EdgeOS, Merlin, Synology, Tomato, Ubios: + case EdgeOS, Merlin, Pfsense, Synology, Tomato, Ubios: } return nil } @@ -118,7 +120,7 @@ func PreStart() (err error) { } // PostInstall performs task after installing ctrld on router. -func PostInstall() error { +func PostInstall(svc *service.Config) error { name := Name() switch name { case EdgeOS: @@ -129,6 +131,8 @@ func PostInstall() error { return postInstallMerlin() case OpenWrt: return postInstallOpenWrt() + case Pfsense: + return postInstallPfsense(svc) case Synology: return postInstallSynology() case Tomato: @@ -140,7 +144,7 @@ func PostInstall() error { } // Cleanup cleans ctrld setup on the router. -func Cleanup() error { +func Cleanup(svc *service.Config) error { name := Name() switch name { case EdgeOS: @@ -151,6 +155,8 @@ func Cleanup() error { return cleanupMerlin() case OpenWrt: return cleanupOpenWrt() + case Pfsense: + return cleanupPfsense(svc) case Synology: return cleanupSynology() case Tomato: @@ -167,6 +173,8 @@ func ListenAddress() string { switch name { case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: return "127.0.0.1:5354" + case Pfsense: + // On pfsense, we run ctrld as DNS resolver. } return "" } @@ -200,6 +208,8 @@ func distroName() string { return EdgeOS case haveFile("/etc/ubnt/init/vyatta-router"): return EdgeOS // For 2.x + case isPfsense(): + return Pfsense } return "" } @@ -223,3 +233,8 @@ func unameU() []byte { out, _ := exec.Command("uname", "-u").Output() return out } + +func isPfsense() bool { + b, err := os.ReadFile("/etc/platform") + return err == nil && bytes.HasPrefix(b, []byte("pfSense")) +} From 2d950eecdfd3652a33bfe239d7ab6b1aba797ba7 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 30 May 2023 00:52:04 +0700 Subject: [PATCH 11/21] cmd/ctrld: spoofing client IP on routers --- cmd/ctrld/dns_proxy.go | 17 +++++++++++++++-- cmd/ctrld/dns_proxy_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index ac9af50..a7602f0 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -50,11 +50,12 @@ func (p *prog) serveDNS(listenerNum string) error { q := m.Question[0] domain := canonicalName(q.Name) reqId := requestID() - fmtSrcToDest := fmtRemoteToLocal(listenerNum, w.RemoteAddr().String(), w.LocalAddr().String()) + remoteAddr := spoofRemoteAddr(w.RemoteAddr(), router.GetClientInfoByMac(macFromMsg(m))) + fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String()) t := time.Now() ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) ctrld.Log(ctx, mainLog.Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain) - upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, w.RemoteAddr(), domain) + upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, domain) var answer *dns.Msg if !matched && listenerConfig.Restricted { answer = new(dns.Msg) @@ -418,6 +419,18 @@ func macFromMsg(msg *dns.Msg) string { return "" } +func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr { + if ci != nil && ci.IP != "" { + switch addr := addr.(type) { + case *net.UDPAddr: + addr.IP = net.ParseIP(ci.IP) + case *net.TCPAddr: + addr.IP = net.ParseIP(ci.IP) + } + } + return addr +} + // runDNSServer starts a DNS server for given address and network, // with the given handler. It ensures the server has started listening. // Any error will be reported to the caller via returned channel. diff --git a/cmd/ctrld/dns_proxy_test.go b/cmd/ctrld/dns_proxy_test.go index c0e8443..2d29bc3 100644 --- a/cmd/ctrld/dns_proxy_test.go +++ b/cmd/ctrld/dns_proxy_test.go @@ -191,3 +191,28 @@ func Test_macFromMsg(t *testing.T) { }) } } + +func Test_remoteAddrFromMsg(t *testing.T) { + loopbackIP := net.ParseIP("127.0.0.1") + tests := []struct { + name string + addr net.Addr + ci *ctrld.ClientInfo + want string + }{ + {"tcp", &net.TCPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.10"}, "192.168.1.10:12345"}, + {"udp", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.11"}, "192.168.1.11:12345"}, + {"nil client info", &net.UDPAddr{IP: loopbackIP, Port: 12345}, nil, "127.0.0.1:12345"}, + {"empty ip", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{}, "127.0.0.1:12345"}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + addr := spoofRemoteAddr(tc.addr, tc.ci) + if addr.String() != tc.want { + t.Errorf("unexpected result, want: %q, got: %q", tc.want, addr.String()) + } + }) + } +} From 1cd54a48e95e753041506cdd0ec8dc369737be75 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 30 May 2023 18:17:48 +0700 Subject: [PATCH 12/21] all: rework routers ntp waiting mechanism Currently, on routers that require NTP waiting, ctrld makes the cleanup process, and restart dnsmasq for restoring default DNS config, so ntpd can query the NTP servers. It did work, but the code will depends on router platforms. Instead, we can spawn a plain DNS listener before PreRun on routers, this listener will serve NTP dns queries and once ntp is configured, the listener is terminated and ctrld will start serving using its configured upstreams. While at it, also fix the userHomeDir function on freshtomato, which must return the binary directory for routers that requires JFFS. --- cmd/ctrld/cli.go | 8 +++++-- cmd/ctrld/dns_proxy.go | 42 ++++++++++++++++++++++++++++++++++++ internal/router/merlin.go | 45 --------------------------------------- internal/router/router.go | 25 ++++++++++++++++------ internal/router/tomato.go | 26 ---------------------- resolver.go | 14 ++++++++++++ 6 files changed, 81 insertions(+), 79 deletions(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index 1d07863..0e2b642 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -165,9 +165,13 @@ func initCLI() { initLogging() if setupRouter { - if err := router.PreStart(); err != nil { + s, _ := runDNSServerForNTPD() + if err := router.PreRun(); err != nil { mainLog.Fatal().Err(err).Msg("failed to perform router pre-start check") } + if err := s.Shutdown(); err != nil { + mainLog.Fatal().Err(err).Msg("failed to shutdown dns server for ntpd") + } } processCDFlags() @@ -909,7 +913,7 @@ func unsupportedPlatformHelp(cmd *cobra.Command) { func userHomeDir() (string, error) { switch router.Name() { - case router.DDWrt, router.Merlin: + case router.DDWrt, router.Merlin, router.Tomato: exe, err := os.Executable() if err != nil { return "", err diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index a7602f0..5b2e34b 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -459,3 +459,45 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha waitLock.Lock() return s, errCh } + +func runDNSServerForNTPD() (*dns.Server, <-chan error) { + dnsResolver := ctrld.NewBootstrapResolver() + s := &dns.Server{ + Addr: router.ListenAddress(), + Net: "udp", + Handler: dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) { + mainLog.Debug().Msg("Serving query for ntpd") + resolveCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + if osUpstreamConfig.Timeout > 0 { + timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(osUpstreamConfig.Timeout)) + defer cancel() + resolveCtx = timeoutCtx + } + answer, err := dnsResolver.Resolve(resolveCtx, m) + if err != nil { + mainLog.Error().Err(err).Msgf("could not resolve: %v", m) + return + } + if err := w.WriteMsg(answer); err != nil { + mainLog.Error().Err(err).Msg("runDNSServerForNTPD: failed to send DNS response") + } + }), + } + + waitLock := sync.Mutex{} + waitLock.Lock() + s.NotifyStartedFunc = waitLock.Unlock + + errCh := make(chan error) + go func() { + defer close(errCh) + if err := s.ListenAndServe(); err != nil { + waitLock.Unlock() + mainLog.Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr) + errCh <- err + } + }() + waitLock.Lock() + return s, errCh +} diff --git a/internal/router/merlin.go b/internal/router/merlin.go index aab05e7..8e20d68 100644 --- a/internal/router/merlin.go +++ b/internal/router/merlin.go @@ -2,16 +2,11 @@ package router import ( "bytes" - "context" - "errors" "fmt" "os" "os/exec" "strings" - "time" "unicode" - - "tailscale.com/logtail/backoff" ) func setupMerlin() error { @@ -92,43 +87,3 @@ func merlinParsePostConf(buf []byte) []byte { } return buf } - -func merlinPreStart() (err error) { - pidFile := "/tmp/ctrld.pid" - - // Remove pid file and trigger dnsmasq restart, so NTP can resolve - // server name and perform time synchronization. - pid, err := os.ReadFile(pidFile) - if err != nil { - return fmt.Errorf("PreStart: os.Readfile: %w", err) - } - if err := os.Remove(pidFile); err != nil { - return fmt.Errorf("PreStart: os.Remove: %w", err) - } - defer func() { - if werr := os.WriteFile(pidFile, pid, 0600); werr != nil { - err = errors.Join(err, werr) - return - } - if rerr := restartDNSMasq(); rerr != nil { - err = errors.Join(err, rerr) - return - } - }() - if err := restartDNSMasq(); err != nil { - return fmt.Errorf("PreStart: restartDNSMasqFn: %w", err) - } - - // Wait until `ntp_ready=1` set. - b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) - for { - out, err := nvram("get", "ntp_ready") - if err != nil { - return fmt.Errorf("PreStart: nvram: %w", err) - } - if out == "1" { - return nil - } - b.BackOff(context.Background(), errors.New("ntp not ready")) - } -} diff --git a/internal/router/router.go b/internal/router/router.go index 81246e4..2a36253 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,14 +2,18 @@ package router import ( "bytes" + "context" "errors" + "fmt" "os" "os/exec" "sync" "sync/atomic" + "time" "github.com/fsnotify/fsnotify" "github.com/kardianos/service" + "tailscale.com/logtail/backoff" "github.com/Control-D-Inc/ctrld" ) @@ -106,14 +110,23 @@ func ConfigureService(sc *service.Config) error { return nil } -// PreStart blocks until the router is ready for running ctrld. -func PreStart() (err error) { +// PreRun blocks until the router is ready for running ctrld. +func PreRun() (err error) { // On some routers, NTP may out of sync, so waiting for it to be ready. switch Name() { - case Merlin: - return merlinPreStart() - case Tomato: - return tomatoPreStart() + case Merlin, Tomato: + // Wait until `ntp_ready=1` set. + b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) + for { + out, err := nvram("get", "ntp_ready") + if err != nil { + return fmt.Errorf("PreStart: nvram: %w", err) + } + if out == "1" { + return nil + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } default: return nil } diff --git a/internal/router/tomato.go b/internal/router/tomato.go index 50f13ba..945e992 100644 --- a/internal/router/tomato.go +++ b/internal/router/tomato.go @@ -1,13 +1,8 @@ package router import ( - "context" - "errors" "fmt" "os/exec" - "time" - - "tailscale.com/logtail/backoff" ) const ( @@ -72,27 +67,6 @@ func cleanupTomato() error { return nil } -func tomatoPreStart() (err error) { - // cleanup to trigger dnsmasq restart, so NTP can resolve - // server name and perform time synchronization. - if err = cleanupTomato(); err != nil { - return err - } - - // Wait until `ntp_ready=1` set. - b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) - for { - out, err := nvram("get", "ntp_ready") - if err != nil { - return fmt.Errorf("PreStart: nvram: %w", err) - } - if out == "1" { - return nil - } - b.BackOff(context.Background(), errors.New("ntp not ready")) - } -} - func tomatoRestartService(name string) error { return tomatoRestartServiceWithKill(name, false) } diff --git a/resolver.go b/resolver.go index 391a4e8..0180762 100644 --- a/resolver.go +++ b/resolver.go @@ -201,3 +201,17 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) } return ips } + +// NewBootstrapResolver returns an OS resolver, which use following nameservers: +// +// - ControlD bootstrap DNS server. +// - Gateway IP address (depends on OS). +// - Input servers. +func NewBootstrapResolver(servers ...string) Resolver { + resolver := &osResolver{nameservers: nameservers()} + resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...) + for _, ns := range servers { + resolver.nameservers = append([]string{net.JoinHostPort(ns, "53")}, resolver.nameservers...) + } + return resolver +} From bbfa7c6c22e4981138f09ccca08a7bf5650dbcf9 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 2 Jun 2023 21:10:23 +0700 Subject: [PATCH 13/21] internal/router: relax dnsmasq lease file parsing condition On DD-WRT v3.0-r52189, dnsmasq version 2.89 lease format looks like: 1685794060 00:00:00:00:00:04 9 It has 6 fields, while the current parser only looks for line with exact 5 fields, which is too restricted. In fact, the parser shold just skip line with less than 4 fields, because the 4th field is the hostname, which is the last client info that ctrld needs. --- internal/router/client_info.go | 2 +- internal/router/client_info_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/router/client_info.go b/internal/router/client_info.go index 1548c67..97446c0 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -105,7 +105,7 @@ func dnsmasqReadClientInfoReader(reader io.Reader) error { r := routerPlatform.Load() return lineread.Reader(reader, func(line []byte) error { fields := bytes.Fields(line) - if len(fields) != 5 { + if len(fields) < 4 { return nil } mac := string(fields[1]) diff --git a/internal/router/client_info_test.go b/internal/router/client_info_test.go index 5ea8b65..fac801c 100644 --- a/internal/router/client_info_test.go +++ b/internal/router/client_info_test.go @@ -78,6 +78,12 @@ lease 192.168.1.2 { iscDHCPReadClientInfoReader, "00:00:00:00:00:02", }, + { + "", + `1685794060 00:00:00:00:00:04 192.168.0.209 cuonglm-ThinkPad-X1-Carbon-Gen-9 00:00:00:00:00:04 9`, + dnsmasqReadClientInfoReader, + "00:00:00:00:00:04", + }, } for _, tc := range tests { From a46bb152af91357b3509087cf9a312484a4ce60e Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 2 Jun 2023 21:16:49 +0700 Subject: [PATCH 14/21] cmd/ctrld: do not mutual net.Addr when spoofing client source IP Otherwise, the original address will be overwritten, causing the connection between the listener and dnsmasq broken. --- cmd/ctrld/dns_proxy.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index 5b2e34b..3498556 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -423,9 +423,19 @@ func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr { if ci != nil && ci.IP != "" { switch addr := addr.(type) { case *net.UDPAddr: - addr.IP = net.ParseIP(ci.IP) + udpAddr := &net.UDPAddr{ + IP: net.ParseIP(ci.IP), + Port: addr.Port, + Zone: addr.Zone, + } + return udpAddr case *net.TCPAddr: - addr.IP = net.ParseIP(ci.IP) + udpAddr := &net.TCPAddr{ + IP: net.ParseIP(ci.IP), + Port: addr.Port, + Zone: addr.Zone, + } + return udpAddr } } return addr From 726a25a7ea3a7663cafdd5cdf0e5900e5ec8b73e Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 2 Jun 2023 15:45:01 +0000 Subject: [PATCH 15/21] internal/router: emit error if dnsfilter is enabled on Ubios/EdgeOS --- internal/router/edgeos.go | 8 ++++++++ internal/router/ubios.go | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/router/edgeos.go b/internal/router/edgeos.go index 4a8e57e..ccdb164 100644 --- a/internal/router/edgeos.go +++ b/internal/router/edgeos.go @@ -37,6 +37,14 @@ func cleanupEdgeOS() error { } func postInstallEdgeOS() error { + // If "Content Filtering" is enabled, UniFi OS will create firewall rules to intercept all DNS queries + // from outside, and route those queries to separated interfaces (e.g: dnsfilter-2@if79) created by UniFi OS. + // Thus, those queries will never reach ctrld listener. UniFi OS does not provide any mechanism to toggle this + // feature via command line, so there's nothing ctrld can do to disable this feature. For now, reporting an + // error and guiding users to disable the feature using UniFi OS web UI. + if contentFilteringEnabled() { + return errContentFilteringEnabled + } return nil } diff --git a/internal/router/ubios.go b/internal/router/ubios.go index 8be261f..48e5d41 100644 --- a/internal/router/ubios.go +++ b/internal/router/ubios.go @@ -2,12 +2,17 @@ package router import ( "bytes" + "fmt" "os" "strconv" ) +var errContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n +To disable it, folowing instruction here: %s`, toggleContentFilteringLink) + const ( - ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf" + ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf" + toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f" ) func setupUbiOS() error { @@ -39,6 +44,10 @@ func cleanupUbiOS() error { } func postInstallUbiOS() error { + // See comment in postInstallEdgeOS. + if contentFilteringEnabled() { + return errContentFilteringEnabled + } return nil } @@ -57,3 +66,8 @@ func ubiosRestartDNSMasq() error { } return proc.Kill() } + +func contentFilteringEnabled() bool { + st, err := os.Stat("/run/dnsfilter/dnsfilter") + return err == nil && !st.IsDir() +} From 25eae187db7b69e30951efd85f1f973cecfd7652 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Sat, 3 Jun 2023 00:00:20 +0700 Subject: [PATCH 16/21] internal/router: do not exit when stopping successfully on freshtomato Otherwise, "restart" will be broken because "start" won't never be called. --- internal/router/service_tomato.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/service_tomato.go b/internal/router/service_tomato.go index 8bfcf73..8b7590c 100644 --- a/internal/router/service_tomato.go +++ b/internal/router/service_tomato.go @@ -229,7 +229,7 @@ stop() { if [ -f "$pid_file" ]; then rm "$pid_file" fi - exit 0 + return 0 fi printf "." sleep 2 From c941f9c6213f1aead37aa0f79e6910384d21a64b Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Sat, 3 Jun 2023 10:21:35 +0700 Subject: [PATCH 17/21] all: add flag to use dev domain for testing --- cmd/ctrld/cli.go | 6 +++++- cmd/ctrld/cli_router.go | 2 ++ cmd/ctrld/main.go | 1 + internal/controld/config.go | 20 +++++++++++++++----- internal/controld/config_test.go | 8 +++++--- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index 0e2b642..aaa720f 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -234,6 +234,8 @@ func initCLI() { runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") runCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") + runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") + _ = runCmd.Flags().MarkHidden("dev") runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "") _ = runCmd.Flags().MarkHidden("homedir") runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) @@ -352,6 +354,8 @@ func initCLI() { startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") + startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") + _ = startCmd.Flags().MarkHidden("dev") startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) startCmd.Flags().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`) _ = startCmd.Flags().MarkHidden("router") @@ -706,7 +710,7 @@ func processCDFlags() { } logger := mainLog.With().Str("mode", "cd").Logger() logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) - resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version) + resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode { s, err := service.New(&prog{}, svcConfig) if err != nil { diff --git a/cmd/ctrld/cli_router.go b/cmd/ctrld/cli_router.go index 3d54c1c..7688e9a 100644 --- a/cmd/ctrld/cli_router.go +++ b/cmd/ctrld/cli_router.go @@ -77,6 +77,8 @@ func initRouterCLI() { routerCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") routerCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") routerCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") + routerCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") + _ = routerCmd.Flags().MarkHidden("dev") routerCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) tmpl := routerCmd.UsageTemplate() diff --git a/cmd/ctrld/main.go b/cmd/ctrld/main.go index 9903a0b..bc3edf0 100644 --- a/cmd/ctrld/main.go +++ b/cmd/ctrld/main.go @@ -27,6 +27,7 @@ var ( verbose int silent bool cdUID string + cdDev bool iface string ifaceStartStop string setupRouter bool diff --git a/internal/controld/config.go b/internal/controld/config.go index 22c18b9..eef98f9 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -17,9 +17,11 @@ import ( ) const ( - apiDomain = "api.controld.com" - resolverDataURL = "https://api.controld.com/utility" - InvalidConfigCode = 40401 + apiDomainCom = "api.controld.com" + apiDomainDev = "api.controld.dev" + resolverDataURLCom = "https://api.controld.com/utility" + resolverDataURLDev = "https://api.controld.dev/utility" + InvalidConfigCode = 40401 ) // ResolverConfig represents Control D resolver data. @@ -54,9 +56,13 @@ type utilityRequest struct { } // FetchResolverConfig fetch Control D config for given uid. -func FetchResolverConfig(uid, version string) (*ResolverConfig, error) { +func FetchResolverConfig(uid, version string, cdDev bool) (*ResolverConfig, error) { body, _ := json.Marshal(utilityRequest{UID: uid}) - req, err := http.NewRequest("POST", resolverDataURL, bytes.NewReader(body)) + apiUrl := resolverDataURLCom + if cdDev { + apiUrl = resolverDataURLDev + } + req, err := http.NewRequest("POST", apiUrl, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("http.NewRequest: %w", err) } @@ -67,6 +73,10 @@ func FetchResolverConfig(uid, version string) (*ResolverConfig, error) { req.Header.Add("Content-Type", "application/json") transport := http.DefaultTransport.(*http.Transport).Clone() transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + apiDomain := apiDomainCom + if cdDev { + apiDomain = apiDomainDev + } ips := ctrld.LookupIP(apiDomain) if len(ips) == 0 { ctrld.ProxyLog.Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr) diff --git a/internal/controld/config_test.go b/internal/controld/config_test.go index d46cf95..2c00247 100644 --- a/internal/controld/config_test.go +++ b/internal/controld/config_test.go @@ -13,16 +13,18 @@ func TestFetchResolverConfig(t *testing.T) { tests := []struct { name string uid string + dev bool wantErr bool }{ - {"valid", "p2", false}, - {"invalid uid", "abcd1234", true}, + {"valid com", "p2", false, false}, + {"valid dev", "p2", true, false}, + {"invalid uid", "abcd1234", false, true}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := FetchResolverConfig(tc.uid, "dev-test") + got, err := FetchResolverConfig(tc.uid, "dev-test", tc.dev) require.False(t, (err != nil) != tc.wantErr, err) if !tc.wantErr { assert.NotEmpty(t, got.DOH) From 542c4f7daf48012810faab82889866366a673c51 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Mon, 5 Jun 2023 18:41:48 +0700 Subject: [PATCH 18/21] all: adding more function/type documentation --- cmd/ctrld/dns_proxy.go | 3 +++ config.go | 14 +++++++++++--- config_quic.go | 1 + doh.go | 13 ++++++------- internal/router/client_info.go | 11 +++++++++++ resolver.go | 16 +++++++++++----- 6 files changed, 43 insertions(+), 15 deletions(-) diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index 3498556..c7cc7a1 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -470,6 +470,9 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha return s, errCh } +// runDNSServerForNTPD starts a DNS server listening on router.ListenAddress(). It must only be called when ctrld +// running on router, before router.PreRun() to serve DNS request for NTP synchronization. The caller must call +// s.Shutdown() explicitly when NTP is synced successfully. func runDNSServerForNTPD() (*dns.Server, <-chan error) { dnsResolver := ctrld.NewBootstrapResolver() s := &dns.Server{ diff --git a/config.go b/config.go index 5f37998..bdd335b 100644 --- a/config.go +++ b/config.go @@ -24,10 +24,17 @@ import ( ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ) +// IpStackBoth ... const ( - IpStackBoth = "both" - IpStackV4 = "v4" - IpStackV6 = "v6" + // IpStackBoth indicates that ctrld will use either ipv4 or ipv6 for connecting to upstream, + // depending on which stack is available when receiving the DNS query. + IpStackBoth = "both" + // IpStackV4 indicates that ctrld will use only ipv4 for connecting to upstream. + IpStackV4 = "v4" + // IpStackV6 indicates that ctrld will use only ipv6 for connecting to upstream. + IpStackV6 = "v6" + // IpStackSplit indicates that ctrld will use either ipv4 or ipv6 for connecting to upstream, + // depending on the record type of the DNS query. IpStackSplit = "split" controlDComDomain = "controld.com" @@ -251,6 +258,7 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool { return false } +// BootstrapIPs returns the bootstrap IPs list of upstreams. func (uc *UpstreamConfig) BootstrapIPs() []string { return uc.bootstrapIPs } diff --git a/config_quic.go b/config_quic.go index b6861b6..085476e 100644 --- a/config_quic.go +++ b/config_quic.go @@ -109,6 +109,7 @@ type parallelDialerResult struct { type quicParallelDialer struct{} +// Dial performs parallel dialing to the given address list. func (d *quicParallelDialer) Dial(ctx context.Context, domain string, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { if len(addrs) == 0 { return nil, errors.New("empty addresses") diff --git a/doh.go b/doh.go index 155361e..f861f2f 100644 --- a/doh.go +++ b/doh.go @@ -13,10 +13,9 @@ import ( ) const ( - DoHMacHeader = "x-cd-mac" - DoHIPHeader = "x-cd-ip" - DoHHostHeader = "x-cd-host" - + dohMacHeader = "x-cd-mac" + dohIPHeader = "x-cd-ip" + dohHostHeader = "x-cd-host" headerApplicationDNS = "application/dns-message" ) @@ -101,13 +100,13 @@ func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) { if sendClientInfo { if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil { if ci.Mac != "" { - req.Header.Set(DoHMacHeader, ci.Mac) + req.Header.Set(dohMacHeader, ci.Mac) } if ci.IP != "" { - req.Header.Set(DoHIPHeader, ci.IP) + req.Header.Set(dohIPHeader, ci.IP) } if ci.Hostname != "" { - req.Header.Set(DoHHostHeader, ci.Hostname) + req.Header.Set(dohHostHeader, ci.Hostname) } } } diff --git a/internal/router/client_info.go b/internal/router/client_info.go index 97446c0..8fc5709 100644 --- a/internal/router/client_info.go +++ b/internal/router/client_info.go @@ -16,8 +16,10 @@ import ( "github.com/Control-D-Inc/ctrld" ) +// readClientInfoFunc represents the function for reading client info. type readClientInfoFunc func(name string) error +// clientInfoFiles specifies client info files and how to read them on supported platforms. var clientInfoFiles = map[string]readClientInfoFunc{ "/tmp/dnsmasq.leases": dnsmasqReadClientInfoFile, // ddwrt "/tmp/dhcp.leases": dnsmasqReadClientInfoFile, // openwrt @@ -31,6 +33,8 @@ var clientInfoFiles = map[string]readClientInfoFunc{ "/var/dhcpd/var/db/dhcpd.leases": iscDHCPReadClientInfoFile, // Pfsense } +// watchClientInfoTable watches changes happens in dnsmasq/dhcpd +// lease files, perform updating to mac table if necessary. func (r *router) watchClientInfoTable() { if r.watcher == nil { return @@ -65,6 +69,7 @@ func (r *router) watchClientInfoTable() { } } +// Stop performs tasks need to be done before the router stopped. func Stop() error { if Name() == "" { return nil @@ -78,6 +83,7 @@ func Stop() error { return nil } +// GetClientInfoByMac returns ClientInfo for the client associated with the given mac. func GetClientInfoByMac(mac string) *ctrld.ClientInfo { if mac == "" { return nil @@ -91,6 +97,7 @@ func GetClientInfoByMac(mac string) *ctrld.ClientInfo { return val.(*ctrld.ClientInfo) } +// dnsmasqReadClientInfoFile populates mac table with client info reading from dnsmasq lease file. func dnsmasqReadClientInfoFile(name string) error { f, err := os.Open(name) if err != nil { @@ -101,6 +108,7 @@ func dnsmasqReadClientInfoFile(name string) error { } +// dnsmasqReadClientInfoReader likes dnsmasqReadClientInfoFile, but reading from an io.Reader instead of file. func dnsmasqReadClientInfoReader(reader io.Reader) error { r := routerPlatform.Load() return lineread.Reader(reader, func(line []byte) error { @@ -124,6 +132,7 @@ func dnsmasqReadClientInfoReader(reader io.Reader) error { }) } +// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file. func iscDHCPReadClientInfoFile(name string) error { f, err := os.Open(name) if err != nil { @@ -133,6 +142,7 @@ func iscDHCPReadClientInfoFile(name string) error { return iscDHCPReadClientInfoReader(f) } +// iscDHCPReadClientInfoReader likes iscDHCPReadClientInfoFile, but reading from an io.Reader instead of file. func iscDHCPReadClientInfoReader(reader io.Reader) error { r := routerPlatform.Load() s := bufio.NewScanner(reader) @@ -172,6 +182,7 @@ func iscDHCPReadClientInfoReader(reader io.Reader) error { return nil } +// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. func normalizeIP(in string) string { // dnsmasq may put ip with interface index in lease file, strip it here. ip, _, found := strings.Cut(in, "%") diff --git a/resolver.go b/resolver.go index 0180762..3162d62 100644 --- a/resolver.go +++ b/resolver.go @@ -12,11 +12,17 @@ import ( ) const ( - ResolverTypeDOH = "doh" - ResolverTypeDOH3 = "doh3" - ResolverTypeDOT = "dot" - ResolverTypeDOQ = "doq" - ResolverTypeOS = "os" + // ResolverTypeDOH specifies DoH resolver. + ResolverTypeDOH = "doh" + // ResolverTypeDOH3 specifies DoH3 resolver. + ResolverTypeDOH3 = "doh3" + // ResolverTypeDOT specifies DoT resolver. + ResolverTypeDOT = "dot" + // ResolverTypeDOQ specifies DoQ resolver. + ResolverTypeDOQ = "doq" + // ResolverTypeOS specifies OS resolver. + ResolverTypeOS = "os" + // ResolverTypeLegacy specifies legacy resolver. ResolverTypeLegacy = "legacy" ) From 929de49c7b5b28e29d9b7fc39358ce66dab43c4d Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 7 Jun 2023 23:00:45 +0700 Subject: [PATCH 19/21] cmd/ctrld: only spawn DNS server for ntpd if necessary On some platforms, like pfsense, ntpd is not problem, so do not spawn the DNS server for it, which may conflict with default DNS server. While at it, also make sure that ctrld will be run at last on startup. --- cmd/ctrld/cli.go | 4 ++-- cmd/ctrld/dns_proxy.go | 7 +++++-- internal/router/pfsense.go | 42 +++++++++++++++++++++++--------------- internal/router/router.go | 4 +++- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index aaa720f..8cfca02 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -165,11 +165,11 @@ func initCLI() { initLogging() if setupRouter { - s, _ := runDNSServerForNTPD() + s, errCh := runDNSServerForNTPD(router.ListenAddress()) if err := router.PreRun(); err != nil { mainLog.Fatal().Err(err).Msg("failed to perform router pre-start check") } - if err := s.Shutdown(); err != nil { + if err := s.Shutdown(); err != nil && errCh != nil { mainLog.Fatal().Err(err).Msg("failed to shutdown dns server for ntpd") } } diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index c7cc7a1..9f9fa30 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -473,10 +473,13 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha // runDNSServerForNTPD starts a DNS server listening on router.ListenAddress(). It must only be called when ctrld // running on router, before router.PreRun() to serve DNS request for NTP synchronization. The caller must call // s.Shutdown() explicitly when NTP is synced successfully. -func runDNSServerForNTPD() (*dns.Server, <-chan error) { +func runDNSServerForNTPD(addr string) (*dns.Server, <-chan error) { + if addr == "" { + return &dns.Server{}, nil + } dnsResolver := ctrld.NewBootstrapResolver() s := &dns.Server{ - Addr: router.ListenAddress(), + Addr: addr, Net: "udp", Handler: dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) { mainLog.Debug().Msg("Serving query for ntpd") diff --git a/internal/router/pfsense.go b/internal/router/pfsense.go index bae3dc2..689cc7b 100644 --- a/internal/router/pfsense.go +++ b/internal/router/pfsense.go @@ -12,21 +12,15 @@ import ( const ( rcPath = "/usr/local/etc/rc.d" unboundRcPath = rcPath + "/unbound" + dnsmasqRcPath = rcPath + "/dnsmasq" ) func setupPfsense() error { // If Pfsense is in DNS Resolver mode, ensure no unbound processes running. - if _, err := exec.Command("service", "unbound", "onestatus").CombinedOutput(); err == nil { - if out, err := exec.Command("killall", "unbound").CombinedOutput(); err != nil { - return fmt.Errorf("could not killall unbound: %s: %w", string(out), err) - } - } + _ = exec.Command("killall", "unbound").Run() + // If Pfsense is in DNS Forwarder mode, ensure no dnsmasq processes running. - if _, err := exec.Command("service", "dnsmasq", "onestatus").CombinedOutput(); err == nil { - if out, err := exec.Command("killall", "dnsmasq").CombinedOutput(); err != nil { - return fmt.Errorf("could not killall unbound: %s: %w", string(out), err) - } - } + _ = exec.Command("killall", "dnsmasq") return nil } @@ -34,12 +28,9 @@ func cleanupPfsense(svc *service.Config) error { if err := os.Remove(filepath.Join(rcPath, svc.Name+".sh")); err != nil { return fmt.Errorf("os.Remove: %w", err) } - if out, err := exec.Command(unboundRcPath, "onerestart").CombinedOutput(); err != nil { - return fmt.Errorf("could not restart unbound: %s: %w", string(out), err) - } - if out, err := exec.Command(unboundRcPath, "onerestart").CombinedOutput(); err != nil { - return fmt.Errorf("could not restart unbound: %s: %w", string(out), err) - } + _ = exec.Command(unboundRcPath, "onerestart").Run() + _ = exec.Command(dnsmasqRcPath, "onerestart").Run() + return nil } @@ -54,3 +45,22 @@ func postInstallPfsense(svc *service.Config) error { } return nil } + +const pfsenseInitScript = `#!/bin/sh + +# PROVIDE: {{.Name}} +# REQUIRE: SERVERS +# REQUIRE: unbound dnsmasq securelevel +# KEYWORD: shutdown + +. /etc/rc.subr + +name="{{.Name}}" +{{.Name}}_env="IS_DAEMON=1" +pidfile="/var/run/${name}.pid" +command="/usr/sbin/daemon" +daemon_args="-P ${pidfile} -r -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}" +command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}" + +run_rc_command "$1" +` diff --git a/internal/router/router.go b/internal/router/router.go index 2a36253..f9b8be8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -105,7 +105,9 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case EdgeOS, Merlin, Pfsense, Synology, Tomato, Ubios: + case Pfsense: + sc.Option["SysvScript"] = pfsenseInitScript + case EdgeOS, Merlin, Synology, Tomato, Ubios: } return nil } From 53f8d006f0643ac59344caa51244f7d4db4f0586 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 7 Jun 2023 23:48:41 +0700 Subject: [PATCH 20/21] all: support older version of Openwrt --- cmd/ctrld/service.go | 3 ++- internal/router/openwrt.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/ctrld/service.go b/cmd/ctrld/service.go index cc42f8b..bce6503 100644 --- a/cmd/ctrld/service.go +++ b/cmd/ctrld/service.go @@ -13,7 +13,8 @@ import ( func newService(s service.Service) service.Service { // TODO: unify for other SysV system. - if router.IsGLiNet() { + switch { + case router.IsGLiNet(), router.IsOldOpenwrt(): return &sysV{s} } return s diff --git a/internal/router/openwrt.go b/internal/router/openwrt.go index 0fbe5f2..afc25ae 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt.go @@ -23,6 +23,16 @@ func IsGLiNet() bool { return bytes.Contains(buf, []byte(" (glinet")) } +// IsOldOpenwrt reports whether the router is an "old" version of Openwrt, +// aka versions which don't have "service" command. +func IsOldOpenwrt() bool { + if Name() != OpenWrt { + return false + } + cmd, _ := exec.LookPath("service") + return cmd == "" +} + func setupOpenWrt() error { // Delete dnsmasq port if set. if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) { From b9eb89c02e1f1dccd1aea26d0969408ed40b3720 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 8 Jun 2023 02:23:44 +0700 Subject: [PATCH 21/21] internal/router: fix missing Run() call --- internal/router/pfsense.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/pfsense.go b/internal/router/pfsense.go index 689cc7b..3818a58 100644 --- a/internal/router/pfsense.go +++ b/internal/router/pfsense.go @@ -20,7 +20,7 @@ func setupPfsense() error { _ = exec.Command("killall", "unbound").Run() // If Pfsense is in DNS Forwarder mode, ensure no dnsmasq processes running. - _ = exec.Command("killall", "dnsmasq") + _ = exec.Command("killall", "dnsmasq").Run() return nil }