From 0fd4851c2d8a57d9b1b77d13cb5f1004c0c6a6de Mon Sep 17 00:00:00 2001 From: x Date: Fri, 17 Jul 2026 05:39:29 -0400 Subject: [PATCH 1/6] connection test --- go.mod | 2 + go.sum | 2 + packages/gateway-v2/discovery_handler.go | 37 ++--- .../gateway-v2/test_connection_handler.go | 153 ++++++++++++++++++ 4 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 packages/gateway-v2/test_connection_handler.go diff --git a/go.mod b/go.mod index 496a588a..70f86398 100644 --- a/go.mod +++ b/go.mod @@ -135,6 +135,8 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/indece-official/go-ebcdic v1.2.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect diff --git a/go.sum b/go.sum index de95d7f7..dedf225c 100644 --- a/go.sum +++ b/go.sum @@ -402,6 +402,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= diff --git a/packages/gateway-v2/discovery_handler.go b/packages/gateway-v2/discovery_handler.go index 9d59fee2..3e6a47ad 100644 --- a/packages/gateway-v2/discovery_handler.go +++ b/packages/gateway-v2/discovery_handler.go @@ -23,15 +23,15 @@ const ( maxSweepTimeout = 30 * time.Second ) -type discoveryTarget struct { +type rpcTarget struct { host string port int } -type discoveryTargetContextKey struct{} +type rpcTargetContextKey struct{} -// serveDiscoveryOverTLS serves the platform discovery RPCs (ssh-exec + port sweep) over one HTTP mux, -// mirroring the ADCS/PKCS11 handlers. The exec target host/port come from the signed gateway certificate. +// serveDiscoveryOverTLS serves the platform RPCs (ssh-exec, port sweep, connection test) over one HTTP mux, +// mirroring the ADCS/PKCS11 handlers. The target host/port come from the signed gateway certificate. func serveDiscoveryOverTLS(ctx context.Context, conn *tls.Conn, reader *bufio.Reader, forwardConfig *ForwardConfig) error { reqCh := make(chan *http.Request, 1) errCh := make(chan error, 1) @@ -55,7 +55,7 @@ func serveDiscoveryOverTLS(ctx context.Context, conn *tls.Conn, reader *bufio.Re opCtx, cancel := context.WithTimeout(ctx, discoveryRequestDeadline) defer cancel() - opCtx = context.WithValue(opCtx, discoveryTargetContextKey{}, discoveryTarget{forwardConfig.TargetHost, forwardConfig.TargetPort}) + opCtx = context.WithValue(opCtx, rpcTargetContextKey{}, rpcTarget{forwardConfig.TargetHost, forwardConfig.TargetPort}) req = req.WithContext(opCtx) rw := newBufferedResponseWriter() @@ -71,36 +71,37 @@ var discoveryMux = sync.OnceValue(func() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/v1/exec", handleDiscoveryExec) mux.HandleFunc("/v1/sweep", handleDiscoverySweep) + mux.HandleFunc("/v1/test-connection", handleTestConnection) return mux }) func handleDiscoveryExec(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - writeDiscoveryError(w, http.StatusMethodNotAllowed, "Only POST is supported") + writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported") return } body, err := io.ReadAll(io.LimitReader(r.Body, maxSshExecRequestBytes)) if err != nil { - writeDiscoveryError(w, http.StatusBadRequest, "failed to read request body") + writeRPCError(w, http.StatusBadRequest, "failed to read request body") return } var env sshExecEnvelope if err := json.Unmarshal(body, &env); err != nil { - writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body") + writeRPCError(w, http.StatusBadRequest, "Invalid request body") return } - target, _ := r.Context().Value(discoveryTargetContextKey{}).(discoveryTarget) + target, _ := r.Context().Value(rpcTargetContextKey{}).(rpcTarget) result, execErr := doSSHExec(target.host, target.port, env) if execErr != nil { - writeDiscoveryError(w, http.StatusBadGateway, execErr.Error()) + writeRPCError(w, http.StatusBadGateway, execErr.Error()) return } - writeDiscoveryJSON(w, http.StatusOK, sshExecResponse{Result: result}) + writeRPCJSON(w, http.StatusOK, sshExecResponse{Result: result}) } func handleDiscoverySweep(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - writeDiscoveryError(w, http.StatusMethodNotAllowed, "Only POST is supported") + writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported") return } var req struct { @@ -108,14 +109,14 @@ func handleDiscoverySweep(w http.ResponseWriter, r *http.Request) { TimeoutMs int `json:"timeoutMs"` } if err := json.NewDecoder(io.LimitReader(r.Body, maxSweepRequestBytes)).Decode(&req); err != nil { - writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body") + writeRPCError(w, http.StatusBadRequest, "Invalid request body") return } if len(req.Targets) > maxSweepTargets { - writeDiscoveryError(w, http.StatusBadRequest, fmt.Sprintf("target count %d exceeds limit %d", len(req.Targets), maxSweepTargets)) + writeRPCError(w, http.StatusBadRequest, fmt.Sprintf("target count %d exceeds limit %d", len(req.Targets), maxSweepTargets)) return } - writeDiscoveryJSON(w, http.StatusOK, map[string][]string{"open": sweepReachable(r.Context(), req.Targets, req.TimeoutMs)}) + writeRPCJSON(w, http.StatusOK, map[string][]string{"open": sweepReachable(r.Context(), req.Targets, req.TimeoutMs)}) } // sweepReachable TCP-probes each host:port concurrently in-network and returns the reachable ones @@ -154,7 +155,7 @@ func sweepReachable(ctx context.Context, targets []string, timeoutMs int) []stri return open } -func writeDiscoveryJSON(w http.ResponseWriter, status int, payload any) { +func writeRPCJSON(w http.ResponseWriter, status int, payload any) { body, err := json.Marshal(payload) if err != nil { http.Error(w, "failed to marshal response", http.StatusInternalServerError) @@ -165,6 +166,6 @@ func writeDiscoveryJSON(w http.ResponseWriter, status int, payload any) { _, _ = w.Write(body) } -func writeDiscoveryError(w http.ResponseWriter, status int, message string) { - writeDiscoveryJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message}}) +func writeRPCError(w http.ResponseWriter, status int, message string) { + writeRPCJSON(w, status, sshExecErrorResponse{Error: sshExecErrorBody{Message: message}}) } diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go new file mode 100644 index 00000000..e68f8605 --- /dev/null +++ b/packages/gateway-v2/test_connection_handler.go @@ -0,0 +1,153 @@ +package gatewayv2 + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + maxTestConnRequestBytes = 64 * 1024 + testConnDefaultTimeout = 15 * time.Second + testConnMaxTimeout = 60 * time.Second +) + +// testConnectionEnvelope is the request body for a connection test. The target host/port come from the signed +// gateway certificate; mode selects which client validates it, and the remaining fields are read per mode. +type testConnectionEnvelope struct { + Mode string `json:"mode"` // "postgres" | "ssh" | "tcp" + TimeoutMs int `json:"timeoutMs"` + + // ssh + AuthMethod string `json:"authMethod"` + PrivateKey string `json:"privateKey"` + Certificate string `json:"certificate"` + + // postgres + ssh + Username string `json:"username"` + Password string `json:"password"` + + // postgres + Database string `json:"database"` + SslEnabled bool `json:"sslEnabled"` + SslRejectUnauthorized bool `json:"sslRejectUnauthorized"` + SslCertificate string `json:"sslCertificate"` +} + +type testConnectionResult struct { + Ok bool `json:"ok"` +} + +type testConnectionResponse struct { + Result testConnectionResult `json:"result"` +} + +// doPostgresConnectionTest authenticates against the target Postgres and runs a trivial query. +func doPostgresConnectionTest(ctx context.Context, host string, port int, env testConnectionEnvelope) error { + config, err := pgx.ParseConfig("") + if err != nil { + return fmt.Errorf("failed to build connection config: %w", err) + } + config.Host = host + config.Port = uint16(port) + config.User = env.Username + config.Password = env.Password + config.Database = env.Database + + if env.SslEnabled { + tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !env.SslRejectUnauthorized} + if env.SslCertificate != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(env.SslCertificate)) { + return fmt.Errorf("failed to parse SSL certificate") + } + tlsConfig.RootCAs = pool + } + config.TLSConfig = tlsConfig + } else { + config.TLSConfig = nil + } + + conn, err := pgx.ConnectConfig(ctx, config) + if err != nil { + return err + } + defer conn.Close(ctx) + + var result int + return conn.QueryRow(ctx, "SELECT 1").Scan(&result) +} + +// 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 { + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return err + } + return conn.Close() +} + +func handleTestConnection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeRPCError(w, http.StatusMethodNotAllowed, "Only POST is supported") + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxTestConnRequestBytes)) + if err != nil { + writeRPCError(w, http.StatusBadRequest, "failed to read request body") + return + } + var env testConnectionEnvelope + if err := json.Unmarshal(body, &env); err != nil { + writeRPCError(w, http.StatusBadRequest, "Invalid request body") + return + } + + timeout := testConnDefaultTimeout + if env.TimeoutMs > 0 { + if timeout = time.Duration(env.TimeoutMs) * time.Millisecond; timeout > testConnMaxTimeout { + timeout = testConnMaxTimeout + } + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + target, _ := r.Context().Value(rpcTargetContextKey{}).(rpcTarget) + + var testErr error + switch env.Mode { + case "postgres": + testErr = doPostgresConnectionTest(ctx, target.host, target.port, env) + case "ssh": + _, testErr = doSSHExec(target.host, target.port, sshExecEnvelope{ + Command: "true", + AuthMethod: env.AuthMethod, + Username: env.Username, + Password: env.Password, + PrivateKey: env.PrivateKey, + Certificate: env.Certificate, + TimeoutMs: env.TimeoutMs, + }) + case "tcp": + testErr = doTCPReachabilityTest(ctx, target.host, target.port) + default: + writeRPCError(w, http.StatusBadRequest, fmt.Sprintf("unsupported test-connection mode: %q", env.Mode)) + return + } + + if testErr != nil { + writeRPCError(w, http.StatusBadGateway, testErr.Error()) + return + } + writeRPCJSON(w, http.StatusOK, testConnectionResponse{Result: testConnectionResult{Ok: true}}) +} From 4dc4f2eafd99ea0eeb362cf00c807343a68c97a7 Mon Sep 17 00:00:00 2001 From: x Date: Sat, 18 Jul 2026 03:14:50 -0400 Subject: [PATCH 2/6] enum --- packages/gateway-v2/test_connection_handler.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index e68f8605..43d596b0 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -20,6 +20,12 @@ const ( testConnMaxTimeout = 60 * time.Second ) +const ( + testConnModePostgres = "postgres" + testConnModeSSH = "ssh" + testConnModeTCP = "tcp" +) + // testConnectionEnvelope is the request body for a connection test. The target host/port come from the signed // gateway certificate; mode selects which client validates it, and the remaining fields are read per mode. type testConnectionEnvelope struct { @@ -126,9 +132,9 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { var testErr error switch env.Mode { - case "postgres": + case testConnModePostgres: testErr = doPostgresConnectionTest(ctx, target.host, target.port, env) - case "ssh": + case testConnModeSSH: _, testErr = doSSHExec(target.host, target.port, sshExecEnvelope{ Command: "true", AuthMethod: env.AuthMethod, @@ -138,7 +144,7 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { Certificate: env.Certificate, TimeoutMs: env.TimeoutMs, }) - case "tcp": + case testConnModeTCP: testErr = doTCPReachabilityTest(ctx, target.host, target.port) default: writeRPCError(w, http.StatusBadRequest, fmt.Sprintf("unsupported test-connection mode: %q", env.Mode)) From 113ea54279c57e4ce207e5eecfa028cd9e6aa853 Mon Sep 17 00:00:00 2001 From: x Date: Sat, 18 Jul 2026 04:38:40 -0400 Subject: [PATCH 3/6] address review --- packages/gateway-v2/test_connection_handler.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 43d596b0..d1a531e9 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -44,7 +44,7 @@ type testConnectionEnvelope struct { // postgres Database string `json:"database"` SslEnabled bool `json:"sslEnabled"` - SslRejectUnauthorized bool `json:"sslRejectUnauthorized"` + SslRejectUnauthorized *bool `json:"sslRejectUnauthorized"` SslCertificate string `json:"sslCertificate"` } @@ -69,7 +69,8 @@ func doPostgresConnectionTest(ctx context.Context, host string, port int, env te config.Database = env.Database if env.SslEnabled { - tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !env.SslRejectUnauthorized} + rejectUnauthorized := env.SslRejectUnauthorized == nil || *env.SslRejectUnauthorized + tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !rejectUnauthorized} if env.SslCertificate != "" { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM([]byte(env.SslCertificate)) { From ee851b63e33b04175478845f50b1b4dd2ae6c0e0 Mon Sep 17 00:00:00 2001 From: x Date: Tue, 21 Jul 2026 02:17:52 -0400 Subject: [PATCH 4/6] split into envelopes --- .../gateway-v2/test_connection_handler.go | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index d1a531e9..2cafc47a 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -26,28 +26,30 @@ const ( testConnModeTCP = "tcp" ) -// testConnectionEnvelope is the request body for a connection test. The target host/port come from the signed -// gateway certificate; mode selects which client validates it, and the remaining fields are read per mode. +// testConnectionEnvelope carries the fields common to every connection test; mode selects which client validates +// it and which per-mode params struct the body is decoded into. The target host/port come from the signed cert. type testConnectionEnvelope struct { Mode string `json:"mode"` // "postgres" | "ssh" | "tcp" TimeoutMs int `json:"timeoutMs"` +} - // ssh - AuthMethod string `json:"authMethod"` - PrivateKey string `json:"privateKey"` - Certificate string `json:"certificate"` - - // postgres + ssh - Username string `json:"username"` - Password string `json:"password"` - - // postgres +type postgresTestParams struct { + Username string `json:"username"` + Password string `json:"password"` Database string `json:"database"` SslEnabled bool `json:"sslEnabled"` SslRejectUnauthorized *bool `json:"sslRejectUnauthorized"` SslCertificate string `json:"sslCertificate"` } +type sshTestParams struct { + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password"` + PrivateKey string `json:"privateKey"` + Certificate string `json:"certificate"` +} + type testConnectionResult struct { Ok bool `json:"ok"` } @@ -57,23 +59,23 @@ type testConnectionResponse struct { } // doPostgresConnectionTest authenticates against the target Postgres and runs a trivial query. -func doPostgresConnectionTest(ctx context.Context, host string, port int, env testConnectionEnvelope) error { +func doPostgresConnectionTest(ctx context.Context, host string, port int, params postgresTestParams) error { config, err := pgx.ParseConfig("") if err != nil { return fmt.Errorf("failed to build connection config: %w", err) } config.Host = host config.Port = uint16(port) - config.User = env.Username - config.Password = env.Password - config.Database = env.Database + config.User = params.Username + config.Password = params.Password + config.Database = params.Database - if env.SslEnabled { - rejectUnauthorized := env.SslRejectUnauthorized == nil || *env.SslRejectUnauthorized + if params.SslEnabled { + rejectUnauthorized := params.SslRejectUnauthorized == nil || *params.SslRejectUnauthorized tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !rejectUnauthorized} - if env.SslCertificate != "" { + if params.SslCertificate != "" { pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM([]byte(env.SslCertificate)) { + if !pool.AppendCertsFromPEM([]byte(params.SslCertificate)) { return fmt.Errorf("failed to parse SSL certificate") } tlsConfig.RootCAs = pool @@ -134,15 +136,25 @@ func handleTestConnection(w http.ResponseWriter, r *http.Request) { var testErr error switch env.Mode { case testConnModePostgres: - testErr = doPostgresConnectionTest(ctx, target.host, target.port, env) + var params postgresTestParams + if err := json.Unmarshal(body, ¶ms); err != nil { + writeRPCError(w, http.StatusBadRequest, "Invalid request body") + return + } + testErr = doPostgresConnectionTest(ctx, target.host, target.port, params) case testConnModeSSH: + var params sshTestParams + if err := json.Unmarshal(body, ¶ms); err != nil { + writeRPCError(w, http.StatusBadRequest, "Invalid request body") + return + } _, testErr = doSSHExec(target.host, target.port, sshExecEnvelope{ Command: "true", - AuthMethod: env.AuthMethod, - Username: env.Username, - Password: env.Password, - PrivateKey: env.PrivateKey, - Certificate: env.Certificate, + AuthMethod: params.AuthMethod, + Username: params.Username, + Password: params.Password, + PrivateKey: params.PrivateKey, + Certificate: params.Certificate, TimeoutMs: env.TimeoutMs, }) case testConnModeTCP: From af1881add66b2bea17247d4a10c496b739724c2e Mon Sep 17 00:00:00 2001 From: x Date: Tue, 21 Jul 2026 04:36:30 -0400 Subject: [PATCH 5/6] capability --- packages/gateway-v2/gateway.go | 4 +++- packages/gateway-v2/test_connection_handler.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/gateway-v2/gateway.go b/packages/gateway-v2/gateway.go index b97033a4..951f4d4f 100644 --- a/packages/gateway-v2/gateway.go +++ b/packages/gateway-v2/gateway.go @@ -385,7 +385,9 @@ func (g *Gateway) reapIdleSessions() { func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) { sendHeartbeat := func() error { - capabilities := map[string]any{} + capabilities := map[string]any{ + CapabilityConnectionTest: true, + } if g.pkcs11Module != nil { capabilities[CapabilityPkcs11] = true } diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 2cafc47a..8b9982de 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -26,6 +26,8 @@ const ( testConnModeTCP = "tcp" ) +const CapabilityConnectionTest = "connectionTest" + // testConnectionEnvelope carries the fields common to every connection test; mode selects which client validates // it and which per-mode params struct the body is decoded into. The target host/port come from the signed cert. type testConnectionEnvelope struct { From d9509832949abb6e940c5d889188af489dc7932e Mon Sep 17 00:00:00 2001 From: Andre <120525481+x032205@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:41:28 -0400 Subject: [PATCH 6/6] Update packages/gateway-v2/test_connection_handler.go Co-authored-by: veria-ai[bot] <224490171+veria-ai[bot]@users.noreply.github.com> --- packages/gateway-v2/test_connection_handler.go | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gateway-v2/test_connection_handler.go b/packages/gateway-v2/test_connection_handler.go index 8b9982de..6a7a3c3c 100644 --- a/packages/gateway-v2/test_connection_handler.go +++ b/packages/gateway-v2/test_connection_handler.go @@ -67,6 +67,7 @@ func doPostgresConnectionTest(ctx context.Context, host string, port int, params return fmt.Errorf("failed to build connection config: %w", err) } config.Host = host + config.Fallbacks = nil config.Port = uint16(port) config.User = params.Username config.Password = params.Password