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

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NexusRMM.Infrastructure.Data;
namespace NexusRMM.Api.Controllers;
[ApiController]
[Route("api/v1/network-devices")]
public class NetworkDevicesController : ControllerBase
{
private readonly RmmDbContext _db;
public NetworkDevicesController(RmmDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? agentId)
{
var query = _db.NetworkDevices.AsQueryable();
if (agentId.HasValue)
query = query.Where(d => d.AgentId == agentId.Value);
var devices = await query
.OrderBy(d => d.IpAddress)
.Select(d => new
{
d.Id,
d.AgentId,
d.IpAddress,
d.MacAddress,
d.Hostname,
d.Vendor,
d.IsManaged,
d.FirstSeen,
d.LastSeen
})
.ToListAsync();
return Ok(devices);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var device = await _db.NetworkDevices.FindAsync(id);
if (device is null)
return NotFound();
_db.NetworkDevices.Remove(device);
await _db.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -114,12 +114,12 @@ public class AgentGrpcService : AgentService.AgentServiceBase
public override async Task<CommandResultResponse> ReportCommandResult(CommandResult request, ServerCallContext context)
{
var taskId = Guid.Parse(request.CommandId);
var task = await _db.Tasks.FindAsync(taskId)
var taskItem = await _db.Tasks.FindAsync(taskId)
?? throw new RpcException(new Status(StatusCode.NotFound, "Task not found"));
task.Status = request.Success ? CoreTaskStatus.Completed : CoreTaskStatus.Failed;
task.CompletedAt = DateTime.UtcNow;
task.Result = JsonSerializer.SerializeToElement(new
taskItem.Status = request.Success ? CoreTaskStatus.Completed : CoreTaskStatus.Failed;
taskItem.CompletedAt = DateTime.UtcNow;
taskItem.Result = JsonSerializer.SerializeToElement(new
{
request.ExitCode,
request.Stdout,
@@ -129,6 +129,11 @@ public class AgentGrpcService : AgentService.AgentServiceBase
await _db.SaveChangesAsync();
if (taskItem.Type == NexusRMM.Core.Models.TaskType.NetworkScan && request.Success && !string.IsNullOrWhiteSpace(request.Stdout))
{
await ProcessNetworkScanResultAsync(request.AgentId, request.Stdout);
}
// SignalR: Command-Ergebnis an agent-Gruppe pushen
await _hub.Clients.Group($"agent-{request.AgentId}")
.CommandResultUpdated(request.CommandId, request.AgentId, request.Success, request.ExitCode);
@@ -136,6 +141,73 @@ public class AgentGrpcService : AgentService.AgentServiceBase
return new CommandResultResponse();
}
private async Task ProcessNetworkScanResultAsync(string agentIdStr, string stdout)
{
try
{
var scannedDevices = JsonSerializer.Deserialize<List<ScanDevice>>(stdout, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (scannedDevices is null || scannedDevices.Count == 0)
return;
Guid? agentId = Guid.TryParse(agentIdStr, out var parsedId) ? parsedId : null;
var knownAgentIps = await _db.Agents
.Select(a => a.IpAddress)
.Where(ip => ip != null && ip != string.Empty)
.ToHashSetAsync();
var now = DateTime.UtcNow;
foreach (var scanned in scannedDevices)
{
if (string.IsNullOrWhiteSpace(scanned.Mac))
continue;
var existing = await _db.NetworkDevices
.FirstOrDefaultAsync(d => d.MacAddress == scanned.Mac);
if (existing is not null)
{
existing.LastSeen = now;
existing.IpAddress = scanned.Ip ?? existing.IpAddress;
existing.IsManaged = knownAgentIps.Contains(scanned.Ip ?? string.Empty);
}
else
{
_db.NetworkDevices.Add(new NexusRMM.Core.Models.NetworkDevice
{
AgentId = agentId,
IpAddress = scanned.Ip ?? string.Empty,
MacAddress = scanned.Mac,
Hostname = scanned.Hostname ?? string.Empty,
Vendor = scanned.Vendor ?? string.Empty,
IsManaged = knownAgentIps.Contains(scanned.Ip ?? string.Empty),
FirstSeen = now,
LastSeen = now
});
}
}
await _db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process NetworkScan result for agent {AgentId}", agentIdStr);
}
}
private class ScanDevice
{
public string? Ip { get; set; }
public string? Mac { get; set; }
public string? Hostname { get; set; }
public string? Vendor { get; set; }
}
private static CommandType MapTaskType(NexusRMM.Core.Models.TaskType type) => type switch
{
NexusRMM.Core.Models.TaskType.Shell => CommandType.Shell,

View File

@@ -0,0 +1,14 @@
namespace NexusRMM.Core.Models;
public class NetworkDevice
{
public int Id { get; set; }
public Guid? AgentId { get; set; } // Welcher Agent hat es entdeckt
public string IpAddress { get; set; } = string.Empty;
public string MacAddress { get; set; } = string.Empty;
public string Hostname { get; set; } = string.Empty;
public string Vendor { get; set; } = string.Empty;
public bool IsManaged { get; set; } // Ist dieses Gerät ein bekannter RMM-Agent?
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
}

View File

@@ -14,6 +14,7 @@ public class RmmDbContext : DbContext
public DbSet<AlertRule> AlertRules => Set<AlertRule>();
public DbSet<Alert> Alerts => Set<Alert>();
public DbSet<SoftwarePackage> SoftwarePackages => Set<SoftwarePackage>();
public DbSet<NetworkDevice> NetworkDevices => Set<NetworkDevice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -65,5 +66,12 @@ public class RmmDbContext : DbContext
e.HasKey(p => p.Id);
e.HasIndex(p => new { p.Name, p.Version, p.OsType }).IsUnique();
});
modelBuilder.Entity<NetworkDevice>(e =>
{
e.HasKey(d => d.Id);
e.HasIndex(d => d.IpAddress);
e.HasIndex(d => d.MacAddress);
});
}
}

View File

@@ -0,0 +1,413 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusRMM.Infrastructure.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusRMM.Infrastructure.Migrations
{
[DbContext(typeof(RmmDbContext))]
[Migration("20260319135017_AddNetworkDevices")]
partial class AddNetworkDevices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("NexusRMM.Core.Models.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AgentVersion")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("EnrolledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hostname")
.IsRequired()
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("MacAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MeshAgentId")
.HasColumnType("text");
b.Property<int>("OsType")
.HasColumnType("integer");
b.Property<string>("OsVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.PrimitiveCollection<string>("Tags")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("Hostname");
b.HasIndex("MacAddress");
b.ToTable("Agents");
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<JsonElement>("Metrics")
.HasColumnType("jsonb");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AgentId");
b.HasIndex("Timestamp");
b.ToTable("AgentMetrics");
});
modelBuilder.Entity("NexusRMM.Core.Models.Alert", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Acknowledged")
.HasColumnType("boolean");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RuleId")
.HasColumnType("integer");
b.Property<int>("Severity")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AgentId");
b.HasIndex("CreatedAt");
b.HasIndex("RuleId");
b.ToTable("Alerts");
});
modelBuilder.Entity("NexusRMM.Core.Models.AlertRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("MetricPath")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Operator")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Severity")
.HasColumnType("integer");
b.Property<double>("Threshold")
.HasColumnType("double precision");
b.HasKey("Id");
b.ToTable("AlertRules");
});
modelBuilder.Entity("NexusRMM.Core.Models.NetworkDevice", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime>("FirstSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hostname")
.IsRequired()
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsManaged")
.HasColumnType("boolean");
b.Property<DateTime>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("MacAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Vendor")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("IpAddress");
b.HasIndex("MacAddress");
b.ToTable("NetworkDevices");
});
modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Checksum")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InstallerUrl")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("OsType")
.HasColumnType("integer");
b.Property<string>("PackageManager")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PackageName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SilentArgs")
.HasColumnType("text");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name", "Version", "OsType")
.IsUnique();
b.ToTable("SoftwarePackages");
});
modelBuilder.Entity("NexusRMM.Core.Models.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<JsonElement?>("Payload")
.HasColumnType("jsonb");
b.Property<JsonElement?>("Result")
.HasColumnType("jsonb");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AgentId");
b.ToTable("Tasks");
});
modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AgentId");
b.ToTable("Tickets");
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
{
b.HasOne("NexusRMM.Core.Models.Agent", "Agent")
.WithMany("Metrics")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
});
modelBuilder.Entity("NexusRMM.Core.Models.Alert", b =>
{
b.HasOne("NexusRMM.Core.Models.Agent", "Agent")
.WithMany("Alerts")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusRMM.Core.Models.AlertRule", "Rule")
.WithMany("Alerts")
.HasForeignKey("RuleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
b.Navigation("Rule");
});
modelBuilder.Entity("NexusRMM.Core.Models.TaskItem", b =>
{
b.HasOne("NexusRMM.Core.Models.Agent", "Agent")
.WithMany("Tasks")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
});
modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b =>
{
b.HasOne("NexusRMM.Core.Models.Agent", "Agent")
.WithMany("Tickets")
.HasForeignKey("AgentId");
b.Navigation("Agent");
});
modelBuilder.Entity("NexusRMM.Core.Models.Agent", b =>
{
b.Navigation("Alerts");
b.Navigation("Metrics");
b.Navigation("Tasks");
b.Navigation("Tickets");
});
modelBuilder.Entity("NexusRMM.Core.Models.AlertRule", b =>
{
b.Navigation("Alerts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusRMM.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNetworkDevices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "NetworkDevices",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AgentId = table.Column<Guid>(type: "uuid", nullable: true),
IpAddress = table.Column<string>(type: "text", nullable: false),
MacAddress = table.Column<string>(type: "text", nullable: false),
Hostname = table.Column<string>(type: "text", nullable: false),
Vendor = table.Column<string>(type: "text", nullable: false),
IsManaged = table.Column<bool>(type: "boolean", nullable: false),
FirstSeen = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastSeen = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NetworkDevices", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_NetworkDevices_IpAddress",
table: "NetworkDevices",
column: "IpAddress");
migrationBuilder.CreateIndex(
name: "IX_NetworkDevices_MacAddress",
table: "NetworkDevices",
column: "MacAddress");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NetworkDevices");
}
}
}

View File

@@ -74,7 +74,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("MacAddress");
b.ToTable("Agents", (string)null);
b.ToTable("Agents");
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>
@@ -100,7 +100,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("Timestamp");
b.ToTable("AgentMetrics", (string)null);
b.ToTable("AgentMetrics");
});
modelBuilder.Entity("NexusRMM.Core.Models.Alert", b =>
@@ -138,7 +138,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("RuleId");
b.ToTable("Alerts", (string)null);
b.ToTable("Alerts");
});
modelBuilder.Entity("NexusRMM.Core.Models.AlertRule", b =>
@@ -172,7 +172,52 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasKey("Id");
b.ToTable("AlertRules", (string)null);
b.ToTable("AlertRules");
});
modelBuilder.Entity("NexusRMM.Core.Models.NetworkDevice", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime>("FirstSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hostname")
.IsRequired()
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsManaged")
.HasColumnType("boolean");
b.Property<DateTime>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("MacAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Vendor")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("IpAddress");
b.HasIndex("MacAddress");
b.ToTable("NetworkDevices");
});
modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b =>
@@ -219,7 +264,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("Name", "Version", "OsType")
.IsUnique();
b.ToTable("SoftwarePackages", (string)null);
b.ToTable("SoftwarePackages");
});
modelBuilder.Entity("NexusRMM.Core.Models.TaskItem", b =>
@@ -253,7 +298,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("AgentId");
b.ToTable("Tasks", (string)null);
b.ToTable("Tasks");
});
modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b =>
@@ -291,7 +336,7 @@ namespace NexusRMM.Infrastructure.Migrations
b.HasIndex("AgentId");
b.ToTable("Tickets", (string)null);
b.ToTable("Tickets");
});
modelBuilder.Entity("NexusRMM.Core.Models.AgentMetric", b =>