feat(worker): remote MCP listener gated by per-session tokens
This commit is contained in:
@@ -41,6 +41,10 @@ public sealed class WorkerConfig
|
|||||||
[JsonPropertyName("external_mcp_api_key")]
|
[JsonPropertyName("external_mcp_api_key")]
|
||||||
public string? ExternalMcpApiKey { get; set; }
|
public string? ExternalMcpApiKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loopback port SSH tunnels forward to; requests there need a per-session token. 0 disables SSH sessions.</summary>
|
||||||
|
[JsonPropertyName("remote_mcp_port")]
|
||||||
|
public int RemoteMcpPort { get; set; } = 47_823;
|
||||||
|
|
||||||
[JsonPropertyName("online_inbox")]
|
[JsonPropertyName("online_inbox")]
|
||||||
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -7,12 +7,28 @@ public sealed class ExternalMcpAuthMiddleware
|
|||||||
{
|
{
|
||||||
private const string HeaderName = "X-ClaudeDo-Key";
|
private const string HeaderName = "X-ClaudeDo-Key";
|
||||||
|
|
||||||
|
/// <summary>Header carrying an SSH session's token on the remote MCP listener.</summary>
|
||||||
|
public const string RemoteTokenHeader = "X-ClaudeDo-Remote-Token";
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next;
|
public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg)
|
public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg, RemoteSessionTokenRegistry remoteTokens)
|
||||||
{
|
{
|
||||||
|
// Tunnelled traffic arrives as loopback too, so the remote listener never falls back to loopback trust.
|
||||||
|
if (cfg.RemoteMcpPort > 0 && ctx.Connection.LocalPort == cfg.RemoteMcpPort)
|
||||||
|
{
|
||||||
|
if (!remoteTokens.IsValid(ctx.Request.Headers[RemoteTokenHeader].ToString()))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync($"Missing or invalid {RemoteTokenHeader} header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey))
|
if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey))
|
||||||
{
|
{
|
||||||
await _next(ctx);
|
await _next(ctx);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
/// <summary>Tokens issued to SSH sessions; the only credential accepted on the remote MCP listener.</summary>
|
||||||
|
public sealed class RemoteSessionTokenRegistry
|
||||||
|
{
|
||||||
|
// ponytail: tokens live until worker restart (no revoke on tile close); a leaked token only works while some tunnel to RemoteMcpPort is open.
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _tokens = new();
|
||||||
|
|
||||||
|
/// <summary>Mints and registers a fresh 256-bit hex token.</summary>
|
||||||
|
public string Issue()
|
||||||
|
{
|
||||||
|
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||||
|
_tokens[token] = 0;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the token was issued by this worker instance.</summary>
|
||||||
|
public bool IsValid(string? token) => !string.IsNullOrEmpty(token) && _tokens.ContainsKey(token);
|
||||||
|
}
|
||||||
@@ -131,6 +131,7 @@ builder.Services.AddSingleton<HandlerRestartCoordinator>();
|
|||||||
builder.Services.AddSingleton<HandlerUsageGuard>();
|
builder.Services.AddSingleton<HandlerUsageGuard>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<HandlerUsageGuard>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<HandlerUsageGuard>());
|
||||||
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
||||||
|
builder.Services.AddSingleton<RemoteSessionTokenRegistry>();
|
||||||
builder.Services.AddSingleton<TaskRunner>();
|
builder.Services.AddSingleton<TaskRunner>();
|
||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
builder.Services.AddSingleton<TaskResetService>();
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
@@ -360,6 +361,7 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddSingleton(cfg);
|
externalBuilder.Services.AddSingleton(cfg);
|
||||||
Share<HubBroadcaster>();
|
Share<HubBroadcaster>();
|
||||||
Share<ConnectedClientTracker>();
|
Share<ConnectedClientTracker>();
|
||||||
|
Share<RemoteSessionTokenRegistry>();
|
||||||
Share<QueueService>();
|
Share<QueueService>();
|
||||||
Share<OverrideSlotService>();
|
Share<OverrideSlotService>();
|
||||||
Share<IDbContextFactory<ClaudeDoDbContext>>();
|
Share<IDbContextFactory<ClaudeDoDbContext>>();
|
||||||
@@ -432,7 +434,9 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
.WithTools<FindingsMcpTools>()
|
.WithTools<FindingsMcpTools>()
|
||||||
.WithTools<NotesMcpTools>()
|
.WithTools<NotesMcpTools>()
|
||||||
.WithTools<TicketMcpTools>();
|
.WithTools<TicketMcpTools>();
|
||||||
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
externalBuilder.WebHost.UseUrls(cfg.RemoteMcpPort > 0
|
||||||
|
? [$"http://127.0.0.1:{cfg.ExternalMcpPort}", $"http://127.0.0.1:{cfg.RemoteMcpPort}"]
|
||||||
|
: [$"http://127.0.0.1:{cfg.ExternalMcpPort}"]);
|
||||||
|
|
||||||
externalApp = externalBuilder.Build();
|
externalApp = externalBuilder.Build();
|
||||||
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
|
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.External;
|
||||||
|
|
||||||
|
public sealed class ExternalMcpAuthMiddlewareTests
|
||||||
|
{
|
||||||
|
private const int RemotePort = 47_823;
|
||||||
|
|
||||||
|
private static async Task<(int Status, bool Passed)> Run(WorkerConfig cfg, RemoteSessionTokenRegistry tokens,
|
||||||
|
int localPort, string? remoteToken = null, string? apiKey = null)
|
||||||
|
{
|
||||||
|
var passed = false;
|
||||||
|
var mw = new ExternalMcpAuthMiddleware(_ => { passed = true; return Task.CompletedTask; });
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Connection.LocalPort = localPort;
|
||||||
|
if (remoteToken is not null) ctx.Request.Headers[ExternalMcpAuthMiddleware.RemoteTokenHeader] = remoteToken;
|
||||||
|
if (apiKey is not null) ctx.Request.Headers["X-ClaudeDo-Key"] = apiKey;
|
||||||
|
await mw.InvokeAsync(ctx, cfg, tokens);
|
||||||
|
return (ctx.Response.StatusCode, passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerConfig Cfg(string? key = null) =>
|
||||||
|
new() { ExternalMcpPort = 47_822, RemoteMcpPort = RemotePort, ExternalMcpApiKey = key };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemotePort_WithoutToken_Is401()
|
||||||
|
{
|
||||||
|
var (status, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), RemotePort);
|
||||||
|
Assert.Equal(401, status);
|
||||||
|
Assert.False(passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemotePort_WithUnknownToken_Is401()
|
||||||
|
{
|
||||||
|
var (status, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), RemotePort, remoteToken: "nope");
|
||||||
|
Assert.Equal(401, status);
|
||||||
|
Assert.False(passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemotePort_WithIssuedToken_PassesEvenWhenApiKeyConfigured()
|
||||||
|
{
|
||||||
|
var tokens = new RemoteSessionTokenRegistry();
|
||||||
|
var token = tokens.Issue();
|
||||||
|
var (_, passed) = await Run(Cfg(key: "secret"), tokens, RemotePort, remoteToken: token);
|
||||||
|
Assert.True(passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemotePort_ApiKeyAlone_DoesNotPass()
|
||||||
|
{
|
||||||
|
var (status, passed) = await Run(Cfg(key: "secret"), new RemoteSessionTokenRegistry(), RemotePort, apiKey: "secret");
|
||||||
|
Assert.Equal(401, status);
|
||||||
|
Assert.False(passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NormalPort_WithoutKeyConfigured_StillPasses()
|
||||||
|
{
|
||||||
|
var (_, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), 47_822);
|
||||||
|
Assert.True(passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Issue_ReturnsDistinct64HexTokens()
|
||||||
|
{
|
||||||
|
var tokens = new RemoteSessionTokenRegistry();
|
||||||
|
var a = tokens.Issue();
|
||||||
|
var b = tokens.Issue();
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
Assert.Matches("^[0-9A-F]{64}$", a);
|
||||||
|
Assert.True(tokens.IsValid(a));
|
||||||
|
Assert.False(tokens.IsValid(""));
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user