mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Improving Mac discovery
This commit is contained in:
@@ -1138,6 +1138,7 @@ func validateConfig(cfg *ctrld.Config) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Add more case here once new validation tag is used in ctrld.Config struct.
|
||||||
func fieldErrorMsg(fe validator.FieldError) string {
|
func fieldErrorMsg(fe validator.FieldError) string {
|
||||||
switch fe.Tag() {
|
switch fe.Tag() {
|
||||||
case "oneof":
|
case "oneof":
|
||||||
@@ -1165,6 +1166,8 @@ func fieldErrorMsg(fe validator.FieldError) string {
|
|||||||
return fmt.Sprintf("must be one of: %q", strings.Join(ipStacks, " "))
|
return fmt.Sprintf("must be one of: %q", strings.Join(ipStacks, " "))
|
||||||
case "iporempty":
|
case "iporempty":
|
||||||
return fmt.Sprintf("invalid IP format: %s", fe.Value())
|
return fmt.Sprintf("invalid IP format: %s", fe.Value())
|
||||||
|
case "file":
|
||||||
|
return fmt.Sprintf("filed does not exist: %s", fe.Value())
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-9
@@ -55,7 +55,10 @@ func (p *prog) serveDNS(listenerNum string) error {
|
|||||||
q := m.Question[0]
|
q := m.Question[0]
|
||||||
domain := canonicalName(q.Name)
|
domain := canonicalName(q.Name)
|
||||||
reqId := requestID()
|
reqId := requestID()
|
||||||
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())
|
fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String())
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
|
||||||
@@ -66,7 +69,7 @@ func (p *prog) serveDNS(listenerNum string) error {
|
|||||||
answer = new(dns.Msg)
|
answer = new(dns.Msg)
|
||||||
answer.SetRcode(m, dns.RcodeRefused)
|
answer.SetRcode(m, dns.RcodeRefused)
|
||||||
} else {
|
} else {
|
||||||
answer = p.proxy(ctx, upstreams, failoverRcodes, m)
|
answer = p.proxy(ctx, upstreams, failoverRcodes, m, ci)
|
||||||
rtt := time.Since(t)
|
rtt := time.Since(t)
|
||||||
ctrld.Log(ctx, mainLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
|
ctrld.Log(ctx, mainLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
|
||||||
}
|
}
|
||||||
@@ -202,7 +205,7 @@ networkRules:
|
|||||||
return upstreams, matched
|
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
|
var staleAnswer *dns.Msg
|
||||||
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
|
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
|
||||||
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
|
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
|
||||||
@@ -245,12 +248,9 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
|
|||||||
return dnsResolver.Resolve(resolveCtx, msg)
|
return dnsResolver.Resolve(resolveCtx, msg)
|
||||||
}
|
}
|
||||||
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
|
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
|
||||||
if upstreamConfig.UpstreamSendClientInfo() {
|
if upstreamConfig.UpstreamSendClientInfo() && ci != nil {
|
||||||
ci := p.mt.GetClientInfoByMac(macFromMsg(msg))
|
ctrld.Log(ctx, mainLog.Debug(), "including client info with the request")
|
||||||
if ci != nil {
|
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci)
|
||||||
ctrld.Log(ctx, mainLog.Debug(), "including client info with the request")
|
|
||||||
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
answer, err := resolve1(n, upstreamConfig, msg)
|
answer, err := resolve1(n, upstreamConfig, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -510,3 +510,16 @@ func inContainer() bool {
|
|||||||
})
|
})
|
||||||
return ret
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ func TestCache(t *testing.T) {
|
|||||||
answer2.SetRcode(msg, dns.RcodeRefused)
|
answer2.SetRcode(msg, dns.RcodeRefused)
|
||||||
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
|
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)
|
got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg, nil)
|
||||||
got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg)
|
got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg, nil)
|
||||||
assert.NotSame(t, got1, got2)
|
assert.NotSame(t, got1, got2)
|
||||||
assert.Equal(t, answer1.Rcode, got1.Rcode)
|
assert.Equal(t, answer1.Rcode, got1.Rcode)
|
||||||
assert.Equal(t, answer2.Rcode, got2.Rcode)
|
assert.Equal(t, answer2.Rcode, got2.Rcode)
|
||||||
|
|||||||
+16
-18
@@ -48,11 +48,11 @@ type prog struct {
|
|||||||
logConn net.Conn
|
logConn net.Conn
|
||||||
cs *controlServer
|
cs *controlServer
|
||||||
|
|
||||||
cfg *ctrld.Config
|
cfg *ctrld.Config
|
||||||
cache dnscache.Cacher
|
cache dnscache.Cacher
|
||||||
sema semaphore
|
sema semaphore
|
||||||
mt *clientinfo.MacTable
|
ciTable *clientinfo.Table
|
||||||
router router.Router
|
router router.Router
|
||||||
|
|
||||||
started chan struct{}
|
started chan struct{}
|
||||||
onStarted []func()
|
onStarted []func()
|
||||||
@@ -106,24 +106,22 @@ func (p *prog) run() {
|
|||||||
uc.Init()
|
uc.Init()
|
||||||
if uc.BootstrapIP == "" {
|
if uc.BootstrapIP == "" {
|
||||||
uc.SetupBootstrapIP()
|
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 {
|
} 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)
|
uc.SetCertPool(rootCertPool)
|
||||||
go uc.Ping()
|
go uc.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mt = clientinfo.NewMacTable()
|
p.ciTable = clientinfo.NewTable(&cfg)
|
||||||
if p.cfg.HasUpstreamSendClientInfo() {
|
if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" {
|
||||||
mainLog.Debug().Msg("Sending client info enabled")
|
mainLog.Debug().Msgf("watching custom lease file: %s", leaseFile)
|
||||||
if err := p.mt.Init(); err == nil {
|
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
|
||||||
mainLog.Debug().Msg("Start watching client info changes")
|
p.ciTable.AddLeaseFile(leaseFile, format)
|
||||||
go p.mt.WatchLeaseFiles()
|
|
||||||
} else {
|
|
||||||
mainLog.Warn().Err(err).Msg("could not record client info")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
p.ciTable.Init()
|
||||||
|
go p.ciTable.RefreshLoop(p.stopCh)
|
||||||
go p.watchLinkState()
|
go p.watchLinkState()
|
||||||
|
|
||||||
for listenerNum := range p.cfg.Listener {
|
for listenerNum := range p.cfg.Listener {
|
||||||
@@ -136,7 +134,7 @@ func (p *prog) run() {
|
|||||||
mainLog.Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
|
mainLog.Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
|
||||||
}
|
}
|
||||||
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
|
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)
|
err := p.serveDNS(listenerNum)
|
||||||
if err != nil && !defaultConfigWritten && cdUID == "" {
|
if err != nil && !defaultConfigWritten && cdUID == "" {
|
||||||
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
|
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.cfg.Service.AllocateIP = true
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
p.preRun()
|
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 {
|
if err := p.serveDNS(listenerNum); err != nil {
|
||||||
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
|
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ type ServiceConfig struct {
|
|||||||
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
|
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
|
||||||
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,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"`
|
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:"-"`
|
Daemon bool `mapstructure:"-" toml:"-"`
|
||||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||||
}
|
}
|
||||||
@@ -316,7 +322,7 @@ func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) {
|
|||||||
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
|
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.
|
// ReBootstrap re-setup the bootstrap IP and the transport.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ctrld_test
|
package ctrld_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ func TestConfigValidation(t *testing.T) {
|
|||||||
{"invalid rules", configWithInvalidRules(t), true},
|
{"invalid rules", configWithInvalidRules(t), true},
|
||||||
{"invalid dns rcodes", configWithInvalidRcodes(t), true},
|
{"invalid dns rcodes", configWithInvalidRcodes(t), true},
|
||||||
{"invalid max concurrent requests", configWithInvalidMaxConcurrentRequests(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 {
|
for _, tc := range tests {
|
||||||
@@ -199,3 +203,25 @@ func configWithInvalidMaxConcurrentRequests(t *testing.T) *ctrld.Config {
|
|||||||
cfg.Service.MaxConcurrentRequests = &n
|
cfg.Service.MaxConcurrentRequests = &n
|
||||||
return cfg
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-167
@@ -1,211 +1,194 @@
|
|||||||
package clientinfo
|
package clientinfo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"tailscale.com/util/lineread"
|
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
// clientInfoFiles specifies client info files and how to read them on supported platforms.
|
// IpResolver is the interface for retrieving IP from Mac.
|
||||||
var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
|
type IpResolver interface {
|
||||||
"/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt
|
LookupIP(mac string) string
|
||||||
"/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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMacTable returns new Mac table to record client information.
|
// MacResolver is the interface for retrieving Mac from IP.
|
||||||
func NewMacTable() *MacTable {
|
type MacResolver interface {
|
||||||
return &MacTable{}
|
LookupMac(ip string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MacTable records clients information by MAC address.
|
// HostnameByIpResolver is the interface for retrieving hostname from IP.
|
||||||
type MacTable struct {
|
type HostnameByIpResolver interface {
|
||||||
mac sync.Map
|
LookupHostnameByIP(ip string) string
|
||||||
watcher *fsnotify.Watcher
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes recording client info.
|
// HostnameByMacResolver is the interface for retrieving hostname from Mac.
|
||||||
func (mt *MacTable) Init() error {
|
type HostnameByMacResolver interface {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
LookupHostnameByMac(mac string) string
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLeaseFile adds given lease file for reading/watching clients info.
|
type HostnameResolver interface {
|
||||||
func (mt *MacTable) AddLeaseFile(name string, format ctrld.LeaseFileFormat) error {
|
HostnameByIpResolver
|
||||||
if err := mt.readLeaseFile(name, format); err != nil {
|
HostnameByMacResolver
|
||||||
return fmt.Errorf("could not read lease file: %w", err)
|
|
||||||
}
|
|
||||||
clientInfoFiles[name] = format
|
|
||||||
return mt.watcher.Add(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientInfoByMac returns ClientInfo for the client associated with the given MAC address.
|
type refresher interface {
|
||||||
func (mt *MacTable) GetClientInfoByMac(mac string) *ctrld.ClientInfo {
|
refresh() error
|
||||||
if mac == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
val, ok := mt.mac.Load(mac)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val.(*ctrld.ClientInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchLeaseFiles watches changes happens in dnsmasq/dhcpd
|
type Table struct {
|
||||||
// lease files, perform updating to mac table if necessary.
|
ipResolvers []IpResolver
|
||||||
func (mt *MacTable) WatchLeaseFiles() {
|
macResolvers []MacResolver
|
||||||
if mt.watcher == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
clientInfoFiles[name] = format
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) RefreshLoop(stopCh chan struct{}) {
|
||||||
timer := time.NewTicker(time.Minute * 5)
|
timer := time.NewTicker(time.Minute * 5)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
for _, name := range mt.watcher.WatchList() {
|
for _, r := range t.refreshers {
|
||||||
format := clientInfoFiles[name]
|
_ = r.refresh()
|
||||||
if err := mt.readLeaseFile(name, format); err != nil {
|
|
||||||
ctrld.ProxyLog.Err(err).Str("file", name).Msg("failed to update lease file")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case event, ok := <-mt.watcher.Events:
|
case <-stopCh:
|
||||||
if !ok {
|
return
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLeaseFile reads the lease file with given format, saving client information to mac table.
|
func (t *Table) Init() {
|
||||||
func (mt *MacTable) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
|
if t.discoverDHCP() || t.discoverARP() {
|
||||||
switch format {
|
t.merlin = &merlinDiscover{}
|
||||||
case ctrld.Dnsmasq:
|
if err := t.merlin.refresh(); err != nil {
|
||||||
return mt.dnsmasqReadClientInfoFile(name)
|
ctrld.ProxyLog.Error().Err(err).Msg("could not init Merlin discover")
|
||||||
case ctrld.IscDhcpd:
|
} else {
|
||||||
return mt.iscDHCPReadClientInfoFile(name)
|
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 (t *Table) LookupIP(mac string) string {
|
||||||
func (mt *MacTable) dnsmasqReadClientInfoFile(name string) error {
|
for _, r := range t.ipResolvers {
|
||||||
f, err := os.Open(name)
|
if ip := r.LookupIP(mac); ip != "" {
|
||||||
if err != nil {
|
return ip
|
||||||
return err
|
}
|
||||||
}
|
}
|
||||||
defer f.Close()
|
return ""
|
||||||
return mt.dnsmasqReadClientInfoReader(f)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file.
|
func (t *Table) LookupMac(ip string) string {
|
||||||
func (mt *MacTable) dnsmasqReadClientInfoReader(reader io.Reader) error {
|
t.arp.mac.Range(func(key, value any) bool {
|
||||||
return lineread.Reader(reader, func(line []byte) error {
|
return true
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
for _, r := range t.macResolvers {
|
||||||
|
if mac := r.LookupMac(ip); mac != "" {
|
||||||
// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file.
|
return mac
|
||||||
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], `";`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.
|
// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file.
|
||||||
@@ -217,3 +200,10 @@ func normalizeIP(in string) string {
|
|||||||
}
|
}
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeHostname(name string) string {
|
||||||
|
if before, _, found := strings.Cut(name, "."); found {
|
||||||
|
return before // remove ".local.", ".lan.", ... suffix
|
||||||
|
}
|
||||||
|
return strings.ToLower(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package clientinfo
|
package clientinfo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_normalizeIP(t *testing.T) {
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
+42
-14
@@ -110,18 +110,6 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error
|
|||||||
return nil, errors.Join(errs...)
|
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 {
|
type legacyResolver struct {
|
||||||
uc *UpstreamConfig
|
uc *UpstreamConfig
|
||||||
}
|
}
|
||||||
@@ -149,6 +137,14 @@ func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, e
|
|||||||
return answer, err
|
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.
|
// LookupIP looks up host using OS resolver.
|
||||||
// It returns a slice of that host's IPv4 and IPv6 addresses.
|
// It returns a slice of that host's IPv4 and IPv6 addresses.
|
||||||
func LookupIP(domain string) []string {
|
func LookupIP(domain string) []string {
|
||||||
@@ -160,7 +156,7 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string)
|
|||||||
if withBootstrapDNS {
|
if withBootstrapDNS {
|
||||||
resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...)
|
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
|
timeoutMs := 2000
|
||||||
if timeout > 0 && timeout < timeoutMs {
|
if timeout > 0 && timeout < timeoutMs {
|
||||||
timeoutMs = timeout
|
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:
|
// NewBootstrapResolver returns an OS resolver, which use following nameservers:
|
||||||
//
|
//
|
||||||
// - ControlD bootstrap DNS server.
|
|
||||||
// - Gateway IP address (depends on OS).
|
// - Gateway IP address (depends on OS).
|
||||||
// - Input servers.
|
// - Input servers.
|
||||||
func NewBootstrapResolver(servers ...string) Resolver {
|
func NewBootstrapResolver(servers ...string) Resolver {
|
||||||
@@ -241,3 +236,36 @@ func NewBootstrapResolver(servers ...string) Resolver {
|
|||||||
}
|
}
|
||||||
return 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user