diff --git a/src/OpenClaw.Connection/DashboardCredentialGate.cs b/src/OpenClaw.Connection/DashboardCredentialGate.cs new file mode 100644 index 000000000..af4d81fcd --- /dev/null +++ b/src/OpenClaw.Connection/DashboardCredentialGate.cs @@ -0,0 +1,71 @@ +namespace OpenClaw.Connection; + +/// +/// Decides whether a dashboard URL may carry a token for one pinned gateway record. +/// Device and bootstrap credentials are not written into the URL. A shared token is +/// appended only when that is the credential resolved for the same pin. +/// +public static class DashboardCredentialGate +{ + public const string PinMismatchMessage = "Dashboard blocked because of a pin mismatch."; + + public static DashboardCredentialDecision Decide( + bool pinMatches, + bool samePinnedRecord, + bool tunnelAllowsSharedToken, + string? credentialSource, + bool isBootstrapToken, + string? resolvedToken, + string? pinnedSharedToken) + { + if (!pinMatches || !samePinnedRecord) + return DashboardCredentialDecision.Mismatch; + + var source = string.IsNullOrWhiteSpace(credentialSource) ? "none" : credentialSource; + var sharedChosen = !isBootstrapToken + && string.Equals(source, CredentialResolver.SourceSharedGatewayToken, StringComparison.Ordinal); + + if (!sharedChosen) + { + return new DashboardCredentialDecision( + AppendToken: false, + CredentialSource: source, + Token: null, + PinMismatch: false); + } + + if (!tunnelAllowsSharedToken) + { + return new DashboardCredentialDecision( + AppendToken: false, + CredentialSource: source, + Token: null, + PinMismatch: false); + } + + if (string.IsNullOrEmpty(resolvedToken) + || !string.Equals(resolvedToken, pinnedSharedToken, StringComparison.Ordinal)) + { + return DashboardCredentialDecision.Mismatch; + } + + return new DashboardCredentialDecision( + AppendToken: true, + CredentialSource: source, + Token: resolvedToken, + PinMismatch: false); + } +} + +public readonly record struct DashboardCredentialDecision( + bool AppendToken, + string CredentialSource, + string? Token, + bool PinMismatch) +{ + public static DashboardCredentialDecision Mismatch { get; } = new( + AppendToken: false, + CredentialSource: "none", + Token: null, + PinMismatch: true); +} diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs index b2239b129..dcdbde62f 100644 --- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs +++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs @@ -18,4 +18,79 @@ public static string Resolve(GatewayRecord record) return $"ws://localhost:{tunnel.LocalPort}"; } + + /// + /// Picks the WebSocket endpoint a dashboard may open. + /// SSH records open only the local forward, and only while that forward is up. + /// The shared token must not be attached to the saved, untunneled address. + /// + public static bool TryResolveDashboardEndpoint( + GatewayRecord record, + SshTunnelSnapshot? tunnel, + out string endpoint, + out bool appendSharedToken, + bool listenerOwned = false) + { + ArgumentNullException.ThrowIfNull(record); + + if (record.SshTunnel is not { } config) + { + endpoint = record.Url; + appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken); + return !string.IsNullOrWhiteSpace(endpoint); + } + + if (!listenerOwned || !IsDashboardTunnelUp(config, tunnel)) + { + endpoint = ""; + appendSharedToken = false; + return false; + } + + if (!TrySubstituteLocalDashboardAddress(record.Url, config.LocalPort, out endpoint)) + { + endpoint = ""; + appendSharedToken = false; + return false; + } + + appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken); + return true; + } + + // The socket client uses Resolve. The browser keeps the saved route + // and only the host and port move onto the local forward. + private static bool TrySubstituteLocalDashboardAddress(string savedUrl, int localPort, out string endpoint) + { + endpoint = ""; + if (!Uri.TryCreate(savedUrl, UriKind.Absolute, out var saved)) + return false; + + var builder = new UriBuilder(saved) + { + Host = "localhost", + Port = localPort, + }; + builder.UserName = ""; + builder.Password = ""; + var text = builder.Uri.AbsoluteUri; + if (saved.AbsolutePath == "/" && + string.IsNullOrEmpty(saved.Query) && + string.IsNullOrEmpty(saved.Fragment) && + !savedUrl.TrimEnd().EndsWith('/')) + { + text = text.TrimEnd('/'); + } + + endpoint = text; + return true; + } + + private static bool IsDashboardTunnelUp(SshTunnelConfig config, SshTunnelSnapshot? tunnel) => + tunnel is { IsRunning: true, Status: OpenClaw.Shared.TunnelStatus.Up } && + tunnel.CurrentLocalPort == config.LocalPort && + tunnel.CurrentRemotePort == config.RemotePort && + tunnel.CurrentSshPort == config.SshPort && + string.Equals(tunnel.CurrentUser, config.User, StringComparison.Ordinal) && + string.Equals(tunnel.CurrentHost, config.Host, StringComparison.OrdinalIgnoreCase); } diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs index d69fc8f74..29b195037 100644 --- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -65,48 +65,21 @@ public static bool TryResolve( credential = null; return false; } - // For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken. - // DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken - // is for HTTP ?token= auth which the chat/dashboard endpoints expect. - if (!string.IsNullOrWhiteSpace(active.SharedGatewayToken)) - { - var sharedCredential = new GatewayCredential( - active.SharedGatewayToken!, - IsBootstrapToken: false, - CredentialResolver.SourceSharedGatewayToken); - if (authorizeCredential is not null && - !authorizeCredential(active, sharedCredential)) - { - credential = null; - return false; - } - credential = new InteractiveGatewayCredential( - active.Url, - active.SharedGatewayToken!, - false, - CredentialResolver.SourceSharedGatewayToken); - return true; - } - // Fall back to standard credential resolution (DeviceToken → Bootstrap) - var resolver = new CredentialResolver(identityReader); - var resolved = resolver.ResolveOperator(active, registry!.GetIdentityDirectory(active.Id)); - if (resolved != null) + if (TryResolveRecord( + active, + registry!.GetIdentityDirectory(active.Id), + identityReader, + authorizeCredential, + out credential, + out var rejected)) { - if (authorizeCredential is not null && - !authorizeCredential(active, resolved)) - { - credential = null; - return false; - } - credential = new InteractiveGatewayCredential( - active.Url, - resolved.Token, - resolved.IsBootstrapToken, - resolved.Source); return true; } + if (rejected) + return false; + if (active.NativePackageFamilyName is not null || !string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase)) { @@ -151,6 +124,76 @@ public static bool TryResolve( legacyCredential.Source); return true; } + + /// + /// Resolves HTTP credentials for one gateway record. + /// Shared token wins for dashboard/chat URLs when it is present and allowed. + /// Otherwise the operator resolver order applies: device token, then bootstrap. + /// + public static bool TryResolveRecord( + GatewayRecord record, + string identityDirectory, + IDeviceIdentityReader identityReader, + Func? authorizeCredential, + out InteractiveGatewayCredential? credential, + out bool rejected) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(identityDirectory); + ArgumentNullException.ThrowIfNull(identityReader); + + rejected = false; + if (string.IsNullOrWhiteSpace(record.Url)) + { + credential = null; + return false; + } + + // HTTP surfaces prefer the shared token. DeviceToken is WebSocket auth. + if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + { + var sharedCredential = new GatewayCredential( + record.SharedGatewayToken!, + IsBootstrapToken: false, + CredentialResolver.SourceSharedGatewayToken); + if (authorizeCredential is not null && + !authorizeCredential(record, sharedCredential)) + { + credential = null; + rejected = true; + return false; + } + + credential = new InteractiveGatewayCredential( + record.Url, + record.SharedGatewayToken!, + false, + CredentialResolver.SourceSharedGatewayToken); + return true; + } + + var resolved = new CredentialResolver(identityReader).ResolveOperator(record, identityDirectory); + if (resolved == null) + { + credential = null; + return false; + } + + if (authorizeCredential is not null && + !authorizeCredential(record, resolved)) + { + credential = null; + rejected = true; + return false; + } + + credential = new InteractiveGatewayCredential( + record.Url, + resolved.Token, + resolved.IsBootstrapToken, + resolved.Source); + return true; + } } public sealed record InteractiveGatewayCredential( diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 14c054c14..48dd55258 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -62,6 +62,7 @@ public SshTunnelConfig? ActiveConfig } public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null; public string? CurrentUser { get; private set; } + public int CurrentSshPort { get; private set; } public string? CurrentHost { get; private set; } public int CurrentRemotePort { get; private set; } public int CurrentLocalPort { get; private set; } @@ -85,7 +86,8 @@ public SshTunnelSnapshot CreateSnapshot() CurrentBrowserProxyLocalPort, StartedAtUtc, LastError, - Status); + Status, + CurrentSshPort); } } @@ -389,6 +391,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s _processStarted = true; CurrentUser = user; + CurrentSshPort = sshPort; CurrentHost = host; CurrentRemotePort = remotePort; CurrentLocalPort = localPort; diff --git a/src/OpenClaw.Connection/SshTunnelSnapshot.cs b/src/OpenClaw.Connection/SshTunnelSnapshot.cs index 060f7196a..7c37c72ae 100644 --- a/src/OpenClaw.Connection/SshTunnelSnapshot.cs +++ b/src/OpenClaw.Connection/SshTunnelSnapshot.cs @@ -15,4 +15,5 @@ public sealed record SshTunnelSnapshot( int CurrentBrowserProxyLocalPort, DateTime? StartedAtUtc, string? LastError, - TunnelStatus Status); + TunnelStatus Status, + int CurrentSshPort = 22); diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 76b490afd..55ca5f6c0 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; namespace OpenClaw.Connection; @@ -33,6 +34,158 @@ public static WindowsTcpListenerSnapshotResult Capture() return new(result, ipv4Complete, ipv6Complete); } + public readonly record struct AcceptedTcpEndpoint( + IPAddress LocalAddress, + int LocalPort, + IPAddress RemoteAddress, + int RemotePort, + int ProcessId); + + public static int? MatchAcceptedProcess( + IEnumerable rows, + IPEndPoint server, + IPEndPoint client) + { + foreach (var row in rows) + { + if (row.LocalPort == server.Port && + row.RemotePort == client.Port && + AddressesMatch(row.LocalAddress, server.Address) && + AddressesMatch(row.RemoteAddress, client.Address)) + return row.ProcessId; + } + + return null; + } + + public static WindowsTcpListenerInfo? MatchListener( + IEnumerable listeners, + IPEndPoint connected) + { + WindowsTcpListenerInfo? wildcard = null; + foreach (var item in listeners) + { + if (item.Port != connected.Port) + continue; + if (AddressesMatch(item.Address, connected.Address)) + return item; + if (wildcard is null && IsSameFamilyWildcard(item.Address, connected.Address)) + wildcard = item; + } + + return wildcard; + } + + public static int? AcceptedProcessId(IPEndPoint server, IPEndPoint client) + { + if (!OperatingSystem.IsWindows()) + return null; + + var rows = new List(); + if (!CaptureConnections(rows)) + return null; + return MatchAcceptedProcess(rows, server, client); + } + + internal static bool AddressesMatch(IPAddress left, IPAddress right) + { + if (left.Equals(right)) + return true; + if (left.IsIPv4MappedToIPv6 && left.MapToIPv4().Equals(right)) + return true; + if (right.IsIPv4MappedToIPv6 && right.MapToIPv4().Equals(left)) + return true; + return false; + } + + private static bool IsSameFamilyWildcard(IPAddress listener, IPAddress connected) + { + if (connected.AddressFamily == AddressFamily.InterNetwork) + return listener.Equals(IPAddress.Any); + if (connected.AddressFamily == AddressFamily.InterNetworkV6) + return listener.Equals(IPAddress.IPv6Any); + return false; + } + + private static bool CaptureConnections(List rows) + { + var ipv4 = CaptureConnectionFamily(AfInet, rows); + var ipv6 = CaptureConnectionFamily(AfInet6, rows); + return ipv4 || ipv6; + } + + private static bool CaptureConnectionFamily(int addressFamily, List rows) + { + for (var attempt = 0; attempt < 3; attempt++) + { + var bufferLength = 0; + var status = GetExtendedTcpTable( + IntPtr.Zero, + ref bufferLength, + sort: true, + ipVersion: addressFamily, + tableClass: TcpTableOwnerPidConnections, + reserved: 0); + if (status != ErrorInsufficientBuffer || bufferLength <= 0) + return false; + + var tablePtr = Marshal.AllocHGlobal(bufferLength); + try + { + status = GetExtendedTcpTable( + tablePtr, + ref bufferLength, + sort: true, + ipVersion: addressFamily, + tableClass: TcpTableOwnerPidConnections, + reserved: 0); + if (status == ErrorInsufficientBuffer) + continue; + if (status != ErrorSuccess) + return false; + + var rowCount = Marshal.ReadInt32(tablePtr); + var rowPtr = IntPtr.Add(tablePtr, sizeof(int)); + var rowSize = addressFamily == AfInet6 + ? Marshal.SizeOf() + : Marshal.SizeOf(); + for (var i = 0; i < rowCount; i++) + { + if (addressFamily == AfInet6) + { + var row = Marshal.PtrToStructure(rowPtr); + rows.Add(new AcceptedTcpEndpoint( + new IPAddress(row.LocalAddress, row.LocalScopeId), + ReadPort(row.LocalPort), + new IPAddress(row.RemoteAddress, row.RemoteScopeId), + ReadPort(row.RemotePort), + unchecked((int)row.OwningProcessId))); + } + else + { + var row = Marshal.PtrToStructure(rowPtr); + rows.Add(new AcceptedTcpEndpoint( + new IPAddress(BitConverter.GetBytes(row.LocalAddress)), + ReadPort(row.LocalPort), + new IPAddress(BitConverter.GetBytes(row.RemoteAddress)), + ReadPort(row.RemotePort), + unchecked((int)row.OwningProcessId))); + } + + rowPtr = IntPtr.Add(rowPtr, rowSize); + } + + return true; + } + finally + { + Marshal.FreeHGlobal(tablePtr); + } + } + + return false; + } + public static string? GetProcessCommandLine(int processId) { if (processId <= 0) @@ -245,6 +398,7 @@ private static void ResolveProcess( private const int AfInet = 2; private const int AfInet6 = 23; private const int TcpTableOwnerPidListener = 3; + private const int TcpTableOwnerPidConnections = 4; private const uint ErrorSuccess = 0; private const uint ErrorInsufficientBuffer = 122; diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index 51020e931..9c993efe1 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -46,7 +46,7 @@ public class AppCapability : NodeCapabilityBase public Func? SettingsSetHandler; public Func? MenuHandler; public Func? SearchHandler; - public Func? DashboardUrlHandler; + public Func>? DashboardUrlHandler; public Func>? ChatSnapshotHandler; public Func>? ChatSendHandler; public Func>? ChatResetHandler; @@ -69,7 +69,7 @@ public override async Task ExecuteAsync(NodeInvokeRequest re "app.settings.set" => HandleSettingsSet(request), "app.menu" => HandleMenu(), "app.search" => HandleSearch(request), - "app.dashboard.url" => HandleDashboardUrl(request), + "app.dashboard.url" => await HandleDashboardUrl(request), "app.chat.snapshot" => await HandleChatSnapshot(request), "app.chat.send" => await HandleChatSend(request), "app.chat.reset" => await HandleChatReset(request), @@ -199,11 +199,11 @@ private NodeInvokeResponse HandleSearch(NodeInvokeRequest request) return Success(SearchHandler(query)); } - private NodeInvokeResponse HandleDashboardUrl(NodeInvokeRequest request) + private async Task HandleDashboardUrl(NodeInvokeRequest request) { if (DashboardUrlHandler == null) return Error("Dashboard URL handler not registered"); - return Success(DashboardUrlHandler(GetStringArg(request.Args, "path"))); + return Success(await DashboardUrlHandler(GetStringArg(request.Args, "path"))); } private async Task HandleChatSnapshot(NodeInvokeRequest request) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 14aa5c30c..d59be89cf 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -236,12 +236,99 @@ private void WireAppCapabilityHandlers() return matches; }; - app.DashboardUrlHandler = (path) => + app.DashboardUrlHandler = async (path) => { + var pinned = _gatewayRegistry?.GetActive(); + if (pinned?.SshTunnel is { } ssh) + { + var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!TryResolvePinnedDashboardCredential( + pinned, + out var pinnedToken, + out var pinnedCredentialSource, + out var pinnedIsBootstrapToken, + out var pinMismatch)) + { + if (pinMismatch) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + + return new { error = "Gateway URL or credential is not configured" }; + } + + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out var endpoint, + out var appendSharedToken, + listenerOwned)) + { + return new { error = "Dashboard blocked because the SSH tunnel is not up." }; + } + + var decision = DashboardCredentialGate.Decide( + DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + pinnedCredentialSource, + pinnedIsBootstrapToken, + pinnedToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + + var url = GatewayDashboardUrlBuilder.Build( + endpoint, + path, + decision.Token, + decision.AppendToken && + !pinnedIsBootstrapToken && + pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken); + var stillOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!DashboardPinStillMatches(pinned)) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + if (!stillOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + stillOwned)) + { + return new { error = "Dashboard blocked because the SSH tunnel is not up." }; + } + + var tlsHost = Uri.TryCreate(pinned.Url, UriKind.Absolute, out var pinnedUri) + ? pinnedUri.Host + : null; + var publicUrl = DashboardCredentialHandoff.Start(async () => + { + if (!DashboardIssuedBinding.Matches(pinned, ssh, _gatewayRegistry?.GetActive()) || + !DashboardPinStillMatches(pinned)) + return false; + var ownedNow = await IsDashboardListenerOwnedAsync(ssh); + return ownedNow && + GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + ownedNow); + }, url, tlsHost, pinned.Id, DashboardCredentialHandoff.DestinationIdentity(pinned)); + return new + { + url = publicUrl, + credentialSource = decision.CredentialSource, + usesSharedGatewayToken = decision.AppendToken && + !pinnedIsBootstrapToken && + pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken, + hasTokenQuery = false + }; + } + if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) return new { error = "Gateway URL or credential is not configured" }; - var url = GatewayDashboardUrlBuilder.Build( + var directUrl = GatewayDashboardUrlBuilder.Build( gatewayUrl, path, token, @@ -249,10 +336,10 @@ private void WireAppCapabilityHandlers() return new { - url, + url = directUrl, credentialSource, usesSharedGatewayToken = !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, - hasTokenQuery = url.Contains("?token=", StringComparison.Ordinal) || url.Contains("&token=", StringComparison.Ordinal) + hasTokenQuery = directUrl.Contains("?token=", StringComparison.Ordinal) || directUrl.Contains("&token=", StringComparison.Ordinal) }; }; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index ced073008..e2e37dd1b 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -151,6 +151,74 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer /// Ensures the managed SSH tunnel is started using the current settings. /// Used by connection settings when the user picks the SSH topology. /// + internal SshTunnelSnapshot? CaptureSshTunnelSnapshot() => + _sshTunnelService?.CreateSnapshot(); + + internal Task IsDashboardListenerOwnedAsync(SshTunnelConfig config) => + _sshTunnelService?.IsOwnedListenerReadyAsync(config, config.LocalPort, CancellationToken.None) + ?? Task.FromResult(false); + + internal bool DashboardPinStillMatches(GatewayRecord pinned) + { + var current = _gatewayRegistry?.GetById(pinned.Id); + return current is not null + && string.Equals(current.Id, pinned.Id, StringComparison.Ordinal) + && string.Equals(current.SharedGatewayToken, pinned.SharedGatewayToken, StringComparison.Ordinal) + && string.Equals(current.BootstrapToken, pinned.BootstrapToken, StringComparison.Ordinal) + && string.Equals(current.Url, pinned.Url, StringComparison.OrdinalIgnoreCase) + && current.SshTunnel == pinned.SshTunnel; + } + + internal bool TryResolvePinnedDashboardCredential( + GatewayRecord pinned, + out string token, + out string credentialSource, + out bool isBootstrapToken, + out bool pinMismatch) + { + token = string.Empty; + credentialSource = "none"; + isBootstrapToken = false; + pinMismatch = false; + + if (!DashboardPinStillMatches(pinned)) + { + pinMismatch = true; + return false; + } + + if (_gatewayRegistry is null || _settings is null) + return false; + + var identityDirectory = _gatewayRegistry.GetIdentityDirectory(pinned.Id); + var resolved = InteractiveGatewayCredentialResolver.TryResolveRecord( + pinned, + identityDirectory, + DeviceIdentityFileReader.Instance, + (record, candidate) => + InteractiveEndpointAuthorizer?.IsCredentialAllowed(record, candidate) == true, + out var credential, + out _); + + if (!DashboardPinStillMatches(pinned)) + { + token = string.Empty; + credentialSource = "none"; + isBootstrapToken = false; + pinMismatch = true; + Logger.Warn("Dashboard pin mismatch after credential resolution; refusing token URL."); + return false; + } + + if (!resolved || credential is null) + return false; + + token = credential.Token; + credentialSource = credential.Source; + isBootstrapToken = credential.IsBootstrapToken; + return true; + } + public void EnsureSshTunnelStarted() { if (_sshTunnelService == null || _settings == null) @@ -4017,6 +4085,12 @@ private void OpenDashboard(string? path = null) { AsyncEventHandlerGuard.Run(async () => { + if (_gatewayRegistry?.GetActive()?.SshTunnel is not null) + { + await OpenDashboardAsync(path); + return; + } + var launcher = new GatewayDashboardLauncher( EnsureSshTunnelConfigured, () => TryResolveChatCredentials(out var url, out var token, out var source, out var bootstrap) @@ -4031,6 +4105,158 @@ private void OpenDashboard(string? path = null) }, new AppLogger(), nameof(OpenDashboard)); } + private async Task OpenDashboardAsync(string? path = null) + { + if (_settings == null) return; + + var pinned = _gatewayRegistry?.GetActive(); + if (pinned?.SshTunnel is { } ssh) + { + var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!TryResolvePinnedDashboardCredential( + pinned, + out var tunnelToken, + out var tunnelCredentialSource, + out var tunnelIsBootstrapToken, + out var pinMismatch)) + { + if (pinMismatch) + { + Logger.Warn("Dashboard pin mismatch; refusing token URL."); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + } + else + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + } + + return; + } + + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out var tunnelEndpoint, + out var appendSharedToken, + listenerOwned)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Dashboard blocked because the SSH tunnel is not up.")); + return; + } + + var decision = DashboardCredentialGate.Decide( + DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + tunnelCredentialSource, + tunnelIsBootstrapToken, + tunnelToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + { + Logger.Warn("Dashboard pin mismatch; refusing token URL."); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + return; + } + + await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( + tunnelEndpoint, + path, + decision.Token, + decision.AppendToken && + !tunnelIsBootstrapToken && + tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken), path, pinned, ssh); + return; + } + + if (!EnsureSshTunnelConfigured()) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); + return; + } + + if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return; + } + + await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( + gatewayUrl, + path, + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken), path); + } + + private Task LaunchPreparedDashboardAsync( + string url, + string? path, + GatewayRecord? issued = null, + SshTunnelConfig? issuedTunnel = null) + { + var launcher = new GatewayDashboardLauncher( + () => true, + () => null, + async target => await global::Windows.System.Launcher.LaunchUriAsync(new Uri(target)), + () => AsyncEventHandlerGuard.Run( + () => _windowManager?.ShowDashboardLaunchFailureAsync(() => OpenDashboard(path)) + ?? Task.CompletedTask, + new AppLogger(), + "Dashboard launch error"), + () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); + Func>? confirm = issued is not null && issuedTunnel is not null + ? () => ConfirmIssuedDashboardListenerAsync(issued, issuedTunnel) + : null; + var tlsHost = issued is not null && Uri.TryCreate(issued.Url, UriKind.Absolute, out var issuedUri) + ? issuedUri.Host + : null; + var identity = issued is null ? null : DashboardCredentialHandoff.DestinationIdentity(issued); + return launcher.OpenPreparedAsync(url, confirm, tlsHost, issued?.Id, identity); + } + + private async Task ConfirmIssuedDashboardListenerAsync( + GatewayRecord issued, + SshTunnelConfig issuedTunnel) + { + if (!DashboardIssuedBinding.Matches(issued, issuedTunnel, _gatewayRegistry?.GetActive()) || + !DashboardPinStillMatches(issued)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + return false; + } + + var listenerOwned = await IsDashboardListenerOwnedAsync(issuedTunnel); + if (!listenerOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + issued, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + listenerOwned)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Dashboard blocked because the SSH tunnel is not up.")); + return false; + } + + return true; + } + // ── IAppCommands implementation ───────────────────────────────────── void IAppCommands.OpenDashboard(string? path) => OpenDashboard(path); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 13376773a..c93e20236 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2566,21 +2566,84 @@ private void OnSavedRowOpenDashboard(object sender, RoutedEventArgs e) => private async Task OnSavedRowOpenDashboardAsync(object sender) { if (sender is not MenuFlyoutItem item || item.Tag is not string gwId) return; - var rec = _gatewayRegistry?.GetById(gwId); - if (rec == null) return; + var pinned = _gatewayRegistry?.GetById(gwId); + if (pinned == null) return; try { - if (!string.IsNullOrWhiteSpace(rec.SharedGatewayToken)) + var listenerOwned = false; + if (pinned.SshTunnel is { } ssh) + listenerOwned = await CurrentApp.IsDashboardListenerOwnedAsync(ssh); + + if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return; + } + + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + CurrentApp.CaptureSshTunnelSnapshot(), + out var endpoint, + out var appendSharedToken, + listenerOwned)) + { + CurrentApp.ShowTransientConnectionError( + "Dashboard blocked because the SSH tunnel is not up."); + return; + } + + string? dashboardToken = pinned.SharedGatewayToken; + var appendDashboardToken = appendSharedToken; + if (pinned.SshTunnel is not null) + { + if (!CurrentApp.TryResolvePinnedDashboardCredential( + pinned, + out var resolvedToken, + out var credentialSource, + out var isBootstrapToken, + out var pinMismatch)) + { + CurrentApp.ShowTransientConnectionError(pinMismatch + ? DashboardCredentialGate.PinMismatchMessage + : "Gateway URL or credential is not configured"); + return; + } + + var decision = DashboardCredentialGate.Decide( + CurrentApp.DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + credentialSource, + isBootstrapToken, + resolvedToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return; + } + + dashboardToken = decision.Token; + appendDashboardToken = decision.AppendToken && + !isBootstrapToken && + credentialSource == CredentialResolver.SourceSharedGatewayToken; + } + else if (appendSharedToken) { var provenanceService = CurrentApp.ManagedLocalPortProvenance; if (provenanceService is null) return; - _ = await provenanceService.InspectAsync(rec); + _ = await provenanceService.InspectAsync(pinned); + if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return; + } var candidate = new GatewayCredential( - rec.SharedGatewayToken!, + pinned.SharedGatewayToken!, IsBootstrapToken: false, CredentialResolver.SourceSharedGatewayToken); - if (CurrentApp.InteractiveEndpointAuthorizer?.IsCredentialAllowed(rec, candidate) != true) + if (CurrentApp.InteractiveEndpointAuthorizer?.IsCredentialAllowed(pinned, candidate) != true) { CurrentApp.ShowTransientConnectionError( "Dashboard blocked because the saved gateway address is not owned by the verified managed gateway."); @@ -2589,11 +2652,22 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } var url = GatewayDashboardUrlBuilder.Build( - rec.Url, + endpoint, path: null, - rec.SharedGatewayToken, - appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken)); - await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); + dashboardToken, + appendSharedGatewayToken: appendDashboardToken); + if (pinned.SshTunnel is { } launchSsh && + !await SavedDashboardListenerStillOwnedAsync(pinned, launchSsh)) + return; + var launchUrl = pinned.SshTunnel is null + ? url + : DashboardCredentialHandoff.Start( + () => SavedDashboardListenerStillOwnedAsync(pinned, pinned.SshTunnel), + url, + Uri.TryCreate(pinned.Url, UriKind.Absolute, out var savedUri) ? savedUri.Host : null, + pinned.Id, + DashboardCredentialHandoff.DestinationIdentity(pinned)); + await global::Windows.System.Launcher.LaunchUriAsync(new Uri(launchUrl)); } catch (Exception ex) { @@ -2601,6 +2675,31 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } + private async Task SavedDashboardListenerStillOwnedAsync(GatewayRecord pinned, SshTunnelConfig ssh) + { + if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return false; + } + + var listenerOwned = await CurrentApp.IsDashboardListenerOwnedAsync(ssh); + if (!listenerOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + CurrentApp.CaptureSshTunnelSnapshot(), + out _, + out _, + listenerOwned)) + { + CurrentApp.ShowTransientConnectionError( + "Dashboard blocked because the SSH tunnel is not up."); + return false; + } + + return true; + } + private void OnSavedRowEdit(object sender, RoutedEventArgs e) { if (sender is not MenuFlyoutItem item || item.Tag is not string gwId) return; diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs new file mode 100644 index 000000000..cbe4a9d03 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -0,0 +1,918 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using OpenClaw.Connection; + +namespace OpenClawTray.Services; + +/// +/// Serves one loopback page that does not contain the dashboard credential. +/// The credential is written only after a fresh ownership check, and only to that response. +/// +internal static class DashboardIssuedBinding +{ + public static bool Matches(GatewayRecord issued, SshTunnelConfig issuedTunnel, GatewayRecord? active) => + active is not null + && string.Equals(active.Id, issued.Id, StringComparison.Ordinal) + && active.SshTunnel == issuedTunnel; +} + +internal static class DashboardCredentialHandoff +{ + // One origin for every launch so the Control UI can keep its local settings. + public const int Port = 47831; + + private static readonly ConcurrentDictionary Live = new(); + private static readonly ConcurrentDictionary OriginPorts = new(); + private static readonly ConcurrentDictionary Listeners = new(); + private static readonly object ListenerGate = new(); + private static readonly JsonSerializerOptions StoreJson = new() + { + PropertyNameCaseInsensitive = true, + }; + + public static string Start( + Func> owned, + string destination, + string? tlsHost = null, + string? originKey = null, + string? destinationIdentity = null) + { + ArgumentNullException.ThrowIfNull(owned); + ArgumentException.ThrowIfNullOrWhiteSpace(destination); + var key = string.IsNullOrWhiteSpace(originKey) ? "default" : originKey; + var identity = string.IsNullOrWhiteSpace(destinationIdentity) + ? ProxiedOriginIdentity(destination) + : destinationIdentity; + var binding = PortFor(key, identity); + try + { + EnsureListener(binding.Port); + } + catch (HttpListenerException ex) + { + throw new InvalidOperationException( + $"Dashboard origin 127.0.0.1:{binding.Port} is already in use. Close the program using that port and open the dashboard again.", + ex); + } + var handoff = new Handoff(owned, destination, tlsHost, binding.Port, binding.Session); + Live[handoff.Nonce] = handoff; + _ = handoff.ExpireUnusedAsync(); + return handoff.BrowserUrl; + } + + /// + /// Test override for the per-gateway origin file. Production uses the tray data directory. + /// + internal static string? OriginStorePath { get; set; } + + internal static void ResetOriginsForTests() => OriginPorts.Clear(); + + /// + /// Saved gateway identity, without the token. A retargeted record keeps its id + /// and must not keep the previous gateway's browser origin. + /// + internal static string DestinationIdentity(GatewayRecord record) + { + ArgumentNullException.ThrowIfNull(record); + var saved = string.IsNullOrWhiteSpace(record.Url) ? "" : SavedUrlIdentity(record.Url); + if (record.SshTunnel is not { } tunnel) + return saved; + return saved + "|" + tunnel.User + "@" + tunnel.Host + ":" + tunnel.SshPort + + ">" + tunnel.LocalPort + ":" + tunnel.RemotePort; + } + + private static OriginBinding PortFor(string key, string destinationIdentity) + { + var cacheKey = key + "\n" + destinationIdentity; + if (OriginPorts.TryGetValue(cacheKey, out var cached)) + return cached; + + lock (ListenerGate) + { + if (OriginPorts.TryGetValue(cacheKey, out cached)) + return cached; + + var saved = ReadStore(); + var match = saved.FirstOrDefault(entry => + entry.Key == key && entry.Destination == destinationIdentity); + if (match is null) + { + // An older file stored only the gateway id. Adopt that port for the + // first destination seen, then leave it so a later destination + // cannot inherit the same browser profile. + var legacy = saved.FirstOrDefault(entry => + entry.Key == key && entry.Destination.Length == 0); + if (legacy is not null) + { + legacy.Destination = destinationIdentity; + if (string.IsNullOrEmpty(legacy.Session)) + legacy.Session = NewSessionId(); + match = legacy; + WriteStore(saved); + } + } + + if (match is null) + { + var port = key == "default" || !PortTaken(Port, saved) ? Port : AllocatePort(saved); + match = new OriginStoreEntry + { + Key = key, + Destination = destinationIdentity, + Port = port, + Session = NewSessionId(), + }; + saved.Add(match); + WriteStore(saved); + } + else if (string.IsNullOrEmpty(match.Session)) + { + match.Session = NewSessionId(); + WriteStore(saved); + } + + cached = new OriginBinding(match.Port, match.Session); + OriginPorts[cacheKey] = cached; + return cached; + } + } + + private static bool PortTaken(int port, List saved) => + saved.Exists(entry => entry.Port == port) || OriginPorts.Values.Any(item => item.Port == port); + + private static int AllocatePort(List saved) + { + for (var attempt = 0; attempt < 8; attempt++) + { + var port = FreePort(); + if (port == Port || PortTaken(port, saved)) + continue; + return port; + } + + throw new InvalidOperationException("No free loopback port is available for a dashboard origin."); + } + + private static int FreePort() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private static string StoreFilePath() => + OriginStorePath ?? Path.Combine(AppIdentity.ResolveRoamingDataDirectory(), "dashboard-origins.json"); + + private static List ReadStore() + { + try + { + var path = StoreFilePath(); + if (!File.Exists(path)) + return []; + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + throw new JsonException("Dashboard origin file is not an object."); + if (doc.RootElement.TryGetProperty("Entries", out var entries) || + doc.RootElement.TryGetProperty("entries", out entries)) + { + return JsonSerializer.Deserialize(doc.RootElement.GetRawText(), StoreJson)?.Entries ?? []; + } + + var legacy = new List(); + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.Number && + property.Value.TryGetInt32(out var port)) + { + legacy.Add(new OriginStoreEntry { Key = property.Name, Port = port }); + } + } + + return legacy; + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + throw new InvalidOperationException("The saved dashboard origins could not be read.", ex); + } + } + + private static void WriteStore(List saved) + { + var path = StoreFilePath(); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var json = JsonSerializer.Serialize(new OriginStoreFile { Entries = saved }, StoreJson); + var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + } + + private static string ProxiedOriginIdentity(string destination) + { + var uri = new Uri(destination); + return uri.Scheme + "://" + uri.IdnHost + ":" + uri.Port; + } + + private static string SavedUrlIdentity(string savedUrl) + { + if (!Uri.TryCreate(savedUrl, UriKind.Absolute, out var saved)) + return savedUrl; + var path = string.IsNullOrEmpty(saved.AbsolutePath) ? "/" : saved.AbsolutePath; + return saved.Scheme + "://" + saved.IdnHost + ":" + saved.Port + path; + } + + private static string NewSessionId() => Convert.ToHexString(Guid.NewGuid().ToByteArray()); + + private sealed record OriginBinding(int Port, string Session); + + private sealed class OriginStoreFile + { + public List Entries { get; set; } = []; + } + + private sealed class OriginStoreEntry + { + public string Key { get; set; } = ""; + public string Destination { get; set; } = ""; + public int Port { get; set; } + public string Session { get; set; } = ""; + } + + private static void EnsureListener(int port) + { + lock (ListenerGate) + { + if (Listeners.TryGetValue(port, out var existing) && existing.IsListening) + return; + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + Listeners[port] = listener; + _ = ServeAsync(listener); + } + } + + private static async Task ServeAsync(HttpListener listener) + { + try + { + while (listener.IsListening) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync(); + } + catch (HttpListenerException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + + _ = AnswerAsync(context); + } + } + catch (ObjectDisposedException) + { + } + } + + private static async Task AnswerAsync(HttpListenerContext context) + { + var response = context.Response; + try + { + var path = context.Request.Url?.AbsolutePath ?? ""; + var requestPort = RequestPort(context); + if (path.StartsWith("/d/", StringComparison.Ordinal)) + { + var nonce = path["/d/".Length..]; + if (!Live.TryGetValue(nonce, out var handoff) || handoff.Port != requestPort) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + await handoff.DeliverAsync(context); + return; + } + + if (!TrySession(path, requestPort, out var session, out var upstreamPath) && + !TrySessionFromReferer(context.Request.Headers["Referer"], path, requestPort, out session, out upstreamPath)) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + if (!await session.Owned()) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + if (context.Request.IsWebSocketRequest) + { + await session.ProxyWebSocketAsync(context, upstreamPath); + return; + } + + await session.ProxyAsync(context, upstreamPath); + } + catch (Exception) + { + try { response.Abort(); } catch (Exception) { } + } + } + + private static int RequestPort(HttpListenerContext context) => + context.Request.LocalEndPoint is IPEndPoint local + ? local.Port + : context.Request.Url?.Port ?? 0; + + private static bool TrySession(string path, int requestPort, out Handoff session, out string upstreamPath) + { + Handoff? match = null; + var matchedPrefix = ""; + foreach (var candidate in Live.Values) + { + if (candidate.Port != requestPort) + continue; + var prefix = "/s/" + candidate.SessionId; + if (!path.Equals(prefix, StringComparison.Ordinal) && + !path.StartsWith(prefix + "/", StringComparison.Ordinal)) + continue; + if (match is not null && candidate.Generation < match.Generation) + continue; + match = candidate; + matchedPrefix = prefix; + } + + if (match is null) + { + session = null!; + upstreamPath = "/"; + return false; + } + + session = match; + upstreamPath = path[matchedPrefix.Length..]; + if (string.IsNullOrEmpty(upstreamPath)) + upstreamPath = "/"; + return true; + } + + private static bool TrySessionFromReferer( + string? referer, + string requestPath, + int requestPort, + out Handoff session, + out string upstreamPath) + { + session = null!; + upstreamPath = requestPath; + if (string.IsNullOrWhiteSpace(referer) || !Uri.TryCreate(referer, UriKind.Absolute, out var uri)) + return false; + if (uri.Port != requestPort) + return false; + if (!TrySession(uri.AbsolutePath, requestPort, out session, out _)) + return false; + if (string.IsNullOrEmpty(upstreamPath)) + upstreamPath = "/"; + return true; + } + + internal static byte[]? DecodeChunked(byte[] body) + { + var output = new MemoryStream(); + var index = 0; + while (index < body.Length) + { + var lineEnd = IndexOfCrlf(body, index); + if (lineEnd < 0) + return null; + var sizeText = Encoding.ASCII.GetString(body, index, lineEnd - index); + var extension = sizeText.IndexOf(';'); + if (extension >= 0) + sizeText = sizeText[..extension]; + if (!int.TryParse(sizeText.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var size) || size < 0) + return null; + index = lineEnd + 2; + if (size == 0) + return output.ToArray(); + if (index + size + 2 > body.Length) + return null; + output.Write(body, index, size); + if (body[index + size] != '\r' || body[index + size + 1] != '\n') + return null; + index += size + 2; + } + + return null; + } + + private static int IndexOfCrlf(byte[] body, int start) + { + for (var i = start; i + 1 < body.Length; i++) + { + if (body[i] == '\r' && body[i + 1] == '\n') + return i; + } + + return -1; + } + + internal static string? ReadHeader(string headerText, string name) + { + foreach (var line in headerText.Split("\r\n")) + { + var prefix = name + ":"; + if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return line[prefix.Length..].Trim(); + } + + return null; + } + + internal static string RewriteLocation(string location, string sessionPrefix) + { + if (location.StartsWith(sessionPrefix, StringComparison.Ordinal)) + return location; + if (location.StartsWith("/", StringComparison.Ordinal)) + return sessionPrefix + location; + return location; + } + + internal static string RewriteRootAbsolute(string html, string sessionPrefix) + { + return System.Text.RegularExpressions.Regex.Replace( + html, + "(src|href|action)=\"/(?!s/)", + "$1=\"" + sessionPrefix + "/", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + /// + /// The Control UI requests assets from the base path the Gateway declares. + /// That path has to include this session. Referer is not a substitute: + /// the Gateway sends Referrer-Policy: no-referrer, and this proxy keeps that header. + /// + internal static string RewriteDeclaredResourceBase(string body, string sessionPrefix) + { + var prefix = sessionPrefix.TrimEnd('/'); + body = System.Text.RegularExpressions.Regex.Replace( + body, + "window\\.__OPENCLAW_CONTROL_UI_BASE_PATH__\\s*=\\s*(\"[^\"]*\"|'[^']*')", + match => "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=" + + JsonSerializer.Serialize(CombineResourceBase(prefix, Unquote(match.Groups[1].Value)))); + body = System.Text.RegularExpressions.Regex.Replace( + body, + " " "\"basePath\":" + JsonSerializer.Serialize(CombineResourceBase(prefix, match.Groups[1].Value))); + } + + internal static string AdaptSetCookie(string setCookie, string sessionPrefix) + { + var parts = setCookie.Split(';'); + var kept = new List { parts[0].Trim() }; + var sawPath = false; + foreach (var raw in parts.Skip(1)) + { + var part = raw.Trim(); + if (part.Length == 0) + continue; + var name = part.Split('=', 2)[0].Trim(); + if (name.Equals("Domain", StringComparison.OrdinalIgnoreCase) || + name.Equals("Secure", StringComparison.OrdinalIgnoreCase)) + continue; + if (name.Equals("SameSite", StringComparison.OrdinalIgnoreCase) && + part.Contains("None", StringComparison.OrdinalIgnoreCase)) + { + kept.Add("SameSite=Lax"); + continue; + } + if (name.Equals("Path", StringComparison.OrdinalIgnoreCase)) + { + sawPath = true; + var value = part.Split('=', 2).Length == 2 ? part.Split('=', 2)[1].Trim() : "/"; + kept.Add("Path=" + CookiePath(value, sessionPrefix)); + continue; + } + + kept.Add(part); + } + + if (!sawPath) + kept.Add("Path=" + sessionPrefix.TrimEnd('/')); + return string.Join("; ", kept); + } + + private static string Unquote(string literal) + { + if (literal.Length >= 2 && (literal[0] == '"' || literal[0] == '\'')) + return literal[1..^1]; + return literal; + } + + private static string CombineResourceBase(string sessionPrefix, string declared) + { + var prefix = sessionPrefix.TrimEnd('/'); + if (string.IsNullOrEmpty(declared) || declared == "/" || declared == "./") + return prefix + "/"; + if (declared.StartsWith(prefix + "/", StringComparison.Ordinal) || + declared.Equals(prefix, StringComparison.Ordinal)) + return declared.EndsWith('/') ? declared : declared + "/"; + var path = declared.StartsWith('/') ? declared : "/" + declared; + var combined = prefix + path.TrimEnd('/'); + return combined + "/"; + } + + private static string CookiePath(string path, string sessionPrefix) + { + var prefix = sessionPrefix.TrimEnd('/'); + if (string.IsNullOrEmpty(path) || path == "/") + return prefix; + if (path.StartsWith(prefix + "/", StringComparison.Ordinal) || path.Equals(prefix, StringComparison.Ordinal)) + return path; + if (!path.StartsWith('/')) + path = "/" + path; + return prefix + path; + } + + private static List ReadSetCookies(string headerText) + { + var cookies = new List(); + foreach (var line in headerText.Split("\r\n")) + { + const string name = "Set-Cookie:"; + if (line.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + cookies.Add(line[name.Length..].Trim()); + } + + return cookies; + } + + internal static void CopySecurityHeaders(string headerText, HttpListenerResponse response) + { + string[] names = + [ + "Content-Security-Policy", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + "Cross-Origin-Embedder-Policy", + ]; + foreach (var line in headerText.Split("\r\n")) + { + var colon = line.IndexOf(':'); + if (colon <= 0) + continue; + var name = line[..colon].Trim(); + if (!names.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + try + { + response.Headers[name] = line[(colon + 1)..].Trim(); + } + catch (ArgumentException) + { + } + } + } + + internal static string? ReadContentType(string headerText) + { + foreach (var line in headerText.Split("\r\n")) + { + const string name = "Content-Type:"; + if (line.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + return line[name.Length..].Trim(); + } + + return null; + } + + private sealed class Handoff + { + private readonly Func> _owned; + private readonly string _destination; + private readonly string _tlsHost; + private readonly int _port; + private int _delivered; + private static int _nextGeneration; + + public Handoff(Func> owned, string destination, string? tlsHost, int port, string sessionId) + { + _owned = owned; + _destination = destination; + _tlsHost = string.IsNullOrWhiteSpace(tlsHost) ? new Uri(destination).Host : tlsHost; + _port = port; + SessionId = sessionId; + Generation = Interlocked.Increment(ref _nextGeneration); + Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); + BrowserUrl = $"http://127.0.0.1:{port}/d/{Nonce}"; + } + + public string Nonce { get; } + public string SessionId { get; } + public int Generation { get; } + public string BrowserUrl { get; } + public int Port => _port; + public Task Owned() => _owned(); + + public async Task ExpireUnusedAsync() + { + await Task.Delay(TimeSpan.FromMinutes(2)); + if (Interlocked.CompareExchange(ref _delivered, 0, 0) == 0) + Live.TryRemove(Nonce, out _); + } + + public async Task DeliverAsync(HttpListenerContext context) + { + var response = context.Response; + var first = Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && await _owned(); + if (!first) + { + Interlocked.Exchange(ref _delivered, 1); + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + var html = ""; + var bytes = Encoding.UTF8.GetBytes(html); + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/html; charset=utf-8"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + response.Close(); + } + + private string SameOriginDestination() + { + var destination = new Uri(_destination); + var hash = destination.Fragment; + // The launch nonce is only the one-use /d/ page. The Control UI stores + // settings under gatewayUrl, including its path, so that path stays + // on the saved session for this destination. + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{_port}/s/{SessionId}/"); + hash = string.IsNullOrEmpty(hash) + ? "#gatewayUrl=" + gateway + : hash + "&gatewayUrl=" + gateway; + var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; + return $"http://127.0.0.1:{_port}/s/{SessionId}{path}{destination.Query}{hash}"; + } + + public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) + { + var destination = new Uri(_destination); + var query = context.Request.Url?.Query ?? ""; + var pathAndQuery = upstreamPath + query; + using var tcp = new TcpClient(); + await tcp.ConnectAsync(destination.Host, destination.Port); + if (!BackendIsOwned(tcp) || !await _owned()) + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + context.Response.Close(); + return; + } + + await using var raw = tcp.GetStream(); + Stream stream = raw; + if (destination.Scheme == "https") + { + var ssl = new SslStream(raw, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsClientAsync(_tlsHost); + stream = ssl; + } + + var bodyBytes = Array.Empty(); + if (context.Request.HasEntityBody) + { + using var incoming = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(incoming); + bodyBytes = incoming.ToArray(); + } + + var requestHeader = new StringBuilder(); + requestHeader.Append($"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\n"); + requestHeader.Append($"Host: {_tlsHost}\r\n"); + requestHeader.Append("Connection: close\r\n"); + foreach (var name in new[] { "Cookie", "Authorization", "Content-Type", "Accept", "Accept-Language" }) + { + var value = context.Request.Headers[name]; + if (!string.IsNullOrEmpty(value)) + requestHeader.Append($"{name}: {value}\r\n"); + } + if (bodyBytes.Length > 0) + requestHeader.Append($"Content-Length: {bodyBytes.Length}\r\n"); + requestHeader.Append("\r\n"); + await stream.WriteAsync(Encoding.ASCII.GetBytes(requestHeader.ToString())); + if (bodyBytes.Length > 0) + await stream.WriteAsync(bodyBytes); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + var payload = buffer.ToArray(); + var headerEnd = FindHeaderEnd(payload); + var body = headerEnd < 0 ? payload : payload[(headerEnd + 4)..]; + var headerText = Encoding.ASCII.GetString(payload, 0, headerEnd < 0 ? payload.Length : headerEnd); + if (ReadHeader(headerText, "Transfer-Encoding") is { } transfer && + transfer.Contains("chunked", StringComparison.OrdinalIgnoreCase)) + { + var decoded = DecodeChunked(body); + if (decoded is null) + { + context.Response.StatusCode = (int)HttpStatusCode.BadGateway; + context.Response.Close(); + return; + } + + body = decoded; + } + context.Response.StatusCode = StatusCode(payload); + CopySecurityHeaders(headerText, context.Response); + var sessionPrefix = "/s/" + SessionId; + foreach (var cookie in ReadSetCookies(headerText)) + { + try + { + context.Response.AppendHeader("Set-Cookie", AdaptSetCookie(cookie, sessionPrefix)); + } + catch (ArgumentException) + { + } + } + if (ReadHeader(headerText, "Location") is { } location) + { + try + { + context.Response.Headers["Location"] = RewriteLocation(location, sessionPrefix); + } + catch (ArgumentException) + { + } + } + if (ReadContentType(headerText) is { } contentType) + { + context.Response.ContentType = contentType; + var textBody = contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("json", StringComparison.OrdinalIgnoreCase); + if (textBody) + { + var text = Encoding.UTF8.GetString(body); + if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + text = RewriteRootAbsolute(text, sessionPrefix); + text = RewriteDeclaredResourceBase(text, sessionPrefix); + body = Encoding.UTF8.GetBytes(text); + } + } + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body); + context.Response.Close(); + } + + public async Task ProxyWebSocketAsync(HttpListenerContext context, string upstreamPath) + { + var browser = await context.AcceptWebSocketAsync(null); + var destination = new Uri(_destination); + var query = context.Request.Url?.Query ?? ""; + using var probe = new TcpClient(); + await probe.ConnectAsync(destination.Host, destination.Port); + if (!BackendIsOwned(probe) || !await _owned()) + { + probe.Close(); + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "closed", CancellationToken.None); + return; + } + + try + { + Stream socketStream = probe.GetStream(); + if (destination.Scheme == "https") + { + var ssl = new SslStream(socketStream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsClientAsync(_tlsHost); + socketStream = ssl; + } + + var key = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + var upgrade = + $"GET {upstreamPath}{query} HTTP/1.1\r\nHost: {_tlsHost}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"; + await socketStream.WriteAsync(Encoding.ASCII.GetBytes(upgrade)); + if (!await ReadUpgradeAcceptedAsync(socketStream)) + { + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "closed", CancellationToken.None); + return; + } + + using var upstream = WebSocket.CreateFromStream( + socketStream, + new WebSocketCreationOptions { IsServer = false }); + await Task.WhenAll( + PumpAsync(browser.WebSocket, upstream), + PumpAsync(upstream, browser.WebSocket)); + } + catch (Exception) + { + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "closed", CancellationToken.None); + } + } + + private static bool BackendIsOwned(TcpClient tcp) + { + if (tcp.Client.LocalEndPoint is not IPEndPoint local || + tcp.Client.RemoteEndPoint is not IPEndPoint remote) + return false; + var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(remote, local); + var listener = WindowsTcpListenerSnapshot.MatchListener( + WindowsTcpListenerSnapshot.Capture().Listeners, + remote); + return accepted is not null && listener is not null && accepted == listener.ProcessId; + } + + private static async Task ReadUpgradeAcceptedAsync(Stream stream) + { + using var header = new MemoryStream(); + var window = new byte[4]; + var one = new byte[1]; + while (await stream.ReadAsync(one) > 0) + { + header.WriteByte(one[0]); + window[0] = window[1]; + window[1] = window[2]; + window[2] = window[3]; + window[3] = one[0]; + if (window[0] == '\r' && window[1] == '\n' && window[2] == '\r' && window[3] == '\n') + break; + if (header.Length > 8192) + return false; + } + + var text = Encoding.ASCII.GetString(header.ToArray()); + return text.Contains(" 101 ", StringComparison.Ordinal); + } + + private static async Task PumpAsync(WebSocket from, WebSocket to) + { + var buffer = new byte[8192]; + while (from.State == WebSocketState.Open && to.State == WebSocketState.Open) + { + var result = await from.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + if (to.State == WebSocketState.Open) + await to.CloseAsync(WebSocketCloseStatus.NormalClosure, "closed", CancellationToken.None); + return; + } + + await to.SendAsync(buffer.AsMemory(0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None); + } + } + + private static int FindHeaderEnd(byte[] payload) + { + for (var i = 0; i + 3 < payload.Length; i++) + { + if (payload[i] == '\r' && payload[i + 1] == '\n' && payload[i + 2] == '\r' && payload[i + 3] == '\n') + return i; + } + + return -1; + } + + private static int StatusCode(byte[] payload) + { + var line = Encoding.ASCII.GetString(payload.AsSpan(0, Math.Min(payload.Length, 32))); + var parts = line.Split(' '); + return parts.Length > 1 && int.TryParse(parts[1], out var code) ? code : 502; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index e7cfae2d3..b0344856b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -37,6 +37,44 @@ public async Task OpenAsync(string? path = null) return true; } + public async Task OpenPreparedAsync( + string url, + Func>? confirmReady = null, + string? tlsHost = null, + string? originKey = null, + string? destinationIdentity = null) + { + // The ownership check that built this URL can be stale by the time the + // browser starts. Refuse the launch when that check no longer holds. + if (confirmReady is not null && !await confirmReady()) + return false; + + // The browser receives a loopback page with no credential. The credential + // is released only when that page is requested and ownership still holds. + var launchUrl = confirmReady is null + ? url + : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost, originKey, destinationIdentity); + return await OpenUrlAsync(launchUrl); + } + + private async Task OpenUrlAsync(string url) + { + try + { + if (!await launchBrowser(url)) + throw new InvalidOperationException("Windows did not open the Dashboard."); + } + catch (Exception ex) when (IsExpectedLaunchFailure(ex)) + { + // Browser failures can include the full credential-bearing URL. Do not log them. + reportFailure(); + return false; + } + + reportOpened?.Invoke(); + return true; + } + private static bool IsExpectedLaunchFailure(Exception error) => error is InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException or Win32Exception or COMException; diff --git a/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs new file mode 100644 index 000000000..ae3c8cca1 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs @@ -0,0 +1,172 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +public sealed class DashboardCredentialGateTests +{ + [Fact] + public void SharedToken_IsAppendedWhenItIsThePinnedCredential() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceSharedGatewayToken, + isBootstrapToken: false, + resolvedToken: "shared-secret", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.PinMismatch); + Assert.True(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, decision.CredentialSource); + Assert.Equal("shared-secret", decision.Token); + } + + [Fact] + public void DeviceToken_IsNeverAppendedEvenWhenASharedTokenIsStored() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceDeviceToken, + isBootstrapToken: false, + resolvedToken: "device-token", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.PinMismatch); + Assert.False(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceDeviceToken, decision.CredentialSource); + Assert.Null(decision.Token); + } + + [Fact] + public void BootstrapToken_IsNotAppended() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceBootstrapToken, + isBootstrapToken: true, + resolvedToken: "bootstrap-token", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceBootstrapToken, decision.CredentialSource); + Assert.Null(decision.Token); + } + + [Theory] + [InlineData(false, true, "shared-secret")] + [InlineData(true, false, "shared-secret")] + [InlineData(true, true, "changed-secret")] + public void PinMismatch_ReturnsNoTokenUrlMaterial( + bool pinMatches, bool samePinnedRecord, string pinnedSharedToken) + { + var decision = DashboardCredentialGate.Decide( + pinMatches, + samePinnedRecord, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceSharedGatewayToken, + isBootstrapToken: false, + resolvedToken: "shared-secret", + pinnedSharedToken); + + Assert.True(decision.PinMismatch); + Assert.False(decision.AppendToken); + Assert.Null(decision.Token); + Assert.Equal("none", decision.CredentialSource); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(false, false)] + public void PinnedSshHttpResolver_OnlyAppendsSharedToken( + bool hasSharedToken, bool hasDeviceToken) + { + using var temp = new TempDirectory(); + var record = new GatewayRecord + { + Id = "paired-ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = hasSharedToken ? "shared-secret" : null, + BootstrapToken = "bootstrap-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + }; + var registry = new GatewayRegistry(temp.Path); + registry.AddOrUpdate(record); + var identityDirectory = registry.GetIdentityDirectory(record.Id); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + if (hasDeviceToken) + identity.StoreDeviceTokenForRole("operator", "paired-device-secret"); + + var operatorCredential = new CredentialResolver(DeviceIdentityFileReader.Instance) + .ResolveOperator(record, identityDirectory); + Assert.NotNull(operatorCredential); + Assert.Equal(hasDeviceToken ? CredentialResolver.SourceDeviceToken + : CredentialResolver.SourceBootstrapToken, operatorCredential.Source); + + var authorized = false; + Assert.True(InteractiveGatewayCredentialResolver.TryResolveRecord( + record, identityDirectory, DeviceIdentityFileReader.Instance, + (pinned, candidate) => + { + Assert.Same(record, pinned); + Assert.Equal(hasSharedToken ? "shared-secret" : operatorCredential.Token, candidate.Token); + authorized = true; + return true; + }, + out var credential, out var rejected)); + Assert.True(authorized); + Assert.False(rejected); + Assert.NotNull(credential); + + var tunnel = new SshTunnelSnapshot(true, "user", "gateway.example", 18789, 45678, + 0, 0, DateTime.UtcNow, null, TunnelStatus.Up); + Assert.True(GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, tunnel, out var endpoint, out var appendSharedToken, listenerOwned: true)); + Assert.Equal("ws://localhost:45678", endpoint); + var decision = DashboardCredentialGate.Decide( + pinMatches: true, samePinnedRecord: true, appendSharedToken, + credential.Source, credential.IsBootstrapToken, credential.Token, record.SharedGatewayToken); + + Assert.False(decision.PinMismatch); + Assert.Equal(hasSharedToken, decision.AppendToken); + Assert.Equal(hasSharedToken ? "shared-secret" : null, decision.Token); + Assert.Equal(hasSharedToken ? CredentialResolver.SourceSharedGatewayToken + : operatorCredential.Source, decision.CredentialSource); + } + + [Fact] + public void PinnedSshHttpResolver_AuthorizationRejectionDoesNotFallBackToDeviceToken() + { + using var temp = new TempDirectory(); + var identity = new DeviceIdentity(temp.Path); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "paired-device-secret"); + var record = new GatewayRecord + { + Id = "paired-ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = "shared-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + }; + + Assert.False(InteractiveGatewayCredentialResolver.TryResolveRecord( + record, temp.Path, DeviceIdentityFileReader.Instance, + (pinned, candidate) => + { + Assert.Same(record, pinned); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, candidate.Source); + return false; + }, + out var credential, out var rejected)); + + Assert.True(rejected); + Assert.Null(credential); + } +} diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs index 5511d22c9..964b52e60 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs @@ -1,3 +1,5 @@ +using OpenClaw.Shared; + namespace OpenClaw.Connection.Tests; public sealed class GatewayClientEndpointResolverTests @@ -52,4 +54,161 @@ public void Resolve_RejectsInvalidTunnelLocalPort(int localPort) Assert.Contains("local port", error.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordDoesNotOpenSavedUrlWhenTunnelIsDown() + { + var record = SshRecord(); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel: null, + out var endpoint, + out var appendSharedToken); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordUsesTunnelOnlyWhenThatForwardIsUp() + { + var record = SshRecord(); + var tunnel = UpTunnel(localPort: 45678); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: true); + + Assert.True(opened); + Assert.Equal("ws://localhost:45678", endpoint); + Assert.True(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordKeepsSavedRouteOnTheLocalForward() + { + var record = SshRecord() with + { + Url = "wss://gateway.example/mount/?view=compact#section", + }; + var tunnel = UpTunnel(localPort: 45678); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: true); + + Assert.True(opened); + Assert.Equal("wss://localhost:45678/mount/?view=compact#section", endpoint); + Assert.True(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordRejectsDifferentSshServerPort() + { + var record = SshRecord(sshPort: 2222); + var tunnel = UpTunnel(localPort: 45678, currentSshPort: 22); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: true); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.Equal("", endpoint); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordDoesNotOpenWhenListenerIsNotOwned() + { + var record = SshRecord(); + var tunnel = UpTunnel(localPort: 45678, currentSshPort: 22); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: false); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.Equal("", endpoint); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordRejectsADifferentLocalForward() + { + var record = SshRecord(); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + UpTunnel(localPort: 9999), + out var endpoint, + out var appendSharedToken); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_DirectRecordKeepsSavedUrl() + { + var record = new GatewayRecord + { + Id = "direct", + Url = "ws://127.0.0.1:18789", + SharedGatewayToken = "shared-secret", + }; + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel: null, + out var endpoint, + out var appendSharedToken); + + Assert.True(opened); + Assert.Equal("ws://127.0.0.1:18789", endpoint); + Assert.True(appendSharedToken); + } + + private static GatewayRecord SshRecord(int sshPort = 22) => new() + { + Id = "ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = "shared-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678, SshPort: sshPort), + }; + + private static SshTunnelSnapshot UpTunnel(int localPort, int currentSshPort = 22) => new( + IsRunning: true, + CurrentUser: "user", + CurrentHost: "gateway.example", + CurrentRemotePort: 18789, + CurrentLocalPort: localPort, + CurrentBrowserProxyRemotePort: 0, + CurrentBrowserProxyLocalPort: 0, + StartedAtUtc: new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), + LastError: null, + Status: TunnelStatus.Up, + CurrentSshPort: currentSshPort); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 73a94b7eb..dcc7d8e21 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -481,6 +481,78 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.DoesNotContain("Process.Start", method); } + [Fact] + public void Dashboard_PinnedCredentialUsesHttpResolverBetweenPinChecks() + { + var method = ExtractMethod(ReadAppSources(), "TryResolvePinnedDashboardCredential"); + + // Retire when the WinUI adapter can be invoked directly by a unit test. + AssertInOrder(method, + "DashboardPinStillMatches(pinned)", + "_gatewayRegistry.GetIdentityDirectory(pinned.Id)", + "InteractiveGatewayCredentialResolver.TryResolveRecord(", + "DeviceIdentityFileReader.Instance", + "IsCredentialAllowed(record, candidate)", + "DashboardPinStillMatches(pinned)", + "token = credential.Token"); + Assert.DoesNotContain("ResolveOperator(", method); + Assert.DoesNotContain("TryResolveChatCredentials(", method); + } + + [Theory] + [InlineData("App.xaml.cs", "OpenDashboardAsync")] + [InlineData("Pages\\ConnectionPage.xaml.cs", "OnSavedRowOpenDashboardAsync")] + [InlineData("App.CapabilityHandlers.cs", "app.DashboardUrlHandler =")] + public void Dashboard_AllSshCallersKeepPinnedCredentialAndEndpointGates(string file, string methodName) + { + var source = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", + file.Replace('\\', Path.DirectorySeparatorChar))); + var method = file == "App.CapabilityHandlers.cs" + ? source[source.IndexOf(methodName, StringComparison.Ordinal).. + source.IndexOf("app.ChatSnapshotHandler =", StringComparison.Ordinal)] + : ExtractMethod(source, methodName); + + Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", method); + Assert.Contains("TryResolvePinnedDashboardCredential(", method); + Assert.Contains("GatewayClientEndpointResolver.TryResolveDashboardEndpoint(", method); + AssertInOrder(method, "TryResolvePinnedDashboardCredential(", + "DashboardCredentialGate.Decide(", "DashboardPinStillMatches(pinned)", + "pinned.SharedGatewayToken", "GatewayDashboardUrlBuilder.Build("); + Assert.Contains("CredentialResolver.SourceSharedGatewayToken", method); + if (file == "App.xaml.cs") + { + Assert.Contains("LaunchPreparedDashboardAsync(", method); + Assert.DoesNotContain("OpenDashboardUri", method); + Assert.DoesNotContain("ex.Message", method); + var prepared = ExtractMethod(source, "LaunchPreparedDashboardAsync"); + Assert.Contains("ConfirmIssuedDashboardListenerAsync", prepared); + var confirm = ExtractMethod(source, "ConfirmIssuedDashboardListenerAsync"); + Assert.Contains("DashboardIssuedBinding.Matches", confirm); + Assert.Contains("IsDashboardListenerOwnedAsync(issuedTunnel)", confirm); + Assert.Contains("TryResolveDashboardEndpoint(", confirm); + } + + if (file == "Pages\\ConnectionPage.xaml.cs") + { + Assert.Contains("SavedDashboardListenerStillOwnedAsync(", method); + var recheck = ExtractMethod(source, "SavedDashboardListenerStillOwnedAsync"); + Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", recheck); + var buildAt = method.IndexOf("GatewayDashboardUrlBuilder.Build(", StringComparison.Ordinal); + var recheckAt = method.IndexOf("SavedDashboardListenerStillOwnedAsync(", StringComparison.Ordinal); + var launchAt = method.IndexOf("LaunchUriAsync", StringComparison.Ordinal); + Assert.True(buildAt >= 0 && recheckAt > buildAt && launchAt > recheckAt); + } + + if (file == "App.CapabilityHandlers.cs") + { + var first = method.IndexOf("IsDashboardListenerOwnedAsync(ssh)", StringComparison.Ordinal); + var buildAt = method.IndexOf("GatewayDashboardUrlBuilder.Build(", StringComparison.Ordinal); + var second = method.IndexOf("IsDashboardListenerOwnedAsync(ssh)", buildAt, StringComparison.Ordinal); + Assert.True(first >= 0 && buildAt > first && second > buildAt); + } + } + [Fact] public void SshTunnelExit_RecoversActiveRegistryGatewayThroughConnectionManager() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index fb1e2138f..c8ffd7d5c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -102,6 +102,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 9d725a8f0..15fe07299 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -1,3 +1,7 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; using OpenClaw.Connection; using OpenClaw.SetupEngine; using OpenClawTray.Services; @@ -194,6 +198,391 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA Assert.Equal(1, opened); } + [Fact] + public void IssuedBinding_RejectsSwitchToAnotherGateway() + { + var tunnel = new SshTunnelConfig("root", "gateway.example", 18789, 45678, false, 22); + var issued = new GatewayRecord + { + Id = "gateway-a", + Url = "wss://gateway.example/mount/", + SharedGatewayToken = "token-a", + SshTunnel = tunnel, + }; + var other = new GatewayRecord { Id = "gateway-b", Url = "ws://127.0.0.1:18789/" }; + + Assert.True(DashboardIssuedBinding.Matches(issued, tunnel, issued)); + Assert.False(DashboardIssuedBinding.Matches(issued, tunnel, other)); + Assert.False(DashboardIssuedBinding.Matches(issued, tunnel, null)); + } + + [Fact] + public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://localhost:45678/mount/?view=compact#section&token=secret"; + var open = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination); + Assert.DoesNotContain("token=", open, StringComparison.OrdinalIgnoreCase); + Assert.Contains($":{DashboardCredentialHandoff.Port}/", open, StringComparison.Ordinal); + using var http = new HttpClient(); + var first = await http.GetStringAsync(open); + Assert.Contains("token=secret", first, StringComparison.Ordinal); + Assert.Contains("/s/", first, StringComparison.Ordinal); + Assert.DoesNotContain("45678", first, StringComparison.Ordinal); + Assert.Equal( + "application/javascript", + DashboardCredentialHandoff.ReadContentType( + "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\n\r\n")); + Assert.Equal( + "", + DashboardCredentialHandoff.RewriteRootAbsolute( + "", + "/s/abc")); + Assert.Equal("/s/abc/assets/app.js", DashboardCredentialHandoff.RewriteLocation("/assets/app.js", "/s/abc")); + var other = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "other-gateway"); + Assert.DoesNotContain($":{DashboardCredentialHandoff.Port}/", other, StringComparison.Ordinal); + Assert.Equal( + 42, + WindowsTcpListenerSnapshot.MatchAcceptedProcess( + [ + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 45678, IPAddress.Loopback, 51000, 42), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 51000, IPAddress.Loopback, 45678, 7), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.IPv6Loopback, 45678, IPAddress.IPv6Loopback, 51000, 9), + ], + new IPEndPoint(IPAddress.Loopback, 45678), + new IPEndPoint(IPAddress.Loopback, 51000))); + Assert.Equal( + 9, + WindowsTcpListenerSnapshot.MatchAcceptedProcess( + [ + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 45678, IPAddress.Loopback, 51000, 42), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.IPv6Loopback, 45678, IPAddress.IPv6Loopback, 51000, 9), + ], + new IPEndPoint(IPAddress.IPv6Loopback, 45678), + new IPEndPoint(IPAddress.IPv6Loopback, 51000))); + Assert.Equal( + 9, + WindowsTcpListenerSnapshot.MatchListener( + [ + new WindowsTcpListenerInfo(IPAddress.Loopback, 45678, 42, null, null), + new WindowsTcpListenerInfo(IPAddress.IPv6Loopback, 45678, 9, null, null), + ], + new IPEndPoint(IPAddress.IPv6Loopback, 45678))?.ProcessId); + Assert.Equal( + "hello", + Encoding.ASCII.GetString(DashboardCredentialHandoff.DecodeChunked( + Encoding.ASCII.GetBytes("5\r\nhello\r\n0\r\n\r\n"))!)); + var denied = await http.GetAsync(open); + Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); + Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + var closed = DashboardCredentialHandoff.Start(() => Task.FromResult(false), destination, originKey: "closed-gateway"); + var blocked = await http.GetAsync(closed); + Assert.Equal(HttpStatusCode.NotFound, blocked.StatusCode); + Assert.DoesNotContain("secret", await blocked.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + [Fact] + public async Task DashboardHandoff_RejectsAnotherGatewaysNonceOnThisOrigin() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://localhost:45678/mount/?view=compact#token=secret"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-b"); + var firstUri = new Uri(first); + var secondUri = new Uri(second); + Assert.NotEqual(firstUri.Port, secondUri.Port); + var nonceB = secondUri.AbsolutePath["/d/".Length..]; + using var http = new HttpClient(); + var cross = await http.GetAsync($"http://127.0.0.1:{firstUri.Port}/s/{nonceB}/"); + Assert.Equal(HttpStatusCode.NotFound, cross.StatusCode); + Assert.DoesNotContain("secret", await cross.Content.ReadAsStringAsync(), StringComparison.Ordinal); + var referred = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{firstUri.Port}/assets/app.js"); + referred.Headers.Referrer = new Uri($"http://127.0.0.1:{secondUri.Port}/s/{nonceB}/"); + var referredResponse = await http.SendAsync(referred); + Assert.Equal(HttpStatusCode.NotFound, referredResponse.StatusCode); + } + + [Fact] + public void DashboardHandoff_ReusesSavedOriginAfterRestart() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://localhost:45678/mount/"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var port = new Uri(first).Port; + DashboardCredentialHandoff.ResetOriginsForTests(); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + Assert.Equal(port, new Uri(second).Port); + Assert.Equal(port, new Uri(DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a")).Port); + } + + [Fact] + public void DashboardHandoff_RetargetedSavedGatewayGetsANewOrigin() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + var tunnel = new SshTunnelConfig("root", "gateway.example", 18789, 45678, false, 22); + var firstRecord = new GatewayRecord + { + Id = "gateway-a", + Url = "wss://gateway-a.example/mount/", + SshTunnel = tunnel, + }; + var retargeted = firstRecord with { Url = "wss://gateway-b.example/mount/" }; + const string proxied = "http://127.0.0.1:45678/mount/"; + var first = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: firstRecord.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(firstRecord)); + var second = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: retargeted.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(retargeted)); + Assert.NotEqual(new Uri(first).Port, new Uri(second).Port); + + DashboardCredentialHandoff.ResetOriginsForTests(); + var restored = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: firstRecord.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(firstRecord)); + Assert.Equal(new Uri(first).Port, new Uri(restored).Port); + } + + [Fact] + public void DashboardHandoff_LegacyOriginFileDoesNotFollowARetarget() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + var path = origins.Combine("origins.json"); + DashboardCredentialHandoff.OriginStorePath = path; + DashboardCredentialHandoff.ResetOriginsForTests(); + File.WriteAllText(path, "{\"gateway-a\":" + DashboardCredentialHandoff.Port + "}"); + var kept = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + "https://gateway-a.example/mount/", + originKey: "gateway-a"); + Assert.Equal(DashboardCredentialHandoff.Port, new Uri(kept).Port); + var moved = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + "https://gateway-b.example/mount/", + originKey: "gateway-a"); + Assert.NotEqual(DashboardCredentialHandoff.Port, new Uri(moved).Port); + } + + [Fact] + public async Task DashboardHandoff_GatewayUrlStaysOnTheSavedSession() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://gateway-a.example/mount/#section"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + using var http = new HttpClient(); + var firstUrl = GatewayUrlFrom(await http.GetStringAsync(first)); + var secondUrl = GatewayUrlFrom(await http.GetStringAsync(second)); + Assert.Equal(firstUrl, secondUrl); + var launchNonce = new Uri(first).AbsolutePath["/d/".Length..]; + Assert.DoesNotContain(launchNonce, firstUrl, StringComparison.Ordinal); + Assert.Contains("/s/", firstUrl, StringComparison.Ordinal); + } + + [Fact] + public void DashboardHandoff_ResourceBaseAndCookieFollowTheSession() + { + const string session = "/s/STABLE"; + var html = DashboardCredentialHandoff.RewriteDeclaredResourceBase( + "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/\";", + session); + Assert.Contains("window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/s/STABLE/\"", html, StringComparison.Ordinal); + Assert.Contains("", html, StringComparison.Ordinal); + Assert.Equal( + "{\"basePath\":\"/s/STABLE/openclaw/\",\"ok\":true}", + DashboardCredentialHandoff.RewriteDeclaredResourceBase("{\"basePath\":\"/openclaw\",\"ok\":true}", session)); + Assert.Equal( + "{\"ok\":true}", + DashboardCredentialHandoff.RewriteDeclaredResourceBase("{\"ok\":true}", session)); + var cookie = DashboardCredentialHandoff.AdaptSetCookie( + "sid=abc; Path=/; Domain=gateway.example; Secure; HttpOnly; SameSite=None", + session); + Assert.Contains("sid=abc", cookie, StringComparison.Ordinal); + Assert.Contains("Path=/s/STABLE", cookie, StringComparison.Ordinal); + Assert.Contains("HttpOnly", cookie, StringComparison.Ordinal); + Assert.Contains("SameSite=Lax", cookie, StringComparison.Ordinal); + Assert.DoesNotContain("Domain=", cookie, StringComparison.Ordinal); + Assert.DoesNotContain("Secure", cookie, StringComparison.Ordinal); + } + + [Fact] + public async Task DashboardHandoff_ProxiesSessionAssetsWithoutRefererAndKeepsCookies() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var upstreamPort = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + for (var request = 0; request < 2; request++) + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + var buffer = new byte[4096]; + var read = 0; + while (read < 4 || !Encoding.ASCII.GetString(buffer, 0, read).Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var count = await stream.ReadAsync(buffer.AsMemory(read)); + if (count == 0) + break; + read += count; + } + + var head = Encoding.ASCII.GetString(buffer, 0, read); + var htmlBody = "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/\";"; + var jsonBody = "{\"basePath\":\"/\"}"; + var response = head.Contains("GET /__openclaw/control-ui-config.json", StringComparison.Ordinal) + ? "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + jsonBody.Length + + "\r\nConnection: close\r\n\r\n" + jsonBody + : "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: sid=abc; Path=/; Domain=gateway.example; Secure\r\nSet-Cookie: other=1; Path=/plugin; HttpOnly\r\nContent-Length: " + + htmlBody.Length + "\r\nConnection: close\r\n\r\n" + htmlBody; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response)); + } + }); + + var open = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + $"http://127.0.0.1:{upstreamPort}/mount/", + originKey: "asset-gateway"); + using var handler = new HttpClientHandler { UseCookies = false }; + using var http = new HttpClient(handler); + var handoff = await http.GetStringAsync(open); + var marker = "location.replace("; + var start = handoff.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var jsonStart = start + marker.Length; + var jsonEnd = handoff.IndexOf(")", jsonStart, StringComparison.Ordinal); + var sessionUrl = System.Text.Json.JsonSerializer.Deserialize(handoff[jsonStart..jsonEnd]); + Assert.NotNull(sessionUrl); + var page = await http.GetAsync(sessionUrl); + var html = await page.Content.ReadAsStringAsync(); + Assert.Contains("window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/s/", html, StringComparison.Ordinal); + Assert.True(page.Headers.TryGetValues("Set-Cookie", out var cookies)); + var cookieText = string.Join("\n", cookies); + Assert.Contains("sid=abc", cookieText, StringComparison.Ordinal); + Assert.Contains("other=1", cookieText, StringComparison.Ordinal); + Assert.DoesNotContain("Domain=", cookieText, StringComparison.Ordinal); + var session = new Uri(sessionUrl); + var slash = session.AbsolutePath.IndexOf('/', 3); + var sessionRoot = slash < 0 ? session.AbsolutePath : session.AbsolutePath[..slash]; + var asset = new Uri($"http://127.0.0.1:{session.Port}{sessionRoot}/__openclaw/control-ui-config.json"); + var config = await http.GetStringAsync(asset); + Assert.Contains("\"basePath\":\"/s/", config, StringComparison.Ordinal); + Assert.DoesNotContain("\"basePath\":\"/\"", config, StringComparison.Ordinal); + } + + private static string GatewayUrlFrom(string page) + { + const string marker = "gatewayUrl="; + var start = page.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var value = page[(start + marker.Length)..]; + var end = value.IndexOfAny(['&', '"', '<']); + if (end >= 0) + value = value[..end]; + return Uri.UnescapeDataString(value); + } + + [Fact] + public async Task AcceptedProcessId_MatchesTheIpv6LoopbackConnection() + { + using var listener = new TcpListener(IPAddress.IPv6Loopback, 0); + listener.Start(); + var server = (IPEndPoint)listener.LocalEndpoint; + using var client = new TcpClient(AddressFamily.InterNetworkV6); + await client.ConnectAsync(server); + var local = (IPEndPoint)client.Client.LocalEndPoint!; + using var accepted = await listener.AcceptTcpClientAsync(); + Assert.Equal(Environment.ProcessId, WindowsTcpListenerSnapshot.AcceptedProcessId(server, local)); + var match = WindowsTcpListenerSnapshot.MatchListener(WindowsTcpListenerSnapshot.Capture().Listeners, server); + Assert.Equal(Environment.ProcessId, match?.ProcessId); + } + + [Fact] + public async Task DashboardHandoff_DecodesChunkedUpstreamBody() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var upstreamPort = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + var buffer = new byte[2048]; + var read = 0; + while (read < 4 || !Encoding.ASCII.GetString(buffer, 0, read).Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var count = await stream.ReadAsync(buffer.AsMemory(read)); + if (count == 0) + break; + read += count; + } + var response = + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + "b\r\n{\"ok\":true}\r\n0\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response)); + }); + + var open = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + $"http://127.0.0.1:{upstreamPort}/item", + originKey: "chunk-gateway"); + using var http = new HttpClient(); + var page = await http.GetStringAsync(open); + var marker = "location.replace("; + var start = page.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var jsonStart = start + marker.Length; + var jsonEnd = page.IndexOf(")", jsonStart, StringComparison.Ordinal); + var sessionUrl = System.Text.Json.JsonSerializer.Deserialize(page[jsonStart..jsonEnd]); + Assert.NotNull(sessionUrl); + var body = await http.GetStringAsync(sessionUrl); + Assert.Equal("{\"ok\":true}", body); + Assert.DoesNotContain("\r\n", body, StringComparison.Ordinal); + } + + [Fact] + public async Task OpenPrepared_RefusesBrowserWhenOwnershipRecheckFails() + { + var launches = 0; + var launcher = new GatewayDashboardLauncher( + () => true, + () => null, + _ => + { + launches++; + return Task.FromResult(true); + }, + () => throw new InvalidOperationException("Unexpected failure")); + + Assert.False(await launcher.OpenPreparedAsync( + "https://localhost/control#token=secret", + () => Task.FromResult(false))); + Assert.Equal(0, launches); + } + private sealed class StartupClock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);