Skip to content

fix(plugin-mssql): delete the Kerberos ticket cache on every failed connect, pass long SPNs through freetds.conf, and stop tunneled connects waiting on each other - #3127

Merged
datlechin merged 5 commits into
mainfrom
fix/mssql-ssl-required
Sep 24, 2026
Merged

datlechin merged 5 commits into
mainfrom
fix/mssql-ssl-required

Conversation

@datlechin

Copy link
Copy Markdown
Member

Follow-up to #3109. That PR was merged before these review findings were fixed. This fixes three defects #3109 introduced, plus one released Kerberos bug the same change made fixable.

What was wrong

1. A failed connect left a Kerberos ticket cache on disk. With Windows Authentication and a stored principal and password, MSSQLKerberosCredentials.acquireTicket writes a ticket-granting ticket to $TMPDIR/tablepro-krb5-<UUID>. The only code that deleted it was the defer in withKerberosEnvironmentIfNeeded, and #3109 moved that call inside freetdsConfigFile.withEntry. Three new throws came before it: the entry refusing the host, dbsetlname refusing a field over 128 bytes, and a failed config write. Each failed connect left one more file holding a usable TGT, and so did each health-monitor reconnect.

2. An explicit Kerberos SPN over 128 bytes failed the connect. dbsetlname caps every LOGINREC string at 128 bytes (TDS_MAX_LOGIN_STR_SZ). That limit exists for LOGIN7 fields, but the SPN is never sent in LOGIN7. It only feeds gss_import_name. #3109 turned the refusal into a hard error. On 0.75 the refusal was silently dropped instead, so a cross-realm server with a long name failed with KDC_ERR_S_PRINCIPAL_UNKNOWN.

3. Every tunneled SQL Server connect waited on every other one. The freetds.conf lease was keyed by host name, and every SSH, Cloudflare and Cloud SQL tunnel hands the driver 127.0.0.1. A tunnel to a server that never answers held the name for its whole dbopen, 30 seconds. The app-owned deadline was armed before the wait, so a healthy connect queued behind two dead ones timed out without ever being tried. An identical connect could also join a held lease while a different one waited, so the waiter could starve.

What changed

  • FreeTDSConnection.openConnection owns the ticket cache once it has it. Its first statement is a defer that deletes the file, so it goes however the attempt ends. MSSQLPluginDriver.connect deletes it only while it still owns it, before the hand-off: an explicit connectionOwnsKerberosCache flag, set right before conn.connect(). It never deletes after the hand-off, because its catch can run on cancel or deadline while dbopen still reads the cache (MSSQL: cancelling a connect can't interrupt a blocked dbopen (Kerberos widens the window) #1889).
  • The SPN is written as the spn option of the connection's freetds.conf entry. That is FreeTDS's documented setting, and it has no 128 byte cap. Every entry states spn, empty or not, so a host named global cannot lend its own. An SPN that does not fit on a freetds.conf line (over 248 bytes) is refused with its own message. dbsetlname(DBSETSERVERPRINCIPAL) is gone.
  • An entry for an IP address is named address,port, the way SQL Server writes a server and port. The section name is what db-lib sends as the LOGIN7 server name, and FreeTDS sends no TLS SNI, so a host name has to stay bare for Azure SQL to route the login. An address names no server a gateway routes by, so the port can join the name there. Connects to different ports on one address never share a lease, and every tunnel is such a connect.
  • Connects to one name are admitted first come, first served, so a run of identical connects cannot keep a different one waiting.
  • Waiting for the entry and logging in are bounded apart, each by the login timeout plus the existing 5 second margin. A connect that waits that long gives up with Another connection to <name> with other settings is still logging in. rather than a server timeout. Cancel wakes a waiter (interruptWaits), which leaves the line at once and skips dbopen. This uses a new SingleResumeGate.isSettled.
  • A host written in brackets, like [::1], is read without them, as libtds itself does. 0.75 accepted it and fix(plugin-mssql): encrypt SQL Server connections set to Required (skip verify) #3109 refused it.

Why not the other shapes

  • dbopen("host:port") with a port-less [host] section was measured and rejected. When no section matches the full name, libtds falls back through $FREETDSCONF, ~/.freetds.conf and the compiled-in /tmp/freetds-macos-arm64/etc/freetds.conf (from strings Libs/libsybdb_arm64.a), and applies their [global] sections. That last path can be created by any local user.
  • host,port for host names too would remove every remaining wait, but it changes the LOGIN7 server name Azure SQL routes by. That could not be tested here, so host names keep the bare name. Connects to one host name with different ports or modes still take turns, now with a bounded wait and a clear reason.

Verification

  • Package tests (swift test): TableProMSSQLCoreTests 105/105, TableProCoreTypesTests 8/8. The tunnel-ports and first-come tests fail against origin/main sources. New tests cover a bounded wait giving up and leaving the line, and an abandoned wait leaving on interruptWaits.
  • verify.sh build PASS, verify.sh plugins PASS, verify.sh test MSSQLFreeTDSConfigTests MSSQLLoginParametersTests MSSQLSSLMappingTests StringCatalogIntegrityTests 63/63 PASS.
  • verify.sh lint on every changed Swift file: the only violations are three on untouched lines of MSSQLPlugin.swift, which is outside CI lint.
  • iOS app build (generic simulator, arm64) succeeded; check-ios-shared-isolation.py ok.
  • scripts/check-mssql-encryption.sh gains checks for tunnels on one address, a bounded wait on one host name, the ticket cache after four kinds of failure, and a 130-byte SPN. Runs against Azure SQL Edge 15:
    • this branch: 23/23 PASS on 127.0.0.1:14339, and 26/26 PASS against a second container that forces encryption and presents a certificate from its own CA for localhost (Verify CA and Verify Identity included);
    • the same script over origin/main driver sources: 6 FAIL. A healthy connect beside two silent tunnels on 127.0.0.1 timed out after 37.3s. A connect behind two others to localhost reported a server timeout after 35.8s. The ticket cache survived a 129-byte database, an unreadable host and an unwritable SPN. A 130-byte SPN was refused before Kerberos.
  • scripts/check-mssql-batch-results.sh: 50 PASS.
  • C probes against the shipped libsybdb_arm64.a: a [127.0.0.1,14339] section connects with encrypt_option TRUE and final server_name = 127.0.0.1,14339, and SQL Server accepts that login. [::1,14399] dials ::1. spn = <130-byte SPN> reaches gss_import_name intact (TDSDUMP using kerberos name MSSQLSvc/...@CORP.CONTOSO-INTERNATIONAL.COM). spn = left empty gives the default MSSQLSvc/127.0.0.1:14339.
  • After merging current main (fix(editor): tier every statement SQL Server runs without a semicolon, so Safe Mode sees each one #3110 to fix(plugins): import a SQL Server file with no GO line a statement at a time and bound the import failure alert #3125): verify.sh build PASS, verify.sh plugins PASS, swift test --filter TableProMSSQLCoreTests 105/105. The app-target MSSQL suites above ran before that merge; they could not run again locally because the XCTest daemon was stuck behind a pending system prompt, so PR CI covers them.
  • Review: Skill(code-review) (Codex is unavailable). Fixed from it: the unbounded lease wait, a cancelled waiter keeping its place, the sleep-ordered test, and an ordering rule held only by a comment.

Not covered

  • No KDC or Active Directory here, so a real cross-realm ticket was not obtained. The checks show the SPN reaches gss_import_name and the cache is removed.
  • No Azure SQL here. Host names keep the LOGIN7 server name they had, so Azure is unaffected by design. Only IP addresses change it.
  • Concurrent dbopens share one db-lib error slot (freetdsClearError(for: nil)), so two failing connects at once can swap their messages. 0.75 had this for every connect. It is left for its own change.
  • SQL Server flows need a server, so there is no TableProUITests automation.

Found while fixing #3078 (#3105).

# Conflicts:
#	CHANGELOG.md
#	Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift
#	TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h
…onnect, pass long SPNs through freetds.conf, and stop tunneled connects waiting on each other
@mintlify

mintlify Bot commented Sep 24, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 24, 2026, 7:32 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit e6678a9 into main Sep 24, 2026
6 of 8 checks passed
@datlechin
datlechin deleted the fix/mssql-ssl-required branch September 24, 2026 19:35

This branch was successfully deployed

1 active deployment
staging - docs — 540f5a35 Deployed Sep 24, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant