diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 13353f1d..ed8d3244 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). @@ -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. + diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index c0e5cba3..c365cd6c 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 147fc29a..a38bd8e3 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 e15b5019..b30ac0f4 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 d0680d63..44a82c42 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 84d280ee..ec46cc27 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 0f00d6a1..17f00c21 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 e6729c1a..90944f5e 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 49f3c716..7fb8ac72 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 54bc5cbd..0c8ff577 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 8e29154e..4a7d00f5 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 b8579a1f..791b7846 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 2cdb2590..628b4060 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 48df000b..470fe641 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 dad83207..57592b68 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 7740514b..8c207df3 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 94d9d813..a409a3b7 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 c6631660..e59307ae 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 106bb16a..b11f538b 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 e22f5630..18389bee 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 fc73fcbb..e1c84a27 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 be7d37e5..5986087d 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 e2656b72..93773606 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 a085305d..03eea148 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 6352c9f8..82cb8ba7 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 ad93fb34..7ec6877a 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 c961824f..7eb04939 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() {