|
#!/usr/bin/env node |
|
// Lists running EC2 instances of one environment via the AWS CLI, lets you pick |
|
// one, and opens an SSH session to its PRIVATE IP. Assumes the VPN/tunnel is up |
|
// so private IPs are reachable. |
|
// |
|
// Dependency-free: Node builtins plus the `aws`, `ssh` and `stty` binaries. |
|
// |
|
// Usage: |
|
// scripts/ec2-ssh.mjs # pick environment, then instance |
|
// scripts/ec2-ssh.mjs stage # pick instance on stage |
|
// scripts/ec2-ssh.mjs prod webapp # pre-filter the instance list |
|
// scripts/ec2-ssh.mjs prod webapp -- uptime # run a remote command instead |
|
// scripts/ec2-ssh.mjs stage --list # only print the instances |
|
// |
|
// Instances are grouped by their Name tag: pick the role first, then the host |
|
// (skipped when the role has a single instance). |
|
// |
|
// In the picker: arrow keys (or ctrl-p/ctrl-n) move, typing filters, enter |
|
// selects, esc/ctrl-c goes back or aborts. The last host used per environment |
|
// is preselected. |
|
// |
|
// Env overrides: SSH_USER (default ec2-user), AWS_REGION, AWS_PROFILE_PREFIX |
|
// (prepended to the environment name to form the AWS profile), SSH_TERM. |
|
// ENVIRONMENTS below is the one thing you edit in the file itself. |
|
|
|
import { spawnSync } from 'node:child_process' |
|
import { openSync, readSync, closeSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
|
import { homedir } from 'node:os' |
|
import { basename, dirname, join } from 'node:path' |
|
|
|
const CMD = basename(process.argv[1] ?? 'ec2-ssh.mjs') |
|
const ENVIRONMENTS = ['test', 'stage', 'prod'] // your environment names |
|
// No defaults for these two: guessing a remote user or an AWS profile name is |
|
// worse than refusing to start. An explicitly empty AWS_PROFILE_PREFIX counts as |
|
// configured, for profiles that are named exactly like the environments. |
|
const SSH_USER = process.env.SSH_USER |
|
const AWS_PROFILE_PREFIX = process.env.AWS_PROFILE_PREFIX |
|
const AWS_REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'eu-central-1' |
|
const STATE_FILE = join(homedir(), '.cache', 'ec2-ssh', 'last-host.json') |
|
const VISIBLE_ROWS = 12 |
|
|
|
const c = { |
|
red: '\x1b[0;31m', |
|
green: '\x1b[0;32m', |
|
yellow: '\x1b[1;33m', |
|
blue: '\x1b[0;34m', |
|
bold: '\x1b[1m', |
|
dim: '\x1b[2m', |
|
invert: '\x1b[7m', |
|
off: '\x1b[0m', |
|
} |
|
const info = (msg) => console.error(`${c.blue} info${c.off} ${msg}`) |
|
const success = (msg) => console.error(`${c.green} done${c.off} ${msg}`) |
|
const fail = (msg) => { |
|
console.error(`${c.red} fail${c.off} ${msg}`) |
|
process.exit(1) |
|
} |
|
|
|
// ssh sends the local TERM with the pty request, and terminals like ghostty or |
|
// kitty ship terminfo entries the servers do not have — htop then dies with |
|
// "Error opening terminal: xterm-ghostty". Anything outside this list is |
|
// reported as xterm-256color; SSH_TERM overrides. |
|
const PORTABLE_TERMS = new Set([ |
|
'xterm', |
|
'xterm-color', |
|
'xterm-16color', |
|
'xterm-256color', |
|
'screen', |
|
'screen-256color', |
|
'tmux', |
|
'tmux-256color', |
|
'vt100', |
|
'vt220', |
|
'linux', |
|
'ansi', |
|
'dumb', |
|
]) |
|
|
|
function remoteTerm() { |
|
const term = process.env.TERM ?? '' |
|
|
|
return process.env.SSH_TERM ?? (PORTABLE_TERMS.has(term) ? term : 'xterm-256color') |
|
} |
|
|
|
const HELP = `Lists running EC2 instances per environment and opens an SSH session. |
|
|
|
Usage: |
|
${CMD} pick environment, then instance |
|
${CMD} stage pick instance on stage |
|
${CMD} prod webapp pre-filter the instance list |
|
${CMD} prod webapp -- uptime run a remote command instead |
|
${CMD} stage --list only print the instances (flat, one line per host) |
|
|
|
Instances are grouped by Name tag: pick the role first, then the host. Picker |
|
keys: arrow keys / ctrl-p / ctrl-n move, typing filters, enter selects, esc or |
|
ctrl-c goes back or aborts. The last host per environment is preselected. |
|
|
|
Environments: ${ENVIRONMENTS.join(', ')} — AWS profile is AWS_PROFILE_PREFIX + environment name |
|
|
|
Required (no defaults): SSH_USER (${SSH_USER ?? 'NOT SET'}), |
|
AWS_PROFILE_PREFIX (${AWS_PROFILE_PREFIX === undefined ? 'NOT SET' : `"${AWS_PROFILE_PREFIX}"`}) |
|
Optional: AWS_REGION (${AWS_REGION}), SSH_TERM (${remoteTerm()}, local TERM is ${process.env.TERM ?? 'unset'})` |
|
|
|
// ─── Arguments ──────────────────────────────────────────────────────── |
|
const argv = process.argv.slice(2) |
|
const separator = argv.indexOf('--') |
|
const remoteCommand = separator === -1 ? [] : argv.slice(separator + 1) |
|
const flags = separator === -1 ? argv : argv.slice(0, separator) |
|
|
|
if (flags.includes('-h') || flags.includes('--help')) { |
|
console.log(HELP) |
|
process.exit(0) |
|
} |
|
|
|
const listOnly = flags.includes('--list') |
|
const positional = flags.filter((arg) => !arg.startsWith('-')) |
|
if (positional.length > 2) fail(`unexpected argument: ${positional[2]}`) |
|
const [environmentArg, filter] = positional |
|
|
|
// Checked before anything else touches AWS, so a missing setup is reported |
|
// instead of an MFA prompt for a profile that cannot exist. |
|
const missing = [ |
|
SSH_USER === undefined ? 'SSH_USER' : null, |
|
AWS_PROFILE_PREFIX === undefined ? 'AWS_PROFILE_PREFIX' : null, |
|
].filter(Boolean) |
|
|
|
if (missing.length > 0) { |
|
const rc = { zsh: '~/.zshrc', bash: '~/.bashrc' }[basename(process.env.SHELL ?? '')] |
|
console.error(`${c.red} fail${c.off} not configured: ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set |
|
|
|
${CMD} ships without defaults for these. Add to ${rc ?? 'your shell config'}: |
|
|
|
${c.bold}export SSH_USER=ec2-user${c.off} ${c.dim}# remote user to log in as${c.off} |
|
${c.bold}export AWS_PROFILE_PREFIX=acme-${c.off} ${c.dim}# + environment name = AWS profile, e.g. acme-prod${c.off} |
|
|
|
Then reload it${rc ? `: source ${rc}` : ''}, or open a new shell. |
|
|
|
${c.dim}Set AWS_PROFILE_PREFIX to an empty string if your profiles are named |
|
exactly like the environments (${ENVIRONMENTS.join(', ')}). See ${CMD} --help.${c.off}`) |
|
process.exit(1) |
|
} |
|
|
|
// ─── Remembered last host per environment ───────────────────────────── |
|
function readState() { |
|
try { |
|
return JSON.parse(readFileSync(STATE_FILE, 'utf8')) |
|
} catch { |
|
return {} |
|
} |
|
} |
|
|
|
function rememberHost(environment, instanceId) { |
|
try { |
|
mkdirSync(dirname(STATE_FILE), { recursive: true }) |
|
writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), [environment]: instanceId }, null, 2)) |
|
} catch { |
|
// a non-writable cache dir must not block the connection |
|
} |
|
} |
|
|
|
// ─── Terminal input ─────────────────────────────────────────────────── |
|
// Everything is read with readSync and the fd is closed right away: a streaming |
|
// reader would leave a pending read() on /dev/tty that swallows the MFA code |
|
// the AWS CLI prompts for later on. |
|
function openTty() { |
|
for (const mode of ['r+', 'r']) { |
|
try { |
|
return openSync('/dev/tty', mode) |
|
} catch { |
|
continue |
|
} |
|
} |
|
|
|
return fail('no terminal available for input — run this from an interactive shell') |
|
} |
|
|
|
function stty(fd, args) { |
|
const result = spawnSync('stty', args, { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' }) |
|
return result.status === 0 ? (result.stdout ?? '').trim() : null |
|
} |
|
|
|
function readKey(fd) { |
|
const buffer = Buffer.alloc(8) |
|
for (;;) { |
|
try { |
|
const read = readSync(fd, buffer, 0, buffer.length, null) |
|
return read === 0 ? null : buffer.subarray(0, read).toString('utf8') |
|
} catch (error) { |
|
if (error.code === 'EAGAIN') continue |
|
throw error |
|
} |
|
} |
|
} |
|
|
|
// Canonical line prompt, used when raw mode is unavailable. |
|
function promptLine(fd, prompt) { |
|
process.stderr.write(prompt) |
|
|
|
const byte = Buffer.alloc(1) |
|
let line = '' |
|
for (;;) { |
|
let read = 0 |
|
try { |
|
read = readSync(fd, byte, 0, 1, null) |
|
} catch (error) { |
|
if (error.code === 'EAGAIN') continue |
|
throw error |
|
} |
|
if (read === 0) break // EOF |
|
const char = byte.toString('utf8') |
|
if (char === '\n') break |
|
line += char |
|
} |
|
|
|
return line |
|
} |
|
|
|
// ─── Picker ─────────────────────────────────────────────────────────── |
|
function pick(items, prompt, options = {}) { |
|
const label = options.label ?? ((item) => item) |
|
|
|
if (items.length === 0) return null |
|
if (items.length === 1) return items[0] |
|
|
|
const fd = openTty() |
|
try { |
|
const saved = stty(fd, ['-g']) |
|
if (saved === null) return pickNumbered(fd, items, prompt, label) |
|
|
|
// Never draw taller than the window: a scrolling frame would break the |
|
// cursor-up arithmetic used to redraw. |
|
const [rows, columns] = (stty(fd, ['size']) ?? '').split(' ').map(Number) |
|
const layout = { |
|
maxRows: Math.min(options.maxRows ?? VISIBLE_ROWS, rows > 6 ? rows - 3 : 20), |
|
width: columns > 20 ? columns - 1 : 100, |
|
} |
|
|
|
// -isig so ctrl-c arrives as a byte and the terminal is always restored |
|
// below; opost stays on, so plain "\n" still returns the carriage. |
|
stty(fd, ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0']) |
|
try { |
|
return pickInteractive(fd, items, prompt, { ...options, label }, layout) |
|
} finally { |
|
stty(fd, [saved]) |
|
} |
|
} finally { |
|
closeSync(fd) |
|
} |
|
} |
|
|
|
// Split a read into single keys: arrow keys arrive as one escape sequence, and a |
|
// fast typist or a paste can deliver several keys in the same read. |
|
function keysIn(chunk) { |
|
const keys = [] |
|
for (let index = 0; index < chunk.length; ) { |
|
const isSequence = chunk[index] === '\x1b' && chunk[index + 1] === '[' |
|
keys.push(chunk.slice(index, index + (isSequence ? 3 : 1))) |
|
index += isSequence ? 3 : 1 |
|
} |
|
|
|
return keys |
|
} |
|
|
|
function pickInteractive(fd, items, prompt, options, layout) { |
|
const { label, preselected = () => false, back = 'quit' } = options |
|
const labels = items.map(label) |
|
const rowWidth = Math.min(layout.width, Math.max(...labels.map((text) => text.length)) + 4) |
|
|
|
let query = '' |
|
let cursor = Math.max( |
|
0, |
|
items.findIndex((item) => preselected(item)), |
|
) |
|
let printed = 0 |
|
let pending = [] |
|
|
|
const render = (matches) => { |
|
const height = Math.min(layout.maxRows, matches.length) |
|
const start = Math.max(0, Math.min(cursor - (height >> 1), matches.length - height)) |
|
|
|
const lines = [ |
|
`${c.yellow} ?${c.off} ${prompt} ` + |
|
`${c.dim}(${matches.length}/${items.length})${c.off} ` + |
|
`${query}${c.dim}▌ ↑↓ move · type to filter · enter select · esc ${back}${c.off}`, |
|
...matches.slice(start, start + height).map((match, row) => { |
|
const selected = start + row === cursor |
|
const text = `${selected ? ' ▸ ' : ' '}${labels[match]} `.padEnd(rowWidth).slice(0, rowWidth) |
|
|
|
return selected ? `${c.invert}${c.bold}${text}${c.off}` : text |
|
}), |
|
] |
|
if (matches.length > height) { |
|
lines.push(` ${c.dim}… ${matches.length - height} more${c.off}`) |
|
} |
|
if (matches.length === 0) lines.push(` ${c.dim}no match${c.off}`) |
|
|
|
// Rewind over the previous frame, then clear from the cursor down. |
|
process.stderr.write(`${printed > 0 ? `\x1b[${printed}A\r` : ''}\x1b[0J${lines.join('\n')}\n`) |
|
printed = lines.length |
|
} |
|
|
|
const erase = () => { |
|
if (printed > 0) process.stderr.write(`\x1b[${printed}A\r\x1b[0J`) |
|
printed = 0 |
|
} |
|
|
|
let chosen = null |
|
let aborted = false |
|
|
|
for (;;) { |
|
const needle = query.toLowerCase() |
|
const matches = items |
|
.map((item, index) => index) |
|
.filter((index) => labels[index].toLowerCase().includes(needle)) |
|
|
|
if (cursor > matches.length - 1) cursor = Math.max(0, matches.length - 1) |
|
render(matches) |
|
|
|
if (pending.length === 0) { |
|
const chunk = readKey(fd) |
|
if (chunk === null) break |
|
pending = keysIn(chunk) |
|
} |
|
|
|
const key = pending.shift() |
|
// esc, ctrl-c and left arrow leave this picker: one level up, or out. |
|
if (key === '\x03' || key === '\x1b' || key === '\x1b[D') { |
|
aborted = true |
|
break |
|
} |
|
if (key === '\r' || key === '\n') { |
|
if (matches.length === 0) continue |
|
chosen = items[matches[cursor]] |
|
break |
|
} |
|
if (key === '\x1b[A' || key === '\x10') { |
|
cursor = cursor > 0 ? cursor - 1 : Math.max(0, matches.length - 1) |
|
continue |
|
} |
|
if (key === '\x1b[B' || key === '\x0e') { |
|
cursor = cursor < matches.length - 1 ? cursor + 1 : 0 |
|
continue |
|
} |
|
if (key === '\x7f' || key === '\b') { |
|
query = query.slice(0, -1) |
|
cursor = 0 |
|
continue |
|
} |
|
if (key === '\x15') { |
|
query = '' |
|
cursor = 0 |
|
continue |
|
} |
|
if (key.startsWith('\x1b')) continue // any other escape sequence |
|
|
|
const typed = [...key].filter((char) => char >= ' ' && char !== '\x7f').join('') |
|
if (typed) { |
|
query += typed |
|
cursor = 0 |
|
} |
|
} |
|
|
|
erase() |
|
if (aborted) return null |
|
|
|
return chosen |
|
} |
|
|
|
function pickNumbered(fd, items, prompt, label) { |
|
items.forEach((item, index) => console.error(` ${c.dim}[${index + 1}]${c.off} ${label(item)}`)) |
|
|
|
const answer = promptLine(fd, `${c.yellow} ?${c.off} ${prompt} [1-${items.length}] `) |
|
const index = Number.parseInt(answer.trim(), 10) |
|
if (!Number.isInteger(index) || index < 1 || index > items.length) fail('invalid selection') |
|
|
|
return items[index - 1] |
|
} |
|
|
|
// ─── Environment ────────────────────────────────────────────────────── |
|
const environment = environmentArg ?? pick(ENVIRONMENTS, 'environment') |
|
if (environment === null) process.exit(130) |
|
if (!ENVIRONMENTS.includes(environment)) { |
|
fail(`unknown environment '${environment}' (expected: ${ENVIRONMENTS.join(', ')})`) |
|
} |
|
const profile = `${AWS_PROFILE_PREFIX}${environment}` |
|
|
|
// ─── Instance list ──────────────────────────────────────────────────── |
|
info(`querying running instances in ${environment} (${profile}, ${AWS_REGION})`) |
|
|
|
// stdin/stderr stay attached so the AWS CLI can prompt for the MFA code. |
|
const aws = spawnSync( |
|
'aws', |
|
[ |
|
'ec2', |
|
'describe-instances', |
|
'--profile', |
|
profile, |
|
'--region', |
|
AWS_REGION, |
|
'--filters', |
|
'Name=instance-state-name,Values=running', |
|
'--output', |
|
'json', |
|
], |
|
{ encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 }, |
|
) |
|
|
|
if (aws.error?.code === 'ENOENT') fail('aws cli not found') |
|
if (aws.status !== 0) fail(`aws describe-instances failed (exit ${aws.status})`) |
|
|
|
// Sort IPs numerically, so .65 does not land behind .199. |
|
const octets = (ip) => ip.split('.').reduce((value, octet) => value * 256 + Number(octet), 0) |
|
|
|
// Compact uptime, e.g. 47m / 31h / 12d — tells replaced hosts from long-lived ones. |
|
const age = (launchTime) => { |
|
const minutes = Math.floor((Date.now() - Date.parse(launchTime ?? '')) / 60_000) |
|
if (!Number.isFinite(minutes) || minutes < 0) return '-' |
|
if (minutes < 60) return `${minutes}m` |
|
const hours = Math.floor(minutes / 60) |
|
|
|
return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d` |
|
} |
|
|
|
let instances = JSON.parse(aws.stdout).Reservations.flatMap((reservation) => reservation.Instances) |
|
.filter((instance) => instance.PrivateIpAddress) |
|
.map((instance) => ({ |
|
name: instance.Tags?.find((tag) => tag.Key === 'Name')?.Value ?? '-', |
|
ip: instance.PrivateIpAddress, |
|
id: instance.InstanceId, |
|
type: instance.InstanceType, |
|
az: instance.Placement?.AvailabilityZone ?? '-', |
|
age: age(instance.LaunchTime), |
|
})) |
|
.sort((a, b) => a.name.localeCompare(b.name) || octets(a.ip) - octets(b.ip)) |
|
|
|
if (instances.length === 0) fail(`no running instances found in ${environment}`) |
|
|
|
if (filter) { |
|
const needle = filter.toLowerCase() |
|
instances = instances.filter((instance) => |
|
[instance.name, instance.ip, instance.id, instance.type].some((field) => |
|
field.toLowerCase().includes(needle), |
|
), |
|
) |
|
if (instances.length === 0) fail(`no running instance in ${environment} matches '${filter}'`) |
|
} |
|
|
|
const width = Math.max(...instances.map((instance) => instance.name.length)) |
|
const label = (instance) => |
|
[ |
|
instance.name.padEnd(width), |
|
instance.ip.padEnd(15), |
|
instance.id.padEnd(19), |
|
instance.type.padEnd(12), |
|
instance.az, |
|
instance.age.padStart(4), |
|
].join(' ') |
|
|
|
if (listOnly) { |
|
for (const instance of instances) console.log(label(instance)) |
|
process.exit(0) |
|
} |
|
|
|
// ─── Connect ────────────────────────────────────────────────────────── |
|
// Prod runs ~90 instances, so pick the role (Name tag) first and only descend |
|
// into the individual hosts when a role has more than one. |
|
const groups = [...new Set(instances.map((instance) => instance.name))].map((name) => { |
|
const members = instances.filter((instance) => instance.name === name) |
|
|
|
return { |
|
name, |
|
members, |
|
types: [...new Set(members.map((member) => member.type))].join(','), |
|
zones: [...new Set(members.map((member) => member.az.replace(/^.*-/, '')))].sort().join(','), |
|
} |
|
}) |
|
|
|
const groupWidth = Math.max(...groups.map((group) => group.name.length)) |
|
const typeWidth = Math.max(...groups.map((group) => group.types.length)) |
|
const groupLabel = (group) => |
|
[ |
|
group.name.padEnd(groupWidth), |
|
`${group.members.length}×`.padStart(3), |
|
group.types.padEnd(typeWidth), |
|
group.zones, |
|
].join(' ') |
|
const memberLabel = (member) => |
|
[ |
|
member.ip.padEnd(15), |
|
member.id.padEnd(19), |
|
member.type.padEnd(12), |
|
member.az, |
|
member.age.padStart(4), |
|
].join(' ') |
|
|
|
const lastHost = readState()[environment] |
|
let selected = null |
|
|
|
while (selected === null) { |
|
const group = pick(groups, `${environment} role`, { |
|
label: groupLabel, |
|
preselected: (candidate) => candidate.members.some((member) => member.id === lastHost), |
|
maxRows: Infinity, // roles are few enough to show in full |
|
}) |
|
if (group === null) process.exit(130) |
|
|
|
if (group.members.length === 1) { |
|
selected = group.members[0] |
|
break |
|
} |
|
|
|
selected = pick(group.members, group.name, { |
|
label: memberLabel, |
|
preselected: (member) => member.id === lastHost, |
|
back: 'back', |
|
}) |
|
// Esc in the host picker returns to the role list — unless there is no list |
|
// to return to, because a single role was auto-selected. |
|
if (selected === null && groups.length === 1) process.exit(130) |
|
} |
|
|
|
rememberHost(environment, selected.id) |
|
|
|
// Hand the session a clean screen: wipe the picker noise, but keep the |
|
// scrollback (no \x1b[3J) so the work from before the login stays reachable. |
|
// Skipped for remote commands, where the surrounding output is the point. |
|
if (remoteCommand.length === 0 && process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H') |
|
|
|
success(`connecting to ${selected.name} (${selected.ip}) as ${SSH_USER}`) |
|
|
|
// Host keys are deliberately not pinned: ASG and karpenter recycle the private |
|
// IPs, so a known_hosts entry would turn into a false "identification changed" |
|
// alarm within days. LogLevel=ERROR drops the resulting "permanently added" |
|
// notice, which would otherwise land on the freshly cleared screen. |
|
const ssh = spawnSync( |
|
'ssh', |
|
[ |
|
'-o', |
|
'StrictHostKeyChecking=no', |
|
'-o', |
|
'UserKnownHostsFile=/dev/null', |
|
'-o', |
|
'LogLevel=ERROR', |
|
`${SSH_USER}@${selected.ip}`, |
|
...remoteCommand, |
|
], |
|
{ stdio: 'inherit', env: { ...process.env, TERM: remoteTerm() } }, |
|
) |
|
process.exit(ssh.status ?? 1) |