Files
ctrld/internal/clientinfo/ndp.go
T
Cuong Manh Le c1e6f5126a internal/clientinfo: watch NDP table changes on Linux
So with clients which only use SLAAC, ctrld could see client's new ip as
soon as its state changes to REACHABLE.

Moreover, the NDP listener is also changed to listen on all possible
ipv6 link local interfaces. That would allow ctrld to get all NDP events
happening in local network.

SLAAC RFC: https://datatracker.ietf.org/doc/html/rfc4862
2024-04-04 22:44:25 +07:00

246 lines
5.6 KiB
Go

package clientinfo
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"strings"
"sync"
"time"
"github.com/mdlayher/ndp"
"github.com/Control-D-Inc/ctrld"
)
// 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
}
// saveInfo saves ip and mac info to mapping table.
// If force is true, old ip will be removed before saving.
func (nd *ndpDiscover) saveInfo(ip, mac string, force bool) {
ip = normalizeIP(ip)
// Store ip => map mapping,
nd.mac.Store(ip, mac)
if force {
// If there is old ip => mac mapping, delete it.
if old, ok := nd.ip.Load(mac); ok {
oldIP := old.(string)
nd.mac.Delete(oldIP)
}
}
// Store mac => ip mapping.
nd.ip.Store(mac, ip)
}
// listen listens on ipv6 link local for Neighbor Solicitation message
// to update new neighbors information to ndp table.
func (nd *ndpDiscover) listen(ctx context.Context) {
ifis, err := allInterfacesWithV6LinkLocal()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6 interfaces")
return
}
for _, ifi := range ifis {
go func(ifi *net.Interface) {
nd.listenOnInterface(ctx, ifi)
}(ifi)
}
}
func (nd *ndpDiscover) listenOnInterface(ctx context.Context, ifi *net.Interface) {
c, ip, err := ndp.Listen(ifi, ndp.Unspecified)
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("ndp listen failed")
return
}
defer c.Close()
ctrld.ProxyLogger.Load().Debug().Msgf("listening ndp on: %s", ip.String())
for {
select {
case <-ctx.Done():
return
default:
}
_ = c.SetReadDeadline(time.Now().Add(30 * time.Second))
msg, _, from, readErr := c.ReadFrom()
if readErr != nil {
var opErr *net.OpError
if errors.As(readErr, &opErr) && (opErr.Timeout() || opErr.Temporary()) {
continue
}
ctrld.ProxyLogger.Load().Debug().Err(readErr).Msg("ndp read loop error")
return
}
// Only looks for neighbor solicitation message, since new clients
// which join network will broadcast this message to us.
am, ok := msg.(*ndp.NeighborSolicitation)
if !ok {
continue
}
fromIP := from.String()
for _, opt := range am.Options {
if lla, ok := opt.(*ndp.LinkLayerAddress); ok {
mac := lla.Addr.String()
nd.saveInfo(fromIP, mac, true)
}
}
}
}
// 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.saveInfo(fields[0], mac, true)
}
}
}
// 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.saveInfo(ip, mac, true)
}
}
}
// 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()
}
// allInterfacesWithV6LinkLocal returns all interfaces which is capable of using NDP.
func allInterfacesWithV6LinkLocal() ([]*net.Interface, error) {
ifis, err := net.Interfaces()
if err != nil {
return nil, err
}
res := make([]*net.Interface, 0, len(ifis))
for _, ifi := range ifis {
ifi := ifi
// Skip if iface is down/loopback/non-multicast.
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 || ifi.Flags&net.FlagMulticast == 0 {
continue
}
addrs, err := ifi.Addrs()
if err != nil {
return nil, err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip, ok := netip.AddrFromSlice(ipNet.IP)
if !ok {
return nil, fmt.Errorf("invalid ip address: %s", ipNet.String())
}
if ip.Is6() && !ip.Is4In6() {
res = append(res, &ifi)
break
}
}
}
return res, nil
}