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")]
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")]
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";
/// <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;
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))
{
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.AddHostedService(sp => sp.GetRequiredService<HandlerUsageGuard>());
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
builder.Services.AddSingleton<RemoteSessionTokenRegistry>();
builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
@@ -360,6 +361,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(cfg);
Share<HubBroadcaster>();
Share<ConnectedClientTracker>();
Share<RemoteSessionTokenRegistry>();
Share<QueueService>();
Share<OverrideSlotService>();
Share<IDbContextFactory<ClaudeDoDbContext>>();
@@ -432,7 +434,9 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<FindingsMcpTools>()
.WithTools<NotesMcpTools>()
.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.UseMiddleware<ExternalMcpAuthMiddleware>();