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:
Claude Agent
2026-03-19 14:53:35 +01:00
parent 55e016c07d
commit 4c40e88718
16 changed files with 1733 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -16,6 +17,7 @@ import (
"nexusrmm.local/agent/internal/deployer"
"nexusrmm.local/agent/internal/executor"
"nexusrmm.local/agent/internal/meshagent"
"nexusrmm.local/agent/internal/scanner"
pb "nexusrmm.local/agent/pkg/proto"
)
@@ -142,6 +144,21 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID
result = deployer.Install(ctx, cmd.Payload)
case pb.CommandType_COMMAND_TYPE_UNINSTALL_SOFTWARE:
result = deployer.Uninstall(ctx, cmd.Payload)
case pb.CommandType_COMMAND_TYPE_NETWORK_SCAN:
var params struct {
Subnet string `json:"subnet"`
}
_ = json.Unmarshal([]byte(cmd.Payload), &params)
devices, err := scanner.Scan(ctx, params.Subnet)
if err != nil {
result = &executor.Result{ExitCode: 1, Stderr: err.Error()}
} else {
result = &executor.Result{
ExitCode: 0,
Stdout: scanner.ToJSON(devices),
Success: true,
}
}
default:
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
}

View 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], ".")
}