Compare commits
7 Commits
4c40e88718
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8905681c8c | ||
|
|
b45a9f3bdc | ||
|
|
d7b618f02d | ||
|
|
c401ea8f29 | ||
|
|
e55640d6a7 | ||
|
|
544d97b1ff | ||
|
|
22b05bd352 |
@@ -7,7 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,15 +20,93 @@ import (
|
|||||||
"nexusrmm.local/agent/internal/executor"
|
"nexusrmm.local/agent/internal/executor"
|
||||||
"nexusrmm.local/agent/internal/meshagent"
|
"nexusrmm.local/agent/internal/meshagent"
|
||||||
"nexusrmm.local/agent/internal/scanner"
|
"nexusrmm.local/agent/internal/scanner"
|
||||||
|
"nexusrmm.local/agent/internal/updater"
|
||||||
|
"nexusrmm.local/agent/internal/winsvc"
|
||||||
pb "nexusrmm.local/agent/pkg/proto"
|
pb "nexusrmm.local/agent/pkg/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Absoluten Pfad zur EXE ermitteln — kritisch im Service-Modus,
|
||||||
|
// da der Working Directory dann C:\Windows\System32 ist.
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Konnte EXE-Pfad nicht ermitteln: %v", err)
|
||||||
|
}
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
configPath := filepath.Join(exeDir, "nexus-agent.yaml")
|
||||||
|
|
||||||
|
// Kommandozeilen-Argument verarbeiten (install / uninstall / start / stop)
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "install":
|
||||||
|
if err := winsvc.Install(exePath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Fehler: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Service erfolgreich installiert.")
|
||||||
|
return
|
||||||
|
case "uninstall":
|
||||||
|
if err := winsvc.Uninstall(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Fehler: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Service erfolgreich deinstalliert.")
|
||||||
|
return
|
||||||
|
case "start":
|
||||||
|
if err := winsvc.Start(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Fehler: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Service gestartet.")
|
||||||
|
return
|
||||||
|
case "stop":
|
||||||
|
if err := winsvc.Stop(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Fehler: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Service gestoppt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob wir als Windows Service laufen
|
||||||
|
isSvc, err := winsvc.IsWindowsService()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Service-Erkennung fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSvc {
|
||||||
|
// Als Windows Service starten — blockiert bis Service gestoppt wird
|
||||||
|
if err := winsvc.Run(makeRunFn(configPath)); err != nil {
|
||||||
|
log.Fatalf("Service-Fehler: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konsolen-Modus: direkt ausführen mit Ctrl+C-Signal
|
||||||
log.Printf("NexusRMM Agent %s starting on %s/%s", version, runtime.GOOS, runtime.GOARCH)
|
log.Printf("NexusRMM Agent %s starting on %s/%s", version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
cfg, err := config.Load("nexus-agent.yaml")
|
stop := make(chan struct{})
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
close(stop)
|
||||||
|
}()
|
||||||
|
|
||||||
|
makeRunFn(configPath)(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRunFn gibt die Haupt-Agent-Loop als Funktion zurück.
|
||||||
|
// stop wird geschlossen wenn der Agent beendet werden soll (Service-Stop oder Ctrl+C).
|
||||||
|
func makeRunFn(configPath string) func(stop <-chan struct{}) {
|
||||||
|
return func(stop <-chan struct{}) {
|
||||||
|
log.Printf("NexusRMM Agent %s starting on %s/%s", version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Config load error: %v", err)
|
log.Fatalf("Config load error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,9 +121,17 @@ func main() {
|
|||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
metrics, _ := collector.Collect()
|
metrics, _ := collector.Collect()
|
||||||
mac, ip := "", ""
|
mac, ip := "", ""
|
||||||
if len(metrics.Networks) > 0 {
|
for _, n := range metrics.Networks {
|
||||||
mac = metrics.Networks[0].MAC
|
if n.MAC == "" || n.IPAddress == "" {
|
||||||
ip = metrics.Networks[0].IPAddress
|
continue
|
||||||
|
}
|
||||||
|
if mac == "" {
|
||||||
|
mac, ip = n.MAC, n.IPAddress
|
||||||
|
}
|
||||||
|
if !strings.Contains(n.IPAddress, ":") {
|
||||||
|
mac, ip = n.MAC, n.IPAddress
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Client.Enroll(context.Background(), &pb.EnrollRequest{
|
resp, err := client.Client.Enroll(context.Background(), &pb.EnrollRequest{
|
||||||
@@ -59,13 +147,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
cfg.AgentID = resp.AgentId
|
cfg.AgentID = resp.AgentId
|
||||||
cfg.HeartbeatInterval = int(resp.HeartbeatInterval)
|
cfg.HeartbeatInterval = int(resp.HeartbeatInterval)
|
||||||
if err := cfg.Save("nexus-agent.yaml"); err != nil {
|
if err := cfg.Save(configPath); err != nil {
|
||||||
log.Printf("Warning: could not save config: %v", err)
|
log.Printf("Warning: could not save config: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("Enrolled with ID: %s", cfg.AgentID)
|
log.Printf("Enrolled with ID: %s", cfg.AgentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeshAgent installieren falls konfiguriert
|
|
||||||
if cfg.MeshEnabled && cfg.MeshCentralUrl != "" {
|
if cfg.MeshEnabled && cfg.MeshCentralUrl != "" {
|
||||||
log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl)
|
log.Printf("Installiere MeshAgent von %s...", cfg.MeshCentralUrl)
|
||||||
if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil {
|
if err := meshagent.Install(context.Background(), cfg.MeshCentralUrl); err != nil {
|
||||||
@@ -75,8 +162,6 @@ func main() {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Duration(cfg.HeartbeatInterval) * time.Second)
|
ticker := time.NewTicker(time.Duration(cfg.HeartbeatInterval) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -88,14 +173,15 @@ func main() {
|
|||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
doHeartbeat(ctx, client, cfg)
|
doHeartbeat(ctx, client, cfg)
|
||||||
case <-sigCh:
|
case <-stop:
|
||||||
log.Println("Shutting down...")
|
log.Println("Agent wird beendet...")
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config.Config) {
|
func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config.Config) {
|
||||||
metrics, err := collector.Collect()
|
metrics, err := collector.Collect()
|
||||||
@@ -122,6 +208,15 @@ func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config
|
|||||||
Filesystem: d.Filesystem,
|
Filesystem: d.Filesystem,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for _, n := range metrics.Networks {
|
||||||
|
req.Metrics.NetworkInterfaces = append(req.Metrics.NetworkInterfaces, &pb.NetworkInterfaceInfo{
|
||||||
|
Name: n.Name,
|
||||||
|
IpAddress: n.IPAddress,
|
||||||
|
MacAddress: n.MAC,
|
||||||
|
BytesSent: int64(n.BytesSent),
|
||||||
|
BytesRecv: int64(n.BytesRecv),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.Client.Heartbeat(ctx, req)
|
resp, err := client.Client.Heartbeat(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,11 +226,12 @@ func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config
|
|||||||
|
|
||||||
for _, cmd := range resp.PendingCommands {
|
for _, cmd := range resp.PendingCommands {
|
||||||
log.Printf("Executing command %s (type: %v)", cmd.CommandId, cmd.Type)
|
log.Printf("Executing command %s (type: %v)", cmd.CommandId, cmd.Type)
|
||||||
go executeCommand(ctx, client, cfg.AgentID, cmd)
|
go executeCommand(ctx, client, cfg, cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID string, cmd *pb.AgentCommand) {
|
func executeCommand(ctx context.Context, client *connection.GrpcClient, cfg *config.Config, cmd *pb.AgentCommand) {
|
||||||
|
agentID := cfg.AgentID
|
||||||
var result *executor.Result
|
var result *executor.Result
|
||||||
switch cmd.Type {
|
switch cmd.Type {
|
||||||
case pb.CommandType_COMMAND_TYPE_SHELL:
|
case pb.CommandType_COMMAND_TYPE_SHELL:
|
||||||
@@ -159,6 +255,26 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID
|
|||||||
Success: true,
|
Success: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case pb.CommandType_COMMAND_TYPE_UPDATE_AGENT:
|
||||||
|
var params struct {
|
||||||
|
ServerAddress string `json:"serverAddress"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(cmd.Payload), ¶ms)
|
||||||
|
if params.ServerAddress == "" {
|
||||||
|
params.ServerAddress = strings.Replace(cfg.ServerAddress, "5001", "5000", 1)
|
||||||
|
}
|
||||||
|
info, err := updater.CheckForUpdate(ctx, params.ServerAddress, version)
|
||||||
|
if err != nil {
|
||||||
|
result = &executor.Result{ExitCode: 1, Stderr: err.Error()}
|
||||||
|
} else if info == nil {
|
||||||
|
result = &executor.Result{ExitCode: 0, Stdout: "Bereits aktuell: " + version, Success: true}
|
||||||
|
} else {
|
||||||
|
if updateErr := updater.Update(ctx, info); updateErr != nil {
|
||||||
|
result = &executor.Result{ExitCode: 1, Stderr: updateErr.Error()}
|
||||||
|
} else {
|
||||||
|
result = &executor.Result{ExitCode: 0, Stdout: "Update auf " + info.Version + " gestartet", Success: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
|
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package collector
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v3/cpu"
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
@@ -79,15 +81,41 @@ func Collect() (*Metrics, error) {
|
|||||||
counterMap[c.Name] = c
|
counterMap[c.Name] = c
|
||||||
}
|
}
|
||||||
for _, iface := range interfaces {
|
for _, iface := range interfaces {
|
||||||
if len(iface.Addrs) == 0 {
|
if len(iface.Addrs) == 0 || iface.HardwareAddr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Loopback und virtuelle Adapter überspringen
|
||||||
|
nameLower := strings.ToLower(iface.Name)
|
||||||
|
if strings.Contains(nameLower, "loopback") || strings.Contains(nameLower, "teredo") ||
|
||||||
|
strings.Contains(nameLower, "isatap") || strings.Contains(nameLower, "6to4") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ni := NetInfo{
|
ni := NetInfo{
|
||||||
Name: iface.Name,
|
Name: iface.Name,
|
||||||
MAC: iface.HardwareAddr,
|
MAC: iface.HardwareAddr,
|
||||||
}
|
}
|
||||||
if len(iface.Addrs) > 0 {
|
// IPv4 bevorzugen, IPv6 als Fallback
|
||||||
ni.IPAddress = iface.Addrs[0].Addr
|
for _, addr := range iface.Addrs {
|
||||||
|
ip, _, err := net.ParseCIDR(addr.Addr)
|
||||||
|
if err != nil {
|
||||||
|
// Versuche direkte IP-Adresse (ohne CIDR)
|
||||||
|
ip = net.ParseIP(addr.Addr)
|
||||||
|
}
|
||||||
|
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip.To4() != nil {
|
||||||
|
// IPv4 gefunden — nehmen und abbrechen
|
||||||
|
ni.IPAddress = addr.Addr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ni.IPAddress == "" {
|
||||||
|
// IPv6 als vorläufiger Fallback
|
||||||
|
ni.IPAddress = addr.Addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ni.IPAddress == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if c, ok := counterMap[iface.Name]; ok {
|
if c, ok := counterMap[iface.Name]; ok {
|
||||||
ni.BytesSent = c.BytesSent
|
ni.BytesSent = c.BytesSent
|
||||||
|
|||||||
155
Agent/internal/updater/updater.go
Normal file
155
Agent/internal/updater/updater.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseInfo enthält Informationen über die aktuell verfügbare Agent-Version.
|
||||||
|
type ReleaseInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
DownloadUrl string `json:"downloadUrl"`
|
||||||
|
Checksum string `json:"checksum"` // SHA256 hex
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckForUpdate fragt den Backend-Server nach der aktuellen Version.
|
||||||
|
// serverAddress: z.B. "localhost:5000" (REST Port, nicht gRPC)
|
||||||
|
func CheckForUpdate(ctx context.Context, serverAddress, currentVersion string) (*ReleaseInfo, error) {
|
||||||
|
url := fmt.Sprintf("http://%s/api/v1/agent/releases/latest?os=%s&arch=%s",
|
||||||
|
serverAddress, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching release info: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %d from release endpoint", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ReleaseInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding release info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Version == currentVersion {
|
||||||
|
return nil, nil // bereits aktuell
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lädt die neue Version herunter und startet einen Austausch-Prozess.
|
||||||
|
// Nach erfolgreichem Download wird das neue Binary neben das aktuelle gelegt
|
||||||
|
// und ein Neustartprozess gestartet.
|
||||||
|
func Update(ctx context.Context, info *ReleaseInfo) error {
|
||||||
|
binaryPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("determining executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download der neuen Version
|
||||||
|
dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, info.DownloadUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("downloading new binary: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected status %d during download", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading download body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256-Prüfung (überspringen wenn Checksum leer = Dev-Modus)
|
||||||
|
if info.Checksum != "" {
|
||||||
|
sum := sha256.Sum256(fileBytes)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
if got != info.Checksum {
|
||||||
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", info.Checksum, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newBinaryPath := binaryPath + ".new"
|
||||||
|
if err := os.WriteFile(newBinaryPath, fileBytes, 0755); err != nil {
|
||||||
|
return fmt.Errorf("writing new binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-spezifischer Neustart
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return restartWindows(binaryPath, newBinaryPath)
|
||||||
|
}
|
||||||
|
return restartUnix(binaryPath, newBinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartWindows(binaryPath, newBinaryPath string) error {
|
||||||
|
batchContent := fmt.Sprintf(`@echo off
|
||||||
|
timeout /t 2 /nobreak > nul
|
||||||
|
move /y "%s" "%s"
|
||||||
|
start "" "%s"
|
||||||
|
del "%%~f0"
|
||||||
|
`, newBinaryPath, binaryPath, binaryPath)
|
||||||
|
|
||||||
|
batchPath := filepath.Join(os.TempDir(), "nexus-agent-update.bat")
|
||||||
|
if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing update batch script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command("cmd", "/c", "start", "", batchPath).Start(); err != nil {
|
||||||
|
return fmt.Errorf("starting update batch script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartUnix(binaryPath, newBinaryPath string) error {
|
||||||
|
scriptContent := fmt.Sprintf(`#!/bin/sh
|
||||||
|
sleep 2
|
||||||
|
mv -f "%s" "%s"
|
||||||
|
"%s" &
|
||||||
|
rm -f "$0"
|
||||||
|
`, newBinaryPath, binaryPath, binaryPath)
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(os.TempDir(), "nexus-agent-update.sh")
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||||
|
return fmt.Errorf("writing update shell script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command("sh", scriptPath).Start(); err != nil {
|
||||||
|
return fmt.Errorf("starting update shell script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
Agent/internal/winsvc/service_stub.go
Normal file
33
Agent/internal/winsvc/service_stub.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package winsvc
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServiceName = "NexusRMMAgent"
|
||||||
|
ServiceDisplayName = "NexusRMM Agent"
|
||||||
|
ServiceDescription = "NexusRMM Remote Monitoring and Management Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsWindowsService() (bool, error) { return false, nil }
|
||||||
|
|
||||||
|
func Run(_ func(stop <-chan struct{})) error {
|
||||||
|
return errors.New("Windows Service wird nur unter Windows unterstützt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(_ string) error {
|
||||||
|
return errors.New("Windows Service wird nur unter Windows unterstützt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Uninstall() error {
|
||||||
|
return errors.New("Windows Service wird nur unter Windows unterstützt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Start() error {
|
||||||
|
return errors.New("Windows Service wird nur unter Windows unterstützt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() error {
|
||||||
|
return errors.New("Windows Service wird nur unter Windows unterstützt")
|
||||||
|
}
|
||||||
169
Agent/internal/winsvc/service_windows.go
Normal file
169
Agent/internal/winsvc/service_windows.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServiceName = "NexusRMMAgent"
|
||||||
|
ServiceDisplayName = "NexusRMM Agent"
|
||||||
|
ServiceDescription = "NexusRMM Remote Monitoring and Management Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsWindowsService gibt true zurück wenn der Prozess als Windows Service läuft.
|
||||||
|
func IsWindowsService() (bool, error) {
|
||||||
|
return svc.IsWindowsService()
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentSvc struct {
|
||||||
|
runFn func(stop <-chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentSvc) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||||
|
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.StartPending}
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
s.runFn(stop)
|
||||||
|
}()
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||||
|
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-r:
|
||||||
|
switch c.Cmd {
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
changes <- svc.Status{State: svc.StopPending}
|
||||||
|
close(stop)
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf Agent-Shutdown warten (max 10s)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run führt den Agent als Windows Service aus.
|
||||||
|
// runFn wird in einer Goroutine gestartet; stop wird geschlossen wenn der Service gestoppt wird.
|
||||||
|
func Run(runFn func(stop <-chan struct{})) error {
|
||||||
|
return svc.Run(ServiceName, &agentSvc{runFn: runFn})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install registriert den Service im Windows Service Manager.
|
||||||
|
// exePath muss der absolute Pfad zur nexus-agent.exe sein.
|
||||||
|
func Install(exePath string) error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
// Prüfen ob Service bereits existiert
|
||||||
|
existing, err := m.OpenService(ServiceName)
|
||||||
|
if err == nil {
|
||||||
|
existing.Close()
|
||||||
|
return fmt.Errorf("Service '%s' ist bereits installiert", ServiceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := m.CreateService(ServiceName, exePath, mgr.Config{
|
||||||
|
DisplayName: ServiceDisplayName,
|
||||||
|
Description: ServiceDescription,
|
||||||
|
StartType: mgr.StartAutomatic,
|
||||||
|
ErrorControl: mgr.ServiceRestart,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service erstellen fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall entfernt den Service (stoppt ihn vorher falls nötig).
|
||||||
|
func Uninstall() error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// Service stoppen falls er läuft
|
||||||
|
status, err := s.Query()
|
||||||
|
if err == nil && status.State != svc.Stopped {
|
||||||
|
if _, err := s.Control(svc.Stop); err == nil {
|
||||||
|
// Kurz warten bis er gestoppt ist
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
st, e := s.Query()
|
||||||
|
if e != nil || st.State == svc.Stopped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Delete(); err != nil {
|
||||||
|
return fmt.Errorf("Service löschen fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start startet den installierten Service.
|
||||||
|
func Start() error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
return s.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stoppt den laufenden Service.
|
||||||
|
func Stop() error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service Manager Verbindung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Service '%s' nicht gefunden: %w", ServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, err = s.Control(svc.Stop)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NexusRMM.Api.Services;
|
||||||
|
|
||||||
|
namespace NexusRMM.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/agent/releases")]
|
||||||
|
public class AgentReleasesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AgentReleaseOptions _options;
|
||||||
|
|
||||||
|
public AgentReleasesController(IOptions<AgentReleaseOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt Informationen zur aktuell verfügbaren Agent-Version zurück.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("latest")]
|
||||||
|
public IActionResult GetLatest([FromQuery] string os = "windows", [FromQuery] string arch = "amd64")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_options.LatestVersion))
|
||||||
|
return Ok(new { version = "dev", downloadUrl = (string?)null, checksum = (string?)null });
|
||||||
|
|
||||||
|
var platform = $"{os}-{arch}";
|
||||||
|
var downloadUrl = Url.Action("Download", "AgentReleases", new { platform }, Request.Scheme);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
version = _options.LatestVersion,
|
||||||
|
downloadUrl,
|
||||||
|
checksum = _options.Checksum
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liefert das Agent-Binary für die angegebene Platform.
|
||||||
|
/// platform: windows-amd64, linux-amd64
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("download/{platform}")]
|
||||||
|
public IActionResult Download(string platform)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_options.ReleasePath))
|
||||||
|
return NotFound("Kein Release-Pfad konfiguriert.");
|
||||||
|
|
||||||
|
var filename = platform.StartsWith("windows") ? "nexus-agent.exe" : "nexus-agent-linux";
|
||||||
|
var filePath = Path.Combine(_options.ReleasePath, filename);
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
return NotFound($"Binary {filename} nicht gefunden unter {_options.ReleasePath}");
|
||||||
|
|
||||||
|
return PhysicalFile(filePath, "application/octet-stream", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public class AgentsController : ControllerBase
|
|||||||
{
|
{
|
||||||
var agents = await _db.Agents
|
var agents = await _db.Agents
|
||||||
.OrderBy(a => a.Hostname)
|
.OrderBy(a => a.Hostname)
|
||||||
.Select(a => new { a.Id, a.Hostname, a.OsType, a.OsVersion, a.IpAddress, a.Status, a.AgentVersion, a.LastSeen, a.Tags })
|
.Select(a => new { a.Id, a.Hostname, a.OsType, a.OsVersion, a.IpAddress, a.MacAddress, a.Status, a.AgentVersion, a.LastSeen, a.EnrolledAt, a.Tags })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(agents);
|
return Ok(agents);
|
||||||
}
|
}
|
||||||
@@ -29,14 +29,13 @@ public class AgentsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/metrics")]
|
[HttpGet("{id:guid}/metrics")]
|
||||||
public async Task<IActionResult> GetMetrics(Guid id, [FromQuery] int hours = 24)
|
public async Task<IActionResult> GetMetrics(Guid id, [FromQuery] int limit = 100)
|
||||||
{
|
{
|
||||||
var since = DateTime.UtcNow.AddHours(-hours);
|
|
||||||
var metrics = await _db.AgentMetrics
|
var metrics = await _db.AgentMetrics
|
||||||
.Where(m => m.AgentId == id && m.Timestamp >= since)
|
.Where(m => m.AgentId == id)
|
||||||
.OrderByDescending(m => m.Timestamp)
|
.OrderByDescending(m => m.Timestamp)
|
||||||
.Take(1000)
|
.Take(limit)
|
||||||
.Select(m => new { m.Timestamp, m.Metrics })
|
.Select(m => new { m.Id, m.AgentId, m.Timestamp, m.Metrics })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(metrics);
|
return Ok(metrics);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,23 +29,21 @@ public class TasksController : ControllerBase
|
|||||||
return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);
|
return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] Guid? agentId = null)
|
||||||
|
{
|
||||||
|
var query = _db.Tasks.AsQueryable();
|
||||||
|
if (agentId.HasValue) query = query.Where(t => t.AgentId == agentId.Value);
|
||||||
|
var tasks = await query.OrderByDescending(t => t.CreatedAt).Take(50).ToListAsync();
|
||||||
|
return Ok(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var task = await _db.Tasks.FindAsync(id);
|
var task = await _db.Tasks.FindAsync(id);
|
||||||
return task is null ? NotFound() : Ok(task);
|
return task is null ? NotFound() : Ok(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("agent/{agentId:guid}")]
|
|
||||||
public async Task<IActionResult> GetByAgent(Guid agentId)
|
|
||||||
{
|
|
||||||
var tasks = await _db.Tasks
|
|
||||||
.Where(t => t.AgentId == agentId)
|
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
|
||||||
.Take(50)
|
|
||||||
.ToListAsync();
|
|
||||||
return Ok(tasks);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateTaskRequest(Guid AgentId, TaskType Type, object? Payload);
|
public record CreateTaskRequest(Guid AgentId, TaskType Type, object? Payload);
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ namespace NexusRMM.Api.GrpcServices;
|
|||||||
|
|
||||||
public class AgentGrpcService : AgentService.AgentServiceBase
|
public class AgentGrpcService : AgentService.AgentServiceBase
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions _camelCase = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
private readonly RmmDbContext _db;
|
private readonly RmmDbContext _db;
|
||||||
private readonly ILogger<AgentGrpcService> _logger;
|
private readonly ILogger<AgentGrpcService> _logger;
|
||||||
private readonly IHubContext<RmmHub, IRmmHubClient> _hub;
|
private readonly IHubContext<RmmHub, IRmmHubClient> _hub;
|
||||||
@@ -29,6 +33,24 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
|||||||
|
|
||||||
public override async Task<EnrollResponse> Enroll(EnrollRequest request, ServerCallContext context)
|
public override async Task<EnrollResponse> Enroll(EnrollRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
|
// Upsert via MAC-Adresse — verhindert Duplikate bei Agent-Neustart
|
||||||
|
var existing = !string.IsNullOrEmpty(request.MacAddress)
|
||||||
|
? await _db.Agents.FirstOrDefaultAsync(a => a.MacAddress == request.MacAddress)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.Hostname = request.Hostname;
|
||||||
|
existing.OsVersion = request.OsVersion;
|
||||||
|
existing.IpAddress = request.IpAddress;
|
||||||
|
existing.AgentVersion = request.AgentVersion;
|
||||||
|
existing.Status = AgentStatus.Online;
|
||||||
|
existing.LastSeen = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("Agent re-enrolled: {AgentId} ({Hostname})", existing.Id, existing.Hostname);
|
||||||
|
return new EnrollResponse { AgentId = existing.Id.ToString(), HeartbeatInterval = 30 };
|
||||||
|
}
|
||||||
|
|
||||||
var agent = new AgentModel
|
var agent = new AgentModel
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -47,11 +69,7 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
_logger.LogInformation("Agent enrolled: {AgentId} ({Hostname})", agent.Id, agent.Hostname);
|
_logger.LogInformation("Agent enrolled: {AgentId} ({Hostname})", agent.Id, agent.Hostname);
|
||||||
|
|
||||||
return new EnrollResponse
|
return new EnrollResponse { AgentId = agent.Id.ToString(), HeartbeatInterval = 30 };
|
||||||
{
|
|
||||||
AgentId = agent.Id.ToString(),
|
|
||||||
HeartbeatInterval = 60
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<HeartbeatResponse> Heartbeat(HeartbeatRequest request, ServerCallContext context)
|
public override async Task<HeartbeatResponse> Heartbeat(HeartbeatRequest request, ServerCallContext context)
|
||||||
@@ -67,7 +85,7 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
|||||||
{
|
{
|
||||||
AgentId = agentId,
|
AgentId = agentId,
|
||||||
Timestamp = DateTime.UtcNow,
|
Timestamp = DateTime.UtcNow,
|
||||||
Metrics = JsonSerializer.SerializeToElement(request.Metrics)
|
Metrics = JsonSerializer.SerializeToElement(request.Metrics, _camelCase)
|
||||||
});
|
});
|
||||||
|
|
||||||
var pendingTasks = await _db.Tasks
|
var pendingTasks = await _db.Tasks
|
||||||
@@ -90,15 +108,30 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
|||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// SignalR: Metriken an agent-Gruppe pushen
|
// SignalR: Metriken an agent-Gruppe pushen (camelCase durch AddJsonProtocol-Konfiguration)
|
||||||
await _hub.Clients.Group($"agent-{agentId}")
|
await _hub.Clients.Group($"agent-{agentId}")
|
||||||
.AgentMetricsUpdated(request.AgentId, new
|
.AgentMetricsUpdated(request.AgentId, new
|
||||||
{
|
{
|
||||||
CpuUsagePercent = request.Metrics?.CpuUsagePercent ?? 0,
|
cpuUsagePercent = request.Metrics?.CpuUsagePercent ?? 0,
|
||||||
MemoryUsagePercent = request.Metrics?.MemoryUsagePercent ?? 0,
|
memoryUsagePercent = request.Metrics?.MemoryUsagePercent ?? 0,
|
||||||
MemoryTotalBytes = request.Metrics?.MemoryTotalBytes ?? 0,
|
memoryTotalBytes = request.Metrics?.MemoryTotalBytes ?? 0,
|
||||||
MemoryAvailableBytes = request.Metrics?.MemoryAvailableBytes ?? 0,
|
memoryAvailableBytes = request.Metrics?.MemoryAvailableBytes ?? 0,
|
||||||
UptimeSeconds = request.Metrics?.UptimeSeconds ?? 0,
|
uptimeSeconds = request.Metrics?.UptimeSeconds ?? 0,
|
||||||
|
disks = request.Metrics?.Disks.Select(d => new
|
||||||
|
{
|
||||||
|
mountPoint = d.MountPoint,
|
||||||
|
totalBytes = d.TotalBytes,
|
||||||
|
freeBytes = d.FreeBytes,
|
||||||
|
filesystem = d.Filesystem,
|
||||||
|
}) ?? [],
|
||||||
|
networkInterfaces = request.Metrics?.NetworkInterfaces.Select(n => new
|
||||||
|
{
|
||||||
|
name = n.Name,
|
||||||
|
ipAddress = n.IpAddress,
|
||||||
|
macAddress = n.MacAddress,
|
||||||
|
bytesSent = n.BytesSent,
|
||||||
|
bytesRecv = n.BytesRecv,
|
||||||
|
}) ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// SignalR: Status-Änderung an alle Clients pushen
|
// SignalR: Status-Änderung an alle Clients pushen
|
||||||
@@ -121,11 +154,11 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
|||||||
taskItem.CompletedAt = DateTime.UtcNow;
|
taskItem.CompletedAt = DateTime.UtcNow;
|
||||||
taskItem.Result = JsonSerializer.SerializeToElement(new
|
taskItem.Result = JsonSerializer.SerializeToElement(new
|
||||||
{
|
{
|
||||||
request.ExitCode,
|
exitCode = request.ExitCode,
|
||||||
request.Stdout,
|
stdout = request.Stdout,
|
||||||
request.Stderr,
|
stderr = request.Stderr,
|
||||||
request.Success
|
success = request.Success,
|
||||||
});
|
}, _camelCase);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
50
Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs
Normal file
50
Backend/src/NexusRMM.Api/Middleware/ApiKeyMiddleware.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace NexusRMM.Api.Middleware;
|
||||||
|
|
||||||
|
public class ApiKeyMiddleware
|
||||||
|
{
|
||||||
|
private const string ApiKeyHeader = "X-Api-Key";
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<ApiKeyMiddleware> _logger;
|
||||||
|
|
||||||
|
public ApiKeyMiddleware(RequestDelegate next, IConfiguration config, ILogger<ApiKeyMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
// Wenn kein API-Key konfiguriert ist: Auth deaktiviert (Dev-Fallback)
|
||||||
|
var configuredKey = _config["Security:ApiKey"];
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredKey))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swagger und Health-Endpunkte überspringen
|
||||||
|
var path = context.Request.Path.Value ?? "";
|
||||||
|
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("/health", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("/hubs", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-Key aus Header lesen
|
||||||
|
if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var receivedKey) ||
|
||||||
|
receivedKey != configuredKey)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ungültiger API-Key von {IP}", context.Connection.RemoteIpAddress);
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsync("{\"error\":\"Unauthorized: Invalid or missing API key\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NexusRMM.Api.GrpcServices;
|
using NexusRMM.Api.GrpcServices;
|
||||||
using NexusRMM.Api.Hubs;
|
using NexusRMM.Api.Hubs;
|
||||||
|
using NexusRMM.Api.Middleware;
|
||||||
using NexusRMM.Api.Services;
|
using NexusRMM.Api.Services;
|
||||||
using NexusRMM.Infrastructure.Data;
|
using NexusRMM.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -18,17 +19,32 @@ builder.Services.AddDbContext<RmmDbContext>(options =>
|
|||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
builder.Services.AddGrpc();
|
builder.Services.AddGrpc();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR()
|
||||||
builder.Services.AddControllers();
|
.AddJsonProtocol(options =>
|
||||||
|
{
|
||||||
|
options.PayloadSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
});
|
||||||
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
|
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
builder.Services.AddScoped<AlertEvaluationService>();
|
builder.Services.AddScoped<AlertEvaluationService>();
|
||||||
|
builder.Services.AddHostedService<AgentOfflineDetectorService>();
|
||||||
|
|
||||||
// MeshCentral Konfiguration
|
// MeshCentral Konfiguration
|
||||||
builder.Services.Configure<MeshCentralOptions>(
|
builder.Services.Configure<MeshCentralOptions>(
|
||||||
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
|
builder.Configuration.GetSection(MeshCentralOptions.SectionName));
|
||||||
|
|
||||||
|
// AgentRelease Konfiguration
|
||||||
|
builder.Services.Configure<AgentReleaseOptions>(
|
||||||
|
builder.Configuration.GetSection(AgentReleaseOptions.SectionName));
|
||||||
|
|
||||||
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
|
// HttpClient für MeshCentral (mit optionalem SSL-Bypass für Entwicklung)
|
||||||
builder.Services.AddHttpClient("MeshCentral")
|
builder.Services.AddHttpClient("MeshCentral")
|
||||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
@@ -59,6 +75,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseMiddleware<ApiKeyMiddleware>();
|
||||||
app.MapGrpcService<AgentGrpcService>();
|
app.MapGrpcService<AgentGrpcService>();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHub<RmmHub>("/hubs/rmm");
|
app.MapHub<RmmHub>("/hubs/rmm");
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusRMM.Api.Hubs;
|
||||||
|
using NexusRMM.Core.Models;
|
||||||
|
using NexusRMM.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace NexusRMM.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background Service: Markiert Agenten als Offline wenn sie länger als
|
||||||
|
/// OfflineThresholdMinutes (Standard: 5) kein Heartbeat gesendet haben.
|
||||||
|
/// Läuft alle CheckIntervalSeconds (Standard: 60) Sekunden.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentOfflineDetectorService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<AgentOfflineDetectorService> _logger;
|
||||||
|
private readonly TimeSpan _offlineThreshold = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
public AgentOfflineDetectorService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<AgentOfflineDetectorService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AgentOfflineDetector gestartet (Threshold: {Threshold}min, Intervall: {Interval}s)",
|
||||||
|
_offlineThreshold.TotalMinutes, _checkInterval.TotalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(_checkInterval, stoppingToken);
|
||||||
|
await CheckAgentsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckAgentsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<RmmDbContext>();
|
||||||
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<RmmHub, IRmmHubClient>>();
|
||||||
|
|
||||||
|
var cutoff = DateTime.UtcNow - _offlineThreshold;
|
||||||
|
|
||||||
|
// Alle Agenten die als Online gelten aber zu lange keine Meldung hatten
|
||||||
|
var staleAgents = await db.Agents
|
||||||
|
.Where(a => a.Status == AgentStatus.Online && a.LastSeen < cutoff)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (staleAgents.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var agent in staleAgents)
|
||||||
|
{
|
||||||
|
agent.Status = AgentStatus.Offline;
|
||||||
|
_logger.LogInformation("Agent {Id} ({Hostname}) als Offline markiert (LastSeen: {LastSeen})",
|
||||||
|
agent.Id, agent.Hostname, agent.LastSeen);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// SignalR: Status-Änderung an alle Clients pushen
|
||||||
|
foreach (var agent in staleAgents)
|
||||||
|
{
|
||||||
|
await hub.Clients.All.AgentStatusChanged(
|
||||||
|
agent.Id.ToString(), "Offline", agent.LastSeen.ToString("O"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fehler im AgentOfflineDetector");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs
Normal file
9
Backend/src/NexusRMM.Api/Services/AgentReleaseOptions.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace NexusRMM.Api.Services;
|
||||||
|
|
||||||
|
public class AgentReleaseOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "AgentRelease";
|
||||||
|
public string LatestVersion { get; set; } = string.Empty;
|
||||||
|
public string ReleasePath { get; set; } = string.Empty;
|
||||||
|
public string Checksum { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"ApiKey": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,13 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"Security": {
|
||||||
|
"ApiKey": ""
|
||||||
|
},
|
||||||
|
"AgentRelease": {
|
||||||
|
"LatestVersion": "",
|
||||||
|
"ReleasePath": "",
|
||||||
|
"Checksum": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { LayoutDashboard, Ticket, Bell, Package, Network, Menu, X } from 'lucide-react'
|
import { LayoutDashboard, Ticket, Bell, Package, Network, Menu, X, Settings } from 'lucide-react'
|
||||||
import { DashboardPage } from './pages/DashboardPage'
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
import { AgentDetailPage } from './pages/AgentDetailPage'
|
import { AgentDetailPage } from './pages/AgentDetailPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
@@ -38,6 +38,19 @@ function AppContent() {
|
|||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [page, setPage] = useState<Page>('dashboard')
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState('')
|
||||||
|
|
||||||
|
const storedKey = localStorage.getItem('nexusrmm_api_key') ?? ''
|
||||||
|
const maskedKey = storedKey.length > 0
|
||||||
|
? storedKey.substring(0, Math.min(8, storedKey.length)) + '...'
|
||||||
|
: '(nicht gesetzt)'
|
||||||
|
|
||||||
|
function handleSaveApiKey() {
|
||||||
|
localStorage.setItem('nexusrmm_api_key', apiKeyInput)
|
||||||
|
setSettingsOpen(false)
|
||||||
|
setApiKeyInput('')
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelectAgent(agentId: string) {
|
function handleSelectAgent(agentId: string) {
|
||||||
setSelectedAgentId(agentId)
|
setSelectedAgentId(agentId)
|
||||||
@@ -96,14 +109,73 @@ function AppContent() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Settings + Version */}
|
||||||
|
<div className="border-t border-border px-2 py-2 flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setApiKeyInput(localStorage.getItem('nexusrmm_api_key') ?? '')
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors w-full text-left text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0"><Settings size={18} /></span>
|
||||||
|
{sidebarOpen && <span>Einstellungen</span>}
|
||||||
|
</button>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="px-4 py-3 text-xs text-muted-foreground border-t border-border">
|
<div className="px-2 py-1 text-xs text-muted-foreground">
|
||||||
NexusRMM v0.1.0
|
NexusRMM v0.1.0
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
{settingsOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-lg w-96 p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Einstellungen</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">API-Key</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aktuell: <span className="font-mono">{maskedKey}</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
|
placeholder="API-Key eingeben..."
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leer lassen um Authentifizierung zu deaktivieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveApiKey}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
{page === 'dashboard' && (
|
{page === 'dashboard' && (
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ import type {
|
|||||||
|
|
||||||
const BASE_URL = '/api/v1'
|
const BASE_URL = '/api/v1'
|
||||||
|
|
||||||
|
// API-Key aus localStorage lesen (leer = Auth deaktiviert)
|
||||||
|
function getApiKey(): string {
|
||||||
|
return localStorage.getItem('nexusrmm_api_key') ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(getApiKey() ? { 'X-Api-Key': getApiKey() } : {}),
|
||||||
|
...options?.headers
|
||||||
|
},
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import * as signalR from '@microsoft/signalr'
|
import * as signalR from '@microsoft/signalr'
|
||||||
import { useSignalR } from './useSignalR'
|
import { useSignalR } from './useSignalR'
|
||||||
|
import type { SystemMetrics } from '../api/types'
|
||||||
|
|
||||||
const HUB_URL = '/hubs/rmm'
|
const HUB_URL = '/hubs/rmm'
|
||||||
|
|
||||||
/**
|
|
||||||
* Verbindet mit dem SignalR Hub und hört auf globale Agent-Events.
|
|
||||||
* Invalidiert TanStack Query-Caches bei Updates.
|
|
||||||
*/
|
|
||||||
export function useGlobalSignalR() {
|
export function useGlobalSignalR() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { status } = useSignalR({
|
const { status } = useSignalR({
|
||||||
url: HUB_URL,
|
url: HUB_URL,
|
||||||
onConnected: (connection) => {
|
onConnected: (connection) => {
|
||||||
connection.on('AgentStatusChanged', (agentId: string, status: string, _lastSeen: string) => {
|
connection.on('AgentStatusChanged', (agentId: string, _status: string, _lastSeen: string) => {
|
||||||
console.debug('[SignalR] AgentStatusChanged', agentId, status)
|
|
||||||
// Agents-Liste invalidieren damit Dashboard aktuell bleibt
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||||
// Einzelnen Agent-Cache aktualisieren
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agent', agentId] })
|
queryClient.invalidateQueries({ queryKey: ['agent', agentId] })
|
||||||
})
|
})
|
||||||
|
|
||||||
connection.on('AlertTriggered', (_agentId: string, agentHostname: string, ruleName: string, _message: string, _severity: string) => {
|
connection.on('AlertTriggered', () => {
|
||||||
console.debug('[SignalR] AlertTriggered', agentHostname, ruleName)
|
|
||||||
// Alerts-Liste invalidieren
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -35,36 +27,47 @@ export function useGlobalSignalR() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tritt der Agent-spezifischen Gruppe bei und hört auf Metriken + Command-Results.
|
* Tritt der Agent-Gruppe bei und empfängt Live-Metriken via SignalR.
|
||||||
* Wird in AgentDetailPage verwendet.
|
* onLiveMetrics wird mit den aktuellen Metriken aufgerufen (direkt, ohne API-Roundtrip).
|
||||||
*/
|
*/
|
||||||
export function useAgentSignalR(agentId: string) {
|
export function useAgentSignalR(
|
||||||
|
agentId: string,
|
||||||
|
onLiveMetrics?: (metrics: SystemMetrics) => void,
|
||||||
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const connectionRef = useRef<signalR.HubConnection | null>(null)
|
const connectionRef = useRef<signalR.HubConnection | null>(null)
|
||||||
|
const onLiveMetricsRef = useRef(onLiveMetrics)
|
||||||
|
|
||||||
|
// Ref aktuell halten ohne Re-Subscribe zu triggern
|
||||||
|
useEffect(() => {
|
||||||
|
onLiveMetricsRef.current = onLiveMetrics
|
||||||
|
}, [onLiveMetrics])
|
||||||
|
|
||||||
|
const handleMetrics = useCallback((id: string, metrics: SystemMetrics) => {
|
||||||
|
if (id !== agentId) return
|
||||||
|
// Live-Callback aufrufen (kein API-Call nötig)
|
||||||
|
onLiveMetricsRef.current?.(metrics)
|
||||||
|
// Query-Cache auch invalidieren damit historische Daten aktuell bleiben
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agentMetrics', agentId] })
|
||||||
|
}, [agentId, queryClient])
|
||||||
|
|
||||||
const { status } = useSignalR({
|
const { status } = useSignalR({
|
||||||
url: HUB_URL,
|
url: HUB_URL,
|
||||||
onConnected: async (connection) => {
|
onConnected: async (connection) => {
|
||||||
connectionRef.current = connection
|
connectionRef.current = connection
|
||||||
|
|
||||||
// Gruppe beitreten
|
|
||||||
try {
|
try {
|
||||||
await connection.invoke('JoinAgentGroup', agentId)
|
await connection.invoke('JoinAgentGroup', agentId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[SignalR] JoinAgentGroup failed:', err)
|
console.warn('[SignalR] JoinAgentGroup failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.on('AgentMetricsUpdated', (id: string) => {
|
connection.on('AgentMetricsUpdated', handleMetrics)
|
||||||
if (id === agentId) {
|
|
||||||
// Metriken neu laden
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agentMetrics', agentId] })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
connection.on('CommandResultUpdated', (_taskId: string, id: string) => {
|
connection.on('CommandResultUpdated', (_taskId: string, id: string) => {
|
||||||
if (id === agentId) {
|
if (id === agentId) {
|
||||||
// Tasks neu laden
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agentTasks', agentId] })
|
queryClient.invalidateQueries({ queryKey: ['agentTasks', agentId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent', agentId] })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -73,7 +76,6 @@ export function useAgentSignalR(agentId: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Gruppe verlassen wenn Komponente unmountet
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
connectionRef.current?.invoke('LeaveAgentGroup', agentId).catch(() => {})
|
connectionRef.current?.invoke('LeaveAgentGroup', agentId).catch(() => {})
|
||||||
|
|||||||
@@ -33,4 +33,9 @@
|
|||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
/* Dropdown-Optionen im Dark Mode sichtbar machen */
|
||||||
|
select option {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,13 +15,11 @@ export default function SoftwarePage() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Fetch packages
|
|
||||||
const { data: packages = [], isLoading } = useQuery({
|
const { data: packages = [], isLoading } = useQuery({
|
||||||
queryKey: ['software-packages', filterOs],
|
queryKey: ['software-packages', filterOs],
|
||||||
queryFn: () => softwarePackagesApi.list(filterOs === 'All' ? undefined : filterOs),
|
queryFn: () => softwarePackagesApi.list(filterOs === 'All' ? undefined : filterOs),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateSoftwarePackageRequest) => softwarePackagesApi.create(data),
|
mutationFn: (data: CreateSoftwarePackageRequest) => softwarePackagesApi.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -96,26 +94,25 @@ export default function SoftwarePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-8">
|
<div className="p-6 space-y-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Package className="text-blue-600" size={28} />
|
<Package className="text-primary" size={24} />
|
||||||
<h1 className="text-3xl font-bold">Software-Verwaltung</h1>
|
<h1 className="text-3xl font-bold text-foreground">Software-Verwaltung</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">Verwalte Software-Pakete und deploye sie auf Agenten</p>
|
<p className="text-muted-foreground">Verwalte Software-Pakete und deploye sie auf Agenten</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-4 mb-6 border-b">
|
<div className="flex gap-2 border-b border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('catalog')}
|
onClick={() => setActiveTab('catalog')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 font-medium text-sm border-b-2 transition-colors',
|
'px-4 py-2 font-medium text-sm border-b-2 transition-colors',
|
||||||
activeTab === 'catalog'
|
activeTab === 'catalog'
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Katalog
|
Katalog
|
||||||
@@ -125,8 +122,8 @@ export default function SoftwarePage() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 font-medium text-sm border-b-2 transition-colors',
|
'px-4 py-2 font-medium text-sm border-b-2 transition-colors',
|
||||||
activeTab === 'deploy'
|
activeTab === 'deploy'
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Deployen
|
Deployen
|
||||||
@@ -135,27 +132,30 @@ export default function SoftwarePage() {
|
|||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{successMessage && (
|
{successMessage && (
|
||||||
<div className="mb-4 p-3 bg-green-100 text-green-800 rounded-lg">{successMessage}</div>
|
<div className="p-3 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg">
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded-lg">{errorMessage}</div>
|
<div className="p-3 bg-red-500/20 text-red-400 border border-red-500/30 rounded-lg">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Catalog Tab */}
|
||||||
{activeTab === 'catalog' && (
|
{activeTab === 'catalog' && (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
{/* Filter and Create Button */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(['All', 'Windows', 'Linux'] as const).map((os) => (
|
{(['All', 'Windows', 'Linux'] as const).map((os) => (
|
||||||
<button
|
<button
|
||||||
key={os}
|
key={os}
|
||||||
onClick={() => setFilterOs(os)}
|
onClick={() => setFilterOs(os)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
'px-4 py-2 rounded-lg font-medium text-sm transition-colors',
|
||||||
filterOs === os
|
filterOs === os
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
|
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{os}
|
{os}
|
||||||
@@ -164,66 +164,64 @@ export default function SoftwarePage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenCreate}
|
onClick={handleOpenCreate}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Neues Paket
|
Neues Paket
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
<div className="text-center py-8 text-muted-foreground">Laden...</div>
|
||||||
) : packages.length === 0 ? (
|
) : packages.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
Keine Pakete {filterOs !== 'All' && `für ${filterOs}`} vorhanden
|
Keine Pakete {filterOs !== 'All' && `für ${filterOs}`} vorhanden
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<table className="w-full">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-gray-100 border-b">
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted border-b border-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Name</th>
|
<th className="px-6 py-3 text-left font-semibold text-foreground">Name</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Version</th>
|
<th className="px-6 py-3 text-left font-semibold text-foreground">Version</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">OS</th>
|
<th className="px-6 py-3 text-left font-semibold text-foreground">OS</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">
|
<th className="px-6 py-3 text-left font-semibold text-foreground">Paketmanager</th>
|
||||||
Paketmanager
|
<th className="px-6 py-3 text-left font-semibold text-foreground">Paketname</th>
|
||||||
</th>
|
<th className="px-6 py-3 text-right font-semibold text-foreground">Aktionen</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Paketname</th>
|
|
||||||
<th className="px-6 py-3 text-right text-sm font-semibold text-gray-900">Aktionen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-border">
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
<tr key={pkg.id} className="border-b hover:bg-gray-50 transition-colors">
|
<tr key={pkg.id} className="hover:bg-muted/50 transition-colors">
|
||||||
<td className="px-6 py-4 font-medium text-gray-900">{pkg.name}</td>
|
<td className="px-6 py-4 font-medium text-foreground">{pkg.name}</td>
|
||||||
<td className="px-6 py-4 text-gray-600">{pkg.version}</td>
|
<td className="px-6 py-4 text-muted-foreground">{pkg.version}</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-1 rounded-full text-sm font-medium text-white',
|
'px-3 py-1 rounded-full text-xs font-medium text-primary-foreground',
|
||||||
pkg.osType === 'Windows' ? 'bg-blue-600' : 'bg-green-600'
|
pkg.osType === 'Windows' ? 'bg-blue-600' : 'bg-green-600'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pkg.osType}
|
{pkg.osType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-gray-600">{pkg.packageManager}</td>
|
<td className="px-6 py-4 text-muted-foreground">{pkg.packageManager}</td>
|
||||||
<td className="px-6 py-4 text-gray-600 font-mono text-sm">{pkg.packageName}</td>
|
<td className="px-6 py-4 text-muted-foreground font-mono text-xs">{pkg.packageName}</td>
|
||||||
<td className="px-6 py-4 flex justify-end gap-3">
|
<td className="px-6 py-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(pkg)}
|
onClick={() => handleEdit(pkg)}
|
||||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
className="p-2 text-primary hover:bg-primary/10 rounded transition-colors"
|
||||||
title="Bearbeiten"
|
title="Bearbeiten"
|
||||||
>
|
>
|
||||||
<Edit size={18} />
|
<Edit size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(pkg.id, pkg.name)}
|
onClick={() => handleDelete(pkg.id, pkg.name)}
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded transition-colors"
|
className="p-2 text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -231,12 +229,12 @@ export default function SoftwarePage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'deploy' && <DeployTab packages={packages} deployMutation={deployMutation} />}
|
{activeTab === 'deploy' && <DeployTab packages={packages} deployMutation={deployMutation} />}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{isCreateModalOpen && (
|
{isCreateModalOpen && (
|
||||||
@@ -316,76 +314,72 @@ function CreatePackageModal({
|
|||||||
availableManagers.includes(formData.packageManager as PackageManager)
|
availableManagers.includes(formData.packageManager as PackageManager)
|
||||||
? (formData.packageManager as PackageManager)
|
? (formData.packageManager as PackageManager)
|
||||||
: (availableManagers[0] as PackageManager)
|
: (availableManagers[0] as PackageManager)
|
||||||
|
setFormData((prev) => ({ ...prev, osType: newOs, packageManager: newManager }))
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
osType: newOs,
|
|
||||||
packageManager: newManager,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary'
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white">
|
<div className="flex items-center justify-between p-6 border-b border-border sticky top-0 bg-card">
|
||||||
<h2 className="text-xl font-bold">
|
<h2 className="text-xl font-bold text-foreground">
|
||||||
{editingPackage ? 'Paket bearbeiten' : 'Neues Paket'}
|
{editingPackage ? 'Paket bearbeiten' : 'Neues Paket'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
<X size={24} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Name *</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => 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"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Version *</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Version *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="z.B. 1.0.0"
|
placeholder="z.B. 1.0.0"
|
||||||
value={formData.version}
|
value={formData.version}
|
||||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
onChange={(e) => 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"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OS */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Betriebssystem *</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Betriebssystem *</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.osType}
|
value={formData.osType}
|
||||||
onChange={(e) => handleOsChange(e.target.value as OsType)}
|
onChange={(e) => handleOsChange(e.target.value as OsType)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="Windows">Windows</option>
|
<option value="Windows">Windows</option>
|
||||||
<option value="Linux">Linux</option>
|
<option value="Linux">Linux</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Package Manager */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Paketmanager *</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Paketmanager *</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.packageManager}
|
value={formData.packageManager}
|
||||||
onChange={(e) => setFormData({ ...formData, packageManager: e.target.value as PackageManager })}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
setFormData({ ...formData, packageManager: e.target.value as PackageManager })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{getAvailablePackageManagers().map((pm) => (
|
{getAvailablePackageManagers().map((pm) => (
|
||||||
<option key={pm} value={pm}>
|
<option key={pm} value={pm}>
|
||||||
@@ -395,74 +389,71 @@ function CreatePackageModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Package Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Paketname *</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Paketname *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="Name wie im Paketmanager"
|
placeholder="Name wie im Paketmanager"
|
||||||
value={formData.packageName}
|
value={formData.packageName}
|
||||||
onChange={(e) => setFormData({ ...formData, packageName: e.target.value })}
|
onChange={(e) => 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"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installer URL (nur für "direct") */}
|
|
||||||
{formData.packageManager === 'direct' && (
|
{formData.packageManager === 'direct' && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Installer URL</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Installer URL</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
value={formData.installerUrl || ''}
|
value={formData.installerUrl || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, installerUrl: e.target.value || undefined })}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
setFormData({ ...formData, installerUrl: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checksum (nur für "direct") */}
|
|
||||||
{formData.packageManager === 'direct' && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">SHA256-Prüfsumme</label>
|
<label className="block text-sm font-medium text-foreground mb-1">SHA256-Prüfsumme</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="SHA256 hash"
|
placeholder="SHA256 hash"
|
||||||
value={formData.checksum || ''}
|
value={formData.checksum || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, checksum: e.target.value || undefined })}
|
onChange={(e) =>
|
||||||
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"
|
setFormData({ ...formData, checksum: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className={cn(inputClass, 'font-mono text-xs')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Silent Args (nur für "direct") */}
|
|
||||||
{formData.packageManager === 'direct' && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-1">Silent-Argumente</label>
|
<label className="block text-sm font-medium text-foreground mb-1">Silent-Argumente</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="/S, --silent, etc."
|
placeholder="/S, --silent, etc."
|
||||||
value={formData.silentArgs || ''}
|
value={formData.silentArgs || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, silentArgs: e.target.value || undefined })}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
setFormData({ ...formData, silentArgs: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors font-medium"
|
className="flex-1 px-4 py-2 border border-border rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors font-medium text-sm"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
|
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Speichern...' : editingPackage ? 'Aktualisieren' : 'Erstellen'}
|
{isLoading ? 'Speichern...' : editingPackage ? 'Aktualisieren' : 'Erstellen'}
|
||||||
</button>
|
</button>
|
||||||
@@ -475,7 +466,7 @@ function CreatePackageModal({
|
|||||||
|
|
||||||
interface DeployTabProps {
|
interface DeployTabProps {
|
||||||
packages: SoftwarePackage[]
|
packages: SoftwarePackage[]
|
||||||
deployMutation: any
|
deployMutation: ReturnType<typeof useMutation<unknown, Error, { agentId: string; packageId: number; action: 'install' | 'uninstall' }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeployTab({ packages, deployMutation }: DeployTabProps) {
|
function DeployTab({ packages, deployMutation }: DeployTabProps) {
|
||||||
@@ -483,28 +474,26 @@ function DeployTab({ packages, deployMutation }: DeployTabProps) {
|
|||||||
const [agentId, setAgentId] = useState('')
|
const [agentId, setAgentId] = useState('')
|
||||||
const [action, setAction] = useState<'install' | 'uninstall'>('install')
|
const [action, setAction] = useState<'install' | 'uninstall'>('install')
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary'
|
||||||
|
|
||||||
const handleDeploy = (e: React.FormEvent) => {
|
const handleDeploy = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedPackageId || !agentId) return
|
if (!selectedPackageId || !agentId) return
|
||||||
deployMutation.mutate({
|
deployMutation.mutate({ agentId, packageId: selectedPackageId as number, action })
|
||||||
agentId,
|
|
||||||
packageId: selectedPackageId as number,
|
|
||||||
action,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg">
|
<div className="max-w-lg">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
<form onSubmit={handleDeploy} className="space-y-4">
|
<form onSubmit={handleDeploy} className="space-y-4">
|
||||||
{/* Package Selection */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Paket auswählen *</label>
|
<label className="block text-sm font-medium text-foreground mb-2">Paket auswählen *</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={selectedPackageId}
|
value={selectedPackageId}
|
||||||
onChange={(e) => setSelectedPackageId(e.target.value ? Number(e.target.value) : '')}
|
onChange={(e) => setSelectedPackageId(e.target.value ? Number(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"
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="">-- Paket auswählen --</option>
|
<option value="">-- Paket auswählen --</option>
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
@@ -515,65 +504,54 @@ function DeployTab({ packages, deployMutation }: DeployTabProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Agent-ID *</label>
|
<label className="block text-sm font-medium text-foreground mb-2">Agent-ID *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="UUID des Agenten (aus Dashboard kopieren)"
|
placeholder="UUID des Agenten (aus Dashboard kopieren)"
|
||||||
value={agentId}
|
value={agentId}
|
||||||
onChange={(e) => setAgentId(e.target.value)}
|
onChange={(e) => 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"
|
className={cn(inputClass, 'font-mono')}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Finde die Agent-ID im Agent-Dashboard oder Agent-Details
|
Finde die Agent-ID im Agent-Dashboard oder Agent-Details
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2">Aktion *</label>
|
<label className="block text-sm font-medium text-foreground mb-2">Aktion *</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
{(['install', 'uninstall'] as const).map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="action"
|
name="action"
|
||||||
value="install"
|
value={a}
|
||||||
checked={action === 'install'}
|
checked={action === a}
|
||||||
onChange={() => setAction('install')}
|
onChange={() => setAction(a)}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4 accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-900">Installieren</span>
|
<span className="text-foreground text-sm">
|
||||||
</label>
|
{a === 'install' ? 'Installieren' : 'Deinstallieren'}
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
</span>
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="action"
|
|
||||||
value="uninstall"
|
|
||||||
checked={action === 'uninstall'}
|
|
||||||
onChange={() => setAction('uninstall')}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-900">Deinstallieren</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={deployMutation.isPending || !selectedPackageId || !agentId}
|
disabled={deployMutation.isPending || !selectedPackageId || !agentId}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Rocket size={18} />
|
<Rocket size={18} />
|
||||||
{deployMutation.isPending ? 'Wird deployed...' : 'Deploy starten'}
|
{deployMutation.isPending ? 'Wird deployed...' : 'Deploy starten'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Info */}
|
<div className="p-3 bg-primary/10 border border-primary/20 rounded-lg text-sm text-foreground">
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-900">
|
|
||||||
<p className="font-medium mb-1">Hinweis:</p>
|
<p className="font-medium mb-1">Hinweis:</p>
|
||||||
<p>
|
<p className="text-muted-foreground">
|
||||||
Die Task wird erstellt und der Agent führt sie beim nächsten Heartbeat aus (ca. 1-2 Minuten).
|
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.
|
Überwache den Task-Fortschritt im Agent-Dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
118
dev-start.ps1
118
dev-start.ps1
@@ -3,55 +3,50 @@
|
|||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Startet die NexusRMM Entwicklungsumgebung
|
Startet die NexusRMM Entwicklungsumgebung
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Startet Docker (Postgres + MeshCentral), führt DB-Migrationen aus,
|
Startet Docker (Postgres), fuehrt DB-Migrationen aus,
|
||||||
startet Backend und Frontend in separaten Fenstern.
|
startet Backend und Frontend in separaten Fenstern.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Continue"
|
||||||
$Root = "D:\001_Projekte\IT_Tool"
|
$Root = "D:\001_Projekte\IT_Tool"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
function Write-Step { param($msg) Write-Host "" ; Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||||
# Helper-Funktionen
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
function Write-Step { param($msg) Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
|
||||||
function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||||
function Write-Warn { param($msg) Write-Host " [!] $msg" -ForegroundColor Yellow }
|
function Write-Warn { param($msg) Write-Host " [!] $msg" -ForegroundColor Yellow }
|
||||||
function Write-Fail { param($msg) Write-Host " [FEHLER] $msg" -ForegroundColor Red }
|
function Write-Fail { param($msg) Write-Host " [FEHLER] $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Voraussetzungen prüfen
|
# 1. Voraussetzungen pruefen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Step "Prüfe Voraussetzungen..."
|
Write-Step "Pruefe Voraussetzungen..."
|
||||||
|
|
||||||
$missing = $false
|
$missing = $false
|
||||||
|
|
||||||
$tools = @(
|
$tools = @(
|
||||||
@{ Name = "docker"; Desc = "Docker Desktop" },
|
@{ Name = "docker"; Desc = "Docker Desktop" },
|
||||||
@{ Name = "dotnet"; Desc = ".NET SDK" },
|
@{ Name = "dotnet"; Desc = ".NET SDK" },
|
||||||
@{ Name = "node"; Desc = "Node.js" },
|
@{ Name = "node"; Desc = "Node.js" },
|
||||||
@{ Name = "npm"; Desc = "npm" },
|
@{ Name = "npm"; Desc = "npm" }
|
||||||
@{ Name = "go"; Desc = "Go" }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($tool in $tools) {
|
foreach ($tool in $tools) {
|
||||||
$cmd = Get-Command $tool.Name -ErrorAction SilentlyContinue
|
$cmd = Get-Command $tool.Name -ErrorAction SilentlyContinue
|
||||||
if ($null -eq $cmd) {
|
if ($null -eq $cmd) {
|
||||||
Write-Fail "$($tool.Desc) ($($tool.Name)) nicht gefunden. Bitte installieren."
|
Write-Fail "$($tool.Desc) ($($tool.Name)) nicht gefunden."
|
||||||
$missing = $true
|
$missing = $true
|
||||||
} else {
|
} else {
|
||||||
Write-OK "$($tool.Desc): $($cmd.Source)"
|
Write-OK "$($tool.Desc): gefunden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($missing) {
|
if ($missing) {
|
||||||
Write-Fail "Fehlende Voraussetzungen – Abbruch."
|
Write-Fail "Fehlende Voraussetzungen. Bitte installieren und erneut starten."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# dotnet-ef prüfen
|
# dotnet-ef pruefen
|
||||||
$efInstalled = dotnet tool list --global 2>$null | Select-String "dotnet-ef"
|
$efCheck = dotnet tool list --global 2>$null | Select-String "dotnet-ef"
|
||||||
if (-not $efInstalled) {
|
if (-not $efCheck) {
|
||||||
Write-Warn "dotnet-ef nicht global installiert. Installiere jetzt..."
|
Write-Warn "dotnet-ef nicht gefunden. Installiere..."
|
||||||
dotnet tool install --global dotnet-ef
|
dotnet tool install --global dotnet-ef
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Fail "dotnet-ef konnte nicht installiert werden."
|
Write-Fail "dotnet-ef konnte nicht installiert werden."
|
||||||
@@ -67,25 +62,20 @@ if (-not $efInstalled) {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Step "Starte PostgreSQL..."
|
Write-Step "Starte PostgreSQL..."
|
||||||
|
|
||||||
Push-Location $Root
|
Set-Location $Root
|
||||||
try {
|
|
||||||
docker compose up -d nexusrmm-postgres
|
docker compose up -d nexusrmm-postgres
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Fail "docker compose up fehlgeschlagen."
|
Write-Fail "docker compose up fehlgeschlagen. Laeuft Docker Desktop?"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
# Warte bis PostgreSQL bereit ist (max 30 Sekunden, Intervall 2 Sekunden)
|
|
||||||
Write-Host " Warte auf PostgreSQL..." -ForegroundColor Cyan
|
Write-Host " Warte auf PostgreSQL..." -ForegroundColor Cyan
|
||||||
$maxWait = 30
|
$maxWait = 30
|
||||||
$waited = 0
|
$waited = 0
|
||||||
$pgReady = $false
|
$pgReady = $false
|
||||||
|
|
||||||
while ($waited -lt $maxWait) {
|
while ($waited -lt $maxWait) {
|
||||||
$result = docker exec nexusrmm-postgres pg_isready -U nexusrmm -q 2>&1
|
docker exec nexusrmm-postgres pg_isready -U nexusrmm -q 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
$pgReady = $true
|
$pgReady = $true
|
||||||
break
|
break
|
||||||
@@ -101,37 +91,30 @@ if (-not $pgReady) {
|
|||||||
Write-OK "PostgreSQL bereit (Port 5433)."
|
Write-OK "PostgreSQL bereit (Port 5433)."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. EF Core Migrationen ausführen
|
# 3. EF Core Migrationen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Step "Führe Datenbank-Migrationen aus..."
|
Write-Step "Fuehre Datenbank-Migrationen aus..."
|
||||||
|
|
||||||
Push-Location "$Root\Backend"
|
Set-Location "$Root\Backend"
|
||||||
try {
|
|
||||||
# --project: Migration-Projekt (Infrastructure enthält DbContext + Migrations)
|
dotnet ef database update --project "src\NexusRMM.Infrastructure" --startup-project "src\NexusRMM.Api" --no-build 2>&1 | Out-Default
|
||||||
# --startup-project: Startup-Projekt mit Verbindungsstring
|
|
||||||
dotnet ef database update `
|
|
||||||
--project "src\NexusRMM.Infrastructure" `
|
|
||||||
--startup-project "src\NexusRMM.Api" `
|
|
||||||
--no-build
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
# --no-build schlägt fehl wenn noch kein Build vorhanden – Build nachholen
|
Write-Warn "Migration mit --no-build fehlgeschlagen. Baue zuerst..."
|
||||||
Write-Warn "Migration mit --no-build fehlgeschlagen, baue zuerst..."
|
|
||||||
dotnet build "src\NexusRMM.Api" --configuration Debug -q
|
dotnet build "src\NexusRMM.Api" --configuration Debug -q
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Fail "dotnet build fehlgeschlagen."
|
Write-Fail "dotnet build fehlgeschlagen."
|
||||||
|
Set-Location $Root
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
dotnet ef database update `
|
dotnet ef database update --project "src\NexusRMM.Infrastructure" --startup-project "src\NexusRMM.Api"
|
||||||
--project "src\NexusRMM.Infrastructure" `
|
|
||||||
--startup-project "src\NexusRMM.Api"
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Fail "dotnet ef database update fehlgeschlagen."
|
Write-Fail "dotnet ef database update fehlgeschlagen."
|
||||||
|
Set-Location $Root
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
Pop-Location
|
Set-Location $Root
|
||||||
}
|
|
||||||
Write-OK "Migrationen erfolgreich."
|
Write-OK "Migrationen erfolgreich."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -139,20 +122,14 @@ Write-OK "Migrationen erfolgreich."
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Step "Starte Backend..."
|
Write-Step "Starte Backend..."
|
||||||
|
|
||||||
$backendCmd = "Set-Location '$Root\Backend'; Write-Host 'NexusRMM Backend' -ForegroundColor Cyan; dotnet run --project src/NexusRMM.Api"
|
$backendCmd = "Set-Location '$Root\Backend'; Write-Host 'NexusRMM Backend gestartet' -ForegroundColor Cyan; dotnet run --project src/NexusRMM.Api"
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", $backendCmd -WindowStyle Normal
|
||||||
Start-Process powershell -ArgumentList @(
|
Write-OK "Backend-Fenster geoeffnet."
|
||||||
"-NoExit",
|
|
||||||
"-Command",
|
|
||||||
$backendCmd
|
|
||||||
) -WindowStyle Normal
|
|
||||||
|
|
||||||
Write-OK "Backend-Fenster geöffnet."
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Kurz warten damit das Backend starten kann
|
# 5. Kurz warten
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Host "`n Warte 5 Sekunden damit das Backend hochfahren kann..." -ForegroundColor Cyan
|
Write-Host " Warte 5 Sekunden fuer Backend-Start..." -ForegroundColor Cyan
|
||||||
Start-Sleep -Seconds 5
|
Start-Sleep -Seconds 5
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -160,29 +137,24 @@ Start-Sleep -Seconds 5
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Step "Starte Frontend..."
|
Write-Step "Starte Frontend..."
|
||||||
|
|
||||||
$frontendCmd = "Set-Location '$Root\Frontend'; Write-Host 'NexusRMM Frontend' -ForegroundColor Cyan; npm run dev"
|
$frontendCmd = "Set-Location '$Root\Frontend'; Write-Host 'NexusRMM Frontend gestartet' -ForegroundColor Cyan; npm run dev"
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", $frontendCmd -WindowStyle Normal
|
||||||
Start-Process powershell -ArgumentList @(
|
Write-OK "Frontend-Fenster geoeffnet."
|
||||||
"-NoExit",
|
|
||||||
"-Command",
|
|
||||||
$frontendCmd
|
|
||||||
) -WindowStyle Normal
|
|
||||||
|
|
||||||
Write-OK "Frontend-Fenster geöffnet."
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. Zusammenfassung
|
# 7. Zusammenfassung
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "╔══════════════════════════════════════════╗" -ForegroundColor Green
|
Write-Host "+------------------------------------------+" -ForegroundColor Green
|
||||||
Write-Host "║ NexusRMM ist bereit! ║" -ForegroundColor Green
|
Write-Host "| NexusRMM ist bereit! |" -ForegroundColor Green
|
||||||
Write-Host "╠══════════════════════════════════════════╣" -ForegroundColor Green
|
Write-Host "+------------------------------------------+" -ForegroundColor Green
|
||||||
Write-Host "║ Frontend: http://localhost:5173 ║" -ForegroundColor Green
|
Write-Host "| Frontend: http://localhost:5173 |" -ForegroundColor Green
|
||||||
Write-Host "║ Backend: http://localhost:5000 ║" -ForegroundColor Green
|
Write-Host "| Backend: http://localhost:5000 |" -ForegroundColor Green
|
||||||
Write-Host "║ Swagger: http://localhost:5000/swagger ║" -ForegroundColor Green
|
Write-Host "| Swagger: http://localhost:5000/swagger |" -ForegroundColor Green
|
||||||
Write-Host "║ gRPC: http://localhost:5001 ║" -ForegroundColor Green
|
Write-Host "| gRPC: http://localhost:5001 |" -ForegroundColor Green
|
||||||
Write-Host "║ MeshCentral: https://localhost:4430 ║" -ForegroundColor Green
|
Write-Host "| MeshCentral: https://localhost:4430 |" -ForegroundColor Green
|
||||||
Write-Host "╚══════════════════════════════════════════╝" -ForegroundColor Green
|
Write-Host "+------------------------------------------+" -ForegroundColor Green
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Agent starten: .\Agent\nexus-agent.exe" -ForegroundColor Yellow
|
Write-Host "Agent starten: .\Agent\nexus-agent.exe" -ForegroundColor Yellow
|
||||||
Write-Host "Alles stoppen: .\dev-stop.ps1" -ForegroundColor Yellow
|
Write-Host "Alles stoppen: .\dev-stop.ps1" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|||||||
127
install-service.ps1
Normal file
127
install-service.ps1
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Installiert den NexusRMM Agent als Windows Service
|
||||||
|
.DESCRIPTION
|
||||||
|
Baut den Agent, kopiert ihn ins Zielverzeichnis und registriert ihn als Windows Service.
|
||||||
|
Muss als Administrator ausgefuehrt werden.
|
||||||
|
.PARAMETER ServerAddress
|
||||||
|
gRPC-Adresse des NexusRMM Servers (Standard: localhost:5001)
|
||||||
|
.PARAMETER InstallDir
|
||||||
|
Installationsverzeichnis (Standard: C:\Program Files\NexusRMM)
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ServerAddress = "localhost:5001",
|
||||||
|
[string]$InstallDir = "C:\Program Files\NexusRMM"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
$Root = $PSScriptRoot
|
||||||
|
|
||||||
|
function Write-Step { param($msg) Write-Host "" ; Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||||
|
function Write-Fail { param($msg) Write-Host " [FEHLER] $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Agent bauen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Step "Baue NexusRMM Agent..."
|
||||||
|
|
||||||
|
Set-Location "$Root\Agent"
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
go build -ldflags "-s -w -X main.version=1.0.0" -o nexus-agent.exe ./cmd/agent
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "go build fehlgeschlagen."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-OK "nexus-agent.exe erstellt."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Installationsverzeichnis anlegen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Step "Lege Installationsverzeichnis an: $InstallDir"
|
||||||
|
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
Write-OK "Verzeichnis bereit."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Dateien kopieren
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Step "Kopiere Dateien..."
|
||||||
|
|
||||||
|
Copy-Item "$Root\Agent\nexus-agent.exe" "$InstallDir\nexus-agent.exe" -Force
|
||||||
|
|
||||||
|
# Konfigurationsdatei anlegen falls nicht vorhanden
|
||||||
|
$configPath = "$InstallDir\nexus-agent.yaml"
|
||||||
|
if (-not (Test-Path $configPath)) {
|
||||||
|
@"
|
||||||
|
serverAddress: $ServerAddress
|
||||||
|
agentId: ""
|
||||||
|
heartbeatInterval: 30
|
||||||
|
meshEnabled: false
|
||||||
|
meshCentralUrl: ""
|
||||||
|
"@ | Out-File -FilePath $configPath -Encoding utf8
|
||||||
|
Write-OK "nexus-agent.yaml erstellt (serverAddress: $ServerAddress)."
|
||||||
|
} else {
|
||||||
|
Write-OK "nexus-agent.yaml bereits vorhanden, wird nicht ueberschrieben."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Bestehenden Service stoppen/deinstallieren falls vorhanden
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$exePath = "$InstallDir\nexus-agent.exe"
|
||||||
|
|
||||||
|
$svcCheck = Get-Service -Name "NexusRMMAgent" -ErrorAction SilentlyContinue
|
||||||
|
if ($svcCheck) {
|
||||||
|
Write-Step "Entferne vorhandenen Service..."
|
||||||
|
& $exePath stop 2>$null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
& $exePath uninstall
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Service-Deinstallation fehlgeschlagen."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-OK "Alter Service entfernt."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Service installieren und starten
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Step "Installiere Service..."
|
||||||
|
|
||||||
|
& $exePath install
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Service-Installation fehlgeschlagen."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-OK "Service installiert."
|
||||||
|
|
||||||
|
Write-Step "Starte Service..."
|
||||||
|
& $exePath start
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Service konnte nicht gestartet werden."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-OK "Service gestartet."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zusammenfassung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "+--------------------------------------------------+" -ForegroundColor Green
|
||||||
|
Write-Host "| NexusRMM Agent als Service installiert! |" -ForegroundColor Green
|
||||||
|
Write-Host "+--------------------------------------------------+" -ForegroundColor Green
|
||||||
|
Write-Host "| Installiert in: $InstallDir" -ForegroundColor Green
|
||||||
|
Write-Host "| Server: $ServerAddress" -ForegroundColor Green
|
||||||
|
Write-Host "| Service-Name: NexusRMMAgent" -ForegroundColor Green
|
||||||
|
Write-Host "+--------------------------------------------------+" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Verwalten:" -ForegroundColor Yellow
|
||||||
|
Write-Host " Start: & '$exePath' start" -ForegroundColor Yellow
|
||||||
|
Write-Host " Stop: & '$exePath' stop" -ForegroundColor Yellow
|
||||||
|
Write-Host " Entfernen: & '$exePath' uninstall" -ForegroundColor Yellow
|
||||||
|
Write-Host " Oder via: services.msc -> NexusRMMAgent" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
Reference in New Issue
Block a user