feat: implement Phase 6 — Software Deployment

Backend:
- SoftwarePackage model (Name, Version, OsType, PackageManager, PackageName, InstallerUrl, Checksum, SilentArgs)
- RmmDbContext: SoftwarePackages DbSet + unique index on (Name, Version, OsType)
- SoftwarePackagesController: full CRUD with OsType filter
- DeployController: POST /api/v1/deploy creates InstallSoftware/UninstallSoftware TaskItem
- EF Migration: AddSoftwarePackages (20260319130448)

Go Agent:
- internal/deployer/deployer.go: Install() and Uninstall() with:
  - Chocolatey (Windows), apt/dnf (Linux), auto-detect
  - Direct installer fallback: HTTP download + SHA256 verify + silent install
  - Supports .msi, .exe (Windows) and .deb, .rpm (Linux)
- main.go: COMMAND_TYPE_INSTALL_SOFTWARE and COMMAND_TYPE_UNINSTALL_SOFTWARE routed to deployer

Frontend:
- SoftwarePage: Katalog tab (CRUD, OS filter, smart package manager select) + Deploy tab
- api/types.ts: SoftwarePackage, PackageManager, DeployRequest/Response types
- api/client.ts: softwarePackagesApi and deployApi
- App.tsx: Software nav item with Package icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 14:06:40 +01:00
parent eb114f68e2
commit 84629dfbcf
13 changed files with 1491 additions and 2 deletions

View File

@@ -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)}
}

View File

@@ -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,
}
}