From 5c03c18ac7954507076132e3a8cd3fadb098cb0e Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 19 Mar 2026 11:35:04 +0100 Subject: [PATCH] feat: implement gRPC AgentService, Program.cs with Kestrel HTTP/2 config --- .../Controllers/AgentsController.cs | 43 +++++++ .../Controllers/TasksController.cs | 51 ++++++++ .../Controllers/TicketsController.cs | 64 ++++++++++ .../GrpcServices/AgentGrpcService.cs | 117 ++++++++++++++++++ Backend/src/NexusRMM.Api/NexusRMM.Api.csproj | 3 + Backend/src/NexusRMM.Api/Program.cs | 39 +++++- Backend/src/NexusRMM.Api/appsettings.json | 6 + 7 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 Backend/src/NexusRMM.Api/Controllers/AgentsController.cs create mode 100644 Backend/src/NexusRMM.Api/Controllers/TasksController.cs create mode 100644 Backend/src/NexusRMM.Api/Controllers/TicketsController.cs create mode 100644 Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs diff --git a/Backend/src/NexusRMM.Api/Controllers/AgentsController.cs b/Backend/src/NexusRMM.Api/Controllers/AgentsController.cs new file mode 100644 index 0000000..b776da6 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/AgentsController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class AgentsController : ControllerBase +{ + private readonly RmmDbContext _db; + public AgentsController(RmmDbContext db) => _db = db; + + [HttpGet] + public async Task GetAll() + { + var agents = await _db.Agents + .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 }) + .ToListAsync(); + return Ok(agents); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var agent = await _db.Agents.FindAsync(id); + return agent is null ? NotFound() : Ok(agent); + } + + [HttpGet("{id:guid}/metrics")] + public async Task GetMetrics(Guid id, [FromQuery] int hours = 24) + { + var since = DateTime.UtcNow.AddHours(-hours); + var metrics = await _db.AgentMetrics + .Where(m => m.AgentId == id && m.Timestamp >= since) + .OrderByDescending(m => m.Timestamp) + .Take(1000) + .Select(m => new { m.Timestamp, m.Metrics }) + .ToListAsync(); + return Ok(metrics); + } +} diff --git a/Backend/src/NexusRMM.Api/Controllers/TasksController.cs b/Backend/src/NexusRMM.Api/Controllers/TasksController.cs new file mode 100644 index 0000000..c5280e7 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/TasksController.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class TasksController : ControllerBase +{ + private readonly RmmDbContext _db; + public TasksController(RmmDbContext db) => _db = db; + + [HttpPost] + public async Task Create([FromBody] CreateTaskRequest request) + { + var task = new TaskItem + { + Id = Guid.NewGuid(), + AgentId = request.AgentId, + Type = request.Type, + Payload = request.Payload is not null ? JsonSerializer.SerializeToElement(request.Payload) : null, + CreatedAt = DateTime.UtcNow + }; + _db.Tasks.Add(task); + await _db.SaveChangesAsync(); + return CreatedAtAction(nameof(GetById), new { id = task.Id }, task); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var task = await _db.Tasks.FindAsync(id); + return task is null ? NotFound() : Ok(task); + } + + [HttpGet("agent/{agentId:guid}")] + public async Task 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); diff --git a/Backend/src/NexusRMM.Api/Controllers/TicketsController.cs b/Backend/src/NexusRMM.Api/Controllers/TicketsController.cs new file mode 100644 index 0000000..560f114 --- /dev/null +++ b/Backend/src/NexusRMM.Api/Controllers/TicketsController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; + +namespace NexusRMM.Api.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class TicketsController : ControllerBase +{ + private readonly RmmDbContext _db; + public TicketsController(RmmDbContext db) => _db = db; + + [HttpGet] + public async Task GetAll([FromQuery] TicketStatus? status = null) + { + var query = _db.Tickets.AsQueryable(); + if (status.HasValue) query = query.Where(t => t.Status == status.Value); + var tickets = await query.OrderByDescending(t => t.CreatedAt).Take(100).ToListAsync(); + return Ok(tickets); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var ticket = await _db.Tickets.Include(t => t.Agent).FirstOrDefaultAsync(t => t.Id == id); + return ticket is null ? NotFound() : Ok(ticket); + } + + [HttpPost] + public async Task Create([FromBody] CreateTicketRequest request) + { + var ticket = new Ticket + { + Title = request.Title, + Description = request.Description, + Priority = request.Priority, + AgentId = request.AgentId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _db.Tickets.Add(ticket); + await _db.SaveChangesAsync(); + return CreatedAtAction(nameof(GetById), new { id = ticket.Id }, ticket); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateTicketRequest request) + { + var ticket = await _db.Tickets.FindAsync(id); + if (ticket is null) return NotFound(); + if (request.Status.HasValue) ticket.Status = request.Status.Value; + if (request.Priority.HasValue) ticket.Priority = request.Priority.Value; + if (request.Title is not null) ticket.Title = request.Title; + if (request.Description is not null) ticket.Description = request.Description; + ticket.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return Ok(ticket); + } +} + +public record CreateTicketRequest(string Title, string Description, TicketPriority Priority, Guid? AgentId); +public record UpdateTicketRequest(string? Title, string? Description, TicketStatus? Status, TicketPriority? Priority); diff --git a/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs new file mode 100644 index 0000000..cbfa014 --- /dev/null +++ b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Core.Models; +using NexusRMM.Infrastructure.Data; +using NexusRMM.Protos; +using AgentModel = NexusRMM.Core.Models.Agent; +using CoreTaskStatus = NexusRMM.Core.Models.TaskStatus; + +namespace NexusRMM.Api.GrpcServices; + +public class AgentGrpcService : AgentService.AgentServiceBase +{ + private readonly RmmDbContext _db; + private readonly ILogger _logger; + + public AgentGrpcService(RmmDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public override async Task Enroll(EnrollRequest request, ServerCallContext context) + { + var agent = new AgentModel + { + Id = Guid.NewGuid(), + Hostname = request.Hostname, + OsType = request.OsType.ToLower() == "windows" ? OsType.Windows : OsType.Linux, + OsVersion = request.OsVersion, + IpAddress = request.IpAddress, + MacAddress = request.MacAddress, + AgentVersion = request.AgentVersion, + Status = AgentStatus.Online, + LastSeen = DateTime.UtcNow, + EnrolledAt = DateTime.UtcNow + }; + + _db.Agents.Add(agent); + await _db.SaveChangesAsync(); + _logger.LogInformation("Agent enrolled: {AgentId} ({Hostname})", agent.Id, agent.Hostname); + + return new EnrollResponse + { + AgentId = agent.Id.ToString(), + HeartbeatInterval = 60 + }; + } + + public override async Task Heartbeat(HeartbeatRequest request, ServerCallContext context) + { + var agentId = Guid.Parse(request.AgentId); + var agent = await _db.Agents.FindAsync(agentId) + ?? throw new RpcException(new Status(StatusCode.NotFound, "Agent not found")); + + agent.LastSeen = DateTime.UtcNow; + agent.Status = AgentStatus.Online; + + _db.AgentMetrics.Add(new AgentMetric + { + AgentId = agentId, + Timestamp = DateTime.UtcNow, + Metrics = JsonSerializer.SerializeToElement(request.Metrics) + }); + + var pendingTasks = await _db.Tasks + .Where(t => t.AgentId == agentId && t.Status == CoreTaskStatus.Pending) + .OrderBy(t => t.CreatedAt) + .Take(10) + .ToListAsync(); + + var response = new HeartbeatResponse(); + foreach (var task in pendingTasks) + { + task.Status = CoreTaskStatus.InProgress; + response.PendingCommands.Add(new AgentCommand + { + CommandId = task.Id.ToString(), + Type = MapTaskType(task.Type), + Payload = task.Payload?.GetRawText() ?? "{}" + }); + } + + await _db.SaveChangesAsync(); + return response; + } + + public override async Task ReportCommandResult(CommandResult request, ServerCallContext context) + { + var taskId = Guid.Parse(request.CommandId); + var task = await _db.Tasks.FindAsync(taskId) + ?? throw new RpcException(new Status(StatusCode.NotFound, "Task not found")); + + task.Status = request.Success ? CoreTaskStatus.Completed : CoreTaskStatus.Failed; + task.CompletedAt = DateTime.UtcNow; + task.Result = JsonSerializer.SerializeToElement(new + { + request.ExitCode, + request.Stdout, + request.Stderr, + request.Success + }); + + await _db.SaveChangesAsync(); + return new CommandResultResponse(); + } + + private static CommandType MapTaskType(NexusRMM.Core.Models.TaskType type) => type switch + { + NexusRMM.Core.Models.TaskType.Shell => CommandType.Shell, + NexusRMM.Core.Models.TaskType.InstallSoftware => CommandType.InstallSoftware, + NexusRMM.Core.Models.TaskType.UninstallSoftware => CommandType.UninstallSoftware, + NexusRMM.Core.Models.TaskType.UpdateAgent => CommandType.UpdateAgent, + NexusRMM.Core.Models.TaskType.NetworkScan => CommandType.NetworkScan, + _ => CommandType.Unspecified + }; +} diff --git a/Backend/src/NexusRMM.Api/NexusRMM.Api.csproj b/Backend/src/NexusRMM.Api/NexusRMM.Api.csproj index 315a627..04244d5 100644 --- a/Backend/src/NexusRMM.Api/NexusRMM.Api.csproj +++ b/Backend/src/NexusRMM.Api/NexusRMM.Api.csproj @@ -17,6 +17,9 @@ + + + diff --git a/Backend/src/NexusRMM.Api/Program.cs b/Backend/src/NexusRMM.Api/Program.cs index 277afe3..3f4e328 100644 --- a/Backend/src/NexusRMM.Api/Program.cs +++ b/Backend/src/NexusRMM.Api/Program.cs @@ -1,20 +1,47 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.EntityFrameworkCore; +using NexusRMM.Api.GrpcServices; +using NexusRMM.Infrastructure.Data; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +// Kestrel: Port 5000 für REST+SignalR (HTTP/1.1+2), Port 5001 für gRPC (HTTP/2) +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenAnyIP(5000, o => o.Protocols = HttpProtocols.Http1AndHttp2); + options.ListenAnyIP(5001, o => o.Protocols = HttpProtocols.Http2); +}); +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddGrpc(); +builder.Services.AddSignalR(); builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get() ?? ["http://localhost:5173"]) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { + app.UseSwagger(); + app.UseSwaggerUI(); } -app.UseHttpsRedirection(); - -app.UseAuthorization(); - +app.UseCors(); +app.MapGrpcService(); app.MapControllers(); app.Run(); diff --git a/Backend/src/NexusRMM.Api/appsettings.json b/Backend/src/NexusRMM.Api/appsettings.json index 10f68b8..b0f7a70 100644 --- a/Backend/src/NexusRMM.Api/appsettings.json +++ b/Backend/src/NexusRMM.Api/appsettings.json @@ -1,4 +1,10 @@ { + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=nexusrmm;Username=nexusrmm;Password=nexusrmm_dev" + }, + "Cors": { + "Origins": ["http://localhost:5173"] + }, "Logging": { "LogLevel": { "Default": "Information",