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
@@ -121,7 +121,7 @@ public static class InstanceEndpoint
|
|||||||
ClearStaleFile(McpSocketPath(instanceDirectory));
|
ClearStaleFile(McpSocketPath(instanceDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ClearStaleFile(string path)
|
internal static void ClearStaleFile(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@ public static class InstanceEndpoint
|
|||||||
RestrictFile(McpSocketPath(instanceDirectory));
|
RestrictFile(McpSocketPath(instanceDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RestrictFile(string path)
|
internal static void RestrictFile(string path)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows()) return;
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ public static class InstanceEndpoint
|
|||||||
public static HttpMessageHandler CreateHandler(string instanceDirectory)
|
public static HttpMessageHandler CreateHandler(string instanceDirectory)
|
||||||
=> CreateHandler(SocketPath(instanceDirectory), PipeName(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())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -239,7 +239,7 @@ public static class InstanceEndpoint
|
|||||||
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
||||||
/// dürfen es nicht.</para>
|
/// dürfen es nicht.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string Fingerprint(string instanceDirectory)
|
internal static string Fingerprint(string instanceDirectory)
|
||||||
{
|
{
|
||||||
var full = Path.GetFullPath(instanceDirectory).TrimEnd(
|
var full = Path.GetFullPath(instanceDirectory).TrimEnd(
|
||||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ClawdDotNet.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: The instance manager's local socket — the way the shell (ClawdDotNet.Shell) talks to
|
||||||
|
/// it without the web access token.
|
||||||
|
///
|
||||||
|
/// <para><b>Why a socket and not the web port.</b> 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 (<see cref="InstanceEndpoint"/>). Over TCP the shell endpoints do not exist.</para>
|
||||||
|
///
|
||||||
|
/// <para>It lives next to <c>manager.json</c>: the shell finds the manager the same way it finds
|
||||||
|
/// its port.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ManagerEndpoint
|
||||||
|
{
|
||||||
|
public const string SocketFileName = "manager-shell.sock";
|
||||||
|
|
||||||
|
public static string SocketPath(string configDirectory)
|
||||||
|
=> Path.Combine(Path.GetFullPath(configDirectory), SocketFileName);
|
||||||
|
|
||||||
|
/// <summary>Pipe names are machine-wide; a fingerprint of the folder keeps two installations apart.</summary>
|
||||||
|
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")
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A socket file left by a hard end would block the next bind (see <see cref="InstanceEndpoint.ClearStale"/>).</summary>
|
||||||
|
public static void ClearStale(string configDirectory)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
InstanceEndpoint.ClearStaleFile(SocketPath(configDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0600 after binding; on Windows the pipe's CurrentUserOnly carries the same promise.</summary>
|
||||||
|
public static void Restrict(string configDirectory)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
InstanceEndpoint.RestrictFile(SocketPath(configDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,13 @@ public sealed class AccessToken(byte[] expected)
|
|||||||
|
|
||||||
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
||||||
public static bool NeedsToken(PathString path)
|
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);
|
||||||
|
|
||||||
|
/// <summary>#764: Where the WebUI trades a one-time code from the shell for the access token.</summary>
|
||||||
|
public const string LoginExchangePath = "/api/login/exchange";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Holt das Token aus der Anfrage.
|
/// Holt das Token aus der Anfrage.
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ public sealed record FtpSettingsUpdateDto(
|
|||||||
bool Enabled, string Host, int Port, string Username, string? Password,
|
bool Enabled, string Host, int Port, string Username, string? Password,
|
||||||
string RemoteDirectory, bool RequireTls, int KeepCount);
|
string RemoteDirectory, bool RequireTls, int KeepCount);
|
||||||
|
|
||||||
|
/// <summary>#764: A one-time sign-in code for the shell, and the address that uses it.</summary>
|
||||||
|
public sealed record ShellLoginCodeDto(string Code, string Url);
|
||||||
|
|
||||||
|
/// <summary>#764: The code the WebUI found in its address.</summary>
|
||||||
|
public sealed record ShellLoginExchangeDto(string? Code);
|
||||||
|
|
||||||
|
/// <summary>#764: The access token, in return for a valid code.</summary>
|
||||||
|
public sealed record ShellLoginTokenDto(string Token);
|
||||||
|
|
||||||
|
/// <summary>#764: What the shell shows next to its tray icon.</summary>
|
||||||
|
public sealed record ShellStatusDto(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort);
|
||||||
|
|
||||||
/// <summary>#764: The license as the web interface sees it — never the key, only whether one is set.</summary>
|
/// <summary>#764: The license as the web interface sees it — never the key, only whether one is set.</summary>
|
||||||
public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId);
|
public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId);
|
||||||
|
|
||||||
@@ -108,6 +120,39 @@ internal static class ManagerEndpoints
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
|
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ─── The shell (#764) ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// (<c>ManagerWebHost.CheckToken</c>): <c>/api/shell</c> answers only there, everything else
|
||||||
|
/// never there.
|
||||||
|
/// </summary>
|
||||||
|
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 ───
|
// ─── Was den Manager selbst angeht ───
|
||||||
|
|
||||||
public static void MapVerwaltung(this WebApplication app)
|
public static void MapVerwaltung(this WebApplication app)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
|
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
|
||||||
|
|
||||||
app.StartAsync().GetAwaiter().GetResult();
|
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);
|
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("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
builder.Logging.AddFilter("System", 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);
|
||||||
builder.Services.AddSingleton(manager.Instances);
|
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(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update")));
|
||||||
builder.Services.AddSingleton(AccessToken.For(token));
|
builder.Services.AddSingleton(AccessToken.For(token));
|
||||||
builder.Services.AddSingleton(forwarding);
|
builder.Services.AddSingleton(forwarding);
|
||||||
|
builder.Services.AddSingleton(new ShellLogin());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -143,6 +166,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
|
|
||||||
MapStaticFiles(app);
|
MapStaticFiles(app);
|
||||||
|
|
||||||
|
app.MapShell();
|
||||||
app.MapVerwaltung();
|
app.MapVerwaltung();
|
||||||
app.MapEvents();
|
app.MapEvents();
|
||||||
app.MapForwarding();
|
app.MapForwarding();
|
||||||
@@ -181,7 +205,17 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
|
|
||||||
private static async Task CheckToken(HttpContext context, RequestDelegate next)
|
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);
|
await next(context);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Connections;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: One-time codes that sign the shell's browser window in.
|
||||||
|
///
|
||||||
|
/// <para><b>Why not hand the shell the access token.</b> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Who gets a code:</b> only a request over the manager's local socket
|
||||||
|
/// (<see cref="ShellTransport"/>), which the file system restricts to the user running the
|
||||||
|
/// manager. Over TCP the endpoint does not exist.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellLogin(TimeProvider? clock = null)
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||||
|
|
||||||
|
/// <summary>Codes by their hash — the code itself is not kept, so a memory dump does not hold usable ones.</summary>
|
||||||
|
private readonly ConcurrentDictionary<string, DateTimeOffset> _open = new();
|
||||||
|
|
||||||
|
public string Issue()
|
||||||
|
{
|
||||||
|
Sweep();
|
||||||
|
var code = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||||
|
_open[Hash(code)] = _clock.GetUtcNow() + Lifetime;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: Marks connections that came in over the manager's local socket — the same pattern as
|
||||||
|
/// the instance's <c>LocalTransport</c>: a mark set by the listener, not a guess from a missing
|
||||||
|
/// IP address.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellTransport
|
||||||
|
{
|
||||||
|
private static readonly ShellTransport Instance = new();
|
||||||
|
|
||||||
|
private ShellTransport() { }
|
||||||
|
|
||||||
|
/// <summary>The only paths the socket serves. Everything else stays behind the token.</summary>
|
||||||
|
public const string PathPrefix = "/api/shell";
|
||||||
|
|
||||||
|
public static Func<ConnectionDelegate, ConnectionDelegate> Mark
|
||||||
|
=> next => connection =>
|
||||||
|
{
|
||||||
|
connection.Features.Set(Instance);
|
||||||
|
return next(connection);
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool Applies(HttpContext context)
|
||||||
|
=> context.Features.Get<ShellTransport>() is not null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
hatToken,
|
hatToken,
|
||||||
|
setzeToken,
|
||||||
loescheToken,
|
loescheToken,
|
||||||
beiUnberechtigt,
|
beiUnberechtigt,
|
||||||
holeAktiveInstanz,
|
holeAktiveInstanz,
|
||||||
@@ -405,7 +406,31 @@ async function starteHauptAnwendung(instanzen) {
|
|||||||
initialisiereRouter();
|
initialisiereRouter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #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] })
|
||||||
|
});
|
||||||
|
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
|
// Prüfe bestehende Session
|
||||||
|
redeemShellCode().then(() => {
|
||||||
if (hatToken()) {
|
if (hatToken()) {
|
||||||
api("/verwaltung/instanzen")
|
api("/verwaltung/instanzen")
|
||||||
.then((instanzen) => {
|
.then((instanzen) => {
|
||||||
@@ -418,3 +443,4 @@ if (hatToken()) {
|
|||||||
} else {
|
} else {
|
||||||
zeigeAnmeldung();
|
zeigeAnmeldung();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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