feat(worker): remote MCP listener gated by per-session tokens

This commit is contained in:
mika kuns
2026-10-02 14:23:37 +02:00
parent 0314319872
commit 34970ca600
5 changed files with 127 additions and 2 deletions
@@ -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
View File
@@ -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);
}
+5 -1
View File
@@ -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;
}
}