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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469)
- In the CSV editor, choose the escape character (a doubled quote or a backslash) in Set CSV Properties…, so files that escape quotes with a backslash read and save correctly. (#1469)
- Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777)
- SQL Server Windows Authentication (Kerberos) now connects to servers whose Kerberos realm differs from your Mac's default realm. TablePro reads the `[domain_realm]` mapping from your Kerberos configuration, the same way the JDBC driver does, so a server that works in DataGrip works here without changing your system `default_realm`. Hosts with no mapping keep using the default realm. (#1947)

### Fixed

- SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912)
- Browsing a Trino table no longer fails with a syntax error. Trino requires `OFFSET` before `LIMIT`, so paging now uses `OFFSET` and `FETCH FIRST`. (#1936)
- The Authentication method selector no longer changes position in the connection form when you switch between methods that show or hide the username and password (for example SQL Server's SQL vs Windows Authentication, or PostgreSQL's password vs AWS IAM). (#1947)

## [0.59.0] - 2026-07-21

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
public var loginTimeoutSeconds: Int
public var authMethod: MSSQLAuthMethod
public var kerberosCachePath: String?
public var kerberosServicePrincipal: String?

public static let defaultPort = 1433
public static let defaultSchema = "dbo"
Expand All @@ -30,12 +31,14 @@ public struct MSSQLConnectionOptions: Sendable, Equatable {
applicationName: String = MSSQLConnectionOptions.defaultApplicationName,
loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds,
authMethod: MSSQLAuthMethod = .sqlServer,
kerberosCachePath: String? = nil
kerberosCachePath: String? = nil,
kerberosServicePrincipal: String? = nil
) {
self.host = host
self.port = port
self.authMethod = authMethod
self.kerberosCachePath = kerberosCachePath
self.kerberosServicePrincipal = kerberosServicePrincipal
switch authMethod {
case .sqlServer:
self.user = user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// Decides whether a realm resolved from the system Kerberos configuration is worth overriding
/// FreeTDS's default service principal with.
///
/// macOS Heimdal never consults `default_realm` when it maps a host to a realm. With no matching
/// `[domain_realm]` entry it synthesizes the DNS domain after the first label, upper-cased, so a
/// correct `default_realm` would be replaced by a guess derived from DNS. That guess breaks the
/// setups where the realm and the DNS domain differ (disjoint namespaces, CNAME aliases, hosts
/// whose service principal lives in the forest root), which authenticate today.
///
/// Only a realm that carries more than the host's own domain came from the Kerberos configuration,
/// and only that one is safe to send. This matches the JDBC driver, which maps a host through
/// `[domain_realm]` and otherwise falls back to `default_realm`.
public enum MSSQLKerberosRealm {
public static func domainGuess(forHost host: String) -> String? {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard let firstDot = trimmed.firstIndex(of: ".") else { return nil }
let domain = trimmed[trimmed.index(after: firstDot)...]
return domain.isEmpty ? nil : domain.uppercased()
}

public static func isConfigured(_ realm: String, forHost host: String) -> Bool {
let trimmedRealm = realm.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedRealm.isEmpty else { return false }
guard let domainGuess = domainGuess(forHost: host) else { return true }
return trimmedRealm.caseInsensitiveCompare(domainGuess) != .orderedSame
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Builds the SQL Server Kerberos service principal name (SPN) with an explicit realm.
///
/// FreeTDS otherwise constructs an unrealmed `MSSQLSvc/host:port`, which macOS Heimdal resolves
/// against the client's `default_realm` with no cross-realm referral. Windows Authentication then
/// fails (`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`) whenever the SQL Server's realm differs from the Mac's
/// default realm. Supplying the realm-qualified SPN via `DBSETSERVERPRINCIPAL` makes Heimdal request
/// the service ticket from the correct realm.
public enum MSSQLKerberosSPN {
/// Returns `MSSQLSvc/<host>:<port>@<REALM>`, or `nil` when no realm is set (letting FreeTDS keep
/// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention.
public static func build(host: String, port: Int, realm: String?) -> String? {
guard let realm = realm?.trimmingCharacters(in: .whitespacesAndNewlines), !realm.isEmpty else {
return nil
}
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return nil }
return "MSSQLSvc/\(trimmedHost):\(port)@\(realm.uppercased())"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Testing
@testable import TableProMSSQLCore

@Suite("MSSQL Kerberos realm")
struct MSSQLKerberosRealmTests {
@Test("A realm that only repeats the host's DNS domain is Heimdal's guess, not configuration")
func domainDerivedRealmIsNotConfigured() {
#expect(!MSSQLKerberosRealm.isConfigured("CORP.CONTOSO.LOCAL", forHost: "sql.corp.contoso.local"))
#expect(!MSSQLKerberosRealm.isConfigured("EXAMPLE.COM", forHost: "sql.example.com"))
}

@Test("A realm that differs from the host's DNS domain came from the Kerberos configuration")
func mappedRealmIsConfigured() {
#expect(MSSQLKerberosRealm.isConfigured("RESOURCE.REALM.COM", forHost: "sql.example.com"))
#expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sql.corp.contoso.local"))
}

@Test("Realm comparison ignores case, so a lower-cased mapping is still treated as the guess")
func comparisonIgnoresCase() {
#expect(!MSSQLKerberosRealm.isConfigured("example.com", forHost: "sql.example.com"))
#expect(!MSSQLKerberosRealm.isConfigured(" Example.Com ", forHost: "sql.example.com"))
}

@Test("An empty realm is never adopted")
func emptyRealmIsNotConfigured() {
#expect(!MSSQLKerberosRealm.isConfigured("", forHost: "sql.example.com"))
#expect(!MSSQLKerberosRealm.isConfigured(" ", forHost: "sql.example.com"))
}

@Test("A dot-less host has no domain to guess from, so any realm must be configured")
func shortHostAlwaysConfigured() {
#expect(MSSQLKerberosRealm.isConfigured("AD.CONTOSO.COM", forHost: "sqlhost"))
#expect(MSSQLKerberosRealm.domainGuess(forHost: "sqlhost") == nil)
}

@Test("The domain guess is every label after the first, upper-cased")
func domainGuessDropsOnlyTheFirstLabel() {
#expect(MSSQLKerberosRealm.domainGuess(forHost: "a.b.c.d.com") == "B.C.D.COM")
#expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.example.com") == "EXAMPLE.COM")
#expect(MSSQLKerberosRealm.domainGuess(forHost: " sql.example.com ") == "EXAMPLE.COM")
#expect(MSSQLKerberosRealm.domainGuess(forHost: "sql.") == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Testing
@testable import TableProMSSQLCore

@Suite("MSSQL Kerberos SPN")
struct MSSQLKerberosSPNTests {
@Test("Realm-qualified SPN is built and the realm is upper-cased")
func buildsRealmQualifiedSPN() {
#expect(
MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "resource.realm.com")
== "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM"
)
}

@Test("No realm yields no SPN, so FreeTDS keeps its default")
func nilRealmReturnsNil() {
#expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: nil) == nil)
}

@Test("Empty or whitespace-only realm yields no SPN")
func emptyRealmReturnsNil() {
#expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: "") == nil)
#expect(MSSQLKerberosSPN.build(host: "sql.example.com", port: 1433, realm: " ") == nil)
}

@Test("Whitespace is trimmed from host and realm")
func trimsWhitespace() {
#expect(
MSSQLKerberosSPN.build(host: " sql.example.com ", port: 1433, realm: " resource.realm.com ")
== "MSSQLSvc/sql.example.com:1433@RESOURCE.REALM.COM"
)
}

@Test("A non-default port is included in the SPN")
func includesNonDefaultPort() {
#expect(
MSSQLKerberosSPN.build(host: "sql.example.com", port: 14330, realm: "R.COM")
== "MSSQLSvc/sql.example.com:14330@R.COM"
)
}
}
1 change: 1 addition & 0 deletions Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ typedef struct loginrec LOGINREC;
#define DBSETPACKET 11
#define DBSETENCRYPT 12
#define DBSETDBNAME 14
#define DBSETSERVERPRINCIPAL 103

// Convenience macros (match FreeTDS sybdb.h)
#define DBSETLHOST(x, y) dbsetlname((x), (y), DBSETHOST)
Expand Down
10 changes: 10 additions & 0 deletions Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable {
_ = dbsetlversion(login, UInt8(DBVERSION_74))
_ = dbsetlogintime(Int32(options.loginTimeoutSeconds))

#if os(macOS)
// Windows Auth cross-realm: FreeTDS otherwise builds its own SPN and only canonicalizes a
// short hostname (via getaddrinfo), never applying [domain_realm] to pick the realm. We
// resolve the canonical host + realm up front and hand FreeTDS the full SPN, so cross-realm
// and short-name/CNAME hosts authenticate like the JDBC driver does.
if options.authMethod == .windows, let spn = options.kerberosServicePrincipal, !spn.isEmpty {
_ = dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL))
}
#endif

freetdsClearError(for: nil)
let serverName = "\(options.host):\(options.port)"
guard let proc = withKerberosEnvironmentIfNeeded({ dbopen(login, serverName) }) else {
Expand Down
91 changes: 91 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import GSS
import TableProMSSQLCore

/// Resolves the canonical SQL Server Kerberos service (host + realm) for a connection host.
///
/// macOS Heimdal does not apply the system Kerberos configuration (`[domain_realm]`) when FreeTDS
/// builds its own SPN string, so a cross-realm host fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`.
/// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN via
/// `DBSETSERVERPRINCIPAL`.
///
/// Once an explicit SPN is set, FreeTDS stops canonicalizing a short hostname to its FQDN (which it
/// otherwise does with `getaddrinfo` for dot-less names). To avoid regressing those connections we
/// perform the same canonicalization here, so the SPN carries the FQDN the service is registered
/// under, short name or CNAME included.
enum MSSQLKerberosRealmResolver {
/// Canonical host and realm for `host`, or `nil` when the realm is unresolved or Heimdal only
/// guessed it from the host's own DNS domain. The caller then leaves the SPN to FreeTDS, whose
/// unrealmed principal resolves against `default_realm`.
static func canonicalService(forHost host: String) -> (host: String, realm: String)? {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let canonicalHost = canonicalHostname(trimmed)
guard let realm = realm(forHost: canonicalHost),
MSSQLKerberosRealm.isConfigured(realm, forHost: canonicalHost)
else { return nil }
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor domain_realm mappings that match the DNS suffix

When a cross-realm server's DNS suffix and Kerberos realm are the same (for example, host sql.resource.example.com, [domain_realm] .resource.example.com = RESOURCE.EXAMPLE.COM, and a different client default_realm), GSS returns exactly the value domainGuess produces. This predicate treats that explicitly configured mapping as a Heimdal guess and returns nil, so FreeTDS receives no realm-qualified SPN and falls back to the client's default realm—the cross-realm connection this change is meant to support still fails. The resolver needs to distinguish an actual [domain_realm] mapping from a derived suffix rather than discarding equal values.

Useful? React with 👍 / 👎.

return (host: canonicalHost, realm: realm)
}

/// Mirrors FreeTDS: canonicalize a dot-less (short) hostname to its FQDN via `getaddrinfo`.
/// A name that already contains a dot is used as-is, matching FreeTDS's own behavior.
private static func canonicalHostname(_ host: String) -> String {
guard !host.contains(".") else { return host }
var hints = addrinfo()
hints.ai_flags = AI_CANONNAME
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0, let result else { return host }
defer { freeaddrinfo(result) }
if let canonical = result.pointee.ai_canonname {
let name = String(cString: canonical)
if name.contains(".") { return name }
}
return host
}

/// Realm for a host from the system Kerberos configuration (`[domain_realm]`, `default_realm`),
/// via GSS name canonicalization, the same resolution the KDC clients use.
private static func realm(forHost host: String) -> String? {
var minor: OM_uint32 = 0
guard let cString = strdup("MSSQLSvc@\(host)") else { return nil }
var nameBuffer = gss_buffer_desc(length: strlen(cString), value: cString)
var importedName: gss_name_t?
let importStatus = gss_import_name(
&minor, &nameBuffer, &__gss_c_nt_hostbased_service_oid_desc, &importedName
)
free(cString)
guard importStatus == GSS_S_COMPLETE, let importedName else { return nil }
defer {
var releaseMinor: OM_uint32 = 0
var releasable: gss_name_t? = importedName
_ = gss_release_name(&releaseMinor, &releasable)
}

var canonicalName: gss_name_t?
let canonStatus = gss_canonicalize_name(
&minor, importedName, &__gss_krb5_mechanism_oid_desc, &canonicalName
)
guard canonStatus == GSS_S_COMPLETE, let canonicalName else { return nil }
defer {
var releaseMinor: OM_uint32 = 0
var releasable: gss_name_t? = canonicalName
_ = gss_release_name(&releaseMinor, &releasable)
}

var displayBuffer = gss_buffer_desc()
var nameType: gss_OID?
let displayStatus = gss_display_name(&minor, canonicalName, &displayBuffer, &nameType)
defer {
var releaseMinor: OM_uint32 = 0
_ = gss_release_buffer(&releaseMinor, &displayBuffer)
}
guard displayStatus == GSS_S_COMPLETE, let value = displayBuffer.value else { return nil }

let display = String(data: Data(bytes: value, count: displayBuffer.length), encoding: .utf8) ?? ""
guard let atIndex = display.lastIndex(of: "@") else { return nil }
let realm = String(display[display.index(after: atIndex)...])
return realm.isEmpty ? nil : realm

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the canonical host in Kerberos SPNs

When the user enters a short hostname or CNAME, GSS canonicalization can produce the FQDN that has the SQL Server SPN registered, but this resolver discards that canonical host and returns only the realm; the later MSSQLKerberosSPN.build(host: config.host, ...) rebuilds DBSETSERVERPRINCIPAL from the raw form field. Because that SPN is then passed verbatim to FreeTDS, connections that previously relied on FreeTDS/GSS host canonicalization can start requesting MSSQLSvc/alias:port@REALM and fail with S_PRINCIPAL_UNKNOWN. Return/build the canonical service name (or canonical host plus realm) instead of only the realm.

Useful? React with 👍 / 👎.

}
}
32 changes: 31 additions & 1 deletion Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

private static let logger = Logger(subsystem: "com.TablePro", category: "MSSQLPluginDriver")

private static let kerberosResolveQueue = DispatchQueue(
label: "com.TablePro.mssql.kerberos-resolve",
qos: .userInitiated
)
private static let kerberosResolveTimeoutSeconds = 5

private struct KerberosRealmResolutionTimeout: Error {}

var currentSchema: String? { _currentSchema }
var serverVersion: String? { _serverVersion }
var supportsSchemas: Bool { true }
Expand Down Expand Up @@ -268,6 +276,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
let conn: FreeTDSConnection
do {
let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod)
let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod)
let options = MSSQLConnectionOptions(
host: config.host,
port: config.port,
Expand All @@ -277,7 +286,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
schema: _currentSchema,
encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode),
authMethod: authMethod,
kerberosCachePath: kerberosCachePath
kerberosCachePath: kerberosCachePath,
kerberosServicePrincipal: kerberosServicePrincipal
)
conn = FreeTDSConnection(options: options)
try await conn.connect()
Expand Down Expand Up @@ -314,6 +324,26 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}

private func resolveKerberosServicePrincipal(authMethod: MSSQLAuthMethod) async throws -> String? {
guard authMethod == .windows else { return nil }
let host = config.host
let port = config.port
do {
return try await runCancellableBlocking(
on: Self.kerberosResolveQueue,
deadline: .seconds(Self.kerberosResolveTimeoutSeconds),
timeoutError: { KerberosRealmResolutionTimeout() },
work: {
MSSQLKerberosRealmResolver.canonicalService(forHost: host)
.flatMap { MSSQLKerberosSPN.build(host: $0.host, port: port, realm: $0.realm) }
}
)
} catch is KerberosRealmResolutionTimeout {
Self.logger.warning("Kerberos realm resolution timed out; using the default service principal")
return nil
}
}

private func acquireKerberosTicketIfNeeded(authMethod: MSSQLAuthMethod) async throws -> String? {
guard authMethod == .windows else { return nil }
let principal = (config.additionalFields[MSSQLKerberosField.principal] ?? "")
Expand Down
35 changes: 35 additions & 0 deletions TablePro/Core/Plugins/ConnectionField+AuthFieldOrder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// ConnectionField+AuthFieldOrder.swift
// TablePro
//

import TableProPluginKit

extension Collection where Element == ConnectionField {
/// Fields that decide whether the built-in Username and Password appear: either they carry the
/// flag themselves (an auth-method dropdown, a password-file toggle), or they gate a dependent
/// field that carries it (SQL Server's Kerberos principal, Snowflake's OAuth token).
var credentialControllerIds: Set<String> {
Set(
filter { $0.hidesUsername || $0.hidesPassword }
.map { $0.visibleWhen?.fieldId ?? $0.id }
)
}

/// Splits the fields so the credential controllers render above the built-in Username and
/// Password. A controller placed below them shifts position every time its own selection shows
/// or hides those credentials.
func splitCredentialControllers() -> (controllers: [ConnectionField], rest: [ConnectionField]) {
let controllerIds = credentialControllerIds
var controllers: [ConnectionField] = []
var rest: [ConnectionField] = []
for field in self {
if controllerIds.contains(field.id) {
controllers.append(field)
} else {
rest.append(field)
}
}
return (controllers, rest)
}
}
Loading
Loading