From 55f26b3a92f56d5b221e9b93bbb142a9a2c64faa Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:53:09 +0100 Subject: [PATCH] Add authentication and improve server side configuration --- RemoteExec.Client/RemoteExecutor.cs | 30 ++--- RemoteExec.Client/RemoteExecutorOptions.cs | 2 + .../Configuration/ApiKeyConfiguration.cs | 8 ++ .../AuthenticationConfiguration.cs | 6 + .../Configuration/ExecutionConfiguration.cs | 14 ++ .../Configuration/MetricsConfiguration.cs | 19 +++ RemoteExec.Server/GlobalSuppressions.cs | 2 +- RemoteExec.Server/Hubs/RemoteExecutionHub.cs | 33 ++++- .../ApiKeyAuthenticationMiddleware.cs | 123 ++++++++++++++++++ RemoteExec.Server/Program.cs | 12 +- .../Services/MetricsBroadcastService.cs | 10 +- .../appsettings.Development.json | 16 ++- RemoteExec.Server/appsettings.json | 46 ++++++- RemoteExec/Program.cs | 5 +- 14 files changed, 292 insertions(+), 34 deletions(-) create mode 100644 RemoteExec.Server/Configuration/ApiKeyConfiguration.cs create mode 100644 RemoteExec.Server/Configuration/AuthenticationConfiguration.cs create mode 100644 RemoteExec.Server/Configuration/ExecutionConfiguration.cs create mode 100644 RemoteExec.Server/Configuration/MetricsConfiguration.cs create mode 100644 RemoteExec.Server/Middleware/ApiKeyAuthenticationMiddleware.cs diff --git a/RemoteExec.Client/RemoteExecutor.cs b/RemoteExec.Client/RemoteExecutor.cs index 06ee796..8a4631a 100644 --- a/RemoteExec.Client/RemoteExecutor.cs +++ b/RemoteExec.Client/RemoteExecutor.cs @@ -22,21 +22,13 @@ public partial class RemoteExecutor : IAsyncDisposable private CancellationTokenSource distributorCts = new(); private Task? distributorTask; - private readonly RemoteExecutorOptions options = new(); + private readonly RemoteExecutorOptions options; private readonly ILogger logger; private bool disposedValue; public event EventHandler? MetricsUpdated; - public RemoteExecutor(string url) : this([url], new RemoteExecutorOptions(), NullLogger.Instance) - { - } - - public RemoteExecutor(string url, ILogger logger) : this([url], new RemoteExecutorOptions(), logger) - { - } - public RemoteExecutor(string url, Action configure) : this([url], NullLogger.Instance, configure) { } @@ -45,14 +37,6 @@ public partial class RemoteExecutor : IAsyncDisposable { } - public RemoteExecutor(string[] urls) : this(urls, new RemoteExecutorOptions(), NullLogger.Instance) - { - } - - public RemoteExecutor(string[] urls, ILogger logger) : this(urls, new RemoteExecutorOptions(), logger) - { - } - public RemoteExecutor(string[] urls, Action configure) : this(urls, NullLogger.Instance, configure) { } @@ -81,13 +65,21 @@ public partial class RemoteExecutor : IAsyncDisposable private void InitializeServers(string[] urls) { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + throw new InvalidOperationException("API Key is required. Configure it in RemoteExecutorOptions."); + } + foreach (string url in urls) { Uri baseUri = new(url); Uri signalRUri = new(baseUri, "/remote"); HubConnection connection = new HubConnectionBuilder() - .WithUrl(signalRUri) + .WithUrl(signalRUri, httpOptions => + { + httpOptions.Headers["X-API-Key"] = options.ApiKey; + }) .ConfigureLogging(logging => { _ = logging.AddProvider(new RemoteExecLoggerProvider(logger)); @@ -99,6 +91,8 @@ public partial class RemoteExecutor : IAsyncDisposable BaseAddress = baseUri }; + httpClient.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey); + ServerConnection serverConnection = new ServerConnection(connection, httpClient); servers.Add(serverConnection); serverAssignedTasks[serverConnection] = new ConcurrentDictionary(); diff --git a/RemoteExec.Client/RemoteExecutorOptions.cs b/RemoteExec.Client/RemoteExecutorOptions.cs index f4afd03..ac45613 100644 --- a/RemoteExec.Client/RemoteExecutorOptions.cs +++ b/RemoteExec.Client/RemoteExecutorOptions.cs @@ -6,6 +6,8 @@ public class RemoteExecutorOptions { public ILoadBalancingStrategy Strategy { get; set; } = new ResourceAwareStrategy(); + public string ApiKey { get; set; } = string.Empty; + // How long to wait for a result before throwing a TimeoutException public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromMinutes(5); diff --git a/RemoteExec.Server/Configuration/ApiKeyConfiguration.cs b/RemoteExec.Server/Configuration/ApiKeyConfiguration.cs new file mode 100644 index 0000000..6ea8e49 --- /dev/null +++ b/RemoteExec.Server/Configuration/ApiKeyConfiguration.cs @@ -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; +} \ No newline at end of file diff --git a/RemoteExec.Server/Configuration/AuthenticationConfiguration.cs b/RemoteExec.Server/Configuration/AuthenticationConfiguration.cs new file mode 100644 index 0000000..057f9bf --- /dev/null +++ b/RemoteExec.Server/Configuration/AuthenticationConfiguration.cs @@ -0,0 +1,6 @@ +namespace RemoteExec.Server.Configuration; + +public class AuthenticationConfiguration +{ + public List ApiKeys { get; set; } = []; +} \ No newline at end of file diff --git a/RemoteExec.Server/Configuration/ExecutionConfiguration.cs b/RemoteExec.Server/Configuration/ExecutionConfiguration.cs new file mode 100644 index 0000000..f188205 --- /dev/null +++ b/RemoteExec.Server/Configuration/ExecutionConfiguration.cs @@ -0,0 +1,14 @@ +namespace RemoteExec.Server.Configuration; + +public class ExecutionConfiguration +{ + /// + /// Maximum number of concurrent tasks. Default is ProcessorCount * 2. + /// + public int? MaxConcurrentTasks { get; set; } + + /// + /// Timeout for assembly loading requests in seconds. Default is 30 seconds. + /// + public int AssemblyLoadTimeoutSeconds { get; set; } = 30; +} \ No newline at end of file diff --git a/RemoteExec.Server/Configuration/MetricsConfiguration.cs b/RemoteExec.Server/Configuration/MetricsConfiguration.cs new file mode 100644 index 0000000..af5ba18 --- /dev/null +++ b/RemoteExec.Server/Configuration/MetricsConfiguration.cs @@ -0,0 +1,19 @@ +namespace RemoteExec.Server.Configuration; + +public class MetricsConfiguration +{ + /// + /// Minimum CPU usage difference (in percentage points) to trigger a metrics broadcast. Default is 1.0. + /// + public double CpuDifferenceThreshold { get; set; } = 1.0; + + /// + /// Minimum memory usage difference (in bytes) to trigger a metrics broadcast. Default is 10 MB. + /// + public long MemoryDifferenceThreshold { get; set; } = 10 * 1024 * 1024; + + /// + /// Broadcast interval in milliseconds. Default is 500 ms. + /// + public int BroadcastIntervalMs { get; set; } = 500; +} \ No newline at end of file diff --git a/RemoteExec.Server/GlobalSuppressions.cs b/RemoteExec.Server/GlobalSuppressions.cs index 25120e1..5f3407a 100644 --- a/RemoteExec.Server/GlobalSuppressions.cs +++ b/RemoteExec.Server/GlobalSuppressions.cs @@ -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 = "", 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 = "", 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 = "", 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 = "")] diff --git a/RemoteExec.Server/Hubs/RemoteExecutionHub.cs b/RemoteExec.Server/Hubs/RemoteExecutionHub.cs index 59228d7..d48629c 100644 --- a/RemoteExec.Server/Hubs/RemoteExecutionHub.cs +++ b/RemoteExec.Server/Hubs/RemoteExecutionHub.cs @@ -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 logger) : Hub +public class RemoteExecutionHub : Hub { private static readonly ConcurrentDictionary connections = new(); @@ -23,8 +25,29 @@ public class RemoteExecutionHub(ILogger 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 logger; + + public RemoteExecutionHub(ILogger logger, IOptions executionOptions, IOptions 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 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 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); diff --git a/RemoteExec.Server/Middleware/ApiKeyAuthenticationMiddleware.cs b/RemoteExec.Server/Middleware/ApiKeyAuthenticationMiddleware.cs new file mode 100644 index 0000000..8869ae1 --- /dev/null +++ b/RemoteExec.Server/Middleware/ApiKeyAuthenticationMiddleware.cs @@ -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 _logger; + private readonly ConcurrentDictionary _apiKeys; + + public ApiKeyAuthenticationMiddleware( + RequestDelegate next, + IOptionsMonitor authOptions, + ILogger logger) + { + _next = next; + _logger = logger; + + // Build lookup dictionary from configuration + _apiKeys = new ConcurrentDictionary(); + + // 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); + } +} \ No newline at end of file diff --git a/RemoteExec.Server/Program.cs b/RemoteExec.Server/Program.cs index 71f0572..8500f3c 100644 --- a/RemoteExec.Server/Program.cs +++ b/RemoteExec.Server/Program.cs @@ -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(builder.Configuration.GetSection("Authentication")); +builder.Services.Configure(builder.Configuration.GetSection("Execution")); +builder.Services.Configure(builder.Configuration.GetSection("Metrics")); + builder.Services.AddHostedService(); WebApplication app = builder.Build(); +app.UseMiddleware(); + app.MapHub("/remote"); // Configure the HTTP request pipeline. @@ -23,7 +29,7 @@ if (app.Environment.IsDevelopment()) _ = app.MapOpenApi(); } -//app.UseHttpsRedirection(); +app.UseHttpsRedirection(); app.UseAuthorization(); diff --git a/RemoteExec.Server/Services/MetricsBroadcastService.cs b/RemoteExec.Server/Services/MetricsBroadcastService.cs index 0855265..4a720ac 100644 --- a/RemoteExec.Server/Services/MetricsBroadcastService.cs +++ b/RemoteExec.Server/Services/MetricsBroadcastService.cs @@ -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 hubContext, ILogger logger) : BackgroundService +public class MetricsBroadcastService(IHubContext hubContext, ILogger logger, IOptions 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); } diff --git a/RemoteExec.Server/appsettings.Development.json b/RemoteExec.Server/appsettings.Development.json index 1cc9b40..a9a287b 100644 --- a/RemoteExec.Server/appsettings.Development.json +++ b/RemoteExec.Server/appsettings.Development.json @@ -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 + } + ] + } } diff --git a/RemoteExec.Server/appsettings.json b/RemoteExec.Server/appsettings.json index 10f68b8..673daed 100644 --- a/RemoteExec.Server/appsettings.json +++ b/RemoteExec.Server/appsettings.json @@ -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 + } } diff --git a/RemoteExec/Program.cs b/RemoteExec/Program.cs index a6c7451..352e191 100644 --- a/RemoteExec/Program.cs +++ b/RemoteExec/Program.cs @@ -3,7 +3,10 @@ using RemoteExec.Client; // Single host example -RemoteExecutor singleHostExecutor = new RemoteExecutor("https://localhost:5001/remote"); +RemoteExecutor singleHostExecutor = new RemoteExecutor("https://localhost:5001/remote", configure => +{ + configure.ApiKey = "dev-api-key-1"; +}); singleHostExecutor.MetricsUpdated += (sender, e) => {