feat: Phase 9 — Offline Detection, API Key Auth, Agent Self-Update

Offline Detection (9.1):
- AgentOfflineDetectorService: BackgroundService, prüft alle 60s
  ob Agents seit >5 min kein Heartbeat hatten → Status=Offline
- IServiceScopeFactory für korrektes Scoped-DI im Singleton
- SignalR-Push AgentStatusChanged bei jeder Offline-Markierung

API Key Auth (9.2):
- ApiKeyMiddleware: prüft X-Api-Key Header gegen Security:ApiKey Config
- Deaktiviert wenn ApiKey leer (Dev-Modus), Swagger/hubs bypassed
- Frontend: getApiKey() aus localStorage, automatisch in allen Requests
- Settings-Modal in Sidebar: API-Key eingeben + maskiert anzeigen

Agent Self-Update (9.3):
- internal/updater/updater.go: CheckForUpdate() + Update()
  Download, SHA256-Verify, Windows Batch-Neustart / Linux Shell-Neustart
- AgentReleasesController: GET /api/v1/agent/releases/latest,
  GET /api/v1/agent/releases/download/{platform}
- AgentReleaseOptions: LatestVersion, ReleasePath, Checksum in appsettings
- executeCommand() erhält cfg *Config statt agentID string
  (für ServerAddress-Ableitung im UpdateAgent-Case)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-19 15:41:24 +01:00
parent e55640d6a7
commit c401ea8f29
11 changed files with 484 additions and 11 deletions

View File

@@ -19,6 +19,7 @@ import (
"nexusrmm.local/agent/internal/executor"
"nexusrmm.local/agent/internal/meshagent"
"nexusrmm.local/agent/internal/scanner"
"nexusrmm.local/agent/internal/updater"
pb "nexusrmm.local/agent/pkg/proto"
)
@@ -142,11 +143,12 @@ func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config
for _, cmd := range resp.PendingCommands {
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
switch cmd.Type {
case pb.CommandType_COMMAND_TYPE_SHELL:
@@ -170,6 +172,28 @@ func executeCommand(ctx context.Context, client *connection.GrpcClient, agentID
Success: true,
}
}
case pb.CommandType_COMMAND_TYPE_UPDATE_AGENT:
// Payload optional: {"serverAddress": "localhost:5000"}
var params struct {
ServerAddress string `json:"serverAddress"`
}
_ = json.Unmarshal([]byte(cmd.Payload), &params)
if params.ServerAddress == "" {
// Aus gRPC-Adresse REST-Adresse ableiten (Port 5000 statt 5001)
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:
result = &executor.Result{ExitCode: -1, Stderr: fmt.Sprintf("unknown command type: %v", cmd.Type)}
}