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.
394 lines
9.3 KiB
Go
394 lines
9.3 KiB
Go
package clientinfo
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/util/lineread"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
type dhcp struct {
|
|
mac2name sync.Map // mac => name
|
|
ip2name sync.Map // ip => name
|
|
ip sync.Map // mac => ip
|
|
mac sync.Map // ip => mac
|
|
|
|
watcher *fsnotify.Watcher
|
|
selfIP string
|
|
logger *ctrld.Logger
|
|
}
|
|
|
|
func (d *dhcp) init() error {
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.addSelf()
|
|
d.watcher = watcher
|
|
return nil
|
|
}
|
|
|
|
func (d *dhcp) watchChanges() {
|
|
if d.watcher == nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-d.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if event.Has(fsnotify.Create) {
|
|
if format, ok := clientInfoFiles[event.Name]; ok {
|
|
if err := d.addLeaseFile(event.Name, format); err != nil {
|
|
d.logger.Err(err).Str("file", event.Name).Msg("could not add lease file")
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Remove) {
|
|
format := clientInfoFiles[event.Name]
|
|
if err := d.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) {
|
|
d.logger.Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info")
|
|
}
|
|
}
|
|
case err, ok := <-d.watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
d.logger.Err(err).Msg("could not watch client info file")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func (d *dhcp) LookupIP(mac string) string {
|
|
val, ok := d.ip.Load(mac)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return val.(string)
|
|
}
|
|
|
|
func (d *dhcp) LookupMac(ip string) string {
|
|
val, ok := d.mac.Load(ip)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return val.(string)
|
|
}
|
|
|
|
func (d *dhcp) LookupHostnameByIP(ip string) string {
|
|
val, ok := d.ip2name.Load(ip)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return val.(string)
|
|
}
|
|
|
|
func (d *dhcp) LookupHostnameByMac(mac string) string {
|
|
val, ok := d.mac2name.Load(mac)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return val.(string)
|
|
}
|
|
|
|
func (d *dhcp) String() string {
|
|
return "dhcp"
|
|
}
|
|
|
|
func (d *dhcp) List() []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
var ips []string
|
|
d.ip.Range(func(key, value any) bool {
|
|
ips = append(ips, value.(string))
|
|
return true
|
|
})
|
|
d.mac.Range(func(key, value any) bool {
|
|
ips = append(ips, key.(string))
|
|
return true
|
|
})
|
|
return ips
|
|
}
|
|
|
|
func (d *dhcp) lookupIPByHostname(name string, v6 bool) string {
|
|
if d == nil {
|
|
return ""
|
|
}
|
|
var (
|
|
rfc1918Addrs []netip.Addr
|
|
others []netip.Addr
|
|
)
|
|
d.ip2name.Range(func(key, value any) bool {
|
|
if value != name {
|
|
return true
|
|
}
|
|
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
|
|
// Categorize addresses into RFC1918 (private) and public
|
|
// RFC1918 addresses are prioritized because they're more likely to be
|
|
// the actual client IP in most network configurations
|
|
if addr.IsPrivate() {
|
|
rfc1918Addrs = append(rfc1918Addrs, addr)
|
|
} else {
|
|
others = append(others, addr)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
result := [][]netip.Addr{rfc1918Addrs, others}
|
|
for _, addrs := range result {
|
|
if len(addrs) > 0 {
|
|
sort.Slice(addrs, func(i, j int) bool {
|
|
return addrs[i].Less(addrs[j])
|
|
})
|
|
return addrs[0].String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AddLeaseFile adds given lease file for reading/watching clients info.
|
|
func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error {
|
|
if d.watcher == nil {
|
|
return nil
|
|
}
|
|
if err := d.readLeaseFile(name, format); err != nil {
|
|
return fmt.Errorf("could not read lease file: %w", err)
|
|
}
|
|
clientInfoFiles[name] = format
|
|
return d.watcher.Add(name)
|
|
}
|
|
|
|
// readLeaseFile reads the lease file with given format, saving client information to dhcp table.
|
|
func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
|
|
switch format {
|
|
case ctrld.Dnsmasq:
|
|
return d.dnsmasqReadClientInfoFile(name)
|
|
case ctrld.IscDhcpd:
|
|
return d.iscDHCPReadClientInfoFile(name)
|
|
case ctrld.KeaDHCP4:
|
|
return d.keaDhcp4ReadClientInfoFile(name)
|
|
}
|
|
return fmt.Errorf("unsupported format: %s, file: %s", format, name)
|
|
}
|
|
|
|
// dnsmasqReadClientInfoFile populates dhcp table with client info reading from dnsmasq lease file.
|
|
func (d *dhcp) dnsmasqReadClientInfoFile(name string) error {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return d.dnsmasqReadClientInfoReader(f)
|
|
|
|
}
|
|
|
|
// dnsmasqReadClientInfoReader performs the same task as dnsmasqReadClientInfoFile,
|
|
// but by reading from an io.Reader instead of file.
|
|
func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error {
|
|
return lineread.Reader(reader, func(line []byte) error {
|
|
fields := bytes.Fields(line)
|
|
if len(fields) < 4 {
|
|
return nil
|
|
}
|
|
|
|
mac := string(fields[1])
|
|
if _, err := net.ParseMAC(mac); err != nil {
|
|
// The second field is not a dhcp, skip.
|
|
return nil
|
|
}
|
|
ip := normalizeIP(string(fields[2]))
|
|
if net.ParseIP(ip) == nil {
|
|
d.logger.Warn().Msgf("invalid ip address entry: %q", ip)
|
|
ip = ""
|
|
}
|
|
|
|
d.mac.Store(ip, mac)
|
|
d.ip.Store(mac, ip)
|
|
hostname := string(fields[3])
|
|
if hostname == "*" {
|
|
return nil
|
|
}
|
|
name := normalizeHostname(hostname)
|
|
d.mac2name.Store(mac, name)
|
|
d.ip2name.Store(ip, name)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// iscDHCPReadClientInfoFile populates dhcp table with client info reading from isc-dhcpd lease file.
|
|
func (d *dhcp) iscDHCPReadClientInfoFile(name string) error {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return d.iscDHCPReadClientInfoReader(f)
|
|
}
|
|
|
|
// iscDHCPReadClientInfoReader performs the same task as iscDHCPReadClientInfoFile,
|
|
// but by reading from an io.Reader instead of file.
|
|
func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
|
|
s := bufio.NewScanner(reader)
|
|
var ip, mac, hostname string
|
|
for s.Scan() {
|
|
line := s.Text()
|
|
if strings.HasPrefix(line, "}") {
|
|
d.mac.Store(ip, mac)
|
|
d.ip.Store(mac, ip)
|
|
if hostname != "" && hostname != "*" {
|
|
name := normalizeHostname(hostname)
|
|
d.mac2name.Store(mac, name)
|
|
d.ip2name.Store(ip, hostname)
|
|
ip, mac, hostname = "", "", ""
|
|
}
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
switch fields[0] {
|
|
case "lease":
|
|
// Normalize IP address to lowercase for consistent comparison
|
|
// DHCP lease files may contain mixed-case IP addresses
|
|
ip = normalizeIP(strings.ToLower(fields[1]))
|
|
if net.ParseIP(ip) == nil {
|
|
d.logger.Warn().Msgf("invalid ip address entry: %q", ip)
|
|
ip = ""
|
|
}
|
|
case "hardware":
|
|
if len(fields) >= 3 {
|
|
// Convert MAC to lowercase and remove trailing semicolon
|
|
// DHCP lease files use semicolon-terminated MAC addresses
|
|
mac = strings.ToLower(strings.TrimRight(fields[2], ";"))
|
|
if _, err := net.ParseMAC(mac); err != nil {
|
|
// Invalid dhcp, skip.
|
|
mac = ""
|
|
}
|
|
}
|
|
case "client-hostname":
|
|
// Remove quotes and semicolons from hostname
|
|
// DHCP lease files may quote hostnames and add semicolons
|
|
hostname = strings.Trim(fields[1], `";`)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// keaDhcp4ReadClientInfoFile populates dhcp table with client info reading from kea dhcp4 lease file.
|
|
func (d *dhcp) keaDhcp4ReadClientInfoFile(name string) error {
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return d.keaDhcp4ReadClientInfoReader(bufio.NewReader(f))
|
|
|
|
}
|
|
|
|
// keaDhcp4ReadClientInfoReader performs the same task as keaDhcp4ReadClientInfoFile,
|
|
// but by reading from an io.Reader instead of file.
|
|
func (d *dhcp) keaDhcp4ReadClientInfoReader(r io.Reader) error {
|
|
cr := csv.NewReader(r)
|
|
for {
|
|
record, err := cr.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(record) < 9 {
|
|
continue // hostname is at 9th field, so skipping record with not enough fields.
|
|
}
|
|
if record[0] == "address" {
|
|
continue // skip header.
|
|
}
|
|
mac := record[1]
|
|
if _, err := net.ParseMAC(mac); err != nil { // skip invalid MAC
|
|
continue
|
|
}
|
|
ip := normalizeIP(record[0])
|
|
if net.ParseIP(ip) == nil {
|
|
d.logger.Warn().Msgf("invalid ip address entry: %q", ip)
|
|
ip = ""
|
|
}
|
|
|
|
d.mac.Store(ip, mac)
|
|
d.ip.Store(mac, ip)
|
|
hostname := record[8]
|
|
if hostname == "*" {
|
|
continue
|
|
}
|
|
name := normalizeHostname(hostname)
|
|
d.mac2name.Store(mac, name)
|
|
d.ip2name.Store(ip, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addSelf populates current host info to dhcp, so queries from
|
|
// the host itself can be attached with proper client info.
|
|
func (d *dhcp) addSelf() {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
d.logger.Err(err).Msg("could not get hostname")
|
|
return
|
|
}
|
|
hostname = normalizeHostname(hostname)
|
|
d.ip2name.Store(ipV4Loopback, hostname)
|
|
d.ip2name.Store(ipv6Loopback, hostname)
|
|
found := false
|
|
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
|
|
mac := i.HardwareAddr.String()
|
|
// Skip loopback interfaces, info was stored above.
|
|
if mac == "" {
|
|
return
|
|
}
|
|
addrs, _ := i.Addrs()
|
|
for _, addr := range addrs {
|
|
if found {
|
|
return
|
|
}
|
|
ipNet, ok := addr.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ip := ipNet.IP
|
|
d.mac.Store(ip.String(), mac)
|
|
d.ip.Store(mac, ip.String())
|
|
if ip.To4() != nil {
|
|
d.mac.Store(ipV4Loopback, mac)
|
|
} else {
|
|
d.mac.Store(ipv6Loopback, mac)
|
|
}
|
|
d.mac2name.Store(mac, hostname)
|
|
d.ip2name.Store(ip.String(), hostname)
|
|
// If we have self IP set, and this IP is it, use this IP only.
|
|
if ip.String() == d.selfIP {
|
|
found = true
|
|
d.mac.Store(ipV4Loopback, mac)
|
|
d.mac.Store(ipv6Loopback, mac)
|
|
}
|
|
}
|
|
})
|
|
}
|