diff --git a/CHANGELOG.md b/CHANGELOG.md index b4169a1f1..e710a4152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The autocomplete popup now filters in place as you type instead of closing and reopening on every keystroke. (#1608) - Syntax highlighting no longer disappears after formatting a query. (#1612) - The GitHub Copilot provider no longer shows a Max output tokens field it ignores, and picking a Copilot model no longer leaves a stray model ID field behind. +- MongoDB now connects over an SSH or Cloudflare tunnel instead of bypassing it and failing with a connection refused error. (#1621) ## [0.49.1] - 2026-06-06 diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 95147a9ce..82cdcf416 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -13,7 +13,10 @@ extension DatabaseManager { /// Rewrite a connection to point at the local tunnel endpoint. A 127.0.0.1 /// certificate can't satisfy hostname verification, so verify modes drop to /// `.required` while keeping encryption; cert paths are cleared and pgpass keeps - /// the original host/port. Shared by the SSH and Cloudflare tunnel paths. + /// the original host/port. A tunnel forwards a single local port, so MongoDB's + /// seed list is collapsed to that endpoint and a direct connection is forced, + /// stopping replica set discovery from reaching members behind the tunnel. + /// Shared by the SSH and Cloudflare tunnel paths. func tunneledConnection(from connection: DatabaseConnection, localPort: Int) -> DatabaseConnection { var tunnelSSL = connection.sslConfig if tunnelSSL.isEnabled { @@ -30,6 +33,10 @@ extension DatabaseManager { effectiveFields["pgpassOriginalHost"] = connection.host effectiveFields["pgpassOriginalPort"] = String(connection.port) } + if connection.type.pluginTypeId == "MongoDB", !connection.usesMongoSrv { + effectiveFields["mongoHosts"] = nil + effectiveFields["mongoParam_directConnection"] = "true" + } return DatabaseConnection( id: connection.id, diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 8fdf0794b..fe59110e8 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -361,6 +361,10 @@ struct DatabaseConnection: Identifiable, Hashable { set { additionalFields["mongoUseSrv"] = newValue ? "true" : "" } } + var usesMongoSrv: Bool { + mongoUseSrv || host.hasSuffix(".mongodb.net") + } + var mongoAuthMechanism: String? { get { additionalFields["mongoAuthMechanism"]?.nilIfEmpty } set { additionalFields["mongoAuthMechanism"] = newValue ?? "" } diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index c9528d72c..ceae225e2 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -104,7 +104,7 @@ struct GeneralPaneView: View { if hostsValue.contains(",") { Section { Label( - String(localized: "SSH tunneling only forwards the first host. Other replica set members must be directly reachable from the SSH server."), + String(localized: "Over an SSH tunnel, TablePro connects directly to the first host. Replica set failover is not available."), systemImage: "exclamationmark.triangle" ) .font(.caption) diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift index ac6201789..8c4af4f00 100644 --- a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -4,9 +4,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("DatabaseManager tunnel rewrite") @MainActor @@ -27,4 +27,52 @@ struct DatabaseManagerTunnelTests { #expect(tunneled.port == 61_234) #expect(tunneled.passwordSource == .env(variable: "DB_PASS")) } + + @Test("Tunneled MongoDB collapses the seed list and forces a direct connection") + func tunnelForcesMongoDirectConnection() { + let connection = DatabaseConnection( + name: "mongo", + host: "primary.internal", + port: 27_017, + type: .mongodb, + additionalFields: ["mongoHosts": "primary.internal:27017,secondary.internal:27017"] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.host == "127.0.0.1") + #expect(tunneled.port == 62_000) + #expect(tunneled.additionalFields["mongoHosts"] == nil) + #expect(tunneled.additionalFields["mongoParam_directConnection"] == "true") + } + + @Test("Tunneled MongoDB leaves SRV connections untouched") + func tunnelLeavesMongoSrvUntouched() { + let connection = DatabaseConnection( + name: "atlas", + host: "cluster0.example.com", + port: 27_017, + type: .mongodb, + additionalFields: ["mongoHosts": "cluster0.example.com", "mongoUseSrv": "true"] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.additionalFields["mongoHosts"] == "cluster0.example.com") + #expect(tunneled.additionalFields["mongoParam_directConnection"] == nil) + } + + @Test("Tunneled non-MongoDB connection gets no direct-connection override") + func tunnelLeavesNonMongoUntouched() { + let connection = DatabaseConnection( + name: "pg", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.additionalFields["mongoParam_directConnection"] == nil) + } }