package clients import ( "bufio" "fmt" "os" "sort" "strconv" "strings" "alpine-router/config" "alpine-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"` } 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 { if kd.MAC != "" && c.MAC == kd.MAC { c.Blocked = kd.Blocked c.StaticIP = kd.StaticIP if kd.Hostname != "" { c.Hostname = kd.Hostname } if kd.StaticIP != "" { c.IP = kd.StaticIP } found = true break } if kd.IP != "" && ip == kd.IP && (kd.MAC == "" || c.MAC == kd.MAC) { c.Blocked = kd.Blocked c.StaticIP = kd.StaticIP if kd.Hostname != "" { c.Hostname = kd.Hostname } if kd.StaticIP != "" { c.IP = kd.StaticIP } found = true break } } 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, } } 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) result := make([]Client, 0, len(byIP)) for _, c := range byIP { 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) 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 } } 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 } seen = append(seen, kd) } } _ = config.UpdateKnownDevices(seen) } 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 }