-
Notifications
You must be signed in to change notification settings - Fork 55
feat(pam): connection test protocol #318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
x032205 marked this conversation as resolved.
|
||
| 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() | ||
|
x032205 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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{ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High: SSH password can be sent to an unverified server This passes connection-test credentials to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way we do test is consistent with discovery. I don't think the attack surface is large especially since gateways are usually within regulated private networks. |
||
| 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()) | ||
|
x032205 marked this conversation as resolved.
|
||
| return | ||
| } | ||
| writeRPCJSON(w, http.StatusOK, testConnectionResponse{Result: testConnectionResult{Ok: true}}) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.