diff --git a/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs index beb9604..372fd85 100644 --- a/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs +++ b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs @@ -121,7 +121,7 @@ public static class InstanceEndpoint ClearStaleFile(McpSocketPath(instanceDirectory)); } - private static void ClearStaleFile(string path) + internal static void ClearStaleFile(string path) { try { @@ -152,7 +152,7 @@ public static class InstanceEndpoint RestrictFile(McpSocketPath(instanceDirectory)); } - private static void RestrictFile(string path) + internal static void RestrictFile(string path) { if (OperatingSystem.IsWindows()) return; @@ -185,7 +185,7 @@ public static class InstanceEndpoint public static HttpMessageHandler CreateHandler(string instanceDirectory) => CreateHandler(SocketPath(instanceDirectory), PipeName(instanceDirectory)); - private static HttpMessageHandler CreateHandler(string socketPath, string pipeName) + internal static HttpMessageHandler CreateHandler(string socketPath, string pipeName) { if (OperatingSystem.IsWindows()) { @@ -239,7 +239,7 @@ public static class InstanceEndpoint /// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und /// dürfen es nicht. /// - private static string Fingerprint(string instanceDirectory) + internal static string Fingerprint(string instanceDirectory) { var full = Path.GetFullPath(instanceDirectory).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); diff --git a/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs b/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs new file mode 100644 index 0000000..a407686 --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs @@ -0,0 +1,46 @@ +namespace ClawdDotNet.Core.Instances; + +/// +/// #764: The instance manager's local socket — the way the shell (ClawdDotNet.Shell) talks to +/// it without the web access token. +/// +/// Why a socket and not the web port. The shell opens the web interface signed in, +/// through a one-time code. Asking for that code must prove that the asker is the user who runs +/// the manager on this machine — not anybody who reaches the port. The file system says that: +/// the socket is 0600 (Linux) or a pipe for the current user only (Windows), exactly like the +/// instance socket (). Over TCP the shell endpoints do not exist. +/// +/// It lives next to manager.json: the shell finds the manager the same way it finds +/// its port. +/// +public static class ManagerEndpoint +{ + public const string SocketFileName = "manager-shell.sock"; + + public static string SocketPath(string configDirectory) + => Path.Combine(Path.GetFullPath(configDirectory), SocketFileName); + + /// Pipe names are machine-wide; a fingerprint of the folder keeps two installations apart. + public static string PipeName(string configDirectory) + => "clawd-manager-" + InstanceEndpoint.Fingerprint(configDirectory); + + public static HttpClient CreateClient(string configDirectory) + => new(InstanceEndpoint.CreateHandler(SocketPath(configDirectory), PipeName(configDirectory))) + { + BaseAddress = new Uri("http://manager") + }; + + /// A socket file left by a hard end would block the next bind (see ). + public static void ClearStale(string configDirectory) + { + if (OperatingSystem.IsWindows()) return; + InstanceEndpoint.ClearStaleFile(SocketPath(configDirectory)); + } + + /// 0600 after binding; on Windows the pipe's CurrentUserOnly carries the same promise. + public static void Restrict(string configDirectory) + { + if (OperatingSystem.IsWindows()) return; + InstanceEndpoint.RestrictFile(SocketPath(configDirectory)); + } +} diff --git a/src/ClawdDotNet.Manager.Web/AccessToken.cs b/src/ClawdDotNet.Manager.Web/AccessToken.cs index dd7729b..257dd36 100644 --- a/src/ClawdDotNet.Manager.Web/AccessToken.cs +++ b/src/ClawdDotNet.Manager.Web/AccessToken.cs @@ -35,7 +35,13 @@ public sealed class AccessToken(byte[] expected) /// Ob dieser Pfad ein Token braucht: alles unter /api und /events. public static bool NeedsToken(PathString path) - => path.StartsWithSegments("/api") || path.StartsWithSegments("/events"); + => (path.StartsWithSegments("/api") || path.StartsWithSegments("/events")) + // #764: Exchanging a one-time code is the login itself - like the page that asks + // for the token, it cannot require one. The code is the proof (ShellLogin). + && !path.Equals(LoginExchangePath, StringComparison.OrdinalIgnoreCase); + + /// #764: Where the WebUI trades a one-time code from the shell for the access token. + public const string LoginExchangePath = "/api/login/exchange"; /// /// Holt das Token aus der Anfrage. diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 7cbdfa3..c3f6f74 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -74,6 +74,18 @@ public sealed record FtpSettingsUpdateDto( bool Enabled, string Host, int Port, string Username, string? Password, string RemoteDirectory, bool RequireTls, int KeepCount); +/// #764: A one-time sign-in code for the shell, and the address that uses it. +public sealed record ShellLoginCodeDto(string Code, string Url); + +/// #764: The code the WebUI found in its address. +public sealed record ShellLoginExchangeDto(string? Code); + +/// #764: The access token, in return for a valid code. +public sealed record ShellLoginTokenDto(string Token); + +/// #764: What the shell shows next to its tray icon. +public sealed record ShellStatusDto(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort); + /// #764: The license as the web interface sees it — never the key, only whether one is set. public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId); @@ -108,6 +120,39 @@ internal static class ManagerEndpoints /// private const long MaxImportBytes = 4L * 1024 * 1024 * 1024; + // ─── The shell (#764) ─── + + /// + /// What the shell asks over the local socket, and the one endpoint over TCP that turns its + /// one-time code into a signed-in browser. The socket-only rule is enforced before routing + /// (ManagerWebHost.CheckToken): /api/shell answers only there, everything else + /// never there. + /// + public static void MapShell(this WebApplication app) + { + app.MapPost("/api/shell/login-code", (ShellLogin login, ManagerCore manager) => + { + var code = login.Issue(); + // In the fragment, not the query: the fragment never reaches a server or its log. + return Results.Ok(new ShellLoginCodeDto(code, $"http://localhost:{manager.Settings.WebUiPort}/#code={code}")); + }); + + app.MapGet("/api/shell/status", (InstanceRegistry registry, ManagerCore manager) => + Results.Ok(new ShellStatusDto( + registry.Instances.Count, + registry.Instances.Count(i => i.Condition == InstanceCondition.Running), + registry.Instances.Count(i => i.Condition is InstanceCondition.Crashed or InstanceCondition.Silent), + manager.LicenseHolds, + manager.Settings.WebUiPort))); + + app.MapPost(AccessToken.LoginExchangePath, (ShellLoginExchangeDto input, ShellLogin login, ManagerCore manager) => + login.Redeem(input.Code) + ? Results.Ok(new ShellLoginTokenDto(manager.Settings.WebUiToken)) + // Same answer for unknown, used and expired: telling them apart helps only a guesser. + : Results.Json(new OutcomeDto(false, "The sign-in link is no longer valid. Open the window again."), + statusCode: StatusCodes.Status401Unauthorized)); + } + // ─── Was den Manager selbst angeht ─── public static void MapVerwaltung(this WebApplication app) diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs index 1910c67..1965ec7 100644 --- a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs +++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs @@ -85,6 +85,7 @@ public sealed class ManagerWebHost : IAsyncDisposable var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding); app.StartAsync().GetAwaiter().GetResult(); + ClawdDotNet.Core.Instances.ManagerEndpoint.Restrict(Path.GetDirectoryName(manager.SettingsPath)!); var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName); @@ -126,7 +127,28 @@ public sealed class ManagerWebHost : IAsyncDisposable builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning); builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning); - builder.WebHost.UseUrls(url); + // #764: The web port as before, and next to it the local socket for the shell. Both + // through ConfigureKestrel - UseUrls next to explicit listeners would be overridden. + var configDirectory = Path.GetDirectoryName(manager.SettingsPath)!; + var bindAddress = url.Contains("0.0.0.0") ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback; + + // CurrentUserOnly is the default and is written anyway: on Windows it is the whole + // access restriction of the pipe - the counterpart of 0600 on Linux. + if (OperatingSystem.IsWindows()) + builder.WebHost.UseNamedPipes(pipes => pipes.CurrentUserOnly = true); + + ClawdDotNet.Core.Instances.ManagerEndpoint.ClearStale(configDirectory); + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.Listen(bindAddress, manager.Settings.WebUiPort); + + if (OperatingSystem.IsWindows()) + kestrel.ListenNamedPipe(ClawdDotNet.Core.Instances.ManagerEndpoint.PipeName(configDirectory), + listen => listen.Use(ShellTransport.Mark)); + else + kestrel.ListenUnixSocket(ClawdDotNet.Core.Instances.ManagerEndpoint.SocketPath(configDirectory), + listen => listen.Use(ShellTransport.Mark)); + }); builder.Services.AddSingleton(manager); builder.Services.AddSingleton(manager.Instances); @@ -135,6 +157,7 @@ public sealed class ManagerWebHost : IAsyncDisposable builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update"))); builder.Services.AddSingleton(AccessToken.For(token)); builder.Services.AddSingleton(forwarding); + builder.Services.AddSingleton(new ShellLogin()); var app = builder.Build(); @@ -143,6 +166,7 @@ public sealed class ManagerWebHost : IAsyncDisposable MapStaticFiles(app); + app.MapShell(); app.MapVerwaltung(); app.MapEvents(); app.MapForwarding(); @@ -181,7 +205,17 @@ public sealed class ManagerWebHost : IAsyncDisposable private static async Task CheckToken(HttpContext context, RequestDelegate next) { - if (!AccessToken.NeedsToken(context.Request.Path)) + // #764: The local socket serves the shell and nothing else; the shell endpoints do not + // exist over TCP. 404 in both directions: what does not exist needs no explanation. + var fromShell = ShellTransport.Applies(context); + var shellPath = context.Request.Path.StartsWithSegments(ShellTransport.PathPrefix); + if (fromShell != shellPath) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + if (fromShell || !AccessToken.NeedsToken(context.Request.Path)) { await next(context); return; diff --git a/src/ClawdDotNet.Manager.Web/ShellLogin.cs b/src/ClawdDotNet.Manager.Web/ShellLogin.cs new file mode 100644 index 0000000..6d1bf57 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/ShellLogin.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http; + +namespace ClawdDotNet.Manager.Web; + +/// +/// #764: One-time codes that sign the shell's browser window in. +/// +/// Why not hand the shell the access token. The shell would have to put it into +/// the address it opens, and an address ends up in histories, logs and window titles. A code +/// that works once and for at most a minute is worth nothing afterwards. The token itself only +/// travels in the answer to the exchange, the same way it travels when someone types it in. +/// +/// Who gets a code: only a request over the manager's local socket +/// (), which the file system restricts to the user running the +/// manager. Over TCP the endpoint does not exist. +/// +public sealed class ShellLogin(TimeProvider? clock = null) +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60); + + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + + /// Codes by their hash — the code itself is not kept, so a memory dump does not hold usable ones. + private readonly ConcurrentDictionary _open = new(); + + public string Issue() + { + Sweep(); + var code = Base64Url(RandomNumberGenerator.GetBytes(32)); + _open[Hash(code)] = _clock.GetUtcNow() + Lifetime; + return code; + } + + /// + /// True once for a code that was issued and has not expired. The code is gone afterwards + /// either way: a second use, or a use after expiry, fails. + /// + public bool Redeem(string? code) + { + if (string.IsNullOrWhiteSpace(code) || code.Length > 100) + return false; + + return _open.TryRemove(Hash(code.Trim()), out var expires) && _clock.GetUtcNow() <= expires; + } + + private void Sweep() + { + var now = _clock.GetUtcNow(); + foreach (var (key, expires) in _open) + { + if (expires < now) _open.TryRemove(key, out _); + } + } + + private static string Hash(string code) + => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(code))); + + private static string Base64Url(byte[] bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} + +/// +/// #764: Marks connections that came in over the manager's local socket — the same pattern as +/// the instance's LocalTransport: a mark set by the listener, not a guess from a missing +/// IP address. +/// +public sealed class ShellTransport +{ + private static readonly ShellTransport Instance = new(); + + private ShellTransport() { } + + /// The only paths the socket serves. Everything else stays behind the token. + public const string PathPrefix = "/api/shell"; + + public static Func Mark + => next => connection => + { + connection.Features.Set(Instance); + return next(connection); + }; + + public static bool Applies(HttpContext context) + => context.Features.Get() is not null; +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index b351cc8..624b35c 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -5,6 +5,7 @@ import { api, hatToken, + setzeToken, loescheToken, beiUnberechtigt, holeAktiveInstanz, @@ -405,16 +406,41 @@ async function starteHauptAnwendung(instanzen) { initialisiereRouter(); } -// Prüfe bestehende Session -if (hatToken()) { - api("/verwaltung/instanzen") - .then((instanzen) => { - starteHauptAnwendung(instanzen); - }) - .catch(() => { - loescheToken(); - zeigeAnmeldung(); +// #764: The shell opens this page with a one-time code in the fragment (#code=…). It is +// traded for the access token once and removed from the address right away, so it shows up in +// no history entry. A failed exchange falls through to the normal sign-in. +async function redeemShellCode() { + const match = /^#code=([A-Za-z0-9_-]{20,100})$/.exec(window.location.hash); + if (!match) return false; + history.replaceState(null, "", window.location.pathname + window.location.search); + try { + const answer = await fetch("/api/login/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: match[1] }) }); -} else { - zeigeAnmeldung(); + if (!answer.ok) return false; + const { token } = await answer.json(); + if (!token) return false; + setzeToken(token); + return true; + } catch { + return false; + } } + +// Prüfe bestehende Session +redeemShellCode().then(() => { + if (hatToken()) { + api("/verwaltung/instanzen") + .then((instanzen) => { + starteHauptAnwendung(instanzen); + }) + .catch(() => { + loescheToken(); + zeigeAnmeldung(); + }); + } else { + zeigeAnmeldung(); + } +}); diff --git a/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj index 6247c45..433e9d0 100644 --- a/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj +++ b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj @@ -19,6 +19,8 @@ + + diff --git a/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs b/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs new file mode 100644 index 0000000..a217ff7 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Net.Http.Json; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Manager.Web; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #764: The shell's one-time sign-in. A new way in — so the three ways it must fail are tested: +/// used twice, used too late, asked for over TCP instead of the local socket. +/// +public sealed class ShellLoginTests +{ + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public void A_code_works_once() + { + var login = new ShellLogin(); + var code = login.Issue(); + + login.Redeem(code).ShouldBeTrue(); + login.Redeem(code).ShouldBeFalse(); + } + + [Fact] + public void A_code_expires_after_a_minute() + { + var clock = new Clock(DateTimeOffset.UnixEpoch); + var login = new ShellLogin(clock); + var code = login.Issue(); + + clock.Now += ShellLogin.Lifetime + TimeSpan.FromSeconds(1); + + login.Redeem(code).ShouldBeFalse(); + } + + [Fact] + public void A_made_up_code_fails() + { + var login = new ShellLogin(); + login.Issue(); + + login.Redeem("guess").ShouldBeFalse(); + login.Redeem("").ShouldBeFalse(); + login.Redeem(null).ShouldBeFalse(); + } + + [Fact] + public void Codes_are_not_predictable() + { + var login = new ShellLogin(); + + login.Issue().ShouldNotBe(login.Issue()); + login.Issue().Length.ShouldBeGreaterThanOrEqualTo(40); + } +} + +/// #764: The same promises against the running web host, over real TCP and the real socket. +public sealed class ShellLoginHostTests : IAsyncDisposable +{ + private const string Token = "a-sufficiently-long-test-token-for-the-webui"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "shl-" + Guid.NewGuid().ToString("N")[..8]); + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private static int FreePort() + { + using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private async Task<(ManagerWebHost Web, ManagerCore Manager, HttpClient Tcp, HttpClient Socket)> StartAsync() + { + Directory.CreateDirectory(_root); + var store = new ManagerSettingsStore(_root); + store.Settings.WebUiToken = Token; + store.Settings.WebUiPort = FreePort(); + store.Settings.InstancesDirectory = Path.Combine(_root, "Instances"); + + var registry = new InstanceRegistry( + new InstanceDirectoryManager(store.Settings.InstancesDirectory), new FakeLauncher(), new FakeChannel(), + NullLoggerFactory.Instance, SupervisionOptions.Default); + var manager = new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance); + await manager.StartAsync(); + var backups = new ManagerBackups(store, registry, null, NullLogger.Instance); + + var web = ManagerWebHost.TryStart(manager, backups, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + var tcp = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{store.Settings.WebUiPort}") }; + var socket = ManagerEndpoint.CreateClient(_root); + return (web!, manager, tcp, socket); + } + + [Fact] + public async Task The_code_comes_only_over_the_socket() + { + var (web, manager, tcp, socket) = await StartAsync(); + await using var _ = web; + await using var __ = manager; + + // Over TCP: not there, even with the access token. + tcp.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); + (await tcp.PostAsync("/api/shell/login-code", null)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + + // Over the socket: there, without a token. + var answer = await socket.PostAsync("/api/shell/login-code", null); + answer.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task The_socket_leads_nowhere_but_the_shell_endpoints() + { + var (web, manager, _, socket) = await StartAsync(); + await using var a = web; + await using var b = manager; + + // No token needed on the socket - so nothing else may be reachable there. + (await socket.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.NotFound); + (await socket.GetAsync("/api/verwaltung/license")).StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task A_code_signs_in_once() + { + var (web, manager, tcp, socket) = await StartAsync(); + await using var a = web; + await using var b = manager; + + var issued = await (await socket.PostAsync("/api/shell/login-code", null)).Content.ReadFromJsonAsync(); + issued!.Url.ShouldContain("#code="); // in the fragment, never the query + + var first = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); + first.StatusCode.ShouldBe(HttpStatusCode.OK); + (await first.Content.ReadFromJsonAsync())!.Token.ShouldBe(Token); + + var second = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); + second.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Without_a_code_or_token_nothing_is_open() + { + var (web, manager, tcp, _) = await StartAsync(); + await using var a = web; + await using var b = manager; + + (await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto("made-up"))).StatusCode + .ShouldBe(HttpStatusCode.Unauthorized); + (await tcp.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } +}