Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Tests

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Node.js ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version:
- 20.x
- 22.x
- 24.x

steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test
214 changes: 118 additions & 96 deletions lib/commands/stack/ssh.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,129 @@ const AWSSSMSession = pkg.default;
import { getStack } from './util.js';
import Vantage from '../../vantage.js';

// Keep the socket alive by pinging every 10s.
// Keep the socket alive by pinging every 30s.
const IDLE_INTERVAL = 30_000;

const INITIAL_SIZE_WAIT = 700;

export function attachSessionInput( session, input = process.stdin, errorOutput = process.stderr ) {
let lastWasNewline = true;
let startedSshControl = false;
const wasRaw = Boolean( input.isRaw );

const listener = data => {
let msg = '';
for ( const byte of data.values() ) {
// ~ byte, indicates start of control code.
if ( lastWasNewline && byte === 0x7E && !startedSshControl ) {
startedSshControl = true;
continue;
}

// Check for newlines (LF or CR).
lastWasNewline = byte === 0x0A || byte === 0x0D;

const char = String.fromCharCode( byte );
if ( startedSshControl ) {
startedSshControl = false;
// ~. - terminate connection (and any multiplexed sessions)
if ( byte === 0x2E ) {
session.close();
return;
}

// ~^Z - suspend ssh
if ( byte === 0x1A ) {
errorOutput.write( '~^Z [suspend ssh]' );
process.kill( process.pid, 'SIGTSTP' );
return;
}

// ~? - show supported escape sequences
if ( byte === 0x3F ) {
let message = '~?\nSupported escape sequences:\n';
message += '~. - terminate connection (and any multiplexed sessions)\n';
message += '~^Z - suspend ssh\n';
message += '~? - this message\n';
message += '(Note that escapes are only recognized immediately after newline.)\n';
errorOutput.write( message );

// Allow immediate input of another control command.
lastWasNewline = true;
break;
}

// ~~ sends the escape character. Any other sequence sends both
// the leading ~ and the typed character to the remote session.
msg += '~';
}

msg += char;
}

if ( msg ) {
session.write( msg );
}
};

if ( input.isTTY && typeof input.setRawMode === 'function' ) {
input.setRawMode( true );
}
input.on( 'data', listener );
input.resume();

return () => {
input.removeListener( 'data', listener );
if ( input.isTTY && typeof input.setRawMode === 'function' ) {
input.setRawMode( wasRaw );
}
};
}

export function startKeepalive( session, schedule = setInterval ) {
return schedule( () => session.ping(), IDLE_INTERVAL );
}

function connect( data ) {
let idleTimer;
let initializationTimer;
let terminalResizeListener;
let terminalResizeDebounce;
let lastWasNewline = true;
let startedSshControl = false;
let detachInput;
let beforeExitListener;
const session = new AWSSSMSession( data.stream_url, data.aws_ssm_session_id, data.token );
const cleanup = () => {
if ( initializationTimer ) {
clearTimeout( initializationTimer );
initializationTimer = null;
}
if ( terminalResizeDebounce ) {
clearTimeout( terminalResizeDebounce );
terminalResizeDebounce = null;
}
if ( terminalResizeListener ) {
process.stdout.removeListener( 'resize', terminalResizeListener );
terminalResizeListener = null;
}
if ( idleTimer ) {
clearInterval( idleTimer );
idleTimer = null;
}
if ( detachInput ) {
detachInput();
detachInput = null;
}
if ( beforeExitListener ) {
process.removeListener( 'beforeExit', beforeExitListener );
beforeExitListener = null;
}
};

session.on( 'connect', () => {
setTimeout( () => {
if ( ! session ) {
return;
}
initializationTimer = setTimeout( () => {
initializationTimer = null;
session.setSize( process.stdout.columns, process.stdout.rows );

if ( process.stdin.isTTY ) {
process.stdin.setRawMode( true );
}
detachInput = attachSessionInput( session );

terminalResizeListener = () => {
if ( terminalResizeDebounce ) {
Expand All @@ -35,99 +136,20 @@ function connect( data ) {
terminalResizeDebounce = setTimeout( () => {
session.setSize( process.stdout.columns, process.stdout.rows );
}, 500 );
}
};

process.stdout.on( 'resize', terminalResizeListener );

setTimeout( () => {
process.stdin.on( 'data', data => {
let msg = '';
for ( const byte of data.values() ) {
// ~ byte, indicates start of control code.
if ( lastWasNewline && byte === 0x7E && !startedSshControl ) {
startedSshControl = true;
continue;
}

// Check for newlines (LF or CR).
if ( byte === 0x0A || byte === 0x0D ) {
lastWasNewline = true;
} else {
lastWasNewline = false;
}

const char = String.fromCharCode( byte );
if ( startedSshControl ) {
startedSshControl = false;
// ~. - terminate connection (and any multiplexed sessions)
if ( byte === 0x2E ) {
session.close();
return;
}

// ~B - send a BREAK to the remote system
// ~C - open a command line
// ~R - Request rekey (SSH protocol 2 only)
// ~^Z - suspend ssh
if ( byte === 0x1A ) {
process.stderr.write( '~^Z [suspend ssh]' );
process.kill( process.pid, 'SIGTSTP' );
return;
}

// ~# - list forwarded connections
// ~& - background ssh (when waiting for connections to terminate)
// ~? - this message
if ( byte === 0x3F ) {
let message = '~?\nSupported escape sequences:\n';
message += '~. - terminate connection (and any multiplexed sessions)\n';
message += '~^Z - suspend ssh\n';
message += '~? - this message\n';
message += '(Note that escapes are only recognized immediately after newline.)\n';
process.stderr.write( message );

// Allow immediate input of another control command.
lastWasNewline = true;
break;
}

// ~~ - send the escape character by typing it twice
// Everything else: invalid escape, send ~ and actual char.
process.stderr.write( byte.toString( 16 ) );
msg += '~';
}

msg += char;
}

session.write( msg );
} );
}, 1000 );
}, INITIAL_SIZE_WAIT );

// Set up our listeners.
process.on( 'beforeExit', () => {
session.close();
} );
idleTimer = setInterval( () => {
if ( ! this.awsSSMSession ) {
return;
}

this.awsSSMSession.ping();
}, IDLE_INTERVAL );
beforeExitListener = () => session.close();
process.on( 'beforeExit', beforeExitListener );
idleTimer = startKeepalive( session );
} );
session.on( 'disconnect', ( reason ) => {
if ( terminalResizeListener ) {
process.stdout.removeListener( 'resize', terminalResizeListener );
}
if ( idleTimer ) {
clearInterval( idleTimer );
}

cleanup();
const message = reason ? `${ reason }. Disconnected.` : 'Disconnected.';
process.stderr.write( message );
session.close();
process.stderr.write( `${ message }\n` );
process.exit();
} );
session.on( 'output', ( data ) => {
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"bin": {
"altis-cli": "./bin/altis-cli.js"
},
"scripts": {
"test": "node --test"
},
"files": [
"bin",
"lib"
Expand Down
63 changes: 63 additions & 0 deletions test/ssh.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { PassThrough } from 'node:stream';
import test from 'node:test';

import { attachSessionInput, startKeepalive } from '../lib/commands/stack/ssh.js';

test('SSH input resumes a paused terminal and forwards data', async () => {
const input = new PassThrough();
const rawModes = [];
input.isTTY = true;
input.isRaw = false;
input.setRawMode = value => {
input.isRaw = value;
rawModes.push(value);
};
input.pause();

const writes = [];
const session = {
write: value => writes.push(value),
close: () => {},
};
const detach = attachSessionInput(session, input, new PassThrough());
input.write(Buffer.from('pwd\r'));
await new Promise(resolve => setImmediate(resolve));

assert.deepEqual(writes, ['pwd\r']);
assert.deepEqual(rawModes, [true]);
detach();
assert.deepEqual(rawModes, [true, false]);
});

test('SSH escape sequence closes the active session', async () => {
const input = new PassThrough();
let closed = false;
const session = {
write: () => assert.fail('escape sequence should not be forwarded'),
close: () => { closed = true; },
};
const detach = attachSessionInput(session, input, new PassThrough());
input.write(Buffer.from('~.'));
await new Promise(resolve => setImmediate(resolve));
detach();

assert.equal(closed, true);
});

test('SSH keepalive pings the active session', () => {
let callback;
let pings = 0;
const timer = Symbol('timer');
const returnedTimer = startKeepalive(
{ ping: () => { pings++; } },
fn => {
callback = fn;
return timer;
},
);

assert.equal(returnedTimer, timer);
callback();
assert.equal(pings, 1);
});