Compare commits

..

26 Commits

Author SHA1 Message Date
Cuong Manh Le
5553490b27 docs: add default value to configs
While at it, also correct some configs to match the latest version.

Fixes #44
2023-06-08 21:54:06 +07:00
Yegor S
eaf39f48a0 Update README.md 2023-06-08 01:48:37 -04:00
Yegor S
a5ddbdcb42 Update README.md 2023-06-08 01:40:13 -04:00
Yegor S
0c99d27be5 Merge pull request #51 from Control-D-Inc/release-branch-v1.2.1
Release branch v1.2.1
2023-06-08 00:19:07 -04:00
Cuong Manh Le
b9eb89c02e internal/router: fix missing Run() call 2023-06-08 02:27:20 +07:00
Cuong Manh Le
53f8d006f0 all: support older version of Openwrt 2023-06-08 02:07:32 +07:00
Cuong Manh Le
929de49c7b 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.
2023-06-08 02:07:10 +07:00
Cuong Manh Le
542c4f7daf all: adding more function/type documentation 2023-06-06 00:07:15 +07:00
Cuong Manh Le
c941f9c621 all: add flag to use dev domain for testing 2023-06-06 00:07:05 +07:00
Cuong Manh Le
25eae187db internal/router: do not exit when stopping successfully on freshtomato
Otherwise, "restart" will be broken because "start" won't never be called.
2023-06-03 10:31:08 +07:00
Cuong Manh Le
726a25a7ea internal/router: emit error if dnsfilter is enabled on Ubios/EdgeOS 2023-06-02 22:45:39 +07:00
Cuong Manh Le
a46bb152af 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.
2023-06-02 22:43:00 +07:00
Cuong Manh Le
bbfa7c6c22 internal/router: relax dnsmasq lease file parsing condition
On DD-WRT v3.0-r52189, dnsmasq version 2.89 lease format looks like:

1685794060 <mac> <ip> <hostname> 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.
2023-06-02 22:42:47 +07:00
Cuong Manh Le
1cd54a48e9 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.
2023-06-02 20:25:11 +07:00
Cuong Manh Le
2d950eecdf cmd/ctrld: spoofing client IP on routers 2023-06-02 20:24:59 +07:00
Cuong Manh Le
b143e46eb0 all: add support for pfsense 2023-06-02 20:24:42 +07:00
Cuong Manh Le
8fda856e24 all: add UpstreamConfig.VerifyDomain
So the self-check process is only done for ControlD upstream, and can be
distinguished between .com and .dev resolvers.
2023-06-02 20:24:25 +07:00
Cuong Manh Le
54e63ccf9b all: add support for EdgeOS 2023-06-02 20:23:37 +07:00
Cuong Manh Le
ee53db1e35 all: add support for freshtomato 2023-06-02 20:21:17 +07:00
Cuong Manh Le
fc502b920b internal/router: add Synology client info file 2023-06-02 20:21:02 +07:00
Cuong Manh Le
20eae82f11 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.
2023-06-02 20:20:47 +07:00
Cuong Manh Le
d2fc530316 all: add support for Synology router 2023-06-02 20:20:31 +07:00
Cuong Manh Le
7ac5555a84 internal/router: fix wrong platform check in PreStart
The NTP workaround is intended to be run on Merlin only.
2023-06-02 20:20:12 +07:00
Cuong Manh Le
15d397d8a6 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.
2023-06-02 20:19:57 +07:00
Cuong Manh Le
b471adfb09 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.
2023-06-02 20:19:25 +07:00
Yegor S
d7a38363e6 Merge pull request #42 from Control-D-Inc/update-readme
Update README.md
2023-05-16 15:17:05 -04:00
35 changed files with 1258 additions and 191 deletions

View File

@@ -9,6 +9,7 @@ A highly configurable DNS forwarding proxy with support for:
- Multiple upstreams with fallbacks - Multiple upstreams with fallbacks
- Multiple network policy driven DNS query steering - Multiple network policy driven DNS query steering
- Policy driven domain based "split horizon" DNS with wildcard support - Policy driven domain based "split horizon" DNS with wildcard support
- Integrations with common router vendors and firmware
## TLDR ## TLDR
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways. Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
@@ -25,12 +26,14 @@ All DNS protocols are supported, including:
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B. 2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D. 3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream. 4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
5. Deploy on a router and create LAN client specific DNS routing policies from a web GUI (When using ControlD.com).
## OS Support ## OS Support
- Windows (386, amd64, arm) - Windows (386, amd64, arm)
- Mac (amd64, arm64) - Mac (amd64, arm64)
- Linux (386, amd64, arm, mips) - Linux (386, amd64, arm, mips)
- FreeBSD
- Common routers (See Router Mode below) - Common routers (See Router Mode below)
# Install # Install
@@ -153,11 +156,14 @@ For granular control of the service, run the `service` command. Each sub-command
## Router Mode ## Router Mode
You can run `ctrld` on any supported router, which will function similarly to the Service Mode mentioned above. The list of supported routers and firmware includes: You can run `ctrld` on any supported router, which will function similarly to the Service Mode mentioned above. The list of supported routers and firmware includes:
- OpenWRT
- DD-WRT
- Asus Merlin - Asus Merlin
- DD-WRT
- FreshTomato
- GL.iNet - GL.iNet
- Ubiquiti - OpenWRT
- pfSense
- Synology
- Ubiquiti (UniFi, EdgeOS)
In order to start `ctrld` as a DNS provider, simply run `./ctrld setup auto` command. In order to start `ctrld` as a DNS provider, simply run `./ctrld setup auto` command.

View File

@@ -36,8 +36,6 @@ import (
"github.com/Control-D-Inc/ctrld/internal/router" "github.com/Control-D-Inc/ctrld/internal/router"
) )
const selfCheckFQDN = "verify.controld.com"
var ( var (
version = "dev" version = "dev"
commit = "none" commit = "none"
@@ -138,16 +136,7 @@ func initCLI() {
mainLog.Fatal().Err(err).Msg("failed create new service") mainLog.Fatal().Err(err).Msg("failed create new service")
} }
s = newService(s) 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 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") mainLog.Error().Err(err).Msg("failed to start service")
} }
}() }()
@@ -176,9 +165,13 @@ func initCLI() {
initLogging() initLogging()
if setupRouter { 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") 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() processCDFlags()
@@ -241,6 +234,8 @@ func initCLI() {
runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
runCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") 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().StringVarP(&homedir, "homedir", "", "", "")
_ = runCmd.Flags().MarkHidden("homedir") _ = runCmd.Flags().MarkHidden("homedir")
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
@@ -298,6 +293,10 @@ func initCLI() {
processCDFlags() 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, // 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 // or sub-process env is different with the parent, we still behave correctly and use
// the expected config file. // the expected config file.
@@ -319,7 +318,7 @@ func initCLI() {
{s.Start, true}, {s.Start, true},
} }
if doTasks(tasks) { 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") mainLog.Warn().Err(err).Msg("post installation failed, please check system/service log for details error")
return return
} }
@@ -329,7 +328,8 @@ func initCLI() {
return return
} }
status = selfCheckStatus(status) domain := cfg.Upstream["0"].VerifyDomain()
status = selfCheckStatus(status, domain)
switch status { switch status {
case service.StatusRunning: case service.StatusRunning:
mainLog.Notice().Msg("Service started") mainLog.Notice().Msg("Service started")
@@ -354,6 +354,8 @@ func initCLI() {
startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") 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().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().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`)
_ = startCmd.Flags().MarkHidden("router") _ = startCmd.Flags().MarkHidden("router")
@@ -474,7 +476,7 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
} }
prog.resetDNS() prog.resetDNS()
mainLog.Debug().Msg("Router cleanup") 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.Warn().Err(err).Msg("could not cleanup router")
} }
mainLog.Notice().Msg("Service uninstalled") mainLog.Notice().Msg("Service uninstalled")
@@ -708,7 +710,7 @@ func processCDFlags() {
} }
logger := mainLog.With().Str("mode", "cd").Logger() logger := mainLog.With().Str("mode", "cd").Logger()
logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) 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 { if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode {
s, err := service.New(&prog{}, svcConfig) s, err := service.New(&prog{}, svcConfig)
if err != nil { if err != nil {
@@ -854,17 +856,25 @@ func netInterface(ifaceName string) (*net.Interface, error) {
func defaultIfaceName() string { func defaultIfaceName() string {
dri, err := interfaces.DefaultRouteInterface() dri, err := interfaces.DefaultRouteInterface()
if err != nil { 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") mainLog.Fatal().Err(err).Msg("failed to get default route interface")
} }
return dri 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) c := new(dns.Client)
bo := backoff.NewBackoff("self-check", logf, 10*time.Second) bo := backoff.NewBackoff("self-check", logf, 10*time.Second)
bo.LogLongerThan = 500 * time.Millisecond bo.LogLongerThan = 500 * time.Millisecond
ctx := context.Background() ctx := context.Background()
err := errors.New("query failed")
maxAttempts := 20 maxAttempts := 20
mainLog.Debug().Msg("Performing self-check") mainLog.Debug().Msg("Performing self-check")
var ( var (
@@ -888,16 +898,16 @@ func selfCheckStatus(status service.Status) service.Status {
} }
mu.Unlock() mu.Unlock()
m := new(dns.Msg) m := new(dns.Msg)
m.SetQuestion(selfCheckFQDN+".", dns.TypeA) m.SetQuestion(domain+".", dns.TypeA)
m.RecursionDesired = true 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 { 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 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 return service.StatusUnknown
} }
@@ -907,7 +917,7 @@ func unsupportedPlatformHelp(cmd *cobra.Command) {
func userHomeDir() (string, error) { func userHomeDir() (string, error) {
switch router.Name() { switch router.Name() {
case router.DDWrt, router.Merlin: case router.DDWrt, router.Merlin, router.Tomato:
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -1,3 +1,5 @@
//go:build linux || freebsd
package main package main
import ( import (
@@ -42,12 +44,11 @@ func initRouterCLI() {
if platform == "auto" { if platform == "auto" {
platform = router.Name() platform = router.Name()
} }
switch platform { if !router.IsSupported(platform) {
case router.DDWrt, router.Merlin, router.OpenWrt, router.Ubios:
default:
unsupportedPlatformHelp(cmd) unsupportedPlatformHelp(cmd)
os.Exit(1) os.Exit(1)
} }
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
mainLog.Fatal().Msgf("could not find executable path: %v", err) 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().StringVarP(&logPath, "log", "", "", "Path to log file")
routerCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") routerCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
routerCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") 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`) routerCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
tmpl := routerCmd.UsageTemplate() tmpl := routerCmd.UsageTemplate()

View File

@@ -1,4 +1,4 @@
//go:build !linux //go:build !linux && !freebsd
package main package main

View File

@@ -50,11 +50,12 @@ func (p *prog) serveDNS(listenerNum string) error {
q := m.Question[0] q := m.Question[0]
domain := canonicalName(q.Name) domain := canonicalName(q.Name)
reqId := requestID() 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() t := time.Now()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
ctrld.Log(ctx, mainLog.Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain) 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 var answer *dns.Msg
if !matched && listenerConfig.Restricted { if !matched && listenerConfig.Restricted {
answer = new(dns.Msg) answer = new(dns.Msg)
@@ -418,6 +419,28 @@ func macFromMsg(msg *dns.Msg) string {
return "" 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, // runDNSServer starts a DNS server for given address and network,
// with the given handler. It ensures the server has started listening. // with the given handler. It ensures the server has started listening.
// Any error will be reported to the caller via returned channel. // 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() waitLock.Lock()
return s, errCh 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
}

View File

@@ -174,7 +174,7 @@ func Test_macFromMsg(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
m := new(dns.Msg) 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}} o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
if tc.wantMac { if tc.wantMac {
ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw} 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())
}
})
}
}

View File

@@ -27,6 +27,7 @@ var (
verbose int verbose int
silent bool silent bool
cdUID string cdUID string
cdDev bool
iface string iface string
ifaceStartStop string ifaceStartStop string
setupRouter bool setupRouter bool

View File

@@ -175,7 +175,10 @@ func (p *prog) setDNS() {
switch router.Name() { switch router.Name() {
case router.DDWrt, router.OpenWrt, router.Ubios: case router.DDWrt, router.OpenWrt, router.Ubios:
// On router, ctrld run as a DNS forwarder, it does not have to change system DNS. // 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 return
} }
if cfg.Listener == nil || cfg.Listener["0"] == nil { if cfg.Listener == nil || cfg.Listener["0"] == nil {

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
) )
func (p *prog) preRun() { func (p *prog) preRun() {
@@ -17,6 +19,13 @@ func setDependencies(svc *service.Config) {
"Wants=NetworkManager-wait-online.service", "Wants=NetworkManager-wait-online.service",
"After=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) { func setWorkingDirectory(svc *service.Config, dir string) {

View File

@@ -13,7 +13,8 @@ import (
func newService(s service.Service) service.Service { func newService(s service.Service) service.Service {
// TODO: unify for other SysV system. // TODO: unify for other SysV system.
if router.IsGLiNet() { switch {
case router.IsGLiNet(), router.IsOldOpenwrt():
return &sysV{s} return &sysV{s}
} }
return s return s

View File

@@ -24,14 +24,31 @@ import (
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
) )
// IpStackBoth ...
const ( const (
IpStackBoth = "both" // IpStackBoth indicates that ctrld will use either ipv4 or ipv6 for connecting to upstream,
IpStackV4 = "v4" // depending on which stack is available when receiving the DNS query.
IpStackV6 = "v6" 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" 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. // SetConfigName set the config name that ctrld will look for.
// DEPRECATED: use SetConfigNameWithPath instead. // 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 // UpstreamSendClientInfo reports whether the upstream is
// configured to send client info to Control D DNS server. // configured to send client info to Control D DNS server.
// //
@@ -224,6 +258,7 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
return false return false
} }
// BootstrapIPs returns the bootstrap IPs list of upstreams.
func (uc *UpstreamConfig) BootstrapIPs() []string { func (uc *UpstreamConfig) BootstrapIPs() []string {
return uc.bootstrapIPs return uc.bootstrapIPs
} }
@@ -347,9 +382,7 @@ func (uc *UpstreamConfig) setupDOHTransportWithoutPingUpstream() {
uc.transport = uc.newDOHTransport(uc.bootstrapIPs6) uc.transport = uc.newDOHTransport(uc.bootstrapIPs6)
case IpStackSplit: case IpStackSplit:
uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4) uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) if hasIPv6() {
defer cancel()
if ctrldnet.IPv6Available(ctx) {
uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6) uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6)
} else { } else {
uc.transport6 = uc.transport4 uc.transport6 = uc.transport4
@@ -419,7 +452,10 @@ func (uc *UpstreamConfig) bootstrapIPForDNSType(dnsType uint16) string {
case dns.TypeA: case dns.TypeA:
return pick(uc.bootstrapIPs4) return pick(uc.bootstrapIPs4)
default: default:
return pick(uc.bootstrapIPs6) if hasIPv6() {
return pick(uc.bootstrapIPs6)
}
return pick(uc.bootstrapIPs4)
} }
} }
return pick(uc.bootstrapIPs) return pick(uc.bootstrapIPs)
@@ -438,7 +474,10 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
case dns.TypeA: case dns.TypeA:
return "tcp4-tls", "udp4" return "tcp4-tls", "udp4"
default: default:
return "tcp6-tls", "udp6" if hasIPv6() {
return "tcp6-tls", "udp6"
}
return "tcp4-tls", "udp4"
} }
} }
return "tcp-tls", "udp" return "tcp-tls", "udp"

View File

@@ -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 { func ptrBool(b bool) *bool {
return &b return &b
} }

View File

@@ -109,6 +109,7 @@ type parallelDialerResult struct {
type quicParallelDialer 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) { func (d *quicParallelDialer) Dial(ctx context.Context, domain string, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
if len(addrs) == 0 { if len(addrs) == 0 {
return nil, errors.New("empty addresses") return nil, errors.New("empty addresses")

View File

@@ -14,10 +14,15 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
## Config Location ## Config Location
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `config.toml` found in following order: `ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
- `$HOME/.ctrld` - `/etc/controld` on *nix.
- Current directory - User's home directory on Windows.
- Same directory with `ctrld` binary on these routers:
- `ddwrt`
- `merlin`
- `freshtomato`
- Current directory.
The user can choose to override default value using command line `--config` or `-c`: The user can choose to override default value using command line `--config` or `-c`:
@@ -38,6 +43,8 @@ if it's existed.
log_path = "" log_path = ""
cache_enable = true cache_enable = true
cache_size = 4096 cache_size = 4096
cache_ttl_override = 60
cache_serve_stale = true
[network.0] [network.0]
cidrs = ["0.0.0.0/0"] cidrs = ["0.0.0.0/0"]
@@ -53,6 +60,7 @@ if it's existed.
name = "Control D - Anti-Malware" name = "Control D - Anti-Malware"
timeout = 5000 timeout = 5000
type = "doh" type = "doh"
ip_stack = "both"
[upstream.1] [upstream.1]
bootstrap_ip = "76.76.2.11" bootstrap_ip = "76.76.2.11"
@@ -60,6 +68,7 @@ if it's existed.
name = "Control D - No Ads" name = "Control D - No Ads"
timeout = 5000 timeout = 5000
type = "doq" type = "doq"
ip_stack = "split"
[upstream.2] [upstream.2]
bootstrap_ip = "76.76.2.22" bootstrap_ip = "76.76.2.22"
@@ -67,6 +76,7 @@ if it's existed.
name = "Control D - Private" name = "Control D - Private"
timeout = 5000 timeout = 5000
type = "dot" type = "dot"
ip_stack = "v4"
[listener.0] [listener.0]
ip = "127.0.0.1" ip = "127.0.0.1"
@@ -104,8 +114,8 @@ Logging level you wish to enable.
- Type: string - Type: string
- Required: no - Required: no
- Valid values: `debug`, `info`, `warn`, `error`, `fatal`, `panic` - Valid values: `debug`, `info`, `warn`, `notice`, `error`, `fatal`, `panic`
- Default: `info` - Default: `notice`
### log_path ### log_path
@@ -113,12 +123,14 @@ Relative or absolute path of the log file.
- Type: string - Type: string
- Required: no - Required: no
- Default: ""
### cache_enable ### cache_enable
When `cache_enable = true`, all resolved DNS query responses will be cached for duration of the upstream record TTLs. When `cache_enable = true`, all resolved DNS query responses will be cached for duration of the upstream record TTLs.
- Type: boolean - Type: boolean
- Required: no - Required: no
- Default: false
### cache_size ### cache_size
The number of cached records, must be a positive integer. Tweaking this value with care depends on your available RAM. The number of cached records, must be a positive integer. Tweaking this value with care depends on your available RAM.
@@ -128,29 +140,22 @@ An invalid `cache_size` value will disable the cache, regardless of `cache_enabl
- Type: int - Type: int
- Required: no - Required: no
- Default: 4096
### cache_ttl_override ### cache_ttl_override
When `cache_ttl_override` is set to a positive value (in seconds), TTLs are overridden to this value and cached for this long. When `cache_ttl_override` is set to a positive value (in seconds), TTLs are overridden to this value and cached for this long.
- Type: int - Type: int
- Required: no - Required: no
- Default: 0
### cache_serve_stale ### cache_serve_stale
When `cache_serve_stale = true`, in cases of upstream failures (upstreams not reachable), `ctrld` will keep serving When `cache_serve_stale = true`, in cases of upstream failures (upstreams not reachable), `ctrld` will keep serving
stale cached records (regardless of their TTLs) until upstream comes online. stale cached records (regardless of their TTLs) until upstream comes online.
The above config will look like this at query time. - Type: boolean
- Required: no
``` - Default: false
2022-11-14T22:18:53.808 INF Setting bootstrap IP for upstream.0 bootstrap_ip=76.76.2.11
2022-11-14T22:18:53.808 INF Starting DNS server on listener.0: 127.0.0.1:53
2022-11-14T22:18:56.381 DBG [9fd5d3] 127.0.0.1:53978 -> listener.0: 127.0.0.1:53: received query: verify.controld.com
2022-11-14T22:18:56.381 INF [9fd5d3] no policy, no network, no rule -> [upstream.0]
2022-11-14T22:18:56.381 DBG [9fd5d3] sending query to upstream.0: Control D - DOH Free
2022-11-14T22:18:56.381 DBG [9fd5d3] debug dial context freedns.controld.com:443 - tcp - 76.76.2.0
2022-11-14T22:18:56.381 DBG [9fd5d3] sending doh request to: 76.76.2.11:443
2022-11-14T22:18:56.420 DBG [9fd5d3] received response of 118 bytes in 39.662597ms
```
## Upstream ## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to. The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -162,6 +167,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - DOH" name = "Control D - DOH"
timeout = 5000 timeout = 5000
type = "doh" type = "doh"
ip_stack = "split"
[upstream.1] [upstream.1]
bootstrap_ip = "" bootstrap_ip = ""
@@ -169,6 +175,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - DOH3" name = "Control D - DOH3"
timeout = 5000 timeout = 5000
type = "doh3" type = "doh3"
ip_stack = "both"
[upstream.2] [upstream.2]
bootstrap_ip = "" bootstrap_ip = ""
@@ -176,6 +183,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Controld D - DOT" name = "Controld D - DOT"
timeout = 5000 timeout = 5000
type = "dot" type = "dot"
ip_stack = "v4"
[upstream.3] [upstream.3]
bootstrap_ip = "" bootstrap_ip = ""
@@ -183,6 +191,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Controld D - DOT" name = "Controld D - DOT"
timeout = 5000 timeout = 5000
type = "doq" type = "doq"
ip_stack = "v6"
[upstream.4] [upstream.4]
bootstrap_ip = "" bootstrap_ip = ""
@@ -190,6 +199,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - Ad Blocking" name = "Control D - Ad Blocking"
timeout = 5000 timeout = 5000
type = "legacy" type = "legacy"
ip_stack = "both"
``` ```
### bootstrap_ip ### bootstrap_ip
@@ -200,6 +210,7 @@ If `bootstrap_ip` is empty, `ctrld` will resolve this itself using its own boots
- type: ip address string - type: ip address string
- required: no - required: no
- Default: ""
### endpoint ### endpoint
IP address, hostname or URL of upstream DNS. Used together with `Type` of the endpoint. IP address, hostname or URL of upstream DNS. Used together with `Type` of the endpoint.
@@ -214,6 +225,7 @@ Human-readable name of the upstream.
- Type: string - Type: string
- Required: no - Required: no
- Default: ""
### timeout ### timeout
Timeout in milliseconds before request failsover to the next upstream (if defined). Timeout in milliseconds before request failsover to the next upstream (if defined).
@@ -221,7 +233,8 @@ Timeout in milliseconds before request failsover to the next upstream (if define
Value `0` means no timeout. Value `0` means no timeout.
- Type: number - Type: number
- required: no - Required: no
- Default: 0
### type ### type
The protocol that `ctrld` will use to send DNS requests to upstream. The protocol that `ctrld` will use to send DNS requests to upstream.
@@ -266,12 +279,14 @@ Name of the network.
- Type: string - Type: string
- Required: no - Required: no
- Default: ""
### cidrs ### cidrs
Specifies the network addresses that the `listener` will accept requests from. You will see more details in the listener policy section. Specifies the network addresses that the `listener` will accept requests from. You will see more details in the listener policy section.
- Type: array of network CIDR string - Type: array of network CIDR string
- Required: no - Required: no
- Default: []
## listener ## listener
@@ -291,18 +306,23 @@ The `[listener]` section specifies the ip and port of the local DNS server. You
### ip ### ip
IP address that serves the incoming requests. If `ip` is empty, ctrld will listen on all available addresses. IP address that serves the incoming requests. If `ip` is empty, ctrld will listen on all available addresses.
- Type: ip address - Type: ip address string
- Required: no
- Default: ""
### port ### port
Port number that the listener will listen on for incoming requests. If `port` is `0`, a random available port will be chosen. Port number that the listener will listen on for incoming requests. If `port` is `0`, a random available port will be chosen.
- Type: number - Type: number
- Required: no
- Default: 0
### restricted ### restricted
If set to `true` makes the listener `REFUSE` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`. If set to `true` makes the listener `REFUSE` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`.
- Type: bool - Type: bool
- Required: no - Required: no
- Default: false
### policy ### policy
Allows `ctrld` to set policy rules to determine which upstreams the requests will be forwarded to. Allows `ctrld` to set policy rules to determine which upstreams the requests will be forwarded to.
@@ -346,19 +366,30 @@ rules = [
- Type: string - Type: string
- Required: no - Required: no
- Default: ""
### networks: ### networks:
`networks` is the list of network rules of the policy. `networks` is the list of network rules of the policy.
- type: array of networks - Type: array of networks
- Required: no
- Default: []
### rules: ### rules:
`rules` is the list of domain rules within the policy. Domain can be either FQDN or wildcard domain. `rules` is the list of domain rules within the policy. Domain can be either FQDN or wildcard domain.
- type: array of rule - Type: array of rule
- Required: no
- Default: []
### failover_rcodes ### failover_rcodes
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`. For example: For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`.
- Type: array of string
- Required: no
- Default: []
-
For example:
```toml ```toml
[listener.0.policy] [listener.0.policy]

13
doh.go
View File

@@ -13,10 +13,9 @@ import (
) )
const ( const (
DoHMacHeader = "x-cd-mac" dohMacHeader = "x-cd-mac"
DoHIPHeader = "x-cd-ip" dohIPHeader = "x-cd-ip"
DoHHostHeader = "x-cd-host" dohHostHeader = "x-cd-host"
headerApplicationDNS = "application/dns-message" headerApplicationDNS = "application/dns-message"
) )
@@ -101,13 +100,13 @@ func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) {
if sendClientInfo { if sendClientInfo {
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil { if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
if ci.Mac != "" { if ci.Mac != "" {
req.Header.Set(DoHMacHeader, ci.Mac) req.Header.Set(dohMacHeader, ci.Mac)
} }
if ci.IP != "" { if ci.IP != "" {
req.Header.Set(DoHIPHeader, ci.IP) req.Header.Set(dohIPHeader, ci.IP)
} }
if ci.Hostname != "" { if ci.Hostname != "" {
req.Header.Set(DoHHostHeader, ci.Hostname) req.Header.Set(dohHostHeader, ci.Hostname)
} }
} }
} }

1
dot.go
View File

@@ -33,6 +33,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
endpoint := r.uc.Endpoint endpoint := r.uc.Endpoint
if r.uc.BootstrapIP != "" { if r.uc.BootstrapIP != "" {
dnsClient.TLSConfig.ServerName = r.uc.Domain dnsClient.TLSConfig.ServerName = r.uc.Domain
dnsClient.Net = "tcp-tls"
_, port, _ := net.SplitHostPort(endpoint) _, port, _ := net.SplitHostPort(endpoint)
endpoint = net.JoinHostPort(r.uc.BootstrapIP, port) endpoint = net.JoinHostPort(r.uc.BootstrapIP, port)
} }

View File

@@ -17,9 +17,11 @@ import (
) )
const ( const (
apiDomain = "api.controld.com" apiDomainCom = "api.controld.com"
resolverDataURL = "https://api.controld.com/utility" apiDomainDev = "api.controld.dev"
InvalidConfigCode = 40401 resolverDataURLCom = "https://api.controld.com/utility"
resolverDataURLDev = "https://api.controld.dev/utility"
InvalidConfigCode = 40401
) )
// ResolverConfig represents Control D resolver data. // ResolverConfig represents Control D resolver data.
@@ -54,9 +56,13 @@ type utilityRequest struct {
} }
// FetchResolverConfig fetch Control D config for given uid. // 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}) 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 { if err != nil {
return nil, fmt.Errorf("http.NewRequest: %w", err) 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") req.Header.Add("Content-Type", "application/json")
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
apiDomain := apiDomainCom
if cdDev {
apiDomain = apiDomainDev
}
ips := ctrld.LookupIP(apiDomain) ips := ctrld.LookupIP(apiDomain)
if len(ips) == 0 { if len(ips) == 0 {
ctrld.ProxyLog.Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr) ctrld.ProxyLog.Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr)

View File

@@ -13,16 +13,18 @@ func TestFetchResolverConfig(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
uid string uid string
dev bool
wantErr bool wantErr bool
}{ }{
{"valid", "p2", false}, {"valid com", "p2", false, false},
{"invalid uid", "abcd1234", true}, {"valid dev", "p2", true, false},
{"invalid uid", "abcd1234", false, true},
} }
for _, tc := range tests { for _, tc := range tests {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() 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) require.False(t, (err != nil) != tc.wantErr, err)
if !tc.wantErr { if !tc.wantErr {
assert.NotEmpty(t, got.DOH) assert.NotEmpty(t, got.DOH)

View File

@@ -1,6 +1,7 @@
package router package router
import ( import (
"bufio"
"bytes" "bytes"
"io" "io"
"log" "log"
@@ -15,14 +16,25 @@ import (
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
) )
var clientInfoFiles = []string{ // readClientInfoFunc represents the function for reading client info.
"/tmp/dnsmasq.leases", // ddwrt type readClientInfoFunc func(name string) error
"/tmp/dhcp.leases", // openwrt
"/var/lib/misc/dnsmasq.leases", // merlin // clientInfoFiles specifies client info files and how to read them on supported platforms.
"/mnt/data/udapi-config/dnsmasq.lease", // UDM Pro var clientInfoFiles = map[string]readClientInfoFunc{
"/data/udapi-config/dnsmasq.lease", // UDR "/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() { func (r *router) watchClientInfoTable() {
if r.watcher == nil { if r.watcher == nil {
return return
@@ -32,14 +44,19 @@ func (r *router) watchClientInfoTable() {
select { select {
case <-timer.C: case <-timer.C:
for _, name := range r.watcher.WatchList() { for _, name := range r.watcher.WatchList() {
_ = readClientInfoFile(name) _ = clientInfoFiles[name](name)
} }
case event, ok := <-r.watcher.Events: case event, ok := <-r.watcher.Events:
if !ok { if !ok {
return return
} }
if event.Has(fsnotify.Write) { 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) 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 { func Stop() error {
if Name() == "" { if Name() == "" {
return nil return nil
@@ -65,6 +83,7 @@ func Stop() error {
return nil return nil
} }
// GetClientInfoByMac returns ClientInfo for the client associated with the given mac.
func GetClientInfoByMac(mac string) *ctrld.ClientInfo { func GetClientInfoByMac(mac string) *ctrld.ClientInfo {
if mac == "" { if mac == "" {
return nil return nil
@@ -78,21 +97,23 @@ func GetClientInfoByMac(mac string) *ctrld.ClientInfo {
return val.(*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) f, err := os.Open(name)
if err != nil { if err != nil {
return err return err
} }
defer f.Close() 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() r := routerPlatform.Load()
return lineread.Reader(reader, func(line []byte) error { return lineread.Reader(reader, func(line []byte) error {
fields := bytes.Fields(line) fields := bytes.Fields(line)
if len(fields) != 5 { if len(fields) < 4 {
return nil return nil
} }
mac := string(fields[1]) 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 { func normalizeIP(in string) string {
// dnsmasq may put ip with interface index in lease file, strip it here. // dnsmasq may put ip with interface index in lease file, strip it here.
ip, _, found := strings.Cut(in, "%") ip, _, found := strings.Cut(in, "%")

View File

@@ -1,6 +1,7 @@
package router package router
import ( import (
"io"
"strings" "strings"
"testing" "testing"
@@ -31,31 +32,65 @@ func Test_normalizeIP(t *testing.T) {
func Test_readClientInfoReader(t *testing.T) { func Test_readClientInfoReader(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
in string in string
mac 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 `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", "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 `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 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 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", "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 { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
r := routerPlatform.Load() r := routerPlatform.Load()
r.mac.Delete(tc.mac) 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) t.Errorf("readClientInfoReader() error = %v", err)
} }
info, existed := r.mac.Load(tc.mac) 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 { if ci, ok := info.(*ctrld.ClientInfo); ok && existed && ci.Mac != tc.mac {
t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac) t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac)
} else {
t.Log(ci)
} }
}) })
} }

View File

@@ -7,9 +7,10 @@ import (
) )
const ( const (
nvramCtrldKeyPrefix = "ctrld_" nvramCtrldKeyPrefix = "ctrld_"
nvramCtrldSetupKey = "ctrld_setup" nvramCtrldSetupKey = "ctrld_setup"
nvramRCStartupKey = "rc_startup" nvramCtrldInstallKey = "ctrld_install"
nvramRCStartupKey = "rc_startup"
) )
//lint:ignore ST1005 This error is for human. //lint:ignore ST1005 This error is for human.
@@ -29,14 +30,14 @@ func setupDDWrt() error {
return err return err
} }
nvramKvMap := nvramKV() nvramKvMap := nvramSetupKV()
nvramKvMap["dnsmasq_options"] = data nvramKvMap["dnsmasq_options"] = data
if err := nvramSetup(nvramKvMap); err != nil { if err := nvramSetKV(nvramKvMap, nvramCtrldSetupKey); err != nil {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := ddwrtRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil
@@ -44,11 +45,11 @@ func setupDDWrt() error {
func cleanupDDWrt() error { func cleanupDDWrt() error {
// Restore old configs. // Restore old configs.
if err := nvramRestore(nvramKV()); err != nil { if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := ddwrtRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil

View File

@@ -49,7 +49,7 @@ func dnsMasqConf() (string, error) {
var sb strings.Builder var sb strings.Builder
var tmplText string var tmplText string
switch Name() { switch Name() {
case DDWrt, OpenWrt, Ubios: case EdgeOS, DDWrt, OpenWrt, Ubios, Synology, Tomato:
tmplText = dnsMasqConfigContentTmpl tmplText = dnsMasqConfigContentTmpl
case Merlin: case Merlin:
tmplText = merlinDNSMasqPostConfTmpl tmplText = merlinDNSMasqPostConfTmpl
@@ -65,3 +65,23 @@ func dnsMasqConf() (string, error) {
} }
return sb.String(), nil 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")
}

56
internal/router/edgeos.go Normal file
View File

@@ -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
}

View File

@@ -35,11 +35,11 @@ func setupMerlin() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := merlinRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
if err := nvramSetup(nvramKV()); err != nil { if err := nvramSetKV(nvramSetupKV(), nvramCtrldSetupKey); err != nil {
return err return err
} }
@@ -48,7 +48,7 @@ func setupMerlin() error {
func cleanupMerlin() error { func cleanupMerlin() error {
// Restore old configs. // Restore old configs.
if err := nvramRestore(nvramKV()); err != nil { if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil {
return err return err
} }
buf, err := os.ReadFile(merlinDNSMasqPostConfPath) buf, err := os.ReadFile(merlinDNSMasqPostConfPath)
@@ -60,7 +60,7 @@ func cleanupMerlin() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := merlinRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil

View File

@@ -26,7 +26,7 @@ NOTE:
+https://community.ui.com/questions/Implement-DNSSEC-into-UniFi/951c72b0-4d88-4c86-9174-45417bd2f9ca +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 +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() { switch Name() {
case DDWrt: case DDWrt:
return map[string]string{ return map[string]string{
@@ -39,11 +39,28 @@ func nvramKV() map[string]string {
return map[string]string{ return map[string]string{
"dnspriv_enable": "0", // Ensure Merlin native DoT disabled. "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 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. // Backup current value, store ctrld's configs.
for key, value := range m { for key, value := range m {
old, err := nvram("get", key) 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) return fmt.Errorf("%s: %w", out, err)
} }
// Commit. // Commit.
@@ -68,7 +85,7 @@ func nvramSetup(m map[string]string) error {
return nil return nil
} }
func nvramRestore(m map[string]string) error { func nvramRestore(m map[string]string, setupKey string) error {
// Restore old configs. // Restore old configs.
for key := range m { for key := range m {
ctrldKey := nvramCtrldKeyPrefix + key 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) return fmt.Errorf("%s: %w", out, err)
} }
// Commit. // Commit.

View File

@@ -23,12 +23,21 @@ func IsGLiNet() bool {
return bytes.Contains(buf, []byte(" (glinet")) 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 { func setupOpenWrt() error {
// Delete dnsmasq port if set. // Delete dnsmasq port if set.
if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) { if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) {
return err return err
} }
// Disable dnsmasq as DNS server.
dnsMasqConfigContent, err := dnsMasqConf() dnsMasqConfigContent, err := dnsMasqConf()
if err != nil { if err != nil {
return err return err
@@ -41,7 +50,7 @@ func setupOpenWrt() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := openwrtRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil
@@ -53,7 +62,7 @@ func cleanupOpenWrt() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := openwrtRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil

View File

@@ -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"
`

View File

@@ -19,10 +19,14 @@ import (
) )
const ( const (
OpenWrt = "openwrt" OpenWrt = "openwrt"
DDWrt = "ddwrt" DDWrt = "ddwrt"
Merlin = "merlin" Merlin = "merlin"
Ubios = "ubios" Ubios = "ubios"
Synology = "synology"
Tomato = "tomato"
EdgeOS = "edgeos"
Pfsense = "pfsense"
) )
// ErrNotSupported reports the current router is not supported error. // ErrNotSupported reports the current router is not supported error.
@@ -37,23 +41,36 @@ type router struct {
watcher *fsnotify.Watcher 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. // SupportedPlatforms return all platforms that can be configured to run with ctrld.
func SupportedPlatforms() []string { 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{ var configureFunc = map[string]func() error{
DDWrt: setupDDWrt, EdgeOS: setupEdgeOS,
Merlin: setupMerlin, DDWrt: setupDDWrt,
OpenWrt: setupOpenWrt, Merlin: setupMerlin,
Ubios: setupUbiOS, OpenWrt: setupOpenWrt,
Pfsense: setupPfsense,
Synology: setupSynology,
Tomato: setupTomato,
Ubios: setupUbiOS,
} }
// Configure configures things for running ctrld on the router. // Configure configures things for running ctrld on the router.
func Configure(c *ctrld.Config) error { func Configure(c *ctrld.Config) error {
name := Name() name := Name()
switch name { switch name {
case DDWrt, Merlin, OpenWrt, Ubios: case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios:
if c.HasUpstreamSendClientInfo() { if c.HasUpstreamSendClientInfo() {
r := routerPlatform.Load() r := routerPlatform.Load()
r.sendClientInfo = true r.sendClientInfo = true
@@ -63,8 +80,8 @@ func Configure(c *ctrld.Config) error {
} }
r.watcher = watcher r.watcher = watcher
go r.watchClientInfoTable() go r.watchClientInfoTable()
for _, file := range clientInfoFiles { for file, readClienInfoFunc := range clientInfoFiles {
_ = readClientInfoFile(file) _ = readClienInfoFunc(file)
_ = r.watcher.Add(file) _ = r.watcher.Add(file)
} }
} }
@@ -88,67 +105,53 @@ func ConfigureService(sc *service.Config) error {
} }
case OpenWrt: case OpenWrt:
sc.Option["SysvScript"] = openWrtScript sc.Option["SysvScript"] = openWrtScript
case Merlin, Ubios: case Pfsense:
sc.Option["SysvScript"] = pfsenseInitScript
case EdgeOS, Merlin, Synology, Tomato, Ubios:
} }
return nil return nil
} }
// PreStart blocks until the router is ready for running ctrld. // PreRun blocks until the router is ready for running ctrld.
func PreStart() (err error) { func PreRun() (err error) {
if Name() != DDWrt { // 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 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. // PostInstall performs task after installing ctrld on router.
func PostInstall() error { func PostInstall(svc *service.Config) error {
name := Name() name := Name()
switch name { switch name {
case EdgeOS:
return postInstallEdgeOS()
case DDWrt: case DDWrt:
return postInstallDDWrt() return postInstallDDWrt()
case Merlin: case Merlin:
return postInstallMerlin() return postInstallMerlin()
case OpenWrt: case OpenWrt:
return postInstallOpenWrt() return postInstallOpenWrt()
case Pfsense:
return postInstallPfsense(svc)
case Synology:
return postInstallSynology()
case Tomato:
return postInstallTomato()
case Ubios: case Ubios:
return postInstallUbiOS() return postInstallUbiOS()
} }
@@ -156,15 +159,23 @@ func PostInstall() error {
} }
// Cleanup cleans ctrld setup on the router. // Cleanup cleans ctrld setup on the router.
func Cleanup() error { func Cleanup(svc *service.Config) error {
name := Name() name := Name()
switch name { switch name {
case EdgeOS:
return cleanupEdgeOS()
case DDWrt: case DDWrt:
return cleanupDDWrt() return cleanupDDWrt()
case Merlin: case Merlin:
return cleanupMerlin() return cleanupMerlin()
case OpenWrt: case OpenWrt:
return cleanupOpenWrt() return cleanupOpenWrt()
case Pfsense:
return cleanupPfsense(svc)
case Synology:
return cleanupSynology()
case Tomato:
return cleanupTomato()
case Ubios: case Ubios:
return cleanupUbiOS() return cleanupUbiOS()
} }
@@ -175,8 +186,10 @@ func Cleanup() error {
func ListenAddress() string { func ListenAddress() string {
name := Name() name := Name()
switch name { switch name {
case DDWrt, Merlin, OpenWrt, Ubios: case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios:
return "127.0.0.1:5354" return "127.0.0.1:5354"
case Pfsense:
// On pfsense, we run ctrld as DNS resolver.
} }
return "" return ""
} }
@@ -194,14 +207,24 @@ func Name() string {
func distroName() string { func distroName() string {
switch { switch {
case bytes.HasPrefix(uname(), []byte("DD-WRT")): case bytes.HasPrefix(unameO(), []byte("DD-WRT")):
return DDWrt return DDWrt
case bytes.HasPrefix(uname(), []byte("ASUSWRT-Merlin")): case bytes.HasPrefix(unameO(), []byte("ASUSWRT-Merlin")):
return Merlin return Merlin
case haveFile("/etc/openwrt_version"): case haveFile("/etc/openwrt_version"):
return OpenWrt return OpenWrt
case haveDir("/data/unifi"): case haveDir("/data/unifi"):
return Ubios 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 "" return ""
} }
@@ -216,7 +239,17 @@ func haveDir(dir string) bool {
return fi != nil && fi.IsDir() return fi != nil && fi.IsDir()
} }
func uname() []byte { func unameO() []byte {
out, _ := exec.Command("uname", "-o").Output() out, _ := exec.Command("uname", "-o").Output()
return out 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"))
}

View File

@@ -48,6 +48,15 @@ func init() {
}, },
new: newUbiosService, 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()...) systems = append(systems, service.AvailableSystems()...)
service.ChooseSystem(systems...) service.ChooseSystem(systems...)

View File

@@ -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
`

View File

@@ -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
}

82
internal/router/tomato.go Normal file
View File

@@ -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
}

View File

@@ -2,12 +2,17 @@ package router
import ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"strconv" "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 ( 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 { func setupUbiOS() error {
@@ -20,7 +25,7 @@ func setupUbiOS() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := ubiosRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil
@@ -32,13 +37,17 @@ func cleanupUbiOS() error {
return err return err
} }
// Restart dnsmasq service. // Restart dnsmasq service.
if err := ubiosRestartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
} }
return nil return nil
} }
func postInstallUbiOS() error { func postInstallUbiOS() error {
// See comment in postInstallEdgeOS.
if contentFilteringEnabled() {
return errContentFilteringEnabled
}
return nil return nil
} }
@@ -57,3 +66,8 @@ func ubiosRestartDNSMasq() error {
} }
return proc.Kill() return proc.Kill()
} }
func contentFilteringEnabled() bool {
st, err := os.Stat("/run/dnsfilter/dnsfilter")
return err == nil && !st.IsDir()
}

46
net.go Normal file
View File

@@ -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"))
}
}

View File

@@ -12,11 +12,17 @@ import (
) )
const ( const (
ResolverTypeDOH = "doh" // ResolverTypeDOH specifies DoH resolver.
ResolverTypeDOH3 = "doh3" ResolverTypeDOH = "doh"
ResolverTypeDOT = "dot" // ResolverTypeDOH3 specifies DoH3 resolver.
ResolverTypeDOQ = "doq" ResolverTypeDOH3 = "doh3"
ResolverTypeOS = "os" // ResolverTypeDOT specifies DoT resolver.
ResolverTypeDOT = "dot"
// ResolverTypeDOQ specifies DoQ resolver.
ResolverTypeDOQ = "doq"
// ResolverTypeOS specifies OS resolver.
ResolverTypeOS = "os"
// ResolverTypeLegacy specifies legacy resolver.
ResolverTypeLegacy = "legacy" ResolverTypeLegacy = "legacy"
) )
@@ -125,7 +131,14 @@ func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, e
Net: udpNet, Net: udpNet,
Dialer: dialer, 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 return answer, err
} }
@@ -194,3 +207,17 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string)
} }
return ips 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
}