From f7fd5a6530f376af08ce333c7e8840f1dc485410 Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Fri, 5 Jun 2026 13:07:05 -0400 Subject: [PATCH 1/2] Add SSH server port for managed tunnels Allow managed SSH tunnel configurations to specify the SSH daemon port separately from the remote gateway port. Persist the setting across gateway records and legacy settings, expose it in connection UIs, and include it in tunnel restart identity and command generation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/CONNECTION_ARCHITECTURE.md | 2 +- docs/WINDOWS_NODE_TESTING.md | 2 +- .../ConnectionSettingsSnapshot.cs | 1 + .../GatewayConnectionManager.cs | 1 + src/OpenClaw.Connection/GatewayRecord.cs | 3 +- src/OpenClaw.Connection/GatewayRegistry.cs | 27 +++++++++++- .../SettingsChangeImpact.cs | 1 + src/OpenClaw.Connection/SshTunnelService.cs | 21 ++++++---- src/OpenClaw.Shared/SettingsData.cs | 1 + src/OpenClaw.Shared/SshTunnelCommandLine.cs | 16 +++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 13 ++++-- .../Pages/ConnectionPage.xaml | 14 ++++++- .../Pages/ConnectionPage.xaml.cs | 17 ++++++-- .../Services/SettingsDataExtensions.cs | 1 + .../Services/SettingsManager.cs | 5 +++ .../Strings/en-us/Resources.resw | 9 ++++ .../Strings/fr-fr/Resources.resw | 9 ++++ .../Strings/nl-nl/Resources.resw | 9 ++++ .../Strings/zh-cn/Resources.resw | 9 ++++ .../Strings/zh-tw/Resources.resw | 9 ++++ .../Windows/ConnectionStatusWindow.xaml | 11 ++++- .../Windows/ConnectionStatusWindow.xaml.cs | 9 +++- .../GatewayConnectionManagerTests.cs | 3 +- .../GatewayRegistryTests.cs | 32 +++++++++++++- .../SettingsChangeImpactTests.cs | 11 +++++ tests/OpenClaw.Shared.Tests/ModelsTests.cs | 42 +++++++++++++++++++ .../SettingsRoundTripTests.cs | 29 +++++++++++++ 27 files changed, 280 insertions(+), 27 deletions(-) diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 13353f1d6..8ef07fe7f 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -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:`. +When a `GatewayRecord` has `SshTunnel` config, the connection manager starts the tunnel before connecting the WebSocket client to `ws://localhost:`. 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). diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index c0e5cba3e..c365cd6c3 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -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:` 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 @`. If local and remote gateway ports differ, forward `` to `127.0.0.1:`. +- 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 @`. If the SSH daemon is not listening on port 22, include `-p `. If local and remote gateway ports differ, forward `` to `127.0.0.1:`. - 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:/` 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. diff --git a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs index 147fc29a5..a38bd8e3c 100644 --- a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs +++ b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs @@ -9,6 +9,7 @@ public sealed record ConnectionSettingsSnapshot( bool UseSshTunnel, string? SshTunnelUser, string? SshTunnelHost, + int SshTunnelSshPort, int SshTunnelRemotePort, int SshTunnelLocalPort, bool EnableNodeMode, diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index e15b50196..b30ac0f45 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -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"); diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index d0680d637..44a82c427 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -49,4 +49,5 @@ public sealed record SshTunnelConfig( string Host, int RemotePort, int LocalPort, - bool IncludeBrowserProxyForward = false); + bool IncludeBrowserProxyForward = false, + int SshPort = 22); diff --git a/src/OpenClaw.Connection/GatewayRegistry.cs b/src/OpenClaw.Connection/GatewayRegistry.cs index 84d280ee3..ec46cc277 100644 --- a/src/OpenClaw.Connection/GatewayRegistry.cs +++ b/src/OpenClaw.Connection/GatewayRegistry.cs @@ -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)) @@ -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 }; diff --git a/src/OpenClaw.Connection/SettingsChangeImpact.cs b/src/OpenClaw.Connection/SettingsChangeImpact.cs index 0f00d6a1e..17f00c21c 100644 --- a/src/OpenClaw.Connection/SettingsChangeImpact.cs +++ b/src/OpenClaw.Connection/SettingsChangeImpact.cs @@ -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; diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index e6729c1ad..90944f5eb 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -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)) { @@ -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; } @@ -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, @@ -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() { @@ -223,7 +226,7 @@ public void Dispose() public Task 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); } diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index 49f3c7165..7fb8ac72b 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -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; diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index 54bc5cbd1..0c8ff577e 100644 --- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs +++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs @@ -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(); @@ -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)); @@ -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); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 8e29154ef..4a7d00f57 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -100,7 +100,8 @@ public void EnsureSshTunnelStarted() _settings.SshTunnelHost, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, - includeBrowserProxyForward); + includeBrowserProxyForward, + _settings.SshTunnelSshPort); } /// @@ -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); @@ -1499,6 +1501,7 @@ private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger _settings.UseSshTunnel, _settings.SshTunnelUser, _settings.SshTunnelHost, + _settings.SshTunnelSshPort, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, SettingsManager.SettingsDirectoryPath, @@ -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(), @@ -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 { diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index b8579a1f6..791b7846e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -986,11 +986,21 @@ - + - + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 2cdb25901..628b40603 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1799,6 +1799,7 @@ private void OnEditTunnelSettings(object sender, RoutedEventArgs e) AddSshExpander.IsExpanded = true; AddSshUserBox.Text = rec.SshTunnel.User; AddSshHostBox.Text = rec.SshTunnel.Host; + AddSshServerPortBox.Text = rec.SshTunnel.SshPort.ToString(); AddSshRemotePortBox.Text = rec.SshTunnel.RemotePort.ToString(); AddSshLocalPortBox.Text = rec.SshTunnel.LocalPort.ToString(); } @@ -1911,6 +1912,7 @@ private void OnSavedRowEdit(object sender, RoutedEventArgs e) AddSshExpander.IsExpanded = true; AddSshUserBox.Text = rec.SshTunnel.User; AddSshHostBox.Text = rec.SshTunnel.Host; + AddSshServerPortBox.Text = rec.SshTunnel.SshPort.ToString(); AddSshRemotePortBox.Text = rec.SshTunnel.RemotePort.ToString(); AddSshLocalPortBox.Text = rec.SshTunnel.LocalPort.ToString(); } @@ -2122,6 +2124,12 @@ private async Task DoDirectConnectFromAddFormAsync() { var sshUser = AddSshUserBox.Text.Trim(); var sshHost = AddSshHostBox.Text.Trim(); + var sshPortText = string.IsNullOrWhiteSpace(AddSshServerPortBox.Text) ? "22" : AddSshServerPortBox.Text; + if (!int.TryParse(sshPortText, out var sshPort) || sshPort is < 1 or > 65535) + { + AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_SshServerPortInvalid"); + return; + } if (!int.TryParse(AddSshRemotePortBox.Text, out var remotePort) || remotePort is < 1 or > 65535) { AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_SshRemotePortInvalid"); @@ -2132,7 +2140,7 @@ private async Task DoDirectConnectFromAddFormAsync() AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_SshLocalPortInvalid"); return; } - sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort); + sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort, SshPort: sshPort); } AddSaveButton.IsEnabled = false; @@ -2145,6 +2153,7 @@ private async Task DoDirectConnectFromAddFormAsync() var prevUseSsh = previousSettings?.UseSshTunnel ?? false; var prevSshUser = previousSettings?.SshTunnelUser; var prevSshHost = previousSettings?.SshTunnelHost; + var prevSshPort = previousSettings?.SshTunnelSshPort ?? 22; var prevSshRemotePort = previousSettings?.SshTunnelRemotePort ?? 0; var prevSshLocalPort = previousSettings?.SshTunnelLocalPort ?? 0; @@ -2231,6 +2240,7 @@ private async Task DoDirectConnectFromAddFormAsync() { previousSettings.SshTunnelUser = sshConfig.User; previousSettings.SshTunnelHost = sshConfig.Host; + previousSettings.SshTunnelSshPort = sshConfig.SshPort; previousSettings.SshTunnelRemotePort = sshConfig.RemotePort; previousSettings.SshTunnelLocalPort = sshConfig.LocalPort; } @@ -2260,7 +2270,7 @@ private async Task DoDirectConnectFromAddFormAsync() AddResultText.Text = $"✗ {ex.Message}"; RollbackDirectConnect(previousActiveId, isNewRecord, recordId, existingRecordSnapshot, previousSettings, prevGatewayUrl, prevUseSsh, prevSshUser, prevSshHost, - prevSshRemotePort, prevSshLocalPort, + prevSshPort, prevSshRemotePort, prevSshLocalPort, identityCleared ? identityKeyPath : null, identityCleared ? identityBackup : null, identityCleared ? identityBackupLength : -1, @@ -2333,7 +2343,7 @@ private void RollbackDirectConnect( string? previousActiveId, bool isNewRecord, string recordId, GatewayRecord? existingRecordSnapshot, SettingsManager? settings, string? prevGatewayUrl, bool prevUseSsh, string? prevSshUser, - string? prevSshHost, int prevSshRemotePort, int prevSshLocalPort, + string? prevSshHost, int prevSshPort, int prevSshRemotePort, int prevSshLocalPort, string? identityKeyPath = null, string? identityBackup = null, long identityBackupLength = -1, DateTime identityBackupMtimeUtc = default) { @@ -2387,6 +2397,7 @@ private void RollbackDirectConnect( settings.UseSshTunnel = prevUseSsh; settings.SshTunnelUser = prevSshUser ?? string.Empty; settings.SshTunnelHost = prevSshHost ?? string.Empty; + settings.SshTunnelSshPort = prevSshPort; settings.SshTunnelRemotePort = prevSshRemotePort; settings.SshTunnelLocalPort = prevSshLocalPort; settings.Save(); diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs index 48df000b1..470fe6416 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs @@ -10,6 +10,7 @@ public static class SettingsDataExtensions settings.UseSshTunnel, settings.SshTunnelUser, settings.SshTunnelHost, + settings.SshTunnelSshPort, settings.SshTunnelRemotePort, settings.SshTunnelLocalPort, settings.EnableNodeMode, diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index dad83207f..57592b68d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -34,6 +34,7 @@ public class SettingsManager public bool UseSshTunnel { get => _data.UseSshTunnel; set => _data = _data with { UseSshTunnel = value }; } public string SshTunnelUser { get => _data.SshTunnelUser ?? ""; set => _data = _data with { SshTunnelUser = value }; } public string SshTunnelHost { get => _data.SshTunnelHost ?? ""; set => _data = _data with { SshTunnelHost = value }; } + public int SshTunnelSshPort { get => IsValidPort(_data.SshTunnelSshPort) ? _data.SshTunnelSshPort : 22; set => _data = _data with { SshTunnelSshPort = value }; } public int SshTunnelRemotePort { get => _data.SshTunnelRemotePort <= 0 ? 18789 : _data.SshTunnelRemotePort; set => _data = _data with { SshTunnelRemotePort = value }; } public int SshTunnelLocalPort { get => _data.SshTunnelLocalPort <= 0 ? 18789 : _data.SshTunnelLocalPort; set => _data = _data with { SshTunnelLocalPort = value }; } public string? LegacyToken { get; private set; } @@ -211,6 +212,7 @@ public void Load() UseSshTunnel = false, SshTunnelUser = "", SshTunnelHost = "", + SshTunnelSshPort = 22, SshTunnelRemotePort = 18789, SshTunnelLocalPort = 18789, AutoStart = true, @@ -277,6 +279,7 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded) GatewayUrl = loaded.GatewayUrl ?? defaults.GatewayUrl, SshTunnelUser = loaded.SshTunnelUser ?? defaults.SshTunnelUser, SshTunnelHost = loaded.SshTunnelHost ?? defaults.SshTunnelHost, + SshTunnelSshPort = IsValidPort(loaded.SshTunnelSshPort) ? loaded.SshTunnelSshPort : defaults.SshTunnelSshPort, SshTunnelRemotePort = loaded.SshTunnelRemotePort <= 0 ? defaults.SshTunnelRemotePort : loaded.SshTunnelRemotePort, SshTunnelLocalPort = loaded.SshTunnelLocalPort <= 0 ? defaults.SshTunnelLocalPort : loaded.SshTunnelLocalPort, NotificationSound = loaded.NotificationSound ?? defaults.NotificationSound, @@ -314,6 +317,8 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded) return data; } + private static bool IsValidPort(int port) => port is >= 1 and <= 65535; + private static List CloneSandboxCustomFolders(IEnumerable? folders) => folders is null ? new List() diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 7740514b7..8c207df3f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3912,6 +3912,9 @@ On your gateway host (Mac/Linux), run: machine-name + + SSH port + Remote port @@ -4590,6 +4593,9 @@ On your gateway host (Mac/Linux), run: 192.168.x.x + + SSH Port + Remote Port @@ -5025,6 +5031,9 @@ Make sure the gateway is running. Connected to {0}. + + SSH port must be 1-65535 + SSH remote port must be 1–65535 diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 94d9d8131..a409a3b71 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3864,6 +3864,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : machine-name + + Port SSH + Port distant @@ -4542,6 +4545,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : 192.168.x.x + + Port SSH + Port distant @@ -4977,6 +4983,9 @@ Assurez-vous que la passerelle est en cours d'exécution. Connecté à {0}. + + Le port SSH doit être compris entre 1 et 65535 + Le port distant SSH doit être compris entre 1 et 65535 diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c66316604..e59307aef 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3865,6 +3865,9 @@ Voer op uw gateway-host (Mac/Linux) uit: machine-name + + SSH-poort + Externe poort @@ -4543,6 +4546,9 @@ Voer op uw gateway-host (Mac/Linux) uit: 192.168.x.x + + SSH-poort + Externe poort @@ -4978,6 +4984,9 @@ Controleer of de gateway actief is. Verbonden met {0}. + + SSH-poort moet 1-65535 zijn + SSH-externe poort moet 1–65535 zijn diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 106bb16a2..b11f538b0 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3864,6 +3864,9 @@ machine-name + + SSH 端口 + 远程端口 @@ -4542,6 +4545,9 @@ 192.168.x.x + + SSH 端口 + 远程端口 @@ -4978,6 +4984,9 @@ 已连接到 {0}。 + + SSH 端口必须在 1-65535 之间 + SSH 远程端口必须在 1–65535 之间 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index e22f5630d..18389bee4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3864,6 +3864,9 @@ machine-name + + SSH 連接埠 + 遠端連接埠 @@ -4542,6 +4545,9 @@ 192.168.x.x + + SSH 連接埠 + 遠端連接埠 @@ -4978,6 +4984,9 @@ 已連線到 {0}。 + + SSH 連接埠必須在 1-65535 之間 + SSH 遠端連接埠必須在 1–65535 之間 diff --git a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml index fc73fcbb8..e1c84a27b 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml @@ -139,8 +139,15 @@ - - + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs index be7d37e5c..5986087dd 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs @@ -370,11 +370,17 @@ private async Task OnDirectConnectAsync() { var sshUser = DiagSshUserBox.Text.Trim(); var sshHost = DiagSshHostBox.Text.Trim(); + var sshPortText = string.IsNullOrWhiteSpace(DiagSshServerPortBox.Text) ? "22" : DiagSshServerPortBox.Text; + if (!int.TryParse(sshPortText, out var sshPort) || sshPort is < 1 or > 65535) + { + DirectConnectResult.Text = LocalizationHelper.GetString("ConnectionPage_SshServerPortInvalid"); + return; + } int.TryParse(DiagSshRemotePortBox.Text, out var remotePort); int.TryParse(DiagSshLocalPortBox.Text, out var localPort); if (remotePort <= 0) remotePort = 18789; if (localPort <= 0) localPort = 18790; - sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort); + sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort, SshPort: sshPort); } DirectConnectResult.Text = useSsh ? LocalizationHelper.GetString("ConnectionStatus_StartingSshTunnel") : LocalizationHelper.GetString("ConnectionStatus_Connecting"); @@ -410,6 +416,7 @@ private async Task OnDirectConnectAsync() settings.UseSshTunnel = true; settings.SshTunnelUser = sshConfig.User; settings.SshTunnelHost = sshConfig.Host; + settings.SshTunnelSshPort = sshConfig.SshPort; settings.SshTunnelRemotePort = sshConfig.RemotePort; settings.SshTunnelLocalPort = sshConfig.LocalPort; settings.Save(); diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index e2656b725..937736068 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -528,7 +528,7 @@ public async Task ConnectNodeOnlyAsync_StartsSshTunnel_WhenGatewayUsesTunnel() { Id = "gw-ssh", Url = "wss://remote.example", - SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678) + SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678, SshPort: 2222) }); _registry.SetActive("gw-ssh"); _resolver.OperatorCredential = null; @@ -550,6 +550,7 @@ public async Task ConnectNodeOnlyAsync_StartsSshTunnel_WhenGatewayUsesTunnel() Assert.Equal(1, tunnel.StartCount); Assert.Equal("host.example", tunnel.LastConfig?.Host); + Assert.Equal(2222, tunnel.LastConfig?.SshPort); Assert.Equal("ws://localhost:45678", node.LastGatewayUrl); } diff --git a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs index a085305d3..03eea1486 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs @@ -166,7 +166,7 @@ public void SaveAndLoad_WithSshTunnelConfig() { var record = MakeRecord("gw-1", "wss://test1") with { - SshTunnel = new SshTunnelConfig("user", "host.example.com", 18789, 18789) + SshTunnel = new SshTunnelConfig("user", "host.example.com", 18789, 18789, SshPort: 2222) }; _registry.AddOrUpdate(record); _registry.Save(); @@ -178,9 +178,39 @@ public void SaveAndLoad_WithSshTunnelConfig() Assert.NotNull(loaded.SshTunnel); Assert.Equal("user", loaded.SshTunnel.User); Assert.Equal("host.example.com", loaded.SshTunnel.Host); + Assert.Equal(2222, loaded.SshTunnel.SshPort); Assert.Equal(18789, loaded.SshTunnel.RemotePort); } + [Fact] + public void Load_WithLegacySshTunnelConfig_DefaultsSshPort() + { + File.WriteAllText(Path.Combine(_tempDir, "gateways.json"), """ + { + "activeId": "gw-1", + "gateways": [ + { + "id": "gw-1", + "url": "wss://test1", + "sshTunnel": { + "user": "user", + "host": "host.example.com", + "remotePort": 18789, + "localPort": 28789, + "includeBrowserProxyForward": false + } + } + ] + } + """); + + _registry.Load(); + + var loaded = _registry.GetById("gw-1")!; + Assert.NotNull(loaded.SshTunnel); + Assert.Equal(22, loaded.SshTunnel.SshPort); + } + [Fact] public void SaveAndLoad_WithLastConnected_RoundTrips() { diff --git a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs index 6352c9f8a..82cb8ba72 100644 --- a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs +++ b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs @@ -9,6 +9,7 @@ private static ConnectionSettingsSnapshot MakeSnapshot( bool useSshTunnel = false, string? sshTunnelUser = null, string? sshTunnelHost = null, + int sshTunnelSshPort = 22, int sshTunnelRemotePort = 0, int sshTunnelLocalPort = 0, bool enableNodeMode = false, @@ -26,6 +27,7 @@ private static ConnectionSettingsSnapshot MakeSnapshot( useSshTunnel, sshTunnelUser, sshTunnelHost, + sshTunnelSshPort, sshTunnelRemotePort, sshTunnelLocalPort, enableNodeMode, @@ -80,6 +82,15 @@ public void SshTunnelChanged_ReturnsOperatorReconnect() SettingsChangeClassifier.Classify(prev, next)); } + [Fact] + public void SshTunnelSshPortChanged_ReturnsOperatorReconnect() + { + var prev = MakeSnapshot(gatewayUrl: "wss://test", useSshTunnel: true, sshTunnelSshPort: 22); + var next = MakeSnapshot(gatewayUrl: "wss://test", useSshTunnel: true, sshTunnelSshPort: 2222); + Assert.Equal(SettingsChangeImpact.OperatorReconnectRequired, + SettingsChangeClassifier.Classify(prev, next)); + } + [Fact] public void NodeModeChanged_ReturnsNodeReconnect() { diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index ad93fb34d..7ec6877aa 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -148,6 +148,48 @@ public void BuildArguments_CanIncludeBrowserProxyForward() Assert.Equal("-o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o TCPKeepAlive=yes -N -L 28789:127.0.0.1:18789 -L 28791:127.0.0.1:18791 scott@mac-mini.local", args); } + [Fact] + public void BuildArguments_CanUseCustomSshPort() + { + var args = SshTunnelCommandLine.BuildArguments( + "scott", + "mac-mini.local", + 18789, + 28789, + includeBrowserProxyForward: false, + sshPort: 2222); + + Assert.Equal("-o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o TCPKeepAlive=yes -N -L 28789:127.0.0.1:18789 -p 2222 scott@mac-mini.local", args); + } + + [Fact] + public void BuildArguments_OmitsDefaultSshPort() + { + var args = SshTunnelCommandLine.BuildArguments( + "scott", + "mac-mini.local", + 18789, + 28789, + includeBrowserProxyForward: false, + sshPort: 22); + + Assert.DoesNotContain(" -p 22 ", args); + Assert.EndsWith("scott@mac-mini.local", args); + } + + [Fact] + public void BuildArguments_RejectsInvalidSshPort() + { + Assert.Throws(() => + SshTunnelCommandLine.BuildArguments( + "scott", + "mac-mini.local", + 18789, + 28789, + includeBrowserProxyForward: false, + sshPort: 0)); + } + [Fact] public void BuildArguments_RejectsBrowserProxyForwardWhenPortPlusTwoOverflows() { diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index c961824f9..7eb049397 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -15,6 +15,7 @@ public void RoundTrip_AllFields_Preserved() UseSshTunnel= true, SshTunnelUser = "user1", SshTunnelHost = "remote-host", + SshTunnelSshPort = 2222, SshTunnelRemotePort = 18789, SshTunnelLocalPort = 28789, AutoStart = true, @@ -69,6 +70,7 @@ public void RoundTrip_AllFields_Preserved() Assert.Equal(original.UseSshTunnel, restored.UseSshTunnel); Assert.Equal(original.SshTunnelUser, restored.SshTunnelUser); Assert.Equal(original.SshTunnelHost, restored.SshTunnelHost); + Assert.Equal(original.SshTunnelSshPort, restored.SshTunnelSshPort); Assert.Equal(original.SshTunnelRemotePort, restored.SshTunnelRemotePort); Assert.Equal(original.SshTunnelLocalPort, restored.SshTunnelLocalPort); Assert.Equal(original.AutoStart, restored.AutoStart); @@ -140,6 +142,7 @@ public void MissingFields_UseDefaults() Assert.False(settings.UseSshTunnel); Assert.Null(settings.SshTunnelUser); Assert.Null(settings.SshTunnelHost); + Assert.Equal(22, settings.SshTunnelSshPort); Assert.Equal(18789, settings.SshTunnelRemotePort); Assert.Equal(18789, settings.SshTunnelLocalPort); Assert.True(settings.AutoStart); @@ -220,6 +223,7 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields() Assert.False(settings.UseSshTunnel); Assert.Null(settings.SshTunnelUser); Assert.Null(settings.SshTunnelHost); + Assert.Equal(22, settings.SshTunnelSshPort); Assert.Equal(18789, settings.SshTunnelRemotePort); Assert.Equal(18789, settings.SshTunnelLocalPort); // New fields should have sensible defaults @@ -254,6 +258,31 @@ public void InvalidJson_ReturnsNull() Assert.Null(SettingsData.FromJson("not json at all")); } + [Fact] + public void SettingsManager_DefaultsInvalidSshPort() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "settings.json"), """ + { + "SshTunnelSshPort": 70000 + } + """); + + var settings = new SettingsManager(dir); + + Assert.Equal(22, settings.SshTunnelSshPort); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + [Fact] public void SettingsManager_PersistsRecordingConsentFlags() { From a9e89a9f90bea6ea329390ad50dba175409af417 Mon Sep 17 00:00:00 2001 From: Keith Mahoney Date: Fri, 5 Jun 2026 14:02:51 -0700 Subject: [PATCH 2/2] dummy commit to trigger clawsweeper --- docs/CONNECTION_ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 8ef07fe7f..ed8d32446 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -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. +