feat: Phase 8 — Network Discovery + Windows Dev-Setup-Skripte
Network Discovery:
- Go Agent: internal/scanner/scanner.go mit TCP-Sweep (Port 445/80/22/443),
ARP-Tabellen-Parser (Windows: arp -a, Linux: /proc/net/arp), Reverse-DNS,
50 gleichzeitige Goroutines mit Semaphore
- Go Agent main.go: COMMAND_TYPE_NETWORK_SCAN Case → scanner.Scan() → JSON stdout
- Backend: NetworkDevice Model (Id, AgentId, IpAddress, MacAddress, Hostname,
Vendor, IsManaged, FirstSeen, LastSeen)
- Backend: EF Migration AddNetworkDevices + Index auf IpAddress + MacAddress
- Backend: NetworkDevicesController GET /api/v1/network-devices + DELETE /{id}
- Backend: AgentGrpcService.ProcessNetworkScanResultAsync — upsert via MAC,
IsManaged=true wenn IP einem bekannten Agent entspricht
- Frontend: NetworkPage.tsx mit Scan-Panel, Device-Tabelle, Filter, Delete
- Frontend: App.tsx — 'Netzwerk' Nav-Eintrag mit Network Icon
Windows Dev-Setup:
- dev-start.ps1 — Startet Docker/Postgres, EF-Migrationen, Backend+Frontend
in separaten PowerShell-Fenstern; Voraussetzungen-Check (docker/dotnet/node/go)
- dev-stop.ps1 — Stoppt alle NexusRMM-Prozesse + PostgreSQL Container
- build-agent.ps1 — Baut nexus-agent.exe (Windows) + optional nexus-agent-linux
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
274
Agent/internal/scanner/scanner.go
Normal file
274
Agent/internal/scanner/scanner.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Device represents a discovered network device.
|
||||
type Device struct {
|
||||
IP string `json:"ip"`
|
||||
MAC string `json:"mac"`
|
||||
Hostname string `json:"hostname"`
|
||||
Vendor string `json:"vendor"`
|
||||
}
|
||||
|
||||
// Scan scans the given subnet (e.g. "192.168.1.0/24").
|
||||
// If subnet is empty, the local network is auto-detected.
|
||||
func Scan(ctx context.Context, subnet string) ([]Device, error) {
|
||||
// Auto-detect subnet if not provided
|
||||
if subnet == "" {
|
||||
detected, err := detectLocalSubnet()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auto-detect subnet: %w", err)
|
||||
}
|
||||
subnet = detected
|
||||
}
|
||||
|
||||
// Parse CIDR
|
||||
_, ipNet, err := net.ParseCIDR(subnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse subnet %q: %w", subnet, err)
|
||||
}
|
||||
|
||||
// Collect all host IPs in the subnet
|
||||
hosts := hostsInNet(ipNet)
|
||||
|
||||
// Concurrent ping sweep using TCP fallback (no raw ICMP needed)
|
||||
const maxConcurrent = 50
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
var mu sync.Mutex
|
||||
alive := make(map[string]bool)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, ip := range hosts {
|
||||
// Check context before spawning
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(ipStr string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
if isAlive(ipStr) {
|
||||
mu.Lock()
|
||||
alive[ipStr] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}(ip.String())
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(alive) == 0 {
|
||||
return []Device{}, nil
|
||||
}
|
||||
|
||||
// Read ARP table to get MAC addresses
|
||||
arpTable, err := readARPTable()
|
||||
if err != nil {
|
||||
// Non-fatal: continue without MAC info
|
||||
arpTable = map[string]string{}
|
||||
}
|
||||
|
||||
// Build results with reverse DNS lookups
|
||||
var devices []Device
|
||||
for ip := range alive {
|
||||
d := Device{IP: ip}
|
||||
|
||||
if mac, ok := arpTable[ip]; ok {
|
||||
d.MAC = mac
|
||||
}
|
||||
|
||||
// Reverse DNS lookup with short timeout
|
||||
hostname := reverseLookup(ip)
|
||||
d.Hostname = hostname
|
||||
|
||||
devices = append(devices, d)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// ToJSON serialises a slice of Devices to a JSON string.
|
||||
func ToJSON(devices []Device) string {
|
||||
b, _ := json.Marshal(devices)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// detectLocalSubnet returns the CIDR of the first non-loopback IPv4 interface.
|
||||
func detectLocalSubnet() (string, error) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
var mask net.IPMask
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
mask = v.Mask
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
// Reconstruct CIDR from network address
|
||||
network := ip.Mask(mask)
|
||||
ones, _ := mask.Size()
|
||||
return fmt.Sprintf("%s/%d", network.String(), ones), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no suitable network interface found")
|
||||
}
|
||||
|
||||
// hostsInNet returns all usable host addresses within the given network.
|
||||
func hostsInNet(ipNet *net.IPNet) []net.IP {
|
||||
var ips []net.IP
|
||||
// Start from network address, increment
|
||||
ip := cloneIP(ipNet.IP)
|
||||
// Increment past network address
|
||||
inc(ip)
|
||||
for ipNet.Contains(ip) {
|
||||
// Skip broadcast (last address)
|
||||
next := cloneIP(ip)
|
||||
inc(next)
|
||||
if !ipNet.Contains(next) {
|
||||
break // current ip is broadcast, stop
|
||||
}
|
||||
ips = append(ips, cloneIP(ip))
|
||||
inc(ip)
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func cloneIP(ip net.IP) net.IP {
|
||||
clone := make(net.IP, len(ip))
|
||||
copy(clone, ip)
|
||||
return clone
|
||||
}
|
||||
|
||||
func inc(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isAlive attempts a TCP connection to common ports to determine if a host is up.
|
||||
func isAlive(ip string) bool {
|
||||
timeout := 300 * time.Millisecond
|
||||
ports := []string{"445", "80", "22", "443"}
|
||||
for _, port := range ports {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, port), timeout)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readARPTable reads the ARP cache and returns a map of IP -> MAC.
|
||||
func readARPTable() (map[string]string, error) {
|
||||
table := make(map[string]string)
|
||||
if runtime.GOOS == "windows" {
|
||||
return readARPWindows(table)
|
||||
}
|
||||
return readARPLinux(table)
|
||||
}
|
||||
|
||||
// readARPWindows parses `arp -a` output on Windows.
|
||||
// Example line: " 192.168.1.1 aa-bb-cc-dd-ee-ff dynamic"
|
||||
func readARPWindows(table map[string]string) (map[string]string, error) {
|
||||
out, err := exec.Command("arp", "-a").Output()
|
||||
if err != nil {
|
||||
return table, err
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
ip := fields[0]
|
||||
if net.ParseIP(ip) == nil {
|
||||
continue
|
||||
}
|
||||
mac := fields[1]
|
||||
// Normalise aa-bb-cc-dd-ee-ff → aa:bb:cc:dd:ee:ff
|
||||
mac = strings.ReplaceAll(mac, "-", ":")
|
||||
if len(mac) == 17 {
|
||||
table[ip] = mac
|
||||
}
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// readARPLinux parses /proc/net/arp on Linux.
|
||||
// Format: IP address HW type Flags HW address Mask Device
|
||||
func readARPLinux(table map[string]string) (map[string]string, error) {
|
||||
data, err := readFile("/proc/net/arp")
|
||||
if err != nil {
|
||||
return table, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
// Skip header line
|
||||
for _, line := range lines[1:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
ip := fields[0]
|
||||
mac := fields[3]
|
||||
if net.ParseIP(ip) != nil && len(mac) == 17 && mac != "00:00:00:00:00:00" {
|
||||
table[ip] = mac
|
||||
}
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// readFile is a thin wrapper so we can test Linux ARP parsing on any OS.
|
||||
func readFile(path string) ([]byte, error) {
|
||||
return exec.Command("cat", path).Output()
|
||||
}
|
||||
|
||||
// reverseLookup returns the hostname for an IP via reverse DNS, with a short timeout.
|
||||
func reverseLookup(ip string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
names, err := net.DefaultResolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Strip trailing dot from FQDN
|
||||
return strings.TrimSuffix(names[0], ".")
|
||||
}
|
||||
Reference in New Issue
Block a user