diff --git a/packages/gateway-v2/discovery_handler.go b/packages/gateway-v2/discovery_handler.go index efa917a8..bf04f16b 100644 --- a/packages/gateway-v2/discovery_handler.go +++ b/packages/gateway-v2/discovery_handler.go @@ -180,3 +180,7 @@ func writeRPCJSON(w http.ResponseWriter, status int, payload any) { func writeRPCError(w http.ResponseWriter, status int, message string) { writeRPCJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message}}) } + +func writeRPCErrorWithKind(w http.ResponseWriter, status int, message string, kind string) { + writeRPCJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message, Kind: kind}}) +} diff --git a/packages/gateway-v2/ssh_handler.go b/packages/gateway-v2/ssh_handler.go index a8a9f0c1..dd50c33f 100644 --- a/packages/gateway-v2/ssh_handler.go +++ b/packages/gateway-v2/ssh_handler.go @@ -44,6 +44,8 @@ type sshExecErrorResponse struct { type sshExecErrorBody struct { Message string `json:"message"` + // Set only by the test-connection handler; absent on every other RPC and on older gateways. + Kind string `json:"kind,omitempty"` } func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { @@ -53,16 +55,27 @@ func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { return ssh.ParsePrivateKey([]byte(privateKey)) } -func buildSSHExecAuth(env sshExecEnvelope) ([]ssh.AuthMethod, error) { +// The ssh package runs these callbacks only once the server offered the method, so onAttempt firing is what +// proves a credential was actually sent. +func buildSSHExecAuth(env sshExecEnvelope, onAttempt func()) ([]ssh.AuthMethod, error) { + if onAttempt == nil { + onAttempt = func() {} + } switch env.AuthMethod { case "password": - return []ssh.AuthMethod{ssh.Password(env.Password)}, nil + return []ssh.AuthMethod{ssh.PasswordCallback(func() (string, error) { + onAttempt() + return env.Password, nil + })}, nil case "public-key": signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } - return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil + return []ssh.AuthMethod{ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { + onAttempt() + return []ssh.Signer{signer}, nil + })}, nil case "certificate": signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase) if err != nil { @@ -80,14 +93,18 @@ func buildSSHExecAuth(env sshExecEnvelope) ([]ssh.AuthMethod, error) { if err != nil { return nil, fmt.Errorf("failed to create certificate signer: %w", err) } - return []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, nil + return []ssh.AuthMethod{ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { + onAttempt() + return []ssh.Signer{certSigner}, nil + })}, nil default: return nil, fmt.Errorf("invalid auth method: %s", env.AuthMethod) } } func doSSHExec(targetHost string, targetPort int, env sshExecEnvelope) (sshExecResult, error) { - authMethods, err := buildSSHExecAuth(env) + credentialOffered := false + authMethods, err := buildSSHExecAuth(env, func() { credentialOffered = true }) if err != nil { return sshExecResult{}, err } @@ -104,7 +121,11 @@ func doSSHExec(targetHost string, targetPort int, env sshExecEnvelope) (sshExecR Timeout: timeout, }) if err != nil { - return sshExecResult{}, fmt.Errorf("failed to dial target SSH server: %w", err) + err = fmt.Errorf("failed to dial target SSH server: %w", err) + if credentialOffered { + return sshExecResult{}, authFailure(err) + } + return sshExecResult{}, connectFailure(err) } defer client.Close() diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go new file mode 100644 index 00000000..c2d6097d --- /dev/null +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -0,0 +1,72 @@ +package gatewayv2 + +import ( + "errors" + "io" + "net" + "os" +) + +// A refused credential stops the heartbeat schedule; an unreachable target keeps retrying. Probes dial and then +// authenticate, so each tags the phase it failed in rather than the classification being read back out of the +// driver's error text, which would need new codes for every account type. +type testConnFailureKind string + +const ( + failureKindAuth testConnFailureKind = "auth" + failureKindTransport testConnFailureKind = "transport" + failureKindUnknown testConnFailureKind = "unknown" +) + +type probeError struct { + kind testConnFailureKind + err error +} + +func (e *probeError) Error() string { return e.err.Error() } +func (e *probeError) Unwrap() error { return e.err } + +func connectFailure(err error) error { + if err == nil { + return nil + } + return &probeError{kind: failureKindTransport, err: err} +} + +// A network error at this point is the connection dying mid-exchange, not the credential being refused. +func authFailure(err error) error { + if err == nil { + return nil + } + if isNetworkError(err) { + return &probeError{kind: failureKindTransport, err: err} + } + return &probeError{kind: failureKindAuth, err: err} +} + +func isNetworkError(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return true + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + return errors.Is(err, os.ErrDeadlineExceeded) +} + +func classifyTestConnFailure(err error) testConnFailureKind { + var probeErr *probeError + if errors.As(err, &probeErr) { + return probeErr.kind + } + return failureKindUnknown +} diff --git a/packages/gateway-v2/test_connection_failure_kind_test.go b/packages/gateway-v2/test_connection_failure_kind_test.go new file mode 100644 index 00000000..1f645501 --- /dev/null +++ b/packages/gateway-v2/test_connection_failure_kind_test.go @@ -0,0 +1,71 @@ +package gatewayv2 + +import ( + "errors" + "fmt" + "net" + "testing" +) + +func TestClassifyTestConnFailure(t *testing.T) { + cases := []struct { + name string + err error + want testConnFailureKind + }{ + { + name: "dial failure", + err: connectFailure(&net.OpError{Op: "dial", Err: errors.New("connection refused")}), + want: failureKindTransport, + }, + { + name: "refused credential", + err: authFailure(errors.New("password authentication failed for user \"pam\"")), + want: failureKindAuth, + }, + { + name: "connection dropped mid-authentication", + err: authFailure(&net.OpError{Op: "read", Err: errors.New("connection reset by peer")}), + want: failureKindTransport, + }, + { + name: "wrapped by a caller", + err: fmt.Errorf("test connection: %w", authFailure(errors.New("login failed"))), + want: failureKindAuth, + }, + { + name: "untagged", + err: errors.New("unsupported SQL dialect"), + want: failureKindUnknown, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := classifyTestConnFailure(tc.err); got != tc.want { + t.Fatalf("classifyTestConnFailure() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSSHPhaseDependsOnCredentialBeingOffered(t *testing.T) { + offered := false + methods, err := buildSSHExecAuth(sshExecEnvelope{AuthMethod: "password", Password: "pw"}, func() { offered = true }) + if err != nil { + t.Fatalf("buildSSHExecAuth: %v", err) + } + if len(methods) != 1 { + t.Fatalf("expected one auth method, got %d", len(methods)) + } + if offered { + t.Fatal("building the auth method must not count as offering a credential") + } +} + +func TestProbeErrorPreservesMessage(t *testing.T) { + const message = "redis authentication failed: WRONGPASS" + if got := authFailure(errors.New(message)).Error(); got != message { + t.Fatalf("Error() = %q, want %q", got, message) + } +} diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 7940425f..fec12c44 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -19,6 +19,7 @@ import ( "sync" "time" + mssqlhandler "github.com/Infisical/infisical-merge/packages/pam/handlers/mssql" "github.com/go-ldap/ldap/v3" "github.com/go-sql-driver/mysql" "github.com/jackc/pgx/v5" @@ -72,6 +73,11 @@ type sqlTestParams struct { SslEnabled bool `json:"sslEnabled"` SslRejectUnauthorized *bool `json:"sslRejectUnauthorized"` SslCertificate string `json:"sslCertificate"` + AuthMethod string `json:"authMethod"` // mssql only: "sql-login" | "ntlm" | "kerberos" + Domain string `json:"domain"` + Realm string `json:"realm"` + KdcAddress string `json:"kdcAddress"` + Spn string `json:"spn"` } type mongoTestParams struct { @@ -205,6 +211,34 @@ func openSQLTestDB(host string, port int, params sqlTestParams) (*sql.DB, error) // doSQLConnectionTest authenticates against the target SQL server and runs a trivial query func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlTestParams) error { + if err := dialTarget(ctx, host, port); err != nil { + return connectFailure(err) + } + + // The database/sql driver has no way to carry NTLM or Kerberos, so these reuse the proxy handshake. + if params.Dialect == "mssql" && (params.AuthMethod == "ntlm" || params.AuthMethod == "kerberos") { + var tlsConfig *tls.Config + if params.SslEnabled { + var err error + if tlsConfig, err = buildTestTLSConfig(host, params.SslCertificate, params.SslRejectUnauthorized); err != nil { + return err + } + } + return authFailure(mssqlhandler.VerifyCredential(ctx, mssqlhandler.MssqlProxyConfig{ + TargetAddr: net.JoinHostPort(host, strconv.Itoa(port)), + InjectUsername: params.Username, + InjectPassword: params.Password, + InjectDatabase: params.Database, + InjectDomain: params.Domain, + InjectRealm: params.Realm, + InjectKDCAddr: params.KdcAddress, + InjectSPN: params.Spn, + AuthMethod: params.AuthMethod, + EnableTLS: params.SslEnabled, + TLSConfig: tlsConfig, + })) + } + db, err := openSQLTestDB(host, port, params) if err != nil { return err @@ -212,7 +246,7 @@ func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlT defer db.Close() var result int - return db.QueryRowContext(ctx, "SELECT 1").Scan(&result) + return authFailure(db.QueryRowContext(ctx, "SELECT 1").Scan(&result)) } // doMongoConnectionTest authenticates against the target MongoDB and pings it @@ -233,13 +267,17 @@ func doMongoConnectionTest(ctx context.Context, host string, port int, params mo opts.SetTLSConfig(tlsConfig) } + if err := dialTarget(ctx, host, port); err != nil { + return connectFailure(err) + } + client, err := mongo.Connect(opts) if err != nil { return err } defer func() { _ = client.Disconnect(ctx) }() - return client.Ping(ctx, nil) + return authFailure(client.Ping(ctx, nil)) } // doRedisConnectionTest authenticates against the target Redis and PINGs it @@ -259,12 +297,12 @@ func doRedisConnectionTest(ctx context.Context, host string, port int, params re return err } if conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig); err != nil { - return err + return connectFailure(err) } } else { var err error if conn, err = dialer.DialContext(ctx, "tcp", addr); err != nil { - return err + return connectFailure(err) } } defer conn.Close() @@ -285,26 +323,27 @@ func doRedisConnectionTest(ctx context.Context, host string, port int, params re err = writer.WriteCommand("AUTH", params.Password) } if err != nil { - return err + return connectFailure(err) } reply, err := readRedisReplyLine(reader) if err != nil { - return err + return connectFailure(err) } if reply != "+OK" { - return fmt.Errorf("redis authentication failed: %s", redisReplyErrorText(reply)) + return authFailure(fmt.Errorf("redis authentication failed: %s", redisReplyErrorText(reply))) } } if err := writer.WriteCommand("PING"); err != nil { - return err + return connectFailure(err) } reply, err := readRedisReplyLine(reader) if err != nil { - return err + return connectFailure(err) } + // The first command after AUTH is what proves the credential took; an unauthenticated server answers NOAUTH. if len(reply) > 0 && (reply[0] == '-' || reply[0] == '!') { - return fmt.Errorf("redis PING failed: %s", redisReplyErrorText(reply)) + return authFailure(fmt.Errorf("redis PING failed: %s", redisReplyErrorText(reply))) } return nil } @@ -352,12 +391,12 @@ func doLdapConnectionTest(ctx context.Context, host string, port int, params lda conn, err := ldap.DialURL(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, strconv.Itoa(port))), opts...) if err != nil { - return err + return connectFailure(err) } defer conn.Close() conn.SetTimeout(timeout) - return conn.Bind(params.Username, params.Password) + return authFailure(conn.Bind(params.Username, params.Password)) } // doKubernetesConnectionTest confirms the API server is reachable and accepts the token (401 = bad credentials) @@ -383,12 +422,12 @@ func doKubernetesConnectionTest(ctx context.Context, host string, port int, para resp, err := client.Do(req) if err != nil { - return err + return connectFailure(err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("kubernetes API rejected the credentials (HTTP %d)", resp.StatusCode) + return authFailure(fmt.Errorf("kubernetes API rejected the credentials (HTTP %d)", resp.StatusCode)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("kubernetes API returned HTTP %d", resp.StatusCode) @@ -459,15 +498,15 @@ func kubernetesImpersonationProbe( resp, err := client.Do(req) if err != nil { - return err + return connectFailure(err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("kubernetes API rejected the gateway's pod token (HTTP %d)", resp.StatusCode) + return authFailure(fmt.Errorf("kubernetes API rejected the gateway's pod token (HTTP %d)", resp.StatusCode)) } if resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("gateway service account cannot impersonate %s:%s (HTTP %d)", namespace, serviceAccountName, resp.StatusCode) + return authFailure(fmt.Errorf("gateway service account cannot impersonate %s:%s (HTTP %d)", namespace, serviceAccountName, resp.StatusCode)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("kubernetes API returned HTTP %d", resp.StatusCode) @@ -478,6 +517,11 @@ func kubernetesImpersonationProbe( // doTCPReachabilityTest confirms the target host:port accepts a TCP connection. It's the fallback for targets we // can't authenticate at rest (RDP, SSH certificate auth), so at least a bad host/port is rejected. func doTCPReachabilityTest(ctx context.Context, host string, port int) error { + return connectFailure(dialTarget(ctx, host, port)) +} + +// Proves the target is reachable before any protocol client runs, so a later failure is the target answering. +func dialTarget(ctx context.Context, host string, port int) error { dialer := net.Dialer{} conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { @@ -494,7 +538,7 @@ func runWithContext(ctx context.Context, op func() error) error { case err := <-done: return err case <-ctx.Done(): - return fmt.Errorf("connection test timed out") + return connectFailure(fmt.Errorf("connection test timed out")) } } @@ -590,7 +634,7 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { } if testErr := runWithContext(ctx, op); testErr != nil { - writeRPCError(w, http.StatusBadGateway, testErr.Error()) + writeRPCErrorWithKind(w, http.StatusBadGateway, testErr.Error(), string(classifyTestConnFailure(testErr))) return } writeRPCJSON(w, http.StatusOK, testConnectionResponse{Result: testConnectionResult{Ok: true}}) diff --git a/packages/gateway-v2/test_connection_mssql_auth_test.go b/packages/gateway-v2/test_connection_mssql_auth_test.go new file mode 100644 index 00000000..ec33234b --- /dev/null +++ b/packages/gateway-v2/test_connection_mssql_auth_test.go @@ -0,0 +1,72 @@ +package gatewayv2 + +import ( + "context" + "net" + "strconv" + "strings" + "testing" +) + +// Accepts and immediately closes, so the reachability dial succeeds and the protocol client still fails. +func hangupPort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + return listener.Addr().(*net.TCPAddr).Port +} + +func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { + port := hangupPort(t) + + for _, authMethod := range []string{"ntlm", "kerberos"} { + t.Run(authMethod, func(t *testing.T) { + err := doSQLConnectionTest(context.Background(), "127.0.0.1", port, sqlTestParams{ + Dialect: "mssql", + Username: "svc_app", + Password: "pw", + Database: "master", + AuthMethod: authMethod, + Domain: "CORP", + Realm: "CORP.EXAMPLE.COM", + Spn: "MSSQLSvc/sql.corp.example.com:" + strconv.Itoa(port), + }) + if err == nil { + t.Fatal("expected a failure against a target that hangs up") + } + if !strings.Contains(err.Error(), "server prelogin") { + t.Fatalf("expected the proxy handshake to run, got: %v", err) + } + }) + } +} + +func TestSQLConnectionTestKeepsSqlLoginOnDriverPath(t *testing.T) { + port := hangupPort(t) + + err := doSQLConnectionTest(context.Background(), "127.0.0.1", port, sqlTestParams{ + Dialect: "mssql", + Username: "sa", + Password: "pw", + Database: "master", + AuthMethod: "sql-login", + }) + if err == nil { + t.Fatal("expected a failure against a target that hangs up") + } + if strings.Contains(err.Error(), "server prelogin") { + t.Fatalf("sql-login should stay on the driver path, got: %v", err) + } +} diff --git a/packages/gateway-v2/test_connection_phase_test.go b/packages/gateway-v2/test_connection_phase_test.go new file mode 100644 index 00000000..2b86ffe9 --- /dev/null +++ b/packages/gateway-v2/test_connection_phase_test.go @@ -0,0 +1,86 @@ +package gatewayv2 + +import ( + "bufio" + "context" + "net" + "testing" + "time" +) + +// fakeRedis answers one AUTH with the supplied reply, then closes. +func fakeRedis(t *testing.T, authReply string) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + if len(line) > 0 && line[0] == '*' { + continue + } + if _, err := conn.Write([]byte(authReply + "\r\n")); err != nil { + return + } + return + } + }() + } + }() + return listener.Addr().(*net.TCPAddr).Port +} + +func TestRedisProbePhases(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + t.Run("rejected password is auth", func(t *testing.T) { + port := fakeRedis(t, "-WRONGPASS invalid username-password pair") + err := doRedisConnectionTest(ctx, "127.0.0.1", port, redisTestParams{Password: "nope"}) + if got := classifyTestConnFailure(err); got != failureKindAuth { + t.Fatalf("got %q (%v), want %q", got, err, failureKindAuth) + } + }) + + t.Run("unreachable target is transport", func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + err = doRedisConnectionTest(ctx, "127.0.0.1", port, redisTestParams{Password: "pw"}) + if got := classifyTestConnFailure(err); got != failureKindTransport { + t.Fatalf("got %q (%v), want %q", got, err, failureKindTransport) + } + }) +} + +func TestTCPProbeIsAlwaysTransport(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + err = doTCPReachabilityTest(context.Background(), "127.0.0.1", port) + if got := classifyTestConnFailure(err); got != failureKindTransport { + t.Fatalf("got %q (%v), want %q", got, err, failureKindTransport) + } +} diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 5757cbee..3a57d111 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -113,6 +113,8 @@ type winrmErrorResponse struct { type winrmErrorBody struct { Message string `json:"message"` + // Absent on older gateways. + Kind string `json:"kind,omitempty"` } const ( @@ -229,7 +231,14 @@ func wrapWinrm(fn winrmHandlerFn) http.HandlerFunc { if errors.Is(err, winrm.ErrConnect) { err = winrm.ErrConnect } - writeWinrmError(w, http.StatusBadGateway, err.Error()) + kind := "unknown" + switch { + case errors.Is(err, winrm.ErrAuth): + kind = "auth" + case errors.Is(err, winrm.ErrConnect): + kind = "transport" + } + writeWinrmErrorWithKind(w, http.StatusBadGateway, err.Error(), kind) return } @@ -425,6 +434,13 @@ func handleWinrmValidateCredential(ctx context.Context, env *winrmRequestEnvelop return map[string]any{"valid": valid}, nil } +func writeWinrmErrorWithKind(w http.ResponseWriter, status int, message string, kind string) { + body, _ := json.Marshal(winrmErrorResponse{Error: winrmErrorBody{Message: message, Kind: kind}}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(body) +} + func writeWinrmError(w http.ResponseWriter, status int, message string) { body, _ := json.Marshal(winrmErrorResponse{Error: winrmErrorBody{Message: message}}) w.Header().Set("Content-Type", "application/json") diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 4d1c93d2..a68d6669 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -167,6 +167,11 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e return nil, nil, fmt.Errorf("dial server: %w", err) } + return p.authenticateOverConn(serverConn) +} + +// authenticateOverConn lets a caller own the dial (to bound it with a context) and still reuse these auth paths. +func (p *MssqlProxy) authenticateOverConn(serverConn net.Conn) (net.Conn, []*TDSPacket, error) { // 1. Send our PRELOGIN to server encOption := uint8(EncryptNotSup) if p.config.EnableTLS { @@ -654,3 +659,27 @@ func (p *MssqlProxy) proxyToClient(server, client net.Conn, errCh chan error) { } } } + +// The context bounds the whole handshake, not just the dial: a target that stalls after accepting the +// connection would otherwise leak a goroutine and socket per probe. +func VerifyCredential(ctx context.Context, config MssqlProxyConfig) error { + dialer := &net.Dialer{} + serverConn, err := dialer.DialContext(ctx, "tcp", config.TargetAddr) + if err != nil { + return fmt.Errorf("dial server: %w", err) + } + + if deadline, ok := ctx.Deadline(); ok { + if err := serverConn.SetDeadline(deadline); err != nil { + serverConn.Close() + return fmt.Errorf("set handshake deadline: %w", err) + } + } + + proxy := NewMssqlProxy(config) + authedConn, _, err := proxy.authenticateOverConn(serverConn) + if err != nil { + return err + } + return authedConn.Close() +}