diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc84c988b..6701699763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -511,6 +511,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server dumps failing on their first view, routine or trigger when restored with sqlcmd or SSMS. - A leading `GO` line sent to SQL Server by MCP and AI assistant tools, and a `GO n` count ignored. - App hanging for a minute when an import stopped on a failing statement several megabytes long. +- SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes. ### Security diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift b/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift index e1f860bfc8..9cd5f45e53 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift @@ -8,6 +8,12 @@ public final class SingleResumeGate: @unchecked Sendable { public init() {} + public var isSettled: Bool { + lock.lock() + defer { lock.unlock() } + return settled + } + public func install(_ continuation: CheckedContinuation, alreadyCancelled: Bool) { lock.lock() if alreadyCancelled, !settled { diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift index 516bde038e..c3272d606d 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift @@ -37,6 +37,8 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable { case invalidPort(Int) case unreadableHost case unreadableAuthorityPath + case unreadableServicePrincipal + case nameInUse(String) case unwritable(String) public var errorDescription: String? { @@ -53,6 +55,18 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable { The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or \ repeated spaces. """) + case .unreadableServicePrincipal: + return String(localized: """ + The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a \ + shorter host name. + """) + case .nameInUse(let name): + return String( + format: String(localized: """ + Another connection to %@ with other settings is still logging in. Try again once it finishes. + """), + name + ) case .unwritable(let detail): return String(format: String(localized: "The FreeTDS configuration could not be written: %@"), detail) } @@ -77,30 +91,43 @@ public enum MSSQLFreeTDSConfig { /// One server as libtds reads it from freetds.conf. /// -/// The entry is named after the host because db-lib sends the name dbopen was given as the server name in the login -/// packet, and that name has to stay the host. It states every option it depends on, even at libtds's own default: a -/// section called `global` holds the defaults for every other one, and a host can have that name. Every value is -/// checked against the way libtds reads the file: `;` and `#` start a comment, runs of white space collapse to one, a -/// `[` opens a section and `=` ends an option's name. A value that would read back differently is refused rather than -/// written. +/// The section's name is the server name db-lib sends in the login packet, because dbopen is given that name and +/// libtds finds the section by it. For a host name it has to stay the host: FreeTDS sends no TLS server name, so an +/// Azure SQL gateway learns which server a login is for from this field alone. An IP address names no server a gateway +/// could route by, so there the port joins the name the way SQL Server writes one, `10.0.0.5,1433`, and connects to +/// other ports on one address, every SSH tunnel on 127.0.0.1 among them, never share a name. +/// +/// The entry states every option it depends on, even at libtds's own default: a section called `global` holds the +/// defaults for every other one, and a host can have that name. Every value is checked against the way libtds reads the +/// file: `;` and `#` start a comment, runs of white space collapse to one, a `[` opens a section and `=` ends an +/// option's name. A value that would read back differently is refused rather than written. public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { + /// The server name dbopen is given, and the section libtds looks it up by. + public let name: String + /// The host libtds dials, without the brackets an IPv6 address is often written in, which libtds drops too. public let host: String public let port: Int public let encryption: MSSQLEncryptionLevel public let verification: MSSQLCertificateVerification public let authorityPath: String? + /// The Kerberos service principal libtds asks a ticket for. Without one it builds `MSSQLSvc/:` in the + /// default realm. It is written here rather than set on the login, whose setter takes no more than 128 bytes. + public let servicePrincipal: String? public init( host: String, port: Int, encryption: MSSQLEncryptionLevel, verification: MSSQLCertificateVerification, - caCertificatePath: String? + caCertificatePath: String?, + servicePrincipal: String? = nil ) throws { guard (1...65_535).contains(port) else { throw MSSQLFreeTDSConfigError.invalidPort(port) } - guard Self.isReadableHost(host) else { + let dialled = Self.withoutBrackets(host) + let name = Self.isNumericAddress(dialled) ? "\(dialled),\(port)" : dialled + guard Self.isReadableHost(dialled), Self.fitsOnOneLine("[\(name)]") else { throw MSSQLFreeTDSConfigError.unreadableHost } let authorityPath = verification.needsAuthority @@ -109,11 +136,17 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { if let authorityPath, !Self.isReadableValue(authorityPath, option: "ca file") { throw MSSQLFreeTDSConfigError.unreadableAuthorityPath } - self.host = host + let principal = servicePrincipal.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + if let principal, !Self.isReadableValue(principal, option: "spn") { + throw MSSQLFreeTDSConfigError.unreadableServicePrincipal + } + self.name = name + self.host = dialled self.port = port self.encryption = encryption self.verification = verification self.authorityPath = authorityPath + self.servicePrincipal = principal } public init(options: MSSQLConnectionOptions) throws { @@ -122,13 +155,11 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { port: options.port, encryption: options.encryptionLevel, verification: options.certificateVerification, - caCertificatePath: options.caCertificatePath + caCertificatePath: options.caCertificatePath, + servicePrincipal: options.authMethod == .windows ? options.kerberosServicePrincipal : nil ) } - /// The server name dbopen is given, and the section libtds looks it up by. - public var name: String { host } - public var text: String { [ "[\(name)]", @@ -137,7 +168,8 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { "\ttds version = 7.4", "\tencryption = \(encryption.rawValue)", "\tca file = \(authorityPath ?? "")", - "\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")" + "\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")", + "\tspn = \(servicePrincipal ?? "")" ].joined(separator: "\n") + "\n" } @@ -147,7 +179,21 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { private static func isReadableHost(_ host: String) -> Bool { guard !host.isEmpty, host.rangeOfCharacter(from: hostDelimiters) == nil else { return false } - return fitsOnOneLine("[\(host)]") && fitsOnOneLine("\thost = \(host)") + return fitsOnOneLine("\thost = \(host)") + } + + private static func withoutBrackets(_ host: String) -> String { + guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host } + return String(host.dropFirst().dropLast()) + } + + private static func isNumericAddress(_ host: String) -> Bool { + var hints = addrinfo() + hints.ai_flags = AI_NUMERICHOST + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0 else { return false } + freeaddrinfo(result) + return true } private static func isReadableValue(_ value: String, option: String) -> Bool { diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift index f6cac2a450..63e48e059f 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift @@ -7,18 +7,21 @@ import Foundation /// The freetds.conf every SQL Server connection in the process reads, named to db-lib once with dbsetifile. /// -/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries for -/// different host names sit side by side, and a connection never waits on one to another host name, however long that -/// server takes to answer. libtds applies every section whose name matches, whatever its case, so two connections that -/// describe one host name differently, with another port or another mode, cannot both be in the file: the second waits -/// until the first dbopen returns. Every -/// change replaces the file whole, which leaves a dbopen that already opened it reading the version it opened, and the -/// file is removed once nothing holds an entry. +/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries with +/// different names sit side by side, and a connection never waits on one with another name, however long that server +/// takes to answer. libtds applies every section whose name matches, whatever its case, so two entries that share a +/// name and differ in anything else cannot both be in the file: the later one waits until the dbopen holding the name +/// returns. Connections to one name take it in the order they asked, so identical connections that keep arriving +/// cannot keep a different one waiting for ever. A wait has a limit, and a connection whose caller gave up leaves +/// the line once `interruptWaits` wakes it. Every change replaces the file whole, which leaves a dbopen that already +/// opened it reading the version it opened, and the file is removed once nothing holds an entry. public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { public let path: String private let condition = NSCondition() private var leases: [String: Lease] = [:] + private var queues: [String: [Int]] = [:] + private var nextTicket = 0 private struct Lease { let entry: MSSQLFreeTDSServerEntry @@ -29,23 +32,77 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { self.path = path } - public func withEntry(_ entry: MSSQLFreeTDSServerEntry, _ body: () throws -> T) throws -> T { - try acquire(entry) + public func withEntry( + _ entry: MSSQLFreeTDSServerEntry, + waitingAtMost timeout: TimeInterval, + givingUpWhen isAbandoned: () -> Bool = { false }, + _ body: () throws -> T + ) throws -> T { + try acquire(entry, waitingUntil: Date(timeIntervalSinceNow: timeout), givingUpWhen: isAbandoned) defer { release(entry) } return try body() } + /// Wakes every waiting connection so one whose caller has given up can leave the line. + public func interruptWaits() { + condition.lock() + condition.broadcast() + condition.unlock() + } + + internal func waitingConnections(named name: String) -> Int { + condition.lock() + defer { condition.unlock() } + return queues[name.lowercased()]?.count ?? 0 + } + private static func key(for entry: MSSQLFreeTDSServerEntry) -> String { entry.name.lowercased() } - private func acquire(_ entry: MSSQLFreeTDSServerEntry) throws { + private func acquire( + _ entry: MSSQLFreeTDSServerEntry, + waitingUntil deadline: Date, + givingUpWhen isAbandoned: () -> Bool + ) throws { let key = Self.key(for: entry) condition.lock() defer { condition.unlock() } - while let lease = leases[key], lease.entry != entry { - condition.wait() + let ticket = nextTicket + nextTicket += 1 + queues[key, default: []].append(ticket) + defer { + leaveQueue(key, ticket: ticket) + condition.broadcast() + } + try waitForTurn(ticket, entry: entry, key: key, until: deadline, givingUpWhen: isAbandoned) + try hold(entry, key: key) + } + + private func waitForTurn( + _ ticket: Int, + entry: MSSQLFreeTDSServerEntry, + key: String, + until deadline: Date, + givingUpWhen isAbandoned: () -> Bool + ) throws { + while !isAdmitted(ticket, entry: entry, key: key) { + if isAbandoned() { + throw CancellationError() + } + if !condition.wait(until: deadline), !isAdmitted(ticket, entry: entry, key: key) { + throw MSSQLFreeTDSConfigError.nameInUse(entry.name) + } } + } + + private func isAdmitted(_ ticket: Int, entry: MSSQLFreeTDSServerEntry, key: String) -> Bool { + guard queues[key]?.first == ticket else { return false } + guard let lease = leases[key] else { return true } + return lease.entry == entry + } + + private func hold(_ entry: MSSQLFreeTDSServerEntry, key: String) throws { if var lease = leases[key] { lease.holders += 1 leases[key] = lease @@ -56,11 +113,17 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { try write() } catch { leases[key] = nil - condition.broadcast() throw error } } + private func leaveQueue(_ key: String, ticket: Int) { + queues[key]?.removeAll { $0 == ticket } + if queues[key]?.isEmpty == true { + queues[key] = nil + } + } + private func release(_ entry: MSSQLFreeTDSServerEntry) { let key = Self.key(for: entry) condition.lock() diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift index 4a8ddd09c2..27af42d1fd 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift @@ -5,8 +5,8 @@ import Foundation /// 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. +/// default realm. Supplying the realm-qualified SPN as the `spn` of the connection's freetds.conf +/// entry makes Heimdal request the service ticket from the correct realm. public enum MSSQLKerberosSPN { /// Returns `MSSQLSvc/:@`, or `nil` when no realm is set (letting FreeTDS keep /// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention. diff --git a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift index c328647d2b..2ddb12567d 100644 --- a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift +++ b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift @@ -127,6 +127,19 @@ struct CancellableBlockingWorkTests { #expect(discardCount.value <= 1) } } + + @Test("A gate reports itself settled once the caller has an answer, whichever side gave it") + func gateReportsSettled() { + let won = SingleResumeGate() + #expect(!won.isSettled) + #expect(won.win(1)) + #expect(won.isSettled) + + let failed = SingleResumeGate() + failed.fail(CancellationError()) + #expect(failed.isSettled) + #expect(!failed.win(1)) + } } private final class CountBox: @unchecked Sendable { diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift index ec2640e479..66fe89a8d5 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift @@ -1,5 +1,5 @@ import Foundation -import TableProMSSQLCore +@testable import TableProMSSQLCore import Testing @Suite("MSSQL FreeTDS config file") @@ -34,7 +34,7 @@ struct MSSQLFreeTDSConfigFileTests { @Test("The entry is in the file while the body runs, and the file is gone after") func entryLivesForTheBody() throws { let server = try entry("db.example.com") - let seen = try file.withEntry(server) { contents() } + let seen = try file.withEntry(server, waitingAtMost: 10) { contents() } #expect(seen == server.text) #expect(!FileManager.default.fileExists(atPath: file.path)) @@ -42,7 +42,7 @@ struct MSSQLFreeTDSConfigFileTests { @Test("Only the owner can read the file") func fileIsPrivate() throws { - let permissions = try file.withEntry(try entry("db.example.com")) { + let permissions = try file.withEntry(try entry("db.example.com"), waitingAtMost: 10) { try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int } @@ -54,8 +54,8 @@ struct MSSQLFreeTDSConfigFileTests { let first = try entry("alpha.example.com", encryption: .request) let second = try entry("beta.example.com", encryption: .require) - let seen = try file.withEntry(first) { - try file.withEntry(second) { contents() } + let seen = try file.withEntry(first, waitingAtMost: 10) { + try file.withEntry(second, waitingAtMost: 10) { contents() } } #expect(seen?.contains(first.text) == true) @@ -66,8 +66,8 @@ struct MSSQLFreeTDSConfigFileTests { func sharedEntryOutlivesTheInnerHolder() throws { let server = try entry("db.example.com") - let afterInner = try file.withEntry(server) { - try file.withEntry(server) {} + let afterInner = try file.withEntry(server, waitingAtMost: 10) { + try file.withEntry(server, waitingAtMost: 10) {} return contents() } @@ -84,7 +84,7 @@ struct MSSQLFreeTDSConfigFileTests { let holding = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(lower) { + try? file.withEntry(lower, waitingAtMost: 10) { order.append("lower in") holding.signal() released.wait() @@ -94,7 +94,7 @@ struct MSSQLFreeTDSConfigFileTests { holding.wait() let done = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(upper) { order.append("upper in") } + try? file.withEntry(upper, waitingAtMost: 10) { order.append("upper in") } done.signal() } Thread.sleep(forTimeInterval: 0.2) @@ -115,7 +115,7 @@ struct MSSQLFreeTDSConfigFileTests { let seenByEncrypted = Box(nil) DispatchQueue.global().async { - try? file.withEntry(plain) { + try? file.withEntry(plain, waitingAtMost: 10) { order.append("plain in") holding.signal() released.wait() @@ -124,7 +124,7 @@ struct MSSQLFreeTDSConfigFileTests { } holding.wait() DispatchQueue.global().async { - try? file.withEntry(encrypted) { + try? file.withEntry(encrypted, waitingAtMost: 10) { order.append("encrypted in") seenByEncrypted.value = contents() } @@ -148,32 +148,183 @@ struct MSSQLFreeTDSConfigFileTests { let finished = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(slow) { + try? file.withEntry(slow, waitingAtMost: 10) { holding.signal() released.wait() } finished.signal() } holding.wait() - let ranWhileSlowHeld = try file.withEntry(fast) { contents()?.contains(fast.text) == true } + let ranWhileSlowHeld = try file.withEntry(fast, waitingAtMost: 10) { contents()?.contains(fast.text) == true } released.signal() finished.wait() #expect(ranWhileSlowHeld) } + @Test("Connects to two ports on one address, as every SSH tunnel is, do not wait on each other") + func portsOnOneAddressDoNotWait() throws { + let silent = try entry("127.0.0.1", port: 50_001) + let live = try entry("127.0.0.1", port: 50_002, encryption: .request) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let silentFinished = DispatchSemaphore(value: 0) + let liveFinished = DispatchSemaphore(value: 0) + let seen = Box(nil) + + DispatchQueue.global().async { + try? file.withEntry(silent, waitingAtMost: 10) { + holding.signal() + released.wait() + } + silentFinished.signal() + } + holding.wait() + DispatchQueue.global().async { + try? file.withEntry(live, waitingAtMost: 10) { seen.value = contents() } + liveFinished.signal() + } + let liveRanWhileSilentHeld = liveFinished.wait(timeout: .now() + 2) == .success + released.signal() + silentFinished.wait() + if !liveRanWhileSilentHeld { + liveFinished.wait() + } + + #expect(liveRanWhileSilentHeld) + #expect(seen.value?.contains(silent.text) == true) + #expect(seen.value?.contains(live.text) == true) + } + + @Test("A connect waiting for a name goes before a later one that matches the entry holding it") + func waitingEntryIsNotOvertaken() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let order = OrderLog() + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let done = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + order.append("first plain in") + holding.signal() + released.wait() + order.append("first plain out") + } + } + holding.wait() + DispatchQueue.global().async { + try? file.withEntry(encrypted, waitingAtMost: 10) { order.append("encrypted in") } + done.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 1 }) + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { order.append("second plain in") } + done.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 2 }) + #expect(order.entries == ["first plain in"]) + released.signal() + done.wait() + done.wait() + + #expect(order.entries == ["first plain in", "first plain out", "encrypted in", "second plain in"]) + } + + @Test("A connect that cannot have the name in time gives up with the reason and leaves the line") + func boundedWaitGivesUp() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let finished = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + holding.signal() + released.wait() + } + finished.signal() + } + holding.wait() + #expect(throws: MSSQLFreeTDSConfigError.nameInUse("db.example.com")) { + try file.withEntry(encrypted, waitingAtMost: 0.2) {} + } + let waitingAfterGivingUp = file.waitingConnections(named: "db.example.com") + let joinedTheHolder = try file.withEntry(plain, waitingAtMost: 0.2) { true } + released.signal() + finished.wait() + + #expect(waitingAfterGivingUp == 0) + #expect(joinedTheHolder) + } + + @Test("A connect whose caller gave up leaves the line as soon as the waits are interrupted") + func abandonedWaitLeavesTheLine() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let holderFinished = DispatchSemaphore(value: 0) + let waiterFinished = DispatchSemaphore(value: 0) + let abandoned = Box(false) + let waiterError = Box(nil) + let ran = Box(false) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + holding.signal() + released.wait() + } + holderFinished.signal() + } + holding.wait() + DispatchQueue.global().async { + do { + try file.withEntry(encrypted, waitingAtMost: 30, givingUpWhen: { abandoned.value }) { ran.value = true } + } catch { + waiterError.value = error + } + waiterFinished.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 1 }) + abandoned.value = true + file.interruptWaits() + let leftInTime = waiterFinished.wait(timeout: .now() + 2) == .success + released.signal() + holderFinished.wait() + if !leftInTime { + waiterFinished.wait() + } + + #expect(leftInTime) + #expect(waiterError.value is CancellationError) + #expect(!ran.value) + #expect(file.waitingConnections(named: "db.example.com") == 0) + } + @Test("A file that cannot be written fails the connect and runs nothing") func unwritableFileThrows() throws { let missing = MSSQLFreeTDSConfigFile(path: (directory as NSString).appendingPathComponent("no/such/freetds.conf")) var ran = false #expect(throws: MSSQLFreeTDSConfigError.self) { - try missing.withEntry(try entry("db.example.com")) { ran = true } + try missing.withEntry(try entry("db.example.com"), waitingAtMost: 10) { ran = true } } #expect(!ran) } } +private func waitUntil(_ condition: () -> Bool) -> Bool { + let deadline = Date(timeIntervalSinceNow: 5) + while !condition() { + guard Date() < deadline else { return false } + Thread.sleep(forTimeInterval: 0.01) + } + return true +} + private final class OrderLog: @unchecked Sendable { private let lock = NSLock() private var recorded: [String] = [] diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index e6276f435d..c76131a5d8 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -288,38 +288,39 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } func connect() async throws { - let gate = SingleResumeGate() - let isKerberos = options.authMethod == .windows - let deadline = DispatchTimeInterval.seconds(options.loginTimeoutSeconds + Self.connectDeadlineMarginSeconds) - - Self.deadlineQueue.asyncAfter(deadline: .now() + deadline) { - gate.fail(MSSQLCoreError.connectionTimedOut(isKerberos: isKerberos)) - } + let attempt = SingleResumeGate() try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - gate.install(continuation, alreadyCancelled: Task.isCancelled) + attempt.install(continuation, alreadyCancelled: Task.isCancelled) queue.async { [self] in do { - let proc = try openConnection() - if gate.win(()) { + let proc = try openConnection(for: attempt) + if attempt.win(()) { adopt(proc) } else { teardown(proc) } } catch { - gate.fail(error) + attempt.fail(error) } } } } onCancel: { - gate.fail(CancellationError()) + attempt.fail(CancellationError()) + freetdsConfigFile.interruptWaits() } } - /// db-lib reads the encryption level and the certificate checks from freetds.conf and from nowhere else, so the - /// server is described there rather than on the login. - private func openConnection() throws -> UnsafeMutablePointer { + /// db-lib reads the encryption level, the certificate checks and the service principal from freetds.conf and from + /// nowhere else, so the server is described there rather than on the login. Waiting for the entry and logging in + /// are bounded apart, each by the login timeout: the wait is for another connection's dbopen to the same server + /// name, and a single deadline over both would fail this one as a timeout without ever trying it. + /// + /// The Kerberos ticket cache handed over for this connect is deleted here, however the connect ends, because this + /// is the one place that runs to the end of the attempt: the caller can give up while dbopen still reads the cache. + private func openConnection(for attempt: SingleResumeGate) throws -> UnsafeMutablePointer { + defer { discardKerberosCache() } let entry: MSSQLFreeTDSServerEntry do { entry = try MSSQLFreeTDSServerEntry(options: options) @@ -334,11 +335,17 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { let opened: UnsafeMutablePointer? do { - opened = try freetdsConfigFile.withEntry(entry) { + opened = try freetdsConfigFile.withEntry( + entry, + waitingAtMost: TimeInterval(connectDeadlineSeconds), + givingUpWhen: { attempt.isSettled } + ) { + guard !attempt.isSettled else { throw CancellationError() } + armDeadline(for: attempt) freetdsClearError(for: nil) return withKerberosEnvironmentIfNeeded { dbopen(login, entry.name) } } - } catch { + } catch let error as MSSQLFreeTDSConfigError { throw MSSQLCoreError.connectionFailed(error.localizedDescription) } guard let proc = opened else { @@ -347,6 +354,22 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { return proc } + private var connectDeadlineSeconds: Int { + options.loginTimeoutSeconds + Self.connectDeadlineMarginSeconds + } + + private func armDeadline(for attempt: SingleResumeGate) { + let isKerberos = options.authMethod == .windows + Self.deadlineQueue.asyncAfter(deadline: .now() + .seconds(connectDeadlineSeconds)) { + attempt.fail(MSSQLCoreError.connectionTimedOut(isKerberos: isKerberos)) + } + } + + private func discardKerberosCache() { + guard let cachePath = options.kerberosCachePath else { return } + try? FileManager.default.removeItem(atPath: cachePath) + } + private func configure(_ login: UnsafeMutablePointer) throws { for parameter in MSSQLLoginParameters.build( user: options.user, @@ -377,20 +400,6 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { ) } } - - #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 { - guard dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL)) == SUCCEED else { - throw MSSQLCoreError.connectionFailed( - String(localized: "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes.") - ) - } - } - #endif } private func openFailure() -> MSSQLCoreError { @@ -419,7 +428,6 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { unsetenv("KRB5CCNAME") } Self.kerberosEnvLock.unlock() - try? FileManager.default.removeItem(atPath: cachePath) } return body() } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift index d598f2535e..b28702dbed 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift @@ -6,8 +6,8 @@ import TableProMSSQLCore /// /// 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`. +/// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN as the `spn` +/// of the connection's freetds.conf entry. /// /// 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 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 84e44a4688..77f3bc12d6 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -356,6 +356,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let conn: FreeTDSConnection do { let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) + var connectionOwnsKerberosCache = false + defer { + if !connectionOwnsKerberosCache, let kerberosCachePath { + try? FileManager.default.removeItem(atPath: kerberosCachePath) + } + } let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod) let fedAuthToken = try await resolveEntraTokenIfNeeded(authMethod: authMethod) var options = MSSQLConnectionOptions( @@ -374,6 +380,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { options.caCertificatePath = config.ssl.caCertificatePath options.fedAuthToken = fedAuthToken conn = FreeTDSConnection(options: options) + connectionOwnsKerberosCache = true try await conn.connect() } catch let error as MSSQLCoreError { switch error { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9656139ca9..e6018b2de4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184091,9 +184091,6 @@ }, "FreeTDS could not set up the login." : { - }, - "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes." : { - }, "The user name is longer than the 128 bytes FreeTDS takes." : { @@ -184112,6 +184109,12 @@ }, "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + }, + "The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a shorter host name." : { + + }, + "Another connection to %@ with other settings is still logging in. Try again once it finishes." : { + }, "The FreeTDS configuration could not be written: %@" : { diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 9da753336b..e271d6421a 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -21075,6 +21075,12 @@ }, "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + }, + "The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a shorter host name." : { + + }, + "Another connection to %@ with other settings is still logging in. Try again once it finishes." : { + }, "The FreeTDS configuration could not be written: %@" : { diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift index 2cae68258f..8b702323f8 100644 --- a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift +++ b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift @@ -73,14 +73,94 @@ struct MSSQLFreeTDSConfigTests { ]) } - @Test("Every entry states the authority and the hostname check, so a section named global cannot lend its own") + @Test("An IP address is named with its port, so connects to other ports on it, every tunnel among them, never share a name") + func addressNamedWithItsPort() throws { + let cases: [(host: String, port: Int, name: String, dialled: String)] = [ + ("127.0.0.1", 54_321, "127.0.0.1,54321", "127.0.0.1"), + ("10.0.0.5", 1_433, "10.0.0.5,1433", "10.0.0.5"), + ("::1", 1_433, "::1,1433", "::1"), + ("[::1]", 1_433, "::1,1433", "::1"), + ("fe80::1%en0", 14_330, "fe80::1%en0,14330", "fe80::1%en0") + ] + for (host, port, name, dialled) in cases { + let server = try entry(host: host, port: port, mode: .required) + #expect(server.name == name, "\(host)") + #expect(lines(server).prefix(3) == ["[\(name)]", "host = \(dialled)", "port = \(port)"], "\(host)") + } + } + + @Test("A host name in brackets is read without them, as libtds reads one") + func bracketsAroundAHostAreDropped() throws { + let server = try entry(host: "[db.example.com]", mode: .required) + + #expect(server.name == "db.example.com") + #expect(lines(server).prefix(2) == ["[db.example.com]", "host = db.example.com"]) + } + + @Test("Every entry states the authority, the hostname check and the service principal, so a section named global cannot lend its own") func everyEntryIsSelfContained() throws { for mode in SSLMode.allCases { let options = lines(try entry(mode: mode)).dropFirst().map { $0.split(separator: "=", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces) } ?? "" } - #expect(options == ["host", "port", "tds version", "encryption", "ca file", "check certificate hostname"], - "\(mode)") + #expect(options == [ + "host", "port", "tds version", "encryption", "ca file", "check certificate hostname", "spn" + ], "\(mode)") + } + } + + @Test("Windows Authentication writes its service principal into the entry, past the 128 bytes a login field takes") + func servicePrincipalIsWritten() throws { + let host = "sql-prod-availability-group-listener-001.finance.emea.corp.contoso-international.com" + let principal = "MSSQLSvc/\(host):1433@CORP.CONTOSO-INTERNATIONAL.COM" + let options = MSSQLConnectionOptions( + host: host, + user: "", + password: "", + database: "app", + authMethod: .windows, + kerberosServicePrincipal: principal + ) + + let server = try MSSQLFreeTDSServerEntry(options: options) + + #expect(principal.utf8.count > 128) + #expect(server.servicePrincipal == principal) + #expect(lines(server).last == "spn = \(principal)") + } + + @Test("A service principal is written only for Windows Authentication") + func servicePrincipalNeedsWindowsAuthentication() throws { + let options = MSSQLConnectionOptions( + host: "db.example.com", + user: "sa", + password: "secret", + database: "app", + kerberosServicePrincipal: "MSSQLSvc/db.example.com:1433@EXAMPLE.COM" + ) + + let server = try MSSQLFreeTDSServerEntry(options: options) + + #expect(server.servicePrincipal == nil) + #expect(lines(server).last == "spn =") + } + + @Test("A service principal libtds would cut short or reshape is refused", arguments: [ + "MSSQLSvc/" + String(repeating: "h", count: 240) + ":1433@EXAMPLE.COM", + "MSSQLSvc/db.example.com:1433@EXAMPLE;COM", + "MSSQLSvc/db.example.com:1433@EXAMPLE#COM", + "MSSQLSvc/db.example.com:1433\n\tencryption = off" + ]) + func unreadableServicePrincipals(principal: String) { + #expect(throws: MSSQLFreeTDSConfigError.unreadableServicePrincipal) { + try MSSQLFreeTDSServerEntry( + host: "db.example.com", + port: 1_433, + encryption: .require, + verification: .none, + caCertificatePath: nil, + servicePrincipal: principal + ) } } @@ -147,7 +227,8 @@ struct MSSQLFreeTDSConfigTests { "tds version = 7.4", "encryption = require", "ca file = /certs/corp.pem", - "check certificate hostname = yes" + "check certificate hostname = yes", + "spn =" ]) } @@ -162,7 +243,10 @@ struct MSSQLFreeTDSConfigTests { "db.example.com\n\tencryption = off", "db.example.com\r", "db example.com", - "[db.example.com]", + "[db.example.com", + "db.example.com]", + "[]", + "[[::1]]", "db=example.com", "db.example.com;comment", "db.example.com#comment", @@ -174,8 +258,8 @@ struct MSSQLFreeTDSConfigTests { } } - @Test("Host names and IP addresses are written as given", arguments: [ - "localhost", "127.0.0.1", "::1", "fe80::1%en0", "sql-01.corp.example.com", "MyServer" + @Test("Host names are written as given", arguments: [ + "localhost", "sql-01.corp.example.com", "MyServer", "myserver.database.windows.net" ]) func readableHosts(host: String) throws { #expect(try entry(host: host, mode: .required).name == host) diff --git a/scripts/check-mssql-encryption.sh b/scripts/check-mssql-encryption.sh index a35657e560..47e2ab931d 100755 --- a/scripts/check-mssql-encryption.sh +++ b/scripts/check-mssql-encryption.sh @@ -14,8 +14,13 @@ # - Verify CA and Verify Identity read TRUE with the authority that signed the server's certificate, and are refused # without it. Verify Identity is refused for a host name the certificate does not carry, and Verify CA is not. # - Connections to one host with different modes, opened at the same time, each get their own mode. -# - A connect to a server that never answers does not hold up a connect to another host. +# - A connect to a server that never answers does not hold up a connect to another host, nor one to another port on +# the same address, which is what every SSH tunnel on 127.0.0.1 is. +# - A connect that waits for another connect to the same host name logs in once that one ends, and one that waits +# longer than its login timeout gives up saying another connection holds the name, not that the server timed out. # - A password longer than db-lib takes fails the connect instead of logging in without one. +# - A Windows Authentication connect deletes the Kerberos ticket cache it was handed however it fails, and a service +# principal longer than the 128 bytes a login field takes reaches Kerberos. # - Against a server that cannot encrypt, which the check plays itself, Disabled and Preferred connect and Required is # refused, and only Required asks for encryption in the prelogin. # @@ -166,6 +171,7 @@ MANIFEST cat > "$WORK/Sources/Check/Check.swift" << 'SWIFT' import Darwin import Foundation +import TableProMSSQLCore import TableProPluginKit @main @@ -255,7 +261,11 @@ enum Check { await verifyingModesCheckTheCertificate() await concurrentModesOnOneHost() try await unansweredConnectHoldsUpNoOtherHost() + try await unansweredConnectHoldsUpNoOtherPortOnTheAddress() + try await waitForAHostNameIsBoundedAndSaysWhy() await overlongPasswordFailsTheConnect() + await kerberosCacheIsDeletedWhateverEndsTheConnect() + await longServicePrincipalReachesKerberos() await serverThatCannotEncrypt() } @@ -313,27 +323,15 @@ enum Check { } static func unansweredConnectHoldsUpNoOtherHost() async throws { - let listener = socket(AF_INET, SOCK_STREAM, 0) - var address = sockaddr_in() - address.sin_family = sa_family_t(AF_INET) - address.sin_addr.s_addr = inet_addr("127.0.0.1") - var length = socklen_t(MemoryLayout.size) - let bound = withUnsafeMutablePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in - bind(listener, generic, length) == 0 && listen(listener, 8) == 0 - && getsockname(listener, generic, &length) == 0 - } - } - guard bound else { + guard let listener = SilentListener() else { expect(false, "a listener that never answers could be opened") return } - let silentPort = Int(UInt16(bigEndian: address.sin_port)) let silentHost = host == "127.0.0.1" ? "localhost" : "127.0.0.1" let silentFinished = Flag() let silent = Task { - let answer = await outcome(config(.required, host: silentHost, port: silentPort)) + let answer = await outcome(config(.required, host: silentHost, port: listener.port)) silentFinished.set() return answer } @@ -342,7 +340,7 @@ enum Check { let live = await outcome(config(.required)) let elapsed = Date().timeIntervalSince(started) let silentStillWaiting = !silentFinished.isSet - close(listener) + listener.close() let silentOutcome = await silent.value expect(live == "TRUE" && elapsed < 10 && silentStillWaiting, @@ -352,12 +350,158 @@ enum Check { "got \(silentOutcome)") } + static func unansweredConnectHoldsUpNoOtherPortOnTheAddress() async throws { + guard host == "127.0.0.1" else { + print("SKIP: connects to other ports on one address need the server on 127.0.0.1") + return + } + guard let first = SilentListener(), let second = SilentListener() else { + expect(false, "two listeners that never answer could be opened") + return + } + let silentFinished = Flag() + let silent = Task { + async let one = outcome(config(.preferred, host: "127.0.0.1", port: first.port)) + async let two = outcome(config(.required, host: "127.0.0.1", port: second.port)) + let answers = await [one, two] + silentFinished.set() + return answers + } + try await Task.sleep(nanoseconds: 1_500_000_000) + let started = Date() + let live = await outcome(config(.required)) + let elapsed = Date().timeIntervalSince(started) + let silentStillWaiting = !silentFinished.isSet + first.close() + second.close() + let silentOutcomes = await silent.value + + expect(live == "TRUE" && elapsed < 10 && silentStillWaiting, + "a connect to 127.0.0.1:\(port) finishes while two to other ports on 127.0.0.1, which never answer, wait", + String(format: "live=%@ in %.1fs", live, elapsed)) + expect(silentOutcomes.allSatisfy { $0.hasPrefix("refused") }, + "the connects that never got an answer fail once their servers go", "got \(silentOutcomes)") + } + + static func waitForAHostNameIsBoundedAndSaysWhy() async throws { + guard host == "127.0.0.1" else { + print("SKIP: a wait for localhost needs the server on 127.0.0.1") + return + } + guard let first = SilentListener(), let second = SilentListener() else { + expect(false, "two listeners that never answer could be opened") + return + } + + let holder = Task { await outcome(config(.required, host: "localhost", port: first.port)) } + try await Task.sleep(nanoseconds: 1_000_000_000) + var started = Date() + let waiter = Task { await outcome(config(.required, host: "localhost")) } + try await Task.sleep(nanoseconds: 10_000_000_000) + first.close() + let waited = await waiter.value + var elapsed = Date().timeIntervalSince(started) + _ = await holder.value + expect(waited == "TRUE" && elapsed > 9, + "a connect to localhost that waits for another to localhost logs in once that one ends", + String(format: "live=%@ after %.1fs", waited, elapsed)) + + guard let third = SilentListener() else { + expect(false, "a third listener that never answers could be opened") + return + } + let holders = Task { + async let one = outcome(config(.required, host: "localhost", port: second.port)) + try? await Task.sleep(nanoseconds: 500_000_000) + async let two = outcome(config(.required, host: "localhost", port: third.port)) + return await [one, two] + } + try await Task.sleep(nanoseconds: 1_500_000_000) + started = Date() + let starved = await outcome(config(.required, host: "localhost")) + elapsed = Date().timeIntervalSince(started) + second.close() + third.close() + let holderOutcomes = await holders.value + + let limit = Double(MSSQLConnectionOptions.defaultLoginTimeoutSeconds + 5) + expect(starved.contains("Another connection to localhost") && elapsed >= limit - 1 && elapsed < limit + 5, + "a connect to localhost behind two others that never answer gives up at its limit and says why", + String(format: "got %@ after %.1fs", starved, elapsed)) + expect(holderOutcomes.allSatisfy { $0.hasPrefix("refused") }, + "the connects that held localhost fail once their servers go", "got \(holderOutcomes)") + } + static func overlongPasswordFailsTheConnect() async { let seen = await outcome(config(.required, password: String(repeating: "p", count: 200))) expect(seen.hasPrefix("refused") && seen.contains("128 bytes"), "a password db-lib refuses fails the connect and says why", "got \(seen)") } + static func windowsOptions( + host: String = Check.host, + database: String = Check.database, + servicePrincipal: String? = nil, + cachePath: String? = nil + ) -> MSSQLConnectionOptions { + MSSQLConnectionOptions( + host: host, + port: port, + user: "", + password: "", + database: database, + encryptionLevel: .require, + authMethod: .windows, + kerberosCachePath: cachePath, + kerberosServicePrincipal: servicePrincipal + ) + } + + static func windowsConnectFailure(_ options: MSSQLConnectionOptions) async -> String? { + let connection = FreeTDSConnection(options: options) + do { + try await connection.connect() + connection.disconnect() + return nil + } catch { + return error.localizedDescription + } + } + + static func kerberosCacheIsDeletedWhateverEndsTheConnect() async { + let tooLongPrincipal = "MSSQLSvc/" + String(repeating: "h", count: 240) + ":\(port)@EXAMPLE.COM" + let cases: [(label: String, options: (String) -> MSSQLConnectionOptions)] = [ + ("a database name db-lib refuses", { windowsOptions(database: String(repeating: "d", count: 129), cachePath: $0) }), + ("a host FreeTDS cannot be given", { windowsOptions(host: "[\(host)", cachePath: $0) }), + ("a service principal FreeTDS cannot be given", + { windowsOptions(servicePrincipal: tooLongPrincipal, cachePath: $0) }), + ("a login Kerberos refuses", { windowsOptions(cachePath: $0) }) + ] + for (label, options) in cases { + let cachePath = (NSTemporaryDirectory() as NSString) + .appendingPathComponent("tablepro-krb5-check-\(UUID().uuidString)") + guard FileManager.default.createFile(atPath: cachePath, contents: Data("ticket".utf8)) else { + expect(false, "a stand-in ticket cache could be written") + return + } + let failure = await windowsConnectFailure(options(cachePath)) + let survived = FileManager.default.fileExists(atPath: cachePath) + try? FileManager.default.removeItem(atPath: cachePath) + expect(failure != nil && !survived, "the ticket cache is gone after \(label) fails the connect", + "failure=\(failure ?? "none") cache survived=\(survived)") + } + } + + static func longServicePrincipalReachesKerberos() async { + let principal = "MSSQLSvc/sql-prod-availability-group-listener-001.finance.emea.corp.contoso-international.com" + + ":\(port)@CORP.CONTOSO-INTERNATIONAL.COM" + let failure = await windowsConnectFailure(windowsOptions(servicePrincipal: principal)) ?? "connected" + let refusedBeforeKerberos = failure.contains("128 bytes") || failure.contains("cannot be passed to FreeTDS") + expect(principal.utf8.count > 128 && failure != "connected" && !refusedBeforeKerberos, + "a \(principal.utf8.count)-byte service principal is handed to Kerberos, which fails without a ticket", + "got \(failure)") + } + static func connectFailure(_ config: DriverConnectionConfig) async -> String? { let driver = MSSQLPluginDriver(config: config) do { @@ -515,6 +659,37 @@ final class ServerWithoutEncryption: @unchecked Sendable { } } +/// Takes connections on 127.0.0.1 and never answers, which is what a tunnel to a server that has gone quiet does. +/// Closing it resets every connection still waiting on it. +final class SilentListener: @unchecked Sendable { + let port: Int + private let descriptor: Int32 + + init?() { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(fd, generic, length) == 0 && listen(fd, 8) == 0 && getsockname(fd, generic, &length) == 0 + } + } + guard bound else { + Darwin.close(fd) + return nil + } + descriptor = fd + port = Int(UInt16(bigEndian: address.sin_port)) + } + + func close() { + Darwin.close(descriptor) + } +} + final class Flag: @unchecked Sendable { private let lock = NSLock() private var raised = false