Skip to content

Commit ad998ec

Browse files
committed
ENG-2660 Add a message once SSH connection is established
Add a message once an SSH connection is established, if the -N option (no remote command) is used.
1 parent b2e854a commit ad998ec

File tree

1 file changed

+61
-65
lines changed

1 file changed

+61
-65
lines changed

src/plugins/ssh/index.ts

Lines changed: 61 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ import { Authn } from "../../types/identity";
1515
import { SshProvider, SshRequest, SupportedSshProvider } from "../../types/ssh";
1616
import { delay } from "../../util";
1717
import { AwsCredentials } from "../aws/types";
18-
import {
19-
ChildProcessByStdio,
20-
StdioNull,
21-
StdioPipe,
22-
spawn,
23-
} from "node:child_process";
24-
import { Readable } from "node:stream";
18+
import { StdioNull, StdioPipe, spawn } from "node:child_process";
2519

2620
/** Matches the error message that AWS SSM print1 when access is not propagated */
2721
// Note that the resource will randomly be either the SSM document or the EC2 instance
@@ -51,7 +45,9 @@ const SUDO_MESSAGE = /Sorry, user .+ may not run sudo on .+/; // The output of `
5145
* in the process's stderr
5246
*/
5347
const DEFAULT_VALIDATION_WINDOW_MS = 5e3;
54-
48+
/** How long we wait for output on stderr before we consider the channel to be successfully established */
49+
const STDERR_TIMEOUT_PERIOD = 10000;
50+
/** Retry delay between SSH command spawn attempts */
5551
const RETRY_DELAY_MS = 5000;
5652

5753
/**
@@ -92,60 +88,6 @@ const UNPROVISIONED_ACCESS_MESSAGES = [
9288
{ pattern: DESTINATION_READ_ERROR },
9389
];
9490

95-
/** Checks if access has propagated through AWS to the SSM agent
96-
*
97-
* AWS takes about 8 minutes, GCP takes under 1 minute
98-
* to fully resolve access after it is granted.
99-
* During this time, calls to `aws ssm start-session` / `gcloud compute start-iap-tunnel`
100-
* will fail randomly with an various error messages.
101-
*
102-
* This function checks the subprocess output to see if any of the error messages
103-
* are printed to the error output within the first 5 seconds of startup.
104-
* If they are, the returned `isAccessPropagated()` function will return false.
105-
* When this occurs, the consumer of this function should retry the `aws` / `gcloud` command.
106-
*
107-
* Note that this function requires interception of the subprocess stderr stream.
108-
* This works because AWS SSM wraps the session in a single-stream pty, so we
109-
* do not capture stderr emitted from the wrapped shell session.
110-
*/
111-
const accessPropagationGuard = (
112-
child: ChildProcessByStdio<null, null, Readable>,
113-
debug?: boolean
114-
) => {
115-
let isEphemeralAccessDeniedException = false;
116-
let isGoogleLoginException = false;
117-
const beforeStart = Date.now();
118-
119-
child.stderr.on("data", (chunk) => {
120-
const chunkString: string = chunk.toString("utf-8");
121-
122-
if (debug) print2(chunkString);
123-
124-
const match = UNPROVISIONED_ACCESS_MESSAGES.find((message) =>
125-
chunkString.match(message.pattern)
126-
);
127-
128-
if (
129-
match &&
130-
Date.now() <=
131-
beforeStart + (match.validationWindowMs || DEFAULT_VALIDATION_WINDOW_MS)
132-
) {
133-
isEphemeralAccessDeniedException = true;
134-
}
135-
136-
const googleLoginMatch = chunkString.match(GOOGLE_LOGIN_MESSAGE);
137-
isGoogleLoginException = isGoogleLoginException || !!googleLoginMatch; // once true, always true
138-
if (isGoogleLoginException) {
139-
isEphemeralAccessDeniedException = false; // always overwrite to false so we don't retry the access
140-
}
141-
});
142-
143-
return {
144-
isAccessPropagated: () => !isEphemeralAccessDeniedException,
145-
isGoogleLoginException: () => isGoogleLoginException,
146-
};
147-
};
148-
14991
const spawnChildProcess = (
15092
credential: AwsCredentials | undefined,
15193
command: string,
@@ -203,11 +145,60 @@ async function spawnSshNode(
203145
);
204146

205147
// TODO ENG-2284 support login with Google Cloud: currently return a boolean to indicate if the exception was a Google login error.
206-
const { isAccessPropagated, isGoogleLoginException } =
207-
accessPropagationGuard(child, options.debug);
148+
let isEphemeralAccessDeniedException = false;
149+
let isGoogleLoginException = false;
150+
151+
let timeout: NodeJS.Timeout;
152+
153+
const remoteCommandOption = hasRemoteCommandOption(options.args);
154+
155+
const resetTimeout = () => {
156+
if (!remoteCommandOption) {
157+
return;
158+
}
159+
if (timeout) {
160+
clearTimeout(timeout);
161+
}
162+
timeout = setTimeout(() => {
163+
console.log("SSH connection established.");
164+
}, STDERR_TIMEOUT_PERIOD);
165+
};
166+
167+
const beforeStart = Date.now();
168+
child.stderr.on("data", (chunk) => {
169+
resetTimeout();
170+
171+
const chunkString: string = chunk.toString("utf-8");
172+
173+
if (options.debug) print2(chunkString);
174+
175+
const match = UNPROVISIONED_ACCESS_MESSAGES.find((message) =>
176+
chunkString.match(message.pattern)
177+
);
178+
179+
if (
180+
match &&
181+
Date.now() <=
182+
beforeStart +
183+
(match.validationWindowMs || DEFAULT_VALIDATION_WINDOW_MS)
184+
) {
185+
isEphemeralAccessDeniedException = true;
186+
}
187+
188+
const googleLoginMatch = chunkString.match(GOOGLE_LOGIN_MESSAGE);
189+
isGoogleLoginException = isGoogleLoginException || !!googleLoginMatch; // once true, always true
190+
if (isGoogleLoginException) {
191+
// always overwrite to false so we don't retry the access
192+
isEphemeralAccessDeniedException = false;
193+
}
194+
});
195+
196+
const isAccessPropagated = () => !isEphemeralAccessDeniedException;
208197

209198
const exitListener = child.on("exit", (code) => {
199+
clearTimeout(timeout);
210200
exitListener.unref();
201+
211202
// In the case of ephemeral AccessDenied exceptions due to unpropagated
212203
// permissions, continually retry access until success
213204
if (!isAccessPropagated()) {
@@ -229,7 +220,7 @@ async function spawnSshNode(
229220
.catch(reject);
230221

231222
return;
232-
} else if (isGoogleLoginException()) {
223+
} else if (isGoogleLoginException) {
233224
reject(`Please login to Google Cloud CLI with 'gcloud auth login'`);
234225
return;
235226
}
@@ -241,11 +232,16 @@ async function spawnSshNode(
241232
});
242233
}
243234

235+
const hasRemoteCommandOption = (options: string[]): boolean => {
236+
return options.some((opt) => opt.startsWith("-N"));
237+
};
238+
244239
const createCommand = (
245240
data: SshRequest,
246241
args: CommandArgs,
247242
proxyCommand: string[]
248243
) => {
244+
// TODO: Unpack no-command options
249245
addCommonArgs(args, proxyCommand);
250246

251247
if ("source" in args) {

0 commit comments

Comments
 (0)