Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/CONNECTION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Setup codes (from QR scan or paste) decode to `{ url, bootstrapToken }` via `Set

`SshTunnelService` manages an SSH local port-forward process. `SshTunnelManager` wraps it behind `ISshTunnelManager` for the connection manager.

When a `GatewayRecord` has `SshTunnel` config, the connection manager starts the tunnel before connecting the WebSocket client to `ws://localhost:<localPort>`.
When a `GatewayRecord` has `SshTunnel` config, the connection manager starts the tunnel before connecting the WebSocket client to `ws://localhost:<localPort>`. The config stores the SSH daemon port (`sshPort`, default `22`) separately from the remote gateway port forwarded by `-L`.

`SshTunnelSnapshot` provides a read-only point-in-time view of tunnel state for UI consumption (avoids coupling UI to the mutable service).

Expand Down Expand Up @@ -232,3 +232,4 @@ Connection tests live in `tests/OpenClaw.Connection.Tests/`:
- `ConnectionDiagnosticsTests` — ring buffer diagnostics

The heaviest remaining gap is Windows shell UI behavior (tray clicks, tooltip visibility, WinUI menu routing). Cover pure decision logic in unit tests; use manual or integration smoke tests for shell behavior.

2 changes: 1 addition & 1 deletion docs/WINDOWS_NODE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ When the node connects, it advertises these capabilities:
- Confirm the Browser proxy bridge toggle is enabled in Settings, then save and reconnect or re-pair if the gateway keeps an older command snapshot.
- The bridge is local-only: it calls `http://127.0.0.1:<gateway-port+2>` from Windows. For a gateway on `ws://127.0.0.1:18789`, the browser-control host must listen on `127.0.0.1:18791`.
- In managed SSH tunnel mode, keep Browser proxy bridge enabled so the tray forwards local gateway port + 2 to remote gateway port + 2. Settings shows a selectable preview of the exact `ssh -N -L ...` command.
- If using a manual SSH tunnel, add both forwards, for example: `ssh -N -L 18789:127.0.0.1:18789 -L 18791:127.0.0.1:18791 <user>@<host>`. If local and remote gateway ports differ, forward `<local-gateway-port+2>` to `127.0.0.1:<remote-gateway-port+2>`.
- If using a manual SSH tunnel, add both forwards, for example: `ssh -N -L 18789:127.0.0.1:18789 -L 18791:127.0.0.1:18791 <user>@<host>`. If the SSH daemon is not listening on port 22, include `-p <ssh-port>`. If local and remote gateway ports differ, forward `<local-gateway-port+2>` to `127.0.0.1:<remote-gateway-port+2>`.
- A local SSH forward is not enough if the remote browser-control host is not running. Command Center port diagnostics should show whether the local gateway and browser-control ports are listening and which process owns them.
- If Command Center shows the browser-control port listening but `browser.proxy` returns an auth error, verify the Windows Settings gateway token matches the browser-control host token/password. QR/bootstrap pairing can connect the node without saving a shared gateway token, but browser-control auth may still require one.
- A local smoke can verify the host dependency without proving gateway invoke auth: start the upstream browser-control host with a temporary no-secret config, confirm `http://127.0.0.1:<gateway-port+2>/` and `/tabs` return HTTP 200, then stop the captured host process. The full parity smoke is not complete until `openclaw nodes invoke --command browser.proxy` succeeds through the active gateway.
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed record ConnectionSettingsSnapshot(
bool UseSshTunnel,
string? SshTunnelUser,
string? SshTunnelHost,
int SshTunnelSshPort,
int SshTunnelRemotePort,
int SshTunnelLocalPort,
bool EnableNodeMode,
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
{
var tunnel = record.SshTunnel;
if (string.IsNullOrWhiteSpace(tunnel.User) || string.IsNullOrWhiteSpace(tunnel.Host) ||
tunnel.SshPort is < 1 or > 65535 ||
tunnel.RemotePort is < 1 or > 65535 || tunnel.LocalPort is < 1 or > 65535)
{
_logger.Warn("[ConnMgr] SSH tunnel config is incomplete");
Expand Down
3 changes: 2 additions & 1 deletion src/OpenClaw.Connection/GatewayRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ public sealed record SshTunnelConfig(
string Host,
int RemotePort,
int LocalPort,
bool IncludeBrowserProxyForward = false);
bool IncludeBrowserProxyForward = false,
int SshPort = 22);
27 changes: 26 additions & 1 deletion src/OpenClaw.Connection/GatewayRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,31 @@ public bool MigrateFromSettings(
int sshRemotePort,
int sshLocalPort,
string settingsDir,
IOpenClawLogger? logger = null) =>
MigrateFromSettings(
gatewayUrl,
token,
bootstrapToken,
useSshTunnel,
sshUser,
sshHost,
sshPort: 22,
sshRemotePort,
sshLocalPort,
settingsDir,
logger);

public bool MigrateFromSettings(
string? gatewayUrl,
string? token,
string? bootstrapToken,
bool useSshTunnel,
string? sshUser,
string? sshHost,
int sshPort,
int sshRemotePort,
int sshLocalPort,
string settingsDir,
IOpenClawLogger? logger = null)
{
if (string.IsNullOrWhiteSpace(gatewayUrl))
Expand All @@ -215,7 +240,7 @@ public bool MigrateFromSettings(
SharedGatewayToken = string.IsNullOrWhiteSpace(bootstrapToken) ? token : null,
BootstrapToken = !string.IsNullOrWhiteSpace(bootstrapToken) ? bootstrapToken : null,
SshTunnel = useSshTunnel
? new SshTunnelConfig(sshUser ?? "", sshHost ?? "", sshRemotePort, sshLocalPort)
? new SshTunnelConfig(sshUser ?? "", sshHost ?? "", sshRemotePort, sshLocalPort, SshPort: sshPort)
: null
};

Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Connection/SettingsChangeImpact.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, Co
if (prev.UseSshTunnel != next.UseSshTunnel ||
prev.SshTunnelUser != next.SshTunnelUser ||
prev.SshTunnelHost != next.SshTunnelHost ||
prev.SshTunnelSshPort != next.SshTunnelSshPort ||
prev.SshTunnelRemotePort != next.SshTunnelRemotePort ||
prev.SshTunnelLocalPort != next.SshTunnelLocalPort)
return SettingsChangeImpact.OperatorReconnectRequired;
Expand Down
21 changes: 12 additions & 9 deletions src/OpenClaw.Connection/SshTunnelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,14 @@ public void EnsureStarted(string user, string host, int remotePort, int localPor
=> EnsureStarted(user, host, remotePort, localPort, includeBrowserProxyForward: false);

public void EnsureStarted(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward)
=> EnsureStarted(user, host, remotePort, localPort, includeBrowserProxyForward, sshPort: 22);

public void EnsureStarted(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward, int sshPort)
{
user = user.Trim();
host = host.Trim();

var spec = BuildSpec(user, host, remotePort, localPort, includeBrowserProxyForward);
var spec = BuildSpec(user, host, remotePort, localPort, includeBrowserProxyForward, sshPort);

if (IsRunning && string.Equals(_lastSpec, spec, StringComparison.Ordinal))
{
Expand All @@ -71,7 +74,7 @@ public void EnsureStarted(string user, string host, int remotePort, int localPor

Stop();
Status = TunnelStatus.Starting;
StartProcess(user, host, remotePort, localPort, includeBrowserProxyForward);
StartProcess(user, host, remotePort, localPort, includeBrowserProxyForward, sshPort);
_lastSpec = spec;
}

Expand Down Expand Up @@ -122,12 +125,12 @@ public void ResetNotConfigured()
Status = TunnelStatus.NotConfigured;
}

private void StartProcess(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward)
private void StartProcess(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward, int sshPort)
{
var psi = new ProcessStartInfo
{
FileName = "ssh",
Arguments = SshTunnelCommandLine.BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward),
Arguments = SshTunnelCommandLine.BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward, sshPort),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand Down Expand Up @@ -206,15 +209,15 @@ private void StartProcess(string user, string host, int remotePort, int localPor
LastError = null;
Status = TunnelStatus.Up;

_logger.Info($"SSH tunnel started: 127.0.0.1:{localPort} -> 127.0.0.1:{remotePort} via {user}@{host}");
_logger.Info($"SSH tunnel started: 127.0.0.1:{localPort} -> 127.0.0.1:{remotePort} via {user}@{host}:{sshPort}");
if (includeBrowserProxyForward)
{
_logger.Info($"SSH tunnel browser proxy forward started: 127.0.0.1:{localPort + 2} -> 127.0.0.1:{remotePort + 2} via {user}@{host}");
_logger.Info($"SSH tunnel browser proxy forward started: 127.0.0.1:{localPort + 2} -> 127.0.0.1:{remotePort + 2} via {user}@{host}:{sshPort}");
}
}

private static string BuildSpec(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward)
=> $"{user}@{host}:{localPort}:{remotePort}:browserProxy={includeBrowserProxyForward}";
private static string BuildSpec(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward, int sshPort)
=> $"{user}@{host}:{sshPort}:{localPort}:{remotePort}:browserProxy={includeBrowserProxyForward}";

public void Dispose()
{
Expand All @@ -223,7 +226,7 @@ public void Dispose()

public Task<string> StartAsync(SshTunnelConfig config, CancellationToken ct)
{
EnsureStarted(config.User, config.Host, config.RemotePort, config.LocalPort, config.IncludeBrowserProxyForward);
EnsureStarted(config.User, config.Host, config.RemotePort, config.LocalPort, config.IncludeBrowserProxyForward, config.SshPort);
var localUrl = $"ws://localhost:{config.LocalPort}";
return Task.FromResult(localUrl);
}
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Shared/SettingsData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record class SettingsData
public bool UseSshTunnel { get; set; } = false;
public string? SshTunnelUser { get; set; }
public string? SshTunnelHost { get; set; }
public int SshTunnelSshPort { get; set; } = 22;
public int SshTunnelRemotePort { get; set; } = 18789;
public int SshTunnelLocalPort { get; set; } = 18789;
public bool AutoStart { get; set; } = true;
Expand Down
16 changes: 16 additions & 0 deletions src/OpenClaw.Shared/SshTunnelCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ public static string BuildArguments(
int remotePort,
int localPort,
bool includeBrowserProxyForward)
=> BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward, sshPort: 22);

public static string BuildArguments(
string user,
string host,
int remotePort,
int localPort,
bool includeBrowserProxyForward,
int sshPort)
{
user = user.Trim();
host = host.Trim();
Expand All @@ -37,6 +46,7 @@ public static string BuildArguments(
throw new ArgumentException($"SSH host contains invalid characters: '{host}'", nameof(host));
ValidatePort(remotePort, nameof(remotePort));
ValidatePort(localPort, nameof(localPort));
ValidatePort(sshPort, nameof(sshPort));
if (includeBrowserProxyForward)
{
ValidateBrowserProxyPort(remotePort, nameof(remotePort));
Expand All @@ -47,6 +57,12 @@ public static string BuildArguments(
AppendLocalForward(sb, localPort, remotePort);
if (includeBrowserProxyForward)
AppendLocalForward(sb, localPort + 2, remotePort + 2);
if (sshPort != 22)
{
sb.Append("-p ");
sb.Append(sshPort);
sb.Append(' ');
}
sb.Append(user);
sb.Append('@');
sb.Append(host);
Expand Down
13 changes: 9 additions & 4 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ public void EnsureSshTunnelStarted()
_settings.SshTunnelHost,
_settings.SshTunnelRemotePort,
_settings.SshTunnelLocalPort,
includeBrowserProxyForward);
includeBrowserProxyForward,
_settings.SshTunnelSshPort);
}

/// <summary>
Expand Down Expand Up @@ -1312,7 +1313,8 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false)
_settings.SshTunnelLocalPort,
_settings.NodeBrowserProxyEnabled &&
SshTunnelCommandLine.CanForwardBrowserProxyPort(
_settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort))
_settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort),
_settings.SshTunnelSshPort)
: null,
};
_gatewayRegistry.AddOrUpdate(record);
Expand Down Expand Up @@ -1499,6 +1501,7 @@ private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger
_settings.UseSshTunnel,
_settings.SshTunnelUser,
_settings.SshTunnelHost,
_settings.SshTunnelSshPort,
_settings.SshTunnelRemotePort,
_settings.SshTunnelLocalPort,
SettingsManager.SettingsDirectoryPath,
Expand Down Expand Up @@ -4142,7 +4145,8 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 ||
_settings.SshTunnelHost,
_settings.SshTunnelRemotePort,
_settings.SshTunnelLocalPort,
includeBrowserProxy);
includeBrowserProxy,
_settings.SshTunnelSshPort);
DiagnosticsJsonlService.Write("tunnel.ensure_started", new
{
status = _sshTunnelService.Status.ToString(),
Expand Down Expand Up @@ -4199,7 +4203,8 @@ private async Task OnSshTunnelExitedAsync(int exitCode)
_settings.SshTunnelHost,
_settings.SshTunnelRemotePort,
_settings.SshTunnelLocalPort,
restartBrowserProxy);
restartBrowserProxy,
_settings.SshTunnelSshPort);
Logger.Info("SSH tunnel restarted successfully");
DiagnosticsJsonlService.Write("tunnel.restart_succeeded", new
{
Expand Down
14 changes: 12 additions & 2 deletions src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -986,11 +986,21 @@
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox x:Uid="ConnectionPage_RemotePort" Grid.Column="0"
<TextBox x:Uid="ConnectionPage_SshServerPort" Grid.Column="0"
x:Name="AddSshServerPortBox"
Header="SSH port"
PlaceholderText="22"/>
<TextBox x:Uid="ConnectionPage_RemotePort" Grid.Column="1"
x:Name="AddSshRemotePortBox"
Header="Remote port"
PlaceholderText="18789"/>
<TextBox x:Uid="ConnectionPage_LocalPort" Grid.Column="1"
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox x:Uid="ConnectionPage_LocalPort" Grid.Column="0"
x:Name="AddSshLocalPortBox"
Header="Local port"
PlaceholderText="18789"/>
Expand Down
Loading
Loading