diff --git a/backend/package-lock.json b/backend/package-lock.json index cfe22d916..9f359b223 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -128,6 +128,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -164,6 +165,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -15590,6 +15592,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sshpk": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz", @@ -18017,6 +18046,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -18857,6 +18895,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -24938,9 +24990,9 @@ } }, "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", "license": "MIT" }, "node_modules/nanoid": { @@ -31402,6 +31454,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/backend/package.json b/backend/package.json index 7a5efcb78..2db395e90 100644 --- a/backend/package.json +++ b/backend/package.json @@ -110,6 +110,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -256,6 +257,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts index b01c87151..079e92e98 100644 --- a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -1,3 +1,13 @@ +import { Client } from "ssh2"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; + +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { PamResource } from "../pam-resource-enums"; import { TPamResourceFactory, TPamResourceFactoryRotateAccountCredentials, @@ -6,6 +16,42 @@ import { import { SSHAuthMethod } from "./ssh-resource-enums"; import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types"; +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TSSHResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (proxyPort: number) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + return operation(proxyPort); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + export const sshResourceFactory: TPamResourceFactory = ( resourceType, connectionDetails, @@ -13,18 +59,164 @@ export const sshResourceFactory: TPamResourceFactory { const validateConnection = async () => { - return connectionDetails; + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + let handshakeComplete = false; + + client.on("error", (err) => { + logger.info( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH client error event received" + ); + // If we got an authentication error, it means we successfully reached the SSH server + // and completed the SSH handshake - that's good enough for connection validation + if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) { + logger.info( + { handshakeComplete, errorMessage: err.message }, + "[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)" + ); + client.end(); + resolve(); + } else { + logger.error( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH connection validation failed" + ); + reject(err); + } + }); + + client.on("handshake", () => { + // SSH handshake completed - the server is reachable and responding + logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true"); + handshakeComplete = true; + client.end(); + resolve(); + }); + + client.on("timeout", () => { + logger.error("[SSH Resource Factory] SSH connection timeout"); + reject(new Error("Connection timeout")); + }); + + // Attempt connection with a dummy username (we don't care about auth success) + // The goal is just to verify SSH server is reachable and responding + client.connect({ + host: "localhost", + port: proxyPort, + username: "infisical-connection-test", + password: "infisical-connection-test-password", + readyTimeout: EXTERNAL_REQUEST_TIMEOUT, + tryKeyboard: false, + // We want to fail fast on auth, we're just testing reachability + authHandler: () => { + // If authHandler is called, SSH handshake succeeded + handshakeComplete = true; + return false; // Don't continue with auth + } + }); + }); + }); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } }; const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( credentials ) => { - return credentials; + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + + client.on("ready", () => { + logger.info( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication successful" + ); + client.end(); + resolve(); + }); + + client.on("error", (err) => { + logger.error( + { error: err.message, username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication failed" + ); + reject(err); + }); + + client.on("timeout", () => { + logger.error( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication timeout" + ); + reject(new Error("Connection timeout")); + }); + + // Build connection config based on auth method + const baseConfig = { + host: "localhost", + port: proxyPort, + username: credentials.username, + readyTimeout: EXTERNAL_REQUEST_TIMEOUT + }; + + switch (credentials.authMethod) { + case SSHAuthMethod.Password: + client.connect({ + ...baseConfig, + password: credentials.password, + tryKeyboard: false + }); + break; + case SSHAuthMethod.PublicKey: + client.connect({ + ...baseConfig, + privateKey: credentials.privateKey, + tryKeyboard: false + }); + break; + default: + reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`)); + } + }); + }); + return credentials; + } catch (error) { + if (error instanceof Error) { + // Check for common authentication failure messages + if ( + error.message.includes("authentication") || + error.message.includes("All configured authentication methods failed") || + error.message.includes("publickey") + ) { + throw new BadRequestError({ + message: "Account credentials invalid: Username or credentials incorrect" + }); + } + + if (error.message === "Connection timeout") { + throw new BadRequestError({ + message: "Connection timeout. Verify that the SSH server is reachable" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } }; const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( - rotationAccountCredentials, - currentCredentials + rotationAccountCredentials ) => { return rotationAccountCredentials; }; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx index c6d303c63..0302e4759 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx @@ -1,5 +1,5 @@ -import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; @@ -30,23 +30,32 @@ type FormData = z.infer; export const SSHAccountForm = ({ account, onSubmit }: Props) => { const isUpdate = Boolean(account); + const getDefaultCredentials = () => { + if (!account) return undefined; + + if (account.credentials.authMethod === SSHAuthMethod.Password) { + return { + ...account.credentials, + password: UNCHANGED_PASSWORD_SENTINEL + }; + } + + if (account.credentials.authMethod === SSHAuthMethod.PublicKey) { + return { + ...account.credentials, + privateKey: UNCHANGED_PASSWORD_SENTINEL + }; + } + + return account.credentials; + }; + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: account ? { ...account, - credentials: - account.credentials.authMethod === SSHAuthMethod.Password - ? { - ...account.credentials, - password: UNCHANGED_PASSWORD_SENTINEL - } - : account.credentials.authMethod === SSHAuthMethod.PublicKey - ? { - ...account.credentials, - privateKey: UNCHANGED_PASSWORD_SENTINEL - } - : account.credentials + credentials: getDefaultCredentials() } : { name: "", diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx index b20b50fee..a616689eb 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx @@ -177,14 +177,14 @@ export const PamSessionRow = ({ session, search, filteredLogs }: Props) => { {filteredLogs.length > 0 && ( - {logsToShow.map((log, idx) => { + {logsToShow.map((log) => { // Handle command logs (database sessions) if ("input" in log && "output" in log) { const formattedInput = formatLogContent(log.input); return (
@@ -206,7 +206,7 @@ export const PamSessionRow = ({ session, search, filteredLogs }: Props) => { if ("data" in log && typeof log.data === "string") { return (