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:
@@ -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)}
|
||||
}
|
||||
|
||||
185
Agent/internal/deployer/deployer.go
Normal file
185
Agent/internal/deployer/deployer.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user