Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "" }
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 49 additions & 1 deletion TableProTests/Core/Database/DatabaseManagerTunnelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing
@testable import TablePro

@Suite("DatabaseManager tunnel rewrite")
@MainActor
Expand All @@ -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)
}
}
Loading