Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c3d4ebb
implement comment hinting
Hweinstock Dec 9, 2024
22e1d1a
make progress on ec2
Hweinstock Dec 9, 2024
018d719
run branch check on PRs only
Hweinstock Dec 9, 2024
d03a2da
use event branch name
Hweinstock Dec 9, 2024
4fb8e66
change wording
Hweinstock Dec 9, 2024
cdecab6
add extra check
Hweinstock Dec 9, 2024
f2900e5
switch to single quotes
Hweinstock Dec 9, 2024
902de77
Merge branch 'aws:master' into master
Hweinstock Dec 9, 2024
b0d1823
adjust test cases
Hweinstock Dec 9, 2024
bf03fd3
Merge branch 'aws:master' into master
Hweinstock Dec 10, 2024
7c602a3
implement simpler hint comment
Hweinstock Dec 10, 2024
b907682
use os to determine command
Hweinstock Dec 10, 2024
3d31db4
Merge branch 'master' into ec2/commentkeys
Hweinstock Dec 10, 2024
eb6142a
merge in master
Hweinstock Dec 10, 2024
fa1b53b
add changelog and remove docs
Hweinstock Dec 10, 2024
d30c057
switch to checking if OS is mac
Hweinstock Dec 10, 2024
f960481
add debuging statements for ci
Hweinstock Dec 10, 2024
dbbfc09
improve debugging
Hweinstock Dec 10, 2024
ad13206
remove debugging statement and fix spacing
Hweinstock Dec 10, 2024
f171bb9
add testing for cleaning keys
Hweinstock Dec 10, 2024
5498f25
add tests for linux/mac
Hweinstock Dec 10, 2024
4cd5400
Merge branch 'aws:master' into master
Hweinstock Dec 10, 2024
9a2cc2c
Merge branch 'master' into ec2/commentkeys
Hweinstock Dec 10, 2024
84c4258
refactor sending command
Hweinstock Dec 10, 2024
4b2c01f
update docs to include detail of removing keys
Hweinstock Dec 11, 2024
d96a7e9
Update packages/core/src/awsService/ec2/model.ts
Hweinstock Dec 11, 2024
8b4954d
Merge branch 'ec2/commentkeys' of github.com:Hweinstock/aws-toolkit-v…
Hweinstock Dec 11, 2024
3309fb3
use try keyword over attempt
Hweinstock Dec 11, 2024
c9152a1
update docs with more info
Hweinstock Dec 11, 2024
5092323
fail on invalid patterns
Hweinstock Dec 11, 2024
0234303
Merge branch 'aws:master' into master
Hweinstock Dec 11, 2024
37d64ae
Merge branch 'master' into ec2/commentkeys
Hweinstock Dec 11, 2024
f988458
Merge branch 'aws:master' into master
Hweinstock Dec 12, 2024
8f8237a
Update packages/core/src/test/awsService/ec2/model.test.ts
Hweinstock Dec 12, 2024
1bc5bdc
Merge branch 'master' into ec2/commentkeys
Hweinstock Dec 12, 2024
9e57d28
Merge branch 'ec2/commentkeys' of github.com:Hweinstock/aws-toolkit-v…
Hweinstock Dec 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/arch_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ For connecting a new VSCode _terminal_, remote connect works like this:

For EC2 specifically, there are a few additional steps:

1. Remote window connections are only supported for EC2 instances running a linux based OS such as Amazon Linux or Ubuntu. However, the terminal option is supported by all OS, and will open a Powershell-based terminal for Windows instances.
1. If connecting to EC2 instance via remote window, the toolkit generates temporary SSH keys (30 second lifetime), with the public key sent to the remote instance.
- Key type is ed25519 if supported, or RSA otherwise.
- This connection will overwrite the `.ssh/authorized_keys` file on the remote machine with each connection.
- Lines in `.ssh/authorized_keys` marked with the comment `#AWSToolkitForVSCode` will be removed by AWS Toolkit.
- Assumes `.sss/authorized_keys` can be found under `/home/ec2-user/` on Amazon Linux and `/home/ubuntu/` on Ubuntu.
1. If insufficient permissions are detected on the attached IAM role, toolkit will prompt to add an inline policy with the necessary actions.
1. If SSM sessions remain open after closing the window/terminal, the toolkit will terminate them on-shutdown, or when starting another session to the same instance.

Expand Down
93 changes: 74 additions & 19 deletions packages/core/src/awsService/ec2/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export interface Ec2RemoteEnv extends VscodeRemoteConnection {
ssmSession: SSM.StartSessionResponse
}

export type Ec2OS = 'Amazon Linux' | 'Ubuntu' | 'macOS'
interface RemoteUser {
os: Ec2OS
name: string
}

export class Ec2Connecter implements vscode.Disposable {
protected ssmClient: SsmClient
protected ec2Client: Ec2Client
Expand Down Expand Up @@ -198,10 +204,16 @@ export class Ec2Connecter implements vscode.Disposable {
remoteEnv.SessionProcess,
remoteEnv.hostname,
remoteEnv.sshPath,
remoteUser,
remoteUser.name,
testSession
)
await startVscodeRemote(remoteEnv.SessionProcess, remoteEnv.hostname, '/', remoteEnv.vscPath, remoteUser)
await startVscodeRemote(
remoteEnv.SessionProcess,
remoteEnv.hostname,
'/',
remoteEnv.vscPath,
remoteUser.name
)
} catch (err) {
const message = err instanceof SshError ? 'Testing SSH connection to instance failed' : ''
this.throwConnectionError(message, selection, err as Error)
Expand All @@ -210,7 +222,10 @@ export class Ec2Connecter implements vscode.Disposable {
}
}

public async prepareEc2RemoteEnvWithProgress(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
public async prepareEc2RemoteEnvWithProgress(
selection: Ec2Selection,
remoteUser: RemoteUser
): Promise<Ec2RemoteEnv> {
const timeout = new Timeout(60000)
await showMessageWithCancel('AWS: Opening remote connection...', timeout)
const remoteEnv = await this.prepareEc2RemoteEnv(selection, remoteUser).finally(() => timeout.cancel())
Expand All @@ -223,7 +238,7 @@ export class Ec2Connecter implements vscode.Disposable {
return ssmSession
}

public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: RemoteUser): Promise<Ec2RemoteEnv> {
const logger = this.configureRemoteConnectionLogger(selection.instanceId)
const { ssm, vsc, ssh } = (await ensureDependencies()).unwrap()
const keyPair = await this.configureSshKeys(selection, remoteUser)
Expand Down Expand Up @@ -271,38 +286,78 @@ export class Ec2Connecter implements vscode.Disposable {
return logger
}

public async configureSshKeys(selection: Ec2Selection, remoteUser: string): Promise<SshKeyPair> {
public async configureSshKeys(selection: Ec2Selection, remoteUser: RemoteUser): Promise<SshKeyPair> {
const keyPair = await SshKeyPair.getSshKeyPair(`aws-ec2-key`, 30000)
await this.sendSshKeyToInstance(selection, keyPair, remoteUser)
return keyPair
}

/** Removes old key(s) that we added to the remote ~/.ssh/authorized_keys file. */
public async tryCleanKeys(
instanceId: string,
hintComment: string,
hostOS: Ec2OS,
remoteAuthorizedKeysPath: string
) {
try {
const deleteExistingKeyCommand = getRemoveLinesCommand(hintComment, hostOS, remoteAuthorizedKeysPath)
await this.sendCommandAndWait(instanceId, deleteExistingKeyCommand)
} catch (e) {
getLogger().warn(`ec2: failed to clean keys: %O`, e)
}
}

private async sendCommandAndWait(instanceId: string, command: string) {
return await this.ssmClient.sendCommandAndWait(instanceId, 'AWS-RunShellScript', {
commands: [command],
})
}

public async sendSshKeyToInstance(
selection: Ec2Selection,
sshKeyPair: SshKeyPair,
remoteUser: string
remoteUser: RemoteUser
): Promise<void> {
const sshPubKey = await sshKeyPair.getPublicKey()
const hintComment = '#AWSToolkitForVSCode'

const remoteAuthorizedKeysPaths = `/home/${remoteUser}/.ssh/authorized_keys`
const command = `echo "${sshPubKey}" > ${remoteAuthorizedKeysPaths}`
const documentName = 'AWS-RunShellScript'
const remoteAuthorizedKeysPath = `/home/${remoteUser.name}/.ssh/authorized_keys`

await this.ssmClient.sendCommandAndWait(selection.instanceId, documentName, {
commands: [command],
})
const appendStr = (s: string) => `echo "${s}" >> ${remoteAuthorizedKeysPath}`
const writeKeyCommand = appendStr([sshPubKey.replace('\n', ''), hintComment].join(' '))

await this.tryCleanKeys(selection.instanceId, hintComment, remoteUser.os, remoteAuthorizedKeysPath)
await this.sendCommandAndWait(selection.instanceId, writeKeyCommand)
}

public async getRemoteUser(instanceId: string) {
const osName = await this.ssmClient.getTargetPlatformName(instanceId)
if (osName === 'Amazon Linux') {
return 'ec2-user'
public async getRemoteUser(instanceId: string): Promise<RemoteUser> {
const os = await this.ssmClient.getTargetPlatformName(instanceId)
if (os === 'Amazon Linux') {
return { name: 'ec2-user', os }
}

if (osName === 'Ubuntu') {
return 'ubuntu'
if (os === 'Ubuntu') {
return { name: 'ubuntu', os }
}

throw new ToolkitError(`Unrecognized OS name ${osName} on instance ${instanceId}`, { code: 'UnknownEc2OS' })
throw new ToolkitError(`Unrecognized OS name ${os} on instance ${instanceId}`, { code: 'UnknownEc2OS' })
}
}

/**
* Generate bash command (as string) to remove lines containing `pattern`.
* @param pattern pattern for deleted lines.
* @param filepath filepath (as string) to target with the command.
* @returns bash command to remove lines from file.
*/
export function getRemoveLinesCommand(pattern: string, hostOS: Ec2OS, filepath: string): string {
if (pattern.includes('/')) {
throw new ToolkitError(`ec2: cannot match pattern containing '/', given: ${pattern}`)
}
// Linux allows not passing extension to -i, whereas macOS requires zero length extension.
Copy link
Contributor

@justinmk3 justinmk3 Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also any BSD flavor. Does the '' work on Linux so we can just always do sed -i ''? According to Claude, it should...

Copy link
Contributor Author

@Hweinstock Hweinstock Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw it in the docs, and double checked I wasn't able to get it to work on Amazon Linux.
image

Its annoying :(

I also thought about not using the -i option and instead piping it to another temporary file, then overwrite authorized_keys with the temporary file so that we could use same command on all OS, but I thought the tradeoff of taking 3-4 steps instead of 1 didn't feel worth it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, another option is to actually pass something like -i '.bk' on all platforms. That assumes one can write to the ~/.ssh/ directory, which is probably a fine assumption.

Copy link
Contributor Author

@Hweinstock Hweinstock Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I wasn't sure if there would be any problems leaving backup files around. My thinking was to leave as little state/artifacts as possible on the remote machine, but having different commands for different OS is annoying.

return `sed -i${isLinux(hostOS) ? '' : " ''"} /${pattern}/d ${filepath}`
}

function isLinux(os: Ec2OS): boolean {
return os === 'Amazon Linux' || os === 'Ubuntu'
}
3 changes: 3 additions & 0 deletions packages/core/src/shared/vscode/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ export async function isCloudDesktop() {
return (await new ChildProcess('/apollo/bin/getmyfabric').run().then((r) => r.exitCode)) === 0
}

export function isMac(): boolean {
return process.platform === 'darwin'
}
/** Returns true if OS is Windows. */
export function isWin(): boolean {
// if (isWeb()) {
Expand Down
105 changes: 100 additions & 5 deletions packages/core/src/test/awsService/ec2/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import assert from 'assert'
import * as sinon from 'sinon'
import { Ec2Connecter } from '../../../awsService/ec2/model'
import { Ec2Connecter, getRemoveLinesCommand } from '../../../awsService/ec2/model'
import { SsmClient } from '../../../shared/clients/ssmClient'
import { Ec2Client } from '../../../shared/clients/ec2Client'
import { Ec2Selection } from '../../../awsService/ec2/prompter'
Expand All @@ -15,6 +15,11 @@ import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
import { DefaultIamClient } from '../../../shared/clients/iamClient'
import { assertNoTelemetryMatch, createTestWorkspaceFolder } from '../../testUtil'
import { fs } from '../../../shared'
import path from 'path'
import { ChildProcess } from '../../../shared/utilities/processUtils'
import { isMac, isWin } from '../../../shared/vscode/env'
import { inspect } from '../../../shared/utilities/collectionUtils'
import { assertLogsContain } from '../../globalSetup.test'

describe('Ec2ConnectClient', function () {
let client: Ec2Connecter
Expand Down Expand Up @@ -140,7 +145,7 @@ describe('Ec2ConnectClient', function () {
}

const keys = await SshKeyPair.getSshKeyPair('key', 30000)
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
await client.sendSshKeyToInstance(testSelection, keys, { name: 'test-user', os: 'Amazon Linux' })
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
sinon.restore()
})
Expand All @@ -154,7 +159,7 @@ describe('Ec2ConnectClient', function () {
}
const testWorkspaceFolder = await createTestWorkspaceFolder()
const keys = await SshKeyPair.getSshKeyPair('key', 60000)
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
await client.sendSshKeyToInstance(testSelection, keys, { name: 'test-user', os: 'Amazon Linux' })
const privKey = await fs.readFileText(keys.getPrivateKeyPath())
assertNoTelemetryMatch(privKey)
sinon.restore()
Expand All @@ -178,13 +183,13 @@ describe('Ec2ConnectClient', function () {
it('identifies the user for ubuntu as ubuntu', async function () {
getTargetPlatformNameStub.resolves('Ubuntu')
const remoteUser = await client.getRemoteUser('testInstance')
assert.strictEqual(remoteUser, 'ubuntu')
assert.strictEqual(remoteUser.name, 'ubuntu')
})

it('identifies the user for amazon linux as ec2-user', async function () {
getTargetPlatformNameStub.resolves('Amazon Linux')
const remoteUser = await client.getRemoteUser('testInstance')
assert.strictEqual(remoteUser, 'ec2-user')
assert.strictEqual(remoteUser.name, 'ec2-user')
})

it('throws error when not given known OS', async function () {
Expand All @@ -197,4 +202,94 @@ describe('Ec2ConnectClient', function () {
}
})
})

describe('attemptToCleanKeys', async function () {
it('calls the sdk with the proper parameters', async function () {
const sendCommandStub = sinon.stub(SsmClient.prototype, 'sendCommandAndWait')

const testSelection = {
instanceId: 'test-id',
region: 'test-region',
}

await client.tryCleanKeys(testSelection.instanceId, 'hint', 'macOS', 'path/to/keys')
sendCommandStub.calledWith(testSelection.instanceId, 'AWS-RunShellScript', {
commands: [getRemoveLinesCommand('hint', 'macOS', 'path/to/keys')],
})
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
sinon.restore()
})

it('logs warning when sdk call fails', async function () {
const sendCommandStub = sinon
.stub(SsmClient.prototype, 'sendCommandAndWait')
.throws(new ToolkitError('error'))

const testSelection = {
instanceId: 'test-id',
region: 'test-region',
}

await client.tryCleanKeys(testSelection.instanceId, 'hint', 'macOS', 'path/to/keys')
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript', {
commands: [getRemoveLinesCommand('hint', 'macOS', 'path/to/keys')],
})
sinon.restore()
assertLogsContain('failed to clean keys', false, 'warn')
})
})
})

describe('getRemoveLinesCommand', async function () {
let tempPath: { uri: { fsPath: string } }

before(async function () {
tempPath = await createTestWorkspaceFolder()
})

after(async function () {
await fs.delete(tempPath.uri.fsPath, { recursive: true, force: true })
})

it('removes lines containing pattern', async function () {
if (isWin()) {
this.skip()
}
// For the test, we only need to distinguish mac and linux
const hostOS = isMac() ? 'macOS' : 'Amazon Linux'
const lines = ['line1', 'line2 pattern', 'line3', 'line4 pattern', 'line5', 'line6 pattern', 'line7']
const expected = ['line1', 'line3', 'line5', 'line7']

const lineToStr = (ls: string[]) => ls.join('\n') + '\n'

const textFile = path.join(tempPath.uri.fsPath, 'test.txt')
const originalContent = lineToStr(lines)
await fs.writeFile(textFile, originalContent)
const [command, ...args] = getRemoveLinesCommand('pattern', hostOS, textFile).split(' ')
const process = new ChildProcess(command, args, { collect: true })
const result = await process.run()
assert.strictEqual(
result.exitCode,
0,
`Ran command '${command} ${args.join(' ')}' and failed with result ${inspect(result)}`
)

const newContent = await fs.readFileText(textFile)
assert.notStrictEqual(newContent, originalContent)
assert.strictEqual(newContent, lineToStr(expected))
})

it('includes empty extension on macOS only', async function () {
const macCommand = getRemoveLinesCommand('pattern', 'macOS', 'test.txt')
const alCommand = getRemoveLinesCommand('pattern', 'Amazon Linux', 'test.txt')
const ubuntuCommand = getRemoveLinesCommand('pattern', 'Ubuntu', 'test.txt')

assert.ok(macCommand.includes("''"))
assert.ok(!alCommand.includes("''"))
assert.strictEqual(ubuntuCommand, alCommand)
})

it('throws when given invalid pattern', function () {
assert.throws(() => getRemoveLinesCommand('pat/tern', 'macOS', 'test.txt'))
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Bug Fix",
"description": "EC2: avoid overwriting authorized_keys file on remote"
}
Loading