Skip to content

Commit 55f26b3

Browse files
Add authentication and improve server side configuration
1 parent 5819432 commit 55f26b3

14 files changed

Lines changed: 292 additions & 34 deletions

RemoteExec.Client/RemoteExecutor.cs

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,13 @@ public partial class RemoteExecutor : IAsyncDisposable
2222
private CancellationTokenSource distributorCts = new();
2323
private Task? distributorTask;
2424

25-
private readonly RemoteExecutorOptions options = new();
25+
private readonly RemoteExecutorOptions options;
2626
private readonly ILogger logger;
2727

2828
private bool disposedValue;
2929

3030
public event EventHandler<ServerMetricsUpdatedEventArgs>? MetricsUpdated;
3131

32-
public RemoteExecutor(string url) : this([url], new RemoteExecutorOptions(), NullLogger.Instance)
33-
{
34-
}
35-
36-
public RemoteExecutor(string url, ILogger logger) : this([url], new RemoteExecutorOptions(), logger)
37-
{
38-
}
39-
4032
public RemoteExecutor(string url, Action<RemoteExecutorOptions> configure) : this([url], NullLogger.Instance, configure)
4133
{
4234
}
@@ -45,14 +37,6 @@ public RemoteExecutor(string url, ILogger logger, Action<RemoteExecutorOptions>
4537
{
4638
}
4739

48-
public RemoteExecutor(string[] urls) : this(urls, new RemoteExecutorOptions(), NullLogger.Instance)
49-
{
50-
}
51-
52-
public RemoteExecutor(string[] urls, ILogger logger) : this(urls, new RemoteExecutorOptions(), logger)
53-
{
54-
}
55-
5640
public RemoteExecutor(string[] urls, Action<RemoteExecutorOptions> configure) : this(urls, NullLogger.Instance, configure)
5741
{
5842
}
@@ -81,13 +65,21 @@ public RemoteExecutor(string[] urls, RemoteExecutorOptions options, ILogger logg
8165

8266
private void InitializeServers(string[] urls)
8367
{
68+
if (string.IsNullOrWhiteSpace(options.ApiKey))
69+
{
70+
throw new InvalidOperationException("API Key is required. Configure it in RemoteExecutorOptions.");
71+
}
72+
8473
foreach (string url in urls)
8574
{
8675
Uri baseUri = new(url);
8776
Uri signalRUri = new(baseUri, "/remote");
8877

8978
HubConnection connection = new HubConnectionBuilder()
90-
.WithUrl(signalRUri)
79+
.WithUrl(signalRUri, httpOptions =>
80+
{
81+
httpOptions.Headers["X-API-Key"] = options.ApiKey;
82+
})
9183
.ConfigureLogging(logging =>
9284
{
9385
_ = logging.AddProvider(new RemoteExecLoggerProvider(logger));
@@ -99,6 +91,8 @@ private void InitializeServers(string[] urls)
9991
BaseAddress = baseUri
10092
};
10193

94+
httpClient.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey);
95+
10296
ServerConnection serverConnection = new ServerConnection(connection, httpClient);
10397
servers.Add(serverConnection);
10498
serverAssignedTasks[serverConnection] = new ConcurrentDictionary<Guid, PendingTask>();

RemoteExec.Client/RemoteExecutorOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ public class RemoteExecutorOptions
66
{
77
public ILoadBalancingStrategy Strategy { get; set; } = new ResourceAwareStrategy();
88

9+
public string ApiKey { get; set; } = string.Empty;
10+
911
// How long to wait for a result before throwing a TimeoutException
1012
public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromMinutes(5);
1113

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace RemoteExec.Server.Configuration;
2+
3+
public class ApiKeyConfiguration
4+
{
5+
public required string Key { get; set; }
6+
public string? Description { get; set; }
7+
public bool Enabled { get; set; } = true;
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RemoteExec.Server.Configuration;
2+
3+
public class AuthenticationConfiguration
4+
{
5+
public List<ApiKeyConfiguration> ApiKeys { get; set; } = [];
6+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RemoteExec.Server.Configuration;
2+
3+
public class ExecutionConfiguration
4+
{
5+
/// <summary>
6+
/// Maximum number of concurrent tasks. Default is ProcessorCount * 2.
7+
/// </summary>
8+
public int? MaxConcurrentTasks { get; set; }
9+
10+
/// <summary>
11+
/// Timeout for assembly loading requests in seconds. Default is 30 seconds.
12+
/// </summary>
13+
public int AssemblyLoadTimeoutSeconds { get; set; } = 30;
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace RemoteExec.Server.Configuration;
2+
3+
public class MetricsConfiguration
4+
{
5+
/// <summary>
6+
/// Minimum CPU usage difference (in percentage points) to trigger a metrics broadcast. Default is 1.0.
7+
/// </summary>
8+
public double CpuDifferenceThreshold { get; set; } = 1.0;
9+
10+
/// <summary>
11+
/// Minimum memory usage difference (in bytes) to trigger a metrics broadcast. Default is 10 MB.
12+
/// </summary>
13+
public long MemoryDifferenceThreshold { get; set; } = 10 * 1024 * 1024;
14+
15+
/// <summary>
16+
/// Broadcast interval in milliseconds. Default is 500 ms.
17+
/// </summary>
18+
public int BroadcastIntervalMs { get; set; } = 500;
19+
}

RemoteExec.Server/GlobalSuppressions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
using System.Diagnostics.CodeAnalysis;
77

88
[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")]
9-
[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}")]
109
[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}")]
10+
[assembly: SuppressMessage("Major Code Smell", "S3010:Static fields should not be updated in constructors", Justification = "<Pending>")]

RemoteExec.Server/Hubs/RemoteExecutionHub.cs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Microsoft.AspNetCore.SignalR;
2+
using Microsoft.Extensions.Options;
23

4+
using RemoteExec.Server.Configuration;
35
using RemoteExec.Shared;
46

57
using System.Collections.Concurrent;
@@ -9,7 +11,7 @@
911

1012
namespace RemoteExec.Server.Hubs;
1113

12-
public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
14+
public class RemoteExecutionHub : Hub
1315
{
1416
private static readonly ConcurrentDictionary<string, RemoteJobAssemblyLoadContext> connections = new();
1517

@@ -23,8 +25,29 @@ public class RemoteExecutionHub(ILogger<RemoteExecutionHub> logger) : Hub
2325
private static TimeSpan lastTotalProcessorTime;
2426

2527
private static int activeTasks = 0;
26-
private static readonly int maxConcurrentTasks = Environment.ProcessorCount * 2;
27-
private static readonly SemaphoreSlim taskSemaphore = new SemaphoreSlim(maxConcurrentTasks, maxConcurrentTasks);
28+
private static int maxConcurrentTasks;
29+
private static SemaphoreSlim taskSemaphore = null!;
30+
31+
private static int assemblyLoadTimeoutSeconds;
32+
private static double cpuDifferenceThreshold;
33+
private static long memoryDifferenceThreshold;
34+
35+
private readonly ILogger<RemoteExecutionHub> logger;
36+
37+
public RemoteExecutionHub(ILogger<RemoteExecutionHub> logger, IOptions<ExecutionConfiguration> executionOptions, IOptions<MetricsConfiguration> metricsOptions)
38+
{
39+
this.logger = logger;
40+
41+
// Initialize static configuration values once
42+
if (taskSemaphore is null)
43+
{
44+
maxConcurrentTasks = executionOptions.Value.MaxConcurrentTasks ?? (Environment.ProcessorCount * 2);
45+
taskSemaphore = new SemaphoreSlim(maxConcurrentTasks, maxConcurrentTasks);
46+
assemblyLoadTimeoutSeconds = executionOptions.Value.AssemblyLoadTimeoutSeconds;
47+
cpuDifferenceThreshold = metricsOptions.Value.CpuDifferenceThreshold;
48+
memoryDifferenceThreshold = metricsOptions.Value.MemoryDifferenceThreshold;
49+
}
50+
}
2851

2952
public override Task OnConnectedAsync()
3053
{
@@ -234,7 +257,7 @@ public static async Task BroadcastMetricsAsync(IHubContext<RemoteExecutionHub> h
234257
int tasksDiff = Math.Abs(metrics.ActiveTasks - lastMetrics.ActiveTasks);
235258
int maxTasksDiff = Math.Abs(metrics.MaxConcurrentTasks - lastMetrics.MaxConcurrentTasks);
236259

237-
if (cpuDiff < 1.0 && memoryDiff < 10 * 1024 * 1024 && connectionsDiff == 0 && tasksDiff == 0 && maxTasksDiff == 0)
260+
if (cpuDiff < cpuDifferenceThreshold && memoryDiff < memoryDifferenceThreshold && connectionsDiff == 0 && tasksDiff == 0 && maxTasksDiff == 0)
238261
{
239262
return; // No significant change
240263
}
@@ -296,7 +319,7 @@ private async Task<Assembly> LoadAssemblyAsync(string assemblyName, RemoteJobAss
296319

297320
await Clients.Caller.SendAsync("RequestAssembly", key, guid);
298321

299-
byte[] assemblyBytes = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30));
322+
byte[] assemblyBytes = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(assemblyLoadTimeoutSeconds));
300323

301324
using MemoryStream ms = new MemoryStream(assemblyBytes);
302325
return assemblyLoadContext.LoadFromStream(ms);
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using Microsoft.Extensions.Options;
2+
using Microsoft.Extensions.Primitives;
3+
4+
using RemoteExec.Server.Configuration;
5+
6+
using System.Collections.Concurrent;
7+
8+
namespace RemoteExec.Server.Middleware;
9+
10+
public class ApiKeyAuthenticationMiddleware
11+
{
12+
private readonly RequestDelegate _next;
13+
private readonly ILogger<ApiKeyAuthenticationMiddleware> _logger;
14+
private readonly ConcurrentDictionary<string, ApiKeyConfiguration> _apiKeys;
15+
16+
public ApiKeyAuthenticationMiddleware(
17+
RequestDelegate next,
18+
IOptionsMonitor<AuthenticationConfiguration> authOptions,
19+
ILogger<ApiKeyAuthenticationMiddleware> logger)
20+
{
21+
_next = next;
22+
_logger = logger;
23+
24+
// Build lookup dictionary from configuration
25+
_apiKeys = new ConcurrentDictionary<string, ApiKeyConfiguration>();
26+
27+
// Initial load
28+
LoadApiKeys(authOptions.CurrentValue);
29+
30+
// Watch for configuration changes
31+
_ = authOptions.OnChange(LoadApiKeys);
32+
}
33+
34+
private void LoadApiKeys(AuthenticationConfiguration config)
35+
{
36+
_apiKeys.Clear();
37+
38+
if (config.ApiKeys == null || config.ApiKeys.Count == 0)
39+
{
40+
_logger.LogWarning("No API keys configured. All requests will be rejected.");
41+
return;
42+
}
43+
44+
foreach (ApiKeyConfiguration apiKey in config.ApiKeys.Where(k => k.Enabled))
45+
{
46+
if (string.IsNullOrWhiteSpace(apiKey.Key))
47+
{
48+
_logger.LogWarning("Skipping empty API key configuration");
49+
continue;
50+
}
51+
52+
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
53+
{
54+
_logger.LogInformation(
55+
"Registered API key: {Description}",
56+
apiKey.Description ?? "No description");
57+
}
58+
else
59+
{
60+
_logger.LogWarning(
61+
"Duplicate API key found and skipped: {Description}",
62+
apiKey.Description ?? "No description");
63+
}
64+
}
65+
66+
_logger.LogInformation("Loaded {Count} active API keys", _apiKeys.Count);
67+
}
68+
69+
public async Task InvokeAsync(HttpContext context)
70+
{
71+
// Skip authentication for health checks
72+
if (context.Request.Path.StartsWithSegments("/health"))
73+
{
74+
await _next(context);
75+
return;
76+
}
77+
78+
// Check if any API keys are configured
79+
if (_apiKeys.IsEmpty)
80+
{
81+
_logger.LogError("No API keys configured. Rejecting request to {Path}", context.Request.Path);
82+
83+
context.Response.StatusCode = 503;
84+
85+
await context.Response.WriteAsync("Service is not properly configured");
86+
return;
87+
}
88+
89+
// Check for API key in header
90+
if (!context.Request.Headers.TryGetValue("X-API-Key", out StringValues extractedApiKey))
91+
{
92+
_logger.LogWarning("API Key missing from request to {Path} from {RemoteIp}", context.Request.Path, context.Connection.RemoteIpAddress);
93+
94+
context.Response.StatusCode = 401;
95+
96+
await context.Response.WriteAsync("API Key is missing");
97+
return;
98+
}
99+
100+
string providedKey = extractedApiKey.ToString();
101+
102+
// Validate API key
103+
if (!_apiKeys.TryGetValue(providedKey, out ApiKeyConfiguration? apiKeyConfig))
104+
{
105+
_logger.LogWarning("Invalid API Key provided for request to {Path} from {RemoteIp}",
106+
context.Request.Path,
107+
context.Connection.RemoteIpAddress);
108+
context.Response.StatusCode = 401;
109+
await context.Response.WriteAsync("Invalid API Key");
110+
return;
111+
}
112+
113+
// Store API key info in HttpContext for potential use in controllers
114+
context.Items["ApiKeyDescription"] = apiKeyConfig.Description;
115+
context.Items["ApiKey"] = providedKey;
116+
117+
_logger.LogDebug("Authenticated request to {Path} using key: {Description}",
118+
context.Request.Path,
119+
apiKeyConfig.Description ?? "No description");
120+
121+
await _next(context);
122+
}
123+
}

RemoteExec.Server/Program.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1+
using RemoteExec.Server.Configuration;
12
using RemoteExec.Server.Hubs;
3+
using RemoteExec.Server.Middleware;
24
using RemoteExec.Server.Services;
35

46
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
57

68
// Add services to the container.
7-
89
builder.Services.AddControllers();
910
builder.Services.AddSignalR();
1011
builder.Services.AddOpenApi();
1112
builder.Services.AddHealthChecks();
1213

13-
// Add metrics broadcast background service
14+
builder.Services.Configure<AuthenticationConfiguration>(builder.Configuration.GetSection("Authentication"));
15+
builder.Services.Configure<ExecutionConfiguration>(builder.Configuration.GetSection("Execution"));
16+
builder.Services.Configure<MetricsConfiguration>(builder.Configuration.GetSection("Metrics"));
17+
1418
builder.Services.AddHostedService<MetricsBroadcastService>();
1519

1620
WebApplication app = builder.Build();
1721

22+
app.UseMiddleware<ApiKeyAuthenticationMiddleware>();
23+
1824
app.MapHub<RemoteExecutionHub>("/remote");
1925

2026
// Configure the HTTP request pipeline.
@@ -23,7 +29,7 @@
2329
_ = app.MapOpenApi();
2430
}
2531

26-
//app.UseHttpsRedirection();
32+
app.UseHttpsRedirection();
2733

2834
app.UseAuthorization();
2935

0 commit comments

Comments
 (0)