mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-05-27 12:52:27 +02:00
c1e6f5126a
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
246 lines
5.6 KiB
Go
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
|
|
}
|