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.
This commit is contained in:
Cuong Manh Le
2023-04-20 23:16:20 +07:00
committed by Cuong Manh Le
parent d52cd11322
commit 0645a738ad
17 changed files with 370 additions and 27 deletions

11
client_info.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

32
doh.go
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
func Test_merlinParsePostConf(t *testing.T) {
origContent := "# foo"
data := strings.Join([]string{
merlinDNSMasqPostConf,
merlinDNSMasqPostConfTmpl,
"\n",
merlinDNSMasqPostConfMarker,
"\n",

View File

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

View File

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

View File

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

View File

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