From 65ba642f841de4e38fdc701b8136817bc80333cc Mon Sep 17 00:00:00 2001 From: Andrew Adams Date: Fri, 22 Nov 2024 16:06:52 -0800 Subject: [PATCH] Initial implementation of Azure SSH (#143) This PR implements the initial version of Azure SSH for the P0 CLI. It is fully functional and works with P0 instances configured with an Azure iam-write integration and an Azure SSH integration. For more details, please see the PR (#143). --- src/commands/scp.ts | 8 ++ src/commands/shared/ssh.ts | 25 ++++++- src/commands/ssh.ts | 10 ++- src/plugins/aws/ssh.ts | 3 + src/plugins/azure/auth.ts | 18 +++++ src/plugins/azure/keygen.ts | 49 +++++++++++++ src/plugins/azure/ssh.ts | 97 ++++++++++++++++++++----- src/plugins/azure/tunnel.ts | 141 ++++++++++++++++++++++++++++++++++++ src/plugins/azure/types.ts | 27 ++++--- src/plugins/google/ssh.ts | 3 + src/plugins/ssh/index.ts | 85 +++++++++++++++------- src/types/ssh.ts | 9 ++- 12 files changed, 415 insertions(+), 60 deletions(-) create mode 100644 src/plugins/azure/auth.ts create mode 100644 src/plugins/azure/keygen.ts create mode 100644 src/plugins/azure/tunnel.ts diff --git a/src/commands/scp.ts b/src/commands/scp.ts index b067061..ba5de2f 100644 --- a/src/commands/scp.ts +++ b/src/commands/scp.ts @@ -84,6 +84,14 @@ const scpAction = async (args: yargs.ArgumentsCamelCase) => { : []; args.sshOptions = sshOptions; + // TODO(ENG-3142): Azure SSH currently doesn't support specifying a port; throw an error if one is set. + if ( + args.provider === "azure" && + sshOptions.some((opt) => opt.startsWith("-P")) + ) { + throw "Azure SSH does not currently support specifying a port. SSH on the target VM must be listening on the default port 22."; + } + const host = getHostIdentifier(args.source, args.destination); if (!host) { diff --git a/src/commands/shared/ssh.ts b/src/commands/shared/ssh.ts index fa3e8d8..04ec3a7 100644 --- a/src/commands/shared/ssh.ts +++ b/src/commands/shared/ssh.ts @@ -54,6 +54,17 @@ export type SshCommandArgs = BaseSshCommandArgs & { export type CommandArgs = ScpCommandArgs | SshCommandArgs; +export type SshAdditionalSetup = { + /** A list of SSH configuration options, as would be used after '-o' in an SSH command */ + sshOptions: string[]; + + /** The port to connect to, overriding the default */ + port: string; + + /** Perform any teardown required after the SSH command exits but before terminating the P0 CLI */ + teardown: () => Promise; +}; + export const SSH_PROVIDERS: Record< SupportedSshProvider, SshProvider @@ -81,6 +92,7 @@ const validateSshInstall = async ( value.state == "installed" && providersToCheck.some((prefix) => key.startsWith(prefix)) ); + if (items.length === 0) { throw "This organization is not configured for SSH access via the P0 CLI"; } @@ -139,9 +151,6 @@ export const provisionRequest = async ( authn, id ); - if (provisionedRequest.permission.publicKey !== publicKey) { - throw "Public key mismatch. Please revoke the request and try again."; - } return { provisionedRequest, publicKey, privateKey }; }; @@ -156,9 +165,17 @@ export const prepareRequest = async ( throw "Server did not return a request id. Please contact support@p0.dev for assistance."; } - const { provisionedRequest } = result; + const { provisionedRequest, publicKey } = result; const sshProvider = SSH_PROVIDERS[provisionedRequest.permission.provider]; + + if ( + sshProvider.validateSshKey && + !sshProvider.validateSshKey(provisionedRequest, publicKey) + ) { + throw "Public key mismatch. Please revoke the request and try again."; + } + await sshProvider.ensureInstall(); const cliRequest = await pluginToCliRequest(provisionedRequest, { diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index 1d1a361..7dc9646 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -51,7 +51,7 @@ export const sshCommand = (yargs: yargs.Argv) => .option("provider", { type: "string", describe: "The cloud provider where the instance is hosted", - choices: ["aws", "gcloud"], + choices: ["aws", "azure", "gcloud"], }) .option("debug", { type: "boolean", @@ -89,6 +89,14 @@ const sshAction = async (args: yargs.ArgumentsCamelCase) => { : []; args.sshOptions = sshOptions; + // TODO(ENG-3142): Azure SSH currently doesn't support specifying a port; throw an error if one is set. + if ( + args.provider === "azure" && + sshOptions.some((opt) => opt.startsWith("-p")) + ) { + throw "Azure SSH does not currently support specifying a port. SSH on the target VM must be listening on the default port 22."; + } + const { request, privateKey, sshProvider } = await prepareRequest( authn, args, diff --git a/src/plugins/aws/ssh.ts b/src/plugins/aws/ssh.ts index 22fbe32..4bdb16a 100644 --- a/src/plugins/aws/ssh.ts +++ b/src/plugins/aws/ssh.ts @@ -72,6 +72,9 @@ export const awsSshProvider: SshProvider< : throwAssertNever(config.login); }, + validateSshKey: (request, publicKey) => + request.permission.publicKey === publicKey, + ensureInstall: async () => { if (!(await ensureSsmInstall())) { throw "Please try again after installing the required AWS utilities"; diff --git a/src/plugins/azure/auth.ts b/src/plugins/azure/auth.ts new file mode 100644 index 0000000..f115f84 --- /dev/null +++ b/src/plugins/azure/auth.ts @@ -0,0 +1,18 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { exec } from "../../util"; + +export const azLogin = async (subscriptionId: string) => { + await exec("az", ["login"], { check: true }); + await exec("az", ["account", "set", "--subscription", subscriptionId], { + check: true, + }); +}; diff --git a/src/plugins/azure/keygen.ts b/src/plugins/azure/keygen.ts new file mode 100644 index 0000000..d5db49b --- /dev/null +++ b/src/plugins/azure/keygen.ts @@ -0,0 +1,49 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { print2 } from "../../drivers/stdio"; +import { exec } from "../../util"; +import path from "node:path"; +import tmp from "tmp-promise"; + +// We pass in the name of the certificate file to generate +export const AD_CERT_FILENAME = "p0cli-azure-ad-ssh-cert.pub"; + +// The `az ssh cert` command manages key generation, and generates SSH RSA keys with the standard names +export const AD_SSH_KEY_PRIVATE = "id_rsa"; + +export const createTempDirectoryForKeys = async (): Promise<{ + path: string; + cleanup: () => Promise; +}> => { + // unsafeCleanup lets us delete the directory even if there are still files in it, which is fine since the + // files are no longer needed once we've authenticated to the remote system. + const { path, cleanup } = await tmp.dir({ + mode: 0o700, + prefix: "p0cli-", + unsafeCleanup: true, + }); + + return { path, cleanup }; +}; + +export const generateSshKeyAndAzureAdCert = async (keyPath: string) => { + try { + await exec( + "az", + ["ssh", "cert", "--file", path.join(keyPath, AD_CERT_FILENAME)], + { check: true } + ); + } catch (error: any) { + print2(error.stdout); + print2(error.stderr); + throw `Failed to generate Azure AD SSH certificate: ${error}`; + } +}; diff --git a/src/plugins/azure/ssh.ts b/src/plugins/azure/ssh.ts index df48f7f..fb5e9d0 100644 --- a/src/plugins/azure/ssh.ts +++ b/src/plugins/azure/ssh.ts @@ -9,24 +9,33 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ import { SshProvider } from "../../types/ssh"; -import { exec } from "../../util"; -import { importSshKey } from "../google/ssh-key"; +import { azLogin } from "./auth"; import { ensureAzInstall } from "./install"; -import { AzureSshPermissionSpec, AzureSshRequest } from "./types"; +import { + AD_CERT_FILENAME, + AD_SSH_KEY_PRIVATE, + createTempDirectoryForKeys, + generateSshKeyAndAzureAdCert, +} from "./keygen"; +import { trySpawnBastionTunnel } from "./tunnel"; +import { + AzureLocalData, + AzureSshPermissionSpec, + AzureSshRequest, +} from "./types"; +import path from "node:path"; // TODO: Determine what this value should be for Azure const PROPAGATION_TIMEOUT_LIMIT_MS = 2 * 60 * 1000; export const azureSshProvider: SshProvider< AzureSshPermissionSpec, - { linuxUserName: string }, + AzureLocalData, AzureSshRequest > = { // TODO: Natively support Azure login in P0 CLI cloudProviderLogin: async () => { - // Always invoke `az login` before each SSH access. This is needed because - // Azure permissions are only updated upon login. - await exec("az", ["login"]); + // Login is handled as part of setup() below return undefined; }, @@ -45,31 +54,81 @@ export const azureSshProvider: SshProvider< propagationTimeoutMs: PROPAGATION_TIMEOUT_LIMIT_MS, - // TODO: Implement + // TODO(ENG-3149): Implement sudo access checks here preTestAccessPropagationArgs: () => undefined, - // TODO: Determine if necessary + // Azure doesn't support ProxyCommand, as nice as that would be. Yet. proxyCommand: () => [], // TODO: Determine if necessary reproCommands: () => undefined, - // TODO: Placeholder + setup: async (request) => { + // TODO(ENG-3129): Does this specifically need to be the subscription ID for the Bastion? + await azLogin(request.subscriptionId); // Always re-login to Azure CLI + + const { path: keyPath, cleanup: sshKeyPathCleanup } = + await createTempDirectoryForKeys(); + + const wrappedCreateCertAndTunnel = async () => { + try { + await generateSshKeyAndAzureAdCert(keyPath); + return await trySpawnBastionTunnel(request); + } catch (error: any) { + await sshKeyPathCleanup(); + throw error; + } + }; + + const { killTunnel, tunnelLocalPort } = await wrappedCreateCertAndTunnel(); + + const sshPrivateKeyPath = path.join(keyPath, AD_SSH_KEY_PRIVATE); + const sshCertificateKeyPath = path.join(keyPath, AD_CERT_FILENAME); + + const teardown = async () => { + await killTunnel(); + await sshKeyPathCleanup(); + }; + + return { + sshOptions: [ + `IdentityFile ${sshPrivateKeyPath}`, + `CertificateFile ${sshCertificateKeyPath}`, + "IdentitiesOnly yes", + + // Because we connect to the Azure Network Bastion tunnel via a local port instead of a ProxyCommand, every + // instance connected to will appear to `ssh` to be the same host but presenting a different host key (i.e., + // `ssh` always connects to localhost but each VM will present its own host key), which will trigger MITM attack + // warnings. We disable host key checking to avoid this. This is ordinarily very dangerous, but in this case, + // security of the connection is ensured by the Azure Bastion Network tunnel, which utilizes HTTPS and thus has + // its own MITM protection. + "StrictHostKeyChecking no", + "UserKnownHostsFile /dev/null", + ], + port: tunnelLocalPort, + teardown, + }; + }, + requestToSsh: (request) => ({ type: "azure", - id: request.permission.resource.instanceId, + id: "localhost", + ...request.cliLocalData, instanceId: request.permission.resource.instanceId, - linuxUserName: request.cliLocalData.linuxUserName, + subscriptionId: request.permission.resource.subscriptionId, + instanceResourceGroup: request.permission.resource.resourceGroupId, + bastionId: request.permission.bastionHostId, }), // TODO: Implement unprovisionedAccessPatterns: [], - // TODO: Placeholder - toCliRequest: async (request, options) => ({ - ...request, - cliLocalData: { - linuxUserName: await importSshKey(request.permission.publicKey, options), - }, - }), + toCliRequest: async (request) => { + return { + ...request, + cliLocalData: { + linuxUserName: request.principal, + }, + }; + }, }; diff --git a/src/plugins/azure/tunnel.ts b/src/plugins/azure/tunnel.ts new file mode 100644 index 0000000..0259770 --- /dev/null +++ b/src/plugins/azure/tunnel.ts @@ -0,0 +1,141 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { retryWithSleep } from "../../common/retry"; +import { print2 } from "../../drivers/stdio"; +import { sleep } from "../../util"; +import { AzureSshRequest } from "./types"; +import { spawn } from "node:child_process"; + +const TUNNEL_READY_STRING = "Tunnel is ready"; +const SPAWN_TUNNEL_TRIES = 3; + +export type BastionTunnelMeta = { + killTunnel: () => Promise; + tunnelLocalPort: string; +}; + +const selectRandomPort = (): string => { + // The IANA ephemeral port range is 49152 to 65535, inclusive. Pick a random value in that range. + // If the port is in use (unlikely but possible), we can just generate a new value and try again. + // 16384 is 65535 - 49152 + 1, the number of possible ports in the range. + const port = Math.floor(Math.random() * 16384) + 49152; + return port.toString(); +}; + +const spawnBastionTunnelInBackground = ( + request: AzureSshRequest, + port: string +): Promise => { + return new Promise((resolve, reject) => { + let processSignalledToExit = false; + let processExited = false; + let stdout = ""; + let stderr = ""; + + const child = spawn( + "az", + [ + "network", + "bastion", + "tunnel", + "--ids", + request.bastionId, + "--target-resource-id", + request.instanceId, + "--resource-port", + "22", + "--port", + port, + ], + // Spawn the process in detached mode so that it is in its own process group; this lets us kill it and all + // descendent processes together. + { detached: true } + ); + + child.on("exit", (code) => { + processExited = true; + if (code === 0) return; + + print2(stdout); + print2(stderr); + reject( + `Error running Azure Network Bastion tunnel; tunnel process ended with status ${code}` + ); + }); + + child.stdout.on("data", (data) => { + const str = data.toString("utf-8"); + stdout += str; + }); + + child.stderr.on("data", (data) => { + const str = data.toString("utf-8"); + stderr += str; + + if (str.includes(TUNNEL_READY_STRING)) { + print2("Azure Bastion tunnel is ready."); + + resolve({ + killTunnel: async () => { + if (processSignalledToExit || processExited) return; + + processSignalledToExit = true; + + if (child.pid) { + // Kill the process and all its descendents via killing the process group; this is only possible + // because we launched the process with `detached: true` above. This is necessary because `az` is + // actually a bash script that spawns a Python process, and we need to kill the Python process as well. + // SIGINT is equivalent to pressing Ctrl-C in the terminal; allows for the tunnel process to perform any + // necessary cleanup of its own before exiting. The negative PID is what indicates that we want to kill + // the whole process group. + try { + process.kill(-child.pid, "SIGINT"); + + // Give the tunnel a chance to quit gracefully after the SIGINT by waiting at least 250 ms and up to + // 5 seconds. If the process is still running after that, it's probably hung; SIGKILL it to force it to + // end immediately. + for (let spins = 0; spins < 20; spins++) { + await sleep(250); + + if (processExited) { + return; + } + } + + process.kill(-child.pid, "SIGKILL"); + } catch (error: any) { + // Ignore the error and move on; we might as well just exit without waiting since we can't control + // the child process, for whatever reason + print2(`Failed to kill Azure Bastion tunnel process: ${error}`); + child.unref(); + } + } + }, + tunnelLocalPort: port, + }); + } + }); + }); +}; + +export const trySpawnBastionTunnel = async ( + request: AzureSshRequest +): Promise => { + // Attempt to spawn the tunnel SPAWN_TUNNEL_TRIES times, picking a new port each time. If we fail + // too many times, then the problem is likely not the port, but something else. + + return await retryWithSleep( + () => spawnBastionTunnelInBackground(request, selectRandomPort()), + () => true, + SPAWN_TUNNEL_TRIES, + 1000 + ); +}; diff --git a/src/plugins/azure/types.ts b/src/plugins/azure/types.ts index 7c1fa09..91c51af 100644 --- a/src/plugins/azure/types.ts +++ b/src/plugins/azure/types.ts @@ -14,10 +14,9 @@ import { CommonSshPermissionSpec } from "../ssh/types"; export type AzureSshPermissionSpec = PermissionSpec<"ssh", AzureSshPermission>; -// TODO: Placeholder; confirm this is correct export type AzureSsh = CliPermissionSpec< AzureSshPermissionSpec, - { linuxUserName: string } + AzureLocalData >; export type AzureSshPermission = CommonSshPermissionSpec & { @@ -25,26 +24,36 @@ export type AzureSshPermission = CommonSshPermissionSpec & { destination: string; parent: string | undefined; group: string | undefined; + bastionHostId: string; + principal: string; resource: { - instanceName: string; instanceId: string; - subscriptionId: string; + instanceName: string; subscriptionName: string; resourceGroupId: string; + subscriptionId: string; region: string; networkInterfaceIds: string[]; }; }; -// TODO: Placeholder; probably wrong export type AzureNodeSpec = { - type: "azure"; instanceId: string; sudo?: boolean; }; -// TODO: Placeholder; probably wrong -export type AzureSshRequest = AzureNodeSpec & { - id: string; +export type AzureBastionSpec = { + bastionId: string; +}; + +export type AzureSshRequest = AzureNodeSpec & + AzureBastionSpec & + AzureLocalData & { + type: "azure"; + id: "localhost"; // Azure SSH always connects to the local tunnel + subscriptionId: string; + }; + +export type AzureLocalData = { linuxUserName: string; }; diff --git a/src/plugins/google/ssh.ts b/src/plugins/google/ssh.ts index 0e0aca3..8cf77f3 100644 --- a/src/plugins/google/ssh.ts +++ b/src/plugins/google/ssh.ts @@ -64,6 +64,9 @@ export const gcpSshProvider: SshProvider< } }, + validateSshKey: (request, publicKey) => + request.permission.publicKey === publicKey, + friendlyName: "Google Cloud", loginRequiredMessage: diff --git a/src/plugins/ssh/index.ts b/src/plugins/ssh/index.ts index 7af674a..ba3713d 100644 --- a/src/plugins/ssh/index.ts +++ b/src/plugins/ssh/index.ts @@ -8,7 +8,11 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ -import { CommandArgs, SSH_PROVIDERS } from "../../commands/shared/ssh"; +import { + CommandArgs, + SSH_PROVIDERS, + SshAdditionalSetup, +} from "../../commands/shared/ssh"; import { PRIVATE_KEY_PATH } from "../../common/keys"; import { print2 } from "../../drivers/stdio"; import { Authn } from "../../types/identity"; @@ -207,10 +211,16 @@ async function spawnSshNode( const createCommand = ( data: SshRequest, args: CommandArgs, + setupData: SshAdditionalSetup | undefined, proxyCommand: string[] ) => { addCommonArgs(args, proxyCommand); + const sshOptions = setupData?.sshOptions ?? []; + const port = setupData?.port; + + const argsOverride = sshOptions.flatMap((opt) => ["-o", opt]); + if ("source" in args) { addScpArgs(args); @@ -218,6 +228,8 @@ const createCommand = ( command: "scp", args: [ ...(args.sshOptions ? args.sshOptions : []), + ...argsOverride, + ...(port ? ["-P", port] : []), args.source, args.destination, ], @@ -228,6 +240,8 @@ const createCommand = ( command: "ssh", args: [ ...(args.sshOptions ? args.sshOptions : []), + ...argsOverride, + ...(port ? ["-p", port] : []), `${data.linuxUserName}@${data.id}`, ...(args.command ? [args.command] : []), ...args.arguments.map( @@ -244,7 +258,10 @@ const createCommand = ( * * These common args are only added if they have not been explicitly specified by the end user. */ -const addCommonArgs = (args: CommandArgs, proxyCommand: string[]) => { +const addCommonArgs = ( + args: CommandArgs, + sshProviderProxyCommand: string[] +) => { const sshOptions = args.sshOptions ? args.sshOptions : []; const identityFileOptionExists = sshOptions.some( @@ -268,13 +285,13 @@ const addCommonArgs = (args: CommandArgs, proxyCommand: string[]) => { } } - const proxyCommandExists = sshOptions.some( + const userSpecifiedProxyCommand = sshOptions.some( (opt, idx) => opt === "-o" && sshOptions[idx + 1]?.startsWith("ProxyCommand") ); - if (!proxyCommandExists) { - sshOptions.push("-o", `ProxyCommand=${proxyCommand.join(" ")}`); + if (!userSpecifiedProxyCommand && sshProviderProxyCommand.length > 0) { + sshOptions.push("-o", `ProxyCommand=${sshProviderProxyCommand.join(" ")}`); } // Force verbose output from SSH so we can parse the output @@ -343,7 +360,12 @@ const preTestAccessPropagationIfNeeded = async < // Pre-testing comes at a performance cost because we have to execute another ssh subprocess after // a successful test. Only do when absolutely necessary. if (testCmdArgs) { - const { command, args } = createCommand(request, testCmdArgs, proxyCommand); + const { command, args } = createCommand( + request, + testCmdArgs, + undefined, // No need to re-apply SSH options from setupData + proxyCommand + ); // Assumes that this is a non-interactive ssh command that exits automatically return spawnSshNode({ credential, @@ -378,9 +400,12 @@ export const sshOrScp = async (args: { const proxyCommand = sshProvider.proxyCommand(request); + const setupData = await sshProvider.setup?.(request); + const { command, args: commandArgs } = createCommand( request, cmdArgs, + setupData, proxyCommand ); @@ -399,26 +424,34 @@ export const sshOrScp = async (args: { const endTime = Date.now() + sshProvider.propagationTimeoutMs; - const exitCode = await preTestAccessPropagationIfNeeded( - sshProvider, - request, - cmdArgs, - proxyCommand, - credential, - endTime - ); - if (exitCode && exitCode !== 0) { - return exitCode; // Only exit if there was an error when pre-testing + let sshNodeExit; + + try { + const exitCode = await preTestAccessPropagationIfNeeded( + sshProvider, + request, + cmdArgs, + proxyCommand, + credential, + endTime + ); + if (exitCode && exitCode !== 0) { + return exitCode; // Only exit if there was an error when pre-testing + } + + sshNodeExit = await spawnSshNode({ + credential, + abortController: new AbortController(), + command, + args: commandArgs, + stdio: ["inherit", "inherit", "pipe"], + debug: cmdArgs.debug, + provider: request.type, + endTime: endTime, + }); + } finally { + await setupData?.teardown(); } - return spawnSshNode({ - credential, - abortController: new AbortController(), - command, - args: commandArgs, - stdio: ["inherit", "inherit", "pipe"], - debug: cmdArgs.debug, - provider: request.type, - endTime: endTime, - }); + return sshNodeExit; }; diff --git a/src/types/ssh.ts b/src/types/ssh.ts index 5deb5ec..4cca986 100644 --- a/src/types/ssh.ts +++ b/src/types/ssh.ts @@ -8,7 +8,7 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ -import { CommandArgs } from "../commands/shared/ssh"; +import { CommandArgs, SshAdditionalSetup } from "../commands/shared/ssh"; import { AwsSsh, AwsSshPermissionSpec, @@ -56,6 +56,9 @@ export type SshProvider< /** Callback to ensure that this provider's CLI utils are installed */ ensureInstall: () => Promise; + /** Validate the SSH key if necessary; throw an exception if the key is invalid */ + validateSshKey?: (request: Request, publicKey: string) => boolean; + /** A human-readable name for this CSP */ friendlyName: string; @@ -79,6 +82,10 @@ export type SshProvider< cmdArgs: CommandArgs ) => CommandArgs | undefined; + /** Perform any setup required before running the SSH command. Returns a list of additional arguments to pass to the + * SSH command. */ + setup?: (request: SR) => Promise; + /** Returns the command and its arguments that are going to be injected as the ssh ProxyCommand option */ proxyCommand: (request: SR) => string[];