diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 3eee5907..c0188858 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -41,6 +41,10 @@ public sealed class WorkerConfig [JsonPropertyName("external_mcp_api_key")] public string? ExternalMcpApiKey { get; set; } + /// Loopback port SSH tunnels forward to; requests there need a per-session token. 0 disables SSH sessions. + [JsonPropertyName("remote_mcp_port")] + public int RemoteMcpPort { get; set; } = 47_823; + [JsonPropertyName("online_inbox")] public OnlineInboxConfig OnlineInbox { get; set; } = new(); diff --git a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs index 38b90ff9..49370676 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs @@ -7,12 +7,28 @@ public sealed class ExternalMcpAuthMiddleware { private const string HeaderName = "X-ClaudeDo-Key"; + /// Header carrying an SSH session's token on the remote MCP listener. + 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); diff --git a/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs b/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs new file mode 100644 index 00000000..a07c0b75 --- /dev/null +++ b/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs @@ -0,0 +1,22 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace ClaudeDo.Worker.External; + +/// Tokens issued to SSH sessions; the only credential accepted on the remote MCP listener. +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 _tokens = new(); + + /// Mints and registers a fresh 256-bit hex token. + public string Issue() + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + _tokens[token] = 0; + return token; + } + + /// True when the token was issued by this worker instance. + public bool IsValid(string? token) => !string.IsNullOrEmpty(token) && _tokens.ContainsKey(token); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 48acc238..b21e5def 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -131,6 +131,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -360,6 +361,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(cfg); Share(); Share(); + Share(); Share(); Share(); Share>(); @@ -432,7 +434,9 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools(); - 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(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs new file mode 100644 index 00000000..6878b3d7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs @@ -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; + } +}