diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 25d8125a6..a4792c1fc 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -518,7 +518,7 @@ public async Task SwitchGatewayAsync(string gatewayId) } } - public async Task ApplySetupCodeAsync(string setupCode) + public async Task ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null) { ThrowIfDisposed(); @@ -554,6 +554,7 @@ public async Task ApplySetupCodeAsync(string setupCode) Url = gatewayUrl, SharedGatewayToken = existing?.SharedGatewayToken, // preserve existing shared token if any BootstrapToken = decoded.Token ?? existing?.BootstrapToken, + SshTunnel = sshTunnel ?? existing?.SshTunnel, }; _registry.AddOrUpdate(record); _registry.SetActive(recordId); diff --git a/src/OpenClaw.Connection/IGatewayConnectionManager.cs b/src/OpenClaw.Connection/IGatewayConnectionManager.cs index 157c17b02..3bbaaaf5a 100644 --- a/src/OpenClaw.Connection/IGatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/IGatewayConnectionManager.cs @@ -46,7 +46,7 @@ public interface IGatewayConnectionManager : IDisposable, IAsyncDisposable Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default); // ─── Setup ─── - Task ApplySetupCodeAsync(string setupCode); + Task ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null); Task ConnectWithSharedTokenAsync(string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null); // ─── Operator Client Access ─── diff --git a/src/OpenClaw.Shared/GatewayErrorClassifier.cs b/src/OpenClaw.Shared/GatewayErrorClassifier.cs new file mode 100644 index 000000000..73eab4137 --- /dev/null +++ b/src/OpenClaw.Shared/GatewayErrorClassifier.cs @@ -0,0 +1,134 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// Actionable classification of a gateway connection error. Lets the UI route a +/// raw error string to a specific recovery path instead of a generic failure — +/// distinguishing unauthorized, scope mismatch, token drift, pairing, TLS, +/// tunnel, and server problems. +/// +public enum GatewayErrorKind +{ + /// No error text, or nothing recognizable. + Unknown, + + /// Connection refused / unreachable / timed out. + Network, + + /// Generic unauthorized / invalid-token rejection. + Auth, + + /// + /// The stored device token is no longer recognized by the gateway (rotated, + /// revoked, or replaced) — the fix is to re-pair, not to retry. + /// + TokenDrift, + + /// + /// Authenticated but missing a required operator/node scope (e.g. cannot + /// approve pairing or read config) — the fix is to re-pair for higher scopes. + /// + ScopeMismatch, + + /// Device/node pairing approval is pending on the gateway host. + PairingRequired, + + /// Pairing was explicitly rejected on the gateway host. + PairingRejected, + + /// TLS/certificate/cleartext transport problem. + Tls, + + /// SSH tunnel could not be established or dropped. + Tunnel, + + /// Gateway returned a 5xx / internal error. + Server, + + /// Rate limited by the gateway. + RateLimited, +} + +/// +/// Pure heuristic classifier for gateway error strings. Order is significant: +/// the more specific kinds (scope, token drift) are matched before the generic +/// auth bucket so a "re-pair" path wins over a plain "retry" path. +/// +public static class GatewayErrorClassifier +{ + public static GatewayErrorKind Classify(string? error) + { + if (string.IsNullOrWhiteSpace(error)) + return GatewayErrorKind.Unknown; + + var e = error.ToLowerInvariant(); + + if ((Contains(e, "rate") && Contains(e, "limit")) || + Contains(e, "429") || Contains(e, "too many request")) + return GatewayErrorKind.RateLimited; + + // SSH/tunnel first: SSH failures often read "Permission denied + // (publickey)" which would otherwise be mistaken for a scope problem. + if (Contains(e, "ssh") || Contains(e, "tunnel")) + return GatewayErrorKind.Tunnel; + + // Transport security before pairing/auth: e.g. "certificate not + // approved by CA" must not be read as a pairing approval. + if (Contains(e, "tls") || Contains(e, "ssl") || Contains(e, "certificate") || + Contains(e, "cert ") || Contains(e, "handshake") || + Contains(e, "cleartext") || Contains(e, "insecure")) + return GatewayErrorKind.Tls; + + // Scope/permission problems — authenticated but under-privileged. + if (Contains(e, "scope") || + Contains(e, "insufficient priv") || + Contains(e, "not permitted") || + Contains(e, "permission denied") || + (Contains(e, "forbidden") && Contains(e, "scope"))) + return GatewayErrorKind.ScopeMismatch; + + // Token drift — the device token specifically is stale/unknown. + if (Contains(e, "re-pair") || Contains(e, "repair token") || + Contains(e, "token rotat") || Contains(e, "token revoked") || + Contains(e, "token mismatch") || Contains(e, "token drift") || + (Contains(e, "device token") && + (Contains(e, "unknown") || Contains(e, "invalid") || + Contains(e, "expired") || Contains(e, "not recognized") || + Contains(e, "no longer")))) + return GatewayErrorKind.TokenDrift; + + // Pairing lifecycle. Use specific tokens ("pairing"/"approval") so we + // don't match "repair" (contains "pair") or "approved by CA". + if (Contains(e, "pairing") || Contains(e, "approval")) + { + if (Contains(e, "reject") || Contains(e, "denied") || Contains(e, "declin")) + return GatewayErrorKind.PairingRejected; + return GatewayErrorKind.PairingRequired; + } + + // Server (5xx) before the broad auth bucket: a transient + // "500 internal error: token validation failed" must not route the + // user to a re-pair flow. + if (Contains(e, "500") || Contains(e, "502") || Contains(e, "503") || + Contains(e, "internal error") || Contains(e, "server error")) + return GatewayErrorKind.Server; + + // Generic auth — after the more specific auth-adjacent kinds above. + if (Contains(e, "401") || Contains(e, "unauthor") || Contains(e, "forbid") || + Contains(e, "auth") || Contains(e, "token") || Contains(e, "credential")) + return GatewayErrorKind.Auth; + + // Network. + if (Contains(e, "refused") || Contains(e, "unreachable") || + Contains(e, "timeout") || Contains(e, "timed out") || + Contains(e, "network") || Contains(e, "no route") || + Contains(e, "could not connect") || Contains(e, "connection closed")) + return GatewayErrorKind.Network; + + return GatewayErrorKind.Unknown; + } + + private static bool Contains(string haystack, string needle) => + haystack.Contains(needle, StringComparison.Ordinal); +} diff --git a/src/OpenClaw.Shared/RemoteGatewayClassifier.cs b/src/OpenClaw.Shared/RemoteGatewayClassifier.cs new file mode 100644 index 000000000..7f67c785f --- /dev/null +++ b/src/OpenClaw.Shared/RemoteGatewayClassifier.cs @@ -0,0 +1,130 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// How the tray reaches a configured gateway. Drives remote-setup guidance and +/// the cleartext-token warning in Connection settings. +/// +public enum GatewayConnectionTopology +{ + /// URL was empty or could not be parsed. + Unknown, + + /// localhost / 127.0.0.1 / ::1 — reached directly on this machine. + Local, + + /// + /// A loopback URL that actually fronts a remote gateway through a managed + /// SSH tunnel (the WebSocket talks to ws://localhost:<localPort> + /// but the bytes are encrypted by SSH end-to-end). + /// + SshTunnel, + + /// Remote host over TLS (wss:// or https://). + DirectSecure, + + /// + /// Remote host over cleartext (ws:// or http://) — the token + /// travels unencrypted across the network. + /// + DirectInsecure, +} + +/// Whether the credential is protected in transit. +public enum GatewayTransportSecurity +{ + /// Loopback only — no network exposure. + LocalLoopback, + + /// Encrypted (TLS) or tunnelled (SSH) — token protected on the wire. + Encrypted, + + /// Cleartext to a non-local host — token exposed on the wire. + Cleartext, +} + +/// Immutable classification of a gateway endpoint for setup/repair UX. +public sealed record RemoteGatewayProfile( + GatewayConnectionTopology Topology, + GatewayTransportSecurity Security, + string Host, + bool IsTls) +{ + public bool IsLocal => Topology == GatewayConnectionTopology.Local; + + public bool IsRemote => + Topology is GatewayConnectionTopology.DirectSecure + or GatewayConnectionTopology.DirectInsecure + or GatewayConnectionTopology.SshTunnel; + + /// + /// True when a token would travel in cleartext over a network. The UI should + /// steer the user to TLS (wss://), an SSH tunnel, or a trusted proxy + /// (e.g. Tailscale) before saving such a gateway. + /// + public bool RecommendsTransportHardening => + Security == GatewayTransportSecurity.Cleartext; +} + +/// +/// Pure classifier that maps a gateway URL (plus whether a managed SSH tunnel is +/// configured) to a . No I/O, no UI types. +/// +public static class RemoteGatewayClassifier +{ + public static RemoteGatewayProfile Classify(string? url, bool hasSshTunnel = false) + { + var trimmed = url?.Trim(); + if (string.IsNullOrEmpty(trimmed) || + !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || + string.IsNullOrEmpty(uri.Host)) + { + // Unparseable input is left to GatewayUrlHelper validation elsewhere; + // we surface no transport warning so half-typed URLs don't flicker a + // scary banner on every keystroke. + return new RemoteGatewayProfile( + GatewayConnectionTopology.Unknown, + GatewayTransportSecurity.LocalLoopback, + Host: string.Empty, + IsTls: false); + } + + var host = uri.Host; + var scheme = uri.Scheme; + var isTls = scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("https", StringComparison.OrdinalIgnoreCase); + + // A managed SSH tunnel encrypts the hop even though the WebSocket URL is + // loopback. Treat it as a remote-but-encrypted endpoint. + if (hasSshTunnel) + { + return new RemoteGatewayProfile( + GatewayConnectionTopology.SshTunnel, + GatewayTransportSecurity.Encrypted, + host, + isTls); + } + + if (LocalGatewayUrlClassifier.IsLocalGatewayUrl(trimmed)) + { + return new RemoteGatewayProfile( + GatewayConnectionTopology.Local, + GatewayTransportSecurity.LocalLoopback, + host, + isTls); + } + + return isTls + ? new RemoteGatewayProfile( + GatewayConnectionTopology.DirectSecure, + GatewayTransportSecurity.Encrypted, + host, + IsTls: true) + : new RemoteGatewayProfile( + GatewayConnectionTopology.DirectInsecure, + GatewayTransportSecurity.Cleartext, + host, + IsTls: false); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index 8e37bc8d3..7c9b1d03f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -602,6 +602,11 @@ Click="OnApplyRepairCode" AutomationProperties.AutomationId="RecoveryApplyRepair"/> + @@ -946,12 +951,83 @@ Header="Shared token" PlaceholderText="Paste shared gateway token" AutomationProperties.AutomationId="AddDirectToken"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 651e2ed3a..065b5e044 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -55,6 +55,10 @@ public sealed partial class ConnectionPage : Page // updates the original record instead of orphaning it as a duplicate. private string? _editingGatewayId; + // Last gateway URL decoded from a pasted setup code, used to drive the + // transport-security advice for the Setup-code method. Null when no valid + // code is decoded. + private string? _lastDecodedSetupUrl; // ─── Reconnect-mask state ─── // Toggling Node mode forces the connection manager to tear down the // WS and rebuild it (so the gateway sees the role change). That brief @@ -978,6 +982,7 @@ private void ApplyRecoveryBody(ConnectionPagePlan plan) RecoveryTunnelBlock.Visibility = Visibility.Collapsed; RecoveryAuthPasteBlock.Visibility = Visibility.Collapsed; RecoveryApproveCmdBlock.Visibility = Visibility.Collapsed; + RecoveryRepairResultText.Visibility = Visibility.Collapsed; RecoveryHelpHeaderText.Text = plan.Recovery switch { @@ -985,6 +990,10 @@ private void ApplyRecoveryBody(ConnectionPagePlan plan) RecoveryCategory.Pairing => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderPairing"), RecoveryCategory.Tunnel => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderTunnel"), RecoveryCategory.Server => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderServer"), + RecoveryCategory.TokenDrift => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderTokenDrift"), + RecoveryCategory.Scope => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderScope"), + RecoveryCategory.Tls => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderTls"), + RecoveryCategory.RateLimited => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderRateLimited"), _ => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderServer"), }; @@ -1011,6 +1020,26 @@ private void ApplyRecoveryBody(ConnectionPagePlan plan) LocalizationHelper.GetString("ConnectionPage_RecoveryServerBullet2"), LocalizationHelper.GetString("ConnectionPage_RecoveryServerBullet3"), }, + RecoveryCategory.TokenDrift => new[] + { + LocalizationHelper.GetString("ConnectionPage_RecoveryTokenDriftBullet1"), + LocalizationHelper.GetString("ConnectionPage_RecoveryTokenDriftBullet2"), + }, + RecoveryCategory.Scope => new[] + { + LocalizationHelper.GetString("ConnectionPage_RecoveryScopeBullet1"), + LocalizationHelper.GetString("ConnectionPage_RecoveryScopeBullet2"), + }, + RecoveryCategory.Tls => new[] + { + LocalizationHelper.GetString("ConnectionPage_RecoveryTlsBullet1"), + LocalizationHelper.GetString("ConnectionPage_RecoveryTlsBullet2"), + }, + RecoveryCategory.RateLimited => new[] + { + LocalizationHelper.GetString("ConnectionPage_RecoveryRateLimitedBullet1"), + LocalizationHelper.GetString("ConnectionPage_RecoveryRateLimitedBullet2"), + }, _ => new[] { LocalizationHelper.GetString("ConnectionPage_RecoveryDefaultBullet1"), @@ -1028,7 +1057,11 @@ private void ApplyRecoveryBody(ConnectionPagePlan plan) RecoveryTunnelBlock.Visibility = Visibility.Visible; RecoveryTunnelDetailText.Text = plan.RecoveryDetail ?? LocalizationHelper.GetString("ConnectionPage_SshTunnelIsDownText"); } - if (plan.Recovery == RecoveryCategory.Auth) + // Auth, token drift, and scope problems are all repaired by pasting a + // fresh setup code (re-pair), which also upgrades scopes on the gateway. + if (plan.Recovery is RecoveryCategory.Auth + or RecoveryCategory.TokenDrift + or RecoveryCategory.Scope) { RecoveryAuthPasteBlock.Visibility = Visibility.Visible; } @@ -1571,6 +1604,7 @@ private void OnEnterAddGateway(object sender, RoutedEventArgs e) { _editingGatewayId = null; _userIntent = UserIntent.AddingGateway; + ClearAddGatewaySshFields(); // Direct is default — make sure the selector is on Direct. // Pre-fill the most common local gateway URL. DirectUrlBox.Text = "ws://127.0.0.1:18789"; @@ -1586,6 +1620,7 @@ private void OnEnterAddGatewayDirect(object sender, RoutedEventArgs e) { _editingGatewayId = null; _userIntent = UserIntent.AddingGateway; + ClearAddGatewaySshFields(); ShowAddPane("direct"); AddDirectItem.IsSelected = true; RefreshFromSnapshot(_lastSnapshot); @@ -1595,6 +1630,7 @@ private void OnEnterAddGatewaySetupCode(object sender, RoutedEventArgs e) { _editingGatewayId = null; _userIntent = UserIntent.AddingGateway; + ClearAddGatewaySshFields(); ShowAddPane("setup"); AddSetupCodeItem.IsSelected = true; RefreshFromSnapshot(_lastSnapshot); @@ -1615,6 +1651,7 @@ private void OnAddBack(object sender, RoutedEventArgs e) AddResultText.Text = ""; AddSetupCodeBox.Text = ""; AddSetupCodePreviewPanel.Visibility = Visibility.Collapsed; + ClearAddGatewaySshFields(); AddScanStatusText.Text = LocalizationHelper.GetString("ConnectionPage_PressScan"); AddScanProgressBar.Visibility = Visibility.Collapsed; AddScanResultsPanel.Children.Clear(); @@ -1641,6 +1678,111 @@ private void ShowAddPane(string tag) bool isFormMethod = (tag == "direct") || (tag == "setup"); AddSshExpander.Visibility = isFormMethod ? Visibility.Visible : Visibility.Collapsed; AddSaveButton.Visibility = isFormMethod ? Visibility.Visible : Visibility.Collapsed; + AddRemoteHelpLink.Visibility = isFormMethod ? Visibility.Visible : Visibility.Collapsed; + if (!isFormMethod) + AddRemoteHelpTip.IsOpen = false; + + UpdateRemoteSetupAdvice(); + } + + private void ClearAddGatewaySshFields() + { + AddSshExpander.IsExpanded = false; + AddSshUserBox.Text = ""; + AddSshHostBox.Text = ""; + AddSshServerPortBox.Text = ""; + AddSshRemotePortBox.Text = ""; + AddSshLocalPortBox.Text = ""; + } + + private void OnRemoteHelpClick(object sender, RoutedEventArgs e) + { + AddRemoteHelpTip.IsOpen = !AddRemoteHelpTip.IsOpen; + } + + // ─── Remote setup transport-security advisory ───────────────────── + // Offline (no network) classification driven by RemoteGatewayClassifier. + // Steers users to TLS / SSH tunnel / trusted proxy before they save a + // gateway that would send the token in cleartext over the network. + + private void OnAddSshExpanding(Expander sender, ExpanderExpandingEventArgs args) => + UpdateRemoteSetupAdvice(sshExpandedOverride: true); + + private void OnAddSshCollapsed(Expander sender, ExpanderCollapsedEventArgs args) => + UpdateRemoteSetupAdvice(sshExpandedOverride: false); + + private void OnAddSshFieldChanged(object sender, TextChangedEventArgs e) => + UpdateRemoteSetupAdvice(); + + private void UpdateRemoteSetupAdvice(bool? sshExpandedOverride = null) + { + if (AddSecurityAdviceBar == null) return; + + // The advice applies to the two form methods (Direct + Setup code). + // Pick the URL from whichever is active: the typed Direct URL, or the + // URL decoded from a pasted setup code. + string? url; + var tag = ActiveAddPaneTag(); + if (tag == "direct") + url = DirectUrlBox.Text?.Trim(); + else if (tag == "setup") + url = _lastDecodedSetupUrl; + else + { + AddSecurityAdviceBar.IsOpen = false; + return; + } + + // The Expander.Expanding/Collapsed events fire before IsExpanded flips, + // so callers pass the post-transition state to avoid a one-frame flash + // of the cleartext warning. + bool sshExpanded = sshExpandedOverride ?? AddSshExpander.IsExpanded; + bool hasSshTunnel = sshExpanded + && !string.IsNullOrWhiteSpace(AddSshUserBox.Text) + && !string.IsNullOrWhiteSpace(AddSshHostBox.Text); + + var profile = RemoteGatewayClassifier.Classify(url, hasSshTunnel); + + InfoBarSeverity severity; + string title; + string message; + bool open = true; + + switch (profile.Topology) + { + case GatewayConnectionTopology.DirectInsecure: + severity = InfoBarSeverity.Warning; + title = LocalizationHelper.GetString("ConnectionPage_AdviceCleartextTitle"); + message = LocalizationHelper.GetString("ConnectionPage_AdviceCleartextMessage"); + break; + case GatewayConnectionTopology.DirectSecure: + severity = InfoBarSeverity.Success; + title = LocalizationHelper.GetString("ConnectionPage_AdviceSecureTitle"); + message = LocalizationHelper.GetString("ConnectionPage_AdviceSecureMessage"); + break; + case GatewayConnectionTopology.SshTunnel: + severity = InfoBarSeverity.Success; + title = LocalizationHelper.GetString("ConnectionPage_AdviceTunnelTitle"); + message = LocalizationHelper.GetString("ConnectionPage_AdviceTunnelMessage"); + break; + default: + // Local or unparseable — no transport warning needed. + AddSecurityAdviceBar.IsOpen = false; + return; + } + + // InfoBar does not reliably repaint its severity icon/accent when + // Severity changes while IsOpen stays true. When the severity actually + // changes (e.g. cleartext ws:// → TLS wss://), force a close before + // re-applying so the bar re-renders cleanly. Guard on change so we + // don't flicker on every keystroke within the same severity. + if (AddSecurityAdviceBar.IsOpen && AddSecurityAdviceBar.Severity != severity) + AddSecurityAdviceBar.IsOpen = false; + + AddSecurityAdviceBar.Severity = severity; + AddSecurityAdviceBar.Title = title; + AddSecurityAdviceBar.Message = message; + AddSecurityAdviceBar.IsOpen = open; } private string ActiveAddPaneTag() @@ -1949,16 +2091,24 @@ private async Task OnApplyRepairCodeAsync() { var code = RecoveryRepairCodeBox.Text?.Trim(); if (string.IsNullOrEmpty(code) || _connectionManager == null) return; + void ShowResult(string text, bool error) + { + RecoveryRepairResultText.Text = text; + RecoveryRepairResultText.Foreground = (Brush)Application.Current.Resources[ + error ? "SystemFillColorCriticalBrush" : "SystemFillColorSuccessBrush"]; + RecoveryRepairResultText.Visibility = Visibility.Visible; + } try { var result = await _connectionManager.ApplySetupCodeAsync(code); - AddResultText.Text = result.Outcome == SetupCodeOutcome.Success - ? LocalizationHelper.GetString("ConnectionPage_RepairedReconnecting") - : $"✗ {result.ErrorMessage ?? LocalizationHelper.GetString("ConnectionPage_CouldNotApplyCode")}"; + if (result.Outcome == SetupCodeOutcome.Success) + ShowResult(LocalizationHelper.GetString("ConnectionPage_RepairedReconnecting"), error: false); + else + ShowResult($"✗ {result.ErrorMessage ?? LocalizationHelper.GetString("ConnectionPage_CouldNotApplyCode")}", error: true); } catch (Exception ex) { - AddResultText.Text = $"✗ {ex.Message}"; + ShowResult($"✗ {ex.Message}", error: true); } } @@ -2107,6 +2257,7 @@ private void OnDirectInputChanged(object sender, RoutedEventArgs e) var url = DirectUrlBox.Text?.Trim(); ScheduleConnectivityTest(url); AutoFillTokenForUrl(url); + UpdateRemoteSetupAdvice(); } private void OnDirectUrlLostFocus(object sender, RoutedEventArgs e) @@ -2115,6 +2266,7 @@ private void OnDirectUrlLostFocus(object sender, RoutedEventArgs e) ScheduleConnectivityTest(url); // On focus-out, overwrite token even if already populated (user finished editing URL). AutoFillTokenForUrl(url, force: true); + UpdateRemoteSetupAdvice(); } private void AutoFillTokenForUrl(string? url, bool force = false) @@ -2263,43 +2415,12 @@ private async Task DoDirectConnectFromAddFormAsync() url = GatewayUrlHelper.NormalizeForWebSocket(url); - // SSH tunnel — read from the Add form (per-gateway) if the expander is open - SshTunnelConfig? sshConfig = null; - bool useSsh = AddSshExpander.IsExpanded - && !string.IsNullOrWhiteSpace(AddSshUserBox.Text) - && !string.IsNullOrWhiteSpace(AddSshHostBox.Text); - if (useSsh) + if (!TryBuildAddSshTunnelConfig(out var sshConfig, out var sshError)) { - 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"); - return; - } - if (!int.TryParse(AddSshLocalPortBox.Text, out var localPort) || localPort is < 1 or > 65535) - { - AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_SshLocalPortInvalid"); - return; - } - var includeBrowserProxyForward = BrowserProxySshTunnelForwardPolicy.ShouldInclude( - CurrentApp.Settings.NodeBrowserProxyEnabled, - remotePort, - localPort); - sshConfig = new SshTunnelConfig( - sshUser, - sshHost, - remotePort, - localPort, - IncludeBrowserProxyForward: includeBrowserProxyForward, - SshPort: sshPort); + AddResultText.Text = sshError; + return; } + bool useSsh = sshConfig != null; AddSaveButton.IsEnabled = false; AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_Connecting"); @@ -2490,6 +2611,51 @@ or OverallConnectionState.Degraded || snapshot.OperatorState is RoleConnectionState.PairingRequired or RoleConnectionState.Error; + private bool TryBuildAddSshTunnelConfig(out SshTunnelConfig? sshConfig, out string? error) + { + sshConfig = null; + error = null; + + if (!AddSshExpander.IsExpanded || + string.IsNullOrWhiteSpace(AddSshUserBox.Text) || + string.IsNullOrWhiteSpace(AddSshHostBox.Text)) + { + return true; + } + + 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) + { + error = LocalizationHelper.GetString("ConnectionPage_SshServerPortInvalid"); + return false; + } + if (!int.TryParse(AddSshRemotePortBox.Text, out var remotePort) || remotePort is < 1 or > 65535) + { + error = LocalizationHelper.GetString("ConnectionPage_SshRemotePortInvalid"); + return false; + } + if (!int.TryParse(AddSshLocalPortBox.Text, out var localPort) || localPort is < 1 or > 65535) + { + error = LocalizationHelper.GetString("ConnectionPage_SshLocalPortInvalid"); + return false; + } + + var includeBrowserProxyForward = BrowserProxySshTunnelForwardPolicy.ShouldInclude( + CurrentApp.Settings.NodeBrowserProxyEnabled, + remotePort, + localPort); + sshConfig = new SshTunnelConfig( + sshUser, + sshHost, + remotePort, + localPort, + IncludeBrowserProxyForward: includeBrowserProxyForward, + SshPort: sshPort); + return true; + } + private static GatewayConnectionSnapshot EnsureDirectConnectSucceeded(GatewayConnectionSnapshot snapshot) { if (snapshot.OperatorState == RoleConnectionState.Error) @@ -2576,6 +2742,11 @@ private async Task DoApplySetupCodeFromAddFormAsync() AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_PleaseEnterSetupCode"); return; } + if (!TryBuildAddSshTunnelConfig(out var sshConfig, out var sshError)) + { + AddResultText.Text = sshError; + return; + } AddSaveButton.IsEnabled = false; AddResultText.Text = LocalizationHelper.GetString("ConnectionPage_Applying"); @@ -2583,7 +2754,7 @@ private async Task DoApplySetupCodeFromAddFormAsync() { if (_connectionManager != null) { - var result = await _connectionManager.ApplySetupCodeAsync(code); + var result = await _connectionManager.ApplySetupCodeAsync(code, sshConfig); AddResultText.Text = result.Outcome switch { SetupCodeOutcome.Success => $"✓ {string.Format(LocalizationHelper.GetString("ConnectionPage_AppliedGateway"), SanitizeUrl(result.GatewayUrl ?? ""))}", @@ -2634,6 +2805,8 @@ private void OnSetupCodeTextChanged(object sender, TextChangedEventArgs? e) if (string.IsNullOrEmpty(code) || code.Length < 10) { AddSetupCodePreviewPanel.Visibility = Visibility.Collapsed; + _lastDecodedSetupUrl = null; + UpdateRemoteSetupAdvice(); return; } var decoded = SetupCodeDecoder.Decode(code); @@ -2648,10 +2821,15 @@ private void OnSetupCodeTextChanged(object sender, TextChangedEventArgs? e) // Auto-test connectivity with the decoded URL if (!string.IsNullOrEmpty(decoded.Url)) ScheduleConnectivityTest(decoded.Url); + // Warn if the decoded URL would send the bootstrap token in cleartext. + _lastDecodedSetupUrl = decoded.Url; + UpdateRemoteSetupAdvice(); } else { AddSetupCodePreviewPanel.Visibility = Visibility.Collapsed; + _lastDecodedSetupUrl = null; + UpdateRemoteSetupAdvice(); } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index c40725d4c..c32e75d01 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -105,6 +105,14 @@ internal enum RecoveryCategory Network, Server, Tunnel, + /// Authenticated but missing a required scope — re-pair for higher scopes. + Scope, + /// Stored device token rotated/revoked — re-pair to repair. + TokenDrift, + /// TLS/cleartext transport problem — switch to wss:// or a tunnel. + Tls, + /// Gateway is temporarily rate-limiting this client. + RateLimited, } /// @@ -449,6 +457,85 @@ private static ConnectionPagePlan BuildRecoveryFromError( RelevantGatewayId = rec?.Id, }, + // Stored device token rotated/revoked — the fix is to re-pair, not + // retry. Same paste-setup-code affordance as Auth, clearer copy. + RecoveryCategory.TokenDrift => new ConnectionPagePlan + { + Mode = ConnectionPageMode.Recovery, + Recovery = RecoveryCategory.TokenDrift, + StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.StatusErr, + StripAccent = ConnectionAccent.Critical, + StripHeadline = "Device needs re-pairing", + StripSub = string.IsNullOrEmpty(err) + ? $"The saved device token for {name} is no longer trusted by the gateway." + : err, + StripPrimaryLabel = null, + StripPrimaryAction = ConnectionPrimaryAction.None, + ActiveGatewayDisplayName = name, + ActiveGatewayDetailLine = url, + ActiveGatewayHasSshTunnel = rec?.SshTunnel != null, + RelevantGatewayId = rec?.Id, + }, + + // Authenticated but under-privileged — re-pair to request the scopes + // this device needs (e.g. operator.admin / operator.pairing). + RecoveryCategory.Scope => new ConnectionPagePlan + { + Mode = ConnectionPageMode.Recovery, + Recovery = RecoveryCategory.Scope, + StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.Lock, + StripAccent = ConnectionAccent.Critical, + StripHeadline = "Not enough access", + StripSub = string.IsNullOrEmpty(err) + ? $"This device is connected but lacks the scopes it needs on {name}." + : err, + StripPrimaryLabel = null, + StripPrimaryAction = ConnectionPrimaryAction.None, + ActiveGatewayDisplayName = name, + ActiveGatewayDetailLine = url, + ActiveGatewayHasSshTunnel = rec?.SshTunnel != null, + RelevantGatewayId = rec?.Id, + }, + + // TLS/cleartext transport problem — steer toward wss:// or a tunnel. + RecoveryCategory.Tls => new ConnectionPagePlan + { + Mode = ConnectionPageMode.Recovery, + Recovery = RecoveryCategory.Tls, + StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.StatusErr, + StripAccent = ConnectionAccent.Critical, + StripHeadline = "Secure connection failed", + StripSub = string.IsNullOrEmpty(err) + ? "The gateway's transport could not be secured." + : err, + StripPrimaryLabel = "Retry", + StripPrimaryAction = ConnectionPrimaryAction.Retry, + RecoveryDetail = err, + ActiveGatewayDisplayName = name, + ActiveGatewayDetailLine = url, + ActiveGatewayHasSshTunnel = rec?.SshTunnel != null, + RelevantGatewayId = rec?.Id, + }, + + RecoveryCategory.RateLimited => new ConnectionPagePlan + { + Mode = ConnectionPageMode.Recovery, + Recovery = RecoveryCategory.RateLimited, + StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.StatusWarn, + StripAccent = ConnectionAccent.Caution, + StripHeadline = "Too many failed attempts", + StripSub = string.IsNullOrEmpty(err) + ? "The gateway is temporarily limiting connection attempts from this client." + : err, + StripPrimaryLabel = null, + StripPrimaryAction = ConnectionPrimaryAction.None, + RecoveryDetail = err, + ActiveGatewayDisplayName = name, + ActiveGatewayDetailLine = url, + ActiveGatewayHasSshTunnel = rec?.SshTunnel != null, + RelevantGatewayId = rec?.Id, + }, + RecoveryCategory.Tunnel => new ConnectionPagePlan { Mode = ConnectionPageMode.Recovery, @@ -708,20 +795,24 @@ private static int CountEnabledCapabilities(SettingsManager s) private static RecoveryCategory ClassifyError(string err) { - if (string.IsNullOrEmpty(err)) return RecoveryCategory.Network; - var e = err.ToLowerInvariant(); - - if (e.Contains("auth") || e.Contains("token") || e.Contains("unauthor") || e.Contains("forbid")) - return RecoveryCategory.Auth; - - if (e.Contains("ssh") || e.Contains("tunnel")) - return RecoveryCategory.Tunnel; - - if (e.Contains("500") || e.Contains("502") || e.Contains("503") || - e.Contains("internal") || e.Contains("server")) - return RecoveryCategory.Server; - - return RecoveryCategory.Network; + // Delegate the heuristic matching to the pure, unit-tested Shared + // classifier so the same kinds drive both setup and recovery copy. + return OpenClaw.Shared.GatewayErrorClassifier.Classify(err) switch + { + OpenClaw.Shared.GatewayErrorKind.ScopeMismatch => RecoveryCategory.Scope, + OpenClaw.Shared.GatewayErrorKind.TokenDrift => RecoveryCategory.TokenDrift, + OpenClaw.Shared.GatewayErrorKind.Auth => RecoveryCategory.Auth, + OpenClaw.Shared.GatewayErrorKind.Tls => RecoveryCategory.Tls, + OpenClaw.Shared.GatewayErrorKind.Tunnel => RecoveryCategory.Tunnel, + OpenClaw.Shared.GatewayErrorKind.Server => RecoveryCategory.Server, + OpenClaw.Shared.GatewayErrorKind.RateLimited => RecoveryCategory.RateLimited, + OpenClaw.Shared.GatewayErrorKind.PairingRejected => RecoveryCategory.Auth, + // PairingRequired is normally driven by snapshot state, not the + // error string; if it surfaces here, the Auth re-pair path is the + // closest actionable fit. Network / Unknown → Network. + OpenClaw.Shared.GatewayErrorKind.PairingRequired => RecoveryCategory.Auth, + _ => RecoveryCategory.Network, + }; } private static string FormatUptime(long uptimeMs) diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 00d5db710..8bcb4e73f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -6629,4 +6629,91 @@ Check your connection settings and try again. Pending declared permissions: {0} + + Unencrypted connection + + + This remote gateway uses ws:// — your token would be sent in cleartext. Use wss:// (TLS), an SSH tunnel, or a trusted proxy / Tailscale. + + + Encrypted (TLS) + + + This gateway uses TLS. Your token is protected in transit when the certificate validates. + + + SSH tunnel + + + Traffic to this gateway is encrypted through the SSH tunnel. + + + Re-pair this device: + + + Upgrade this device's access: + + + Fix the secure connection: + + + The gateway no longer trusts this device's saved token (it was rotated or revoked). + + + Get a fresh setup code from the gateway host and paste it below to re-pair. + + + This device is authenticated but is missing the scopes it needs (for example operator.admin or operator.pairing). + + + Re-pair with a fresh setup code to request the required scopes, or approve the higher scopes on the gateway host. + + + The gateway's TLS/secure transport could not be established. + + + Verify the certificate on the gateway host, or connect over wss:// or an SSH tunnel. + + + Paste a shared gateway token, or leave it blank to pair with a setup code. + + + Connect to wss:// or https:// when the gateway has a trusted certificate. Your token is encrypted in transit after certificate validation. + + + When the gateway only listens on localhost, expand "Use SSH tunnel" below. The tray forwards a local port over SSH so traffic stays encrypted. + + + If a Tailscale or reverse proxy already encrypts the path, a plain ws:// URL over that private network is fine. + + + Paste a shared token, or leave it blank and pair with a setup code. Re-pairing also upgrades this device's scopes. + + + Wait before reconnecting: + + + The gateway is rate-limiting this client after repeated failed attempts. + + + Stop retrying for a moment, then reconnect after correcting the token, password, or pairing state. + + + How do I connect to a remote gateway? + + + Connecting to a remote gateway + + + Direct (TLS) + + + SSH tunnel + + + Trusted proxy / Tailscale + + + Auth + diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 0988cbb46..7a415c547 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6581,4 +6581,91 @@ Vérifiez vos paramètres de connexion et réessayez. Pending declared permissions: {0} + + Connexion non chiffrée + + + Cette gateway distante utilise ws:// — votre token serait envoyé en clair. Utilisez wss:// (TLS), un tunnel SSH, ou un proxy de confiance / Tailscale. + + + Chiffré (TLS) + + + Cette gateway utilise TLS. Votre token est protégé en transit lorsque le certificat est validé. + + + Tunnel SSH + + + Le trafic vers cette gateway est chiffré via le tunnel SSH. + + + Réappairer cet appareil : + + + Mettre à niveau l'accès de cet appareil : + + + Corriger la connexion sécurisée : + + + La gateway ne fait plus confiance au token enregistré de cet appareil (il a été renouvelé ou révoqué). + + + Obtenez un nouveau code de configuration depuis l'hôte de la gateway et collez-le ci-dessous pour réappairer. + + + Cet appareil est authentifié mais il manque les scopes nécessaires (par exemple operator.admin ou operator.pairing). + + + Réappairez avec un nouveau code de configuration pour demander les scopes requis, ou approuvez les scopes supérieurs sur l'hôte de la gateway. + + + Le transport TLS/sécurisé de la gateway n'a pas pu être établi. + + + Vérifiez le certificat sur l'hôte de la gateway, ou connectez-vous via wss:// ou un tunnel SSH. + + + Collez un token de gateway partagé, ou laissez-le vide pour appairer avec un code de configuration. + + + Connectez-vous à wss:// ou https:// lorsque la gateway possède un certificat de confiance. Votre token est chiffré en transit après validation du certificat. + + + Lorsque la gateway n'écoute que sur localhost, développez « Utiliser un tunnel SSH » ci-dessous. La tray transfère un port local via SSH afin que le trafic reste chiffré. + + + Si Tailscale ou un reverse proxy chiffre déjà le chemin, une simple URL ws:// sur ce réseau privé convient. + + + Collez un token partagé, ou laissez-le vide et appairez avec un code de configuration. Le réappairage met aussi à niveau les scopes de cet appareil. + + + Attendez avant de vous reconnecter : + + + La gateway limite temporairement ce client après plusieurs tentatives échouées. + + + Arrêtez les nouvelles tentatives un moment, puis reconnectez-vous après avoir corrigé le token, le mot de passe ou l’état d’appairage. + + + Comment me connecter à une gateway distante ? + + + Se connecter à une gateway distante + + + Connexion directe (TLS) + + + Tunnel SSH + + + Proxy de confiance / Tailscale + + + Authentification + diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index b6f0c2695..ce5b67238 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6582,4 +6582,91 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Pending declared permissions: {0} + + Niet-versleutelde verbinding + + + Deze externe gateway gebruikt ws:// — uw token zou onversleuteld worden verzonden. Gebruik wss:// (TLS), een SSH-tunnel of een vertrouwde proxy / Tailscale. + + + Versleuteld (TLS) + + + Deze gateway gebruikt TLS. Uw token is beschermd tijdens het transport wanneer het certificaat wordt gevalideerd. + + + SSH-tunnel + + + Verkeer naar deze gateway wordt versleuteld via de SSH-tunnel. + + + Koppel dit apparaat opnieuw: + + + Werk de toegang van dit apparaat bij: + + + Herstel de beveiligde verbinding: + + + De gateway vertrouwt het opgeslagen token van dit apparaat niet meer (het is gewijzigd of ingetrokken). + + + Haal een nieuwe installatiecode op van de gateway-host en plak deze hieronder om opnieuw te koppelen. + + + Dit apparaat is geverifieerd maar mist de benodigde scopes (bijvoorbeeld operator.admin of operator.pairing). + + + Koppel opnieuw met een nieuwe installatiecode om de vereiste scopes aan te vragen, of keur de hogere scopes goed op de gateway-host. + + + Het TLS-/beveiligde transport van de gateway kon niet tot stand worden gebracht. + + + Controleer het certificaat op de gateway-host, of maak verbinding via wss:// of een SSH-tunnel. + + + Plak een gedeeld gateway-token, of laat het leeg om te koppelen met een installatiecode. + + + Maak verbinding met wss:// of https:// wanneer de gateway een vertrouwd certificaat heeft. Uw token wordt tijdens het transport versleuteld na certificaatvalidatie. + + + Wanneer de gateway alleen op localhost luistert, vouwt u "SSH-tunnel gebruiken" hieronder uit. De tray stuurt een lokale poort door via SSH zodat het verkeer versleuteld blijft. + + + Als Tailscale of een reverse proxy het pad al versleutelt, is een gewone ws://-URL over dat privénetwerk prima. + + + Plak een gedeeld token, of laat het leeg en koppel met een installatiecode. Opnieuw koppelen werkt ook de scopes van dit apparaat bij. + + + Wacht voordat u opnieuw verbinding maakt: + + + De gateway beperkt deze client tijdelijk na herhaalde mislukte pogingen. + + + Stop even met opnieuw proberen en verbind opnieuw nadat het token, wachtwoord of de koppelingsstatus is gecorrigeerd. + + + Hoe maak ik verbinding met een externe gateway? + + + Verbinding maken met een externe gateway + + + Directe verbinding (TLS) + + + SSH-tunnel + + + Vertrouwde proxy / Tailscale + + + Verificatie + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 0e66266a3..42a6cd3e7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6582,4 +6582,91 @@ Pending declared permissions: {0} + + 未加密的连接 + + + 此远程 gateway 使用 ws:// — 您的 token 将以明文发送。请使用 wss://(TLS)、SSH 隧道或受信任的代理 / Tailscale。 + + + 已加密 (TLS) + + + 此 gateway 使用 TLS。证书验证通过后,您的 token 在传输过程中受到保护。 + + + SSH 隧道 + + + 到此 gateway 的流量通过 SSH 隧道加密。 + + + 重新配对此设备: + + + 升级此设备的访问权限: + + + 修复安全连接: + + + gateway 不再信任此设备保存的 token(它已被轮换或吊销)。 + + + 从 gateway 主机获取新的设置代码,并粘贴到下方以重新配对。 + + + 此设备已通过身份验证,但缺少所需的 scope(例如 operator.admin 或 operator.pairing)。 + + + 使用新的设置代码重新配对以请求所需的 scope,或在 gateway 主机上批准更高的 scope。 + + + 无法建立 gateway 的 TLS/安全传输。 + + + 验证 gateway 主机上的证书,或通过 wss:// 或 SSH 隧道连接。 + + + 粘贴共享 gateway token,或将其留空以使用设置代码配对。 + + + 当 gateway 拥有受信任证书时,连接到 wss:// 或 https://。证书验证通过后,您的 token 会在传输过程中加密。 + + + 当 gateway 仅在 localhost 上侦听时,请展开下方的“使用 SSH 隧道”。tray 会通过 SSH 转发本地端口,使流量保持加密。 + + + 如果 Tailscale 或反向代理已加密路径,则在该专用网络上使用普通 ws:// URL 即可。 + + + 粘贴共享 token,或将其留空并使用设置代码配对。重新配对还会升级此设备的 scope。 + + + 请稍后再重新连接: + + + gateway 在多次失败尝试后正在对此客户端限速。 + + + 暂时停止重试,然后在修正 token、密码或配对状态后重新连接。 + + + 如何连接到远程 gateway? + + + 连接到远程 gateway + + + 直连 (TLS) + + + SSH 隧道 + + + 受信任代理 / Tailscale + + + 身份验证 + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 5e3a02349..971af07ec 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6582,4 +6582,91 @@ Pending declared permissions: {0} + + 未加密的連線 + + + 此遠端 gateway 使用 ws:// — 您的 token 將以明文傳送。請使用 wss://(TLS)、SSH 通道或受信任的 Proxy / Tailscale。 + + + 已加密 (TLS) + + + 此 gateway 使用 TLS。憑證驗證通過後,您的 token 在傳輸過程中受到保護。 + + + SSH 通道 + + + 到此 gateway 的流量透過 SSH 通道加密。 + + + 重新配對此裝置: + + + 升級此裝置的存取權限: + + + 修復安全連線: + + + gateway 不再信任此裝置儲存的 token(它已被輪換或撤銷)。 + + + 從 gateway 主機取得新的設定代碼,並貼到下方以重新配對。 + + + 此裝置已通過驗證,但缺少所需的 scope(例如 operator.admin 或 operator.pairing)。 + + + 使用新的設定代碼重新配對以請求所需的 scope,或在 gateway 主機上核准更高的 scope。 + + + 無法建立 gateway 的 TLS/安全傳輸。 + + + 驗證 gateway 主機上的憑證,或透過 wss:// 或 SSH 通道連線。 + + + 貼上共用 gateway token,或將其留空以使用設定代碼配對。 + + + 當 gateway 擁有受信任憑證時,連線到 wss:// 或 https://。憑證驗證通過後,您的 token 會在傳輸過程中加密。 + + + 當 gateway 僅在 localhost 上接聽時,請展開下方的「使用 SSH 通道」。tray 會透過 SSH 轉發本機連接埠,使流量保持加密。 + + + 如果 Tailscale 或反向 Proxy 已加密路徑,則在該私人網路上使用一般 ws:// URL 即可。 + + + 貼上共用 token,或將其留空並使用設定代碼配對。重新配對也會升級此裝置的 scope。 + + + 請稍後再重新連線: + + + gateway 在多次失敗嘗試後正在對此用戶端限速。 + + + 暫時停止重試,然後在修正 token、密碼或配對狀態後重新連線。 + + + 如何連線到遠端 gateway? + + + 連線到遠端 gateway + + + 直連 (TLS) + + + SSH 通道 + + + 受信任 Proxy / Tailscale + + + 驗證 + diff --git a/tests/OpenClaw.Connection.Tests/OperatorScopeHelperTests.cs b/tests/OpenClaw.Connection.Tests/OperatorScopeHelperTests.cs index 01a085072..ebc37aaf6 100644 --- a/tests/OpenClaw.Connection.Tests/OperatorScopeHelperTests.cs +++ b/tests/OpenClaw.Connection.Tests/OperatorScopeHelperTests.cs @@ -173,7 +173,7 @@ public void SimulateConnected() public Task ReconnectAsync() => Task.CompletedTask; public Task SwitchGatewayAsync(string gatewayId) => Task.CompletedTask; public Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task ApplySetupCodeAsync(string setupCode) => Task.FromResult(new SetupCodeResult(SetupCodeOutcome.InvalidCode)); + public Task ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null) => Task.FromResult(new SetupCodeResult(SetupCodeOutcome.InvalidCode)); public Task ConnectWithSharedTokenAsync(string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null) => Task.FromResult(new SetupCodeResult(SetupCodeOutcome.InvalidCode)); public void Dispose() { } public ValueTask DisposeAsync() => ValueTask.CompletedTask; diff --git a/tests/OpenClaw.Connection.Tests/SetupCodeFlowTests.cs b/tests/OpenClaw.Connection.Tests/SetupCodeFlowTests.cs index e0d2a8db2..12deec914 100644 --- a/tests/OpenClaw.Connection.Tests/SetupCodeFlowTests.cs +++ b/tests/OpenClaw.Connection.Tests/SetupCodeFlowTests.cs @@ -102,6 +102,34 @@ public async Task ApplySetupCode_CredentialResolverReturnsBootstrap() manager.Dispose(); } + [Fact] + public async Task ApplySetupCode_WithSshTunnel_PersistsTunnelConfig() + { + var json = """{"url":"ws://gateway.example.com:18789","bootstrapToken":"boot-tok-ssh"}"""; + var code = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)); + var sshTunnel = new SshTunnelConfig( + "operator", + "ssh.example.com", + RemotePort: 18789, + LocalPort: 18791, + IncludeBrowserProxyForward: true, + SshPort: 2222); + + var resolver = new CredentialResolver(new FakeIdentityReader()); + var factory = new RecordingClientFactory(); + var manager = new GatewayConnectionManager( + resolver, factory, _registry, NullLogger.Instance); + + var result = await manager.ApplySetupCodeAsync(code, sshTunnel); + + Assert.Equal(SetupCodeOutcome.Success, result.Outcome); + var active = _registry.GetActive(); + Assert.NotNull(active); + Assert.Equal(sshTunnel, active.SshTunnel); + + manager.Dispose(); + } + [Fact] public async Task ApplySetupCode_WithExistingCredential_ForcesBootstrapForImmediatePairing() { diff --git a/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs b/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs new file mode 100644 index 000000000..b08636b03 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs @@ -0,0 +1,159 @@ +using OpenClaw.Shared; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class GatewayErrorClassifierTests +{ + [Theory] + [InlineData(null, GatewayErrorKind.Unknown)] + [InlineData("", GatewayErrorKind.Unknown)] + [InlineData(" ", GatewayErrorKind.Unknown)] + public void Classify_Empty_IsUnknown(string? error, GatewayErrorKind expected) + { + Assert.Equal(expected, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("Insufficient scope: operator.admin required")] + [InlineData("Forbidden — missing scope operator.pairing")] + [InlineData("permission denied for this operation")] + [InlineData("client is not permitted to approve devices")] + public void Classify_ScopeProblems_AreScopeMismatch(string error) + { + Assert.Equal(GatewayErrorKind.ScopeMismatch, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("Device token no longer recognized by gateway")] + [InlineData("device token invalid — please re-pair")] + [InlineData("token rotated on the server")] + [InlineData("token revoked")] + [InlineData("device token unknown")] + public void Classify_TokenDrift_IsTokenDrift(string error) + { + Assert.Equal(GatewayErrorKind.TokenDrift, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("Pairing approval pending on the gateway host")] + [InlineData("device pairing required")] + public void Classify_PairingPending_IsPairingRequired(string error) + { + Assert.Equal(GatewayErrorKind.PairingRequired, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("Pairing request was rejected")] + [InlineData("approval denied by operator")] + public void Classify_PairingRejected_IsPairingRejected(string error) + { + Assert.Equal(GatewayErrorKind.PairingRejected, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("TLS handshake failed")] + [InlineData("certificate validation error")] + [InlineData("server requires a secure (non-cleartext) connection")] + public void Classify_Tls_IsTls(string error) + { + Assert.Equal(GatewayErrorKind.Tls, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("ssh tunnel exited unexpectedly")] + [InlineData("tunnel could not bind local port")] + public void Classify_Tunnel_IsTunnel(string error) + { + Assert.Equal(GatewayErrorKind.Tunnel, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("401 Unauthorized")] + [InlineData("invalid credential supplied")] + [InlineData("authentication failed")] + public void Classify_GenericAuth_IsAuth(string error) + { + Assert.Equal(GatewayErrorKind.Auth, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("500 internal error")] + [InlineData("gateway returned a server error")] + public void Classify_Server_IsServer(string error) + { + Assert.Equal(GatewayErrorKind.Server, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("connection refused")] + [InlineData("host unreachable")] + [InlineData("connect timed out")] + public void Classify_Network_IsNetwork(string error) + { + Assert.Equal(GatewayErrorKind.Network, GatewayErrorClassifier.Classify(error)); + } + + [Theory] + [InlineData("rate limit exceeded")] + [InlineData("429 Too Many Requests")] + [InlineData("too many requests, slow down")] + public void Classify_RateLimited_IsRateLimited(string error) + { + Assert.Equal(GatewayErrorKind.RateLimited, GatewayErrorClassifier.Classify(error)); + } + + [Fact] + public void Classify_SshPermissionDenied_IsTunnel_NotScope() + { + // SSH failures read "Permission denied (publickey)" — must not be + // mistaken for a scope problem (tunnel detection runs first). + Assert.Equal( + GatewayErrorKind.Tunnel, + GatewayErrorClassifier.Classify("SSH tunnel failed: Permission denied (publickey)")); + } + + [Fact] + public void Classify_ServerErrorMentioningToken_IsServer_NotAuth() + { + // A transient 5xx that merely mentions a token must not route to the + // re-pair (Auth) path. + Assert.Equal( + GatewayErrorKind.Server, + GatewayErrorClassifier.Classify("500 internal error: token validation failed")); + } + + [Fact] + public void Classify_CertificateNotApproved_IsTls_NotPairing() + { + Assert.Equal( + GatewayErrorKind.Tls, + GatewayErrorClassifier.Classify("certificate not approved by CA")); + } + + [Fact] + public void Classify_RepairWord_DoesNotMatchPairing() + { + // "repair" contains "pair" — must not be classified as pairing. + Assert.NotEqual( + GatewayErrorKind.PairingRequired, + GatewayErrorClassifier.Classify("could not repair connection to gateway")); + } + + [Fact] + public void Classify_ScopeWins_OverGenericAuthKeywords() + { + // Contains both "unauthorized" and "scope" — scope is the actionable kind. + Assert.Equal( + GatewayErrorKind.ScopeMismatch, + GatewayErrorClassifier.Classify("Unauthorized: insufficient scope operator.write")); + } + + [Fact] + public void Classify_TokenDriftWins_OverGenericAuthKeywords() + { + Assert.Equal( + GatewayErrorKind.TokenDrift, + GatewayErrorClassifier.Classify("auth failed: device token no longer valid, re-pair required")); + } +} diff --git a/tests/OpenClaw.Shared.Tests/RemoteGatewayClassifierTests.cs b/tests/OpenClaw.Shared.Tests/RemoteGatewayClassifierTests.cs new file mode 100644 index 000000000..79ea41ad7 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/RemoteGatewayClassifierTests.cs @@ -0,0 +1,89 @@ +using OpenClaw.Shared; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class RemoteGatewayClassifierTests +{ + [Theory] + [InlineData("ws://localhost:18789")] + [InlineData("wss://127.0.0.1:18789")] + [InlineData("http://[::1]:18789")] + public void Classify_LoopbackHosts_AreLocalLoopback(string url) + { + var profile = RemoteGatewayClassifier.Classify(url); + + Assert.Equal(GatewayConnectionTopology.Local, profile.Topology); + Assert.Equal(GatewayTransportSecurity.LocalLoopback, profile.Security); + Assert.True(profile.IsLocal); + Assert.False(profile.IsRemote); + Assert.False(profile.RecommendsTransportHardening); + } + + [Theory] + [InlineData("wss://gateway.example.com:18789")] + [InlineData("https://gateway.example.com")] + public void Classify_RemoteTls_IsDirectSecure(string url) + { + var profile = RemoteGatewayClassifier.Classify(url); + + Assert.Equal(GatewayConnectionTopology.DirectSecure, profile.Topology); + Assert.Equal(GatewayTransportSecurity.Encrypted, profile.Security); + Assert.True(profile.IsRemote); + Assert.True(profile.IsTls); + Assert.False(profile.RecommendsTransportHardening); + } + + [Theory] + [InlineData("ws://gateway.example.com:18789")] + [InlineData("http://10.0.0.5:18789")] + [InlineData("ws://machine-name:18789")] + public void Classify_RemoteCleartext_IsDirectInsecure_AndWarns(string url) + { + var profile = RemoteGatewayClassifier.Classify(url); + + Assert.Equal(GatewayConnectionTopology.DirectInsecure, profile.Topology); + Assert.Equal(GatewayTransportSecurity.Cleartext, profile.Security); + Assert.True(profile.IsRemote); + Assert.False(profile.IsTls); + Assert.True(profile.RecommendsTransportHardening); + } + + [Fact] + public void Classify_WithSshTunnel_IsEncryptedTunnel_EvenForLoopbackUrl() + { + // The WebSocket talks to localhost but the bytes are SSH-encrypted to a + // remote host — treat as remote-but-encrypted, never warn. + var profile = RemoteGatewayClassifier.Classify("ws://localhost:18789", hasSshTunnel: true); + + Assert.Equal(GatewayConnectionTopology.SshTunnel, profile.Topology); + Assert.Equal(GatewayTransportSecurity.Encrypted, profile.Security); + Assert.True(profile.IsRemote); + Assert.False(profile.IsLocal); + Assert.False(profile.RecommendsTransportHardening); + } + + [Fact] + public void Classify_SshTunnel_TakesPrecedenceOverCleartextRemoteUrl() + { + var profile = RemoteGatewayClassifier.Classify("ws://gateway.example.com:18789", hasSshTunnel: true); + + Assert.Equal(GatewayConnectionTopology.SshTunnel, profile.Topology); + Assert.False(profile.RecommendsTransportHardening); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + [InlineData("not a url")] + [InlineData("ws://")] + public void Classify_UnparseableInput_IsUnknown_AndDoesNotWarn(string? url) + { + var profile = RemoteGatewayClassifier.Classify(url); + + Assert.Equal(GatewayConnectionTopology.Unknown, profile.Topology); + Assert.False(profile.RecommendsTransportHardening); + Assert.False(profile.IsRemote); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index f68ffd18b..e9a6f77ac 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -55,6 +55,18 @@ public void LocalNodeTrustPairListUpdate_RefreshesVisibleNodeList() Assert.Contains("Node list refresh failed after local node trust request", managerSource); } + [Fact] + public void SetupCodeEntry_ClearsStaleSshTunnelFields() + { + var pageSource = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs"); + + Assert.Contains("private void ClearAddGatewaySshFields()", pageSource); + Assert.Contains("ClearAddGatewaySshFields();\r\n ShowAddPane(\"setup\");", pageSource); + Assert.Contains("AddSshExpander.IsExpanded = false;", pageSource); + Assert.Contains("AddSshUserBox.Text = \"\";", pageSource); + Assert.Contains("AddSshHostBox.Text = \"\";", pageSource); + } + private static string ReadSource(params string[] relativePathParts) { var root = TestRepositoryPaths.GetRepositoryRoot();