diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index a0304da..8cfca02 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" @@ -138,16 +136,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") } }() @@ -176,9 +165,13 @@ func initCLI() { initLogging() if setupRouter { - if err := router.PreStart(); err != nil { + 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 && errCh != nil { + mainLog.Fatal().Err(err).Msg("failed to shutdown dns server for ntpd") + } } processCDFlags() @@ -241,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`) @@ -298,6 +293,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. @@ -319,7 +318,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 } @@ -329,7 +328,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") @@ -354,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") @@ -474,7 +476,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") @@ -708,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 { @@ -854,17 +856,25 @@ 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 } -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 ctx := context.Background() - err := errors.New("query failed") maxAttempts := 20 mainLog.Debug().Msg("Performing self-check") var ( @@ -888,16 +898,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, _, _ := 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) + mainLog.Debug().Msgf("self-check against %q succeeded", domain) return status } - bo.BackOff(ctx, err) + 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 } @@ -907,7 +917,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/cli_router_linux.go b/cmd/ctrld/cli_router.go similarity index 93% rename from cmd/ctrld/cli_router_linux.go rename to cmd/ctrld/cli_router.go index 3bd70f1..7688e9a 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 ( @@ -42,12 +44,11 @@ func initRouterCLI() { if platform == "auto" { platform = router.Name() } - switch platform { - case router.DDWrt, router.Merlin, router.OpenWrt, router.Ubios: - 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) @@ -76,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/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/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index ac9af50..9f9fa30 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,28 @@ 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: + udpAddr := &net.UDPAddr{ + IP: net.ParseIP(ci.IP), + Port: addr.Port, + Zone: addr.Zone, + } + return udpAddr + case *net.TCPAddr: + udpAddr := &net.TCPAddr{ + IP: net.ParseIP(ci.IP), + Port: addr.Port, + Zone: addr.Zone, + } + return udpAddr + } + } + 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. @@ -446,3 +469,51 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha waitLock.Lock() 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(addr string) (*dns.Server, <-chan error) { + if addr == "" { + return &dns.Server{}, nil + } + dnsResolver := ctrld.NewBootstrapResolver() + s := &dns.Server{ + Addr: addr, + 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/cmd/ctrld/dns_proxy_test.go b/cmd/ctrld/dns_proxy_test.go index c9ff9d9..2d29bc3 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} @@ -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()) + } + }) + } +} 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/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index f19aeda..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, 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/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/config.go b/config.go index bef32e3..bdd335b 100644 --- a/config.go +++ b/config.go @@ -24,14 +24,31 @@ 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" + 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 +218,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. // @@ -224,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 } @@ -347,9 +382,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 +452,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 +474,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/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 } 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/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/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) diff --git a/internal/router/client_info.go b/internal/router/client_info.go index f6a13fb..8fc5709 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,25 @@ 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 +// 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 + "/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 + "/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 @@ -32,14 +44,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) } } @@ -52,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 @@ -65,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 @@ -78,21 +97,23 @@ func GetClientInfoByMac(mac string) *ctrld.ClientInfo { return val.(*ctrld.ClientInfo) } -func readClientInfoFile(name string) error { +// 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 { return err } defer f.Close() - return readClientInfoReader(f) + return dnsmasqReadClientInfoReader(f) } -func readClientInfoReader(reader io.Reader) 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 { fields := bytes.Fields(line) - if len(fields) != 5 { + if len(fields) < 4 { return nil } mac := string(fields[1]) @@ -111,6 +132,57 @@ func readClientInfoReader(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 { + return err + } + defer f.Close() + 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) + 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 +} + +// 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/internal/router/client_info_test.go b/internal/router/client_info_test.go index 2b228c2..fac801c 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,65 @@ 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", + }, + { + "", + `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 { 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 +99,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/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 b8fad8c..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: + case EdgeOS, DDWrt, OpenWrt, Ubios, Synology, Tomato: tmplText = dnsMasqConfigContentTmpl case Merlin: tmplText = merlinDNSMasqPostConfTmpl @@ -65,3 +65,23 @@ func dnsMasqConf() (string, error) { } return sb.String(), nil } + +func restartDNSMasq() error { + switch Name() { + case EdgeOS: + return edgeOSRestartDNSMasq() + 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/edgeos.go b/internal/router/edgeos.go new file mode 100644 index 0000000..ccdb164 --- /dev/null +++ b/internal/router/edgeos.go @@ -0,0 +1,56 @@ +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 { + // 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 +} + +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/merlin.go b/internal/router/merlin.go index ca739cb..8e20d68 100644 --- a/internal/router/merlin.go +++ b/internal/router/merlin.go @@ -35,11 +35,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 +48,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 +60,7 @@ func cleanupMerlin() error { return err } // Restart dnsmasq service. - if err := merlinRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil 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 6e5881d..afc25ae 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt.go @@ -23,12 +23,21 @@ 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) { return err } - // Disable dnsmasq as DNS server. dnsMasqConfigContent, err := dnsMasqConf() if err != nil { return err @@ -41,7 +50,7 @@ func setupOpenWrt() error { return err } // Restart dnsmasq service. - if err := openwrtRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -53,7 +62,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/pfsense.go b/internal/router/pfsense.go new file mode 100644 index 0000000..3818a58 --- /dev/null +++ b/internal/router/pfsense.go @@ -0,0 +1,66 @@ +package router + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/kardianos/service" +) + +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. + _ = exec.Command("killall", "unbound").Run() + + // If Pfsense is in DNS Forwarder mode, ensure no dnsmasq processes running. + _ = exec.Command("killall", "dnsmasq").Run() + 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) + } + _ = exec.Command(unboundRcPath, "onerestart").Run() + _ = exec.Command(dnsmasqRcPath, "onerestart").Run() + + 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 +} + +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 c8beac7..f9b8be8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -19,10 +19,14 @@ import ( ) const ( - OpenWrt = "openwrt" - DDWrt = "ddwrt" - Merlin = "merlin" - Ubios = "ubios" + OpenWrt = "openwrt" + DDWrt = "ddwrt" + Merlin = "merlin" + Ubios = "ubios" + Synology = "synology" + Tomato = "tomato" + EdgeOS = "edgeos" + Pfsense = "pfsense" ) // ErrNotSupported reports the current router is not supported error. @@ -37,23 +41,36 @@ type router struct { watcher *fsnotify.Watcher } +// IsSupported reports whether the given platform is supported by ctrld. +func IsSupported(platform string) bool { + switch platform { + case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: + 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} + return []string{EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios} } var configureFunc = map[string]func() error{ - DDWrt: setupDDWrt, - Merlin: setupMerlin, - OpenWrt: setupOpenWrt, - Ubios: setupUbiOS, + EdgeOS: setupEdgeOS, + DDWrt: setupDDWrt, + Merlin: setupMerlin, + OpenWrt: setupOpenWrt, + Pfsense: setupPfsense, + 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: + case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true @@ -63,8 +80,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) } } @@ -88,67 +105,53 @@ func ConfigureService(sc *service.Config) error { } case OpenWrt: sc.Option["SysvScript"] = openWrtScript - case Merlin, Ubios: + case Pfsense: + sc.Option["SysvScript"] = pfsenseInitScript + case EdgeOS, Merlin, Synology, Tomato, Ubios: } return nil } -// PreStart blocks until the router is ready for running ctrld. -func PreStart() (err error) { - if Name() != DDWrt { +// 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, 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 } - - 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. -func PostInstall() error { +func PostInstall(svc *service.Config) error { name := Name() switch name { + case EdgeOS: + return postInstallEdgeOS() case DDWrt: return postInstallDDWrt() case Merlin: return postInstallMerlin() case OpenWrt: return postInstallOpenWrt() + case Pfsense: + return postInstallPfsense(svc) + case Synology: + return postInstallSynology() + case Tomato: + return postInstallTomato() case Ubios: return postInstallUbiOS() } @@ -156,15 +159,23 @@ 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: + return cleanupEdgeOS() case DDWrt: return cleanupDDWrt() case Merlin: return cleanupMerlin() case OpenWrt: return cleanupOpenWrt() + case Pfsense: + return cleanupPfsense(svc) + case Synology: + return cleanupSynology() + case Tomato: + return cleanupTomato() case Ubios: return cleanupUbiOS() } @@ -175,8 +186,10 @@ func Cleanup() error { func ListenAddress() string { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios: + 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 "" } @@ -194,14 +207,24 @@ 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 + 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 + case isPfsense(): + return Pfsense } return "" } @@ -216,7 +239,17 @@ 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 +} + +func isPfsense() bool { + b, err := os.ReadFile("/etc/platform") + return err == nil && bytes.HasPrefix(b, []byte("pfSense")) +} 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..8b7590c --- /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 + return 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 new file mode 100644 index 0000000..8c1d1d6 --- /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 := restartDNSMasq(); 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 := restartDNSMasq(); 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 +} diff --git a/internal/router/tomato.go b/internal/router/tomato.go new file mode 100644 index 0000000..945e992 --- /dev/null +++ b/internal/router/tomato.go @@ -0,0 +1,82 @@ +package router + +import ( + "fmt" + "os/exec" +) + +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 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..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 { @@ -20,7 +25,7 @@ func setupUbiOS() error { return err } // Restart dnsmasq service. - if err := ubiosRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil @@ -32,13 +37,17 @@ func cleanupUbiOS() error { return err } // Restart dnsmasq service. - if err := ubiosRestartDNSMasq(); err != nil { + if err := restartDNSMasq(); err != nil { return err } return nil } 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() +} 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..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" ) @@ -125,7 +131,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 } @@ -194,3 +207,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 +}