mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
250 lines
5.7 KiB
Go
250 lines
5.7 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"
|
|
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
|
)
|
|
|
|
// 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.
|
|
func (nd *ndpDiscover) saveInfo(ip, mac string) {
|
|
ip = normalizeIP(ip)
|
|
// Store ip => map mapping,
|
|
nd.mac.Store(ip, mac)
|
|
|
|
// Do not store mac => ip mapping if new ip is a link local unicast.
|
|
if ctrldnet.IsLinkLocalUnicastIPv6(ip) {
|
|
return
|
|
}
|
|
|
|
// If there is old ip => mac mapping, delete it.
|
|
if old, existed := nd.ip.Load(mac); existed {
|
|
oldIP := old.(string)
|
|
if oldIP != ip {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|