diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index bb8a8da..6ed93e7 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -1138,6 +1138,7 @@ func validateConfig(cfg *ctrld.Config) { os.Exit(1) } +// NOTE: Add more case here once new validation tag is used in ctrld.Config struct. func fieldErrorMsg(fe validator.FieldError) string { switch fe.Tag() { case "oneof": @@ -1165,6 +1166,8 @@ func fieldErrorMsg(fe validator.FieldError) string { return fmt.Sprintf("must be one of: %q", strings.Join(ipStacks, " ")) case "iporempty": return fmt.Sprintf("invalid IP format: %s", fe.Value()) + case "file": + return fmt.Sprintf("filed does not exist: %s", fe.Value()) } return "" } diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index 52a5fb1..b263aa7 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -55,7 +55,10 @@ func (p *prog) serveDNS(listenerNum string) error { q := m.Question[0] domain := canonicalName(q.Name) reqId := requestID() - remoteAddr := spoofRemoteAddr(w.RemoteAddr(), p.mt.GetClientInfoByMac(macFromMsg(m))) + remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + mac := macFromMsg(m) + ci := p.getClientInfo(remoteIP, mac) + remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci) fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String()) t := time.Now() ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) @@ -66,7 +69,7 @@ func (p *prog) serveDNS(listenerNum string) error { answer = new(dns.Msg) answer.SetRcode(m, dns.RcodeRefused) } else { - answer = p.proxy(ctx, upstreams, failoverRcodes, m) + answer = p.proxy(ctx, upstreams, failoverRcodes, m, ci) rtt := time.Since(t) ctrld.Log(ctx, mainLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt) } @@ -202,7 +205,7 @@ networkRules: return upstreams, matched } -func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg) *dns.Msg { +func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg, ci *ctrld.ClientInfo) *dns.Msg { var staleAnswer *dns.Msg serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams) @@ -245,12 +248,9 @@ 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 := p.mt.GetClientInfoByMac(macFromMsg(msg)) - if ci != nil { - ctrld.Log(ctx, mainLog.Debug(), "including client info with the request") - ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) - } + if upstreamConfig.UpstreamSendClientInfo() && ci != nil { + ctrld.Log(ctx, mainLog.Debug(), "including client info with the request") + ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) } answer, err := resolve1(n, upstreamConfig, msg) if err != nil { @@ -510,3 +510,16 @@ func inContainer() bool { }) return ret } + +func (p *prog) getClientInfo(ip, mac string) *ctrld.ClientInfo { + ci := &ctrld.ClientInfo{} + if mac != "" { + ci.Mac = mac + ci.IP = p.ciTable.LookupIP(mac) + } else { + ci.IP = ip + ci.Mac = p.ciTable.LookupMac(ip) + } + ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac) + return ci +} diff --git a/cmd/ctrld/dns_proxy_test.go b/cmd/ctrld/dns_proxy_test.go index 2d29bc3..3245875 100644 --- a/cmd/ctrld/dns_proxy_test.go +++ b/cmd/ctrld/dns_proxy_test.go @@ -149,8 +149,8 @@ func TestCache(t *testing.T) { answer2.SetRcode(msg, dns.RcodeRefused) prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute))) - got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg) - got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg) + got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg, nil) + got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg, nil) assert.NotSame(t, got1, got2) assert.Equal(t, answer1.Rcode, got1.Rcode) assert.Equal(t, answer2.Rcode, got2.Rcode) diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index 3298cd4..9aa7db4 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -48,11 +48,11 @@ type prog struct { logConn net.Conn cs *controlServer - cfg *ctrld.Config - cache dnscache.Cacher - sema semaphore - mt *clientinfo.MacTable - router router.Router + cfg *ctrld.Config + cache dnscache.Cacher + sema semaphore + ciTable *clientinfo.Table + router router.Router started chan struct{} onStarted []func() @@ -106,24 +106,22 @@ func (p *prog) run() { uc.Init() if uc.BootstrapIP == "" { uc.SetupBootstrapIP() - mainLog.Info().Msgf("Bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs()) + mainLog.Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs()) } else { - mainLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Using bootstrap IP for upstream.%s", n) + mainLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n) } uc.SetCertPool(rootCertPool) go uc.Ping() } - p.mt = clientinfo.NewMacTable() - if p.cfg.HasUpstreamSendClientInfo() { - mainLog.Debug().Msg("Sending client info enabled") - if err := p.mt.Init(); err == nil { - mainLog.Debug().Msg("Start watching client info changes") - go p.mt.WatchLeaseFiles() - } else { - mainLog.Warn().Err(err).Msg("could not record client info") - } + p.ciTable = clientinfo.NewTable(&cfg) + if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" { + mainLog.Debug().Msgf("watching custom lease file: %s", leaseFile) + format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat) + p.ciTable.AddLeaseFile(leaseFile, format) } + p.ciTable.Init() + go p.ciTable.RefreshLoop(p.stopCh) go p.watchLinkState() for listenerNum := range p.cfg.Listener { @@ -136,7 +134,7 @@ func (p *prog) run() { mainLog.Warn().Msgf("no default upstream for: [listener.%s]", listenerNum) } addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)) - mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, addr) + mainLog.Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr) err := p.serveDNS(listenerNum) if err != nil && !defaultConfigWritten && cdUID == "" { mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum) @@ -162,7 +160,7 @@ func (p *prog) run() { p.cfg.Service.AllocateIP = true p.mu.Unlock() p.preRun() - mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, net.JoinHostPort(ip, strconv.Itoa(port))) + mainLog.Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, net.JoinHostPort(ip, strconv.Itoa(port))) if err := p.serveDNS(listenerNum); err != nil { mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum) return diff --git a/config.go b/config.go index afc1e49..9e8f518 100644 --- a/config.go +++ b/config.go @@ -153,6 +153,12 @@ type ServiceConfig struct { CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"` CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"` MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"` + DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"` + DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"` + DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"` + DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_dhcp,omitempty"` + DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"` + DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"` Daemon bool `mapstructure:"-" toml:"-"` AllocateIP bool `mapstructure:"-" toml:"-"` } @@ -316,7 +322,7 @@ func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) { uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip) } } - ProxyLog.Debug().Msgf("Bootstrap IPs: %v", uc.bootstrapIPs) + ProxyLog.Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs) } // ReBootstrap re-setup the bootstrap IP and the transport. diff --git a/config_test.go b/config_test.go index 3591327..83a3386 100644 --- a/config_test.go +++ b/config_test.go @@ -1,6 +1,7 @@ package ctrld_test import ( + "os" "strings" "testing" @@ -91,6 +92,9 @@ func TestConfigValidation(t *testing.T) { {"invalid rules", configWithInvalidRules(t), true}, {"invalid dns rcodes", configWithInvalidRcodes(t), true}, {"invalid max concurrent requests", configWithInvalidMaxConcurrentRequests(t), true}, + {"non-existed lease file", configWithNonExistedLeaseFile(t), true}, + {"lease file format required if lease file exist", configWithExistedLeaseFile(t), true}, + {"invalid lease file format", configWithInvalidLeaseFileFormat(t), true}, } for _, tc := range tests { @@ -199,3 +203,25 @@ func configWithInvalidMaxConcurrentRequests(t *testing.T) *ctrld.Config { cfg.Service.MaxConcurrentRequests = &n return cfg } + +func configWithNonExistedLeaseFile(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + cfg.Service.DHCPLeaseFile = "non-existed" + return cfg +} + +func configWithExistedLeaseFile(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cfg.Service.DHCPLeaseFile = exe + return cfg +} + +func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + cfg.Service.DHCPLeaseFileFormat = "invalid" + return cfg +} diff --git a/internal/clientinfo/arp.go b/internal/clientinfo/arp.go new file mode 100644 index 0000000..ef70031 --- /dev/null +++ b/internal/clientinfo/arp.go @@ -0,0 +1,29 @@ +package clientinfo + +import "sync" + +type arpDiscover struct { + mac sync.Map // ip => mac + ip sync.Map // mac => ip +} + +func (a *arpDiscover) refresh() error { + a.scan() + return nil +} + +func (a *arpDiscover) LookupIP(mac string) string { + val, ok := a.ip.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +func (a *arpDiscover) LookupMac(ip string) string { + val, ok := a.mac.Load(ip) + if !ok { + return "" + } + return val.(string) +} diff --git a/internal/clientinfo/arp_linux.go b/internal/clientinfo/arp_linux.go new file mode 100644 index 0000000..3e48337 --- /dev/null +++ b/internal/clientinfo/arp_linux.go @@ -0,0 +1,28 @@ +package clientinfo + +import ( + "bufio" + "os" + "strings" +) + +const procNetArpFile = "/proc/net/arp" + +func (a *arpDiscover) scan() { + f, err := os.Open(procNetArpFile) + if err != nil { + return + } + defer f.Close() + + s := bufio.NewScanner(f) + s.Scan() // skip header + for s.Scan() { + line := s.Text() + fields := strings.Fields(line) + ip := fields[0] + mac := fields[3] + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/arp_test.go b/internal/clientinfo/arp_test.go new file mode 100644 index 0000000..08a75f8 --- /dev/null +++ b/internal/clientinfo/arp_test.go @@ -0,0 +1,23 @@ +package clientinfo + +import ( + "sync" + "testing" +) + +func TestArpScan(t *testing.T) { + a := &arpDiscover{} + a.scan() + + for _, table := range []*sync.Map{&a.mac, &a.ip} { + count := 0 + table.Range(func(key, value any) bool { + count++ + t.Logf("%s => %s", key, value) + return true + }) + if count == 0 { + t.Error("empty result from arp scan") + } + } +} diff --git a/internal/clientinfo/arp_unix.go b/internal/clientinfo/arp_unix.go new file mode 100644 index 0000000..f5d8f88 --- /dev/null +++ b/internal/clientinfo/arp_unix.go @@ -0,0 +1,30 @@ +//go:build !linux && !windows + +package clientinfo + +import ( + "os/exec" + "strings" +) + +func (a *arpDiscover) scan() { + data, err := exec.Command("arp", "-an").Output() + if err != nil { + return + } + + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) <= 3 { + continue + } + + // trim brackets + ip := strings.ReplaceAll(fields[1], "(", "") + ip = strings.ReplaceAll(ip, ")", "") + + mac := fields[3] + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/arp_windows.go b/internal/clientinfo/arp_windows.go new file mode 100644 index 0000000..016b752 --- /dev/null +++ b/internal/clientinfo/arp_windows.go @@ -0,0 +1,38 @@ +package clientinfo + +import ( + "os/exec" + "strings" +) + +func (a *arpDiscover) scan() { + data, err := exec.Command("arp", "-a").Output() + if err != nil { + return + } + + header := false + for _, line := range strings.Split(string(data), "\n") { + if len(line) == 0 { + continue // empty lines + } + if line[0] != ' ' { + header = true // "Interface:" lines, next is header line. + continue + } + if header { + header = false // header lines + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + ip := fields[0] + mac := strings.ReplaceAll(fields[1], "-", ":") + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go index 48b8678..79e1acd 100644 --- a/internal/clientinfo/client_info.go +++ b/internal/clientinfo/client_info.go @@ -1,211 +1,194 @@ package clientinfo import ( - "bufio" - "bytes" - "fmt" - "io" - "log" - "net" - "os" "strings" - "sync" "time" - "github.com/fsnotify/fsnotify" - "tailscale.com/util/lineread" - "github.com/Control-D-Inc/ctrld" ) -// clientInfoFiles specifies client info files and how to read them on supported platforms. -var clientInfoFiles = map[string]ctrld.LeaseFileFormat{ - "/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt - "/tmp/dhcp.leases": ctrld.Dnsmasq, // openwrt - "/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // merlin - "/mnt/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDM Pro - "/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDR - "/etc/dhcpd/dhcpd-leases.log": ctrld.Dnsmasq, // Synology - "/tmp/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // Tomato - "/run/dnsmasq-dhcp.leases": ctrld.Dnsmasq, // EdgeOS - "/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS - "/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense - "/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla +// IpResolver is the interface for retrieving IP from Mac. +type IpResolver interface { + LookupIP(mac string) string } -// NewMacTable returns new Mac table to record client information. -func NewMacTable() *MacTable { - return &MacTable{} +// MacResolver is the interface for retrieving Mac from IP. +type MacResolver interface { + LookupMac(ip string) string } -// MacTable records clients information by MAC address. -type MacTable struct { - mac sync.Map - watcher *fsnotify.Watcher +// HostnameByIpResolver is the interface for retrieving hostname from IP. +type HostnameByIpResolver interface { + LookupHostnameByIP(ip string) string } -// Init initializes recording client info. -func (mt *MacTable) Init() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - mt.watcher = watcher - for file, format := range clientInfoFiles { - // Ignore errors for default lease files. - _ = mt.AddLeaseFile(file, format) - } - return nil +// HostnameByMacResolver is the interface for retrieving hostname from Mac. +type HostnameByMacResolver interface { + LookupHostnameByMac(mac string) string } -// AddLeaseFile adds given lease file for reading/watching clients info. -func (mt *MacTable) AddLeaseFile(name string, format ctrld.LeaseFileFormat) error { - if err := mt.readLeaseFile(name, format); err != nil { - return fmt.Errorf("could not read lease file: %w", err) - } - clientInfoFiles[name] = format - return mt.watcher.Add(name) +type HostnameResolver interface { + HostnameByIpResolver + HostnameByMacResolver } -// GetClientInfoByMac returns ClientInfo for the client associated with the given MAC address. -func (mt *MacTable) GetClientInfoByMac(mac string) *ctrld.ClientInfo { - if mac == "" { - return nil - } - val, ok := mt.mac.Load(mac) - if !ok { - return nil - } - return val.(*ctrld.ClientInfo) +type refresher interface { + refresh() error } -// WatchLeaseFiles watches changes happens in dnsmasq/dhcpd -// lease files, perform updating to mac table if necessary. -func (mt *MacTable) WatchLeaseFiles() { - if mt.watcher == nil { +type Table struct { + ipResolvers []IpResolver + macResolvers []MacResolver + hostnameResolvers []HostnameResolver + refreshers []refresher + + dhcp *dhcp + merlin *merlinDiscover + arp *arpDiscover + ptr *ptrDiscover + mdns *mdns + cfg *ctrld.Config +} + +func NewTable(cfg *ctrld.Config) *Table { + return &Table{cfg: cfg} +} + +func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) { + if !t.discoverDHCP() { return } + clientInfoFiles[name] = format +} + +func (t *Table) RefreshLoop(stopCh chan struct{}) { timer := time.NewTicker(time.Minute * 5) for { select { case <-timer.C: - for _, name := range mt.watcher.WatchList() { - format := clientInfoFiles[name] - if err := mt.readLeaseFile(name, format); err != nil { - ctrld.ProxyLog.Err(err).Str("file", name).Msg("failed to update lease file") - } + for _, r := range t.refreshers { + _ = r.refresh() } - case event, ok := <-mt.watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) { - format := clientInfoFiles[event.Name] - if err := mt.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) { - ctrld.ProxyLog.Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info") - } - } - case err, ok := <-mt.watcher.Errors: - if !ok { - return - } - ctrld.ProxyLog.Err(err).Msg("could not watch client info file") + case <-stopCh: + return } } } -// readLeaseFile reads the lease file with given format, saving client information to mac table. -func (mt *MacTable) readLeaseFile(name string, format ctrld.LeaseFileFormat) error { - switch format { - case ctrld.Dnsmasq: - return mt.dnsmasqReadClientInfoFile(name) - case ctrld.IscDhcpd: - return mt.iscDHCPReadClientInfoFile(name) +func (t *Table) Init() { + if t.discoverDHCP() || t.discoverARP() { + t.merlin = &merlinDiscover{} + if err := t.merlin.refresh(); err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not init Merlin discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.merlin) + t.refreshers = append(t.refreshers, t.merlin) + } + } + if t.discoverDHCP() { + t.dhcp = &dhcp{} + ctrld.ProxyLog.Debug().Msg("start dhcp discovery") + if err := t.dhcp.refresh(); err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not init DHCP discover") + } else { + t.ipResolvers = append(t.ipResolvers, t.dhcp) + t.macResolvers = append(t.macResolvers, t.dhcp) + t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) + t.refreshers = append(t.refreshers, t.dhcp) + } + go t.dhcp.watchChanges() + } + if t.discoverARP() { + t.arp = &arpDiscover{} + ctrld.ProxyLog.Debug().Msg("start arp discovery") + if err := t.arp.refresh(); err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not init ARP discover") + } else { + t.ipResolvers = append(t.ipResolvers, t.arp) + t.macResolvers = append(t.macResolvers, t.arp) + t.refreshers = append(t.refreshers, t.arp) + } + } + if t.discoverPTR() { + t.ptr = &ptrDiscover{resolver: ctrld.NewPrivateResolver()} + ctrld.ProxyLog.Debug().Msg("start ptr discovery") + if err := t.ptr.refresh(); err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not init PTR discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.ptr) + t.refreshers = append(t.refreshers, t.ptr) + } + } + if t.discoverMDNS() { + t.mdns = &mdns{} + ctrld.ProxyLog.Debug().Msg("start mdns discovery") + if err := t.mdns.init(); err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not init mDNS discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.mdns) + } } - return fmt.Errorf("unsupported format: %s, file: %s", format, name) } -// dnsmasqReadClientInfoFile populates mac table with client info reading from dnsmasq lease file. -func (mt *MacTable) dnsmasqReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err +func (t *Table) LookupIP(mac string) string { + for _, r := range t.ipResolvers { + if ip := r.LookupIP(mac); ip != "" { + return ip + } } - defer f.Close() - return mt.dnsmasqReadClientInfoReader(f) - + return "" } -// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file. -func (mt *MacTable) dnsmasqReadClientInfoReader(reader io.Reader) error { - return lineread.Reader(reader, func(line []byte) error { - fields := bytes.Fields(line) - if len(fields) < 4 { - return nil - } - mac := string(fields[1]) - if _, err := net.ParseMAC(mac); err != nil { - // The second field is not a mac, skip. - return nil - } - ip := normalizeIP(string(fields[2])) - if net.ParseIP(ip) == nil { - log.Printf("invalid ip address entry: %q", ip) - ip = "" - } - hostname := string(fields[3]) - mt.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) - return nil +func (t *Table) LookupMac(ip string) string { + t.arp.mac.Range(func(key, value any) bool { + return true }) -} - -// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file. -func (mt *MacTable) iscDHCPReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err - } - defer f.Close() - return mt.iscDHCPReadClientInfoReader(f) -} - -// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file. -func (mt *MacTable) iscDHCPReadClientInfoReader(reader io.Reader) error { - s := bufio.NewScanner(reader) - var ip, mac, hostname string - for s.Scan() { - line := s.Text() - if strings.HasPrefix(line, "}") { - if mac != "" { - mt.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], `";`) + for _, r := range t.macResolvers { + if mac := r.LookupMac(ip); mac != "" { + return mac } } - return nil + return "" +} + +func (t *Table) LookupHostname(ip, mac string) string { + for _, r := range t.hostnameResolvers { + if name := r.LookupHostnameByIP(ip); name != "" { + return name + } + if name := r.LookupHostnameByMac(mac); name != "" { + return name + } + } + return "" +} + +func (t *Table) discoverDHCP() bool { + if t.cfg.Service.DiscoverDHCP == nil { + return true + } + return *t.cfg.Service.DiscoverDHCP +} + +func (t *Table) discoverARP() bool { + if t.cfg.Service.DiscoverARP == nil { + return true + } + return *t.cfg.Service.DiscoverARP +} + +func (t *Table) discoverMDNS() bool { + if t.cfg.Service.DiscoverMDNS == nil { + return true + } + return *t.cfg.Service.DiscoverMDNS +} + +func (t *Table) discoverPTR() bool { + if t.cfg.Service.DiscoverPtr == nil { + return true + } + return *t.cfg.Service.DiscoverPtr } // normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. @@ -217,3 +200,10 @@ func normalizeIP(in string) string { } return in } + +func normalizeHostname(name string) string { + if before, _, found := strings.Cut(name, "."); found { + return before // remove ".local.", ".lan.", ... suffix + } + return strings.ToLower(name) +} diff --git a/internal/clientinfo/client_info_test.go b/internal/clientinfo/client_info_test.go index 2f2e092..79e5912 100644 --- a/internal/clientinfo/client_info_test.go +++ b/internal/clientinfo/client_info_test.go @@ -1,11 +1,7 @@ package clientinfo import ( - "io" - "strings" "testing" - - "github.com/Control-D-Inc/ctrld" ) func Test_normalizeIP(t *testing.T) { @@ -29,79 +25,3 @@ func Test_normalizeIP(t *testing.T) { }) } } - -func Test_readClientInfoReader(t *testing.T) { - mt := NewMacTable() - tests := []struct { - name string - in string - readFunc func(r io.Reader) error - mac string - }{ - { - "good dnsmasq", - `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 * 01:e6:20:59:b8:c1:6d -`, - mt.dnsmasqReadClientInfoReader, - "e6:20:59:b8:c1:6d", - }, - { - "bad dnsmasq seen on UDMdream machine", - `1683329857 e6:20:59:b8:c1:6e 192.168.1.111 * 01:e6:20:59:b8:c1:6e -duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c -1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 -`, - mt.dnsmasqReadClientInfoReader, - "e6:20:59:b8:c1:6e", - }, - { - "isc-dhcpd good", - `lease 192.168.1.1 { - hardware ethernet 00:00:00:00:00:01; - client-hostname "host-1"; -} -`, - mt.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"; -} -`, - mt.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`, - mt.dnsmasqReadClientInfoReader, - "00:00:00:00:00:04", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mt.mac.Delete(tc.mac) - if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { - t.Errorf("readClientInfoReader() error = %v", err) - } - info, existed := mt.mac.Load(tc.mac) - if !existed { - t.Error("client info missing") - } - if ci, ok := info.(*ctrld.ClientInfo); ok && existed && ci.Mac != tc.mac { - t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac) - } else { - t.Log(ci) - } - }) - } -} diff --git a/internal/clientinfo/dhcp.go b/internal/clientinfo/dhcp.go new file mode 100644 index 0000000..5ca28ec --- /dev/null +++ b/internal/clientinfo/dhcp.go @@ -0,0 +1,256 @@ +package clientinfo + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/netip" + "os" + "strings" + "sync" + + "github.com/Control-D-Inc/ctrld" + "tailscale.com/net/interfaces" + "tailscale.com/util/lineread" + + "github.com/fsnotify/fsnotify" +) + +type dhcp struct { + mac2name sync.Map // mac => name + ip2name sync.Map // ip => name + ip sync.Map // mac => ip + mac sync.Map // ip => mac + + watcher *fsnotify.Watcher +} + +func (d *dhcp) refresh() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + d.addSelf() + d.watcher = watcher + for file, format := range clientInfoFiles { + // Ignore errors for default lease files. + _ = d.addLeaseFile(file, format) + } + return nil +} + +func (d *dhcp) watchChanges() { + if d.watcher == nil { + return + } + for { + select { + case event, ok := <-d.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + format := clientInfoFiles[event.Name] + if err := d.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) { + ctrld.ProxyLog.Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info") + } + } + case err, ok := <-d.watcher.Errors: + if !ok { + return + } + ctrld.ProxyLog.Err(err).Msg("could not watch client info file") + } + } + +} + +func (d *dhcp) LookupIP(mac string) string { + val, ok := d.ip.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupMac(ip string) string { + val, ok := d.mac.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupHostnameByIP(ip string) string { + val, ok := d.ip2name.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupHostnameByMac(mac string) string { + val, ok := d.mac2name.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +// AddLeaseFile adds given lease file for reading/watching clients info. +func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error { + if d.watcher == nil { + return nil + } + if err := d.readLeaseFile(name, format); err != nil { + return fmt.Errorf("could not read lease file: %w", err) + } + clientInfoFiles[name] = format + return d.watcher.Add(name) +} + +// readLeaseFile reads the lease file with given format, saving client information to dhcp table. +func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error { + switch format { + case ctrld.Dnsmasq: + return d.dnsmasqReadClientInfoFile(name) + case ctrld.IscDhcpd: + return d.iscDHCPReadClientInfoFile(name) + } + return fmt.Errorf("unsupported format: %s, file: %s", format, name) +} + +// dnsmasqReadClientInfoFile populates dhcp table with client info reading from dnsmasq lease file. +func (d *dhcp) dnsmasqReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return d.dnsmasqReadClientInfoReader(f) + +} + +// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file. +func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error { + return lineread.Reader(reader, func(line []byte) error { + fields := bytes.Fields(line) + if len(fields) < 4 { + return nil + } + + mac := string(fields[1]) + if _, err := net.ParseMAC(mac); err != nil { + // The second field is not a dhcp, skip. + return nil + } + ip := normalizeIP(string(fields[2])) + if net.ParseIP(ip) == nil { + ctrld.ProxyLog.Warn().Msgf("invalid ip address entry: %q", ip) + ip = "" + } + + d.mac.Store(ip, mac) + d.ip.Store(mac, ip) + hostname := string(fields[3]) + if hostname == "*" { + return nil + } + name := normalizeHostname(hostname) + d.mac2name.Store(mac, name) + d.ip2name.Store(ip, name) + return nil + }) +} + +// iscDHCPReadClientInfoFile populates dhcp table with client info reading from isc-dhcpd lease file. +func (d *dhcp) iscDHCPReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return d.iscDHCPReadClientInfoReader(f) +} + +// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file. +func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error { + s := bufio.NewScanner(reader) + var ip, mac, hostname string + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "}") { + d.mac.Store(ip, mac) + d.ip.Store(mac, ip) + if hostname != "" && hostname != "*" { + name := normalizeHostname(hostname) + d.mac2name.Store(mac, name) + d.ip2name.Store(ip, 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 { + ctrld.ProxyLog.Warn().Msgf("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 dhcp, skip. + mac = "" + } + } + case "client-hostname": + hostname = strings.Trim(fields[1], `";`) + } + } + return nil +} + +// addSelf populates current host info to dhcp, so queries from +// the host itself can be attached with proper client info. +func (d *dhcp) addSelf() { + hostname, err := os.Hostname() + if err != nil { + ctrld.ProxyLog.Err(err).Msg("could not get hostname") + return + } + hostname = normalizeHostname(hostname) + d.ip2name.Store("127.0.0.1", hostname) + d.ip2name.Store("::1", hostname) + interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { + mac := i.HardwareAddr.String() + // Skip loopback interfaces, info was stored above. + if mac == "" { + return + } + addrs, _ := i.Addrs() + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP + d.mac.Store(ip.String(), mac) + d.ip.Store(mac, ip.String()) + if ip.To4() != nil { + d.mac.Store("127.0.0.1", mac) + } else { + d.mac.Store("::1", mac) + } + d.mac2name.Store(mac, hostname) + d.ip2name.Store(ip.String(), hostname) + } + }) +} diff --git a/internal/clientinfo/dhcp_lease_files.go b/internal/clientinfo/dhcp_lease_files.go new file mode 100644 index 0000000..4932a4b --- /dev/null +++ b/internal/clientinfo/dhcp_lease_files.go @@ -0,0 +1,18 @@ +package clientinfo + +import "github.com/Control-D-Inc/ctrld" + +// clientInfoFiles specifies client info files and how to read them on supported platforms. +var clientInfoFiles = map[string]ctrld.LeaseFileFormat{ + "/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt + "/tmp/dhcp.leases": ctrld.Dnsmasq, // openwrt + "/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // merlin + "/mnt/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDM Pro + "/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDR + "/etc/dhcpd/dhcpd-leases.log": ctrld.Dnsmasq, // Synology + "/tmp/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // Tomato + "/run/dnsmasq-dhcp.leases": ctrld.Dnsmasq, // EdgeOS + "/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS + "/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense + "/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla +} diff --git a/internal/clientinfo/dhcp_test.go b/internal/clientinfo/dhcp_test.go new file mode 100644 index 0000000..af3a168 --- /dev/null +++ b/internal/clientinfo/dhcp_test.go @@ -0,0 +1,88 @@ +package clientinfo + +import ( + "io" + "strings" + "testing" +) + +func Test_readClientInfoReader(t *testing.T) { + d := &dhcp{} + tests := []struct { + name string + in string + readFunc func(r io.Reader) error + mac string + hostname string + }{ + { + "good dnsmasq", + `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 host1 01:e6:20:59:b8:c1:6d +`, + d.dnsmasqReadClientInfoReader, + "e6:20:59:b8:c1:6d", + "host1", + }, + { + "bad dnsmasq seen on UDMdream machine", + `1683329857 e6:20:59:b8:c1:6e 192.168.1.111 host1 01:e6:20:59:b8:c1:6e +duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c +1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 +`, + d.dnsmasqReadClientInfoReader, + "e6:20:59:b8:c1:6e", + "host1", + }, + { + "isc-dhcpd good", + `lease 192.168.1.1 { + hardware ethernet 00:00:00:00:00:01; + client-hostname "host-1"; +} +`, + d.iscDHCPReadClientInfoReader, + "00:00:00:00:00:01", + "host-1", + }, + { + "isc-dhcpd bad dhcp", + `lease 192.168.1.1 { + hardware ethernet invalid-dhcp; + client-hostname "host-1"; +} + +lease 192.168.1.2 { + hardware ethernet 00:00:00:00:00:02; + client-hostname "host-2"; +} +`, + d.iscDHCPReadClientInfoReader, + "00:00:00:00:00:02", + "host-2", + }, + { + "", + `1685794060 00:00:00:00:00:04 192.168.0.209 example 00:00:00:00:00:04 9`, + d.dnsmasqReadClientInfoReader, + "00:00:00:00:00:04", + "example", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d.mac2name.Delete(tc.mac) + if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { + t.Errorf("readClientInfoReader() error = %v", err) + } + val, existed := d.mac2name.Load(tc.mac) + if !existed { + t.Error("client info missing") + } + hostname := val.(string) + if existed && hostname != tc.hostname { + t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, hostname) + } + }) + } +} diff --git a/internal/clientinfo/mdns.go b/internal/clientinfo/mdns.go new file mode 100644 index 0000000..ce92d50 --- /dev/null +++ b/internal/clientinfo/mdns.go @@ -0,0 +1,163 @@ +package clientinfo + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/miekg/dns" + "tailscale.com/logtail/backoff" + + "github.com/Control-D-Inc/ctrld" + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" +) + +var ( + mdnsV4Addr = &net.UDPAddr{ + IP: net.ParseIP("224.0.0.251"), + Port: 5353, + } + mdnsV6Addr = &net.UDPAddr{ + IP: net.ParseIP("ff02::fb"), + Port: 5353, + } +) + +type mdns struct { + name sync.Map // ip => hostname +} + +func (m *mdns) LookupHostnameByIP(ip string) string { + val, ok := m.name.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (m *mdns) LookupHostnameByMac(mac string) string { + return "" +} + +func (m *mdns) init() error { + ifaces, err := multicastInterfaces() + if err != nil { + return err + } + + v4ConnList := make([]*net.UDPConn, 0, len(ifaces)) + v6ConnList := make([]*net.UDPConn, 0, len(ifaces)) + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + if conn, err := net.ListenMulticastUDP("udp4", &iface, mdnsV4Addr); err == nil { + v4ConnList = append(v4ConnList, conn) + go m.readLoop(conn) + } + if ctrldnet.IPv6Available(context.Background()) { + if conn, err := net.ListenMulticastUDP("udp6", &iface, mdnsV6Addr); err == nil { + v6ConnList = append(v6ConnList, conn) + go m.readLoop(conn) + } + } + } + + go func() { + bo := backoff.NewBackoff("mdns probe", func(format string, args ...any) {}, time.Second*30) + for { + err := m.probe(v4ConnList, v6ConnList) + if err != nil { + ctrld.ProxyLog.Warn().Err(err).Msg("error while probing mdns") + } + bo.BackOff(context.Background(), errors.New("mdns probe backoff")) + } + }() + + return nil +} + +func (m *mdns) readLoop(conn *net.UDPConn) { + defer conn.Close() + buf := make([]byte, dns.MaxMsgSize) + + for { + _ = conn.SetReadDeadline(time.Now().Add(time.Second * 30)) + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + if err, ok := err.(*net.OpError); ok { + if err.Timeout() || err.Temporary() { + continue + } + ctrld.ProxyLog.Debug().Err(err).Msg("mdns readLoop error") + } + return + } + + var msg dns.Msg + if err := msg.Unpack(buf[:n]); err != nil { + continue + } + + var ip, name string + for _, answer := range msg.Answer { + switch ar := answer.(type) { + case *dns.A: + ip, name = ar.A.String(), ar.Hdr.Name + case *dns.AAAA: + ip, name = ar.AAAA.String(), ar.Hdr.Name + } + if ip != "" && name != "" { + name = normalizeHostname(name) + ctrld.ProxyLog.Debug().Msgf("Found hostname: %q, ip: %q via mdns", name, ip) + m.name.Store(ip, name) + } + } + } +} + +func (m *mdns) probe(v4connList, v6connList []*net.UDPConn) error { + msg := new(dns.Msg) + msg.Question = make([]dns.Question, len(services)) + for i, service := range services { + msg.Question[i] = dns.Question{ + Name: dns.CanonicalName(service), + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + } + } + + buf, err := msg.Pack() + if err != nil { + return err + } + do := func(connList []*net.UDPConn, remoteAddr net.Addr) error { + for _, conn := range connList { + _ = conn.SetWriteDeadline(time.Now().Add(time.Second * 30)) + if _, err := conn.WriteTo(buf, remoteAddr); err != nil { + return err + } + } + return nil + } + return errors.Join(do(v4connList, mdnsV4Addr), do(v6connList, mdnsV6Addr)) +} + +func multicastInterfaces() ([]net.Interface, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + interfaces := make([]net.Interface, 0, len(ifaces)) + for _, ifi := range ifaces { + if (ifi.Flags & net.FlagUp) == 0 { + continue + } + if (ifi.Flags & net.FlagMulticast) > 0 { + interfaces = append(interfaces, ifi) + } + } + return interfaces, nil +} diff --git a/internal/clientinfo/mdns_services.go b/internal/clientinfo/mdns_services.go new file mode 100644 index 0000000..d7869c8 --- /dev/null +++ b/internal/clientinfo/mdns_services.go @@ -0,0 +1,70 @@ +package clientinfo + +var services = [...]string{ + // From: https://jonathanmumm.com/tech-it/mdns-bonjour-bible-common-service-strings-for-various-vendors/ + "_afpovertcp._tcp.local.", + "_airdroid._tcp.local.", + "_airdrop._tcp.local.", + "_airplay._tcp.local.", + "_airport._tcp.local.", + "_amzn-wplay._tcp.local.", + "_sub._apple-mobdev2._tcp.local.", + "_apple-mobdev2._tcp.local.", + "_apple-sasl._tcp.local.", + "_atc._tcp.local.", + "_sketchmirror._tcp.local.", + "_bp2p._tcp.local.", + "_Friendly._sub._bp2p._tcp.local.", + "_invoke._sub._bp2p._tcp.local.", + "_webdav._sub._bp2p._tcp.local.", + "_device-info._tcp.local.", + "_distcc._tcp.local.", + "_dpap._tcp.local.", + "_eppc._tcp.local.", + "_esdevice._tcp.local.", + "_esfileshare._tcp.local.", + "_ftp._tcp.local.", + "_googlecast._tcp.local.", + "_googlezone._tcp.local.", + "_hap._tcp.local.", + "_homekit._tcp.local.", + "_home-sharing._tcp.local.", + "_http._tcp.local.", + "_hudson._tcp.local.", + "_ica-networking._tcp.local.", + "_print._sub._ipp._tcp.local.", + "_cups._sub._ipps._tcp.local.", + "_print._sub._ipps._tcp.local.", + "_jenkins._tcp.local.", + "_KeynoteControl._tcp.local.", + "_keynotepair._tcp.local.", + "_mediaremotetv._tcp.local.", + "_nfs._tcp.local.", + "_nvstream._tcp.local.", + "_androidtvremote._tcp.local.", + "_omnistate._tcp.local.", + "_photoshopserver._tcp.local.", + "_printer._tcp.local.", + "_raop._tcp.local.", + "_readynas._tcp.local.", + "_rfb._tcp.local.", + "_physicalweb._tcp.local.", + "_rsp._tcp.local.", + "_scanner._tcp.local.", + "_sftp-ssh._tcp.local.", + "_sleep-proxy._udp.local.", + "_smb._tcp.local.", + "_spotify-connect._tcp.local.", + "_ssh._tcp.local.", + "_teamviewer._tcp.local.", + "_telnet._tcp.local.", + "_touch-able._tcp.local.", + "_tunnel._tcp.local.", + "_webdav._tcp.local.", + "_webdav._tcp.local.", + "_workstation._tcp.local.", + "_xserveraid._tcp.local.", + + // Merlin + "_alexa._tcp", +} diff --git a/internal/clientinfo/merlin.go b/internal/clientinfo/merlin.go new file mode 100644 index 0000000..7e793ed --- /dev/null +++ b/internal/clientinfo/merlin.go @@ -0,0 +1,67 @@ +package clientinfo + +import ( + "strings" + "sync" + + "github.com/Control-D-Inc/ctrld/internal/router" + "github.com/Control-D-Inc/ctrld/internal/router/merlin" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/nvram" +) + +const merlinNvramCustomClientListKey = "custom_clientlist" + +type merlinDiscover struct { + hostname sync.Map // mac => hostname +} + +func (m *merlinDiscover) refresh() error { + if router.Name() != merlin.Name { + return nil + } + out, err := nvram.Run("get", merlinNvramCustomClientListKey) + if err != nil { + return err + } + ctrld.ProxyLog.Debug().Msg("reading Merlin custom client list") + m.parseMerlinCustomClientList(out) + return nil +} + +func (m *merlinDiscover) LookupHostnameByIP(ip string) string { + return "" +} + +func (m *merlinDiscover) LookupHostnameByMac(mac string) string { + val, ok := m.hostname.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +// "nvram get custom_clientlist" output: +// +// 00:00:00:00:00:01>0>4>>00:00:00:00:00:02>0>24>>... +// +// So to parse it, do the following steps: +// +// - Split by "<" => entries +// - For each entry, split by ">" => parts +// - Empty parts => skip +// - Empty parts[0] => skip empty hostname +// - Empty parts[1] => skip empty MAC +func (m *merlinDiscover) parseMerlinCustomClientList(data string) { + entries := strings.Split(data, "<") + for _, entry := range entries { + parts := strings.SplitN(string(entry), ">", 3) + if len(parts) < 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { + continue + } + hostname := normalizeHostname(parts[0]) + mac := strings.ToLower(parts[1]) + m.hostname.Store(mac, hostname) + } +} diff --git a/internal/clientinfo/merlin_test.go b/internal/clientinfo/merlin_test.go new file mode 100644 index 0000000..0437035 --- /dev/null +++ b/internal/clientinfo/merlin_test.go @@ -0,0 +1,82 @@ +package clientinfo + +import ( + "testing" +) + +func TestParseMerlinCustomClientList(t *testing.T) { + tests := []struct { + name string + clientList string + macList []string + hostnameList []string + macNotPresentList []string + }{ + { + "normal", + "00:00:00:00:00:01>0>4>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + nil, + }, + { + "multiple clients", + "00:00:00:00:00:01>0>4>>00:00:00:00:00:02>0>24>>", + []string{"00:00:00:00:00:01", "00:00:00:00:00:02"}, + []string{"client1", "client2"}, + nil, + }, + { + "empty hostname", + "00:00:00:00:00:01>0>4>><>00:00:00:00:00:02>0>24>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + []string{"00:00:00:00:00:02"}, + }, + { + "empty dhcp", + "00:00:00:00:00:01>0>4>>>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + []string{""}, + }, + { + "invalid", + "qwerty", + nil, + nil, + nil, + }, + { + "empty", + "", + + nil, + nil, + nil, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := &merlinDiscover{} + m.parseMerlinCustomClientList(tc.clientList) + for i, mac := range tc.macList { + val, ok := m.hostname.Load(mac) + if !ok { + t.Errorf("missing hostname: %s", mac) + } + hostname := val.(string) + if hostname != tc.hostnameList[i] { + t.Errorf("hostname mismatch, want: %q, got: %q", tc.hostnameList[i], hostname) + } + } + for _, mac := range tc.macNotPresentList { + if _, ok := m.hostname.Load(mac); ok { + t.Errorf("mac2name address %q should not be present", mac) + } + } + }) + } +} diff --git a/internal/clientinfo/ptr_lookup.go b/internal/clientinfo/ptr_lookup.go new file mode 100644 index 0000000..600b67c --- /dev/null +++ b/internal/clientinfo/ptr_lookup.go @@ -0,0 +1,62 @@ +package clientinfo + +import ( + "context" + "sync" + "time" + + "github.com/miekg/dns" + + "github.com/Control-D-Inc/ctrld" +) + +type ptrDiscover struct { + hostname sync.Map // ip => hostname + resolver ctrld.Resolver +} + +func (p *ptrDiscover) refresh() error { + p.hostname.Range(func(key, value any) bool { + ip := key.(string) + if name := p.lookupHostname(ip); name != "" { + p.hostname.Store(ip, name) + } + return true + }) + return nil +} + +func (p *ptrDiscover) LookupHostnameByIP(ip string) string { + if val, ok := p.hostname.Load(ip); ok { + return val.(string) + } + return p.lookupHostname(ip) +} +func (p *ptrDiscover) LookupHostnameByMac(mac string) string { + return "" +} + +func (p *ptrDiscover) lookupHostname(ip string) string { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + msg := new(dns.Msg) + addr, err := dns.ReverseAddr(ip) + if err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("invalid ip address") + return "" + } + msg.SetQuestion(addr, dns.TypePTR) + ans, err := p.resolver.Resolve(ctx, msg) + if err != nil { + ctrld.ProxyLog.Error().Err(err).Msg("could not lookup IP") + return "" + } + for _, rr := range ans.Answer { + if ptr, ok := rr.(*dns.PTR); ok { + hostname := normalizeHostname(ptr.Ptr) + p.hostname.Store(ip, hostname) + return hostname + } + } + return "" +} diff --git a/resolver.go b/resolver.go index 2512434..2bee2d8 100644 --- a/resolver.go +++ b/resolver.go @@ -110,18 +110,6 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error return nil, errors.Join(errs...) } -func newDialer(dnsAddress string) *net.Dialer { - return &net.Dialer{ - Resolver: &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{} - return d.DialContext(ctx, network, dnsAddress) - }, - }, - } -} - type legacyResolver struct { uc *UpstreamConfig } @@ -149,6 +137,14 @@ func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, e return answer, err } +type dummyResolver struct{} + +func (d dummyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { + ans := new(dns.Msg) + ans.SetReply(msg) + return ans, nil +} + // LookupIP looks up host using OS resolver. // It returns a slice of that host's IPv4 and IPv6 addresses. func LookupIP(domain string) []string { @@ -160,7 +156,7 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) if withBootstrapDNS { resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...) } - ProxyLog.Debug().Msgf("Resolving %q using bootstrap DNS %q", domain, resolver.nameservers) + ProxyLog.Debug().Msgf("resolving %q using bootstrap DNS %q", domain, resolver.nameservers) timeoutMs := 2000 if timeout > 0 && timeout < timeoutMs { timeoutMs = timeout @@ -230,7 +226,6 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) // 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 { @@ -241,3 +236,36 @@ func NewBootstrapResolver(servers ...string) Resolver { } return resolver } + +// NewPrivateResolver returns an OS resolver, which includes only private DNS servers. +// This is useful for doing PTR lookup in LAN network. +func NewPrivateResolver() Resolver { + nss := nameservers() + n := 0 + for _, ns := range nss { + host, _, _ := net.SplitHostPort(ns) + ip := net.ParseIP(host) + if ip != nil && ip.IsPrivate() && !ip.IsLoopback() { + nss[n] = ns + n++ + } + } + nss = nss[:n] + if len(nss) == 0 { + return &dummyResolver{} + } + resolver := &osResolver{nameservers: nss} + return resolver +} + +func newDialer(dnsAddress string) *net.Dialer { + return &net.Dialer{ + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, network, dnsAddress) + }, + }, + } +}