From cb445825f4ffd07fe2038747acea86e02f7bd682 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 4 Jan 2024 00:23:41 +0700 Subject: [PATCH] internal/clientinfo: add NDP discovery --- internal/clientinfo/client_info.go | 29 +++++-- internal/clientinfo/ndp.go | 126 +++++++++++++++++++++++++++++ internal/clientinfo/ndp_linux.go | 24 ++++++ internal/clientinfo/ndp_others.go | 31 +++++++ internal/clientinfo/ndp_test.go | 64 +++++++++++++++ 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 internal/clientinfo/ndp.go create mode 100644 internal/clientinfo/ndp_linux.go create mode 100644 internal/clientinfo/ndp_others.go create mode 100644 internal/clientinfo/ndp_test.go diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go index 07e4cf0..f51cf88 100644 --- a/internal/clientinfo/client_info.go +++ b/internal/clientinfo/client_info.go @@ -74,6 +74,7 @@ type Table struct { dhcp *dhcp merlin *merlinDiscover arp *arpDiscover + ndp *ndpDiscover ptr *ptrDiscover mdns *mdns hf *hostsFile @@ -172,16 +173,28 @@ func (t *Table) init() { } go t.dhcp.watchChanges() } - // ARP table. + // ARP/NDP table. if t.discoverARP() { t.arp = &arpDiscover{} + t.ndp = &ndpDiscover{} ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery") - if err := t.arp.refresh(); err != nil { - ctrld.ProxyLogger.Load().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) + discovers := map[string]interface { + refresher + IpResolver + MacResolver + }{ + "ARP": t.arp, + "NDP": t.ndp, + } + + for protocol, discover := range discovers { + if err := discover.refresh(); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", protocol) + } else { + t.ipResolvers = append(t.ipResolvers, discover) + t.macResolvers = append(t.macResolvers, discover) + t.refreshers = append(t.refreshers, discover) + } } } // PTR lookup. @@ -328,7 +341,7 @@ func (t *Table) ListClients() []*Client { _ = r.refresh() } ipMap := make(map[string]*Client) - il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns, t.vni} + il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni} for _, ir := range il { for _, ip := range ir.List() { c, ok := ipMap[ip] diff --git a/internal/clientinfo/ndp.go b/internal/clientinfo/ndp.go new file mode 100644 index 0000000..337c414 --- /dev/null +++ b/internal/clientinfo/ndp.go @@ -0,0 +1,126 @@ +package clientinfo + +import ( + "bufio" + "io" + "net" + "strings" + "sync" +) + +// ndpDiscover provides client discovery functionality using NDP protocol. +type ndpDiscover struct { + mac sync.Map // ip => mac + ip sync.Map // mac => ip +} + +// refresh re-scans the NDP table. +func (nd *ndpDiscover) refresh() error { + nd.scan() + return nil +} + +// LookupIP returns the ipv6 associated with the input MAC address. +func (nd *ndpDiscover) LookupIP(mac string) string { + val, ok := nd.ip.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +// LookupMac returns the MAC address of the given IP address. +func (nd *ndpDiscover) LookupMac(ip string) string { + val, ok := nd.mac.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +// String returns human-readable format of ndpDiscover. +func (nd *ndpDiscover) String() string { + return "ndp" +} + +// List returns all known IP addresses. +func (nd *ndpDiscover) List() []string { + if nd == nil { + return nil + } + var ips []string + nd.ip.Range(func(key, value any) bool { + ips = append(ips, value.(string)) + return true + }) + nd.mac.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} + +// scanWindows populates NDP table using information from "netsh" command. +func (nd *ndpDiscover) scanWindows(r io.Reader) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 { + continue + } + if mac := parseMAC(fields[1]); mac != "" { + nd.mac.Store(fields[0], mac) + nd.ip.Store(mac, fields[0]) + } + } +} + +// scanUnix populates NDP table using information from "ndp" command. +func (nd *ndpDiscover) scanUnix(r io.Reader) { + scanner := bufio.NewScanner(r) + scanner.Scan() // skip header + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + if mac := parseMAC(fields[1]); mac != "" { + ip := fields[0] + if idx := strings.IndexByte(ip, '%'); idx != -1 { + ip = ip[:idx] + } + nd.mac.Store(ip, mac) + nd.ip.Store(mac, ip) + } + } +} + +// normalizeMac ensure the given MAC address have the proper format +// before being parsed. +// +// Example, changing "00:0:00:0:00:01" to "00:00:00:00:00:01", which +// can be seen on Darwin. +func normalizeMac(mac string) string { + if len(mac) == 17 { + return mac + } + // Windows use "-" instead of ":" as separator. + mac = strings.ReplaceAll(mac, "-", ":") + parts := strings.Split(mac, ":") + if len(parts) != 6 { + return "" + } + for i, c := range parts { + if len(c) == 1 { + parts[i] = "0" + c + } + } + return strings.Join(parts, ":") +} + +// parseMAC parses the input MAC, doing normalization, +// and return the result after calling net.ParseMac function. +func parseMAC(mac string) string { + hw, _ := net.ParseMAC(normalizeMac(mac)) + return hw.String() +} diff --git a/internal/clientinfo/ndp_linux.go b/internal/clientinfo/ndp_linux.go new file mode 100644 index 0000000..713a7e3 --- /dev/null +++ b/internal/clientinfo/ndp_linux.go @@ -0,0 +1,24 @@ +package clientinfo + +import ( + "github.com/vishvananda/netlink" + + "github.com/Control-D-Inc/ctrld" +) + +// scan populates NDP table using information from system mappings. +func (nd *ndpDiscover) scan() { + neighs, err := netlink.NeighList(0, netlink.FAMILY_V6) + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not get neigh list") + return + } + + for _, n := range neighs { + ip := n.IP.String() + mac := n.HardwareAddr.String() + nd.mac.Store(ip, mac) + nd.ip.Store(mac, ip) + } + +} diff --git a/internal/clientinfo/ndp_others.go b/internal/clientinfo/ndp_others.go new file mode 100644 index 0000000..05ac322 --- /dev/null +++ b/internal/clientinfo/ndp_others.go @@ -0,0 +1,31 @@ +//go:build !linux + +package clientinfo + +import ( + "bytes" + "os/exec" + "runtime" + + "github.com/Control-D-Inc/ctrld" +) + +// scan populates NDP table using information from system mappings. +func (nd *ndpDiscover) scan() { + switch runtime.GOOS { + case "windows": + data, err := exec.Command("netsh", "interface", "ipv6", "show", "neighbors").Output() + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table") + return + } + nd.scanWindows(bytes.NewReader(data)) + default: + data, err := exec.Command("ndp", "-an").Output() + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table") + return + } + nd.scanUnix(bytes.NewReader(data)) + } +} diff --git a/internal/clientinfo/ndp_test.go b/internal/clientinfo/ndp_test.go new file mode 100644 index 0000000..c8cd398 --- /dev/null +++ b/internal/clientinfo/ndp_test.go @@ -0,0 +1,64 @@ +package clientinfo + +import ( + "strings" + "sync" + "testing" +) + +func Test_ndpDiscover_scanUnix(t *testing.T) { + r := strings.NewReader(`Neighbor Linklayer Address Netif Expire St Flgs Prbs +2405:4802:1f90:fda0:1459:ec89:523d:3583 00:0:00:0:00:01 en0 permanent R +2405:4802:1f90:fda0:186b:c54a:1370:c196 (incomplete) en0 expired N +2405:4802:1f90:fda0:88de:14ef:6a8c:579a 00:0:00:0:00:02 en0 permanent R +fe80::1%lo0 (incomplete) lo0 permanent R +`) + nd := &ndpDiscover{} + nd.scanUnix(r) + + for _, m := range []*sync.Map{&nd.mac, &nd.ip} { + count := 0 + m.Range(func(key, value any) bool { + count++ + return true + }) + if count != 2 { + t.Errorf("unexpected count, want 2, got: %d", count) + } + } +} + +func Test_ndpDiscover_scanWindows(t *testing.T) { + r := strings.NewReader(`Interface 14: Wi-Fi + + +Internet Address Physical Address Type +-------------------------------------------- ----------------- ----------- +2405:4802:1f90:fda0:ffff:ffff:ffff:ff88 00-00-00-00-00-00 Unreachable +fe80::1 60-57-47-21-dd-00 Reachable (Router) +fe80::6257:47ff:fe21:dd00 60-57-47-21-dd-00 Reachable (Router) +ff02::1 33-33-00-00-00-01 Permanent +ff02::2 33-33-00-00-00-02 Permanent +ff02::c 33-33-00-00-00-0c Permanent +`) + nd := &ndpDiscover{} + nd.scanWindows(r) + + count := 0 + nd.mac.Range(func(key, value any) bool { + count++ + return true + }) + if count != 6 { + t.Errorf("unexpected count, want 6, got: %d", count) + } + + count = 0 + nd.ip.Range(func(key, value any) bool { + count++ + return true + }) + if count != 5 { + t.Errorf("unexpected count, want 5, got: %d", count) + } +}