feat: implement gRPC AgentService, Program.cs with Kestrel HTTP/2 config

This commit is contained in:
Claude Agent
2026-03-19 11:35:04 +01:00
parent fe32c9cd88
commit 5c03c18ac7
7 changed files with 317 additions and 6 deletions

View File

@@ -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<IActionResult> 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<IActionResult> GetById(Guid id)
{
var agent = await _db.Agents.FindAsync(id);
return agent is null ? NotFound() : Ok(agent);
}
[HttpGet("{id:guid}/metrics")]
public async Task<IActionResult> 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);
}
}

View File

@@ -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<IActionResult> 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<IActionResult> GetById(Guid id)
{
var task = await _db.Tasks.FindAsync(id);
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);

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);

View File

@@ -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<AgentGrpcService> _logger;
public AgentGrpcService(RmmDbContext db, ILogger<AgentGrpcService> logger)
{
_db = db;
_logger = logger;
}
public override async Task<EnrollResponse> 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<HeartbeatResponse> 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<CommandResultResponse> 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
};
}

View File

@@ -17,6 +17,9 @@
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.5.0" />
</ItemGroup>
</Project>

View File

@@ -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<RmmDbContext>(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<string[]>() ?? ["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<AgentGrpcService>();
app.MapControllers();
app.Run();

View File

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