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/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 new file mode 100644 index 00000000..6a7a3c3c --- /dev/null +++ b/packages/gateway-v2/test_connection_handler.go @@ -0,0 +1,175 @@ +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 +) + +const ( + testConnModePostgres = "postgres" + testConnModeSSH = "ssh" + 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 { + Mode string `json:"mode"` // "postgres" | "ssh" | "tcp" + TimeoutMs int `json:"timeoutMs"` +} + +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"` +} + +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, params postgresTestParams) error { + config, err := pgx.ParseConfig("") + if err != nil { + 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 + config.Database = params.Database + + if params.SslEnabled { + rejectUnauthorized := params.SslRejectUnauthorized == nil || *params.SslRejectUnauthorized + tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: !rejectUnauthorized} + if params.SslCertificate != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(params.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 testConnModePostgres: + 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: params.AuthMethod, + Username: params.Username, + Password: params.Password, + PrivateKey: params.PrivateKey, + Certificate: params.Certificate, + TimeoutMs: env.TimeoutMs, + }) + case testConnModeTCP: + 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}}) +}