Add authentication and improve server side configuration

This commit is contained in:
Stone_Red
2025-12-23 14:53:44 +01:00
parent 5819432c36
commit 55f26b3a92
14 changed files with 292 additions and 34 deletions
@@ -0,0 +1,8 @@
namespace RemoteExec.Server.Configuration;
public class ApiKeyConfiguration
{
public required string Key { get; set; }
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
}
@@ -0,0 +1,6 @@
namespace RemoteExec.Server.Configuration;
public class AuthenticationConfiguration
{
public List<ApiKeyConfiguration> ApiKeys { get; set; } = [];
}
@@ -0,0 +1,14 @@
namespace RemoteExec.Server.Configuration;
public class ExecutionConfiguration
{
/// <summary>
/// Maximum number of concurrent tasks. Default is ProcessorCount * 2.
/// </summary>
public int? MaxConcurrentTasks { get; set; }
/// <summary>
/// Timeout for assembly loading requests in seconds. Default is 30 seconds.
/// </summary>
public int AssemblyLoadTimeoutSeconds { get; set; } = 30;
}
@@ -0,0 +1,19 @@
namespace RemoteExec.Server.Configuration;
public class MetricsConfiguration
{
/// <summary>
/// Minimum CPU usage difference (in percentage points) to trigger a metrics broadcast. Default is 1.0.
/// </summary>
public double CpuDifferenceThreshold { get; set; } = 1.0;
/// <summary>
/// Minimum memory usage difference (in bytes) to trigger a metrics broadcast. Default is 10 MB.
/// </summary>
public long MemoryDifferenceThreshold { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Broadcast interval in milliseconds. Default is 500 ms.
/// </summary>
public int BroadcastIntervalMs { get; set; } = 500;
}
+1 -1
View File
@@ -6,5 +6,5 @@
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "<Pending>", Scope = "type", Target = "~T:RemoteExec.Server.Hubs.RemoteExecutionHub")]
[assembly: SuppressMessage("Major Code Smell", "S2139:Exceptions should be either logged or rethrown but not both", Justification = "<Pending>", Scope = "member", Target = "~M:RemoteExec.Server.Hubs.RemoteExecutionHub.RequestAssemblyAsync(System.String)~System.Threading.Tasks.Task{System.Reflection.Assembly}")]
[assembly: SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "<Pending>", Scope = "member", Target = "~M:RemoteExec.Server.Hubs.RemoteExecutionHub.Execute(RemoteExec.Shared.RemoteExecutionRequest)~System.Threading.Tasks.Task{RemoteExec.Shared.RemoteExecutionResult}")]
[assembly: SuppressMessage("Major Code Smell", "S3010:Static fields should not be updated in constructors", Justification = "<Pending>")]
+28 -5
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using RemoteExec.Server.Configuration;
using RemoteExec.Shared;
using System.Collections.Concurrent;
@@ -9,7 +11,7 @@ using System.Text.Json;
namespace RemoteExec.Server.Hubs;
public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
public class RemoteExecutionHub : Hub
{
private static readonly ConcurrentDictionary<string, RemoteJobAssemblyLoadContext> connections = new();
@@ -23,8 +25,29 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
private static TimeSpan lastTotalProcessorTime;
private static int activeTasks = 0;
private static readonly int maxConcurrentTasks = Environment.ProcessorCount * 2;
private static readonly SemaphoreSlim taskSemaphore = new SemaphoreSlim(maxConcurrentTasks, maxConcurrentTasks);
private static int maxConcurrentTasks;
private static SemaphoreSlim taskSemaphore = null!;
private static int assemblyLoadTimeoutSeconds;
private static double cpuDifferenceThreshold;
private static long memoryDifferenceThreshold;
private readonly ILogger<RemoteExecutionHub> logger;
public RemoteExecutionHub(ILogger<RemoteExecutionHub> logger, IOptions<ExecutionConfiguration> executionOptions, IOptions<MetricsConfiguration> metricsOptions)
{
this.logger = logger;
// Initialize static configuration values once
if (taskSemaphore is null)
{
maxConcurrentTasks = executionOptions.Value.MaxConcurrentTasks ?? (Environment.ProcessorCount * 2);
taskSemaphore = new SemaphoreSlim(maxConcurrentTasks, maxConcurrentTasks);
assemblyLoadTimeoutSeconds = executionOptions.Value.AssemblyLoadTimeoutSeconds;
cpuDifferenceThreshold = metricsOptions.Value.CpuDifferenceThreshold;
memoryDifferenceThreshold = metricsOptions.Value.MemoryDifferenceThreshold;
}
}
public override Task OnConnectedAsync()
{
@@ -234,7 +257,7 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
int tasksDiff = Math.Abs(metrics.ActiveTasks - lastMetrics.ActiveTasks);
int maxTasksDiff = Math.Abs(metrics.MaxConcurrentTasks - lastMetrics.MaxConcurrentTasks);
if (cpuDiff < 1.0 && memoryDiff < 10 * 1024 * 1024 && connectionsDiff == 0 && tasksDiff == 0 && maxTasksDiff == 0)
if (cpuDiff < cpuDifferenceThreshold && memoryDiff < memoryDifferenceThreshold && connectionsDiff == 0 && tasksDiff == 0 && maxTasksDiff == 0)
{
return; // No significant change
}
@@ -296,7 +319,7 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
await Clients.Caller.SendAsync("RequestAssembly", key, guid);
byte[] assemblyBytes = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30));
byte[] assemblyBytes = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(assemblyLoadTimeoutSeconds));
using MemoryStream ms = new MemoryStream(assemblyBytes);
return assemblyLoadContext.LoadFromStream(ms);
@@ -0,0 +1,123 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using RemoteExec.Server.Configuration;
using System.Collections.Concurrent;
namespace RemoteExec.Server.Middleware;
public class ApiKeyAuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ApiKeyAuthenticationMiddleware> _logger;
private readonly ConcurrentDictionary<string, ApiKeyConfiguration> _apiKeys;
public ApiKeyAuthenticationMiddleware(
RequestDelegate next,
IOptionsMonitor<AuthenticationConfiguration> authOptions,
ILogger<ApiKeyAuthenticationMiddleware> logger)
{
_next = next;
_logger = logger;
// Build lookup dictionary from configuration
_apiKeys = new ConcurrentDictionary<string, ApiKeyConfiguration>();
// Initial load
LoadApiKeys(authOptions.CurrentValue);
// Watch for configuration changes
_ = authOptions.OnChange(LoadApiKeys);
}
private void LoadApiKeys(AuthenticationConfiguration config)
{
_apiKeys.Clear();
if (config.ApiKeys == null || config.ApiKeys.Count == 0)
{
_logger.LogWarning("No API keys configured. All requests will be rejected.");
return;
}
foreach (ApiKeyConfiguration apiKey in config.ApiKeys.Where(k => k.Enabled))
{
if (string.IsNullOrWhiteSpace(apiKey.Key))
{
_logger.LogWarning("Skipping empty API key configuration");
continue;
}
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
{
_logger.LogInformation(
"Registered API key: {Description}",
apiKey.Description ?? "No description");
}
else
{
_logger.LogWarning(
"Duplicate API key found and skipped: {Description}",
apiKey.Description ?? "No description");
}
}
_logger.LogInformation("Loaded {Count} active API keys", _apiKeys.Count);
}
public async Task InvokeAsync(HttpContext context)
{
// Skip authentication for health checks
if (context.Request.Path.StartsWithSegments("/health"))
{
await _next(context);
return;
}
// Check if any API keys are configured
if (_apiKeys.IsEmpty)
{
_logger.LogError("No API keys configured. Rejecting request to {Path}", context.Request.Path);
context.Response.StatusCode = 503;
await context.Response.WriteAsync("Service is not properly configured");
return;
}
// Check for API key in header
if (!context.Request.Headers.TryGetValue("X-API-Key", out StringValues extractedApiKey))
{
_logger.LogWarning("API Key missing from request to {Path} from {RemoteIp}", context.Request.Path, context.Connection.RemoteIpAddress);
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API Key is missing");
return;
}
string providedKey = extractedApiKey.ToString();
// Validate API key
if (!_apiKeys.TryGetValue(providedKey, out ApiKeyConfiguration? apiKeyConfig))
{
_logger.LogWarning("Invalid API Key provided for request to {Path} from {RemoteIp}",
context.Request.Path,
context.Connection.RemoteIpAddress);
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Invalid API Key");
return;
}
// Store API key info in HttpContext for potential use in controllers
context.Items["ApiKeyDescription"] = apiKeyConfig.Description;
context.Items["ApiKey"] = providedKey;
_logger.LogDebug("Authenticated request to {Path} using key: {Description}",
context.Request.Path,
apiKeyConfig.Description ?? "No description");
await _next(context);
}
}
+9 -3
View File
@@ -1,20 +1,26 @@
using RemoteExec.Server.Configuration;
using RemoteExec.Server.Hubs;
using RemoteExec.Server.Middleware;
using RemoteExec.Server.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks();
// Add metrics broadcast background service
builder.Services.Configure<AuthenticationConfiguration>(builder.Configuration.GetSection("Authentication"));
builder.Services.Configure<ExecutionConfiguration>(builder.Configuration.GetSection("Execution"));
builder.Services.Configure<MetricsConfiguration>(builder.Configuration.GetSection("Metrics"));
builder.Services.AddHostedService<MetricsBroadcastService>();
WebApplication app = builder.Build();
app.UseMiddleware<ApiKeyAuthenticationMiddleware>();
app.MapHub<RemoteExecutionHub>("/remote");
// Configure the HTTP request pipeline.
@@ -23,7 +29,7 @@ if (app.Environment.IsDevelopment())
_ = app.MapOpenApi();
}
//app.UseHttpsRedirection();
app.UseHttpsRedirection();
app.UseAuthorization();
@@ -1,22 +1,24 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using RemoteExec.Server.Configuration;
using RemoteExec.Server.Hubs;
namespace RemoteExec.Server.Services;
public class MetricsBroadcastService(IHubContext<RemoteExecutionHub> hubContext, ILogger<MetricsBroadcastService> logger) : BackgroundService
public class MetricsBroadcastService(IHubContext<RemoteExecutionHub> hubContext, ILogger<MetricsBroadcastService> logger, IOptions<MetricsConfiguration> metricsOptions) : BackgroundService
{
private readonly TimeSpan _broadcastInterval = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan broadcastInterval = TimeSpan.FromMilliseconds(metricsOptions.Value.BroadcastIntervalMs);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Metrics broadcast service started");
logger.LogInformation("Metrics broadcast service started with interval {Interval}ms", broadcastInterval.TotalMilliseconds);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_broadcastInterval, stoppingToken);
await Task.Delay(broadcastInterval, stoppingToken);
await RemoteExecutionHub.BroadcastMetricsAsync(hubContext);
}
+15 -1
View File
@@ -15,5 +15,19 @@
}
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Authentication": {
"ApiKeys": [
{
"Key": "dev-api-key-1",
"Description": "Dev client 1",
"Enabled": true
},
{
"Key": "dev-api-key-2",
"Description": "Dev client 2",
"Enabled": false
}
]
}
}
+45 -1
View File
@@ -5,5 +5,49 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
// Authentication configuration
"Authentication": {
// List of API keys for authenticating clients
// Each entry should contain key identification and validation information
"ApiKeys": [
// Example:
// {
// "Key": "your-secret-api-key-here",
// "Name": "Client1",
// "Enabled": true
// }
]
},
// Remote execution configuration
"Execution": {
// Maximum number of tasks that can execute concurrently on the server
// null = uses Environment.ProcessorCount * 2 (default)
// Increase for CPU-light tasks, decrease for resource-intensive operations
"MaxConcurrentTasks": null,
// Timeout in seconds for loading assemblies from clients
// If a client doesn't respond with assembly bytes within this time, the request fails
// Default: 30 seconds
"AssemblyLoadTimeoutSeconds": 30
},
// Server metrics broadcasting configuration
"Metrics": {
// Minimum CPU usage difference (in percentage points) required to trigger a metrics broadcast
// Lower values = more frequent updates, higher values = less network traffic
// Default: 1.0 (1%)
"CpuDifferenceThreshold": 1.0,
// Minimum memory usage difference (in bytes) required to trigger a metrics broadcast
// Default: 10485760 (10 MB)
"MemoryDifferenceThreshold": 10485760,
// Interval in milliseconds between metrics broadcasts to all connected clients
// Lower values = more real-time updates, higher values = less server load
// Default: 5000 (5 seconds)
"BroadcastIntervalMs": 5000
}
}