Skip to content

Commit 3b2dc5f

Browse files
Merge master into feature/amazonqLSP
2 parents b009f81 + 4335e1e commit 3b2dc5f

File tree

5 files changed

+184
-25
lines changed

5 files changed

+184
-25
lines changed

docs/arch_features.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ For connecting a new VSCode _terminal_, remote connect works like this:
4141

4242
For EC2 specifically, there are a few additional steps:
4343

44+
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.
4445
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.
4546
- Key type is ed25519 if supported, or RSA otherwise.
46-
- This connection will overwrite the `.ssh/authorized_keys` file on the remote machine with each connection.
47+
- Lines in `.ssh/authorized_keys` marked with the comment `#AWSToolkitForVSCode` will be removed by AWS Toolkit.
48+
- Assumes `.sss/authorized_keys` can be found under `/home/ec2-user/` on Amazon Linux and `/home/ubuntu/` on Ubuntu.
4749
1. If insufficient permissions are detected on the attached IAM role, toolkit will prompt to add an inline policy with the necessary actions.
4850
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.
4951

packages/core/src/awsService/ec2/model.ts

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export interface Ec2RemoteEnv extends VscodeRemoteConnection {
4444
ssmSession: SSM.StartSessionResponse
4545
}
4646

47+
export type Ec2OS = 'Amazon Linux' | 'Ubuntu' | 'macOS'
48+
interface RemoteUser {
49+
os: Ec2OS
50+
name: string
51+
}
52+
4753
export class Ec2Connecter implements vscode.Disposable {
4854
protected ssmClient: SsmClient
4955
protected ec2Client: Ec2Client
@@ -198,10 +204,16 @@ export class Ec2Connecter implements vscode.Disposable {
198204
remoteEnv.SessionProcess,
199205
remoteEnv.hostname,
200206
remoteEnv.sshPath,
201-
remoteUser,
207+
remoteUser.name,
202208
testSession
203209
)
204-
await startVscodeRemote(remoteEnv.SessionProcess, remoteEnv.hostname, '/', remoteEnv.vscPath, remoteUser)
210+
await startVscodeRemote(
211+
remoteEnv.SessionProcess,
212+
remoteEnv.hostname,
213+
'/',
214+
remoteEnv.vscPath,
215+
remoteUser.name
216+
)
205217
} catch (err) {
206218
const message = err instanceof SshError ? 'Testing SSH connection to instance failed' : ''
207219
this.throwConnectionError(message, selection, err as Error)
@@ -210,7 +222,10 @@ export class Ec2Connecter implements vscode.Disposable {
210222
}
211223
}
212224

213-
public async prepareEc2RemoteEnvWithProgress(selection: Ec2Selection, remoteUser: string): Promise<Ec2RemoteEnv> {
225+
public async prepareEc2RemoteEnvWithProgress(
226+
selection: Ec2Selection,
227+
remoteUser: RemoteUser
228+
): Promise<Ec2RemoteEnv> {
214229
const timeout = new Timeout(60000)
215230
await showMessageWithCancel('AWS: Opening remote connection...', timeout)
216231
const remoteEnv = await this.prepareEc2RemoteEnv(selection, remoteUser).finally(() => timeout.cancel())
@@ -223,7 +238,7 @@ export class Ec2Connecter implements vscode.Disposable {
223238
return ssmSession
224239
}
225240

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

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

295+
/** Removes old key(s) that we added to the remote ~/.ssh/authorized_keys file. */
296+
public async tryCleanKeys(
297+
instanceId: string,
298+
hintComment: string,
299+
hostOS: Ec2OS,
300+
remoteAuthorizedKeysPath: string
301+
) {
302+
try {
303+
const deleteExistingKeyCommand = getRemoveLinesCommand(hintComment, hostOS, remoteAuthorizedKeysPath)
304+
await this.sendCommandAndWait(instanceId, deleteExistingKeyCommand)
305+
} catch (e) {
306+
getLogger().warn(`ec2: failed to clean keys: %O`, e)
307+
}
308+
}
309+
310+
private async sendCommandAndWait(instanceId: string, command: string) {
311+
return await this.ssmClient.sendCommandAndWait(instanceId, 'AWS-RunShellScript', {
312+
commands: [command],
313+
})
314+
}
315+
280316
public async sendSshKeyToInstance(
281317
selection: Ec2Selection,
282318
sshKeyPair: SshKeyPair,
283-
remoteUser: string
319+
remoteUser: RemoteUser
284320
): Promise<void> {
285321
const sshPubKey = await sshKeyPair.getPublicKey()
322+
const hintComment = '#AWSToolkitForVSCode'
286323

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

291-
await this.ssmClient.sendCommandAndWait(selection.instanceId, documentName, {
292-
commands: [command],
293-
})
326+
const appendStr = (s: string) => `echo "${s}" >> ${remoteAuthorizedKeysPath}`
327+
const writeKeyCommand = appendStr([sshPubKey.replace('\n', ''), hintComment].join(' '))
328+
329+
await this.tryCleanKeys(selection.instanceId, hintComment, remoteUser.os, remoteAuthorizedKeysPath)
330+
await this.sendCommandAndWait(selection.instanceId, writeKeyCommand)
294331
}
295332

296-
public async getRemoteUser(instanceId: string) {
297-
const osName = await this.ssmClient.getTargetPlatformName(instanceId)
298-
if (osName === 'Amazon Linux') {
299-
return 'ec2-user'
333+
public async getRemoteUser(instanceId: string): Promise<RemoteUser> {
334+
const os = await this.ssmClient.getTargetPlatformName(instanceId)
335+
if (os === 'Amazon Linux') {
336+
return { name: 'ec2-user', os }
300337
}
301338

302-
if (osName === 'Ubuntu') {
303-
return 'ubuntu'
339+
if (os === 'Ubuntu') {
340+
return { name: 'ubuntu', os }
304341
}
305342

306-
throw new ToolkitError(`Unrecognized OS name ${osName} on instance ${instanceId}`, { code: 'UnknownEc2OS' })
343+
throw new ToolkitError(`Unrecognized OS name ${os} on instance ${instanceId}`, { code: 'UnknownEc2OS' })
307344
}
308345
}
346+
347+
/**
348+
* Generate bash command (as string) to remove lines containing `pattern`.
349+
* @param pattern pattern for deleted lines.
350+
* @param filepath filepath (as string) to target with the command.
351+
* @returns bash command to remove lines from file.
352+
*/
353+
export function getRemoveLinesCommand(pattern: string, hostOS: Ec2OS, filepath: string): string {
354+
if (pattern.includes('/')) {
355+
throw new ToolkitError(`ec2: cannot match pattern containing '/', given: ${pattern}`)
356+
}
357+
// Linux allows not passing extension to -i, whereas macOS requires zero length extension.
358+
return `sed -i${isLinux(hostOS) ? '' : " ''"} /${pattern}/d ${filepath}`
359+
}
360+
361+
function isLinux(os: Ec2OS): boolean {
362+
return os === 'Amazon Linux' || os === 'Ubuntu'
363+
}

packages/core/src/shared/vscode/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ export async function isCloudDesktop() {
149149
return (await new ChildProcess('/apollo/bin/getmyfabric').run().then((r) => r.exitCode)) === 0
150150
}
151151

152+
export function isMac(): boolean {
153+
return process.platform === 'darwin'
154+
}
152155
/** Returns true if OS is Windows. */
153156
export function isWin(): boolean {
154157
// if (isWeb()) {

packages/core/src/test/awsService/ec2/model.test.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import assert from 'assert'
77
import * as sinon from 'sinon'
8-
import { Ec2Connecter } from '../../../awsService/ec2/model'
8+
import { Ec2Connecter, getRemoveLinesCommand } from '../../../awsService/ec2/model'
99
import { SsmClient } from '../../../shared/clients/ssmClient'
1010
import { Ec2Client } from '../../../shared/clients/ec2Client'
1111
import { Ec2Selection } from '../../../awsService/ec2/prompter'
@@ -15,6 +15,11 @@ import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
1515
import { DefaultIamClient } from '../../../shared/clients/iamClient'
1616
import { assertNoTelemetryMatch, createTestWorkspaceFolder } from '../../testUtil'
1717
import { fs } from '../../../shared'
18+
import path from 'path'
19+
import { ChildProcess } from '../../../shared/utilities/processUtils'
20+
import { isMac, isWin } from '../../../shared/vscode/env'
21+
import { inspect } from '../../../shared/utilities/collectionUtils'
22+
import { assertLogsContain } from '../../globalSetup.test'
1823

1924
describe('Ec2ConnectClient', function () {
2025
let client: Ec2Connecter
@@ -140,7 +145,7 @@ describe('Ec2ConnectClient', function () {
140145
}
141146

142147
const keys = await SshKeyPair.getSshKeyPair('key', 30000)
143-
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
148+
await client.sendSshKeyToInstance(testSelection, keys, { name: 'test-user', os: 'Amazon Linux' })
144149
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
145150
sinon.restore()
146151
})
@@ -154,7 +159,7 @@ describe('Ec2ConnectClient', function () {
154159
}
155160
const testWorkspaceFolder = await createTestWorkspaceFolder()
156161
const keys = await SshKeyPair.getSshKeyPair('key', 60000)
157-
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
162+
await client.sendSshKeyToInstance(testSelection, keys, { name: 'test-user', os: 'Amazon Linux' })
158163
const privKey = await fs.readFileText(keys.getPrivateKeyPath())
159164
assertNoTelemetryMatch(privKey)
160165
sinon.restore()
@@ -178,13 +183,13 @@ describe('Ec2ConnectClient', function () {
178183
it('identifies the user for ubuntu as ubuntu', async function () {
179184
getTargetPlatformNameStub.resolves('Ubuntu')
180185
const remoteUser = await client.getRemoteUser('testInstance')
181-
assert.strictEqual(remoteUser, 'ubuntu')
186+
assert.strictEqual(remoteUser.name, 'ubuntu')
182187
})
183188

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

190195
it('throws error when not given known OS', async function () {
@@ -197,4 +202,94 @@ describe('Ec2ConnectClient', function () {
197202
}
198203
})
199204
})
205+
206+
describe('tryCleanKeys', async function () {
207+
it('calls the sdk with the proper parameters', async function () {
208+
const sendCommandStub = sinon.stub(SsmClient.prototype, 'sendCommandAndWait')
209+
210+
const testSelection = {
211+
instanceId: 'test-id',
212+
region: 'test-region',
213+
}
214+
215+
await client.tryCleanKeys(testSelection.instanceId, 'hint', 'macOS', 'path/to/keys')
216+
sendCommandStub.calledWith(testSelection.instanceId, 'AWS-RunShellScript', {
217+
commands: [getRemoveLinesCommand('hint', 'macOS', 'path/to/keys')],
218+
})
219+
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
220+
sinon.restore()
221+
})
222+
223+
it('logs warning when sdk call fails', async function () {
224+
const sendCommandStub = sinon
225+
.stub(SsmClient.prototype, 'sendCommandAndWait')
226+
.throws(new ToolkitError('error'))
227+
228+
const testSelection = {
229+
instanceId: 'test-id',
230+
region: 'test-region',
231+
}
232+
233+
await client.tryCleanKeys(testSelection.instanceId, 'hint', 'macOS', 'path/to/keys')
234+
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript', {
235+
commands: [getRemoveLinesCommand('hint', 'macOS', 'path/to/keys')],
236+
})
237+
sinon.restore()
238+
assertLogsContain('failed to clean keys', false, 'warn')
239+
})
240+
})
241+
})
242+
243+
describe('getRemoveLinesCommand', async function () {
244+
let tempPath: { uri: { fsPath: string } }
245+
246+
before(async function () {
247+
tempPath = await createTestWorkspaceFolder()
248+
})
249+
250+
after(async function () {
251+
await fs.delete(tempPath.uri.fsPath, { recursive: true, force: true })
252+
})
253+
254+
it('removes lines containing pattern', async function () {
255+
if (isWin()) {
256+
this.skip()
257+
}
258+
// For the test, we only need to distinguish mac and linux
259+
const hostOS = isMac() ? 'macOS' : 'Amazon Linux'
260+
const lines = ['line1', 'line2 pattern', 'line3', 'line4 pattern', 'line5', 'line6 pattern', 'line7']
261+
const expected = ['line1', 'line3', 'line5', 'line7']
262+
263+
const lineToStr = (ls: string[]) => ls.join('\n') + '\n'
264+
265+
const textFile = path.join(tempPath.uri.fsPath, 'test.txt')
266+
const originalContent = lineToStr(lines)
267+
await fs.writeFile(textFile, originalContent)
268+
const [command, ...args] = getRemoveLinesCommand('pattern', hostOS, textFile).split(' ')
269+
const process = new ChildProcess(command, args, { collect: true })
270+
const result = await process.run()
271+
assert.strictEqual(
272+
result.exitCode,
273+
0,
274+
`Ran command '${command} ${args.join(' ')}' and failed with result ${inspect(result)}`
275+
)
276+
277+
const newContent = await fs.readFileText(textFile)
278+
assert.notStrictEqual(newContent, originalContent)
279+
assert.strictEqual(newContent, lineToStr(expected))
280+
})
281+
282+
it('includes empty extension on macOS only', async function () {
283+
const macCommand = getRemoveLinesCommand('pattern', 'macOS', 'test.txt')
284+
const alCommand = getRemoveLinesCommand('pattern', 'Amazon Linux', 'test.txt')
285+
const ubuntuCommand = getRemoveLinesCommand('pattern', 'Ubuntu', 'test.txt')
286+
287+
assert.ok(macCommand.includes("''"))
288+
assert.ok(!alCommand.includes("''"))
289+
assert.strictEqual(ubuntuCommand, alCommand)
290+
})
291+
292+
it('throws when given invalid pattern', function () {
293+
assert.throws(() => getRemoveLinesCommand('pat/tern', 'macOS', 'test.txt'))
294+
})
200295
})
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "EC2: avoid overwriting authorized_keys file on remote"
4+
}

0 commit comments

Comments
 (0)