From 0645a738ad8cb3fe1564a07f34d088044552108e Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 20 Apr 2023 23:16:20 +0700 Subject: [PATCH] all: add router client info detection This commit add the ability for ctrld to gather client information, including mac/ip/hostname, and send to Control-D server through a config per upstream. - Add send_client_info upstream config. - Read/Watch dnsmasq leases files on supported platforms. - Add corresponding client info to DoH query header All of these only apply for Control-D upstream, though. --- client_info.go | 11 +++++ cmd/ctrld/dns_proxy.go | 28 ++++++++++- cmd/ctrld/dns_proxy_test.go | 36 ++++++++++++++ cmd/ctrld/prog.go | 3 ++ config.go | 54 ++++++++++++++++++--- config_internal_test.go | 26 ++++++++++ config_test.go | 6 ++- doh.go | 32 ++++++++++++- internal/router/client_info.go | 88 ++++++++++++++++++++++++++++++++++ internal/router/ddwrt.go | 11 +++-- internal/router/dnsmasq.go | 37 ++++++++++++-- internal/router/merlin.go | 6 ++- internal/router/merlin_test.go | 2 +- internal/router/openwrt.go | 4 ++ internal/router/router.go | 42 ++++++++++++---- internal/router/ubios.go | 4 ++ testhelper/config.go | 7 +++ 17 files changed, 370 insertions(+), 27 deletions(-) create mode 100644 client_info.go create mode 100644 internal/router/client_info.go diff --git a/client_info.go b/client_info.go new file mode 100644 index 0000000..d0d993a --- /dev/null +++ b/client_info.go @@ -0,0 +1,11 @@ +package ctrld + +// ClientInfoCtxKey is the context key to store client info. +type ClientInfoCtxKey struct{} + +// ClientInfo represents ctrld's clients information. +type ClientInfo struct { + Mac string + IP string + Hostname string +} diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index 757c7ed..a76f398 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -20,7 +20,13 @@ import ( "github.com/Control-D-Inc/ctrld/internal/router" ) -const staleTTL = 60 * time.Second +const ( + staleTTL = 60 * time.Second + // EDNS0_OPTION_MAC is dnsmasq EDNS0 code for adding mac option. + // https://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=blob;f=src/dns-protocol.h;h=76ac66a8c28317e9c121a74ab5fd0e20f6237dc8;hb=HEAD#l81 + // This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification. + EDNS0_OPTION_MAC = 0xFDE9 +) var osUpstreamConfig = &ctrld.UpstreamConfig{ Name: "OS resolver", @@ -230,6 +236,12 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i return dnsResolver.Resolve(resolveCtx, msg) } resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { + if upstreamConfig.UpstreamSendClientInfo() { + ci := router.GetClientInfoByMac(macFromMsg(msg)) + if ci != nil { + ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) + } + } answer, err := resolve1(n, upstreamConfig, msg) if err != nil { ctrld.Log(ctx, mainLog.Debug().Err(err), "could not resolve query on first attempt, retrying...") @@ -386,3 +398,17 @@ func dnsListenAddress(lc *ctrld.ListenerConfig) string { } return net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)) } + +func macFromMsg(msg *dns.Msg) string { + if opt := msg.IsEdns0(); opt != nil { + for _, s := range opt.Option { + switch e := s.(type) { + case *dns.EDNS0_LOCAL: + if e.Code == EDNS0_OPTION_MAC { + return net.HardwareAddr(e.Data).String() + } + } + } + } + return "" +} diff --git a/cmd/ctrld/dns_proxy_test.go b/cmd/ctrld/dns_proxy_test.go index 6d64346..c9ff9d9 100644 --- a/cmd/ctrld/dns_proxy_test.go +++ b/cmd/ctrld/dns_proxy_test.go @@ -155,3 +155,39 @@ func TestCache(t *testing.T) { assert.Equal(t, answer1.Rcode, got1.Rcode) assert.Equal(t, answer2.Rcode, got2.Rcode) } + +func Test_macFromMsg(t *testing.T) { + tests := []struct { + name string + mac string + wantMac bool + }{ + {"has mac", "4c:20:b8:ab:87:1b", true}, + {"no mac", "4c:20:b8:ab:87:1b", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + hw, err := net.ParseMAC(tc.mac) + if err != nil { + t.Fatal(err) + } + m := new(dns.Msg) + m.SetQuestion(selfCheckFQDN+".", 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} + o.Option = append(o.Option, ec1) + } + m.Extra = append(m.Extra, o) + got := macFromMsg(m) + if tc.wantMac && got != tc.mac { + t.Errorf("mismatch, want: %q, got: %q", tc.mac, got) + } + if !tc.wantMac && got != "" { + t.Errorf("unexpected mac: %q", got) + } + }) + } +} diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index 08251ef..549d9ff 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -139,6 +139,9 @@ func (p *prog) Stop(s service.Service) error { return err } p.preStop() + if err := router.Stop(); err != nil { + mainLog.Warn().Err(err).Msg("problem occurred while stopping router") + } mainLog.Info().Msg("Service stopped") close(p.stopCh) return nil diff --git a/config.go b/config.go index 7afaac9..c77d73c 100644 --- a/config.go +++ b/config.go @@ -80,6 +80,17 @@ type Config struct { Upstream map[string]*UpstreamConfig `mapstructure:"upstream" toml:"upstream" validate:"min=1,dive"` } +// HasUpstreamSendClientInfo reports whether the config has any upstream +// is configured to send client info to Control D DNS server. +func (c *Config) HasUpstreamSendClientInfo() bool { + for _, uc := range c.Upstream { + if uc.UpstreamSendClientInfo() { + return true + } + } + return false +} + // ServiceConfig specifies the general ctrld config. type ServiceConfig struct { LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"` @@ -101,12 +112,15 @@ type NetworkConfig struct { // UpstreamConfig specifies configuration for upstreams that ctrld will forward requests to. type UpstreamConfig struct { - Name string `mapstructure:"name" toml:"name,omitempty"` - Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy"` - Endpoint string `mapstructure:"endpoint" toml:"endpoint,omitempty" validate:"required_unless=Type os"` - BootstrapIP string `mapstructure:"bootstrap_ip" toml:"bootstrap_ip,omitempty"` - Domain string `mapstructure:"-" toml:"-"` - Timeout int `mapstructure:"timeout" toml:"timeout,omitempty" validate:"gte=0"` + Name string `mapstructure:"name" toml:"name,omitempty"` + Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy"` + Endpoint string `mapstructure:"endpoint" toml:"endpoint,omitempty" validate:"required_unless=Type os"` + BootstrapIP string `mapstructure:"bootstrap_ip" toml:"bootstrap_ip,omitempty"` + Domain string `mapstructure:"-" toml:"-"` + Timeout int `mapstructure:"timeout" toml:"timeout,omitempty" validate:"gte=0"` + // The caller should not access this field directly. + // Use UpstreamSendClientInfo instead. + SendClientInfo *bool `mapstructure:"send_client_info" toml:"send_client_info,omitempty"` transport *http.Transport `mapstructure:"-" toml:"-"` http3RoundTripper http.RoundTripper `mapstructure:"-" toml:"-"` certPool *x509.CertPool `mapstructure:"-" toml:"-"` @@ -163,6 +177,34 @@ func (uc *UpstreamConfig) Init() { } } +// UpstreamSendClientInfo reports whether the upstream is +// configured to send client info to Control D DNS server. +// +// Client info includes: +// - MAC +// - Lan IP +// - Hostname +func (uc *UpstreamConfig) UpstreamSendClientInfo() bool { + if uc.SendClientInfo != nil && !(*uc.SendClientInfo) { + return false + } + if uc.SendClientInfo == nil { + return true + } + switch uc.Type { + case ResolverTypeDOH, ResolverTypeDOH3: + if u, err := url.Parse(uc.Endpoint); err == nil { + domain := u.Hostname() + for _, parent := range []string{"controld.com", "controld.net"} { + if dns.IsSubDomain(parent, domain) { + return true + } + } + } + } + return false +} + // SetCertPool sets the system cert pool used for TLS connections. func (uc *UpstreamConfig) SetCertPool(cp *x509.CertPool) { uc.certPool = cp diff --git a/config_internal_test.go b/config_internal_test.go index a470cf8..4ec872a 100644 --- a/config_internal_test.go +++ b/config_internal_test.go @@ -147,6 +147,28 @@ func TestUpstreamConfig_Init(t *testing.T) { Timeout: 0, }, }, + { + "doh+doh3 with send client info set", + &UpstreamConfig{ + Name: "doh", + Type: "doh", + Endpoint: "https://example.com?k=v", + BootstrapIP: "", + Domain: "", + Timeout: 0, + SendClientInfo: ptrBool(false), + }, + &UpstreamConfig{ + Name: "doh", + Type: "doh", + Endpoint: "https://example.com?k=v", + BootstrapIP: "", + Domain: "example.com", + Timeout: 0, + SendClientInfo: ptrBool(false), + u: u2, + }, + }, } for _, tc := range tests { @@ -158,3 +180,7 @@ func TestUpstreamConfig_Init(t *testing.T) { }) } } + +func ptrBool(b bool) *bool { + return &b +} diff --git a/config_test.go b/config_test.go index e2d75c0..90cd81a 100644 --- a/config_test.go +++ b/config_test.go @@ -24,10 +24,12 @@ func TestLoadConfig(t *testing.T) { assert.Contains(t, cfg.Network, "0") assert.Contains(t, cfg.Network, "1") - assert.Len(t, cfg.Upstream, 3) + assert.Len(t, cfg.Upstream, 4) assert.Contains(t, cfg.Upstream, "0") assert.Contains(t, cfg.Upstream, "1") assert.Contains(t, cfg.Upstream, "2") + assert.Contains(t, cfg.Upstream, "3") + assert.NotNil(t, cfg.Upstream["3"].SendClientInfo) assert.Len(t, cfg.Listener, 2) assert.Contains(t, cfg.Listener, "0") @@ -42,6 +44,8 @@ func TestLoadConfig(t *testing.T) { assert.Len(t, cfg.Listener["0"].Policy.Rules, 2) assert.Contains(t, cfg.Listener["0"].Policy.Rules[0], "*.ru") assert.Contains(t, cfg.Listener["0"].Policy.Rules[1], "*.local.host") + + assert.True(t, cfg.HasUpstreamSendClientInfo()) } func TestLoadDefaultConfig(t *testing.T) { diff --git a/doh.go b/doh.go index 4fd4bd6..1cd9717 100644 --- a/doh.go +++ b/doh.go @@ -12,12 +12,22 @@ import ( "github.com/miekg/dns" ) +const ( + DoHMacHeader = "Dns-Mac" + DoHIPHeader = "Dns-IP" + DoHHostHeader = "Dns-Host" + + headerContentTypeValue = "application/dns-message" + headerAcceptValue = "application/dns-message" +) + func newDohResolver(uc *UpstreamConfig) *dohResolver { r := &dohResolver{ endpoint: uc.u, isDoH3: uc.Type == ResolverTypeDOH3, transport: uc.transport, http3RoundTripper: uc.http3RoundTripper, + sendClientInfo: uc.UpstreamSendClientInfo(), } return r } @@ -27,6 +37,7 @@ type dohResolver struct { isDoH3 bool transport *http.Transport http3RoundTripper http.RoundTripper + sendClientInfo bool } func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { @@ -45,8 +56,7 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro if err != nil { return nil, fmt.Errorf("could not create request: %w", err) } - req.Header.Set("Content-Type", "application/dns-message") - req.Header.Set("Accept", "application/dns-message") + addHeader(ctx, req, r.sendClientInfo) c := http.Client{Transport: r.transport} if r.isDoH3 { @@ -78,3 +88,21 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro answer := new(dns.Msg) return answer, answer.Unpack(buf) } + +func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) { + req.Header.Set("Content-Type", headerContentTypeValue) + req.Header.Set("Accept", headerAcceptValue) + if sendClientInfo { + if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil { + if ci.Mac != "" { + req.Header.Set(DoHMacHeader, ci.Mac) + } + if ci.IP != "" { + req.Header.Set(DoHIPHeader, ci.IP) + } + if ci.Hostname != "" { + req.Header.Set(DoHHostHeader, ci.Hostname) + } + } + } +} diff --git a/internal/router/client_info.go b/internal/router/client_info.go new file mode 100644 index 0000000..05fafa2 --- /dev/null +++ b/internal/router/client_info.go @@ -0,0 +1,88 @@ +package router + +import ( + "bytes" + "log" + "os" + "time" + + "github.com/fsnotify/fsnotify" + "tailscale.com/util/lineread" + + "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 +} + +func (r *router) watchClientInfoTable() { + if r.watcher == nil { + return + } + timer := time.NewTicker(time.Minute * 5) + for { + select { + case <-timer.C: + for _, name := range r.watcher.WatchList() { + _ = readClientInfoFile(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) { + log.Println("could not read client info file:", err) + } + } + case err, ok := <-r.watcher.Errors: + if !ok { + return + } + log.Println("error:", err) + } + } +} + +func Stop() error { + if Name() == "" { + return nil + } + r := routerPlatform.Load() + if r.watcher != nil { + if err := r.watcher.Close(); err != nil { + return err + } + } + return nil +} + +func GetClientInfoByMac(mac string) *ctrld.ClientInfo { + if mac == "" { + return nil + } + _ = Name() + r := routerPlatform.Load() + val, ok := r.mac.Load(mac) + if !ok { + return nil + } + return val.(*ctrld.ClientInfo) +} + +func readClientInfoFile(name string) error { + r := routerPlatform.Load() + return lineread.File(name, func(line []byte) error { + fields := bytes.Fields(line) + mac := string(fields[1]) + ip := string(fields[2]) + hostname := string(fields[3]) + r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) + return nil + }) +} diff --git a/internal/router/ddwrt.go b/internal/router/ddwrt.go index 022e82e..ad6e921 100644 --- a/internal/router/ddwrt.go +++ b/internal/router/ddwrt.go @@ -20,9 +20,9 @@ https://wiki.dd-wrt.com/wiki/index.php/Journalling_Flash_File_System `) var nvramKeys = map[string]string{ - "dns_dnsmasq": "1", // Make dnsmasq running but disable DNS ability, ctrld will replace it. - "dnsmasq_options": dnsMasqConfigContent, // Configuration of dnsmasq set by ctrld. - "dns_crypt": "0", // Disable DNSCrypt. + "dns_dnsmasq": "1", // Make dnsmasq running but disable DNS ability, ctrld will replace it. + "dnsmasq_options": "", // Configuration of dnsmasq set by ctrld, filled by setupDDWrt. + "dns_crypt": "0", // Disable DNSCrypt. } func setupDDWrt() error { @@ -31,6 +31,11 @@ func setupDDWrt() error { return nil } + data, err := dnsMasqConf() + if err != nil { + return err + } + nvramKeys["dnsmasq_options"] = data // Backup current value, store ctrld's configs. for key, value := range nvramKeys { old, err := nvram("get", key) diff --git a/internal/router/dnsmasq.go b/internal/router/dnsmasq.go index d90854a..009bf27 100644 --- a/internal/router/dnsmasq.go +++ b/internal/router/dnsmasq.go @@ -1,14 +1,22 @@ package router -const dnsMasqConfigContent = `# GENERATED BY ctrld - DO NOT MODIFY +import ( + "strings" + "text/template" +) + +const dnsMasqConfigContentTmpl = `# GENERATED BY ctrld - DO NOT MODIFY no-resolv server=127.0.0.1#5354 +{{- if .SendClientInfo}} +add-mac +{{- end}} ` const merlinDNSMasqPostConfPath = "/jffs/scripts/dnsmasq.postconf" const merlinDNSMasqPostConfMarker = `# GENERATED BY ctrld - EOF` -const merlinDNSMasqPostConf = `# GENERATED BY ctrld - DO NOT MODIFY +const merlinDNSMasqPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY #!/bin/sh @@ -20,7 +28,9 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then pc_delete "servers-file" "$config_file" # no WAN DNS settings pc_append "no-resolv" "$config_file" # do not read /etc/resolv.conf pc_append "server=127.0.0.1#5354" "$config_file" # use ctrld as upstream - + {{- if .SendClientInfo}} + pc_append "add-mac" "$config_file" # add client mac + {{- end}} # For John fork pc_delete "resolv-file" "$config_file" # no WAN DNS settings @@ -32,3 +42,24 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then exit 0 fi ` + +func dnsMasqConf() (string, error) { + var sb strings.Builder + var tmplText string + switch Name() { + case DDWrt, OpenWrt, Ubios: + tmplText = dnsMasqConfigContentTmpl + case Merlin: + tmplText = merlinDNSMasqPostConfTmpl + } + tmpl := template.Must(template.New("").Parse(tmplText)) + var to = &struct { + SendClientInfo bool + }{ + routerPlatform.Load().sendClientInfo, + } + if err := tmpl.Execute(&sb, to); err != nil { + return "", err + } + return sb.String(), nil +} diff --git a/internal/router/merlin.go b/internal/router/merlin.go index 0048d17..b2386bf 100644 --- a/internal/router/merlin.go +++ b/internal/router/merlin.go @@ -19,6 +19,10 @@ func setupMerlin() error { return err } + merlinDNSMasqPostConf, err := dnsMasqConf() + if err != nil { + return err + } data := strings.Join([]string{ merlinDNSMasqPostConf, "\n", @@ -38,7 +42,7 @@ func setupMerlin() error { } func cleanupMerlin() error { - buf, err := os.ReadFile(merlinDNSMasqPostConf) + buf, err := os.ReadFile(merlinDNSMasqPostConfPath) if err != nil && !os.IsNotExist(err) { return err } diff --git a/internal/router/merlin_test.go b/internal/router/merlin_test.go index 2a3c241..e1715af 100644 --- a/internal/router/merlin_test.go +++ b/internal/router/merlin_test.go @@ -9,7 +9,7 @@ import ( func Test_merlinParsePostConf(t *testing.T) { origContent := "# foo" data := strings.Join([]string{ - merlinDNSMasqPostConf, + merlinDNSMasqPostConfTmpl, "\n", merlinDNSMasqPostConfMarker, "\n", diff --git a/internal/router/openwrt.go b/internal/router/openwrt.go index 177be36..97f4628 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt.go @@ -19,6 +19,10 @@ func setupOpenWrt() error { return err } // Disable dnsmasq as DNS server. + dnsMasqConfigContent, err := dnsMasqConf() + if err != nil { + return err + } if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { return err } diff --git a/internal/router/router.go b/internal/router/router.go index 13ec295..bc628ad 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,8 +5,10 @@ import ( "errors" "os" "os/exec" + "sync" "sync/atomic" + "github.com/fsnotify/fsnotify" "github.com/kardianos/service" "github.com/Control-D-Inc/ctrld" @@ -25,7 +27,10 @@ var ErrNotSupported = errors.New("unsupported platform") var routerPlatform atomic.Pointer[router] type router struct { - name string + name string + sendClientInfo bool + mac sync.Map + watcher *fsnotify.Watcher } // SupportedPlatforms return all platforms that can be configured to run with ctrld. @@ -33,18 +38,37 @@ func SupportedPlatforms() []string { return []string{DDWrt, Merlin, OpenWrt, Ubios} } +var configureFunc = map[string]func() error{ + DDWrt: setupDDWrt, + Merlin: setupMerlin, + OpenWrt: setupOpenWrt, + Ubios: setupUbiOS, +} + // Configure configures things for running ctrld on the router. func Configure(c *ctrld.Config) error { name := Name() switch name { - case DDWrt: - return setupDDWrt() - case Merlin: - return setupMerlin() - case OpenWrt: - return setupOpenWrt() - case Ubios: - return setupUbiOS() + case DDWrt, Merlin, OpenWrt, Ubios: + if c.HasUpstreamSendClientInfo() { + r := routerPlatform.Load() + r.sendClientInfo = true + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + r.watcher = watcher + go r.watchClientInfoTable() + for _, file := range clientInfoFiles { + _ = readClientInfoFile(file) + _ = r.watcher.Add(file) + } + } + configure := configureFunc[name] + if err := configure(); err != nil { + return err + } + return nil default: return ErrNotSupported } diff --git a/internal/router/ubios.go b/internal/router/ubios.go index 8affb33..80fe04b 100644 --- a/internal/router/ubios.go +++ b/internal/router/ubios.go @@ -12,6 +12,10 @@ const ( func setupUbiOS() error { // Disable dnsmasq as DNS server. + dnsMasqConfigContent, err := dnsMasqConf() + if err != nil { + return err + } if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { return err } diff --git a/testhelper/config.go b/testhelper/config.go index 6d646be..0b739f0 100644 --- a/testhelper/config.go +++ b/testhelper/config.go @@ -50,6 +50,13 @@ type = "legacy" endpoint = "8.8.8.8" timeout = 5 +[upstream.3] +name = "DOH with client info" +type = "doh" +endpoint = "https://dns.controld.com/client_info_upstream/main-device" +timeout = 5 +send_client_info = false + [listener.0] ip = "127.0.0.1" port = 53