mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-05-15 00:50:25 +02:00
d88c860cac
This commit adds detailed explanatory comments throughout the codebase to explain WHY certain logic is needed, not just WHAT the code does. This improves code maintainability and helps developers understand the reasoning behind complex decisions. Key improvements: - Version string processing: Explain why "v" prefix is added for semantic versioning - Control-D configuration: Explain why config is reset to prevent mixing of settings - DNS server categorization: Explain LAN vs public server handling for performance - Listener configuration: Document complex fallback logic for port/IP selection - MAC address normalization: Explain cross-platform compatibility needs - IPv6 address processing: Document Unix-specific interface suffix handling - Log content truncation: Explain why large content is limited to prevent flooding - IP address categorization: Document RFC1918 prioritization logic - IPv4/IPv6 separation: Explain network stack compatibility needs - DNS priority logic: Document different priority levels for different scenarios - Domain controller processing: Explain Windows API prefix handling - Reverse mapping creation: Document API encoding/decoding needs - Default value fallbacks: Explain why defaults prevent system failures - IP stack configuration: Document different defaults for different upstream types These comments help future developers understand the reasoning behind complex business logic, making the codebase more maintainable and reducing the risk of incorrect modifications during maintenance.
563 lines
14 KiB
Go
563 lines
14 KiB
Go
package clientinfo
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
const (
|
|
ipV4Loopback = "127.0.0.1"
|
|
ipv6Loopback = "::1"
|
|
)
|
|
|
|
// IpResolver is the interface for retrieving IP from Mac.
|
|
type IpResolver interface {
|
|
fmt.Stringer
|
|
// LookupIP returns ip of the device with given mac.
|
|
LookupIP(mac string) string
|
|
}
|
|
|
|
// MacResolver is the interface for retrieving Mac from IP.
|
|
type MacResolver interface {
|
|
fmt.Stringer
|
|
// LookupMac returns mac of the device with given ip.
|
|
LookupMac(ip string) string
|
|
}
|
|
|
|
// HostnameByIpResolver is the interface for retrieving hostname from IP.
|
|
type HostnameByIpResolver interface {
|
|
// LookupHostnameByIP returns hostname of the given ip.
|
|
LookupHostnameByIP(ip string) string
|
|
}
|
|
|
|
// HostnameByMacResolver is the interface for retrieving hostname from Mac.
|
|
type HostnameByMacResolver interface {
|
|
// LookupHostnameByMac returns hostname of the device with given mac.
|
|
LookupHostnameByMac(mac string) string
|
|
}
|
|
|
|
// HostnameResolver is the interface for retrieving hostname from either IP or Mac.
|
|
type HostnameResolver interface {
|
|
fmt.Stringer
|
|
HostnameByIpResolver
|
|
HostnameByMacResolver
|
|
}
|
|
|
|
type refresher interface {
|
|
refresh() error
|
|
}
|
|
|
|
type ipLister interface {
|
|
fmt.Stringer
|
|
// List returns list of ip known by the resolver.
|
|
List() []string
|
|
}
|
|
|
|
type Client struct {
|
|
IP netip.Addr
|
|
Mac string
|
|
Hostname string
|
|
Source map[string]struct{}
|
|
QueryCount int64
|
|
IncludeQueryCount bool
|
|
}
|
|
|
|
type Table struct {
|
|
ipResolvers []IpResolver
|
|
macResolvers []MacResolver
|
|
hostnameResolvers []HostnameResolver
|
|
refreshers []refresher
|
|
initOnce sync.Once
|
|
stopOnce sync.Once
|
|
refreshInterval int
|
|
logger *ctrld.Logger
|
|
|
|
dhcp *dhcp
|
|
arp *arpDiscover
|
|
ndp *ndpDiscover
|
|
ptr *ptrDiscover
|
|
mdns *mdns
|
|
hf *hostsFile
|
|
vni *virtualNetworkIface
|
|
svcCfg ctrld.ServiceConfig
|
|
quitCh chan struct{}
|
|
stopCh chan struct{}
|
|
selfIP string
|
|
selfIPLock sync.RWMutex
|
|
cdUID string
|
|
ptrNameservers []string
|
|
}
|
|
|
|
func NewTable(cfg *ctrld.Config, selfIP, cdUID string, ns []string, logger *ctrld.Logger) *Table {
|
|
refreshInterval := cfg.Service.DiscoverRefreshInterval
|
|
// Set default refresh interval if not configured
|
|
// This ensures client discovery continues to work even without explicit configuration
|
|
if refreshInterval <= 0 {
|
|
refreshInterval = 2 * 60 // 2 minutes
|
|
}
|
|
// Use no-op logger if none provided
|
|
// This prevents nil pointer dereferences when logging is not configured
|
|
if logger == nil {
|
|
logger = ctrld.NopLogger
|
|
}
|
|
return &Table{
|
|
svcCfg: cfg.Service,
|
|
quitCh: make(chan struct{}),
|
|
stopCh: make(chan struct{}),
|
|
selfIP: selfIP,
|
|
cdUID: cdUID,
|
|
ptrNameservers: ns,
|
|
refreshInterval: refreshInterval,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
|
|
if !t.discoverDHCP() {
|
|
return
|
|
}
|
|
clientInfoFiles[name] = format
|
|
}
|
|
|
|
// RefreshLoop runs all the refresher to update new client info data.
|
|
func (t *Table) RefreshLoop(ctx context.Context) {
|
|
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
|
|
defer func() {
|
|
timer.Stop()
|
|
close(t.quitCh)
|
|
}()
|
|
for {
|
|
select {
|
|
case <-timer.C:
|
|
t.Refresh()
|
|
case <-t.stopCh:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Init initializes all client info discovers.
|
|
func (t *Table) Init() {
|
|
t.initOnce.Do(t.init)
|
|
}
|
|
|
|
// Refresh forces all discovers to retrieve new data.
|
|
func (t *Table) Refresh() {
|
|
for _, r := range t.refreshers {
|
|
_ = r.refresh()
|
|
}
|
|
}
|
|
|
|
// Stop stops all the discovers.
|
|
// It blocks until all the discovers done.
|
|
func (t *Table) Stop() {
|
|
t.stopOnce.Do(func() {
|
|
close(t.stopCh)
|
|
})
|
|
<-t.quitCh
|
|
}
|
|
|
|
// SelfIP returns the selfIP value of the Table in a thread-safe manner.
|
|
func (t *Table) SelfIP() string {
|
|
t.selfIPLock.RLock()
|
|
defer t.selfIPLock.RUnlock()
|
|
return t.selfIP
|
|
}
|
|
|
|
// SetSelfIP sets the selfIP value of the Table in a thread-safe manner.
|
|
func (t *Table) SetSelfIP(ip string) {
|
|
t.selfIPLock.Lock()
|
|
defer t.selfIPLock.Unlock()
|
|
t.selfIP = ip
|
|
t.dhcp.selfIP = t.selfIP
|
|
t.dhcp.addSelf()
|
|
}
|
|
|
|
// initSelfDiscover initializes necessary client metadata for self query.
|
|
func (t *Table) initSelfDiscover() {
|
|
t.dhcp = &dhcp{selfIP: t.selfIP, logger: t.logger}
|
|
t.dhcp.addSelf()
|
|
t.ipResolvers = append(t.ipResolvers, t.dhcp)
|
|
t.macResolvers = append(t.macResolvers, t.dhcp)
|
|
t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp)
|
|
}
|
|
|
|
func (t *Table) init() {
|
|
// Custom client ID presents, use it as the only source.
|
|
if _, clientID := controld.ParseRawUID(t.cdUID); clientID != "" {
|
|
t.logger.Debug().Msg("start self discovery with custom client id")
|
|
t.initSelfDiscover()
|
|
return
|
|
}
|
|
|
|
// If we are running on platforms that should only do self discover, use it as the only source, too.
|
|
if ctrld.SelfDiscover() {
|
|
t.logger.Debug().Msg("start self discovery on desktop platforms")
|
|
t.initSelfDiscover()
|
|
return
|
|
}
|
|
|
|
// Hosts file mapping.
|
|
if t.discoverHosts() {
|
|
t.hf = &hostsFile{logger: t.logger}
|
|
t.logger.Debug().Msg("start hosts file discovery")
|
|
if err := t.hf.init(); err != nil {
|
|
t.logger.Error().Err(err).Msg("could not init hosts file discover")
|
|
} else {
|
|
t.hostnameResolvers = append(t.hostnameResolvers, t.hf)
|
|
t.refreshers = append(t.refreshers, t.hf)
|
|
}
|
|
go t.hf.watchChanges()
|
|
}
|
|
// DHCP lease files.
|
|
if t.discoverDHCP() {
|
|
t.dhcp = &dhcp{selfIP: t.selfIP, logger: t.logger}
|
|
t.logger.Debug().Msg("start dhcp discovery")
|
|
if err := t.dhcp.init(); err != nil {
|
|
t.logger.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)
|
|
}
|
|
go t.dhcp.watchChanges()
|
|
}
|
|
// ARP/NDP table.
|
|
if t.discoverARP() {
|
|
t.arp = &arpDiscover{}
|
|
t.ndp = &ndpDiscover{logger: t.logger}
|
|
t.logger.Debug().Msg("start arp discovery")
|
|
discovers := map[string]interface {
|
|
refresher
|
|
IpResolver
|
|
MacResolver
|
|
}{
|
|
"ARP": t.arp,
|
|
"NDP": t.ndp,
|
|
}
|
|
|
|
for protocol, discover := range discovers {
|
|
if err := discover.refresh(); err != nil {
|
|
t.logger.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)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
<-t.quitCh
|
|
cancel()
|
|
}()
|
|
go t.ndp.listen(ctx)
|
|
go t.ndp.subscribe(ctx)
|
|
}
|
|
// PTR lookup.
|
|
if t.discoverPTR() {
|
|
t.ptr = &ptrDiscover{
|
|
resolver: ctrld.NewPrivateResolver(context.Background()),
|
|
logger: t.logger,
|
|
}
|
|
if len(t.ptrNameservers) > 0 {
|
|
nss := make([]string, 0, len(t.ptrNameservers))
|
|
for _, ns := range t.ptrNameservers {
|
|
host, port := ns, "53"
|
|
if h, p, err := net.SplitHostPort(ns); err == nil {
|
|
host, port = h, p
|
|
}
|
|
// Only use valid ip:port pair.
|
|
// Invalid nameservers can cause PTR discovery to fail silently
|
|
if _, portErr := strconv.Atoi(port); portErr == nil && port != "0" && net.ParseIP(host) != nil {
|
|
nss = append(nss, net.JoinHostPort(host, port))
|
|
} else {
|
|
t.logger.Warn().Msgf("ignoring invalid nameserver for ptr discover: %q", ns)
|
|
}
|
|
}
|
|
if len(nss) > 0 {
|
|
t.ptr.resolver = ctrld.NewResolverWithNameserver(nss)
|
|
t.logger.Debug().Msgf("using nameservers %v for ptr discovery", nss)
|
|
}
|
|
|
|
}
|
|
t.logger.Debug().Msg("start ptr discovery")
|
|
if err := t.ptr.refresh(); err != nil {
|
|
t.logger.Error().Err(err).Msg("could not init PTR discover")
|
|
} else {
|
|
t.hostnameResolvers = append(t.hostnameResolvers, t.ptr)
|
|
t.refreshers = append(t.refreshers, t.ptr)
|
|
}
|
|
}
|
|
// mdns.
|
|
if t.discoverMDNS() {
|
|
t.mdns = &mdns{logger: t.logger}
|
|
t.logger.Debug().Msg("start mdns discovery")
|
|
if err := t.mdns.init(t.quitCh); err != nil {
|
|
t.logger.Error().Err(err).Msg("could not init mDNS discover")
|
|
} else {
|
|
t.hostnameResolvers = append(t.hostnameResolvers, t.mdns)
|
|
}
|
|
}
|
|
// VPN clients.
|
|
if t.discoverDHCP() || t.discoverARP() {
|
|
t.vni = &virtualNetworkIface{}
|
|
t.hostnameResolvers = append(t.hostnameResolvers, t.vni)
|
|
}
|
|
}
|
|
|
|
func (t *Table) LookupIP(mac string) string {
|
|
t.initOnce.Do(t.init)
|
|
for _, r := range t.ipResolvers {
|
|
if ip := r.LookupIP(mac); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (t *Table) LookupMac(ip string) string {
|
|
t.initOnce.Do(t.init)
|
|
for _, r := range t.macResolvers {
|
|
if mac := r.LookupMac(ip); mac != "" {
|
|
return mac
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (t *Table) LookupHostname(ip, mac string) string {
|
|
t.initOnce.Do(t.init)
|
|
for _, r := range t.hostnameResolvers {
|
|
if name := r.LookupHostnameByIP(ip); name != "" {
|
|
return name
|
|
}
|
|
if name := r.LookupHostnameByMac(mac); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// LookupRFC1918IPv4 returns the RFC1918 IPv4 address for the given MAC address, if any.
|
|
func (t *Table) LookupRFC1918IPv4(mac string) string {
|
|
t.initOnce.Do(t.init)
|
|
for _, r := range t.ipResolvers {
|
|
ip, err := netip.ParseAddr(r.LookupIP(mac))
|
|
if err != nil || ip.Is6() {
|
|
continue
|
|
}
|
|
if ip.IsPrivate() {
|
|
return ip.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// LocalHostname returns the localhost hostname associated with loopback IP.
|
|
func (t *Table) LocalHostname() string {
|
|
for _, ip := range []string{ipV4Loopback, ipv6Loopback} {
|
|
if name := t.LookupHostname(ip, ""); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type macEntry struct {
|
|
mac string
|
|
src string
|
|
}
|
|
|
|
type hostnameEntry struct {
|
|
name string
|
|
src string
|
|
}
|
|
|
|
func (t *Table) lookupMacAll(ip string) []*macEntry {
|
|
var res []*macEntry
|
|
for _, r := range t.macResolvers {
|
|
res = append(res, &macEntry{mac: r.LookupMac(ip), src: r.String()})
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (t *Table) lookupHostnameAll(ip, mac string) []*hostnameEntry {
|
|
var res []*hostnameEntry
|
|
for _, r := range t.hostnameResolvers {
|
|
src := r.String()
|
|
// For ptrDiscover, lookup hostname may block due to server unavailable,
|
|
// so only lookup from cache to prevent timeout reached.
|
|
if ptrResolver, ok := r.(*ptrDiscover); ok {
|
|
if name := ptrResolver.lookupHostnameFromCache(ip); name != "" {
|
|
res = append(res, &hostnameEntry{name: name, src: src})
|
|
}
|
|
continue
|
|
}
|
|
if name := r.LookupHostnameByIP(ip); name != "" {
|
|
res = append(res, &hostnameEntry{name: name, src: src})
|
|
continue
|
|
}
|
|
if name := r.LookupHostnameByMac(mac); name != "" {
|
|
res = append(res, &hostnameEntry{name: name, src: src})
|
|
continue
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// ListClients returns list of clients discovered by ctrld.
|
|
func (t *Table) ListClients() []*Client {
|
|
t.Refresh()
|
|
ipMap := make(map[string]*Client)
|
|
il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
|
|
|
|
for _, ir := range il {
|
|
if ir == nil {
|
|
continue
|
|
}
|
|
|
|
for _, ip := range ir.List() {
|
|
// Validate IP before using MustParseAddr
|
|
if addr, err := netip.ParseAddr(ip); err == nil {
|
|
c, ok := ipMap[ip]
|
|
if !ok {
|
|
c = &Client{
|
|
IP: addr,
|
|
Source: map[string]struct{}{},
|
|
}
|
|
ipMap[ip] = c
|
|
}
|
|
// Safely get source name
|
|
if src := ir.String(); src != "" {
|
|
c.Source[src] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
clientsByMAC := make(map[string]*Client)
|
|
for ip := range ipMap {
|
|
c := ipMap[ip]
|
|
for _, e := range t.lookupMacAll(ip) {
|
|
if c.Mac == "" && e.mac != "" {
|
|
c.Mac = e.mac
|
|
}
|
|
if e.mac != "" {
|
|
c.Source[e.src] = struct{}{}
|
|
}
|
|
}
|
|
for _, e := range t.lookupHostnameAll(ip, c.Mac) {
|
|
if c.Hostname == "" && e.name != "" {
|
|
c.Hostname = e.name
|
|
clientsByMAC[c.Mac] = c
|
|
}
|
|
if e.name != "" {
|
|
c.Source[e.src] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
clients := make([]*Client, 0, len(ipMap))
|
|
for _, c := range ipMap {
|
|
// If we found a client with empty hostname, use hostname from
|
|
// an existed client which has the same MAC address.
|
|
// This helps fill in missing hostnames when multiple IPs share the same MAC
|
|
if cFromMac := clientsByMAC[c.Mac]; cFromMac != nil && c.Hostname == "" {
|
|
c.Hostname = cFromMac.Hostname
|
|
}
|
|
clients = append(clients, c)
|
|
}
|
|
return clients
|
|
}
|
|
|
|
// StoreVPNClient stores client info for VPN clients.
|
|
func (t *Table) StoreVPNClient(ci *ctrld.ClientInfo) {
|
|
if ci == nil || t.vni == nil {
|
|
return
|
|
}
|
|
t.vni.mac.Store(ci.IP, ci.Mac)
|
|
t.vni.ip2name.Store(ci.IP, ci.Hostname)
|
|
}
|
|
|
|
// ipFinder is the interface for retrieving IP address from hostname.
|
|
type ipFinder interface {
|
|
lookupIPByHostname(name string, v6 bool) string
|
|
}
|
|
|
|
// LookupIPByHostname returns the ip address of given hostname.
|
|
// If v6 is true, return IPv6 instead of default IPv4.
|
|
func (t *Table) LookupIPByHostname(hostname string, v6 bool) *netip.Addr {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
for _, finder := range []ipFinder{t.hf, t.ptr, t.mdns, t.dhcp} {
|
|
if addr := finder.lookupIPByHostname(hostname, v6); addr != "" {
|
|
if ip, err := netip.ParseAddr(addr); err == nil {
|
|
return &ip
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Table) discoverDHCP() bool {
|
|
if t.svcCfg.DiscoverDHCP == nil {
|
|
return true
|
|
}
|
|
return *t.svcCfg.DiscoverDHCP
|
|
}
|
|
|
|
func (t *Table) discoverARP() bool {
|
|
if t.svcCfg.DiscoverARP == nil {
|
|
return true
|
|
}
|
|
return *t.svcCfg.DiscoverARP
|
|
}
|
|
|
|
func (t *Table) discoverMDNS() bool {
|
|
if t.svcCfg.DiscoverMDNS == nil {
|
|
return true
|
|
}
|
|
return *t.svcCfg.DiscoverMDNS
|
|
}
|
|
|
|
func (t *Table) discoverPTR() bool {
|
|
if t.svcCfg.DiscoverPtr == nil {
|
|
return true
|
|
}
|
|
return *t.svcCfg.DiscoverPtr
|
|
}
|
|
|
|
func (t *Table) discoverHosts() bool {
|
|
if t.svcCfg.DiscoverHosts == nil {
|
|
return true
|
|
}
|
|
return *t.svcCfg.DiscoverHosts
|
|
}
|
|
|
|
// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file.
|
|
func normalizeIP(in string) string {
|
|
// dnsmasq may put ip with interface index in lease file, strip it here.
|
|
ip, _, found := strings.Cut(in, "%")
|
|
if found {
|
|
return ip
|
|
}
|
|
return in
|
|
}
|
|
|
|
func normalizeHostname(name string) string {
|
|
if before, _, found := strings.Cut(name, "."); found {
|
|
return before // remove ".local.", ".lan.", ... suffix
|
|
}
|
|
return name
|
|
}
|