diff --git a/Agent/cmd/agent/main.go b/Agent/cmd/agent/main.go index 7002f55..512fed2 100644 --- a/Agent/cmd/agent/main.go +++ b/Agent/cmd/agent/main.go @@ -13,6 +13,7 @@ import ( "nexusrmm.local/agent/internal/collector" "nexusrmm.local/agent/internal/config" "nexusrmm.local/agent/internal/connection" + "nexusrmm.local/agent/internal/deployer" "nexusrmm.local/agent/internal/executor" pb "nexusrmm.local/agent/pkg/proto" ) @@ -128,6 +129,10 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID switch cmd.Type { case pb.CommandType_COMMAND_TYPE_SHELL: result = executor.Execute(ctx, cmd.Payload, 300) + case pb.CommandType_COMMAND_TYPE_INSTALL_SOFTWARE: + result = deployer.Install(ctx, cmd.Payload) + case pb.CommandType_COMMAND_TYPE_UNINSTALL_SOFTWARE: + result = deployer.Uninstall(ctx, cmd.Payload) default: result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)} } diff --git a/Agent/internal/deployer/deployer.go b/Agent/internal/deployer/deployer.go new file mode 100644 index 0000000..a645ebf --- /dev/null +++ b/Agent/internal/deployer/deployer.go @@ -0,0 +1,185 @@ +package deployer + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "nexusrmm.local/agent/internal/executor" +) + +// Payload wird vom Backend als JSON im cmd.Payload-Feld gesendet. +type Payload struct { + PackageName string `json:"packageName"` + PackageManager string `json:"packageManager"` // "choco", "apt", "dnf", "direct" + InstallerUrl string `json:"installerUrl"` + Checksum string `json:"checksum"` // SHA256 (hex, optional) + SilentArgs string `json:"silentArgs"` + DisplayName string `json:"displayName"` + Version string `json:"version"` +} + +// Install installiert ein Software-Paket auf dem aktuellen System. +func Install(ctx context.Context, payloadJSON string) *executor.Result { + payload, err := parsePayload(payloadJSON) + if err != nil { + return errorResult(fmt.Sprintf("Ungültiges Payload-JSON: %v", err)) + } + + switch payload.PackageManager { + case "choco": + return installWithChoco(ctx, payload.PackageName) + case "apt": + return installWithApt(ctx, payload.PackageName) + case "dnf": + return installWithDnf(ctx, payload.PackageName) + case "direct": + return installDirect(ctx, *payload) + default: + // Auto-detect + return installAutoDetect(ctx, *payload) + } +} + +// Uninstall deinstalliert ein Software-Paket. +func Uninstall(ctx context.Context, payloadJSON string) *executor.Result { + payload, err := parsePayload(payloadJSON) + if err != nil { + return errorResult(fmt.Sprintf("Ungültiges Payload-JSON: %v", err)) + } + + switch payload.PackageManager { + case "choco": + return executor.Execute(ctx, fmt.Sprintf("choco uninstall %s -y", payload.PackageName), 600) + case "apt": + return executor.Execute(ctx, fmt.Sprintf("apt-get remove -y %s", payload.PackageName), 600) + case "dnf": + return executor.Execute(ctx, fmt.Sprintf("dnf remove -y %s", payload.PackageName), 600) + default: + return installAutoDetect(ctx, *payload) + } +} + +func installWithChoco(ctx context.Context, packageName string) *executor.Result { + return executor.Execute(ctx, + fmt.Sprintf("choco install %s -y --no-progress", packageName), 600) +} + +func installWithApt(ctx context.Context, packageName string) *executor.Result { + return executor.Execute(ctx, + fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y %s", packageName), 600) +} + +func installWithDnf(ctx context.Context, packageName string) *executor.Result { + return executor.Execute(ctx, + fmt.Sprintf("dnf install -y %s", packageName), 600) +} + +func installAutoDetect(ctx context.Context, payload Payload) *executor.Result { + if runtime.GOOS == "windows" { + // Versuche choco, dann winget als Fallback + if isCommandAvailable("choco") { + return installWithChoco(ctx, payload.PackageName) + } + if payload.InstallerUrl != "" { + return installDirect(ctx, payload) + } + return errorResult("Kein unterstützter Paketmanager auf Windows verfügbar (choco nicht gefunden)") + } + // Linux + if isCommandAvailable("apt-get") { + return installWithApt(ctx, payload.PackageName) + } + if isCommandAvailable("dnf") { + return installWithDnf(ctx, payload.PackageName) + } + if payload.InstallerUrl != "" { + return installDirect(ctx, payload) + } + return errorResult("Kein unterstützter Paketmanager auf Linux verfügbar") +} + +func installDirect(ctx context.Context, payload Payload) *executor.Result { + if payload.InstallerUrl == "" { + return errorResult("Direct-Install: Keine InstallerUrl angegeben") + } + + // Temp-Datei herunterladen + tmpFile, err := os.CreateTemp("", "nexusrmm-install-*") + if err != nil { + return errorResult(fmt.Sprintf("Temp-Datei konnte nicht erstellt werden: %v", err)) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + resp, err := http.Get(payload.InstallerUrl) + if err != nil { + tmpFile.Close() + return errorResult(fmt.Sprintf("Download fehlgeschlagen: %v", err)) + } + defer resp.Body.Close() + + hasher := sha256.New() + writer := io.MultiWriter(tmpFile, hasher) + if _, err := io.Copy(writer, resp.Body); err != nil { + tmpFile.Close() + return errorResult(fmt.Sprintf("Download-Fehler beim Schreiben: %v", err)) + } + tmpFile.Close() + + // Checksum prüfen + if payload.Checksum != "" { + actualHash := hex.EncodeToString(hasher.Sum(nil)) + expected := strings.ToLower(payload.Checksum) + if actualHash != expected { + return errorResult(fmt.Sprintf("Prüfsummen-Fehler: erwartet %s, erhalten %s", expected, actualHash)) + } + } + + // Installer ausführen + ext := strings.ToLower(filepath.Ext(payload.InstallerUrl)) + var installCmd string + switch { + case runtime.GOOS == "windows" && ext == ".msi": + installCmd = fmt.Sprintf("msiexec /i \"%s\" /quiet /norestart %s", tmpPath, payload.SilentArgs) + case runtime.GOOS == "windows" && (ext == ".exe"): + installCmd = fmt.Sprintf("\"%s\" %s", tmpPath, payload.SilentArgs) + case runtime.GOOS == "linux" && (ext == ".deb"): + installCmd = fmt.Sprintf("dpkg -i \"%s\"", tmpPath) + case runtime.GOOS == "linux" && (ext == ".rpm"): + installCmd = fmt.Sprintf("rpm -i \"%s\"", tmpPath) + default: + installCmd = fmt.Sprintf("\"%s\" %s", tmpPath, payload.SilentArgs) + } + + return executor.Execute(ctx, installCmd, 1200) +} + +func isCommandAvailable(name string) bool { + result := executor.Execute(context.Background(), "which "+name+" || where "+name, 5) + return result.Success || result.ExitCode == 0 +} + +func parsePayload(payloadJSON string) (*Payload, error) { + var p Payload + if err := json.Unmarshal([]byte(payloadJSON), &p); err != nil { + return nil, err + } + return &p, nil +} + +func errorResult(msg string) *executor.Result { + return &executor.Result{ + ExitCode: -1, + Stderr: msg, + Success: false, + } +} diff --git a/Backend/src/NexusRMM.Api/Controllers/DeployController.cs b/Backend/src/NexusRMM.Api/Controllers/DeployController.cs new file mode 100644 index 0000000..38c9b78 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/DeployController.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/deploy")] +public class DeployController : ControllerBase +{ + private readonly RmmDbContext _db; + public DeployController(RmmDbContext db) => _db = db; + + /// + /// Startet Software-Deployment auf einem Agent. + /// Erstellt einen TaskItem mit Typ InstallSoftware/UninstallSoftware. + /// + [HttpPost] + public async Task Deploy([FromBody] DeployRequest req) + { + var agent = await _db.Agents.FindAsync(req.AgentId); + if (agent is null) return NotFound(new { error = "Agent nicht gefunden" }); + + var pkg = await _db.SoftwarePackages.FindAsync(req.PackageId); + if (pkg is null) return NotFound(new { error = "Paket nicht gefunden" }); + + var payload = new + { + packageName = pkg.PackageName, + packageManager = pkg.PackageManager, + installerUrl = pkg.InstallerUrl ?? "", + checksum = pkg.Checksum ?? "", + silentArgs = pkg.SilentArgs ?? "", + displayName = pkg.Name, + version = pkg.Version, + }; + + var task = new TaskItem + { + Id = Guid.NewGuid(), + AgentId = req.AgentId, + Type = req.Action == "uninstall" ? TaskType.UninstallSoftware : TaskType.InstallSoftware, + Payload = JsonSerializer.SerializeToElement(payload), + CreatedAt = DateTime.UtcNow, + }; + + _db.Tasks.Add(task); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(Deploy), new { id = task.Id }, new + { + task.Id, + task.AgentId, + task.Type, + task.Status, + PackageName = pkg.Name, + Version = pkg.Version, + }); + } +} + +public record DeployRequest(Guid AgentId, int PackageId, string Action); // Action: "install" | "uninstall" diff --git a/Backend/src/NexusRMM.Api/Controllers/SoftwarePackagesController.cs b/Backend/src/NexusRMM.Api/Controllers/SoftwarePackagesController.cs new file mode 100644 index 0000000..d07e92a --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/SoftwarePackagesController.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/software-packages")] +public class SoftwarePackagesController : ControllerBase +{ + private readonly RmmDbContext _db; + public SoftwarePackagesController(RmmDbContext db) => _db = db; + + [HttpGet] + public async Task GetAll([FromQuery] OsType? osType = null) + { + var q = _db.SoftwarePackages.AsQueryable(); + if (osType.HasValue) q = q.Where(p => p.OsType == osType.Value); + return Ok(await q.OrderBy(p => p.Name).ToListAsync()); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var pkg = await _db.SoftwarePackages.FindAsync(id); + return pkg is null ? NotFound() : Ok(pkg); + } + + [HttpPost] + public async Task Create([FromBody] CreateSoftwarePackageRequest req) + { + var pkg = new SoftwarePackage + { + Name = req.Name, + Version = req.Version, + OsType = req.OsType, + PackageManager = req.PackageManager, + PackageName = req.PackageName, + InstallerUrl = req.InstallerUrl, + Checksum = req.Checksum, + SilentArgs = req.SilentArgs, + }; + _db.SoftwarePackages.Add(pkg); + await _db.SaveChangesAsync(); + return CreatedAtAction(nameof(GetById), new { id = pkg.Id }, pkg); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] CreateSoftwarePackageRequest req) + { + var pkg = await _db.SoftwarePackages.FindAsync(id); + if (pkg is null) return NotFound(); + pkg.Name = req.Name; + pkg.Version = req.Version; + pkg.OsType = req.OsType; + pkg.PackageManager = req.PackageManager; + pkg.PackageName = req.PackageName; + pkg.InstallerUrl = req.InstallerUrl; + pkg.Checksum = req.Checksum; + pkg.SilentArgs = req.SilentArgs; + await _db.SaveChangesAsync(); + return Ok(pkg); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var pkg = await _db.SoftwarePackages.FindAsync(id); + if (pkg is null) return NotFound(); + _db.SoftwarePackages.Remove(pkg); + await _db.SaveChangesAsync(); + return NoContent(); + } +} + +public record CreateSoftwarePackageRequest( + string Name, + string Version, + OsType OsType, + string PackageManager, + string PackageName, + string? InstallerUrl, + string? Checksum, + string? SilentArgs); diff --git a/Backend/src/NexusRMM.Core/Models/SoftwarePackage.cs b/Backend/src/NexusRMM.Core/Models/SoftwarePackage.cs new file mode 100644 index 0000000..415bf74 --- /dev/null +++ b/Backend/src/NexusRMM.Core/Models/SoftwarePackage.cs @@ -0,0 +1,26 @@ +namespace NexusRMM.Core.Models; + +public class SoftwarePackage +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public OsType OsType { get; set; } + + /// Paketmanager: "choco", "apt", "dnf", "direct" + public string PackageManager { get; set; } = "choco"; + + /// Paketname für den Paketmanager (z.B. "7zip" für choco) + public string PackageName { get; set; } = string.Empty; + + /// Optionale direkte Download-URL (für Fallback) + public string? InstallerUrl { get; set; } + + /// SHA256-Prüfsumme der Installer-Datei + public string? Checksum { get; set; } + + /// Silent-Install-Parameter für direkten Installer + public string? SilentArgs { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Backend/src/NexusRMM.Infrastructure/Data/RmmDbContext.cs b/Backend/src/NexusRMM.Infrastructure/Data/RmmDbContext.cs index 447c698..b8045b0 100644 --- a/Backend/src/NexusRMM.Infrastructure/Data/RmmDbContext.cs +++ b/Backend/src/NexusRMM.Infrastructure/Data/RmmDbContext.cs @@ -13,6 +13,7 @@ public class RmmDbContext : DbContext public DbSet Tickets => Set(); public DbSet AlertRules => Set(); public DbSet Alerts => Set(); + public DbSet SoftwarePackages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -58,5 +59,11 @@ public class RmmDbContext : DbContext e.HasOne(a => a.Rule).WithMany(r => r.Alerts).HasForeignKey(a => a.RuleId); e.HasOne(a => a.Agent).WithMany(a => a.Alerts).HasForeignKey(a => a.AgentId); }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => new { p.Name, p.Version, p.OsType }).IsUnique(); + }); } } diff --git a/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.Designer.cs b/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.Designer.cs new file mode 100644 index 0000000..67747bb --- /dev/null +++ b/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.Designer.cs @@ -0,0 +1,368 @@ +// +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("20260319130448_AddSoftwarePackages")] + partial class AddSoftwarePackages + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Hostname") + .IsRequired() + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("MacAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("MeshAgentId") + .HasColumnType("text"); + + b.Property("OsType") + .HasColumnType("integer"); + + b.Property("OsVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.PrimitiveCollection("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("Metrics") + .HasColumnType("jsonb"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Acknowledged") + .HasColumnType("boolean"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RuleId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("MetricPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Operator") + .IsRequired() + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("Threshold") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("AlertRules"); + }); + + modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Checksum") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallerUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OsType") + .HasColumnType("integer"); + + b.Property("PackageManager") + .IsRequired() + .HasColumnType("text"); + + b.Property("PackageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SilentArgs") + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("Result") + .HasColumnType("jsonb"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.ToTable("Tasks"); + }); + + modelBuilder.Entity("NexusRMM.Core.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("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 + } + } +} diff --git a/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.cs b/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.cs new file mode 100644 index 0000000..b1fd864 --- /dev/null +++ b/Backend/src/NexusRMM.Infrastructure/Migrations/20260319130448_AddSoftwarePackages.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NexusRMM.Infrastructure.Migrations +{ + /// + public partial class AddSoftwarePackages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SoftwarePackages", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Version = table.Column(type: "text", nullable: false), + OsType = table.Column(type: "integer", nullable: false), + PackageManager = table.Column(type: "text", nullable: false), + PackageName = table.Column(type: "text", nullable: false), + InstallerUrl = table.Column(type: "text", nullable: true), + Checksum = table.Column(type: "text", nullable: true), + SilentArgs = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SoftwarePackages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SoftwarePackages_Name_Version_OsType", + table: "SoftwarePackages", + columns: new[] { "Name", "Version", "OsType" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SoftwarePackages"); + } + } +} diff --git a/Backend/src/NexusRMM.Infrastructure/Migrations/RmmDbContextModelSnapshot.cs b/Backend/src/NexusRMM.Infrastructure/Migrations/RmmDbContextModelSnapshot.cs index ac19005..448ef54 100644 --- a/Backend/src/NexusRMM.Infrastructure/Migrations/RmmDbContextModelSnapshot.cs +++ b/Backend/src/NexusRMM.Infrastructure/Migrations/RmmDbContextModelSnapshot.cs @@ -175,6 +175,53 @@ namespace NexusRMM.Infrastructure.Migrations b.ToTable("AlertRules"); }); + modelBuilder.Entity("NexusRMM.Core.Models.SoftwarePackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Checksum") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallerUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OsType") + .HasColumnType("integer"); + + b.Property("PackageManager") + .IsRequired() + .HasColumnType("text"); + + b.Property("PackageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SilentArgs") + .HasColumnType("text"); + + b.Property("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("Id") diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index b8cb4f3..eeb5dbc 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -1,10 +1,11 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { LayoutDashboard, Ticket, Bell, Menu, X } from 'lucide-react' +import { LayoutDashboard, Ticket, Bell, Package, Menu, X } from 'lucide-react' import { DashboardPage } from './pages/DashboardPage' import { AgentDetailPage } from './pages/AgentDetailPage' import TicketsPage from './pages/TicketsPage' import AlertsPage from './pages/AlertsPage' +import SoftwarePage from './pages/SoftwarePage' import { cn } from './lib/utils' const queryClient = new QueryClient({ @@ -16,7 +17,7 @@ const queryClient = new QueryClient({ }, }) -type Page = 'dashboard' | 'agent-detail' | 'tickets' | 'alerts' +type Page = 'dashboard' | 'agent-detail' | 'tickets' | 'alerts' | 'software' interface NavItem { id: Page @@ -28,6 +29,7 @@ const navItems: NavItem[] = [ { id: 'dashboard', label: 'Dashboard', icon: }, { id: 'tickets', label: 'Tickets', icon: }, { id: 'alerts', label: 'Alerts', icon: }, + { id: 'software', label: 'Software', icon: }, ] function AppContent() { @@ -110,6 +112,7 @@ function AppContent() { )} {page === 'tickets' && } {page === 'alerts' && } + {page === 'software' && } ) diff --git a/Frontend/src/api/client.ts b/Frontend/src/api/client.ts index 4bcd092..c16013c 100644 --- a/Frontend/src/api/client.ts +++ b/Frontend/src/api/client.ts @@ -10,6 +10,10 @@ import type { AlertItem, CreateAlertRuleRequest, UpdateAlertRuleRequest, + SoftwarePackage, + CreateSoftwarePackageRequest, + DeployRequest, + DeployResponse, } from './types' const BASE_URL = '/api/v1' @@ -73,3 +77,23 @@ export const alertsApi = { acknowledge: (id: number) => request<{ id: number; acknowledged: boolean }>(`/alerts/${id}/acknowledge`, { method: 'POST' }), } + +// Software Packages +export const softwarePackagesApi = { + list: (osType?: string) => { + const param = osType ? `?osType=${osType}` : '' + return request(`/software-packages${param}`) + }, + create: (data: CreateSoftwarePackageRequest) => + request('/software-packages', { method: 'POST', body: JSON.stringify(data) }), + update: (id: number, data: CreateSoftwarePackageRequest) => + request(`/software-packages/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id: number) => + request(`/software-packages/${id}`, { method: 'DELETE' }), +} + +// Deploy +export const deployApi = { + deploy: (data: DeployRequest) => + request('/deploy', { method: 'POST', body: JSON.stringify(data) }), +} diff --git a/Frontend/src/api/types.ts b/Frontend/src/api/types.ts index 900c95d..2509d98 100644 --- a/Frontend/src/api/types.ts +++ b/Frontend/src/api/types.ts @@ -132,3 +132,44 @@ export interface UpdateAlertRuleRequest { severity?: AlertSeverity enabled?: boolean } + +export type PackageManager = 'choco' | 'apt' | 'dnf' | 'direct' + +export interface SoftwarePackage { + id: number + name: string + version: string + osType: OsType + packageManager: PackageManager + packageName: string + installerUrl: string | null + checksum: string | null + silentArgs: string | null + createdAt: string +} + +export interface CreateSoftwarePackageRequest { + name: string + version: string + osType: OsType + packageManager: PackageManager + packageName: string + installerUrl?: string + checksum?: string + silentArgs?: string +} + +export interface DeployRequest { + agentId: string + packageId: number + action: 'install' | 'uninstall' +} + +export interface DeployResponse { + id: string + agentId: string + type: string + status: string + packageName: string + version: string +} diff --git a/Frontend/src/pages/SoftwarePage.tsx b/Frontend/src/pages/SoftwarePage.tsx new file mode 100644 index 0000000..58c3f40 --- /dev/null +++ b/Frontend/src/pages/SoftwarePage.tsx @@ -0,0 +1,585 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Package, Plus, Trash2, Edit, Rocket, X } from 'lucide-react' +import { softwarePackagesApi, deployApi } from '../api/client' +import type { SoftwarePackage, OsType, PackageManager, CreateSoftwarePackageRequest } from '../api/types' +import { cn } from '../lib/utils' + +export default function SoftwarePage() { + const [activeTab, setActiveTab] = useState<'catalog' | 'deploy'>('catalog') + const [filterOs, setFilterOs] = useState<'All' | OsType>('All') + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [editingPackage, setEditingPackage] = useState(null) + const [successMessage, setSuccessMessage] = useState('') + const [errorMessage, setErrorMessage] = useState('') + + const queryClient = useQueryClient() + + // Fetch packages + const { data: packages = [], isLoading } = useQuery({ + queryKey: ['software-packages', filterOs], + queryFn: () => softwarePackagesApi.list(filterOs === 'All' ? undefined : filterOs), + }) + + // Mutations + const createMutation = useMutation({ + mutationFn: (data: CreateSoftwarePackageRequest) => softwarePackagesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['software-packages'] }) + setIsCreateModalOpen(false) + setEditingPackage(null) + setSuccessMessage('Paket erfolgreich erstellt') + setTimeout(() => setSuccessMessage(''), 3000) + }, + onError: (error: Error) => { + setErrorMessage(error.message) + setTimeout(() => setErrorMessage(''), 3000) + }, + }) + + const updateMutation = useMutation({ + mutationFn: (data: { id: number; body: CreateSoftwarePackageRequest }) => + softwarePackagesApi.update(data.id, data.body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['software-packages'] }) + setIsCreateModalOpen(false) + setEditingPackage(null) + setSuccessMessage('Paket erfolgreich aktualisiert') + setTimeout(() => setSuccessMessage(''), 3000) + }, + onError: (error: Error) => { + setErrorMessage(error.message) + setTimeout(() => setErrorMessage(''), 3000) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => softwarePackagesApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['software-packages'] }) + setSuccessMessage('Paket erfolgreich gelöscht') + setTimeout(() => setSuccessMessage(''), 3000) + }, + onError: (error: Error) => { + setErrorMessage(error.message) + setTimeout(() => setErrorMessage(''), 3000) + }, + }) + + const deployMutation = useMutation({ + mutationFn: (data: { agentId: string; packageId: number; action: 'install' | 'uninstall' }) => + deployApi.deploy(data), + onSuccess: () => { + setSuccessMessage('Deployment-Task erfolgreich erstellt') + setTimeout(() => setSuccessMessage(''), 3000) + }, + onError: (error: Error) => { + setErrorMessage(error.message) + setTimeout(() => setErrorMessage(''), 3000) + }, + }) + + const handleDelete = (id: number, name: string) => { + if (window.confirm(`Paket "${name}" wirklich löschen?`)) { + deleteMutation.mutate(id) + } + } + + const handleEdit = (pkg: SoftwarePackage) => { + setEditingPackage(pkg) + setIsCreateModalOpen(true) + } + + const handleOpenCreate = () => { + setEditingPackage(null) + setIsCreateModalOpen(true) + } + + return ( +
+
+ {/* Header */} +
+
+ +

Software-Verwaltung

+
+

Verwalte Software-Pakete und deploye sie auf Agenten

+
+ + {/* Tabs */} +
+ + +
+ + {/* Messages */} + {successMessage && ( +
{successMessage}
+ )} + {errorMessage && ( +
{errorMessage}
+ )} + + {/* Tab Content */} + {activeTab === 'catalog' && ( +
+ {/* Filter and Create Button */} +
+
+ {(['All', 'Windows', 'Linux'] as const).map((os) => ( + + ))} +
+ +
+ + {/* Table */} + {isLoading ? ( +
Laden...
+ ) : packages.length === 0 ? ( +
+ Keine Pakete {filterOs !== 'All' && `für ${filterOs}`} vorhanden +
+ ) : ( +
+ + + + + + + + + + + + + {packages.map((pkg) => ( + + + + + + + + + ))} + +
NameVersionOS + Paketmanager + PaketnameAktionen
{pkg.name}{pkg.version} + + {pkg.osType} + + {pkg.packageManager}{pkg.packageName} + + +
+
+ )} +
+ )} + + {activeTab === 'deploy' && } +
+ + {/* Create/Edit Modal */} + {isCreateModalOpen && ( + { + setIsCreateModalOpen(false) + setEditingPackage(null) + }} + onSubmit={(data) => { + if (editingPackage) { + updateMutation.mutate({ id: editingPackage.id, body: data }) + } else { + createMutation.mutate(data) + } + }} + isLoading={createMutation.isPending || updateMutation.isPending} + /> + )} +
+ ) +} + +interface CreatePackageModalProps { + package: SoftwarePackage | null + isOpen: boolean + onClose: () => void + onSubmit: (data: CreateSoftwarePackageRequest) => void + isLoading: boolean +} + +function CreatePackageModal({ + package: editingPackage, + isOpen, + onClose, + onSubmit, + isLoading, +}: CreatePackageModalProps) { + const [formData, setFormData] = useState( + editingPackage + ? { + name: editingPackage.name, + version: editingPackage.version, + osType: editingPackage.osType, + packageManager: editingPackage.packageManager, + packageName: editingPackage.packageName, + installerUrl: editingPackage.installerUrl ?? undefined, + checksum: editingPackage.checksum ?? undefined, + silentArgs: editingPackage.silentArgs ?? undefined, + } + : { + name: '', + version: '', + osType: 'Windows', + packageManager: 'choco', + packageName: '', + } + ) + + const getAvailablePackageManagers = (): PackageManager[] => { + if (formData.osType === 'Windows') { + return ['choco', 'direct'] + } else { + return ['apt', 'dnf', 'direct'] + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit(formData) + } + + const handleOsChange = (newOs: OsType) => { + const availableManagers = newOs === 'Windows' ? ['choco', 'direct'] : ['apt', 'dnf', 'direct'] + const newManager = + availableManagers.includes(formData.packageManager as PackageManager) + ? (formData.packageManager as PackageManager) + : (availableManagers[0] as PackageManager) + + setFormData((prev) => ({ + ...prev, + osType: newOs, + packageManager: newManager, + })) + } + + if (!isOpen) return null + + return ( +
+
+
+

+ {editingPackage ? 'Paket bearbeiten' : 'Neues Paket'} +

+ +
+ +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Version */} +
+ + setFormData({ ...formData, version: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* OS */} +
+ + +
+ + {/* Package Manager */} +
+ + +
+ + {/* Package Name */} +
+ + setFormData({ ...formData, packageName: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Installer URL (nur für "direct") */} + {formData.packageManager === 'direct' && ( +
+ + setFormData({ ...formData, installerUrl: e.target.value || undefined })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} + + {/* Checksum (nur für "direct") */} + {formData.packageManager === 'direct' && ( +
+ + setFormData({ ...formData, checksum: e.target.value || undefined })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs" + /> +
+ )} + + {/* Silent Args (nur für "direct") */} + {formData.packageManager === 'direct' && ( +
+ + setFormData({ ...formData, silentArgs: e.target.value || undefined })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} + + {/* Buttons */} +
+ + +
+
+
+
+ ) +} + +interface DeployTabProps { + packages: SoftwarePackage[] + deployMutation: any +} + +function DeployTab({ packages, deployMutation }: DeployTabProps) { + const [selectedPackageId, setSelectedPackageId] = useState('') + const [agentId, setAgentId] = useState('') + const [action, setAction] = useState<'install' | 'uninstall'>('install') + + const handleDeploy = (e: React.FormEvent) => { + e.preventDefault() + if (!selectedPackageId || !agentId) return + deployMutation.mutate({ + agentId, + packageId: selectedPackageId as number, + action, + }) + } + + return ( +
+
+
+ {/* Package Selection */} +
+ + +
+ + {/* Agent ID */} +
+ + setAgentId(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm" + /> +

+ Finde die Agent-ID im Agent-Dashboard oder Agent-Details +

+
+ + {/* Action */} +
+ +
+ + +
+
+ + {/* Submit */} + + + {/* Info */} +
+

Hinweis:

+

+ Die Task wird erstellt und der Agent führt sie beim nächsten Heartbeat aus (ca. 1-2 Minuten). + Überwache den Task-Fortschritt im Agent-Dashboard. +

+
+
+
+
+ ) +}