Improving Mac discovery

This commit is contained in:
Cuong Manh Le
2023-07-14 16:53:17 +00:00
committed by Cuong Manh Le
parent 3007cb86ec
commit 76d2e2c226
22 changed files with 1229 additions and 291 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

256
internal/clientinfo/dhcp.go Normal file
View File

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

View File

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

View File

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

163
internal/clientinfo/mdns.go Normal file
View File

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

View File

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

View File

@@ -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:
//
// <client 1>00:00:00:00:00:01>0>4>><client 2>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)
}
}

View File

@@ -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",
"<client1>00:00:00:00:00:01>0>4>>",
[]string{"00:00:00:00:00:01"},
[]string{"client1"},
nil,
},
{
"multiple clients",
"<client1>00:00:00:00:00:01>0>4>><client2>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",
"<client1>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",
"<client1>00:00:00:00:00:01>0>4>><client 1>>>",
[]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)
}
}
})
}
}

View File

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

View File

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