Stufe 2 (#764): Einmal-Anmeldung fuer die Huelle ueber den Manager-Socket
Der Manager hoert zusaetzlich auf einem lokalen Socket (0600 bzw. Pipe nur fuer den eigenen Benutzer). Dort gibt es nur /api/shell/*, ueber TCP gibt es diese Pfade nicht. Ein Einmal-Code (256 Bit, einmal, hoechstens 60 s) wird in der WebUI aus dem Fragment gegen das Token getauscht und sofort aus der Adresse entfernt. Tests: zweimal, zu spaet, ueber TCP - alle scheitern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c3927292fd
commit
775c4dbc8e
@@ -19,6 +19,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
||||
<!-- #764: the shell's one-time sign-in lives in the manager's web host. -->
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Manager.Web\ClawdDotNet.Manager.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#764: The same promises against the running web host, over real TCP and the real socket.</summary>
|
||||
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<ShellLoginCodeDto>();
|
||||
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<ShellLoginTokenDto>())!.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user