Skip to content

SOCKS5 proxy support (and federated auth) - #174

Open
noozo wants to merge 5 commits into
elixir-ecto:masterfrom
noozo:master
Open

noozo wants to merge 5 commits into
elixir-ecto:masterfrom
noozo:master

Conversation

@noozo

@noozo noozo commented Oct 2, 2025 •

Copy link
Copy Markdown

Adds SOCKS5 proxy connections and Microsoft Entra ID access-token authentication for Azure SQL and Microsoft Fabric SQL endpoints. Also adds an optional response-size limit and fixes connection cleanup and disconnect errors.

Changes

  • Route TCP connections through a SOCKS5 proxy using :proxy_host, :proxy_port (default 1080), and optional :proxy_username / :proxy_password credentials.
  • Accept :access_token as a string, zero-arity function, or MFA tuple. Token providers run on each connection attempt, with bounded execution and sanitized failures. Implement the FEDAUTH Security Token workflow, require TLS, redact credentials, and preserve authentication across gateway redirects.
  • Add :max_response_bytes to cap buffered query responses. Exceeding the limit closes the connection and returns Tds.ResponseTooLargeError.
  • Close TCP/TLS connections after failed logins and redirects, and return DBConnection.ConnectionError for closed sockets.
  • Document federated authentication, TLS configuration, and response limits.

Validation

  • 76 local unit and fake-server tests pass, covering LOGIN7 encoding, token providers, federated login and redirects, TLS lifecycle, disconnects, and response limits.
  • mix compile --warnings-as-errors, mix format --check-formatted, and mix deps.unlock --check-unused pass with Elixir 1.18.3 / OTP 27.
  • SQL Server integration tests were not run locally: Docker and sqlcmd are unavailable. Live Azure SQL / Fabric authentication was not validated in this run.
  • The original SOCKS5 implementation was reported working with an Oxylabs proxy.

Credit to @raul-gracia for the federated authentication contribution.

noozo and others added 3 commits October 2, 2025 16:37
…ycle fixes (#1)

* Support Entra ID access token login (TDS FEDAUTH SecurityToken)

Add an opt-in :access_token connection option (string, 0-arity fun or
MFA, resolved on every connect so pooled connections pick up refreshed
tokens). When set, LOGIN7 sets fExtension and points ibExtension at a
FeatureExt block carrying the FEDAUTH feature (bFedAuthLibrary
SecurityToken, fFedAuthEcho mirroring the server's PRELOGIN
FEDAUTHREQUIRED response, UTF-16LE token), with empty username and
password. The token is refused unless ssl is enabled.

FEATUREEXTACK is now decoded and ignored during login, and FEDAUTHINFO
fails the login with a clear error since tokens are passed up front.

Without :access_token the PRELOGIN and LOGIN7 packets are unchanged.

* Document the :access_token option

* Test LOGIN7 split across three packets for a 6000 character token

* Add max_response_bytes option to cap buffered responses

When set, msg_recv counts the bytes read from the socket for the
current response (packet headers included). Once the count goes over
the limit it stops reading, closes the socket and returns
{:disconnect, %Tds.Error{message: "response exceeded max_response_bytes (N)"}, state},
so DBConnection drops the connection and the caller gets the error.
The login path and transaction manager requests handle it too.

Without the option nothing changes.

* Return Tds.ResponseTooLargeError when max_response_bytes is exceeded

The error carries the configured limit and the bytes read so far, so
callers can match on the struct instead of a message string. Tds.query
returns {:error, %Tds.ResponseTooLargeError{limit: n, received: bytes}}
and the connection is closed. Document the option in the README.

* Return a proper disconnect when a send or receive fails

msg_send wrapped receive failures as {0, {:disconnect, ...}} and passed
send failures through as {:error, reason}, so DBConnection raised
"bad return value" and logged the whole protocol state. Send and
receive failures now return {:disconnect, %Tds.Error{}, state}, and the
callers pass that through: decode (the SET batch after login), ping,
transactions, and connect, which closes the socket and returns
{:error, exception}. A failed routing redirect returns a 3-tuple error.

Tests use a loopback fake server that closes the socket mid-response or
stops answering until the DBConnection timeout.

* Keep the access token out of inspectable connection state

The resolved token now lives in a dedicated state field that is cleared
after login, and opts carry :REDACTED from the moment the token is
resolved. Tds.Protocol implements Inspect to redact password,
access_token and proxy_password, so crash reports and DBConnection
errors never print credentials.

The fake test server can now negotiate TLS the way SQL Server does
(handshake wrapped in PRELOGIN packets), so the tests run the real
access token login over TLS and check that LOGIN7 carries the token
while logs from failed logins, closed sockets and timeouts do not.

* Stop the TLS transport normally and with its socket

Tds.Tls is linked to the connection process and stopped with :tcp_closed
when the server hung up, so a server-side close killed the pooled
connection process instead of coming back as a disconnect. It also
outlived its socket: disconnect/2 and the max_response_bytes close left
the GenServer running, one per disconnect.

The transport now monitors its socket and stops :normal when the socket
closes or the server hangs up. A routing redirect closes the gateway leg
before connecting to the routed host, so the gateway hanging up later
cannot take the routed connection down.

* Fail the connect when the access token function raises or hangs

A raising :access_token function or MFA crashed the connection process
from connect/1, so DBConnection never applied its backoff. Resolution now
runs in a monitored process bounded by :connect_timeout (15 seconds by
default). A raise, throw, exit or timeout returns
{:error, %Tds.Error{message: "access token could not be fetched: ..."}}
naming only the exception module, and the pool backs off and retries.

* Report a dead socket as DBConnection.ConnectionError

Send and receive failures surfaced as DBConnection.ConnectionError
before (through DBConnection's bad return handling) and had become
%Tds.Error{}, breaking `rescue DBConnection.ConnectionError`. They now
return {:disconnect, %DBConnection.ConnectionError{}, state} with
"tds connection closed (the pool may have closed it after a timeout):
<reason>", the way postgrex and myxql do, for queries, ping and login.

* Validate max_response_bytes and cap query responses only

The cap applied to every response, so a limit smaller than the login
exchange made every connect fail, and any value was accepted. start_link
and child_spec now raise ArgumentError unless the option is a positive
integer or nil, and the limit is set on the connection state once login
completes, leaving PRELOGIN, LOGIN7 and the session SET batch
unbounded. The README notes that over TLS the count is decrypted bytes.

The tests now use the shared fake server and assert received > limit
instead of an exact byte count.

* Cover the redirect token, ssl check, FEDAUTH echo and live state

Mutations of these paths survived the suite. New tests check that a
routing redirect sends the token to the routed server, that a token is
refused when :ssl is nil or omitted, that LOGIN7 echoes the server's
FEDAUTHREQUIRED value (the fake server can now send 0 or 1), that a
close after the connection SET batch fails the connect with a
ConnectionError, and that the driver state of a live pooled connection
never shows the token or password.

* Fail a token login the server did not acknowledge as federated

The login parser skipped FEATUREEXTACK, so a server that ignored the
FEDAUTH feature still produced a "successful" token login. LOGINACK now
carries the acknowledged FeatureIds, and a token login without the
FEDAUTH acknowledgement fails with "server did not acknowledge federated
authentication", as tedious does. Password logins are unaffected.

The fake server returns {:error, reason} instead of crashing when the
client hangs up mid-exchange.

* Send the routed host as SNI after a gateway redirect

The redirect reused ssl_opts as they were, so a pinned
server_name_indication (as in the README's federated example) kept
naming the gateway on the routed connection, and the hostname check ran
against the wrong name. A pinned SNI is now replaced by the routed host.
The fake server reports the SNI it receives so the test can check both
legs.

* Warn when an access token is sent without certificate verification

A bearer token over TLS with verify: :verify_none can be intercepted by
anyone who can present a certificate. connect/1 now logs a warning when
the token is resolved with that setting. The routing redirect reuses the
token resolved for the connect instead of going through connect/1 again,
so the warning fires once per connect and the token is not put back
into opts.

* Redact ssl_opts keys and passwords when inspecting the state

Inspect for Tds.Protocol hid the top level credentials but printed
ssl_opts as given, including a client private key, its password and the
same inside certs_keys. Those are now shown as :REDACTED too.

* Close a failed TLS login through the TLS transport

When login failed after PRELOGIN had switched to TLS (wrong password, an
expired or unacknowledged token, a failed routed leg), connect closed
the raw TCP socket and left the ssl connection process running until the
connecting process died, one more per backoff retry. The error path now
closes the socket in use, like disconnect/2, which also stops Tds.Tls.

* Return the resolved token as a message, not an exit reason

The resolver process exited with {:access_token, {:ok, token}}, an
abnormal exit, so any process the token function linked to (a lazily
started cache or HTTP client) received the token in an exit signal, and
logged it when it trapped exits. The resolver now sends the result to
the connecting process and exits :normal. On timeout it is killed and a
result that raced the kill is dropped from the mailbox.

* Stop the token resolver when the connecting process dies

A token function still running when the connecting process died (pool
shutdown, a killed connection) kept its resolver process alive forever.
A small watcher now monitors both and kills the resolver if the
connecting process goes first.

* Keep the {:error, reason} values out of the token fetch error

An {:error, reason} from the token function was inspected verbatim into
the connect error, so a reason such as %{client_secret: "..."} ended up
in the logs. The error now names only an atom reason or the reason's
struct module, and says nothing more for any other term.

* Test that socket errors stop Tds.Tls normally and a slow resolver is killed

Two earlier fixes had no test: Tds.Tls stopping :normal on
tcp_error/ssl_error (and on tcp_closed/ssl_closed) after passing the
event on, and the token resolver being killed once connect_timeout
passes.

* Note that a stalled TLS handshake or login can block a connect

The TLS handshake runs with an :infinity timeout and the LOGIN7 response
is read without one. This predates the access token work, so it is only
documented in the federated authentication section for now.
@noozo noozo changed the title SOCKS5 proxy support SOCKS5 proxy support (and federated auth) Oct 6, 2026
noozo and others added 2 commits October 6, 2026 12:01
A Fabric warehouse refused every login from Tds. Four changes, each
checked against a live warehouse:

- PRELOGIN decoded the INSTOPT option as the encryption level, so a
  server that sends INSTOPT looked like it had encryption off.
- A server answering ENCRYPT_REQ was refused although the client asked
  for encryption. Both ENCRYPT_ON and ENCRYPT_REQ mean the whole
  connection is encrypted.
- PRELOGIN now sends TRACEID (connection id, activity id, sequence). A
  routed Fabric login without it fails with error 18456.
- The gateway routes to `host\instance`. The routed LOGIN7 named only
  the host, and the backend hung up without an error. It now names
  `host\instance`, as SqlClient and go-mssqldb do.
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.

2 participants