package clients import ( "bufio" "fmt" "os" "sort" "strconv" "strings" "nano-router/config" "nano-router/traffic" ) const LeasesFile = "/var/lib/misc/dnsmasq.leases" type Client struct { IP string `json:"ip"` MAC string `json:"mac"` Hostname string `json:"hostname"` Interface string `json:"interface"` LeaseExpires int64 `json:"lease_expires"` IsDHCP bool `json:"is_dhcp"` Online bool `json:"online"` TxBytes uint64 `json:"tx_bytes"` RxBytes uint64 `json:"rx_bytes"` LastActive int64 `json:"last_active"` Known bool `json:"known"` Blocked bool `json:"blocked"` StaticIP string `json:"static_ip"` Policy string `json:"policy"` // "disabled" | "direct" | "vpn" | "" (use default) } func GetAll() ([]Client, error) { leases, err := parseDNSMasqLeases() if err != nil { leases = map[string]*Client{} } arpEntries, err := parseARPTable() if err != nil { return nil, fmt.Errorf("arp: %w", err) } byIP := make(map[string]*Client, len(arpEntries)) for ip, c := range arpEntries { byIP[ip] = c } for ip, lease := range leases { if c, exists := byIP[ip]; exists { c.IsDHCP = true c.LeaseExpires = lease.LeaseExpires if lease.Hostname != "" { c.Hostname = lease.Hostname } if c.MAC == "" { c.MAC = lease.MAC } } else { byIP[ip] = lease } } blockedByMAC := make(map[string]bool) cfg, cfgErr := config.Load() if cfgErr == nil && cfg != nil { knownByMAC := make(map[string]bool) for _, c := range byIP { if c.MAC != "" { knownByMAC[c.MAC] = true } } for _, kd := range cfg.KnownDevices { key := kd.IP found := false for ip, c := range byIP { matchedMAC := kd.MAC != "" && c.MAC == kd.MAC matchedIP := kd.IP != "" && ip == kd.IP && (kd.MAC == "" || c.MAC == kd.MAC) if !matchedMAC && !matchedIP { continue } // Policy, blocked state, and hostname apply to every IP this MAC // has on the network (device connected to multiple interfaces/VLANs). c.Blocked = kd.Blocked c.Policy = kd.Policy if kd.Hostname != "" { c.Hostname = kd.Hostname } // Static IP binding (DHCP reservation) and IP override only apply // to the canonical/primary entry for this device. if matchedIP || ip == kd.StaticIP || (!found && matchedMAC) { c.StaticIP = kd.StaticIP if kd.StaticIP != "" { c.IP = kd.StaticIP } } found = true // No break — keep iterating so all IPs for this MAC are updated. } if !found && key != "" { knownByMACKey := kd.MAC if knownByMACKey != "" && knownByMAC[knownByMACKey] { continue } displayIP := kd.IP if kd.StaticIP != "" { displayIP = kd.StaticIP } byIP[key] = &Client{ IP: displayIP, MAC: kd.MAC, Hostname: kd.Hostname, Known: true, Blocked: kd.Blocked, StaticIP: kd.StaticIP, Policy: kd.Policy, } } if kd.Blocked && kd.MAC != "" { blockedByMAC[kd.MAC] = true } } } trafficAvailable := traffic.Available() for ip, c := range byIP { if c.Known && c.IP == "" { continue } if trafficAvailable { traffic.EnsureIPTracked(ip) } ts := traffic.Get(ip) c.TxBytes = ts.TxBytes c.RxBytes = ts.RxBytes if !ts.LastActive.IsZero() { c.LastActive = ts.LastActive.Unix() } if trafficAvailable { c.Online = traffic.IsOnline(ip) } } go syncKnownDevices(byIP) // Exclude upstream gateways — they appear in the ARP table but are not // LAN clients. Build the exclusion set from configured interface gateways. gatewayIPs := make(map[string]bool) if cfgErr == nil && cfg != nil { for _, iface := range cfg.Interfaces { if iface.Gateway != "" { gatewayIPs[iface.Gateway] = true } } } result := make([]Client, 0, len(byIP)) for _, c := range byIP { if gatewayIPs[c.IP] { continue } result = append(result, *c) } sort.Slice(result, func(i, j int) bool { if result[i].Online != result[j].Online { return result[i].Online } if result[i].Known != result[j].Known { return result[i].Known } return ipLess(result[i].IP, result[j].IP) }) return result, nil } func syncKnownDevices(byIP map[string]*Client) { cfg, err := config.Load() if err != nil { return } savedHostnames := make(map[string]string) savedBlocked := make(map[string]bool) savedStaticIPs := make(map[string]string) savedPolicies := make(map[string]string) for _, kd := range cfg.KnownDevices { key := kd.MAC if key == "" { key = kd.IP } savedHostnames[key] = kd.Hostname if kd.Blocked { savedBlocked[key] = true } if kd.StaticIP != "" { savedStaticIPs[key] = kd.StaticIP } if kd.Policy != "" { savedPolicies[key] = kd.Policy } } var seen []config.KnownDevice for _, c := range byIP { if c.MAC != "" && c.IP != "" { key := c.MAC hostname := c.Hostname if saved, ok := savedHostnames[key]; ok { hostname = saved } kd := config.KnownDevice{ IP: c.IP, MAC: c.MAC, Hostname: hostname, } if savedBlocked[key] { kd.Blocked = true } if sip, ok := savedStaticIPs[key]; ok { kd.StaticIP = sip } if pol, ok := savedPolicies[key]; ok { kd.Policy = pol } seen = append(seen, kd) } } _ = config.UpdateKnownDevices(seen) } // GetARPIPsByMAC returns a map of MAC address → all IPs currently seen in the // ARP table for that MAC. Used by the firewall to apply per-device policies to // every IP a device has (e.g. multi-interface or dual-stack devices). func GetARPIPsByMAC() map[string][]string { arp, err := parseARPTable() if err != nil { return nil } result := make(map[string][]string) for ip, c := range arp { if c.MAC != "" { result[c.MAC] = append(result[c.MAC], ip) } } return result } func parseDNSMasqLeases() (map[string]*Client, error) { f, err := os.Open(LeasesFile) if err != nil { if os.IsNotExist(err) { return map[string]*Client{}, nil } return nil, err } defer f.Close() out := map[string]*Client{} scanner := bufio.NewScanner(f) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 4 { continue } exp, _ := strconv.ParseInt(fields[0], 10, 64) mac := fields[1] ip := fields[2] hostname := fields[3] if hostname == "*" { hostname = "" } out[ip] = &Client{ IP: ip, MAC: mac, Hostname: hostname, LeaseExpires: exp, IsDHCP: true, } } return out, scanner.Err() } func parseARPTable() (map[string]*Client, error) { f, err := os.Open("/proc/net/arp") if err != nil { return nil, err } defer f.Close() out := map[string]*Client{} scanner := bufio.NewScanner(f) scanner.Scan() for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 6 { continue } ip := fields[0] flags := fields[2] mac := fields[3] iface := fields[5] if mac == "00:00:00:00:00:00" || iface == "lo" { continue } arpOnline := flags == "0x2" || flags == "0x6" out[ip] = &Client{ IP: ip, MAC: mac, Interface: iface, Online: arpOnline, } } return out, scanner.Err() } func ipLess(a, b string) bool { return ipToUint32(a) < ipToUint32(b) } func ipToUint32(ip string) uint32 { parts := strings.SplitN(ip, ".", 4) if len(parts) != 4 { return 0 } var v uint32 for _, p := range parts { n, _ := strconv.ParseUint(p, 10, 8) v = v<<8 | uint32(n) } return v }