Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dc29fbb
fix(connection): open the dashboard only after the SSH forward is owned
SebTardif Sep 24, 2026
32cdda6
Merge main into PR #1514
shanselman Sep 25, 2026
6649c8b
fix(connection): bind dashboard launch to SSH ownership lease
shanselman Sep 25, 2026
af1b4ba
Revert "fix(connection): bind dashboard launch to SSH ownership lease"
shanselman Sep 25, 2026
d135659
fix(connection): recheck SSH ownership before the dashboard opens
SebTardif Sep 27, 2026
79d3c3a
fix(connection): hold the SSH forward through dashboard launch
SebTardif Sep 27, 2026
d088984
fix(connection): do not replace an SSH forward during dashboard launch
SebTardif Sep 27, 2026
0c8c67c
fix(connection): keep SSH stop and replacement atomic with the handof…
SebTardif Sep 28, 2026
d6b8ec7
fix(connection): finish a deferred SSH stop when the dashboard handof…
SebTardif Sep 28, 2026
8e74928
fix(connection): keep the checked gateway on its SSH forward until th…
SebTardif Sep 29, 2026
53e529e
ci: retrigger after handshake wait flake
SebTardif Sep 29, 2026
4aed6a0
fix(connection): bind the dashboard token, lease, and browser connection
SebTardif Sep 30, 2026
ab3d748
ci: retrigger after product CI did not start
SebTardif Oct 1, 2026
8d5cf3a
merge main into the dashboard SSH handoff
SebTardif Oct 1, 2026
7e4afbc
fix(connection): hold the dashboard SSH lease for the launched browser
SebTardif Oct 2, 2026
b90da57
ci: retrigger after the dark sidebar navigation flake
SebTardif Oct 2, 2026
148dae6
fix(connection): keep each dashboard handoff until that browser connects
SebTardif Oct 2, 2026
d8601d5
fix(connection): do not treat browser traffic as dashboard consumption
SebTardif Oct 2, 2026
23ff490
fix(connection): let Stop cancel a dashboard hold and open the local …
SebTardif Oct 2, 2026
947f30a
fix(connection): keep a submitted dashboard URL off a freed SSH port
SebTardif Oct 2, 2026
0dc8d36
ci: retrigger after the dark sidebar navigation flake
SebTardif Oct 2, 2026
a74068c
fix(connection): keep pending dashboard navigation across Stop and sh…
SebTardif Oct 2, 2026
a17730b
ci: retrigger after a concurrent command output flake
SebTardif Oct 2, 2026
890e158
fix(connection): let restart reuse the dashboard port guard
SebTardif Oct 2, 2026
36067e9
ci: retrigger after a canceled-process wait flake
SebTardif Oct 2, 2026
6f0a4cb
fix(connection): keep the dashboard port through SSH restart
SebTardif Oct 2, 2026
36835f2
fix(connection): restart dashboard SSH behind the retained port
SebTardif Oct 2, 2026
99f5016
fix(connection): keep the dashboard port across SSH stop
SebTardif Oct 2, 2026
2a20af4
fix(connection): keep manager ownership and both loopback routes
SebTardif Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/OpenClaw.Connection/DashboardBrowserHandoff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace OpenClaw.Connection;

/// <summary>
/// Gateway and SSH forward captured together for one dashboard launch.
/// </summary>
public sealed record DashboardGatewayTunnelSnapshot(
string GatewayIdentity,
string GatewayUrl,
string Token,
bool AppendToken,
SshTunnelConfig? Tunnel,
bool IsBootstrapToken = false,
string Source = "");

/// <summary>
/// Binds a dashboard URL to the gateway and SSH destination checked before launch.
/// </summary>
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);
}
}
269 changes: 269 additions & 0 deletions src/OpenClaw.Connection/DashboardForwardPortGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;

namespace OpenClaw.Connection;

/// <summary>
/// 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.
/// </summary>
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<int, Slot> 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;
}
}

}
3 changes: 2 additions & 1 deletion src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
}
12 changes: 8 additions & 4 deletions src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ public static bool TryResolve(
active.Url,
active.SharedGatewayToken!,
false,
CredentialResolver.SourceSharedGatewayToken);
CredentialResolver.SourceSharedGatewayToken,
active.Id);
return true;
}

Expand All @@ -103,7 +104,8 @@ public static bool TryResolve(
active.Url,
resolved.Token,
resolved.IsBootstrapToken,
resolved.Source);
resolved.Source,
active.Id);
return true;
}

Expand Down Expand Up @@ -148,7 +150,8 @@ public static bool TryResolve(
gatewayUrl,
legacyCredential.Token,
legacyCredential.IsBootstrapToken,
legacyCredential.Source);
legacyCredential.Source,
legacyRecord.Id);
return true;
}
}
Expand All @@ -157,4 +160,5 @@ public sealed record InteractiveGatewayCredential(
string GatewayUrl,
string Token,
bool IsBootstrapToken,
string Source);
string Source,
string? GatewayId = null);
Loading
Loading