diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs new file mode 100644 index 000000000..6ab63b81a --- /dev/null +++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs @@ -0,0 +1,77 @@ +namespace OpenClaw.Connection; + +/// +/// Gateway and SSH forward captured together for one dashboard launch. +/// +public sealed record DashboardGatewayTunnelSnapshot( + string GatewayIdentity, + string GatewayUrl, + string Token, + bool AppendToken, + SshTunnelConfig? Tunnel, + bool IsBootstrapToken = false, + string Source = ""); + +/// +/// Binds a dashboard URL to the gateway and SSH destination checked before launch. +/// +public static class DashboardBrowserHandoff +{ + public static bool SameBinding( + DashboardGatewayTunnelSnapshot before, + DashboardGatewayTunnelSnapshot after) + { + if (!string.Equals(before.GatewayIdentity, after.GatewayIdentity, StringComparison.OrdinalIgnoreCase)) + return false; + + return SameTunnelDestination(before.Tunnel, after.Tunnel); + } + + public static bool SameTunnelDestination(SshTunnelConfig? left, SshTunnelConfig? right) + { + if (left is null || right is null) + return left is null && right is null; + + return string.Equals(left.User.Trim(), right.User.Trim(), StringComparison.Ordinal) && + string.Equals(left.Host.Trim(), right.Host.Trim(), StringComparison.OrdinalIgnoreCase) && + left.RemotePort == right.RemotePort && + left.LocalPort == right.LocalPort && + left.SshPort == right.SshPort && + left.IncludeBrowserProxyForward == right.IncludeBrowserProxyForward; + } + + public static string ProjectOntoLocalForward(string savedGatewayUrl, int localPort) + { + ArgumentException.ThrowIfNullOrWhiteSpace(savedGatewayUrl); + var normalized = savedGatewayUrl + .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) + .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase); + var endpoint = new Uri(normalized, UriKind.Absolute); + var builder = new UriBuilder(endpoint) + { + Scheme = endpoint.Scheme is "https" ? "https" : "http", + Host = "127.0.0.1", + Port = localPort, + }; + return builder.Uri.AbsoluteUri; + } + + public static bool UrlUsesCapturedForward(string url, DashboardGatewayTunnelSnapshot captured) + { + if (captured.Tunnel is not SshTunnelConfig tunnel) + return false; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + var loopback = uri.Host is "127.0.0.1" or "localhost" or "::1"; + if (!loopback || uri.Port != tunnel.LocalPort) + return false; + if (!captured.AppendToken) + return true; + if (string.IsNullOrEmpty(captured.Token)) + return false; + + var marker = "token=" + Uri.EscapeDataString(captured.Token); + return uri.Fragment.Contains(marker, StringComparison.Ordinal); + } +} diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs new file mode 100644 index 000000000..28e259438 --- /dev/null +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace OpenClaw.Connection; + +/// +/// Owns a loopback forward port after SSH teardown so a different process +/// cannot accept a dashboard URL that was already handed to the browser. +/// The listener stays up while a replacement SSH process binds a backend port. +/// +internal static class DashboardForwardPortGuard +{ + private sealed class Slot + { + public required TcpListener Listener { get; init; } + public TcpListener? V6Listener { get; init; } + public required CancellationTokenSource Cancel { get; init; } + public int? BackendPort { get; set; } + public int? BackendProcessId { get; set; } + public string? User { get; set; } + public string? Host { get; set; } + public int? RemotePort { get; set; } + public int? SshPort { get; set; } + public DateTime? BackendStartTimeUtc { get; set; } + } + + private static readonly object Gate = new(); + private static readonly Dictionary Listeners = new(); + + internal static void Hold(int port) + { + if (port is < 1 or > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + + lock (Gate) + { + if (Listeners.ContainsKey(port)) + return; + + var listener = new TcpListener(IPAddress.Loopback, port); + TcpListener? v6 = null; + try + { + listener.Start(); + if (Socket.OSSupportsIPv6) + { + v6 = new TcpListener(IPAddress.IPv6Loopback, port); + v6.Server.DualMode = false; + v6.Start(); + } + } + catch + { + try { listener.Stop(); } catch (SocketException) { } + try { v6?.Stop(); } catch (SocketException) { } + throw; + } + + var cancel = new CancellationTokenSource(); + var slot = new Slot { Listener = listener, V6Listener = v6, Cancel = cancel }; + Listeners[port] = slot; + _ = AcceptAsync(port, slot, listener); + if (v6 is not null) + _ = AcceptAsync(port, slot, v6); + } + } + + internal static void ClearBackend(int port) + { + lock (Gate) + { + if (!Listeners.TryGetValue(port, out var slot)) + return; + slot.BackendPort = null; + slot.BackendProcessId = null; + slot.BackendStartTimeUtc = null; + } + } + + internal static bool AllowsDestination(int port, string user, string host, int remotePort, int sshPort) + { + lock (Gate) + { + if (!Listeners.TryGetValue(port, out var slot)) + return true; + if (slot.User is null || slot.Host is null || slot.RemotePort is null || slot.SshPort is null) + { + slot.User = user; + slot.Host = host; + slot.RemotePort = remotePort; + slot.SshPort = sshPort; + return true; + } + + return string.Equals(slot.User, user, StringComparison.Ordinal) && + string.Equals(slot.Host, host, StringComparison.Ordinal) && + slot.RemotePort == remotePort && + slot.SshPort == sshPort; + } + } + + internal static void SetBackend(int port, int backendPort, int backendProcessId, DateTime backendStartTimeUtc) + { + lock (Gate) + { + if (Listeners.TryGetValue(port, out var slot)) + { + slot.BackendPort = backendPort; + slot.BackendProcessId = backendProcessId; + slot.BackendStartTimeUtc = backendStartTimeUtc; + } + } + } + + internal static void Release(int port) + { + Slot? slot; + lock (Gate) + { + if (!Listeners.Remove(port, out slot)) + return; + } + + slot.Cancel.Cancel(); + try + { + slot.Listener.Stop(); + } + catch (SocketException) + { + } + + try + { + slot.V6Listener?.Stop(); + } + catch (SocketException) + { + } + } + + internal static bool IsHolding(int port) + { + lock (Gate) + return Listeners.ContainsKey(port); + } + + internal static int? BackendPort(int port) + { + lock (Gate) + return Listeners.TryGetValue(port, out var slot) ? slot.BackendPort : null; + } + + private static async Task AcceptAsync(int port, Slot slot, TcpListener listener) + { + try + { + while (!slot.Cancel.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false); + _ = Pump(port, client); + } + } + catch (OperationCanceledException) + { + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + + private static async Task Pump(int port, TcpClient client) + { + using (client) + { + int? backendPort; + int? backendProcessId; + lock (Gate) + { + if (!Listeners.TryGetValue(port, out var slot)) + return; + backendPort = slot.BackendPort; + backendProcessId = slot.BackendProcessId; + } + + DateTime? backendStart; + lock (Gate) + { + backendStart = Listeners.TryGetValue(port, out var remembered) ? remembered.BackendStartTimeUtc : null; + } + + if (backendPort is not int target || backendProcessId is not int processId || processId <= 0) + return; + if (!ProcessStillStartedAt(processId, backendStart)) + return; + + try + { + using var backend = new TcpClient(); + backend.Connect(IPAddress.Loopback, target); + if (backend.Client.LocalEndPoint is not IPEndPoint clientEnd || + backend.Client.RemoteEndPoint is not IPEndPoint serverEnd || + serverEnd.Port != target || + !IPAddress.IsLoopback(serverEnd.Address) || + !WindowsTcpListenerSnapshot.TryConfirmAcceptedLoopbackOwner( + serverEnd.Port, + clientEnd.Port, + processId)) + { + return; + } + + var left = client.GetStream(); + var right = backend.GetStream(); + using var stop = new CancellationTokenSource(); + var toRight = left.CopyToAsync(right, stop.Token); + var toLeft = right.CopyToAsync(left, stop.Token); + await Task.WhenAny(toRight, toLeft).ConfigureAwait(false); + stop.Cancel(); + client.Close(); + backend.Close(); + try + { + await Task.WhenAll(toRight, toLeft).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (IOException) + { + } + catch (ObjectDisposedException) + { + } + } + catch (IOException) + { + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + } + + private static bool ProcessStillStartedAt(int processId, DateTime? startTimeUtc) + { + if (startTimeUtc is null) + return false; + + try + { + using var process = Process.GetProcessById(processId); + var actual = process.StartTime.ToUniversalTime(); + return Math.Abs((actual - startTimeUtc.Value).TotalSeconds) < 2; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return false; + } + } + +} diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs index b2239b129..3a78227da 100644 --- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs +++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs @@ -16,6 +16,7 @@ public static string Resolve(GatewayRecord record) if (tunnel.LocalPort is < 1 or > 65535) throw new InvalidOperationException("SSH tunnel local port must be between 1 and 65535."); - return $"ws://localhost:{tunnel.LocalPort}"; + var host = DashboardForwardPortGuard.IsHolding(tunnel.LocalPort) ? "127.0.0.1" : "localhost"; + return $"ws://{host}:{tunnel.LocalPort}"; } } diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs index d69fc8f74..4d92f3b4e 100644 --- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -84,7 +84,8 @@ public static bool TryResolve( active.Url, active.SharedGatewayToken!, false, - CredentialResolver.SourceSharedGatewayToken); + CredentialResolver.SourceSharedGatewayToken, + active.Id); return true; } @@ -103,7 +104,8 @@ public static bool TryResolve( active.Url, resolved.Token, resolved.IsBootstrapToken, - resolved.Source); + resolved.Source, + active.Id); return true; } @@ -148,7 +150,8 @@ public static bool TryResolve( gatewayUrl, legacyCredential.Token, legacyCredential.IsBootstrapToken, - legacyCredential.Source); + legacyCredential.Source, + legacyRecord.Id); return true; } } @@ -157,4 +160,5 @@ public sealed record InteractiveGatewayCredential( string GatewayUrl, string Token, bool IsBootstrapToken, - string Source); + string Source, + string? GatewayId = null); diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 14c054c14..be16d891a 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -1,10 +1,21 @@ using OpenClaw.Shared; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Net; +using System.Net.Sockets; namespace OpenClaw.Connection; +/// +/// SSH process that passed listener ownership for one dashboard launch. +/// +public readonly record struct SettingsOwnedForwardBinding( + bool Owned, + long Generation, + int LocalPort, + int ProcessId); + /// /// Manages an SSH local port-forward process for gateway access. /// @@ -19,6 +30,11 @@ public sealed class SshTunnelService : ISshTunnelManager private SshTunnelOwner _currentOwner; private string? _lastSpec; private long _lifecycleGeneration; + private int _browserHandoffLeases; + private readonly HashSet _dashboardProtectedPorts = new(); + private readonly List _browserHandoffs = new(); + private long _nextBrowserHandoffId; + private DeferredTunnelStop _deferredStop; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). public event EventHandler? TunnelExited; @@ -50,6 +66,29 @@ public long OwnershipGeneration } } } + + public bool HasDeferredStop + { + get + { + lock (_stateLock) + { + return _deferredStop != DeferredTunnelStop.None; + } + } + } + + public int BrowserHandoffLeaseCount + { + get + { + lock (_stateLock) + { + return _browserHandoffLeases; + } + } + } + public SshTunnelConfig? ActiveConfig { get @@ -60,7 +99,7 @@ public SshTunnelConfig? ActiveConfig } } } - public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null; + public string? LocalTunnelUrl => IsActive ? $"ws://{LoopbackWebSocketHost(CurrentLocalPort)}:{CurrentLocalPort}" : null; public string? CurrentUser { get; private set; } public string? CurrentHost { get; private set; } public int CurrentRemotePort { get; private set; } @@ -184,6 +223,7 @@ private void EnsureStartedCore( tunnel.IncludeBrowserProxyForward, tunnel.SshPort); + Process? claimedProcess; lock (_stateLock) { if (IsRunningLocked() && string.Equals(_lastSpec, spec, StringComparison.Ordinal)) @@ -192,9 +232,16 @@ private void EnsureStartedCore( Status = TunnelStatus.Up; return; } + + if (_browserHandoffLeases > 0) + throw new InvalidOperationException("SSH tunnel is held for a dashboard launch."); + + // Lease check and claim share this hold. A later handoff cannot + // make the stop defer after this caller has decided to replace. + claimedProcess = ClaimProcessForStopLocked(); } - StopLocked(); + StopClaimedProcess(claimedProcess); beforeStart?.Invoke(tunnel); lock (_stateLock) { @@ -212,27 +259,113 @@ public void Stop() } } - private void StopLocked() + /// + /// Stops SSH. A port already handed to the dashboard stays bound here. + /// An unsubmitted handoff still defers the stop. + /// + private bool StopLocked() { + CancelOpenBrowserHandoffs(); + int[] portsToKeep; + SshTunnelConfig? config; + lock (_stateLock) + { + portsToKeep = _dashboardProtectedPorts.ToArray(); + config = _currentConfig; + if (_browserHandoffLeases > 0 && portsToKeep.Length == 0) + { + RememberDeferredStopLocked(DeferredTunnelStop.Stop); + return false; + } + } + + BindDashboardGuards(portsToKeep, config); Process? process; + int[] portsToRelease; lock (_stateLock) { - // Claim and clear the current process before stopping it. Exit callbacks can - // then only observe stale ownership and cannot overwrite a replacement. - _lifecycleGeneration++; - process = _process; - _process = null; - _processStarted = false; - _currentConfig = null; - _currentOwner = SshTunnelOwner.Unspecified; - _lastSpec = null; - CurrentBrowserProxyLocalPort = 0; - CurrentBrowserProxyRemotePort = 0; - StartedAtUtc = null; - if (Status != TunnelStatus.NotConfigured) - Status = TunnelStatus.Stopped; + if (portsToKeep.Length > 0) + { + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + } + + portsToRelease = PortsToReleaseLocked(); + process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; } + StopClaimedProcess(process); + ReleaseForwardPorts(portsToRelease); + return true; + } + + /// + /// Caller holds . Claim clears the current config, + /// so the ports have to be captured first. + /// + private int[] PortsToReleaseLocked() + { + if (_browserHandoffs.Any(handoff => !handoff.Settled && (handoff.Submitted || handoff.Opening))) + return []; + + return _browserHandoffs + .Select(handoff => handoff.LocalPort) + .Append(_currentConfig?.LocalPort ?? 0) + .Where(port => port > 0 && !_dashboardProtectedPorts.Contains(port)) + .Distinct() + .ToArray(); + } + + private static void BindDashboardGuards(int[] ports, SshTunnelConfig? config) + { + foreach (var port in ports) + { + if (!DashboardForwardPortGuard.IsHolding(port)) + DashboardForwardPortGuard.Hold(port); + if (config is not null && config.LocalPort == port) + { + DashboardForwardPortGuard.AllowsDestination( + port, + config.User, + config.Host, + config.RemotePort, + config.SshPort); + } + } + } + + private static void ReleaseForwardPorts(int[] ports) + { + foreach (var port in ports) + DashboardForwardPortGuard.Release(port); + } + + /// + /// Caller holds and has already decided the lease + /// does not defer this stop. Does not consult the lease again. + /// + private Process? ClaimProcessForStopLocked() + { + // Claim and clear the current process before stopping it. Exit callbacks can + // then only observe stale ownership and cannot overwrite a replacement. + _lifecycleGeneration++; + var process = _process; + _process = null; + _processStarted = false; + _currentConfig = null; + _currentOwner = SshTunnelOwner.Unspecified; + _lastSpec = null; + CurrentBrowserProxyLocalPort = 0; + CurrentBrowserProxyRemotePort = 0; + StartedAtUtc = null; + if (Status != TunnelStatus.NotConfigured) + Status = TunnelStatus.Stopped; + return process; + } + + private void StopClaimedProcess(Process? process) + { if (process == null) return; @@ -260,12 +393,40 @@ public void ResetNotConfigured() { lock (_operationLock) { - StopLocked(); + CancelOpenBrowserHandoffs(); + int[] portsToKeep; + SshTunnelConfig? config; + lock (_stateLock) + { + portsToKeep = _dashboardProtectedPorts.ToArray(); + config = _currentConfig; + if (_browserHandoffLeases > 0 && portsToKeep.Length == 0) + { + RememberDeferredStopLocked(DeferredTunnelStop.ResetNotConfigured); + return; + } + } + + BindDashboardGuards(portsToKeep, config); + Process? process; + int[] portsToRelease; lock (_stateLock) { + if (portsToKeep.Length > 0) + { + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + } + + portsToRelease = PortsToReleaseLocked(); + process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; LastError = null; Status = TunnelStatus.NotConfigured; } + + StopClaimedProcess(process); + ReleaseForwardPorts(portsToRelease); } } @@ -277,6 +438,14 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s var localPort = tunnel.LocalPort; var includeBrowserProxyForward = tunnel.IncludeBrowserProxyForward; var sshPort = tunnel.SshPort; + var reuseGuard = DashboardForwardPortGuard.IsHolding(localPort); + if (reuseGuard && + !DashboardForwardPortGuard.AllowsDestination(localPort, user, host, remotePort, sshPort)) + { + throw new InvalidOperationException("SSH destination does not match the dashboard forward."); + } + + var sshLocalPort = reuseGuard ? AllocateLoopbackPort() : localPort; var psi = new ProcessStartInfo { FileName = "ssh", @@ -284,9 +453,10 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s user, host, remotePort, - localPort, + sshLocalPort, includeBrowserProxyForward, - sshPort), + sshPort, + reuseGuard && includeBrowserProxyForward ? localPort + 2 : null), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -352,6 +522,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s return; } + RetainPublicPortAfterUnexpectedExit(tunnel.LocalPort); _logger.Warn($"SSH tunnel exited unexpectedly (code {tunnelExit.ExitCode})"); try { process.Dispose(); } catch (Exception disposeEx) { _logger.Debug($"SshTunnelService: process dispose after unexpected exit failed: {disposeEx.Message}"); } @@ -376,6 +547,8 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s throw new InvalidOperationException("Failed to start ssh process"); } processStarted = true; + if (reuseGuard) + DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id, process.StartTime.ToUniversalTime()); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -478,7 +651,8 @@ private static string BuildSpec(string user, string host, int remotePort, int lo public void Dispose() { - Stop(); + lock (_operationLock) + TeardownLocked(); } public Task IsOwnedListenerReadyAsync( @@ -529,11 +703,19 @@ _process is null || try { - if (!ValidateListenerOwnership( + if (DashboardForwardPortGuard.IsHolding(destinationPort) && + !TryConfirmRetainedPublicRoute(destinationPort)) + { + return Task.FromResult(false); + } + + var proofPort = ListenerPortToProve(destinationPort); + if (proofPort < 1 || + !ValidateListenerOwnership( WindowsTcpListenerSnapshot.Capture(), - destinationPort, - processId, - processStartTimeUtc)) + proofPort, + processId, + processStartTimeUtc)) { return Task.FromResult(false); } @@ -558,6 +740,612 @@ _process is null || public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) => (await StartOwnedAsync(config, ct).ConfigureAwait(false)).Url; + public async Task EnsureSettingsOwnedForwardReadyAsync( + SshTunnelConfig config, + CancellationToken cancellationToken) + { + Process? process = null; + long generation = 0; + var acquiredGuard = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + SshTunnelOwner restartOwner; + lock (_stateLock) + { + restartOwner = DashboardRestartOwner( + _currentOwner, + IsRunningLocked() && _currentConfig?.LocalPort == config.LocalPort); + } + + acquiredGuard = ClaimDashboardPublicPort(config); + EnsureStartedCore( + config, + restartOwner, + tunnel => RejectOccupiedForwardPorts(tunnel)); + + var normalizedConfig = config with + { + User = config.User.Trim(), + Host = config.Host.Trim(), + }; + DateTime processStartTimeUtc; + lock (_stateLock) + { + if (!IsRunningLocked() || + _process is null || + !Equals(_currentConfig, normalizedConfig)) + { + throw new InvalidOperationException( + "SSH tunnel changed before listener ownership could be verified."); + } + + process = _process; + generation = _lifecycleGeneration; + processStartTimeUtc = process.StartTime.ToUniversalTime(); + } + + var processId = process.Id; + await WaitForOwnedLocalListenerAsync( + config.LocalPort, + process, + generation, + processId, + processStartTimeUtc, + cancellationToken).ConfigureAwait(false); + if (config.IncludeBrowserProxyForward) + { + await WaitForOwnedLocalListenerAsync( + config.LocalPort + 2, + process, + generation, + processId, + processStartTimeUtc, + cancellationToken).ConfigureAwait(false); + } + + lock (_stateLock) + { + if (generation != _lifecycleGeneration || + !ReferenceEquals(_process, process) || + !IsRunningLocked() || + !Equals(_currentConfig, normalizedConfig) || + _currentOwner is not (SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionManager)) + { + return default; + } + + return new SettingsOwnedForwardBinding( + true, + generation, + normalizedConfig.LocalPort, + processId); + } + } + catch (Exception ex) + { + var stillCurrent = false; + lock (_stateLock) + { + stillCurrent = process is null + ? _process is null + : generation == _lifecycleGeneration && ReferenceEquals(_process, process); + if (!stillCurrent) + { + _logger.Warn($"SSH dashboard forward wait lost ownership: {ex.Message}"); + return default; + } + + LastError = ex.Message; + Status = TunnelStatus.Failed; + } + + if (acquiredGuard) + ReleaseUnsubmittedGuard(config.LocalPort); + + if (process is not null) + StopIfCurrent(process, generation); + + _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); + return default; + } + } + + public bool IsSettingsOwnedForwardCurrent(long generation, int localPort) + { + lock (_stateLock) + { + return IsForwardCurrentLocked(generation, localPort); + } + } + + public bool TryEnterBrowserHandoff(long generation, int localPort, int processId = 0) => + TryEnterBrowserHandoff(generation, localPort, processId, out _); + + public bool TryEnterBrowserHandoff(long generation, int localPort, int processId, out long handoffId) + { + lock (_stateLock) + { + handoffId = 0; + if (!IsForwardCurrentLocked(generation, localPort)) + return false; + if (processId > 0 && _process?.Id != processId) + return false; + + handoffId = ++_nextBrowserHandoffId; + _browserHandoffLeases++; + _browserHandoffs.Add(new BrowserHandoffLease + { + Id = handoffId, + LocalPort = localPort, + }); + return true; + } + } + + public void NoteBrowserHandoffClient(int processId) => + NoteBrowserHandoffClient(NewestOpenHandoffId(), processId); + + public void NoteBrowserHandoffClient(long handoffId, int processId) + { + if (handoffId <= 0 || processId <= 0) + return; + + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is not null) + handoff.BrowserProcessId = processId; + } + } + + internal int? HandoffBrowserProcessId(long handoffId) + { + lock (_stateLock) + { + return FindOpenHandoffLocked(handoffId)?.BrowserProcessId; + } + } + + public bool IsBrowserHandoffOpen(long handoffId) + { + lock (_stateLock) + { + return FindOpenHandoffLocked(handoffId) is not null; + } + } + + /// + /// Keeps one browser-handoff lease after launch and asks the caller to watch it. + /// A stop that arrives before attributable browser use stays deferred. + /// Returns false when is not leased. + /// + public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watchDeferredStop) + { + lock (_stateLock) + { + watchDeferredStop = false; + if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort) + return false; + + // Process.Start returns before the browser connects. Keep the lease + // until attributable use, including a stop that arrives after launch. + watchDeferredStop = true; + return true; + } + } + + public bool TryBeginDashboardNavigation(long handoffId) + { + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is null || handoff.CancelRequested || handoff.Submitted) + return false; + + handoff.Opening = true; + _dashboardProtectedPorts.Add(handoff.LocalPort); + return true; + } + } + + public bool CompleteDashboardNavigation(long handoffId, bool opened, int? processId) + { + var exit = false; + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is null) + return false; + + handoff.Opening = false; + if (!opened) + { + exit = true; + if (!_browserHandoffs.Any(other => + !other.Settled && + other.Id != handoff.Id && + other.LocalPort == handoff.LocalPort && + (other.Submitted || other.Opening))) + { + _dashboardProtectedPorts.Remove(handoff.LocalPort); + } + } + else + { + handoff.Submitted = true; + _dashboardProtectedPorts.Add(handoff.LocalPort); + if (processId is int id && id > 0) + handoff.BrowserProcessId = id; + } + } + + if (exit) + ExitBrowserHandoff(handoffId); + return opened && IsBrowserHandoffOpen(handoffId); + } + + public void CancelOpenBrowserHandoffs() + { + long[] handoffIds; + lock (_stateLock) + { + foreach (var handoff in _browserHandoffs) + { + if (!handoff.Settled) + handoff.CancelRequested = true; + } + + handoffIds = _browserHandoffs + .Where(handoff => !handoff.Settled && !handoff.Submitted && !handoff.Opening) + .Select(handoff => handoff.Id) + .ToArray(); + } + + foreach (var handoffId in handoffIds) + ExitBrowserHandoff(handoffId); + } + + public void ExitBrowserHandoff() + { + long handoffId; + lock (_stateLock) + { + var oldest = FindOldestOpenHandoffLocked(); + if (oldest is null) + return; + handoffId = oldest.Id; + } + + ExitBrowserHandoff(handoffId); + } + + public void ExitBrowserHandoff(long handoffId) + { + // Same order as Stop: operation lock, then state lock. Claim under the + // state lock and kill only after releasing it. Do not take the operation + // lock again when the caller already holds it. + var enteredOperationLock = false; + if (!Monitor.IsEntered(_operationLock)) + { + Monitor.Enter(_operationLock); + enteredOperationLock = true; + } + + try + { + Process? process = null; + var completeStop = false; + lock (_stateLock) + { + var handoff = _browserHandoffs.FirstOrDefault(item => item.Id == handoffId); + if (handoff is null || handoff.Settled) + return; + + handoff.Settled = true; + _browserHandoffs.Remove(handoff); + if (_browserHandoffLeases > 0) + _browserHandoffLeases--; + if (_browserHandoffLeases != 0 || _deferredStop == DeferredTunnelStop.None) + return; + + var reset = _deferredStop == DeferredTunnelStop.ResetNotConfigured; + process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; + if (reset) + { + LastError = null; + Status = TunnelStatus.NotConfigured; + } + + completeStop = true; + } + + if (completeStop) + StopClaimedProcess(process); + } + finally + { + if (enteredOperationLock) + Monitor.Exit(_operationLock); + } + } + + /// + /// Releases one browser-handoff lease for . + /// A deferred stop or reset then runs. Returns false when that port is not leased. + /// + public bool TryCompleteDeferredBrowserHandoff(int localPort) + { + var enteredOperationLock = false; + if (!Monitor.IsEntered(_operationLock)) + { + Monitor.Enter(_operationLock); + enteredOperationLock = true; + } + + try + { + lock (_stateLock) + { + if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort) + return false; + } + + ExitBrowserHandoff(); + return true; + } + finally + { + if (enteredOperationLock) + Monitor.Exit(_operationLock); + } + } + + /// + /// Waits until the browser uses , or the SSH process is gone. + /// A timeout leaves the lease and listener in place and returns false. + /// + public Task WatchBrowserHandoffConsumptionAsync( + int localPort, + TimeSpan timeout, + Func? consumptionProbe, + CancellationToken cancellationToken = default) => + WatchBrowserHandoffConsumptionAsync(0, localPort, timeout, consumptionProbe, cancellationToken); + + public async Task WatchBrowserHandoffConsumptionAsync( + long handoffId, + int localPort, + TimeSpan timeout, + Func? consumptionProbe, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + timeout; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (handoffId > 0) + { + if (!IsBrowserHandoffOpen(handoffId)) + return true; + } + else if (BrowserHandoffLeaseCount <= 0) + { + return true; + } + + // A shell activation process can exit after handing the URL to an + // existing browser, so that exit is not navigation completion. + var consumed = consumptionProbe?.Invoke(localPort) == true; + if (!consumed && !IsRunning && handoffId > 0 && HandoffNeedsRetainedPort(handoffId)) + { + RetainPublicPortAfterUnexpectedExit(localPort); + return true; + } + else if (consumed || !IsRunning) + { + var completed = handoffId > 0 + ? TryFinishHandoff(handoffId, localPort) + : TryCompleteDeferredBrowserHandoff(localPort); + if (completed || (handoffId > 0 ? !IsBrowserHandoffOpen(handoffId) : BrowserHandoffLeaseCount <= 0)) + return true; + } + + if (DateTime.UtcNow >= deadline) + { + _logger.Warn( + "Dashboard SSH forward stayed owned because the browser has not connected; the listener was not stopped."); + return false; + } + + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken).ConfigureAwait(false); + } + } + + private bool TryFinishHandoff(long handoffId, int localPort) + { + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is null || handoff.LocalPort != localPort) + return false; + } + + ExitBrowserHandoff(handoffId); + return true; + } + + private long NewestOpenHandoffId() + { + lock (_stateLock) + { + return FindNewestOpenHandoffLocked()?.Id ?? 0; + } + } + + private BrowserHandoffLease? FindOpenHandoffLocked(long handoffId) => + _browserHandoffs.FirstOrDefault(handoff => handoff.Id == handoffId && !handoff.Settled); + + private BrowserHandoffLease? FindOldestOpenHandoffLocked() + { + BrowserHandoffLease? oldest = null; + foreach (var handoff in _browserHandoffs) + { + if (handoff.Settled) + continue; + if (oldest is null || handoff.Id < oldest.Id) + oldest = handoff; + } + + return oldest; + } + + private BrowserHandoffLease? FindNewestOpenHandoffLocked() + { + BrowserHandoffLease? newest = null; + foreach (var handoff in _browserHandoffs) + { + if (handoff.Settled) + continue; + if (newest is null || handoff.Id > newest.Id) + newest = handoff; + } + + return newest; + } + + private sealed class BrowserHandoffLease + { + public long Id { get; init; } + public int LocalPort { get; init; } + public int? BrowserProcessId { get; set; } + public bool Opening { get; set; } + public bool Submitted { get; set; } + public bool CancelRequested { get; set; } + public bool Settled { get; set; } + } + + /// + /// Caller holds . A reset replaces a plain stop. + /// A later stop does not downgrade a reset. + /// + private void RememberDeferredStopLocked(DeferredTunnelStop kind) + { + if (_deferredStop != DeferredTunnelStop.ResetNotConfigured) + _deferredStop = kind; + } + + private enum DeferredTunnelStop + { + None, + Stop, + ResetNotConfigured, + } + + private bool IsForwardCurrentLocked(long generation, int localPort) + { + return generation == _lifecycleGeneration && + IsRunningLocked() && + _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionManager && + _currentConfig?.LocalPort == localPort; + } + + /// + /// Keeps a manager-owned tunnel on that owner when Dashboard moves SSH behind the public port. + /// + internal static SshTunnelOwner DashboardRestartOwner(SshTunnelOwner currentOwner, bool runningOnRequestedPort) => + runningOnRequestedPort && currentOwner == SshTunnelOwner.GatewayConnectionManager + ? SshTunnelOwner.GatewayConnectionManager + : SshTunnelOwner.Settings; + + internal static string LoopbackWebSocketHost(int port) => + DashboardForwardPortGuard.IsHolding(port) ? "127.0.0.1" : "localhost"; + + /// + /// Binds the dashboard's public port before SSH listens there. + /// Returns true when this call created the guard. + /// The browser URL is not issued until this returns. + /// + private bool ClaimDashboardPublicPort(SshTunnelConfig config) + { + var localPort = config.LocalPort; + var user = config.User.Trim(); + var host = config.Host.Trim(); + if (DashboardForwardPortGuard.IsHolding(localPort)) + { + DashboardForwardPortGuard.AllowsDestination( + localPort, + user, + host, + config.RemotePort, + config.SshPort); + return false; + } + + var ownsPublicPort = false; + lock (_stateLock) + { + ownsPublicPort = IsRunningLocked() && _currentConfig?.LocalPort == localPort; + } + + if (ownsPublicPort) + Stop(); + + RejectForeignForwardPort(localPort); + if (config.IncludeBrowserProxyForward) + RejectForeignForwardPort(localPort + 2); + + DashboardForwardPortGuard.Hold(localPort); + DashboardForwardPortGuard.AllowsDestination( + localPort, + user, + host, + config.RemotePort, + config.SshPort); + return true; + } + + private void ReleaseUnsubmittedGuard(int port) + { + var protect = false; + lock (_stateLock) + protect = _dashboardProtectedPorts.Contains(port); + if (!protect) + DashboardForwardPortGuard.Release(port); + } + + private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) + { + RejectForeignForwardPort(tunnel.LocalPort); + if (tunnel.IncludeBrowserProxyForward) + RejectForeignForwardPort(tunnel.LocalPort + 2); + } + + internal static void RejectForeignForwardPort(int port) + { + if (DashboardForwardPortGuard.IsHolding(port)) + return; + + EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), port); + } + + private static int ListenerPortToProve(int publicPort) + { + if (!DashboardForwardPortGuard.IsHolding(publicPort)) + return publicPort; + + return DashboardForwardPortGuard.BackendPort(publicPort) ?? -1; + } + + private static int AllocateLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + public async Task StartOwnedAsync( SshTunnelConfig config, CancellationToken ct) @@ -569,12 +1357,7 @@ public async Task StartOwnedAsync( EnsureStartedCore( config, SshTunnelOwner.GatewayConnectionManager, - tunnel => - { - EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort); - if (tunnel.IncludeBrowserProxyForward) - EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort + 2); - }); + RejectOccupiedForwardPorts); var normalizedConfig = config with { @@ -616,7 +1399,7 @@ await WaitForOwnedLocalListenerAsync( ct).ConfigureAwait(false); } return new SshTunnelStartResult( - $"ws://localhost:{config.LocalPort}", + $"ws://{LoopbackWebSocketHost(config.LocalPort)}:{config.LocalPort}", normalizedConfig, generation); } @@ -630,10 +1413,118 @@ await WaitForOwnedLocalListenerAsync( public Task StopAsync() { - Stop(); + lock (_operationLock) + TeardownLocked(); return Task.CompletedTask; } + private void TeardownLocked() + { + int[] protectedPorts; + int stoppedPort; + Process? process; + SshTunnelConfig? stoppedConfig; + lock (_stateLock) + { + stoppedConfig = _currentConfig; + stoppedPort = stoppedConfig?.LocalPort ?? 0; + protectedPorts = _browserHandoffs + .Where(handoff => !handoff.Settled && (handoff.Submitted || handoff.Opening)) + .Select(handoff => handoff.LocalPort) + .Concat(_dashboardProtectedPorts) + .Where(port => port > 0) + .Distinct() + .ToArray(); + } + + BindDashboardGuards(protectedPorts, stoppedConfig); + lock (_stateLock) + { + foreach (var handoff in _browserHandoffs) + handoff.CancelRequested = true; + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + _deferredStop = DeferredTunnelStop.None; + foreach (var port in protectedPorts) + _dashboardProtectedPorts.Remove(port); + process = ClaimProcessForStopLocked(); + } + + foreach (var port in protectedPorts) + DashboardForwardPortGuard.ClearBackend(port); + StopClaimedProcess(process); + + if (protectedPorts.Length == 0 && stoppedPort > 0) + DashboardForwardPortGuard.Release(stoppedPort); + } + + private bool HandoffNeedsRetainedPort(long handoffId) + { + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + return handoff is { Submitted: true } or { Opening: true }; + } + } + + /// + /// Keeps the public dashboard port after SSH exits and drops the lease. + /// Recovery can then start a backend listener. A deferred stop is not run. + /// + private void RetainPublicPortAfterUnexpectedExit(int localPort) + { + SshTunnelConfig? config; + long[] retainIds; + lock (_stateLock) + { + config = _currentConfig; + retainIds = _browserHandoffs + .Where(handoff => !handoff.Settled && + handoff.LocalPort == localPort && + (handoff.Submitted || handoff.Opening)) + .Select(handoff => handoff.Id) + .ToArray(); + if (retainIds.Length > 0) + _dashboardProtectedPorts.Add(localPort); + } + + if (retainIds.Length == 0) + return; + + if (!DashboardForwardPortGuard.IsHolding(localPort)) + DashboardForwardPortGuard.Hold(localPort); + if (config is not null && config.LocalPort == localPort) + { + DashboardForwardPortGuard.AllowsDestination( + localPort, + config.User, + config.Host, + config.RemotePort, + config.SshPort); + } + + DashboardForwardPortGuard.ClearBackend(localPort); + foreach (var handoffId in retainIds) + DropHandoffForRecovery(handoffId); + } + + private void DropHandoffForRecovery(long handoffId) + { + lock (_stateLock) + { + var handoff = _browserHandoffs.FirstOrDefault(item => item.Id == handoffId); + if (handoff is null || handoff.Settled) + return; + + handoff.Settled = true; + _browserHandoffs.Remove(handoff); + if (_browserHandoffLeases > 0) + _browserHandoffLeases--; + if (_browserHandoffLeases == 0) + _deferredStop = DeferredTunnelStop.None; + } + } + public Task StopIfOwnedAsync( SshTunnelConfig config, long ownershipGeneration, @@ -645,13 +1536,14 @@ public Task StopIfOwnedAsync( try { ct.ThrowIfCancellationRequested(); + var normalizedConfig = config with + { + User = config.User.Trim(), + Host = config.Host.Trim(), + }; lock (_stateLock) { - var normalizedConfig = config with - { - User = config.User.Trim(), - Host = config.Host.Trim(), - }; + ct.ThrowIfCancellationRequested(); if (_lifecycleGeneration != ownershipGeneration || !Equals(_currentConfig, normalizedConfig) || _currentOwner != SshTunnelOwner.GatewayConnectionManager) @@ -660,8 +1552,7 @@ public Task StopIfOwnedAsync( } } - ct.ThrowIfCancellationRequested(); - StopLocked(); + TeardownLocked(); return Task.FromResult(true); } finally @@ -693,11 +1584,20 @@ private async Task WaitForOwnedLocalListenerAsync( } } - if (ValidateListenerOwnership( - WindowsTcpListenerSnapshot.Capture(), - localPort, - processId, - processStartTimeUtc)) + if (DashboardForwardPortGuard.IsHolding(localPort) && + !TryConfirmRetainedPublicRoute(localPort)) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken).ConfigureAwait(false); + continue; + } + + var proofPort = ListenerPortToProve(localPort); + if (proofPort > 0 && + ValidateListenerOwnership( + WindowsTcpListenerSnapshot.Capture(), + proofPort, + processId, + processStartTimeUtc)) { return; } @@ -712,19 +1612,46 @@ private void StopIfCurrent(Process process, long generation) { lock (_operationLock) { + Process? claimed; lock (_stateLock) { if (generation != _lifecycleGeneration || - !ReferenceEquals(_process, process)) + !ReferenceEquals(_process, process) || + _browserHandoffLeases > 0) { return; } + + claimed = ClaimProcessForStopLocked(); } - StopLocked(); + StopClaimedProcess(claimed); } } + internal static bool TryConfirmRetainedPublicRoute(int publicPort) + { + var snapshot = WindowsTcpListenerSnapshot.Capture(); + if (!snapshot.Ipv4Complete || !snapshot.Ipv6Complete) + return false; + + var listeners = snapshot.Listeners + .Where(listener => listener.Port == publicPort && CanServeLoopback(listener.Address)) + .ToArray(); + var ownerPid = Environment.ProcessId; + if (listeners.Any(listener => listener.ProcessId != ownerPid)) + { + throw new InvalidOperationException( + $"Local port {publicPort} is not owned exclusively by the dashboard forward."); + } + + if (listeners.Length == 0) + return false; + + return !Socket.OSSupportsIPv6 || + listeners.Any(listener => listener.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6); + } + internal static void EnsurePortIsUnoccupied( WindowsTcpListenerSnapshotResult snapshot, int localPort) diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 76b490afd..6f89bed01 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -22,6 +22,8 @@ public sealed record WindowsTcpListenerSnapshotResult( /// Address-specific TCP listener ownership from the Windows IP Helper API. public static class WindowsTcpListenerSnapshot { + public const uint TcpStateEstablished = 5; + public static WindowsTcpListenerSnapshotResult Capture() { if (!OperatingSystem.IsWindows()) @@ -33,6 +35,93 @@ public static WindowsTcpListenerSnapshotResult Capture() return new(result, ipv4Complete, ipv6Complete); } + /// + /// True when an established TCP row is using on loopback. + /// Listeners are not established, so the owned SSH socket alone does not match. + /// + public static bool HasEstablishedLoopbackConnection(int port) + { + if (!OperatingSystem.IsWindows() || port is < 1 or > 65535) + return false; + + try + { + return ScanEstablished(AfInet, port, ipv6: false) || + ScanEstablished(AfInet6, port, ipv6: true); + } + catch (Exception ex) + { + Trace.WriteLine( + $"Established TCP lookup failed for port {port}: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + public static bool IsEstablishedLoopbackForwardUse( + uint state, + IPAddress localAddress, + int localPort, + IPAddress remoteAddress, + int remotePort, + int forwardPort) + { + if (state != TcpStateEstablished || forwardPort is < 1 or > 65535) + return false; + if (localPort == forwardPort && IPAddress.IsLoopback(localAddress)) + return true; + if (remotePort == forwardPort && IPAddress.IsLoopback(remoteAddress)) + return true; + return false; + } + + public static string EstablishedForwardKey( + IPAddress localAddress, + int localPort, + IPAddress remoteAddress, + int remotePort, + int processId) => + $"{localAddress}|{localPort}|{remoteAddress}|{remotePort}|{processId}"; + + public static bool IsUnseenEstablishedForwardUse( + uint state, + IPAddress localAddress, + int localPort, + IPAddress remoteAddress, + int remotePort, + int processId, + int forwardPort, + int? browserProcessId, + IReadOnlySet seen) + { + if (!IsEstablishedLoopbackForwardUse( + state, + localAddress, + localPort, + remoteAddress, + remotePort, + forwardPort)) + { + return false; + } + + // An accepted SSH socket is not the browser that opened the dashboard. + if (localPort == forwardPort || remotePort != forwardPort) + return false; + if (browserProcessId is not int browser || browser <= 0 || processId != browser) + return false; + + var key = EstablishedForwardKey(localAddress, localPort, remoteAddress, remotePort, processId); + return !seen.Contains(key); + } + + private readonly record struct EstablishedTcpRow( + uint State, + IPAddress LocalAddress, + int LocalPort, + IPAddress RemoteAddress, + int RemotePort, + int ProcessId); + public static string? GetProcessCommandLine(int processId) { if (processId <= 0) @@ -215,6 +304,206 @@ private static bool CaptureTable( return false; } + public static bool TryConfirmAcceptedLoopbackOwner(int listenPort, int clientPort, int processId) + { + if (!OperatingSystem.IsWindows() || + listenPort is < 1 or > 65535 || + clientPort is < 1 or > 65535 || + processId <= 0) + { + return false; + } + + var found = false; + var complete = true; + bool Visit(EstablishedTcpRow row) + { + if (row.State == TcpStateEstablished && + row.LocalPort == listenPort && + row.RemotePort == clientPort && + IPAddress.IsLoopback(row.LocalAddress) && + IPAddress.IsLoopback(row.RemoteAddress) && + row.ProcessId == processId) + { + found = true; + return true; + } + + return false; + } + + VisitEstablished(AfInet, ipv6: false, Visit, ref complete); + if (!found) + VisitEstablished(AfInet6, ipv6: true, Visit, ref complete); + return found && complete; + } + + private static void VisitEstablished( + int addressFamily, + bool ipv6, + Func visit, + ref bool complete) + { + var rowSize = ipv6 + ? Marshal.SizeOf() + : Marshal.SizeOf(); + for (var attempt = 0; attempt < 3; attempt++) + { + var bufferLength = 0; + var status = GetExtendedTcpTable( + IntPtr.Zero, + ref bufferLength, + sort: true, + ipVersion: addressFamily, + tableClass: TcpTableOwnerPidAll, + reserved: 0); + if (status != ErrorInsufficientBuffer || bufferLength <= 0) + { + complete = false; + return; + } + + var tablePtr = Marshal.AllocHGlobal(bufferLength); + try + { + status = GetExtendedTcpTable( + tablePtr, + ref bufferLength, + sort: true, + ipVersion: addressFamily, + tableClass: TcpTableOwnerPidAll, + reserved: 0); + if (status == ErrorInsufficientBuffer) + continue; + if (status != ErrorSuccess) + { + complete = false; + return; + } + + var rowCount = Marshal.ReadInt32(tablePtr); + var rowPtr = IntPtr.Add(tablePtr, sizeof(int)); + for (var index = 0; index < rowCount; index++) + { + if (visit(ReadEstablishedRow(rowPtr, ipv6))) + return; + rowPtr = IntPtr.Add(rowPtr, rowSize); + } + + return; + } + finally + { + Marshal.FreeHGlobal(tablePtr); + } + } + + complete = false; + } + + private static EstablishedTcpRow ReadEstablishedRow(IntPtr rowPtr, bool ipv6) + { + if (ipv6) + { + var row = Marshal.PtrToStructure(rowPtr); + return new EstablishedTcpRow( + row.State, + new IPAddress(row.LocalAddress, row.LocalScopeId), + ReadPort(row.LocalPort), + new IPAddress(row.RemoteAddress, row.RemoteScopeId), + ReadPort(row.RemotePort), + unchecked((int)row.OwningProcessId)); + } + + var ipv4 = Marshal.PtrToStructure(rowPtr); + return new EstablishedTcpRow( + ipv4.State, + new IPAddress(BitConverter.GetBytes(ipv4.LocalAddress)), + ReadPort(ipv4.LocalPort), + new IPAddress(BitConverter.GetBytes(ipv4.RemoteAddress)), + ReadPort(ipv4.RemotePort), + unchecked((int)ipv4.OwningProcessId)); + } + + private static bool ScanEstablished(int addressFamily, int port, bool ipv6) + { + var rowSize = ipv6 + ? Marshal.SizeOf() + : Marshal.SizeOf(); + for (var attempt = 0; attempt < 3; attempt++) + { + var bufferLength = 0; + var status = GetExtendedTcpTable( + IntPtr.Zero, + ref bufferLength, + sort: true, + ipVersion: addressFamily, + tableClass: TcpTableOwnerPidAll, + 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: TcpTableOwnerPidAll, + reserved: 0); + if (status == ErrorInsufficientBuffer) + continue; + if (status != ErrorSuccess) + return false; + + var rowCount = Marshal.ReadInt32(tablePtr); + var rowPtr = IntPtr.Add(tablePtr, sizeof(int)); + for (var index = 0; index < rowCount; index++) + { + if (ReadEstablishedLoopback(rowPtr, ipv6, port)) + return true; + rowPtr = IntPtr.Add(rowPtr, rowSize); + } + + return false; + } + finally + { + Marshal.FreeHGlobal(tablePtr); + } + } + + return false; + } + + private static bool ReadEstablishedLoopback(IntPtr rowPtr, bool ipv6, int port) + { + if (ipv6) + { + var row = Marshal.PtrToStructure(rowPtr); + var local = new IPAddress(row.LocalAddress, row.LocalScopeId); + var remote = new IPAddress(row.RemoteAddress, row.RemoteScopeId); + return IsEstablishedLoopbackForwardUse( + row.State, + local, + ReadPort(row.LocalPort), + remote, + ReadPort(row.RemotePort), + port); + } + + var ipv4 = Marshal.PtrToStructure(rowPtr); + return IsEstablishedLoopbackForwardUse( + ipv4.State, + new IPAddress(BitConverter.GetBytes(ipv4.LocalAddress)), + ReadPort(ipv4.LocalPort), + new IPAddress(BitConverter.GetBytes(ipv4.RemoteAddress)), + ReadPort(ipv4.RemotePort), + port); + } + private static int ReadPort(byte[] bytes) => bytes is { Length: >= 2 } ? (bytes[0] << 8) + bytes[1] : 0; @@ -245,6 +534,7 @@ private static void ResolveProcess( private const int AfInet = 2; private const int AfInet6 = 23; private const int TcpTableOwnerPidListener = 3; + private const int TcpTableOwnerPidAll = 5; private const uint ErrorSuccess = 0; private const uint ErrorInsufficientBuffer = 122; diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index 11f28fa4e..aeaa48a84 100644 --- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs +++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs @@ -49,6 +49,23 @@ public static string BuildArguments( int localPort, bool includeBrowserProxyForward, int sshPort) + => BuildArguments( + user, + host, + remotePort, + localPort, + includeBrowserProxyForward, + sshPort, + browserProxyLocalPort: null); + + public static string BuildArguments( + string user, + string host, + int remotePort, + int localPort, + bool includeBrowserProxyForward, + int sshPort, + int? browserProxyLocalPort) { user = user.Trim(); host = host.Trim(); @@ -60,17 +77,18 @@ public static string BuildArguments( ValidatePort(remotePort, nameof(remotePort)); ValidatePort(localPort, nameof(localPort)); ValidatePort(sshPort, nameof(sshPort)); + var proxyLocalPort = browserProxyLocalPort ?? localPort + 2; if (includeBrowserProxyForward) { ValidateBrowserProxyPort(remotePort, nameof(remotePort)); - ValidateBrowserProxyPort(localPort, nameof(localPort)); + ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort)); } var sb = new StringBuilder(); sb.Append(BaseOptions); AppendLocalForward(sb, localPort, remotePort); if (includeBrowserProxyForward) - AppendLocalForward(sb, localPort + 2, remotePort + 2); + AppendLocalForward(sb, proxyLocalPort, remotePort + 2); if (sshPort != 22) { sb.Append("-p "); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 1074a4b27..7b8e41838 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3950,12 +3950,26 @@ private bool TryResolveChatCredentials( out string gatewayUrl, out string token, out string credentialSource, - out bool isBootstrapToken) + out bool isBootstrapToken) => + TryResolveChatCredentials( + out gatewayUrl, + out token, + out credentialSource, + out isBootstrapToken, + out _); + + private bool TryResolveChatCredentials( + out string gatewayUrl, + out string token, + out string credentialSource, + out bool isBootstrapToken, + out string? gatewayId) { gatewayUrl = string.Empty; token = string.Empty; credentialSource = "none"; isBootstrapToken = false; + gatewayId = null; if (_settings == null) return false; @@ -3979,6 +3993,7 @@ private bool TryResolveChatCredentials( token = credential.Token; credentialSource = credential.Source; isBootstrapToken = credential.IsBootstrapToken; + gatewayId = credential.GatewayId; return true; } @@ -4004,24 +4019,202 @@ private void OpenNativeSetupCompletion(string handle, bool explicitRetry = false }, new AppLogger(), nameof(OpenNativeSetupCompletion)); } + private enum DashboardLaunchReadiness + { + Ready, + FailedVisible, + FailedReport, + } + + private sealed record DashboardLaunchPreparation( + InteractiveGatewayCredential Credential, + DashboardGatewayTunnelSnapshot Snapshot, + SettingsOwnedForwardBinding? OwnedForward); + private void OpenDashboard(string? path = null) { AsyncEventHandlerGuard.Run(async () => { + DashboardLaunchPreparation? prepared = null; + var readiness = DashboardLaunchReadiness.FailedReport; var launcher = new GatewayDashboardLauncher( - EnsureSshTunnelConfigured, - () => TryResolveChatCredentials(out var url, out var token, out var source, out var bootstrap) - ? new InteractiveGatewayCredential(url, token, bootstrap, source) : null, - async url => await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)), - () => AsyncEventHandlerGuard.Run( - () => _windowManager?.ShowDashboardLaunchFailureAsync( - () => OpenDashboard(path)) - ?? Task.CompletedTask, new AppLogger(), "Dashboard launch error"), - () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); + async () => + { + (readiness, prepared) = await PrepareDashboardLaunchAsync(); + return readiness == DashboardLaunchReadiness.Ready; + }, + () => prepared?.Credential, + url => LaunchDashboardBrowserAsync(url, prepared), + () => + { + if (readiness == DashboardLaunchReadiness.FailedVisible) + return; + AsyncEventHandlerGuard.Run( + () => _windowManager?.ShowDashboardLaunchFailureAsync( + () => OpenDashboard(path)) ?? Task.CompletedTask, + new AppLogger(), + "Dashboard launch error"); + }, + () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId), + () => prepared?.OwnedForward?.LocalPort); await launcher.OpenAsync(path); }, new AppLogger(), nameof(OpenDashboard)); } + private async Task<(DashboardLaunchReadiness Readiness, DashboardLaunchPreparation? Prepared)> PrepareDashboardLaunchAsync() + { + if (_settings == null) + return (DashboardLaunchReadiness.FailedVisible, null); + + var capturedTunnel = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null; + var hadCredentials = TryCaptureDashboardGatewaySnapshot(capturedTunnel, out var before); + var sshGate = await EnsureDashboardSshForwardOwnedAsync(capturedTunnel); + if (!sshGate.Allowed) + return (DashboardLaunchReadiness.FailedReport, null); + + if (!hadCredentials || before is null) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return (DashboardLaunchReadiness.FailedVisible, null); + } + + var tunnelNow = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null; + if (!TryCaptureDashboardGatewaySnapshot(tunnelNow, out var after) || after is null) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return (DashboardLaunchReadiness.FailedVisible, null); + } + + if (!DashboardBrowserHandoff.SameBinding(before, after)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return (DashboardLaunchReadiness.FailedVisible, null); + } + + SettingsOwnedForwardBinding? ownedForward = sshGate.Generation is long generation + ? new SettingsOwnedForwardBinding(true, generation, sshGate.LocalPort, sshGate.ProcessId) + : null; + return (DashboardLaunchReadiness.Ready, new DashboardLaunchPreparation( + new InteractiveGatewayCredential( + after.GatewayUrl, + after.Token, + after.IsBootstrapToken, + after.Source), + after, + ownedForward)); + } + + private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPreparation? prepared) + { + if (prepared is null) + return Task.FromResult(false); + + if (prepared.OwnedForward is not { } owned) + return Task.FromResult(DashboardBrowserShell.TryOpen(url, out _)); + + if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot)) + return Task.FromResult(false); + + if (_sshTunnelService is not SshTunnelService tunnel || + !tunnel.TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)) + { + return Task.FromResult(false); + } + + if (tunnel.HasDeferredStop || !tunnel.TryBeginDashboardNavigation(handoffId)) + { + tunnel.ExitBrowserHandoff(handoffId); + return Task.FromResult(false); + } + + var opened = DashboardBrowserShell.TryOpen(url, out var browserProcessId); + if (!tunnel.CompleteDashboardNavigation(handoffId, opened, browserProcessId)) + return Task.FromResult(false); + + var localPort = owned.LocalPort; + _ = Task.Run(async () => + { + try + { + while (tunnel.IsBrowserHandoffOpen(handoffId)) + { + var settled = await tunnel.WatchBrowserHandoffConsumptionAsync( + handoffId, + localPort, + TimeSpan.FromSeconds(60), + consumptionProbe: null).ConfigureAwait(false); + if (settled) + return; + } + } + catch (Exception ex) + { + Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}"); + if (!tunnel.IsRunning) + tunnel.ExitBrowserHandoff(handoffId); + } + }); + + return Task.FromResult(true); + } + + private SshTunnelConfig? CreateDashboardSshTunnelOrNull() + { + if (_settings == null || + string.IsNullOrWhiteSpace(_settings.SshTunnelUser) || + string.IsNullOrWhiteSpace(_settings.SshTunnelHost) || + _settings.SshTunnelRemotePort is < 1 or > 65535 || + _settings.SshTunnelLocalPort is < 1 or > 65535) + { + return null; + } + + var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( + _settings.NodeBrowserProxyEnabled, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort); + return new SshTunnelConfig( + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + includeBrowserProxy, + _settings.SshTunnelSshPort); + } + + private bool TryCaptureDashboardGatewaySnapshot( + SshTunnelConfig? tunnel, + out DashboardGatewayTunnelSnapshot? snapshot) + { + snapshot = null; + if (!TryResolveChatCredentials( + out var gatewayUrl, + out var token, + out var credentialSource, + out var isBootstrapToken, + out var gatewayId)) + { + return false; + } + + var identity = string.IsNullOrWhiteSpace(gatewayId) ? gatewayUrl : gatewayId; + snapshot = new DashboardGatewayTunnelSnapshot( + identity, + gatewayUrl, + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, + tunnel, + isBootstrapToken, + credentialSource); + return true; + } + // ── IAppCommands implementation ───────────────────────────────────── void IAppCommands.OpenDashboard(string? path) => OpenDashboard(path); @@ -4342,60 +4535,54 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private bool EnsureSshTunnelConfigured() + private async Task<(bool Allowed, long? Generation, int LocalPort, int ProcessId)> EnsureDashboardSshForwardOwnedAsync( + SshTunnelConfig? capturedTunnel) { if (_settings == null) { - return false; + return (false, null, 0, 0); } - if (_settings.UseSshTunnel) + if (!_settings.UseSshTunnel) { - if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) || - string.IsNullOrWhiteSpace(_settings.SshTunnelHost) || - _settings.SshTunnelRemotePort is < 1 or > 65535 || - _settings.SshTunnelLocalPort is < 1 or > 65535) - { - Logger.Warn("SSH tunnel is enabled but settings are incomplete"); - UpdateTrayIcon(); - return false; - } + _sshTunnelService?.Stop(); + return (true, null, 0, 0); + } - try + if (capturedTunnel is null) + { + Logger.Warn("SSH tunnel is enabled but settings are incomplete"); + UpdateTrayIcon(); + return (false, null, 0, 0); + } + + try + { + _sshTunnelService ??= new SshTunnelService(new AppLogger()); + var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( + capturedTunnel, + CancellationToken.None); + DiagnosticsJsonlService.Write("tunnel.ensure_started", new { - _sshTunnelService ??= new SshTunnelService(new AppLogger()); - var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( - _settings.NodeBrowserProxyEnabled, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort); - _sshTunnelService.EnsureStarted( - _settings.SshTunnelUser, - _settings.SshTunnelHost, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort, - includeBrowserProxy, - _settings.SshTunnelSshPort); - DiagnosticsJsonlService.Write("tunnel.ensure_started", new - { - status = _sshTunnelService.Status.ToString(), - localEndpoint = $"127.0.0.1:{_settings.SshTunnelLocalPort}", - remoteHost = string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ? null : _settings.SshTunnelHost, - remotePort = _settings.SshTunnelRemotePort - }); - } - catch (Exception ex) + status = _sshTunnelService.Status.ToString(), + localEndpoint = $"127.0.0.1:{capturedTunnel.LocalPort}", + remoteHost = string.IsNullOrWhiteSpace(capturedTunnel.Host) ? null : capturedTunnel.Host, + remotePort = capturedTunnel.RemotePort + }); + if (!owned.Owned) { - Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); - return false; + return (false, null, 0, 0); } + + return (true, owned.Generation, owned.LocalPort, owned.ProcessId); } - else + catch (Exception ex) { - _sshTunnelService?.Stop(); + Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); + UpdateTrayIcon(); + return (false, null, 0, 0); } - - return true; } private void OnSshTunnelExited(object? sender, SshTunnelExit tunnelExit) => diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs new file mode 100644 index 000000000..3596aa022 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs @@ -0,0 +1,62 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace OpenClawTray.Services; + +/// +/// Opens a dashboard URL with the shell and returns a browser process id when Windows provides one. +/// Launch failures are not logged: the exception text can contain the credential URL. +/// +internal static class DashboardBrowserShell +{ + internal static bool TryOpen(string url, out int? processId) + { + processId = null; + Process? process = null; + try + { + process = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + return InterpretStartedProcess(process, out processId); + } + catch (Exception) + { + processId = null; + return false; + } + finally + { + try + { + process?.Dispose(); + } + catch (Exception ex) + { + Trace.WriteLine($"Dashboard browser handle release failed: {ex.GetType().Name}"); + } + } + } + + internal static bool InterpretStartedProcess(Process? process, out int? processId) + { + processId = null; + if (process is null) + return true; + + processId = TryReadProcessId(process); + return true; + } + + private static int? TryReadProcessId(Process process) + { + try + { + if (process.HasExited) + return null; + return process.Id > 0 ? process.Id : null; + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) + { + return null; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index e7cfae2d3..87172324d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -7,22 +7,26 @@ namespace OpenClawTray.Services; /// Uses the normal Dashboard credential path without setup completion authority. internal sealed class GatewayDashboardLauncher( - Func ensureTunnel, + Func> ensureTunnel, Func resolveCredential, Func> launchBrowser, Action reportFailure, - Action? reportOpened = null) + Action? reportOpened = null, + Func? ownedLocalForwardPort = null) { internal const string FailureNotificationId = "setup-dashboard-launch"; public async Task OpenAsync(string? path = null) { try { - if (!ensureTunnel()) + if (!await ensureTunnel()) throw new InvalidOperationException("The Gateway tunnel is unavailable."); var credential = resolveCredential() ?? throw new InvalidOperationException("The Gateway credential is unavailable."); - var url = GatewayDashboardUrlBuilder.Build(credential.GatewayUrl, path, credential.Token, + var gatewayUrl = ownedLocalForwardPort?.Invoke() is int localPort + ? DashboardBrowserHandoff.ProjectOntoLocalForward(credential.GatewayUrl, localPort) + : credential.GatewayUrl; + var url = GatewayDashboardUrlBuilder.Build(gatewayUrl, path, credential.Token, !credential.IsBootstrapToken && credential.Source == CredentialResolver.SourceSharedGatewayToken); if (!await launchBrowser(url)) throw new InvalidOperationException("Windows did not open the Dashboard."); diff --git a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs new file mode 100644 index 000000000..97d6bc589 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs @@ -0,0 +1,101 @@ +namespace OpenClaw.Connection.Tests; + +public sealed class DashboardBrowserHandoffTests +{ + private static SshTunnelConfig Tunnel(int localPort = 18789) => + new("user", "gateway.example", 22, localPort, false, 22); + + [Fact] + public void SameBinding_AllowsTokenRotationOnTheSameGatewayAndDestination() + { + var tunnel = Tunnel(); + var before = new DashboardGatewayTunnelSnapshot( + "gw-a", + "ws://127.0.0.1:18789", + "old-token", + true, + tunnel); + var after = before with { Token = "new-token" }; + + Assert.True(DashboardBrowserHandoff.SameBinding(before, after)); + } + + [Fact] + public void SameBinding_RejectsGatewaySwitchOntoTheSameLocalPort() + { + var tunnel = Tunnel(); + var before = new DashboardGatewayTunnelSnapshot( + "gw-a", + "ws://127.0.0.1:18789", + "token-a", + true, + tunnel); + var after = new DashboardGatewayTunnelSnapshot( + "gw-b", + "ws://127.0.0.1:18789", + "token-b", + true, + tunnel); + + Assert.False(DashboardBrowserHandoff.SameBinding(before, after)); + Assert.True(DashboardBrowserHandoff.SameTunnelDestination(before.Tunnel, after.Tunnel)); + } + + [Fact] + public void SameBinding_RejectsDestinationChangeWhenLocalPortStays() + { + var before = new DashboardGatewayTunnelSnapshot( + "gw-a", + "ws://127.0.0.1:18789", + "token-a", + true, + Tunnel()); + var after = before with { Tunnel = Tunnel() with { Host = "other.example" } }; + + Assert.False(DashboardBrowserHandoff.SameBinding(before, after)); + } + + [Fact] + public void ProjectOntoLocalForward_KeepsTheSavedMountPathAndFragment() + { + var tunnel = new SshTunnelConfig("user", "gateway.example", 18789, 19001); + var saved = new DashboardGatewayTunnelSnapshot( + "gateway-a", + "wss://gateway.example/mount/#token=old&view=compact", + "token-a", + true, + tunnel); + + var browserUrl = DashboardBrowserHandoff.ProjectOntoLocalForward(saved.GatewayUrl, tunnel.LocalPort); + + Assert.Equal("https://127.0.0.1:19001/mount/#token=old&view=compact", browserUrl); + Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward( + "https://127.0.0.1:19001/mount/custodian#token=token-a", + saved)); + } + + [Fact] + public void UrlUsesCapturedForward_RequiresLoopbackPortAndCapturedToken() + { + var captured = new DashboardGatewayTunnelSnapshot( + "gw-a", + "ws://127.0.0.1:18789", + "token a", + true, + Tunnel()); + var token = Uri.EscapeDataString(captured.Token); + + Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward( + $"http://127.0.0.1:18789/#token={token}", + captured)); + Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward( + "http://127.0.0.1:18789/#token=other", + captured)); + Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward( + $"http://127.0.0.1:9/#token={token}", + captured)); + Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward( + $"https://gateway.example/#token={token}", + captured)); + } +} diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs index 5511d22c9..aeb6a8f6a 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Net.Sockets; + namespace OpenClaw.Connection.Tests; public sealed class GatewayClientEndpointResolverTests @@ -32,6 +35,31 @@ public void Resolve_UsesLocalForwardForTunnelBackedRecord() Assert.Equal("ws://localhost:45678", GatewayClientEndpointResolver.Resolve(record)); } + [Fact] + public void Resolve_PinsHeldDashboardPortToIpv4Loopback() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + DashboardForwardPortGuard.Hold(port); + try + { + var record = new GatewayRecord + { + Id = "held-forward", + Url = "ws://remote.internal:18789", + SshTunnel = new SshTunnelConfig("user", "remote.internal", 18789, port), + }; + + Assert.Equal($"ws://127.0.0.1:{port}", GatewayClientEndpointResolver.Resolve(record)); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + [Theory] [InlineData(0)] [InlineData(65536)] diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 0125d270e..e73334a3e 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -1,5 +1,8 @@ using OpenClaw.Shared; +using System.Diagnostics; using System.Net; +using System.Net.Sockets; +using System.Reflection; namespace OpenClaw.Connection.Tests; @@ -234,6 +237,114 @@ await Assert.ThrowsAnyAsync( } } + [Fact] + public void AllowsDestination_PinsTheOriginalGateway() + { + const int port = 45681; + DashboardForwardPortGuard.Release(port); + DashboardForwardPortGuard.Hold(port); + try + { + Assert.True(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18789, 22)); + Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "other.example", 18789, 22)); + Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18790, 22)); + Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18789, 2222)); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + + [Fact] + public void DashboardRestartOwner_PreservesManagerOwnership() + { + Assert.Equal( + SshTunnelOwner.GatewayConnectionManager, + SshTunnelService.DashboardRestartOwner(SshTunnelOwner.GatewayConnectionManager, runningOnRequestedPort: true)); + Assert.Equal( + SshTunnelOwner.Settings, + SshTunnelService.DashboardRestartOwner(SshTunnelOwner.GatewayConnectionManager, runningOnRequestedPort: false)); + Assert.Equal( + SshTunnelOwner.Settings, + SshTunnelService.DashboardRestartOwner(SshTunnelOwner.Settings, runningOnRequestedPort: true)); + } + + [Fact] + public void Hold_ReservesBothLoopbackRoutes() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + DashboardForwardPortGuard.Hold(port); + try + { + using var ipv4 = new TcpListener(IPAddress.Loopback, port); + Assert.ThrowsAny(() => ipv4.Start()); + if (Socket.OSSupportsIPv6) + { + var ipv6 = new TcpListener(IPAddress.IPv6Loopback, port); + ipv6.Server.DualMode = false; + Assert.ThrowsAny(() => ipv6.Start()); + } + + Assert.Equal("127.0.0.1", SshTunnelService.LoopbackWebSocketHost(port)); + var confirmed = false; + for (var attempt = 0; attempt < 20 && !confirmed; attempt++) + { + confirmed = SshTunnelService.TryConfirmRetainedPublicRoute(port); + if (!confirmed) + Thread.Sleep(50); + } + + Assert.True(confirmed); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + + [Fact] + public async Task EnsureSettingsOwnedForwardReadyAsync_ReleasesGuardWhenStartupFails() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + using var service = new SshTunnelService(NullLogger.Instance); + try + { + var ready = await service.EnsureSettingsOwnedForwardReadyAsync( + new SshTunnelConfig("user", "host", 18789, port, false, 0), + CancellationToken.None); + + Assert.False(ready.Owned); + Assert.False(DashboardForwardPortGuard.IsHolding(port)); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + + [Fact] + public void RejectForeignForwardPort_AllowsTheDashboardGuard() + { + const int port = 45679; + DashboardForwardPortGuard.Release(port); + DashboardForwardPortGuard.Hold(port); + try + { + SshTunnelService.RejectForeignForwardPort(port); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + [Fact] public void EnsurePortIsUnoccupied_RejectsExistingListener() { @@ -337,6 +448,51 @@ public void ValidateListenerOwnership_AcceptsOnlyExactLaunchedProcess() () => SshTunnelService.ValidateListenerOwnership(unrelated, 45678, 4321, startedAt)); } + [Fact] + public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListener() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var service = new SshTunnelService(NullLogger.Instance); + + var ready = await service.EnsureSettingsOwnedForwardReadyAsync( + new SshTunnelConfig("user", "host", 18789, port), + CancellationToken.None); + + Assert.False(ready.Owned); + Assert.False(service.IsRunning); + Assert.NotEqual(TunnelStatus.Up, service.Status); + Assert.Contains(port.ToString(), service.LastError); + Assert.Contains("already owned", service.LastError); + Assert.False(service.IsSettingsOwnedForwardCurrent(service.OwnershipGeneration, port)); + } + + [Fact] + public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProxyListener() + { + using var proxy = new TcpListener(IPAddress.Loopback, 0); + proxy.Start(); + var proxyPort = ((IPEndPoint)proxy.LocalEndpoint).Port; + var localPort = proxyPort - 2; + using var service = new SshTunnelService(NullLogger.Instance); + + var ready = await service.EnsureSettingsOwnedForwardReadyAsync( + new SshTunnelConfig( + "user", + "host", + 18789, + localPort, + IncludeBrowserProxyForward: true), + CancellationToken.None); + + Assert.False(ready.Owned); + Assert.False(service.IsRunning); + Assert.NotEqual(TunnelStatus.Up, service.Status); + Assert.NotNull(service.LastError); + Assert.Contains("already owned", service.LastError); + } + [Fact] public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() { @@ -354,4 +510,511 @@ public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() 4321, DateTime.UtcNow)); } + + [Fact] + public void EnsureStarted_WhileBrowserHandoffLeaseHeld_DoesNotReplaceTrackedProcess() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + try + { + var ex = Assert.Throws( + () => service.EnsureStarted("user", "host", 18789, 45679)); + + Assert.Equal("SSH tunnel is held for a dashboard launch.", ex.Message); + Assert.Same(process, TrackedProcess(service)); + Assert.Equal(4, service.OwnershipGeneration); + Assert.True(service.IsRunning); + Assert.Equal(TunnelStatus.Up, service.Status); + Assert.Equal(config, service.ActiveConfig); + } + finally + { + service.ExitBrowserHandoff(); + } + } + + [Fact] + public async Task StopIfOwnedAsync_DuringBrowserHandoff_CancelsTheHoldAndStops() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.GatewayConnectionManager, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + var stopped = await service.StopIfOwnedAsync(config, ownershipGeneration: 4, CancellationToken.None); + + Assert.True(stopped); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.IsBrowserHandoffOpen(1)); + Assert.Null(TrackedProcess(service)); + Assert.False(service.IsRunning); + Assert.Equal(TunnelStatus.Stopped, service.Status); + Assert.Throws(() => process.HasExited); + } + + [Fact] + public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + SetPrivate(service, "k__BackingField", "tunnel still up"); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + service.ResetNotConfigured(); + + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.Null(TrackedProcess(service)); + Assert.Throws(() => process.HasExited); + Assert.Equal(TunnelStatus.NotConfigured, service.Status); + Assert.Null(service.LastError); + Assert.False(service.IsRunning); + } + + [Fact] + public void Stop_AfterNavigationSubmitted_StopsSshAndKeepsThePort() + { + var port = 45678; + DashboardForwardPortGuard.Release(port); + using (var service = new SshTunnelService(NullLogger.Instance)) + { + var config = new SshTunnelConfig("user", "host", 18789, port); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); + Assert.True(service.TryBeginDashboardNavigation(handoffId)); + Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null)); + + service.Stop(); + + Assert.False(service.HasDeferredStop); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.IsRunning); + Assert.True(DashboardForwardPortGuard.IsHolding(port)); + Assert.Throws(() => process.HasExited); + + service.Stop(); + + Assert.True(DashboardForwardPortGuard.IsHolding(port)); + } + + DashboardForwardPortGuard.Release(port); + } + + [Fact] + public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited() + { + var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + DashboardForwardPortGuard.Release(config.LocalPort); + try + { + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + using var shell = Process.Start(new ProcessStartInfo("cmd.exe", "/c exit") + { + CreateNoWindow = true, + UseShellExecute = false, + }); + Assert.NotNull(shell); + Assert.True(shell.WaitForExit(3_000)); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); + Assert.True(service.TryBeginDashboardNavigation(handoffId)); + Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: shell.Id)); + + var settled = await service.WatchBrowserHandoffConsumptionAsync( + handoffId, + config.LocalPort, + TimeSpan.Zero, + consumptionProbe: null); + + Assert.False(settled); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.True(service.IsRunning); + Assert.False(process.HasExited); + service.ExitBrowserHandoff(handoffId); + } + finally + { + service.Dispose(); + DashboardForwardPortGuard.Release(config.LocalPort); + } + } + + [Fact] + public async Task Watch_SubmittedNavigation_KeepsThePortWhenSshExits() + { + var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + DashboardForwardPortGuard.Release(config.LocalPort); + try + { + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); + Assert.True(service.TryBeginDashboardNavigation(handoffId)); + Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null)); + process.Kill(entireProcessTree: true); + Assert.True(process.WaitForExit(3_000)); + + var settled = await service.WatchBrowserHandoffConsumptionAsync( + handoffId, + config.LocalPort, + TimeSpan.Zero, + consumptionProbe: null); + + Assert.True(settled); + Assert.False(service.IsBrowserHandoffOpen(handoffId)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + Assert.False(DashboardForwardPortGuard.AllowsDestination( + config.LocalPort, + config.User, + config.Host, + config.RemotePort, + 2222)); + service.Stop(); + Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + service.ExitBrowserHandoff(handoffId); + } + finally + { + service.Dispose(); + DashboardForwardPortGuard.Release(config.LocalPort); + } + } + + [Fact] + public void Stop_DuringBrowserHandoff_CancelsTheHoldAndStops() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + service.Stop(); + + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.Null(TrackedProcess(service)); + Assert.Throws(() => process.HasExited); + Assert.Equal(TunnelStatus.Stopped, service.Status); + Assert.False(service.IsRunning); + } + + [Fact] + public void StopDuringBrowserHandoff_DoesNotWaitForBrowserTraffic() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + service.Stop(); + + Assert.False(service.HasDeferredStop); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.Null(TrackedProcess(service)); + Assert.Throws(() => process.HasExited); + Assert.Equal(TunnelStatus.Stopped, service.Status); + } + + [Fact] + public void ReleaseBrowserHandoff_AfterStop_FindsNoLease() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + service.Stop(); + + Assert.False(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); + Assert.False(watch); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.Throws(() => process.HasExited); + Assert.False(service.IsRunning); + } + + [Fact] + public void ReleaseBrowserHandoff_KeepsLeaseWhenNoStopIsPending() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + try + { + Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); + + Assert.True(watch); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.False(process.HasExited); + Assert.True(service.IsRunning); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + } + + [Fact] + public void TryEnterBrowserHandoff_RejectsReplacementProcess() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + var otherProcessId = process.Id == 1 ? 2 : 1; + + Assert.False(service.TryEnterBrowserHandoff(4, config.LocalPort, otherProcessId)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + service.NoteBrowserHandoffClient(handoffId, 88); + Assert.Equal(88, service.HandoffBrowserProcessId(handoffId)); + service.NoteBrowserHandoffClient(handoffId, 0); + Assert.Equal(88, service.HandoffBrowserProcessId(handoffId)); + + service.ExitBrowserHandoff(handoffId); + Assert.Null(service.HandoffBrowserProcessId(handoffId)); + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + + [Fact] + public void BrowserHandoff_SecondOpenKeepsTheFirstBrowserIdentity() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var first)); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var second)); + + service.NoteBrowserHandoffClient(first, 11); + service.NoteBrowserHandoffClient(second, 22); + + Assert.Equal(11, service.HandoffBrowserProcessId(first)); + Assert.Equal(22, service.HandoffBrowserProcessId(second)); + Assert.Equal(2, service.BrowserHandoffLeaseCount); + + service.ExitBrowserHandoff(second); + Assert.Equal(11, service.HandoffBrowserProcessId(first)); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + service.ExitBrowserHandoff(first); + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + + [Fact] + public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + + try + { + var completed = await service.WatchBrowserHandoffConsumptionAsync( + config.LocalPort, + TimeSpan.Zero, + static _ => false); + + Assert.False(completed); + Assert.False(process.HasExited); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.True(service.IsRunning); + Assert.Equal(TunnelStatus.Up, service.Status); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + } + } + + [Fact] + public async Task WatchBrowserHandoff_NullProbe_HoldsUntilTheSshProcessIsGone() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); + + var waiting = await service.WatchBrowserHandoffConsumptionAsync( + handoffId, + config.LocalPort, + TimeSpan.Zero, + consumptionProbe: null); + Assert.False(waiting); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.True(service.IsRunning); + + process.Kill(entireProcessTree: true); + Assert.True(process.WaitForExit(3_000)); + + var settled = await service.WatchBrowserHandoffConsumptionAsync( + handoffId, + config.LocalPort, + TimeSpan.FromSeconds(2), + consumptionProbe: null); + Assert.True(settled); + Assert.False(service.IsBrowserHandoffOpen(handoffId)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.False(service.IsRunning); + } + + [Fact] + public async Task WatchBrowserHandoff_ProbeCompletesDeferredStop() + { + using var service = new SshTunnelService(NullLogger.Instance); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + using var process = PlantRunningTunnel( + service, + config, + SshTunnelOwner.Settings, + generation: 4); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); + service.Stop(); + + try + { + var completed = await service.WatchBrowserHandoffConsumptionAsync( + config.LocalPort, + TimeSpan.FromSeconds(5), + static _ => true); + + Assert.True(completed); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.Null(TrackedProcess(service)); + Assert.Throws(() => process.HasExited); + Assert.Equal(TunnelStatus.Stopped, service.Status); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + } + } + + private static Process PlantRunningTunnel( + SshTunnelService service, + SshTunnelConfig config, + SshTunnelOwner owner, + long generation) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/d /c ping -n 30 127.0.0.1", + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + Assert.True(process.Start()); + SetPrivate(service, "_process", process); + SetPrivate(service, "_processStarted", true); + SetPrivate(service, "_currentConfig", config); + SetPrivate(service, "_currentOwner", owner); + SetPrivate( + service, + "_lastSpec", + $"{config.User}@{config.Host}:{config.SshPort}:{config.LocalPort}:{config.RemotePort}:browserProxy={config.IncludeBrowserProxyForward}"); + SetPrivate(service, "_lifecycleGeneration", generation); + SetPrivate(service, "k__BackingField", TunnelStatus.Up); + return process; + } + + private static Process? TrackedProcess(SshTunnelService service) => + (Process?)GetPrivate(service, "_process"); + + private static SshTunnelOwner CurrentOwner(SshTunnelService service) => + (SshTunnelOwner)GetPrivate(service, "_currentOwner")!; + + private static void SetPrivate(object target, string name, object? value) => + PrivateField(name).SetValue(target, value); + + private static object? GetPrivate(object target, string name) => + PrivateField(name).GetValue(target); + + private static FieldInfo PrivateField(string name) + { + var field = typeof(SshTunnelService).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return field; + } } diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs index 5304276ba..dc4c7883f 100644 --- a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs +++ b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net; namespace OpenClaw.Connection.Tests; @@ -11,6 +12,104 @@ public void GetProcessCommandLine_InvalidPid_ReturnsNull() Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(-1)); } + [Fact] + public void IsEstablishedLoopbackForwardUse_MatchesBrowserOrAcceptedRowOnly() + { + var loopback = IPAddress.Loopback; + var remote = IPAddress.Parse("203.0.113.5"); + + Assert.False(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse( + state: 2, + loopback, + localPort: 18789, + remote, + remotePort: 40000, + forwardPort: 18789)); + Assert.True(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + loopback, + localPort: 18789, + remote, + remotePort: 40000, + forwardPort: 18789)); + Assert.True(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + remote, + localPort: 40000, + loopback, + remotePort: 18789, + forwardPort: 18789)); + Assert.False(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + remote, + localPort: 18789, + remote, + remotePort: 40000, + forwardPort: 18789)); + } + + [Fact] + public void IsUnseenEstablishedForwardUse_RequiresTheLaunchedBrowserClient() + { + var loopback = IPAddress.Loopback; + var client = IPAddress.Parse("203.0.113.8"); + var seen = new HashSet(StringComparer.Ordinal) + { + WindowsTcpListenerSnapshot.EstablishedForwardKey(client, 40000, loopback, 18789, 88), + }; + + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40000, + loopback, + remotePort: 18789, + processId: 88, + forwardPort: 18789, + browserProcessId: 88, + seen)); + Assert.True(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40001, + loopback, + remotePort: 18789, + processId: 88, + forwardPort: 18789, + browserProcessId: 88, + seen)); + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40001, + loopback, + remotePort: 18789, + processId: 88, + forwardPort: 18789, + browserProcessId: 77, + seen)); + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40001, + loopback, + remotePort: 18789, + processId: 88, + forwardPort: 18789, + browserProcessId: null, + seen)); + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + loopback, + localPort: 18789, + client, + remotePort: 40002, + processId: 88, + forwardPort: 18789, + browserProcessId: 88, + seen)); + } + [Fact] public async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses() { diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index af2c4ef08..196a2f640 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -225,6 +225,37 @@ public void BuildArguments_RejectsInvalidSshPort() sshPort: 0)); } + [Fact] + public void BuildArguments_ValidatesTheExplicitProxyPort() + { + var args = SshTunnelCommandLine.BuildArguments( + "scott", + "mac-mini.local", + 18789, + 65534, + includeBrowserProxyForward: true, + sshPort: 22, + browserProxyLocalPort: 19002); + + Assert.Contains("-L 65534:127.0.0.1:18789", args); + Assert.Contains("-L 19002:127.0.0.1:18791", args); + } + + [Fact] + public void BuildArguments_AcceptsProxyPorts65534And65535() + { + var args = SshTunnelCommandLine.BuildArguments( + "scott", + "mac-mini.local", + 18789, + 65533, + includeBrowserProxyForward: true, + sshPort: 22); + + Assert.Contains("-L 65533:127.0.0.1:18789", args); + Assert.Contains("-L 65535:127.0.0.1:18791", args); + } + [Fact] public void BuildArguments_RejectsBrowserProxyForwardWhenPortPlusTwoOverflows() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 65aac5279..cb0f6ce77 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -473,12 +473,80 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() { var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboard"); + var prepare = ExtractMethod(source, "PrepareDashboardLaunchAsync"); + var launch = ExtractMethod(source, "LaunchDashboardBrowserAsync"); + var launcher = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Services", + "GatewayDashboardLauncher.cs")); Assert.Contains("new GatewayDashboardLauncher(", method); - Assert.Contains("EnsureSshTunnelConfigured,", method); Assert.Contains("ShowDashboardLaunchFailureAsync", method); + Assert.Contains("Dismiss(GatewayDashboardLauncher.FailureNotificationId)", method); + Assert.Contains("await launcher.OpenAsync(path)", method); Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", method); Assert.DoesNotContain("Process.Start", method); + Assert.DoesNotContain("Failed to open dashboard", method); + Assert.Contains("if (!sshGate.Allowed)", prepare); + Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare); + Assert.DoesNotContain("ProjectOntoLocalForward", prepare); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", prepare); + Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot)", launch); + Assert.Contains("TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", launch); + Assert.Contains("HasDeferredStop", launch); + Assert.Contains("WatchBrowserHandoffConsumptionAsync(", launch); + Assert.Contains("ExitBrowserHandoff(handoffId)", launch); + Assert.DoesNotContain("NoteBrowserHandoffProcessName", launch); + Assert.DoesNotContain("TryGetDefaultBrowserProcessName", launch); + Assert.DoesNotContain("hasBrowserIdentity", launch); + Assert.DoesNotContain("The dashboard did not confirm a browser connection. The SSH forward hold ended.", launch); + Assert.DoesNotContain("ex.Message", launch); + Assert.DoesNotContain("Process.Start", launch); + var watch = launch[launch.IndexOf("WatchBrowserHandoffConsumptionAsync(", StringComparison.Ordinal)..]; + Assert.Contains("if (!tunnel.IsRunning)", watch); + Assert.Contains("ExitBrowserHandoff(handoffId)", watch); + Assert.DoesNotContain("ex.Message", launcher); + AssertInOrder( + launch, + "UrlUsesCapturedForward(url, prepared.Snapshot)", + "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", + "TryBeginDashboardNavigation(handoffId)", + "DashboardBrowserShell.TryOpen", + "CompleteDashboardNavigation(handoffId, opened, browserProcessId)", + "WatchBrowserHandoffConsumptionAsync(", + "if (!tunnel.IsRunning)", + "ExitBrowserHandoff(handoffId)"); + AssertInOrder( + launcher, + "await ensureTunnel()", + "ProjectOntoLocalForward", + "GatewayDashboardUrlBuilder.Build("); + } + + [Fact] + public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl() + { + var source = ReadAppSources(); + var gate = ExtractMethod(source, "EnsureDashboardSshForwardOwnedAsync"); + + Assert.Contains("if (!_settings.UseSshTunnel)", gate); + Assert.Contains("_sshTunnelService?.Stop()", gate); + Assert.Contains("return (true, null, 0, 0);", gate); + Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); + Assert.Contains("if (!owned.Owned)", gate); + Assert.Contains("return (true, owned.Generation, owned.LocalPort, owned.ProcessId);", gate); + Assert.DoesNotContain("OwnershipGeneration", gate); + Assert.DoesNotContain("CurrentLocalPort", gate); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate); + Assert.DoesNotContain("EnsureStarted(", gate); + AssertInOrder( + gate, + "if (!_settings.UseSshTunnel)", + "EnsureSettingsOwnedForwardReadyAsync(", + "if (!owned.Owned)", + "owned.Generation"); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs new file mode 100644 index 000000000..24099d464 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs @@ -0,0 +1,20 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class DashboardBrowserShellTests +{ + [Fact] + public void TryOpen_InvalidTarget_ReturnsFalseWithoutAProcessId() + { + Assert.False(DashboardBrowserShell.TryOpen("", out var processId)); + Assert.Null(processId); + } + + [Fact] + public void InterpretStartedProcess_NullProcess_IsSuccessfulActivationWithoutAProcessId() + { + Assert.True(DashboardBrowserShell.InterpretStartedProcess(null, out var processId)); + Assert.Null(processId); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 7be9c15ee..3cc7ff14a 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -100,6 +100,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 9d725a8f0..03252b818 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -120,7 +120,7 @@ public void RetiredOrInvalidHandoff_IsVisibleFailureRoute_NotGlobalDashboardFall public async Task OrdinaryDashboard_UsesRequestedPathAndNormalCredentials(string? path, string suffix) { string? launched = null; - var launcher = new GatewayDashboardLauncher(() => true, + var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true), () => new(Gateway.Url, "synthetic shared", false, CredentialResolver.SourceSharedGatewayToken), url => { launched = url; return Task.FromResult(true); }, () => throw new InvalidOperationException("Unexpected failure")); @@ -135,7 +135,7 @@ public async Task OrdinaryDashboard_UsesRequestedPathAndNormalCredentials(string public async Task DeviceAndBootstrapTokens_NeverEnterBrowserUrl(string source, bool bootstrap) { string? launched = null; - var launcher = new GatewayDashboardLauncher(() => true, + var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true), () => new(Gateway.Url, "do-not-export", bootstrap, source), url => { launched = url; return Task.FromResult(true); }, () => throw new InvalidOperationException("Unexpected failure")); @@ -152,7 +152,7 @@ public async Task Failure_IsReportedOnceWithoutAutomaticRetry(string failure) { var launches = 0; var failures = 0; - var launcher = new GatewayDashboardLauncher(() => failure != "tunnel", + var launcher = new GatewayDashboardLauncher(() => Task.FromResult(failure != "tunnel"), () => failure == "credential" ? null : new(Gateway.Url, "shared", false, CredentialResolver.SourceSharedGatewayToken), _ => { @@ -181,7 +181,7 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA var urls = new List(); var failures = 0; var opened = 0; - var launcher = new GatewayDashboardLauncher(() => true, + var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true), () => new(Gateway.Url, "synthetic", false, CredentialResolver.SourceSharedGatewayToken), url => { urls.Add(url); return Task.FromResult(urls.Count == 2); }, () => failures++, () => opened++);