From 3eff3a9145e02d5141624f793c63825f3feffdc2 Mon Sep 17 00:00:00 2001 From: Nathan Brahms Date: Tue, 1 Oct 2024 16:44:42 -0700 Subject: [PATCH] refactor: Separate CSP-specific functionality from generic SSH code (#122) For maintainability, anything CSP-specific for SSH should be contained within the CSP's SSH provider object. I also sorted the provider properties. --- src/plugins/aws/ssh.ts | 86 +++++++++++++++++++--------- src/plugins/google/ssh.ts | 115 +++++++++++++++++++++++++++----------- src/plugins/ssh/index.ts | 92 +++++++----------------------- src/types/ssh.ts | 54 +++++++++++++----- 4 files changed, 203 insertions(+), 144 deletions(-) diff --git a/src/plugins/aws/ssh.ts b/src/plugins/aws/ssh.ts index 7c9a0e5..31fd085 100644 --- a/src/plugins/aws/ssh.ts +++ b/src/plugins/aws/ssh.ts @@ -31,34 +31,38 @@ const MAX_SSH_RETRIES = 30; /** The name of the SessionManager port forwarding document. This document is managed by AWS. */ const START_SSH_SESSION_DOCUMENT_NAME = "AWS-StartSSHSession"; +/**There are 2 cases of unprovisioned access in AWS + * 1. SSM:StartSession action is missing either on the SSM document (AWS-StartSSHSession) or the EC2 instance + * 2. Temporary error when issuing an SCP command + * + * 1: results in UNAUTHORIZED_START_SESSION_MESSAGE + * 2: results in CONNECTION_CLOSED_MESSAGE + */ +const unprovisionedAccessPatterns = [ + /** Matches the error message that AWS SSM prints when access is not propagated */ + // Note that the resource will randomly be either the SSM document or the EC2 instance + { + pattern: + /An error occurred \(AccessDeniedException\) when calling the StartSession operation: User: arn:aws:sts::.*:assumed-role\/P0GrantsRole.* is not authorized to perform: ssm:StartSession on resource: arn:aws:.*:.*:.* because no identity-based policy allows the ssm:StartSession action/, + }, + /** + * Matches the following error messages that AWS SSM pints when ssh authorized + * key access hasn't propagated to the instance yet. + * - Connection closed by UNKNOWN port 65535 + * - scp: Connection closed + * - kex_exchange_identification: Connection closed by remote host + */ + { + pattern: /\bConnection closed\b.*\b(?:by UNKNOWN port \d+|by remote host)?/, + }, +] as const; + export const awsSshProvider: SshProvider< AwsSshPermissionSpec, undefined, AwsSshRequest, AwsCredentials > = { - requestToSsh: (request) => { - const { permission, generated } = request; - const { instanceId, accountId, region } = permission.spec; - const { idc, ssh, name } = generated; - const { linuxUserName } = ssh; - const common = { linuxUserName, accountId, region, id: instanceId }; - return !idc - ? { ...common, role: name, type: "aws", access: "role" } - : { - ...common, - idc, - permissionSet: name, - type: "aws", - access: "idc", - }; - }, - toCliRequest: async (request) => ({ ...request, cliLocalData: undefined }), - ensureInstall: async () => { - if (!(await ensureSsmInstall())) { - throw "Please try again after installing the required AWS utilities"; - } - }, cloudProviderLogin: async (authn, request) => { const { config } = await getAwsConfig(authn, request.accountId); if (!config.login?.type || config.login?.type === "iam") { @@ -71,6 +75,19 @@ export const awsSshProvider: SshProvider< ? await assumeRoleWithOktaSaml(authn, request as AwsSshRoleRequest) : throwAssertNever(config.login); }, + + ensureInstall: async () => { + if (!(await ensureSsmInstall())) { + throw "Please try again after installing the required AWS utilities"; + } + }, + + friendlyName: "AWS", + + maxRetries: MAX_SSH_RETRIES, + + preTestAccessPropagationArgs: () => undefined, + proxyCommand: (request) => { return [ "aws", @@ -86,6 +103,7 @@ export const awsSshProvider: SshProvider< '"portNumber=%p"', ]; }, + reproCommands: (request) => { // TODO: Add manual commands for IDC login if (request.access !== "idc") { @@ -95,7 +113,25 @@ export const awsSshProvider: SshProvider< } return undefined; }, - preTestAccessPropagationArgs: () => undefined, - maxRetries: MAX_SSH_RETRIES, - friendlyName: "AWS", + + requestToSsh: (request) => { + const { permission, generated } = request; + const { instanceId, accountId, region } = permission.spec; + const { idc, ssh, name } = generated; + const { linuxUserName } = ssh; + const common = { linuxUserName, accountId, region, id: instanceId }; + return !idc + ? { ...common, role: name, type: "aws", access: "role" } + : { + ...common, + idc, + permissionSet: name, + type: "aws", + access: "idc", + }; + }, + + toCliRequest: async (request) => ({ ...request, cliLocalData: undefined }), + + unprovisionedAccessPatterns, }; diff --git a/src/plugins/google/ssh.ts b/src/plugins/google/ssh.ts index 0aa29d4..b7280d9 100644 --- a/src/plugins/google/ssh.ts +++ b/src/plugins/google/ssh.ts @@ -20,35 +20,75 @@ import { GcpSshPermissionSpec, GcpSshRequest } from "./types"; */ const MAX_SSH_RETRIES = 120; +/** + * There are 7 cases of unprovisioned access in Google Cloud. + * These are all potentially subject to propagation delays. + * 1. The linux user name is not present in the user's Google Workspace profile `posixAccounts` attribute + * 2. The public key is not present in the user's Google Workspace profile `sshPublicKeys` attribute + * 3. The user cannot act as the service account of the compute instance + * 4. The user cannot tunnel through the IAP tunnel to the instance + * 5. The user doesn't have osLogin or osAdminLogin role to the instance + * 5.a. compute.instances.get permission is missing + * 5.b. compute.instances.osLogin permission is missing + * 6. compute.instances.osAdminLogin is not provisioned but compute.instances.osLogin is - happens when a user upgrades existing access to sudo + * 7: Rare occurrence, the exact conditions so far undetermined (together with CONNECTION_CLOSED_MESSAGE) + * + * 1, 2, 3 (yes!), 5b: result in PUBLIC_KEY_DENIED_MESSAGE + * 4: results in UNAUTHORIZED_TUNNEL_USER_MESSAGE and also CONNECTION_CLOSED_MESSAGE + * 5a: results in UNAUTHORIZED_INSTANCES_GET_MESSAGE + * 6: results in SUDO_MESSAGE + * 7: results in DESTINATION_READ_ERROR and also CONNECTION_CLOSED_MESSAGE + */ +const unprovisionedAccessPatterns = [ + { pattern: /Permission denied \(publickey\)/ }, + { + // The output of `sudo -v` when the user is not allowed to run sudo + pattern: /Sorry, user .+ may not run sudo on .+/, + }, + { pattern: /Error while connecting \[4033: 'not authorized'\]/ }, + { + pattern: /Required 'compute\.instances\.get' permission/, + validationWindowMs: 30e3, + }, + { pattern: /Error while connecting \[4010: 'destination read failed'\]/ }, +] as const; + export const gcpSshProvider: SshProvider< GcpSshPermissionSpec, { linuxUserName: string }, GcpSshRequest > = { - requestToSsh: (request) => { - return { - id: request.permission.spec.instanceName, - projectId: request.permission.spec.projectId, - zone: request.permission.spec.zone, - linuxUserName: request.cliLocalData.linuxUserName, - type: "gcloud", - }; - }, - toCliRequest: async (request, options) => ({ - ...request, - cliLocalData: { - linuxUserName: await importSshKey( - request.permission.spec.publicKey, - options - ), - }, - }), + // TODO support login with Google Cloud + cloudProviderLogin: async () => undefined, + ensureInstall: async () => { if (!(await ensureGcpSshInstall())) { throw "Please try again after installing the required GCP utilities"; } }, - cloudProviderLogin: async () => undefined, // TODO @ENG-2284 support login with Google Cloud + + friendlyName: "Google Cloud", + + loginRequiredMessage: + "Please login to Google Cloud CLI with 'gcloud auth login'", + + loginRequiredPattern: /You do not currently have an active account selected/, + + maxRetries: MAX_SSH_RETRIES, + + preTestAccessPropagationArgs: (cmdArgs) => { + if (isSudoCommand(cmdArgs)) { + return { + ...cmdArgs, + // `sudo -v` prints `Sorry, user may not run sudo on .` to stderr when user is not a sudoer. + // It prints nothing to stdout when user is a sudoer - which is important because we don't want any output from the pre-test. + command: "sudo", + arguments: ["-v"], + }; + } + return undefined; + }, + proxyCommand: (request) => { return [ "gcloud", @@ -65,19 +105,28 @@ export const gcpSshProvider: SshProvider< `--project=${request.projectId}`, ]; }, - reproCommands: () => undefined, // TODO @ENG-2284 support login with Google Cloud - preTestAccessPropagationArgs: (cmdArgs) => { - if (isSudoCommand(cmdArgs)) { - return { - ...cmdArgs, - // `sudo -v` prints `Sorry, user may not run sudo on .` to stderr when user is not a sudoer. - // It prints nothing to stdout when user is a sudoer - which is important because we don't want any output from the pre-test. - command: "sudo", - arguments: ["-v"], - }; - } - return undefined; + + reproCommands: () => undefined, + + requestToSsh: (request) => { + return { + id: request.permission.spec.instanceName, + projectId: request.permission.spec.projectId, + zone: request.permission.spec.zone, + linuxUserName: request.cliLocalData.linuxUserName, + type: "gcloud", + }; }, - maxRetries: MAX_SSH_RETRIES, - friendlyName: "Google Cloud", + + unprovisionedAccessPatterns, + + toCliRequest: async (request, options) => ({ + ...request, + cliLocalData: { + linuxUserName: await importSshKey( + request.permission.spec.publicKey, + options + ), + }, + }), }; diff --git a/src/plugins/ssh/index.ts b/src/plugins/ssh/index.ts index 103267f..a231892 100644 --- a/src/plugins/ssh/index.ts +++ b/src/plugins/ssh/index.ts @@ -23,30 +23,6 @@ import { } from "node:child_process"; import { Readable } from "node:stream"; -/** Matches the error message that AWS SSM print1 when access is not propagated */ -// Note that the resource will randomly be either the SSM document or the EC2 instance -const UNAUTHORIZED_START_SESSION_MESSAGE = - /An error occurred \(AccessDeniedException\) when calling the StartSession operation: User: arn:aws:sts::.*:assumed-role\/P0GrantsRole.* is not authorized to perform: ssm:StartSession on resource: arn:aws:.*:.*:.* because no identity-based policy allows the ssm:StartSession action/; -/** - * Matches the following error messages that AWS SSM print1 when ssh authorized - * key access hasn't propagated to the instance yet. - * - Connection closed by UNKNOWN port 65535 - * - scp: Connection closed - * - kex_exchange_identification: Connection closed by remote host - */ -const CONNECTION_CLOSED_MESSAGE = - /\bConnection closed\b.*\b(?:by UNKNOWN port \d+|by remote host)?/; -const PUBLIC_KEY_DENIED_MESSAGE = /Permission denied \(publickey\)/; -const UNAUTHORIZED_TUNNEL_USER_MESSAGE = - /Error while connecting \[4033: 'not authorized'\]/; -const UNAUTHORIZED_INSTANCES_GET_MESSAGE = - /Required 'compute\.instances\.get' permission/; -const DESTINATION_READ_ERROR = - /Error while connecting \[4010: 'destination read failed'\]/; -const GOOGLE_LOGIN_MESSAGE = - /You do not currently have an active account selected/; -const SUDO_MESSAGE = /Sorry, user .+ may not run sudo on .+/; // The output of `sudo -v` when the user is not allowed to run sudo - /** Maximum amount of time after SSH subprocess starts to check for {@link UNPROVISIONED_ACCESS_MESSAGES} * in the process's stderr */ @@ -54,44 +30,6 @@ const DEFAULT_VALIDATION_WINDOW_MS = 5e3; const RETRY_DELAY_MS = 5000; -/** - * AWS - * There are 2 cases of unprovisioned access in AWS - * 1. SSM:StartSession action is missing either on the SSM document (AWS-StartSSHSession) or the EC2 instance - * 2. Temporary error when issuing an SCP command - * - * 1: results in UNAUTHORIZED_START_SESSION_MESSAGE - * 2: results in CONNECTION_CLOSED_MESSAGE - * - * Google Cloud - * There are 7 cases of unprovisioned access in Google Cloud. - * These are all potentially subject to propagation delays. - * 1. The linux user name is not present in the user's Google Workspace profile `posixAccounts` attribute - * 2. The public key is not present in the user's Google Workspace profile `sshPublicKeys` attribute - * 3. The user cannot act as the service account of the compute instance - * 4. The user cannot tunnel through the IAP tunnel to the instance - * 5. The user doesn't have osLogin or osAdminLogin role to the instance - * 5.a. compute.instances.get permission is missing - * 5.b. compute.instances.osLogin permission is missing - * 6. compute.instances.osAdminLogin is not provisioned but compute.instances.osLogin is - happens when a user upgrades existing access to sudo - * 7: Rare occurrence, the exact conditions so far undetermined (together with CONNECTION_CLOSED_MESSAGE) - * - * 1, 2, 3 (yes!), 5b: result in PUBLIC_KEY_DENIED_MESSAGE - * 4: results in UNAUTHORIZED_TUNNEL_USER_MESSAGE and also CONNECTION_CLOSED_MESSAGE - * 5a: results in UNAUTHORIZED_INSTANCES_GET_MESSAGE - * 6: results in SUDO_MESSAGE - * 7: results in DESTINATION_READ_ERROR and also CONNECTION_CLOSED_MESSAGE - */ -const UNPROVISIONED_ACCESS_MESSAGES = [ - { pattern: UNAUTHORIZED_START_SESSION_MESSAGE }, - { pattern: CONNECTION_CLOSED_MESSAGE }, - { pattern: PUBLIC_KEY_DENIED_MESSAGE }, - { pattern: SUDO_MESSAGE }, - { pattern: UNAUTHORIZED_TUNNEL_USER_MESSAGE }, - { pattern: UNAUTHORIZED_INSTANCES_GET_MESSAGE, validationWindowMs: 30e3 }, - { pattern: DESTINATION_READ_ERROR }, -]; - /** Checks if access has propagated through AWS to the SSM agent * * AWS takes about 8 minutes, GCP takes under 1 minute @@ -109,11 +47,12 @@ const UNPROVISIONED_ACCESS_MESSAGES = [ * do not capture stderr emitted from the wrapped shell session. */ const accessPropagationGuard = ( + provider: SshProvider, child: ChildProcessByStdio, debug?: boolean ) => { let isEphemeralAccessDeniedException = false; - let isGoogleLoginException = false; + let isLoginException = false; const beforeStart = Date.now(); child.stderr.on("data", (chunk) => { @@ -121,7 +60,7 @@ const accessPropagationGuard = ( if (debug) print2(chunkString); - const match = UNPROVISIONED_ACCESS_MESSAGES.find((message) => + const match = provider.unprovisionedAccessPatterns.find((message) => chunkString.match(message.pattern) ); @@ -133,16 +72,19 @@ const accessPropagationGuard = ( isEphemeralAccessDeniedException = true; } - const googleLoginMatch = chunkString.match(GOOGLE_LOGIN_MESSAGE); - isGoogleLoginException = isGoogleLoginException || !!googleLoginMatch; // once true, always true - if (isGoogleLoginException) { + if (provider.loginRequiredPattern) { + const loginMatch = chunkString.match(provider.loginRequiredPattern); + isLoginException = isLoginException || !!loginMatch; // once true, always true + } + + if (isLoginException) { isEphemeralAccessDeniedException = false; // always overwrite to false so we don't retry the access } }); return { isAccessPropagated: () => !isEphemeralAccessDeniedException, - isGoogleLoginException: () => isGoogleLoginException, + isLoginException: () => isLoginException, }; }; @@ -203,8 +145,11 @@ async function spawnSshNode( ); // TODO ENG-2284 support login with Google Cloud: currently return a boolean to indicate if the exception was a Google login error. - const { isAccessPropagated, isGoogleLoginException } = - accessPropagationGuard(child, options.debug); + const { isAccessPropagated, isLoginException } = accessPropagationGuard( + provider, + child, + options.debug + ); const exitListener = child.on("exit", (code) => { exitListener.unref(); @@ -229,8 +174,11 @@ async function spawnSshNode( .catch(reject); return; - } else if (isGoogleLoginException()) { - reject(`Please login to Google Cloud CLI with 'gcloud auth login'`); + } else if (isLoginException()) { + reject( + provider.loginRequiredMessage ?? + `Please log in to the ${provider.friendlyName} CLI to SSH` + ); return; } diff --git a/src/types/ssh.ts b/src/types/ssh.ts index 738670f..99d867f 100644 --- a/src/types/ssh.ts +++ b/src/types/ssh.ts @@ -42,20 +42,24 @@ export type SshProvider< SR extends SshRequest = SshRequest, C extends object | undefined = undefined, // credentials object > = { - requestToSsh: (request: CliPermissionSpec) => SR; - /** Converts a backend request to a CLI request */ - toCliRequest: ( - request: Request, - options?: { debug?: boolean } - ) => Promise>; - ensureInstall: () => Promise; /** Logs in the user to the cloud provider */ cloudProviderLogin: (authn: Authn, request: SR) => Promise; - /** Returns the command and its arguments that are going to be injected as the ssh ProxyCommand option */ - proxyCommand: (request: SR) => string[]; - /** Each element in the returned array is a command that can be run to reproduce the - * steps of logging in the user to the ssh session. */ - reproCommands: (request: SR) => string[] | undefined; + + /** Callback to ensure that this provider's CLI utils are installed */ + ensureInstall: () => Promise; + + /** A human-readable name for this CSP */ + friendlyName: string; + + /** Friendly message to ask the user to log in to the CSP */ + loginRequiredMessage?: string; + + /** Regex match for error string indicating that CSP login is required */ + loginRequiredPattern?: RegExp; + + /** Maximum number of times to call this provider's CLI SSH command */ + maxRetries: number; + /** Arguments for a pre-test command to verify access propagation prior * to actually logging in the user to the ssh session. * This must return arguments for a non-interactive command - meaning the `command` @@ -66,8 +70,30 @@ export type SshProvider< preTestAccessPropagationArgs: ( cmdArgs: CommandArgs ) => CommandArgs | undefined; - maxRetries: number; - friendlyName: string; + + /** Returns the command and its arguments that are going to be injected as the ssh ProxyCommand option */ + proxyCommand: (request: SR) => string[]; + + /** Each element in the returned array is a command that can be run to reproduce the + * steps of logging in the user to the ssh session. */ + reproCommands: (request: SR) => string[] | undefined; + + /** Unwraps this provider's types */ + requestToSsh: (request: CliPermissionSpec) => SR; + + /** Regex matches for error strings indicating that the provider has not yet fully provisioned node acces */ + unprovisionedAccessPatterns: readonly { + /** If the error matches this string, indicates that access is not provisioned */ + readonly pattern: RegExp; + /** Maximum amount of time to wait for provisioning after encountering this error */ + readonly validationWindowMs?: number; + }[]; + + /** Converts a backend request to a CLI request */ + toCliRequest: ( + request: Request, + options?: { debug?: boolean } + ) => Promise>; }; export type SshRequest = AwsSshRequest | GcpSshRequest;