From 1736625189cd94f7f6b4ce545b72a2606e6f612c Mon Sep 17 00:00:00 2001 From: bernie-g Date: Tue, 25 Aug 2026 21:12:39 -0400 Subject: [PATCH 01/10] feat(gateway): verify MSSQL Windows auth credentials A Windows-auth SQL Server login has no SQL-managed password, so the database/sql path cannot carry it and the connection test fell back to a plain TCP check. Route ntlm and kerberos through the session proxy's own handshake instead, which already speaks both. The live NTLM test is env-gated and skips unless a server is provided. --- .../gateway-v2/test_connection_handler.go | 23 +++++++ .../test_connection_mssql_auth_test.go | 67 +++++++++++++++++++ packages/pam/handlers/mssql/ntlm_live_test.go | 36 ++++++++++ packages/pam/handlers/mssql/proxy.go | 12 ++++ 4 files changed, 138 insertions(+) create mode 100644 packages/gateway-v2/test_connection_mssql_auth_test.go create mode 100644 packages/pam/handlers/mssql/ntlm_live_test.go diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 7940425f..0ee3578e 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/stdlib" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/msdsn" + mssqlhandler "github.com/Infisical/infisical-merge/packages/pam/handlers/mssql" "github.com/smallnest/resp3" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -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,23 @@ 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 { + // Windows-auth SQL Server logins have no SQL-managed password, so the database/sql driver path can't carry them. + // The session proxy already speaks NTLM and Kerberos against MSSQL, so the check reuses that handshake. + if params.Dialect == "mssql" && (params.AuthMethod == "ntlm" || params.AuthMethod == "kerberos") { + return mssqlhandler.VerifyCredential(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, + }) + } + db, err := openSQLTestDB(host, port, params) if err != nil { return err 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..036b56a9 --- /dev/null +++ b/packages/gateway-v2/test_connection_mssql_auth_test.go @@ -0,0 +1,67 @@ +package gatewayv2 + +import ( + "context" + "net" + "strconv" + "strings" + "testing" +) + +// closedPort returns a port nothing is listening on, so a connection attempt fails immediately. +func closedPort(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) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + return port +} + +// Windows-auth logins must not go through the database/sql driver: it has no way to carry NTLM or Kerberos, so +// they would fail as a bad DSN rather than as a real authentication attempt. The proxy handshake owns those. +func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { + port := closedPort(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 connection failure against a closed port") + } + // The proxy dials the target itself and wraps the failure; the driver path would not produce this. + if !strings.Contains(err.Error(), "dial server") { + t.Fatalf("expected the proxy handshake to run, got: %v", err) + } + }) + } +} + +func TestSQLConnectionTestKeepsSqlLoginOnDriverPath(t *testing.T) { + port := closedPort(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 connection failure against a closed port") + } + if strings.Contains(err.Error(), "dial server") { + t.Fatalf("sql-login should stay on the driver path, got: %v", err) + } +} diff --git a/packages/pam/handlers/mssql/ntlm_live_test.go b/packages/pam/handlers/mssql/ntlm_live_test.go new file mode 100644 index 00000000..65d0a5b3 --- /dev/null +++ b/packages/pam/handlers/mssql/ntlm_live_test.go @@ -0,0 +1,36 @@ +package mssql + +import ( + "os" + "testing" +) + +// Live NTLM check against a real SQL Server. Skipped unless PAM_MSSQL_NTLM_HOST is set. +func TestVerifyCredentialNTLMLive(t *testing.T) { + host := os.Getenv("PAM_MSSQL_NTLM_HOST") + if host == "" { + t.Skip("PAM_MSSQL_NTLM_HOST not set") + } + + base := MssqlProxyConfig{ + TargetAddr: host, + InjectUsername: os.Getenv("PAM_MSSQL_NTLM_USER"), + InjectPassword: os.Getenv("PAM_MSSQL_NTLM_PASS"), + InjectDomain: os.Getenv("PAM_MSSQL_NTLM_DOMAIN"), + InjectDatabase: "master", + AuthMethod: "ntlm", + SessionID: "ntlm-live-test", + } + + if err := VerifyCredential(base); err != nil { + t.Fatalf("expected NTLM login to succeed, got: %v", err) + } + + bad := base + bad.InjectPassword = "definitely-not-the-password" + if err := VerifyCredential(bad); err == nil { + t.Fatal("expected a wrong password to be rejected") + } else { + t.Logf("wrong password correctly rejected: %v", err) + } +} diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 4d1c93d2..3b92b553 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -654,3 +654,15 @@ func (p *MssqlProxy) proxyToClient(server, client net.Conn, errCh chan error) { } } } + +// VerifyCredential performs the login handshake against the target and drops the connection. It reuses the same +// auth paths a session uses, so sql-login, NTLM, and Kerberos all behave here exactly as they do for a real +// connection. A nil error means the credential authenticated. +func VerifyCredential(config MssqlProxyConfig) error { + proxy := NewMssqlProxy(config) + serverConn, _, err := proxy.connectAndAuthenticateToServer() + if err != nil { + return err + } + return serverConn.Close() +} From 753f886b45f75a682fc8f385a05c697c939004dd Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 15:29:49 -0400 Subject: [PATCH 02/10] feat(gateway): report whether a probe failure was auth or transport The test-connection and WinRM handlers returned 502 with a message for every failure, so a dial timeout and a refused password were indistinguishable upstream. The control plane needs them apart: a refused credential stops the schedule, an unreachable target keeps retrying. Classify at the source, where the driver error still exists, using typed errors where the drivers provide them and message matching only where they don't. WinRM already separated ErrAuth from ErrConnect internally and was flattening both; that now survives the response too. --- packages/gateway-v2/discovery_handler.go | 4 + packages/gateway-v2/ssh_handler.go | 3 + .../test_connection_failure_kind.go | 139 ++++++++++++++++++ .../gateway-v2/test_connection_handler.go | 2 +- packages/gateway-v2/winrm_handler.go | 18 ++- 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 packages/gateway-v2/test_connection_failure_kind.go 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..296005f3 100644 --- a/packages/gateway-v2/ssh_handler.go +++ b/packages/gateway-v2/ssh_handler.go @@ -44,6 +44,9 @@ type sshExecErrorResponse struct { type sshExecErrorBody struct { Message string `json:"message"` + // Set only by the test-connection handler, so the control plane can tell a refused credential from a + // target it never reached. Absent on every other RPC and on older gateways. + Kind string `json:"kind,omitempty"` } func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { 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..51553d17 --- /dev/null +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -0,0 +1,139 @@ +package gatewayv2 + +import ( + "errors" + "net" + "os" + "strings" + + "github.com/go-ldap/ldap/v3" + "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5/pgconn" + mssql "github.com/microsoft/go-mssqldb" +) + +// Whether the target refused the credential or we never got far enough to ask. The control plane needs these +// apart: a refused credential stops the schedule, while an unreachable target keeps retrying. Only the gateway +// holds the driver error, so the classification happens here rather than by matching strings upstream. +type testConnFailureKind string + +const ( + failureKindAuth testConnFailureKind = "auth" + failureKindTransport testConnFailureKind = "transport" + failureKindUnknown testConnFailureKind = "unknown" +) + +// SQLSTATE 28xxx is "invalid authorization specification", which Postgres uses for a rejected password. +const pgInvalidAuthorizationClass = "28" + +func classifyTestConnFailure(err error) testConnFailureKind { + if err == nil { + return failureKindUnknown + } + + // Anything that never completed a connection is transport, whatever the protocol said afterwards. + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return failureKindTransport + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return failureKindTransport + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return failureKindTransport + } + if errors.Is(err, os.ErrDeadlineExceeded) { + return failureKindTransport + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if strings.HasPrefix(pgErr.Code, pgInvalidAuthorizationClass) { + return failureKindAuth + } + return failureKindUnknown + } + + var myErr *mysql.MySQLError + if errors.As(err, &myErr) { + // 1045 access denied, 1044 access denied to database, 1698 auth plugin rejected the credential. + switch myErr.Number { + case 1044, 1045, 1698: + return failureKindAuth + default: + return failureKindUnknown + } + } + + var msErr mssql.Error + if errors.As(err, &msErr) { + // 18456 login failed, 18452 untrusted domain, 4060 cannot open database for this login. + switch msErr.Number { + case 4060, 18452, 18456: + return failureKindAuth + default: + return failureKindUnknown + } + } + + var ldapErr *ldap.Error + if errors.As(err, &ldapErr) { + switch ldapErr.ResultCode { + case ldap.LDAPResultInvalidCredentials, ldap.LDAPResultInsufficientAccessRights: + return failureKindAuth + default: + return failureKindUnknown + } + } + + return classifyTestConnFailureByMessage(err.Error()) +} + +// Drivers without typed errors (SSH, Redis, MongoDB, and the MSSQL proxy handshake) only report a string. +var authFailureSubstrings = []string{ + "unable to authenticate", // golang.org/x/crypto/ssh + "no supported methods remain", // golang.org/x/crypto/ssh + "ssh: handshake failed", // golang.org/x/crypto/ssh + "ntlm authentication failed", // MSSQL proxy handshake + "kerberos authentication failed", + "authentication failed", + "auth failed", + "invalid password", + "wrong password", + "access denied", + "permission denied", + "wrongpassword", // Redis + "noauth", // Redis + "invalid username-password pair", // MongoDB + "authentication error", +} + +var transportFailureSubstrings = []string{ + "connection refused", + "connection reset", + "no such host", + "i/o timeout", + "timed out", + "deadline exceeded", + "network is unreachable", + "host is unreachable", + "broken pipe", + "eof", +} + +func classifyTestConnFailureByMessage(message string) testConnFailureKind { + lowered := strings.ToLower(message) + for _, needle := range authFailureSubstrings { + if strings.Contains(lowered, needle) { + return failureKindAuth + } + } + for _, needle := range transportFailureSubstrings { + if strings.Contains(lowered, needle) { + return failureKindTransport + } + } + return failureKindUnknown +} diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 0ee3578e..9a069578 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -613,7 +613,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/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 5757cbee..8437ce4a 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"` + // Lets the control plane tell a rejected credential from an unreachable host. 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") From 53d2e9d02973e12b397a1af3da0bd740a0132a94 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 16:36:26 -0400 Subject: [PATCH 03/10] fix(gateway): bound the MSSQL credential check with a context A target that accepted the connection and withheld its PRELOGIN response left the verifier blocked on a read after the caller had already timed out, and since transport failures are retried those goroutines and sockets accumulated. --- .../test_connection_failure_kind.go | 12 ++++---- .../gateway-v2/test_connection_handler.go | 4 +-- packages/pam/handlers/mssql/ntlm_live_test.go | 9 ++++-- packages/pam/handlers/mssql/proxy.go | 29 +++++++++++++++++-- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index 51553d17..396da1d5 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -93,10 +93,10 @@ func classifyTestConnFailure(err error) testConnFailureKind { // Drivers without typed errors (SSH, Redis, MongoDB, and the MSSQL proxy handshake) only report a string. var authFailureSubstrings = []string{ - "unable to authenticate", // golang.org/x/crypto/ssh - "no supported methods remain", // golang.org/x/crypto/ssh - "ssh: handshake failed", // golang.org/x/crypto/ssh - "ntlm authentication failed", // MSSQL proxy handshake + "unable to authenticate", // golang.org/x/crypto/ssh + "no supported methods remain", // golang.org/x/crypto/ssh + "ssh: handshake failed", // golang.org/x/crypto/ssh + "ntlm authentication failed", // MSSQL proxy handshake "kerberos authentication failed", "authentication failed", "auth failed", @@ -104,8 +104,8 @@ var authFailureSubstrings = []string{ "wrong password", "access denied", "permission denied", - "wrongpassword", // Redis - "noauth", // Redis + "wrongpassword", // Redis + "noauth", // Redis "invalid username-password pair", // MongoDB "authentication error", } diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 9a069578..efb70766 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -19,13 +19,13 @@ 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" "github.com/jackc/pgx/v5/stdlib" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/msdsn" - mssqlhandler "github.com/Infisical/infisical-merge/packages/pam/handlers/mssql" "github.com/smallnest/resp3" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -214,7 +214,7 @@ func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlT // Windows-auth SQL Server logins have no SQL-managed password, so the database/sql driver path can't carry them. // The session proxy already speaks NTLM and Kerberos against MSSQL, so the check reuses that handshake. if params.Dialect == "mssql" && (params.AuthMethod == "ntlm" || params.AuthMethod == "kerberos") { - return mssqlhandler.VerifyCredential(mssqlhandler.MssqlProxyConfig{ + return mssqlhandler.VerifyCredential(ctx, mssqlhandler.MssqlProxyConfig{ TargetAddr: net.JoinHostPort(host, strconv.Itoa(port)), InjectUsername: params.Username, InjectPassword: params.Password, diff --git a/packages/pam/handlers/mssql/ntlm_live_test.go b/packages/pam/handlers/mssql/ntlm_live_test.go index 65d0a5b3..a8972509 100644 --- a/packages/pam/handlers/mssql/ntlm_live_test.go +++ b/packages/pam/handlers/mssql/ntlm_live_test.go @@ -1,8 +1,10 @@ package mssql import ( + "context" "os" "testing" + "time" ) // Live NTLM check against a real SQL Server. Skipped unless PAM_MSSQL_NTLM_HOST is set. @@ -22,13 +24,16 @@ func TestVerifyCredentialNTLMLive(t *testing.T) { SessionID: "ntlm-live-test", } - if err := VerifyCredential(base); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := VerifyCredential(ctx, base); err != nil { t.Fatalf("expected NTLM login to succeed, got: %v", err) } bad := base bad.InjectPassword = "definitely-not-the-password" - if err := VerifyCredential(bad); err == nil { + if err := VerifyCredential(ctx, bad); err == nil { t.Fatal("expected a wrong password to be rejected") } else { t.Logf("wrong password correctly rejected: %v", err) diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 3b92b553..09aebf5b 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -167,6 +167,12 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e return nil, nil, fmt.Errorf("dial server: %w", err) } + return p.authenticateOverConn(serverConn) +} + +// authenticateOverConn runs the PRELOGIN and login handshake over an already-dialed connection, so a caller +// that needs to own the dial (to bound it with a context) can still reuse the same 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 { @@ -658,11 +664,28 @@ func (p *MssqlProxy) proxyToClient(server, client net.Conn, errCh chan error) { // VerifyCredential performs the login handshake against the target and drops the connection. It reuses the same // auth paths a session uses, so sql-login, NTLM, and Kerberos all behave here exactly as they do for a real // connection. A nil error means the credential authenticated. -func VerifyCredential(config MssqlProxyConfig) error { +// +// The context bounds the whole handshake, not just the dial: a target that accepts the connection and then +// withholds its PRELOGIN response would otherwise leave this blocked on a read after the caller gave up, and +// retries would stack those goroutines and sockets up until the gateway ran out of them. +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) - serverConn, _, err := proxy.connectAndAuthenticateToServer() + authedConn, _, err := proxy.authenticateOverConn(serverConn) if err != nil { return err } - return serverConn.Close() + return authedConn.Close() } From 66981c4f74a35a7a06bf783795765b65ef164248 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:28:05 -0400 Subject: [PATCH 04/10] fix(gateway): supply the TLS config for Windows-auth MSSQL checks The branch set EnableTLS without a TLSConfig, so every SSL-enabled NTLM or Kerberos check failed against a working server. Comments across the change are cut back to the few that explain something the code cannot. --- packages/gateway-v2/test_connection_failure_kind.go | 8 +++----- packages/gateway-v2/test_connection_handler.go | 11 +++++++++-- .../gateway-v2/test_connection_mssql_auth_test.go | 4 ---- packages/pam/handlers/mssql/proxy.go | 13 ++++--------- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index 396da1d5..1e5a20be 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -12,9 +12,8 @@ import ( mssql "github.com/microsoft/go-mssqldb" ) -// Whether the target refused the credential or we never got far enough to ask. The control plane needs these -// apart: a refused credential stops the schedule, while an unreachable target keeps retrying. Only the gateway -// holds the driver error, so the classification happens here rather than by matching strings upstream. +// A refused credential stops the control plane's schedule; an unreachable target keeps retrying. Only the +// gateway holds the driver error, so the classification happens here rather than by matching strings upstream. type testConnFailureKind string const ( @@ -31,7 +30,6 @@ func classifyTestConnFailure(err error) testConnFailureKind { return failureKindUnknown } - // Anything that never completed a connection is transport, whatever the protocol said afterwards. var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return failureKindTransport @@ -91,7 +89,7 @@ func classifyTestConnFailure(err error) testConnFailureKind { return classifyTestConnFailureByMessage(err.Error()) } -// Drivers without typed errors (SSH, Redis, MongoDB, and the MSSQL proxy handshake) only report a string. +// Drivers without typed errors only report a string. var authFailureSubstrings = []string{ "unable to authenticate", // golang.org/x/crypto/ssh "no supported methods remain", // golang.org/x/crypto/ssh diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index efb70766..cd06dd98 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -211,9 +211,15 @@ 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 { - // Windows-auth SQL Server logins have no SQL-managed password, so the database/sql driver path can't carry them. - // The session proxy already speaks NTLM and Kerberos against MSSQL, so the check reuses that handshake. + // 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 mssqlhandler.VerifyCredential(ctx, mssqlhandler.MssqlProxyConfig{ TargetAddr: net.JoinHostPort(host, strconv.Itoa(port)), InjectUsername: params.Username, @@ -225,6 +231,7 @@ func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlT InjectSPN: params.Spn, AuthMethod: params.AuthMethod, EnableTLS: params.SslEnabled, + TLSConfig: tlsConfig, }) } diff --git a/packages/gateway-v2/test_connection_mssql_auth_test.go b/packages/gateway-v2/test_connection_mssql_auth_test.go index 036b56a9..2744cb78 100644 --- a/packages/gateway-v2/test_connection_mssql_auth_test.go +++ b/packages/gateway-v2/test_connection_mssql_auth_test.go @@ -8,7 +8,6 @@ import ( "testing" ) -// closedPort returns a port nothing is listening on, so a connection attempt fails immediately. func closedPort(t *testing.T) int { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -20,8 +19,6 @@ func closedPort(t *testing.T) int { return port } -// Windows-auth logins must not go through the database/sql driver: it has no way to carry NTLM or Kerberos, so -// they would fail as a bad DSN rather than as a real authentication attempt. The proxy handshake owns those. func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { port := closedPort(t) @@ -40,7 +37,6 @@ func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { if err == nil { t.Fatal("expected a connection failure against a closed port") } - // The proxy dials the target itself and wraps the failure; the driver path would not produce this. if !strings.Contains(err.Error(), "dial server") { t.Fatalf("expected the proxy handshake to run, got: %v", err) } diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 09aebf5b..66550402 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -170,8 +170,7 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e return p.authenticateOverConn(serverConn) } -// authenticateOverConn runs the PRELOGIN and login handshake over an already-dialed connection, so a caller -// that needs to own the dial (to bound it with a context) can still reuse the same auth paths. +// 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) @@ -661,13 +660,9 @@ func (p *MssqlProxy) proxyToClient(server, client net.Conn, errCh chan error) { } } -// VerifyCredential performs the login handshake against the target and drops the connection. It reuses the same -// auth paths a session uses, so sql-login, NTLM, and Kerberos all behave here exactly as they do for a real -// connection. A nil error means the credential authenticated. -// -// The context bounds the whole handshake, not just the dial: a target that accepts the connection and then -// withholds its PRELOGIN response would otherwise leave this blocked on a read after the caller gave up, and -// retries would stack those goroutines and sockets up until the gateway ran out of them. +// VerifyCredential performs the login handshake against the target and drops the connection. A nil error means +// the credential authenticated. 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) From 1f564281c7f9000ab03b001ad80b61e9c1a4ea7c Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:33:33 -0400 Subject: [PATCH 05/10] fix(gateway): treat a bare SSH handshake failure as transport ssh: handshake failed wraps everything that goes wrong after TCP connect, including a key-exchange mismatch or a peer hangup, so a host answering port 22 with a broken handshake was reported as a rejected credential and took the account off the check schedule. A genuine rejection still matches the authentication strings nested inside it. --- .../test_connection_failure_kind.go | 4 +- .../test_connection_failure_kind_test.go | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/gateway-v2/test_connection_failure_kind_test.go diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index 1e5a20be..b8142368 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -93,7 +93,6 @@ func classifyTestConnFailure(err error) testConnFailureKind { var authFailureSubstrings = []string{ "unable to authenticate", // golang.org/x/crypto/ssh "no supported methods remain", // golang.org/x/crypto/ssh - "ssh: handshake failed", // golang.org/x/crypto/ssh "ntlm authentication failed", // MSSQL proxy handshake "kerberos authentication failed", "authentication failed", @@ -119,6 +118,9 @@ var transportFailureSubstrings = []string{ "host is unreachable", "broken pipe", "eof", + // Wraps everything that fails after TCP connect, including a version or key-exchange mismatch and a peer + // hangup. Only reached once the auth list above has ruled out a genuine credential rejection. + "ssh: handshake failed", } func classifyTestConnFailureByMessage(message string) testConnFailureKind { 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..1cdbac0f --- /dev/null +++ b/packages/gateway-v2/test_connection_failure_kind_test.go @@ -0,0 +1,38 @@ +package gatewayv2 + +import ( + "errors" + "testing" +) + +func TestClassifySSHHandshakeFailures(t *testing.T) { + cases := []struct { + name string + err string + want testConnFailureKind + }{ + { + name: "rejected password", + err: "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain", + want: failureKindAuth, + }, + { + name: "no common key exchange algorithm", + err: "ssh: handshake failed: ssh: no common algorithm for key exchange; client offered: [...], server offered: [...]", + want: failureKindTransport, + }, + { + name: "peer hung up mid handshake", + err: "ssh: handshake failed: read tcp 10.0.0.1:22: connection reset by peer", + want: failureKindTransport, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := classifyTestConnFailure(errors.New(tc.err)); got != tc.want { + t.Fatalf("classifyTestConnFailure(%q) = %q, want %q", tc.err, got, tc.want) + } + }) + } +} From 62c9f9c8f82ac60b9eaf03b26e990a4d6b411026 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:44:51 -0400 Subject: [PATCH 06/10] refactor(gateway): classify probe failures by phase, not by error text The classifier read Postgres SQLSTATEs, MySQL/MSSQL error numbers, LDAP result codes, and a list of driver error substrings to decide whether a credential was refused or the target was never reached. That list had to grow with every account type and was wrong twice in review, once for cloud outages and once for SSH handshakes. The probes already know the answer: they dial, then they authenticate. Each one now tags its failure with the phase it happened in, so the vendor codes and the substring lists are gone and a new account type needs no classification code at all. This is the same shape gateway-retry.ts already uses upstream, where an established channel decides retryability. --- .../test_connection_failure_kind.go | 163 +++++++----------- .../test_connection_failure_kind_test.go | 59 +++++-- .../gateway-v2/test_connection_handler.go | 57 +++--- .../test_connection_mssql_auth_test.go | 30 ++-- .../gateway-v2/test_connection_phase_test.go | 86 +++++++++ 5 files changed, 249 insertions(+), 146 deletions(-) create mode 100644 packages/gateway-v2/test_connection_phase_test.go diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index b8142368..62d8a06c 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -2,18 +2,19 @@ package gatewayv2 import ( "errors" + "io" "net" "os" - "strings" - "github.com/go-ldap/ldap/v3" - "github.com/go-sql-driver/mysql" - "github.com/jackc/pgx/v5/pgconn" - mssql "github.com/microsoft/go-mssqldb" + "golang.org/x/crypto/ssh" ) -// A refused credential stops the control plane's schedule; an unreachable target keeps retrying. Only the -// gateway holds the driver error, so the classification happens here rather than by matching strings upstream. +// Whether the target refused the credential or we never got far enough to ask. The control plane needs these +// apart: a refused credential stops the heartbeat schedule, while an unreachable target keeps retrying. +// +// A probe knows which of the two happened structurally, because it dials and then authenticates in that order, +// so the answer is recorded as the phases run rather than recovered afterwards from a driver's error text. That +// keeps a new account type from needing its own error codes here. type testConnFailureKind string const ( @@ -22,118 +23,72 @@ const ( failureKindUnknown testConnFailureKind = "unknown" ) -// SQLSTATE 28xxx is "invalid authorization specification", which Postgres uses for a rejected password. -const pgInvalidAuthorizationClass = "28" +type probeError struct { + kind testConnFailureKind + err error +} -func classifyTestConnFailure(err error) testConnFailureKind { +func (e *probeError) Error() string { return e.err.Error() } +func (e *probeError) Unwrap() error { return e.err } + +// connectFailure tags a failure that happened before the target could evaluate a credential. +func connectFailure(err error) error { if err == nil { - return failureKindUnknown + return nil } + return &probeError{kind: failureKindTransport, err: err} +} - var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { - return failureKindTransport +// authFailure tags a failure from the step that authenticates. A network error this late means the connection +// died mid-exchange rather than the credential being refused, so it stays transport. +func authFailure(err error) error { + if err == nil { + return nil } - var opErr *net.OpError - if errors.As(err, &opErr) { - return failureKindTransport + if isNetworkError(err) { + return &probeError{kind: failureKindTransport, err: err} } - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) { - return failureKindTransport + return &probeError{kind: failureKindAuth, err: err} +} + +// sshFailure splits an SSH client error. The ssh package reports a refused credential as ServerAuthError; +// a version or key-exchange mismatch, a rejected host key, and a hangup all fail before any credential is sent. +func sshFailure(err error) error { + if err == nil { + return nil } - if errors.Is(err, os.ErrDeadlineExceeded) { - return failureKindTransport + var authErr *ssh.ServerAuthError + if errors.As(err, &authErr) { + return &probeError{kind: failureKindAuth, err: err} } + return connectFailure(err) +} - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - if strings.HasPrefix(pgErr.Code, pgInvalidAuthorizationClass) { - return failureKindAuth - } - return failureKindUnknown +func isNetworkError(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return true } - - var myErr *mysql.MySQLError - if errors.As(err, &myErr) { - // 1045 access denied, 1044 access denied to database, 1698 auth plugin rejected the credential. - switch myErr.Number { - case 1044, 1045, 1698: - return failureKindAuth - default: - return failureKindUnknown - } + var opErr *net.OpError + if errors.As(err, &opErr) { + return true } - - var msErr mssql.Error - if errors.As(err, &msErr) { - // 18456 login failed, 18452 untrusted domain, 4060 cannot open database for this login. - switch msErr.Number { - case 4060, 18452, 18456: - return failureKindAuth - default: - return failureKindUnknown - } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true } - - var ldapErr *ldap.Error - if errors.As(err, &ldapErr) { - switch ldapErr.ResultCode { - case ldap.LDAPResultInvalidCredentials, ldap.LDAPResultInsufficientAccessRights: - return failureKindAuth - default: - return failureKindUnknown - } + // The peer closing mid-exchange is the connection dying, not a credential being turned down. + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true } - - return classifyTestConnFailureByMessage(err.Error()) -} - -// Drivers without typed errors only report a string. -var authFailureSubstrings = []string{ - "unable to authenticate", // golang.org/x/crypto/ssh - "no supported methods remain", // golang.org/x/crypto/ssh - "ntlm authentication failed", // MSSQL proxy handshake - "kerberos authentication failed", - "authentication failed", - "auth failed", - "invalid password", - "wrong password", - "access denied", - "permission denied", - "wrongpassword", // Redis - "noauth", // Redis - "invalid username-password pair", // MongoDB - "authentication error", + return errors.Is(err, os.ErrDeadlineExceeded) } -var transportFailureSubstrings = []string{ - "connection refused", - "connection reset", - "no such host", - "i/o timeout", - "timed out", - "deadline exceeded", - "network is unreachable", - "host is unreachable", - "broken pipe", - "eof", - // Wraps everything that fails after TCP connect, including a version or key-exchange mismatch and a peer - // hangup. Only reached once the auth list above has ruled out a genuine credential rejection. - "ssh: handshake failed", -} - -func classifyTestConnFailureByMessage(message string) testConnFailureKind { - lowered := strings.ToLower(message) - for _, needle := range authFailureSubstrings { - if strings.Contains(lowered, needle) { - return failureKindAuth - } - } - for _, needle := range transportFailureSubstrings { - if strings.Contains(lowered, needle) { - return failureKindTransport - } +// An untagged failure is one no probe attributed to a phase, which the control plane treats as unclassified. +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 index 1cdbac0f..8e9e2138 100644 --- a/packages/gateway-v2/test_connection_failure_kind_test.go +++ b/packages/gateway-v2/test_connection_failure_kind_test.go @@ -2,37 +2,74 @@ package gatewayv2 import ( "errors" + "fmt" + "net" "testing" + + "golang.org/x/crypto/ssh" ) -func TestClassifySSHHandshakeFailures(t *testing.T) { +func TestClassifyTestConnFailure(t *testing.T) { cases := []struct { name string - err string + err error want testConnFailureKind }{ { - name: "rejected password", - err: "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain", + 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: "no common key exchange algorithm", - err: "ssh: handshake failed: ssh: no common algorithm for key exchange; client offered: [...], server offered: [...]", + name: "connection dropped mid-authentication", + err: authFailure(&net.OpError{Op: "read", Err: errors.New("connection reset by peer")}), want: failureKindTransport, }, { - name: "peer hung up mid handshake", - err: "ssh: handshake failed: read tcp 10.0.0.1:22: 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(errors.New(tc.err)); got != tc.want { - t.Fatalf("classifyTestConnFailure(%q) = %q, want %q", tc.err, got, tc.want) + if got := classifyTestConnFailure(tc.err); got != tc.want { + t.Fatalf("classifyTestConnFailure() = %q, want %q", got, tc.want) } }) } } + +// The ssh package wraps both a refused credential and a failed handshake in "ssh: handshake failed", so only the +// nested ServerAuthError separates them. +func TestSSHFailurePhases(t *testing.T) { + refused := fmt.Errorf("ssh: handshake failed: %w", &ssh.ServerAuthError{ + Errors: []error{errors.New("ssh: unable to authenticate")}, + }) + if got := classifyTestConnFailure(sshFailure(refused)); got != failureKindAuth { + t.Fatalf("refused credential = %q, want %q", got, failureKindAuth) + } + + kexMismatch := errors.New("ssh: handshake failed: ssh: no common algorithm for key exchange") + if got := classifyTestConnFailure(sshFailure(kexMismatch)); got != failureKindTransport { + t.Fatalf("key exchange mismatch = %q, want %q", got, failureKindTransport) + } +} + +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 cd06dd98..82fd5eba 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -211,6 +211,10 @@ 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 @@ -220,7 +224,7 @@ func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlT return err } } - return mssqlhandler.VerifyCredential(ctx, mssqlhandler.MssqlProxyConfig{ + return authFailure(mssqlhandler.VerifyCredential(ctx, mssqlhandler.MssqlProxyConfig{ TargetAddr: net.JoinHostPort(host, strconv.Itoa(port)), InjectUsername: params.Username, InjectPassword: params.Password, @@ -232,7 +236,7 @@ func doSQLConnectionTest(ctx context.Context, host string, port int, params sqlT AuthMethod: params.AuthMethod, EnableTLS: params.SslEnabled, TLSConfig: tlsConfig, - }) + })) } db, err := openSQLTestDB(host, port, params) @@ -242,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 @@ -263,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 @@ -289,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() @@ -315,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 } @@ -382,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) @@ -413,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) @@ -489,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) @@ -508,6 +517,12 @@ 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)) +} + +// dialTarget proves the target is reachable before any protocol client runs, so that a failure after this point +// is the target answering rather than the network, and each probe can name its phase without reading the error. +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 { @@ -524,7 +539,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")) } } @@ -610,7 +625,7 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { Certificate: params.Certificate, TimeoutMs: env.TimeoutMs, }) - return err + return sshFailure(err) } case testConnModeTCP: op = func() error { return doTCPReachabilityTest(ctx, target.host, target.port) } diff --git a/packages/gateway-v2/test_connection_mssql_auth_test.go b/packages/gateway-v2/test_connection_mssql_auth_test.go index 2744cb78..b4a4cc7b 100644 --- a/packages/gateway-v2/test_connection_mssql_auth_test.go +++ b/packages/gateway-v2/test_connection_mssql_auth_test.go @@ -8,19 +8,29 @@ import ( "testing" ) -func closedPort(t *testing.T) int { +// hangupPort accepts connections and immediately closes them, so the probe's reachability dial succeeds and the +// protocol client still fails. A closed port would be rejected before either path runs. +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) } - port := listener.Addr().(*net.TCPAddr).Port - listener.Close() - return port + 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 := closedPort(t) + port := hangupPort(t) for _, authMethod := range []string{"ntlm", "kerberos"} { t.Run(authMethod, func(t *testing.T) { @@ -35,9 +45,9 @@ func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { Spn: "MSSQLSvc/sql.corp.example.com:" + strconv.Itoa(port), }) if err == nil { - t.Fatal("expected a connection failure against a closed port") + t.Fatal("expected a failure against a target that hangs up") } - if !strings.Contains(err.Error(), "dial server") { + if !strings.Contains(err.Error(), "server prelogin") { t.Fatalf("expected the proxy handshake to run, got: %v", err) } }) @@ -45,7 +55,7 @@ func TestSQLConnectionTestRoutesWindowsAuthToProxy(t *testing.T) { } func TestSQLConnectionTestKeepsSqlLoginOnDriverPath(t *testing.T) { - port := closedPort(t) + port := hangupPort(t) err := doSQLConnectionTest(context.Background(), "127.0.0.1", port, sqlTestParams{ Dialect: "mssql", @@ -55,9 +65,9 @@ func TestSQLConnectionTestKeepsSqlLoginOnDriverPath(t *testing.T) { AuthMethod: "sql-login", }) if err == nil { - t.Fatal("expected a connection failure against a closed port") + t.Fatal("expected a failure against a target that hangs up") } - if strings.Contains(err.Error(), "dial server") { + 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) + } +} From 288239faec13ccaa08934d66ae6a208e282daa29 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:53:57 -0400 Subject: [PATCH 07/10] fix(gateway): decide the SSH phase from whether a credential was sent ssh.ServerAuthError is returned by NewServerConn, not by the client, so the type check added with the phase refactor could never match and every SSH failure classified as transport. The client wraps a refused password and a broken handshake in the same ssh: handshake failed text, so the two are only separable by whether the client got as far as offering a credential, which the auth callbacks now record. Adds live_probe_test.go (build tag liveprobe): 36 cases across Postgres, MySQL, MSSQL, MSSQL over NTLM, Redis, MongoDB, LDAP, SSH, and TCP, run against real servers. --- packages/gateway-v2/enroll.go | 4 +- packages/gateway-v2/live_probe_test.go | 206 ++++++++++++++++++ packages/gateway-v2/ssh_handler.go | 32 ++- packages/gateway-v2/systemd.go | 8 +- .../test_connection_failure_kind.go | 15 -- .../test_connection_failure_kind_test.go | 26 +-- .../gateway-v2/test_connection_handler.go | 2 +- 7 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 packages/gateway-v2/live_probe_test.go diff --git a/packages/gateway-v2/enroll.go b/packages/gateway-v2/enroll.go index 06088f2f..9b590f44 100644 --- a/packages/gateway-v2/enroll.go +++ b/packages/gateway-v2/enroll.go @@ -9,8 +9,8 @@ import ( ) const ( - INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN" - INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN" + INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN" + INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN" INFISICAL_GATEWAY_ENROLLMENT_TOKEN_KEY = "INFISICAL_GATEWAY_ENROLLMENT_TOKEN" ) diff --git a/packages/gateway-v2/live_probe_test.go b/packages/gateway-v2/live_probe_test.go new file mode 100644 index 00000000..cc83e1a6 --- /dev/null +++ b/packages/gateway-v2/live_probe_test.go @@ -0,0 +1,206 @@ +//go:build liveprobe + +package gatewayv2 + +import ( + "context" + "net" + "os" + "testing" + "time" +) + +// Live classification checks against real targets. Run with: +// +// go test -tags liveprobe ./packages/gateway-v2/ -run TestLive -v +// +// Each case runs the real probe against a real server and asserts the phase the control plane will act on: +// auth stops the heartbeat schedule, transport keeps it retrying. +func expectKind(t *testing.T, name string, err error, want testConnFailureKind) { + t.Helper() + got := classifyTestConnFailure(err) + if got != want { + t.Errorf("%s: got %q, want %q (err: %v)", name, got, want, err) + return + } + t.Logf("%-34s %-10s %v", name, got, err) +} + +func expectOk(t *testing.T, name string, err error) { + t.Helper() + if err != nil { + t.Errorf("%s: expected success, got: %v", name, err) + return + } + t.Logf("%-34s ok", name) +} + +func liveCtx(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t.Cleanup(cancel) + return ctx +} + +func deadPort(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) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + return port +} + +func TestLivePostgres(t *testing.T) { + ctx := liveCtx(t) + base := sqlTestParams{Dialect: "postgres", Username: "pamadmin", Password: "adminpass", Database: "appdb"} + + expectOk(t, "postgres/valid", doSQLConnectionTest(ctx, "127.0.0.1", 15432, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "postgres/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 15432, bad), failureKindAuth) + + noDB := base + noDB.Database = "does-not-exist" + expectKind(t, "postgres/missing database", doSQLConnectionTest(ctx, "127.0.0.1", 15432, noDB), failureKindAuth) + + expectKind(t, "postgres/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) + expectKind(t, "postgres/host unresolvable", doSQLConnectionTest(ctx, "no-such-host.invalid", 5432, base), failureKindTransport) +} + +func TestLiveMysql(t *testing.T) { + ctx := liveCtx(t) + base := sqlTestParams{Dialect: "mysql", Username: "root", Password: "Live!Test123", Database: "appdb"} + + expectOk(t, "mysql/valid", doSQLConnectionTest(ctx, "127.0.0.1", 13306, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "mysql/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 13306, bad), failureKindAuth) + + expectKind(t, "mysql/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) +} + +func TestLiveMssql(t *testing.T) { + ctx := liveCtx(t) + base := sqlTestParams{Dialect: "mssql", Username: "sa", Password: "Heartbeat!Test123", Database: "master", AuthMethod: "sql-login"} + + expectOk(t, "mssql/valid", doSQLConnectionTest(ctx, "127.0.0.1", 11433, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "mssql/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 11433, bad), failureKindAuth) + + noDB := base + noDB.Database = "does-not-exist" + expectKind(t, "mssql/missing database", doSQLConnectionTest(ctx, "127.0.0.1", 11433, noDB), failureKindAuth) + + expectKind(t, "mssql/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) +} + +func TestLiveRedis(t *testing.T) { + ctx := liveCtx(t) + + expectOk(t, "redis/valid default user", doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Password: "default-pass-123"})) + expectOk(t, "redis/valid acl user", doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Username: "pamuser", Password: "pam-pass-123"})) + + expectKind(t, "redis/wrong password", + doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Password: "wrong-password"}), failureKindAuth) + expectKind(t, "redis/unknown acl user", + doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Username: "ghost", Password: "pam-pass-123"}), failureKindAuth) + expectKind(t, "redis/no password supplied", + doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{}), failureKindAuth) + expectKind(t, "redis/port closed", + doRedisConnectionTest(ctx, "127.0.0.1", deadPort(t), redisTestParams{Password: "default-pass-123"}), failureKindTransport) +} + +func TestLiveMongo(t *testing.T) { + ctx := liveCtx(t) + base := mongoTestParams{Username: "pamadmin", Password: "Live!Test123", AuthSource: "admin"} + + expectOk(t, "mongo/valid", doMongoConnectionTest(ctx, "127.0.0.1", 27018, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "mongo/wrong password", doMongoConnectionTest(ctx, "127.0.0.1", 27018, bad), failureKindAuth) + + expectKind(t, "mongo/port closed", doMongoConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) +} + +func TestLiveLdap(t *testing.T) { + ctx := liveCtx(t) + base := ldapTestParams{Username: "cn=admin,dc=example,dc=org", Password: "LiveTest123"} + + expectOk(t, "ldap/valid bind", doLdapConnectionTest(ctx, "127.0.0.1", 1389, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "ldap/wrong password", doLdapConnectionTest(ctx, "127.0.0.1", 1389, bad), failureKindAuth) + + unknown := base + unknown.Username = "cn=ghost,dc=example,dc=org" + expectKind(t, "ldap/unknown dn", doLdapConnectionTest(ctx, "127.0.0.1", 1389, unknown), failureKindAuth) + + expectKind(t, "ldap/port closed", doLdapConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) +} + +func TestLiveSSH(t *testing.T) { + run := func(params sshTestParams, host string, port int) error { + _, err := doSSHExec(host, port, sshExecEnvelope{ + Command: "true", AuthMethod: params.AuthMethod, Username: params.Username, + Password: params.Password, PrivateKey: params.PrivateKey, TimeoutMs: 15000, + }) + return err + } + + expectOk(t, "ssh/valid password", + run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "LiveTest123"}, "127.0.0.1", 2225)) + expectKind(t, "ssh/wrong password", + run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "wrong-password"}, "127.0.0.1", 2225), + failureKindAuth) + expectKind(t, "ssh/unknown user", + run(sshTestParams{AuthMethod: "password", Username: "ghost", Password: "LiveTest123"}, "127.0.0.1", 2225), + failureKindAuth) + expectKind(t, "ssh/port closed", + run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", deadPort(t)), + failureKindTransport) + // A plain TCP service that is not SSH: the handshake fails before any credential is offered. + expectKind(t, "ssh/not an ssh server", + run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", 15432), + failureKindTransport) + // The server offers only publickey, so the password is never sent and nothing counts toward a lockout. + expectKind(t, "ssh/method not offered", + run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", 2223), + failureKindTransport) +} + +// Windows-auth MSSQL against the real SQL Server on EC2, which routes through the proxy handshake rather than +// the database/sql driver. Skipped unless PAM_MSSQL_NTLM_PASSWORD is set. +func TestLiveMssqlNtlm(t *testing.T) { + password := os.Getenv("PAM_MSSQL_NTLM_PASSWORD") + if password == "" { + t.Skip("PAM_MSSQL_NTLM_PASSWORD not set") + } + ctx := liveCtx(t) + const host = "18.220.191.145" + base := sqlTestParams{ + Dialect: "mssql", Username: "pamsql", Password: password, Database: "master", + AuthMethod: "ntlm", Domain: "EC2AMAZ-DG3116F", + } + + expectOk(t, "mssql-ntlm/valid", doSQLConnectionTest(ctx, host, 1433, base)) + + bad := base + bad.Password = "wrong-password" + expectKind(t, "mssql-ntlm/wrong password", doSQLConnectionTest(ctx, host, 1433, bad), failureKindAuth) + + expectKind(t, "mssql-ntlm/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) +} + +func TestLiveTCP(t *testing.T) { + ctx := liveCtx(t) + expectOk(t, "tcp/reachable", doTCPReachabilityTest(ctx, "127.0.0.1", 15432)) + expectKind(t, "tcp/port closed", doTCPReachabilityTest(ctx, "127.0.0.1", deadPort(t)), failureKindTransport) +} diff --git a/packages/gateway-v2/ssh_handler.go b/packages/gateway-v2/ssh_handler.go index 296005f3..55a658b1 100644 --- a/packages/gateway-v2/ssh_handler.go +++ b/packages/gateway-v2/ssh_handler.go @@ -56,16 +56,28 @@ func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { return ssh.ParsePrivateKey([]byte(privateKey)) } -func buildSSHExecAuth(env sshExecEnvelope) ([]ssh.AuthMethod, error) { +// buildSSHExecAuth calls onAttempt as each credential is handed to the client. The ssh package invokes these +// callbacks only once the transport handshake succeeded and the server offered the method, so a failure with +// onAttempt never called means no credential was ever sent and nothing can have counted toward a lockout. +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 { @@ -83,14 +95,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 } @@ -107,7 +123,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/systemd.go b/packages/gateway-v2/systemd.go index e6166c35..337f5890 100644 --- a/packages/gateway-v2/systemd.go +++ b/packages/gateway-v2/systemd.go @@ -12,10 +12,10 @@ import ( ) const ( - legacyServiceName = "infisical-gateway" - legacyConfigPath = "/etc/infisical/gateway.conf" - legacyServicePath = "/etc/systemd/system/infisical-gateway.service" - gatewaysConfigDir = "/etc/infisical/gateways" + legacyServiceName = "infisical-gateway" + legacyConfigPath = "/etc/infisical/gateway.conf" + legacyServicePath = "/etc/systemd/system/infisical-gateway.service" + gatewaysConfigDir = "/etc/infisical/gateways" ) func serviceFilePath(name string) string { diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index 62d8a06c..41c7c4bc 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -5,8 +5,6 @@ import ( "io" "net" "os" - - "golang.org/x/crypto/ssh" ) // Whether the target refused the credential or we never got far enough to ask. The control plane needs these @@ -51,19 +49,6 @@ func authFailure(err error) error { return &probeError{kind: failureKindAuth, err: err} } -// sshFailure splits an SSH client error. The ssh package reports a refused credential as ServerAuthError; -// a version or key-exchange mismatch, a rejected host key, and a hangup all fail before any credential is sent. -func sshFailure(err error) error { - if err == nil { - return nil - } - var authErr *ssh.ServerAuthError - if errors.As(err, &authErr) { - return &probeError{kind: failureKindAuth, err: err} - } - return connectFailure(err) -} - func isNetworkError(err error) bool { var netErr net.Error if errors.As(err, &netErr) { diff --git a/packages/gateway-v2/test_connection_failure_kind_test.go b/packages/gateway-v2/test_connection_failure_kind_test.go index 8e9e2138..edd8033d 100644 --- a/packages/gateway-v2/test_connection_failure_kind_test.go +++ b/packages/gateway-v2/test_connection_failure_kind_test.go @@ -5,8 +5,6 @@ import ( "fmt" "net" "testing" - - "golang.org/x/crypto/ssh" ) func TestClassifyTestConnFailure(t *testing.T) { @@ -51,19 +49,19 @@ func TestClassifyTestConnFailure(t *testing.T) { } } -// The ssh package wraps both a refused credential and a failed handshake in "ssh: handshake failed", so only the -// nested ServerAuthError separates them. -func TestSSHFailurePhases(t *testing.T) { - refused := fmt.Errorf("ssh: handshake failed: %w", &ssh.ServerAuthError{ - Errors: []error{errors.New("ssh: unable to authenticate")}, - }) - if got := classifyTestConnFailure(sshFailure(refused)); got != failureKindAuth { - t.Fatalf("refused credential = %q, want %q", got, failureKindAuth) +// A credential is only offered once the transport handshake succeeded and the server accepted the method, so +// whether the client got that far is what separates a refused login from a handshake that never asked. +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) } - - kexMismatch := errors.New("ssh: handshake failed: ssh: no common algorithm for key exchange") - if got := classifyTestConnFailure(sshFailure(kexMismatch)); got != failureKindTransport { - t.Fatalf("key exchange mismatch = %q, want %q", got, failureKindTransport) + 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") } } diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 82fd5eba..7b954377 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -625,7 +625,7 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { Certificate: params.Certificate, TimeoutMs: env.TimeoutMs, }) - return sshFailure(err) + return err } case testConnModeTCP: op = func() error { return doTCPReachabilityTest(ctx, target.host, target.port) } From 3bdb981de99be6940fd8fc5107c772e07eb5e43e Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:57:35 -0400 Subject: [PATCH 08/10] chore(gateway): drop unrelated gofmt churn enroll.go and systemd.go were already unformatted on main and a blanket gofmt -w reformatted them into this branch. --- packages/gateway-v2/enroll.go | 4 ++-- packages/gateway-v2/systemd.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/gateway-v2/enroll.go b/packages/gateway-v2/enroll.go index 9b590f44..06088f2f 100644 --- a/packages/gateway-v2/enroll.go +++ b/packages/gateway-v2/enroll.go @@ -9,8 +9,8 @@ import ( ) const ( - INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN" - INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN" + INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN" + INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN" INFISICAL_GATEWAY_ENROLLMENT_TOKEN_KEY = "INFISICAL_GATEWAY_ENROLLMENT_TOKEN" ) diff --git a/packages/gateway-v2/systemd.go b/packages/gateway-v2/systemd.go index 337f5890..e6166c35 100644 --- a/packages/gateway-v2/systemd.go +++ b/packages/gateway-v2/systemd.go @@ -12,10 +12,10 @@ import ( ) const ( - legacyServiceName = "infisical-gateway" - legacyConfigPath = "/etc/infisical/gateway.conf" - legacyServicePath = "/etc/systemd/system/infisical-gateway.service" - gatewaysConfigDir = "/etc/infisical/gateways" + legacyServiceName = "infisical-gateway" + legacyConfigPath = "/etc/infisical/gateway.conf" + legacyServicePath = "/etc/systemd/system/infisical-gateway.service" + gatewaysConfigDir = "/etc/infisical/gateways" ) func serviceFilePath(name string) string { From a19f07a6d5205eebd3b9c92af4dc2e57c80e98f0 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 20:58:52 -0400 Subject: [PATCH 09/10] test(gateway): drop the live probe suites from the branch Both needed a hand-built environment (local containers on fixed ports, a SQL Server on EC2) that nobody else can reproduce, so they documented a verification run rather than testing anything on their own. The phase behaviour they proved is covered by the self-contained tests. --- packages/gateway-v2/live_probe_test.go | 206 ------------------ packages/pam/handlers/mssql/ntlm_live_test.go | 41 ---- 2 files changed, 247 deletions(-) delete mode 100644 packages/gateway-v2/live_probe_test.go delete mode 100644 packages/pam/handlers/mssql/ntlm_live_test.go diff --git a/packages/gateway-v2/live_probe_test.go b/packages/gateway-v2/live_probe_test.go deleted file mode 100644 index cc83e1a6..00000000 --- a/packages/gateway-v2/live_probe_test.go +++ /dev/null @@ -1,206 +0,0 @@ -//go:build liveprobe - -package gatewayv2 - -import ( - "context" - "net" - "os" - "testing" - "time" -) - -// Live classification checks against real targets. Run with: -// -// go test -tags liveprobe ./packages/gateway-v2/ -run TestLive -v -// -// Each case runs the real probe against a real server and asserts the phase the control plane will act on: -// auth stops the heartbeat schedule, transport keeps it retrying. -func expectKind(t *testing.T, name string, err error, want testConnFailureKind) { - t.Helper() - got := classifyTestConnFailure(err) - if got != want { - t.Errorf("%s: got %q, want %q (err: %v)", name, got, want, err) - return - } - t.Logf("%-34s %-10s %v", name, got, err) -} - -func expectOk(t *testing.T, name string, err error) { - t.Helper() - if err != nil { - t.Errorf("%s: expected success, got: %v", name, err) - return - } - t.Logf("%-34s ok", name) -} - -func liveCtx(t *testing.T) context.Context { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - t.Cleanup(cancel) - return ctx -} - -func deadPort(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) - } - port := listener.Addr().(*net.TCPAddr).Port - listener.Close() - return port -} - -func TestLivePostgres(t *testing.T) { - ctx := liveCtx(t) - base := sqlTestParams{Dialect: "postgres", Username: "pamadmin", Password: "adminpass", Database: "appdb"} - - expectOk(t, "postgres/valid", doSQLConnectionTest(ctx, "127.0.0.1", 15432, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "postgres/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 15432, bad), failureKindAuth) - - noDB := base - noDB.Database = "does-not-exist" - expectKind(t, "postgres/missing database", doSQLConnectionTest(ctx, "127.0.0.1", 15432, noDB), failureKindAuth) - - expectKind(t, "postgres/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) - expectKind(t, "postgres/host unresolvable", doSQLConnectionTest(ctx, "no-such-host.invalid", 5432, base), failureKindTransport) -} - -func TestLiveMysql(t *testing.T) { - ctx := liveCtx(t) - base := sqlTestParams{Dialect: "mysql", Username: "root", Password: "Live!Test123", Database: "appdb"} - - expectOk(t, "mysql/valid", doSQLConnectionTest(ctx, "127.0.0.1", 13306, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "mysql/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 13306, bad), failureKindAuth) - - expectKind(t, "mysql/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) -} - -func TestLiveMssql(t *testing.T) { - ctx := liveCtx(t) - base := sqlTestParams{Dialect: "mssql", Username: "sa", Password: "Heartbeat!Test123", Database: "master", AuthMethod: "sql-login"} - - expectOk(t, "mssql/valid", doSQLConnectionTest(ctx, "127.0.0.1", 11433, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "mssql/wrong password", doSQLConnectionTest(ctx, "127.0.0.1", 11433, bad), failureKindAuth) - - noDB := base - noDB.Database = "does-not-exist" - expectKind(t, "mssql/missing database", doSQLConnectionTest(ctx, "127.0.0.1", 11433, noDB), failureKindAuth) - - expectKind(t, "mssql/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) -} - -func TestLiveRedis(t *testing.T) { - ctx := liveCtx(t) - - expectOk(t, "redis/valid default user", doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Password: "default-pass-123"})) - expectOk(t, "redis/valid acl user", doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Username: "pamuser", Password: "pam-pass-123"})) - - expectKind(t, "redis/wrong password", - doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Password: "wrong-password"}), failureKindAuth) - expectKind(t, "redis/unknown acl user", - doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{Username: "ghost", Password: "pam-pass-123"}), failureKindAuth) - expectKind(t, "redis/no password supplied", - doRedisConnectionTest(ctx, "127.0.0.1", 6380, redisTestParams{}), failureKindAuth) - expectKind(t, "redis/port closed", - doRedisConnectionTest(ctx, "127.0.0.1", deadPort(t), redisTestParams{Password: "default-pass-123"}), failureKindTransport) -} - -func TestLiveMongo(t *testing.T) { - ctx := liveCtx(t) - base := mongoTestParams{Username: "pamadmin", Password: "Live!Test123", AuthSource: "admin"} - - expectOk(t, "mongo/valid", doMongoConnectionTest(ctx, "127.0.0.1", 27018, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "mongo/wrong password", doMongoConnectionTest(ctx, "127.0.0.1", 27018, bad), failureKindAuth) - - expectKind(t, "mongo/port closed", doMongoConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) -} - -func TestLiveLdap(t *testing.T) { - ctx := liveCtx(t) - base := ldapTestParams{Username: "cn=admin,dc=example,dc=org", Password: "LiveTest123"} - - expectOk(t, "ldap/valid bind", doLdapConnectionTest(ctx, "127.0.0.1", 1389, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "ldap/wrong password", doLdapConnectionTest(ctx, "127.0.0.1", 1389, bad), failureKindAuth) - - unknown := base - unknown.Username = "cn=ghost,dc=example,dc=org" - expectKind(t, "ldap/unknown dn", doLdapConnectionTest(ctx, "127.0.0.1", 1389, unknown), failureKindAuth) - - expectKind(t, "ldap/port closed", doLdapConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) -} - -func TestLiveSSH(t *testing.T) { - run := func(params sshTestParams, host string, port int) error { - _, err := doSSHExec(host, port, sshExecEnvelope{ - Command: "true", AuthMethod: params.AuthMethod, Username: params.Username, - Password: params.Password, PrivateKey: params.PrivateKey, TimeoutMs: 15000, - }) - return err - } - - expectOk(t, "ssh/valid password", - run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "LiveTest123"}, "127.0.0.1", 2225)) - expectKind(t, "ssh/wrong password", - run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "wrong-password"}, "127.0.0.1", 2225), - failureKindAuth) - expectKind(t, "ssh/unknown user", - run(sshTestParams{AuthMethod: "password", Username: "ghost", Password: "LiveTest123"}, "127.0.0.1", 2225), - failureKindAuth) - expectKind(t, "ssh/port closed", - run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", deadPort(t)), - failureKindTransport) - // A plain TCP service that is not SSH: the handshake fails before any credential is offered. - expectKind(t, "ssh/not an ssh server", - run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", 15432), - failureKindTransport) - // The server offers only publickey, so the password is never sent and nothing counts toward a lockout. - expectKind(t, "ssh/method not offered", - run(sshTestParams{AuthMethod: "password", Username: "pamuser", Password: "whatever"}, "127.0.0.1", 2223), - failureKindTransport) -} - -// Windows-auth MSSQL against the real SQL Server on EC2, which routes through the proxy handshake rather than -// the database/sql driver. Skipped unless PAM_MSSQL_NTLM_PASSWORD is set. -func TestLiveMssqlNtlm(t *testing.T) { - password := os.Getenv("PAM_MSSQL_NTLM_PASSWORD") - if password == "" { - t.Skip("PAM_MSSQL_NTLM_PASSWORD not set") - } - ctx := liveCtx(t) - const host = "18.220.191.145" - base := sqlTestParams{ - Dialect: "mssql", Username: "pamsql", Password: password, Database: "master", - AuthMethod: "ntlm", Domain: "EC2AMAZ-DG3116F", - } - - expectOk(t, "mssql-ntlm/valid", doSQLConnectionTest(ctx, host, 1433, base)) - - bad := base - bad.Password = "wrong-password" - expectKind(t, "mssql-ntlm/wrong password", doSQLConnectionTest(ctx, host, 1433, bad), failureKindAuth) - - expectKind(t, "mssql-ntlm/port closed", doSQLConnectionTest(ctx, "127.0.0.1", deadPort(t), base), failureKindTransport) -} - -func TestLiveTCP(t *testing.T) { - ctx := liveCtx(t) - expectOk(t, "tcp/reachable", doTCPReachabilityTest(ctx, "127.0.0.1", 15432)) - expectKind(t, "tcp/port closed", doTCPReachabilityTest(ctx, "127.0.0.1", deadPort(t)), failureKindTransport) -} diff --git a/packages/pam/handlers/mssql/ntlm_live_test.go b/packages/pam/handlers/mssql/ntlm_live_test.go deleted file mode 100644 index a8972509..00000000 --- a/packages/pam/handlers/mssql/ntlm_live_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package mssql - -import ( - "context" - "os" - "testing" - "time" -) - -// Live NTLM check against a real SQL Server. Skipped unless PAM_MSSQL_NTLM_HOST is set. -func TestVerifyCredentialNTLMLive(t *testing.T) { - host := os.Getenv("PAM_MSSQL_NTLM_HOST") - if host == "" { - t.Skip("PAM_MSSQL_NTLM_HOST not set") - } - - base := MssqlProxyConfig{ - TargetAddr: host, - InjectUsername: os.Getenv("PAM_MSSQL_NTLM_USER"), - InjectPassword: os.Getenv("PAM_MSSQL_NTLM_PASS"), - InjectDomain: os.Getenv("PAM_MSSQL_NTLM_DOMAIN"), - InjectDatabase: "master", - AuthMethod: "ntlm", - SessionID: "ntlm-live-test", - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := VerifyCredential(ctx, base); err != nil { - t.Fatalf("expected NTLM login to succeed, got: %v", err) - } - - bad := base - bad.InjectPassword = "definitely-not-the-password" - if err := VerifyCredential(ctx, bad); err == nil { - t.Fatal("expected a wrong password to be rejected") - } else { - t.Logf("wrong password correctly rejected: %v", err) - } -} From 704cd9cffbcc146bd0637e53c2b30bcca8a88e54 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Thu, 27 Aug 2026 21:03:07 -0400 Subject: [PATCH 10/10] chore(gateway): trim comments down to the load-bearing ones --- packages/gateway-v2/ssh_handler.go | 8 +++----- .../gateway-v2/test_connection_failure_kind.go | 15 ++++----------- .../test_connection_failure_kind_test.go | 2 -- packages/gateway-v2/test_connection_handler.go | 3 +-- .../gateway-v2/test_connection_mssql_auth_test.go | 3 +-- packages/gateway-v2/winrm_handler.go | 2 +- packages/pam/handlers/mssql/proxy.go | 5 ++--- 7 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/gateway-v2/ssh_handler.go b/packages/gateway-v2/ssh_handler.go index 55a658b1..dd50c33f 100644 --- a/packages/gateway-v2/ssh_handler.go +++ b/packages/gateway-v2/ssh_handler.go @@ -44,8 +44,7 @@ type sshExecErrorResponse struct { type sshExecErrorBody struct { Message string `json:"message"` - // Set only by the test-connection handler, so the control plane can tell a refused credential from a - // target it never reached. Absent on every other RPC and on older gateways. + // Set only by the test-connection handler; absent on every other RPC and on older gateways. Kind string `json:"kind,omitempty"` } @@ -56,9 +55,8 @@ func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { return ssh.ParsePrivateKey([]byte(privateKey)) } -// buildSSHExecAuth calls onAttempt as each credential is handed to the client. The ssh package invokes these -// callbacks only once the transport handshake succeeded and the server offered the method, so a failure with -// onAttempt never called means no credential was ever sent and nothing can have counted toward a lockout. +// 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() {} diff --git a/packages/gateway-v2/test_connection_failure_kind.go b/packages/gateway-v2/test_connection_failure_kind.go index 41c7c4bc..c2d6097d 100644 --- a/packages/gateway-v2/test_connection_failure_kind.go +++ b/packages/gateway-v2/test_connection_failure_kind.go @@ -7,12 +7,9 @@ import ( "os" ) -// Whether the target refused the credential or we never got far enough to ask. The control plane needs these -// apart: a refused credential stops the heartbeat schedule, while an unreachable target keeps retrying. -// -// A probe knows which of the two happened structurally, because it dials and then authenticates in that order, -// so the answer is recorded as the phases run rather than recovered afterwards from a driver's error text. That -// keeps a new account type from needing its own error codes here. +// 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 ( @@ -29,7 +26,6 @@ type probeError struct { func (e *probeError) Error() string { return e.err.Error() } func (e *probeError) Unwrap() error { return e.err } -// connectFailure tags a failure that happened before the target could evaluate a credential. func connectFailure(err error) error { if err == nil { return nil @@ -37,8 +33,7 @@ func connectFailure(err error) error { return &probeError{kind: failureKindTransport, err: err} } -// authFailure tags a failure from the step that authenticates. A network error this late means the connection -// died mid-exchange rather than the credential being refused, so it stays transport. +// 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 @@ -62,14 +57,12 @@ func isNetworkError(err error) bool { if errors.As(err, &dnsErr) { return true } - // The peer closing mid-exchange is the connection dying, not a credential being turned down. if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return true } return errors.Is(err, os.ErrDeadlineExceeded) } -// An untagged failure is one no probe attributed to a phase, which the control plane treats as unclassified. func classifyTestConnFailure(err error) testConnFailureKind { var probeErr *probeError if errors.As(err, &probeErr) { diff --git a/packages/gateway-v2/test_connection_failure_kind_test.go b/packages/gateway-v2/test_connection_failure_kind_test.go index edd8033d..1f645501 100644 --- a/packages/gateway-v2/test_connection_failure_kind_test.go +++ b/packages/gateway-v2/test_connection_failure_kind_test.go @@ -49,8 +49,6 @@ func TestClassifyTestConnFailure(t *testing.T) { } } -// A credential is only offered once the transport handshake succeeded and the server accepted the method, so -// whether the client got that far is what separates a refused login from a handshake that never asked. func TestSSHPhaseDependsOnCredentialBeingOffered(t *testing.T) { offered := false methods, err := buildSSHExecAuth(sshExecEnvelope{AuthMethod: "password", Password: "pw"}, func() { offered = true }) diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 7b954377..fec12c44 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -520,8 +520,7 @@ func doTCPReachabilityTest(ctx context.Context, host string, port int) error { return connectFailure(dialTarget(ctx, host, port)) } -// dialTarget proves the target is reachable before any protocol client runs, so that a failure after this point -// is the target answering rather than the network, and each probe can name its phase without reading the error. +// 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))) diff --git a/packages/gateway-v2/test_connection_mssql_auth_test.go b/packages/gateway-v2/test_connection_mssql_auth_test.go index b4a4cc7b..ec33234b 100644 --- a/packages/gateway-v2/test_connection_mssql_auth_test.go +++ b/packages/gateway-v2/test_connection_mssql_auth_test.go @@ -8,8 +8,7 @@ import ( "testing" ) -// hangupPort accepts connections and immediately closes them, so the probe's reachability dial succeeds and the -// protocol client still fails. A closed port would be rejected before either path runs. +// 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") diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 8437ce4a..3a57d111 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -113,7 +113,7 @@ type winrmErrorResponse struct { type winrmErrorBody struct { Message string `json:"message"` - // Lets the control plane tell a rejected credential from an unreachable host. Absent on older gateways. + // Absent on older gateways. Kind string `json:"kind,omitempty"` } diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 66550402..a68d6669 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -660,9 +660,8 @@ func (p *MssqlProxy) proxyToClient(server, client net.Conn, errCh chan error) { } } -// VerifyCredential performs the login handshake against the target and drops the connection. A nil error means -// the credential authenticated. 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. +// 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)