mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-05-27 12:52:27 +02:00
Improving Mac discovery
This commit is contained in:
committed by
Cuong Manh Le
parent
3007cb86ec
commit
76d2e2c226
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user