watchyourlan/backend/internal/arp/arpscan.go
2026-06-20 17:54:21 +08:00

287 lines
5.6 KiB
Go

package arp
import (
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
var arpArgs string
func scanIface(iface string) string {
var cmd *exec.Cmd
if arpArgs != "" {
cmd = exec.Command("arp-scan", "-glNx", arpArgs, "-I", iface)
} else {
cmd = exec.Command("arp-scan", "-glNx", "-I", iface)
}
out, err := cmd.Output()
slog.Debug(cmd.String())
if check.IfError(err) {
return string("")
}
return string(out)
}
func scanStr(str string) string {
args := strings.Split(str, " ")
cmd := exec.Command("arp-scan", args...)
out, err := cmd.Output()
slog.Debug(cmd.String())
if check.IfError(err) {
return string("")
}
return string(out)
}
func parseOutput(text, iface string) []models.Host {
var foundHosts = []models.Host{}
p := strings.Split(text, "\n")
for _, host := range p {
if host != "" {
var oneHost models.Host
p := strings.Split(host, " ")
oneHost.Iface = iface
oneHost.IP = p[0]
oneHost.Mac = p[1]
oneHost.Hw = p[2]
oneHost.Date = time.Now().Format("2006-01-02 15:04:05")
oneHost.Now = 1
foundHosts = append(foundHosts, oneHost)
}
}
return foundHosts
}
// Scan all interfaces
func Scan(ifaces, args string, strs []string) []models.Host {
var text string
var p []string
var foundHosts = []models.Host{}
arpArgs = args
p = resolveScanInterfaces(ifaces)
for _, iface := range p {
slog.Debug("Scanning interface " + iface)
text = scanIface(iface)
slog.Debug("Found IPs: \n" + text)
foundHosts = append(foundHosts, parseOutput(text, iface)...)
}
for _, s := range strs {
slog.Debug("Scanning string " + s)
text = scanStr(s)
slog.Debug("Found IPs: \n" + text)
p = strings.Split(s, " ")
foundHosts = append(foundHosts, parseOutput(text, p[len(p)-1])...)
}
return foundHosts
}
func resolveScanInterfaces(ifaces string) []string {
configured := strings.Fields(ifaces)
available := usableInterfaceMap()
auto := autoScanInterfaces(available)
selected, invalid, usedAuto := selectScanInterfaces(configured, available, auto)
if len(invalid) > 0 {
slog.Warn("Ignoring unavailable scan interfaces", "ifaces", strings.Join(invalid, " "))
}
if usedAuto && len(selected) > 0 {
slog.Warn("Using auto-detected scan interfaces", "ifaces", strings.Join(selected, " "))
}
return selected
}
func selectScanInterfaces(configured []string, available map[string]bool, auto []string) ([]string, []string, bool) {
if len(configured) == 0 {
return uniqueStrings(auto), nil, len(auto) > 0
}
var selected []string
var invalid []string
for _, iface := range configured {
if available[iface] {
selected = appendUniqueString(selected, iface)
continue
}
invalid = appendUniqueString(invalid, iface)
}
if len(selected) > 0 {
return selected, invalid, false
}
return uniqueStrings(auto), invalid, len(auto) > 0
}
func usableInterfaceMap() map[string]bool {
interfaces, err := net.Interfaces()
if err != nil {
slog.Warn("Cannot list network interfaces", "err", err)
return nil
}
available := make(map[string]bool)
for _, iface := range interfaces {
if isUsableInterface(iface) {
available[iface.Name] = true
}
}
return available
}
func isUsableInterface(iface net.Interface) bool {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
return false
}
addrs, err := iface.Addrs()
if err != nil {
slog.Debug("Cannot list interface addresses", "iface", iface.Name, "err", err)
return false
}
for _, addr := range addrs {
if hasUsableIPv4(addr) {
return true
}
}
return false
}
func hasUsableIPv4(addr net.Addr) bool {
ipNet, ok := addr.(*net.IPNet)
if !ok {
return false
}
ip := ipNet.IP.To4()
return ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}
func autoScanInterfaces(available map[string]bool) []string {
if len(available) == 0 {
return nil
}
ifaces := defaultRouteInterfaces(available)
if len(ifaces) > 0 {
return ifaces
}
for iface := range available {
if isVirtualInterface(iface) {
continue
}
ifaces = append(ifaces, iface)
}
sort.Strings(ifaces)
return ifaces
}
func defaultRouteInterfaces(available map[string]bool) []string {
data, err := os.ReadFile("/proc/net/route")
if err != nil {
slog.Debug("Cannot read default routes", "err", err)
return nil
}
var ifaces []string
minMetric := -1
for _, line := range strings.Split(string(data), "\n")[1:] {
fields := strings.Fields(line)
if len(fields) < 7 || fields[1] != "00000000" {
continue
}
iface := fields[0]
if !available[iface] || isVirtualInterface(iface) {
continue
}
metric, err := strconv.Atoi(fields[6])
if err != nil {
metric = 0
}
switch {
case minMetric == -1 || metric < minMetric:
minMetric = metric
ifaces = []string{iface}
case metric == minMetric:
ifaces = appendUniqueString(ifaces, iface)
}
}
sort.Strings(ifaces)
return ifaces
}
func isVirtualInterface(iface string) bool {
prefixes := []string{
"br-",
"docker",
"lxc",
"tailscale",
"tap",
"tun",
"vboxnet",
"veth",
"virbr",
"vmnet",
"wg",
}
for _, prefix := range prefixes {
if strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
var out []string
for _, value := range values {
out = appendUniqueString(out, value)
}
return out
}
func appendUniqueString(values []string, value string) []string {
if value == "" {
return values
}
for _, item := range values {
if item == value {
return values
}
}
return append(values, value)
}