Files
alpine-router/clients/clients.go
2026-04-13 09:46:02 +03:00

306 lines
6.0 KiB
Go

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
}