From 9a073ea6a3f0d4ca3b71f0396222ce5dbe10b8a7 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 7 Nov 2022 17:55:44 +0000 Subject: [PATCH 01/11] Add initial DB connection code --- lib/commands/stack/db-conn.js | 484 ++++++++++++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 lib/commands/stack/db-conn.js diff --git a/lib/commands/stack/db-conn.js b/lib/commands/stack/db-conn.js new file mode 100644 index 0000000..0d8cafd --- /dev/null +++ b/lib/commands/stack/db-conn.js @@ -0,0 +1,484 @@ +const EventEmitter = require( 'events' ); +const fs = require( 'fs' ); +const net = require( 'net' ); +var stream = require( 'stream' ); +const ora = require( 'ora' ); + +const AWSSSMSession = require( '@humanmade/ssm' ).default; +const decode = require( '@humanmade/ssm' ).decode; + +const { getStack } = require( './util' ); +const Vantage = require( '../../vantage' ); + +// Keep the socket alive by pinging every 10s. +const KEEPALIVE_INTERVAL = 10_000; +const IDLE_INTERVAL = 30_000; + +const keepalive = '\x01\x03'; + +// const parseData = ( data ) => { +// const buf = Buffer.from( data ); +// const len = buf.length; +// let contentLength = 0; +// let i = 0; +// for ( i = 0; i < len; i++ ) { +// const byte = buf[ i ]; +// console.log( { byte } ); +// if ( byte === 0x01 ) { +// // Start header +// continue; +// } +// if ( byte === 0x03 ) { +// // End header. +// break; +// } +// contentLength = contentLength << 1 + byte; +// } + +// const content = buf.slice( i ); +// console.log( { contentLength, act: content.length } ); +// return { +// length: contentLength, +// data: content.toString(), +// }; +// } + +/** @param {TypedArray} value */ +const hexDump = ( value ) => { + const hex = Buffer.from( value ).toString( 'hex' ); + const groups = hex.match( /.{2}/g ); + let res = ''; + for ( let i = 0, done = 0; i < groups.length; i++ ) { + res += groups[i]; + done++; + if ( done >= 8 ) { + done = 0; + res += '\n'; + } else if ( done === 4 ) { + res += ' '; + } else { + res += ' '; + } + } + return res; +} + +class Logger { + _log( level, type, ...args ) { + if ( typeof type !== 'string' ) { + console.trace( 'Log called without valid string type' ); + } + const mapped = args.map( arg => { + if ( arg instanceof Uint8Array ) { + return arg.constructor.name + '<\n ' + hexDump( arg ).replace( /\n/g, '\n ' ) + '\n>'; + } + return arg; + } ); + console.log( + Date.now(), + '[' + level + ']', + type, + ...mapped + ); + } + debug = ( ...args ) => this._log( 'debug', ...args ); + info = ( ...args ) => this._log( 'info', ...args ); + warn = ( ...args ) => this._log( 'warn', ...args ); + error = ( ...args ) => this._log( 'error', ...args ); +} + +class WrappedLogger { + parent; + prefix; + + constructor( parent, prefix ) { + this.parent = parent; + this.prefix = prefix; + } + + debug( type, ...args ) { + this.parent.debug( `${ this.prefix }:${ type }`, ...args ); + } + info( type, ...args ) { + this.parent.info( `${ this.prefix }:${ type }`, ...args ); + } + warn( type, ...args ) { + this.parent.warn( `${ this.prefix }:${ type }`, ...args ); + } + error( type, ...args ) { + this.parent.error( `${ this.prefix }:${ type }`, ...args ); + } +} + +class NullLogger { + debug() {} + info() {} + warn() {} + error() {} +} + +class PortServer extends EventEmitter { + session; + + /** @type {?Buffer} */ + buffer; + bytesRead = 0; + dataWaiting = 0; + /** @type Logger */ + log; + started = false; + server; + + pauseStream = true; + pipeStream; + + constructor( log, session ) { + super(); + + this.log = log; + this.session = session; + this.buffer = Buffer.alloc( 8192 ); + + this.pipeStream = new stream.Duplex( { + write: ( chunk, enc, cb ) => { + if ( ! this.session ) { + cb( new Error( 'Cannot write before connection is open' ) ); + return; + } + + this.log.debug( 'client:recv', chunk ); + this.session.writeRaw( chunk ); + cb(); + }, + read: () => { + this.pauseStream = false; + this._maybeSend(); + }, + // read: + } ); + } + + start() { + if ( this.started ) { + throw new Error( 'Cannot start server multiple times' ); + } + + this.started = true; + this.server = net.createServer(); + this.server.on( 'connection', this.onConnect ); + this.server.on( 'error', this.onError ); + this.server.on( 'close', this.onClose ); + this.server.on( 'drop', this.onDrop ); + this.server.on( 'listening', this.onListening ); + this.server.listen( { + host: '127.0.0.1', + // port: 0, + port: 3306, + } ); + + this.session.on( 'rawOutput', ( data ) => { + /** @type Uint8Array */ + const x = data; + this.log.debug( 'server:recv', data ); + this._writeData( data ); + } ); + } + + /** @param {Uint8Array} data */ + _writeData( data ) { + const dataSize = data.length; + + // Resize buffer if data exceeds buffer length. + if ( ( this.buffer.length - this.dataWaiting ) < dataSize ) { + const factor = Math.ceil( ( dataSize - ( buffer.length - size ) ) / 8192 ); + + const newBuffer = Buffer.alloc( this.buffer.length + ( 8192 * factor ) ); + this.buffer.copy( newBuffer, 0, 0, this.dataWaiting ); + this.buffer = newBuffer; + } + + this.buffer.set( data, this.dataWaiting ); + this.dataWaiting += dataSize; + + this._maybeSend(); + } + + _maybeSend() { + if ( this.pauseStream ) { + return; + } + + let sendMore = false; + + do { + const amount = Math.min( 8192, this.dataWaiting ); + if ( amount <= 0 ) { + // Don't pause the stream, just wrap up. + return; + } + + const chunk = Buffer.alloc( amount ); + this.buffer.copy( chunk, 0, 0, amount ); + this.log.debug( 'client:send', chunk ); + sendMore = this.pipeStream.push( chunk ); + this.buffer.copy( this.buffer, 0, amount, this.dataWaiting ); + this.dataWaiting -= amount; + } while ( sendMore ); + + this.pauseStream = true; + } + + onListening = ( args ) => { + const addr = this.server.address(); + this.log.debug( 'listen', addr ); + this.emit( 'listen', addr.port ); + } + + /** + * @param {net.Socket} socket + */ + onConnect = ( socket ) => { + if ( ! this.session ) { + const err = new Error( 'Socket not established' ); + socket.destroy( err ); + return; + } + + /** @type Socket {socket} Socket */ + console.log( 'Client connected.' ); + this.log.debug( 'socket:connect' ); + socket.pipe( this.pipeStream ); + this.pipeStream.pipe( socket ); + + socket.on( 'close', () => { + // todo: close connection cleanly + this.log.debug( 'socket:close' ); + process.exit(); + } ); + + // Flush the buffered data. + // this.pauseStream = false; + // this._maybeSend(); + } + + onError = ( err ) => { + this.log.debug( 'error', err ); + } + + onClose = () => { + this.log.debug( 'close' ); + } + + onDrop = () => { + this.log.debug( 'drop' ); + } +} + +class PortForwardingConnection extends EventEmitter { + idleTimer; + /** @type Logger */ + log; + session; + + constructor( log, data ) { + super(); + + this.log = log; + this.session = new AWSSSMSession( data.stream_url, data.id, data.token, new WrappedLogger( this.log, 'ssm' ) ); + this.session.on( 'connect', this.onConnect ); + this.session.on( 'disconnect', this.onDisconnect ); + this.session.on( 'handshake', this.onHandshake ); + this.session.on( 'handshakeComplete', this.onHandshakeComplete ); + this.session.on( 'output', ( data ) => { + // console.log( { data } ); + // process.stderr.write( data ); + } ); + } + + onConnect = () => { + // Set up our listeners. + process.on( 'beforeExit', () => { + this.session.close(); + } ); + setTimeout( () => { + // this.session.write( '\x01\x03' ); + }, 1000 ); + this.idleTimer = setInterval( () => { + if ( ! this.session ) { + return; + } + + this.log.debug( 'ping' ); + this.session.ping(); + + // this.session.write( '\x01\x03' ); + }, IDLE_INTERVAL ); + } + + onDisconnect = ( reason ) => { + if ( this.idleTimer ) { + clearInterval( this.idleTimer ); + } + + const message = reason ? `${ reason }. Disconnected.` : 'Disconnected.'; + process.stderr.write( message ); + this.session.close(); + process.exit(); + } + + onHandshake = data => { + const resp = { + // Must be <1.1.70 to avoid mux. + ClientVersion: '1.1.69', + ProcessedClientActions: [], + Errors: null, + }; + for ( const item of data.RequestedClientActions ) { + const processed = { + ActionType: item.ActionType, + ActionResult: null, + Error: '', + }; + switch ( item.ActionType ) { + case 'SessionType': + // ... + if ( item.ActionParameters.SessionType !== 'Port' ) { + console.warn( `Unsupported session type ${ item.ActionParameters.SessionType }` ); + processed.ActionStatus = 2; // message.Failed + processed.Error = "Failed to process actionn"; + break; + } + + const sessionProps = item.ActionParameters.Properties; + if ( sessionProps.type !== 'LocalPortForwarding' ) { + console.warn( `Unsupported port session type ${ sessionProps.type }` ); + processed.ActionStatus = 2; // message.Failed + processed.Error = "Failed to process actionn"; + break; + } + + processed.ActionStatus = 1; // message.Success + + this.emit( 'startPortSession', sessionProps ); + break; + + default: + processed.ActionResult = 3; // message.Unsupported + console.warn( `Unsupported client action ${ item.ActionType }` ); + processed.Error = "Unsupported action"; + break; + } + + resp.ProcessedClientActions.push( processed ); + } + + this.session.sendHandshakeResponse( resp ); + } + + onHandshakeComplete = data => { + // { + // "HandshakeTimeToComplete": 2006400425, + // "CustomerMessage": "" + // } + this.emit( 'connected', { + message: data.CustomerMessage, + } ); + } +} + +function connect( log, data ) { + const conn = new PortForwardingConnection( log, data ); + const localServer = new PortServer( log, conn.session ); + + conn.on( 'startPortSession', () => { + localServer.start(); + } ); + conn.on( 'connected', data => { + console.log( 'Now connected!' ); + if ( data.message.length ) { + console.log( 'Server says: ' + data.message ); + } + } ) + + localServer.on( 'listen', port => { + console.log( `MySQL server established on port ${ port }` ); + } ); +} + +const handler = function ( argv ) { + const { config, debug } = argv; + + // const arr = new Uint8Array( [ + // 0x00, 0x00, 0x00, 0x74, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + // 0x5f, 0x64, 0x61, 0x74, 0x61, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + // 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x84, 0x42, 0x61, 0x40, 0x25, + // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 0xb8, 0x95, 0xa0, 0x35, 0x3a, 0xef, 0x10, 0xbc, 0x43, 0x80, 0x2c, 0x68, 0xe1, 0xa9, 0x41, 0xc8, + // 0x30, 0x65, 0x6b, 0xde, 0x98, 0x30, 0x20, 0xb4, 0xa0, 0x4d, 0x4f, 0xb8, 0x02, 0x74, 0x63, 0xad, + // 0x8c, 0xd1, 0x5d, 0x89, 0xd4, 0xb9, 0x53, 0xf9, 0x61, 0x65, 0x4b, 0x1d, 0x65, 0x79, 0xb9, 0xb9, + // 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + // ] ); + // console.log( decode( arr ) ); + + // const arr = new Uint8Array( [ + // 0x00, 0x00, 0x00, 0x74, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + // 0x5f, 0x64, 0x61, 0x74, 0x61, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + // 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x84, 0x42, 0xbc, 0xd2, 0xdd, + // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 0xa1, 0x52, 0x49, 0x93, 0x85, 0x57, 0xa1, 0xbe, 0x9b, 0x42, 0xf1, 0xa2, 0xff, 0x77, 0x4f, 0xa2, + // 0x19, 0x72, 0x3f, 0xc4, 0xe6, 0x96, 0x80, 0x7b, 0x01, 0xbf, 0xa3, 0x6c, 0x5c, 0xca, 0x5f, 0xb7, + // 0xb0, 0x28, 0x0d, 0x4c, 0x9e, 0xca, 0x81, 0x44, 0x27, 0xf5, 0x55, 0xa7, 0xd0, 0xe7, 0xa3, 0xa7, + // 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ] ); + const log = debug ? new Logger() : new NullLogger(); + + // console.log( decode( arr ) ); + // return; + + const rawData = fs.readFileSync( 0, 'utf-8' ); + const data = JSON.parse( rawData ); + + + // getStack( argv ).then( stack => { + // const v = new Vantage( config ); + + // const status = new ora( `Starting session on ${stack}` ); + // status.start(); + + // const url = `stack/applications/${stack}/cli/sessions`; + // v.fetch( url, { method: 'POST' } ).then( resp => { + // // hm-stack currently returns a 500 for invalid stacks, so we have to + // // assume any failure is a 404: https://github.com/humanmade/hm-stack/issues/367 + // if ( ! resp.ok ) { + // status.fail( `Invalid stack ${stack}` ); + // return; + // } + + // return resp.json().then( data => { + // status.stop(); + connect( log, data ); + // }); + // }).catch( e => { + // status.fail( `Could not fetch details for ${stack}` ); + // throw e; + // }); + // } ); +}; + +module.exports = { + command: 'db-conn [stack]', + description: 'DB? into a stack.', + builder: subcommand => { + subcommand.option( 'app-server', { + description: 'Use an app server instead of the sandbox.', + default: false, + type: 'boolean', + } ); + subcommand.option( 'debug', { + description: 'Output debug logs to the terminal', + default: false, + type: 'boolean', + } ); + }, + handler, +}; From f7d41dd412e57fd0577941b39e396ab3577d7c87 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 15 Oct 2026 08:45:39 +0200 Subject: [PATCH 02/11] Refresh experimental database tunnel for the TypeScript CLI --- README.md | 24 ++ lib/commands/stack/db-conn.js | 484 ---------------------------------- lib/commands/stack/db-conn.ts | 187 +++++++++++++ lib/types/cli.ts | 1 + test/commands.test.js | 2 +- test/db-conn.test.js | 151 +++++++++++ 6 files changed, 364 insertions(+), 485 deletions(-) delete mode 100644 lib/commands/stack/db-conn.js create mode 100644 lib/commands/stack/db-conn.ts create mode 100644 test/db-conn.test.js diff --git a/README.md b/README.md index 01812c3..bec0315 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,27 @@ npm test CLI endpoint additions live in `lib/types/api.ts`; X-Ray summaries and statistics live in `lib/types/xray.ts`. Neither builds nor installs require a frontend checkout. `fetchJSON()` describes expected API responses without validating them at runtime. + +### Experimental database tunnel + +`altis-cli app db-conn --port 3307` accepts database SSM session JSON on stdin: + +```sh +cat session.json | altis-cli app db-conn --port 3307 +``` + +The JSON must contain `id` (SSM SessionId), `stream_url` (StreamUrl), and +`token` (TokenValue). Treat this file as a credential. The session must use +`AWS-StartPortForwardingSessionToRemoteHost`, targeting the sandbox instance +and the desired database host on port 3306. The local listener binds to +`127.0.0.1` and supports one database client connection; create a new session +after that client disconnects. Ctrl-C closes the tunnel. + +This remains a development prototype. It requires the binary socket and +handshake API from [humanmade/ssm#2](https://github.com/humanmade/ssm/pull/2), +which is absent from the currently declared published dependency. The command +reports that limitation before opening a connection. No stack argument is +accepted yet: [Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547) +adds an application method but does not expose a REST endpoint to create these +sessions. Automatic session creation, dependency integration, and a live +SSM/MySQL test are required before this command is ready to ship. diff --git a/lib/commands/stack/db-conn.js b/lib/commands/stack/db-conn.js deleted file mode 100644 index 0d8cafd..0000000 --- a/lib/commands/stack/db-conn.js +++ /dev/null @@ -1,484 +0,0 @@ -const EventEmitter = require( 'events' ); -const fs = require( 'fs' ); -const net = require( 'net' ); -var stream = require( 'stream' ); -const ora = require( 'ora' ); - -const AWSSSMSession = require( '@humanmade/ssm' ).default; -const decode = require( '@humanmade/ssm' ).decode; - -const { getStack } = require( './util' ); -const Vantage = require( '../../vantage' ); - -// Keep the socket alive by pinging every 10s. -const KEEPALIVE_INTERVAL = 10_000; -const IDLE_INTERVAL = 30_000; - -const keepalive = '\x01\x03'; - -// const parseData = ( data ) => { -// const buf = Buffer.from( data ); -// const len = buf.length; -// let contentLength = 0; -// let i = 0; -// for ( i = 0; i < len; i++ ) { -// const byte = buf[ i ]; -// console.log( { byte } ); -// if ( byte === 0x01 ) { -// // Start header -// continue; -// } -// if ( byte === 0x03 ) { -// // End header. -// break; -// } -// contentLength = contentLength << 1 + byte; -// } - -// const content = buf.slice( i ); -// console.log( { contentLength, act: content.length } ); -// return { -// length: contentLength, -// data: content.toString(), -// }; -// } - -/** @param {TypedArray} value */ -const hexDump = ( value ) => { - const hex = Buffer.from( value ).toString( 'hex' ); - const groups = hex.match( /.{2}/g ); - let res = ''; - for ( let i = 0, done = 0; i < groups.length; i++ ) { - res += groups[i]; - done++; - if ( done >= 8 ) { - done = 0; - res += '\n'; - } else if ( done === 4 ) { - res += ' '; - } else { - res += ' '; - } - } - return res; -} - -class Logger { - _log( level, type, ...args ) { - if ( typeof type !== 'string' ) { - console.trace( 'Log called without valid string type' ); - } - const mapped = args.map( arg => { - if ( arg instanceof Uint8Array ) { - return arg.constructor.name + '<\n ' + hexDump( arg ).replace( /\n/g, '\n ' ) + '\n>'; - } - return arg; - } ); - console.log( - Date.now(), - '[' + level + ']', - type, - ...mapped - ); - } - debug = ( ...args ) => this._log( 'debug', ...args ); - info = ( ...args ) => this._log( 'info', ...args ); - warn = ( ...args ) => this._log( 'warn', ...args ); - error = ( ...args ) => this._log( 'error', ...args ); -} - -class WrappedLogger { - parent; - prefix; - - constructor( parent, prefix ) { - this.parent = parent; - this.prefix = prefix; - } - - debug( type, ...args ) { - this.parent.debug( `${ this.prefix }:${ type }`, ...args ); - } - info( type, ...args ) { - this.parent.info( `${ this.prefix }:${ type }`, ...args ); - } - warn( type, ...args ) { - this.parent.warn( `${ this.prefix }:${ type }`, ...args ); - } - error( type, ...args ) { - this.parent.error( `${ this.prefix }:${ type }`, ...args ); - } -} - -class NullLogger { - debug() {} - info() {} - warn() {} - error() {} -} - -class PortServer extends EventEmitter { - session; - - /** @type {?Buffer} */ - buffer; - bytesRead = 0; - dataWaiting = 0; - /** @type Logger */ - log; - started = false; - server; - - pauseStream = true; - pipeStream; - - constructor( log, session ) { - super(); - - this.log = log; - this.session = session; - this.buffer = Buffer.alloc( 8192 ); - - this.pipeStream = new stream.Duplex( { - write: ( chunk, enc, cb ) => { - if ( ! this.session ) { - cb( new Error( 'Cannot write before connection is open' ) ); - return; - } - - this.log.debug( 'client:recv', chunk ); - this.session.writeRaw( chunk ); - cb(); - }, - read: () => { - this.pauseStream = false; - this._maybeSend(); - }, - // read: - } ); - } - - start() { - if ( this.started ) { - throw new Error( 'Cannot start server multiple times' ); - } - - this.started = true; - this.server = net.createServer(); - this.server.on( 'connection', this.onConnect ); - this.server.on( 'error', this.onError ); - this.server.on( 'close', this.onClose ); - this.server.on( 'drop', this.onDrop ); - this.server.on( 'listening', this.onListening ); - this.server.listen( { - host: '127.0.0.1', - // port: 0, - port: 3306, - } ); - - this.session.on( 'rawOutput', ( data ) => { - /** @type Uint8Array */ - const x = data; - this.log.debug( 'server:recv', data ); - this._writeData( data ); - } ); - } - - /** @param {Uint8Array} data */ - _writeData( data ) { - const dataSize = data.length; - - // Resize buffer if data exceeds buffer length. - if ( ( this.buffer.length - this.dataWaiting ) < dataSize ) { - const factor = Math.ceil( ( dataSize - ( buffer.length - size ) ) / 8192 ); - - const newBuffer = Buffer.alloc( this.buffer.length + ( 8192 * factor ) ); - this.buffer.copy( newBuffer, 0, 0, this.dataWaiting ); - this.buffer = newBuffer; - } - - this.buffer.set( data, this.dataWaiting ); - this.dataWaiting += dataSize; - - this._maybeSend(); - } - - _maybeSend() { - if ( this.pauseStream ) { - return; - } - - let sendMore = false; - - do { - const amount = Math.min( 8192, this.dataWaiting ); - if ( amount <= 0 ) { - // Don't pause the stream, just wrap up. - return; - } - - const chunk = Buffer.alloc( amount ); - this.buffer.copy( chunk, 0, 0, amount ); - this.log.debug( 'client:send', chunk ); - sendMore = this.pipeStream.push( chunk ); - this.buffer.copy( this.buffer, 0, amount, this.dataWaiting ); - this.dataWaiting -= amount; - } while ( sendMore ); - - this.pauseStream = true; - } - - onListening = ( args ) => { - const addr = this.server.address(); - this.log.debug( 'listen', addr ); - this.emit( 'listen', addr.port ); - } - - /** - * @param {net.Socket} socket - */ - onConnect = ( socket ) => { - if ( ! this.session ) { - const err = new Error( 'Socket not established' ); - socket.destroy( err ); - return; - } - - /** @type Socket {socket} Socket */ - console.log( 'Client connected.' ); - this.log.debug( 'socket:connect' ); - socket.pipe( this.pipeStream ); - this.pipeStream.pipe( socket ); - - socket.on( 'close', () => { - // todo: close connection cleanly - this.log.debug( 'socket:close' ); - process.exit(); - } ); - - // Flush the buffered data. - // this.pauseStream = false; - // this._maybeSend(); - } - - onError = ( err ) => { - this.log.debug( 'error', err ); - } - - onClose = () => { - this.log.debug( 'close' ); - } - - onDrop = () => { - this.log.debug( 'drop' ); - } -} - -class PortForwardingConnection extends EventEmitter { - idleTimer; - /** @type Logger */ - log; - session; - - constructor( log, data ) { - super(); - - this.log = log; - this.session = new AWSSSMSession( data.stream_url, data.id, data.token, new WrappedLogger( this.log, 'ssm' ) ); - this.session.on( 'connect', this.onConnect ); - this.session.on( 'disconnect', this.onDisconnect ); - this.session.on( 'handshake', this.onHandshake ); - this.session.on( 'handshakeComplete', this.onHandshakeComplete ); - this.session.on( 'output', ( data ) => { - // console.log( { data } ); - // process.stderr.write( data ); - } ); - } - - onConnect = () => { - // Set up our listeners. - process.on( 'beforeExit', () => { - this.session.close(); - } ); - setTimeout( () => { - // this.session.write( '\x01\x03' ); - }, 1000 ); - this.idleTimer = setInterval( () => { - if ( ! this.session ) { - return; - } - - this.log.debug( 'ping' ); - this.session.ping(); - - // this.session.write( '\x01\x03' ); - }, IDLE_INTERVAL ); - } - - onDisconnect = ( reason ) => { - if ( this.idleTimer ) { - clearInterval( this.idleTimer ); - } - - const message = reason ? `${ reason }. Disconnected.` : 'Disconnected.'; - process.stderr.write( message ); - this.session.close(); - process.exit(); - } - - onHandshake = data => { - const resp = { - // Must be <1.1.70 to avoid mux. - ClientVersion: '1.1.69', - ProcessedClientActions: [], - Errors: null, - }; - for ( const item of data.RequestedClientActions ) { - const processed = { - ActionType: item.ActionType, - ActionResult: null, - Error: '', - }; - switch ( item.ActionType ) { - case 'SessionType': - // ... - if ( item.ActionParameters.SessionType !== 'Port' ) { - console.warn( `Unsupported session type ${ item.ActionParameters.SessionType }` ); - processed.ActionStatus = 2; // message.Failed - processed.Error = "Failed to process actionn"; - break; - } - - const sessionProps = item.ActionParameters.Properties; - if ( sessionProps.type !== 'LocalPortForwarding' ) { - console.warn( `Unsupported port session type ${ sessionProps.type }` ); - processed.ActionStatus = 2; // message.Failed - processed.Error = "Failed to process actionn"; - break; - } - - processed.ActionStatus = 1; // message.Success - - this.emit( 'startPortSession', sessionProps ); - break; - - default: - processed.ActionResult = 3; // message.Unsupported - console.warn( `Unsupported client action ${ item.ActionType }` ); - processed.Error = "Unsupported action"; - break; - } - - resp.ProcessedClientActions.push( processed ); - } - - this.session.sendHandshakeResponse( resp ); - } - - onHandshakeComplete = data => { - // { - // "HandshakeTimeToComplete": 2006400425, - // "CustomerMessage": "" - // } - this.emit( 'connected', { - message: data.CustomerMessage, - } ); - } -} - -function connect( log, data ) { - const conn = new PortForwardingConnection( log, data ); - const localServer = new PortServer( log, conn.session ); - - conn.on( 'startPortSession', () => { - localServer.start(); - } ); - conn.on( 'connected', data => { - console.log( 'Now connected!' ); - if ( data.message.length ) { - console.log( 'Server says: ' + data.message ); - } - } ) - - localServer.on( 'listen', port => { - console.log( `MySQL server established on port ${ port }` ); - } ); -} - -const handler = function ( argv ) { - const { config, debug } = argv; - - // const arr = new Uint8Array( [ - // 0x00, 0x00, 0x00, 0x74, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, - // 0x5f, 0x64, 0x61, 0x74, 0x61, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - // 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x84, 0x42, 0x61, 0x40, 0x25, - // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // 0xb8, 0x95, 0xa0, 0x35, 0x3a, 0xef, 0x10, 0xbc, 0x43, 0x80, 0x2c, 0x68, 0xe1, 0xa9, 0x41, 0xc8, - // 0x30, 0x65, 0x6b, 0xde, 0x98, 0x30, 0x20, 0xb4, 0xa0, 0x4d, 0x4f, 0xb8, 0x02, 0x74, 0x63, 0xad, - // 0x8c, 0xd1, 0x5d, 0x89, 0xd4, 0xb9, 0x53, 0xf9, 0x61, 0x65, 0x4b, 0x1d, 0x65, 0x79, 0xb9, 0xb9, - // 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, - // ] ); - // console.log( decode( arr ) ); - - // const arr = new Uint8Array( [ - // 0x00, 0x00, 0x00, 0x74, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, - // 0x5f, 0x64, 0x61, 0x74, 0x61, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - // 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x84, 0x42, 0xbc, 0xd2, 0xdd, - // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // 0xa1, 0x52, 0x49, 0x93, 0x85, 0x57, 0xa1, 0xbe, 0x9b, 0x42, 0xf1, 0xa2, 0xff, 0x77, 0x4f, 0xa2, - // 0x19, 0x72, 0x3f, 0xc4, 0xe6, 0x96, 0x80, 0x7b, 0x01, 0xbf, 0xa3, 0x6c, 0x5c, 0xca, 0x5f, 0xb7, - // 0xb0, 0x28, 0x0d, 0x4c, 0x9e, 0xca, 0x81, 0x44, 0x27, 0xf5, 0x55, 0xa7, 0xd0, 0xe7, 0xa3, 0xa7, - // 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // ] ); - const log = debug ? new Logger() : new NullLogger(); - - // console.log( decode( arr ) ); - // return; - - const rawData = fs.readFileSync( 0, 'utf-8' ); - const data = JSON.parse( rawData ); - - - // getStack( argv ).then( stack => { - // const v = new Vantage( config ); - - // const status = new ora( `Starting session on ${stack}` ); - // status.start(); - - // const url = `stack/applications/${stack}/cli/sessions`; - // v.fetch( url, { method: 'POST' } ).then( resp => { - // // hm-stack currently returns a 500 for invalid stacks, so we have to - // // assume any failure is a 404: https://github.com/humanmade/hm-stack/issues/367 - // if ( ! resp.ok ) { - // status.fail( `Invalid stack ${stack}` ); - // return; - // } - - // return resp.json().then( data => { - // status.stop(); - connect( log, data ); - // }); - // }).catch( e => { - // status.fail( `Could not fetch details for ${stack}` ); - // throw e; - // }); - // } ); -}; - -module.exports = { - command: 'db-conn [stack]', - description: 'DB? into a stack.', - builder: subcommand => { - subcommand.option( 'app-server', { - description: 'Use an app server instead of the sandbox.', - default: false, - type: 'boolean', - } ); - subcommand.option( 'debug', { - description: 'Output debug logs to the terminal', - default: false, - type: 'boolean', - } ); - }, - handler, -}; diff --git a/lib/commands/stack/db-conn.ts b/lib/commands/stack/db-conn.ts new file mode 100644 index 0000000..1f0db77 --- /dev/null +++ b/lib/commands/stack/db-conn.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'node:fs'; +import { createServer, type Socket } from 'node:net'; +import { Duplex } from 'node:stream'; +import pkg from '@humanmade/ssm'; +import type { CliArguments, CommandDefinition } from '../../types/cli.ts'; + +interface SessionData { + id: string; + stream_url: string; + token: string; +} +interface HandshakeAction { + ActionType: string; + ActionParameters?: { SessionType?: string; Properties?: { type?: string } }; +} +export interface PortSession { + on(event: string, listener: (...args: any[]) => void): unknown; + writeRaw(data: Uint8Array): void; + sendHandshakeResponse(response: unknown): void; + close(): void; +} + +// Bound data waiting for a local client, or for a slow client to read. +const MAX_BUFFER = 16 * 1024 * 1024; + +export function parseSession(raw: string): SessionData { + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + throw new Error('Expected session JSON on stdin (id, stream_url, token).'); + } + if (!data || typeof data !== 'object' || !['id', 'stream_url', 'token'].every( + key => typeof (data as Record)[key] === 'string' && (data as Record)[key].length > 0 + )) { + throw new Error('Session JSON must contain non-empty id, stream_url, and token strings.'); + } + const session = data as SessionData; + try { + if (new URL(session.stream_url).protocol !== 'wss:') throw new Error(); + } catch { + throw new Error('Session stream_url must be a valid wss:// URL.'); + } + return session; +} + +/** One non-multiplexed SSM session supports one database client connection. */ +export function forwardSession(session: PortSession, port = 3306) { + let stopped = false; + let accepted = false; + let negotiated = false; + let listeningStarted = false; + let paused = false; + let pendingWrite: (() => void) | undefined; + let client: Socket | undefined; + let resolveDone: () => void; + let rejectDone: (error: Error) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + const tunnel = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + const write = () => { + try { session.writeRaw(chunk); callback(); } + catch (error) { callback(error as Error); } + }; + if (paused) pendingWrite = write; + else write(); + }, + }); + const server = createServer(socket => { + if (accepted || stopped) { + socket.destroy(); + return; + } + accepted = true; + client = socket; + socket.on('error', stop); + socket.on('close', () => stop()); + socket.pipe(tunnel).pipe(socket); + }); + const stop = (error?: Error) => { + if (stopped) return; + stopped = true; + clearTimeout(timeout); + pendingWrite = undefined; + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', interrupt); + client?.destroy(); + tunnel.destroy(); + server.close(); + try { session.close(); } + catch (closeError) { error ??= closeError as Error; } + if (error) rejectDone(error); + else resolveDone(); + }; + const interrupt = () => stop(); + const timeout = setTimeout(() => stop(new Error('Timed out negotiating the database session.')), 30_000); + process.on('SIGINT', interrupt); + process.on('SIGTERM', interrupt); + tunnel.on('error', stop); + server.on('error', stop); + session.on('disconnect', (reason?: string) => stop(new Error(reason || 'Database session disconnected.'))); + session.on('pause', () => { paused = true; }); + session.on('resume', () => { + paused = false; + const write = pendingWrite; + pendingWrite = undefined; + write?.(); + }); + session.on('rawOutput', (data: Uint8Array) => { + if (stopped) return; + if (tunnel.readableLength + data.byteLength > MAX_BUFFER) { + stop(new Error('Database client is too slow: tunnel buffer limit exceeded.')); + return; + } + tunnel.push(Buffer.from(data)); + }); + session.on('handshake', (data: { RequestedClientActions: HandshakeAction[] }) => { + if (stopped) return; + try { + let portSession = false; + const errors: string[] = []; + const actions = data.RequestedClientActions.map(action => { + const supported = action.ActionType === 'SessionType' + && action.ActionParameters?.SessionType === 'Port' + && action.ActionParameters.Properties?.type === 'LocalPortForwarding'; + if (supported) portSession = true; + else errors.push(`Unsupported database session action: ${action.ActionType}`); + return { ActionType: action.ActionType, ActionStatus: supported ? 1 : 3, + ActionResult: null, Error: supported ? '' : errors[errors.length - 1] }; + }); + // Stay below 1.1.70: this transport does not implement SSM multiplexing. + session.sendHandshakeResponse({ ClientVersion: '1.1.69', ProcessedClientActions: actions, Errors: errors }); + if (!portSession || errors.length) { + stop(new Error(errors.join('; ') || 'SSM did not request a port forwarding session.')); + return; + } + negotiated = true; + } catch (error) { stop(error as Error); } + }); + session.on('handshakeComplete', () => { + if (stopped || listeningStarted) return; + if (!negotiated) { + stop(new Error('SSM completed the handshake without negotiating port forwarding.')); + return; + } + clearTimeout(timeout); + listeningStarted = true; + server.listen({ host: '127.0.0.1', port }); + }); + // Do not send application-level keepalive data into the database stream. + return { server, done, close: () => stop() }; +} + +export default { + command: 'db-conn', + describe: 'Experimental database tunnel using session JSON from stdin (requires SSM socket support).', + builder: parser => { + parser.option('port', { describe: 'Local loopback port to listen on.', type: 'number', default: 3306 }); + }, + handler: async (argv: CliArguments) => { + const port = argv.port ?? 3306; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535.'); + } + if (process.stdin.isTTY) { + throw new Error('Pipe database SSM session JSON into this command. Vantage session creation is not implemented yet.'); + } + // Check the prototype before opening a WebSocket. Published SSM releases + // currently support terminal sessions only; see humanmade/ssm#2. + const Session = pkg.default; + const prototype = Session.prototype as unknown as Partial; + if (typeof prototype.writeRaw !== 'function' || typeof prototype.sendHandshakeResponse !== 'function') { + throw new Error('Database tunnels require @humanmade/ssm socket support (humanmade/ssm#2); the installed version does not support it.'); + } + const data = parseSession(readFileSync(0, 'utf8')); + const session = new Session(data.stream_url, data.id, data.token) as unknown as PortSession; + const forwarding = forwardSession(session, port); + forwarding.server.on('listening', () => { + console.log(`Database tunnel listening on 127.0.0.1:${port}. One client connection per session; Ctrl-C to close.`); + }); + await forwarding.done; + }, +} satisfies CommandDefinition; diff --git a/lib/types/cli.ts b/lib/types/cli.ts index 9942366..1ee6c4a 100644 --- a/lib/types/cli.ts +++ b/lib/types/cli.ts @@ -47,6 +47,7 @@ export interface CliArguments { origin?: string; output?: string; path?: string; + port?: number; pattern?: (string | number)[]; postSync?: boolean; replace?: (string | number)[]; diff --git a/test/commands.test.js b/test/commands.test.js index abd3b30..d80d21b 100644 --- a/test/commands.test.js +++ b/test/commands.test.js @@ -14,7 +14,7 @@ const commands = [ ['import', 'database', 'example-dev'], ['sync-local', 'all', 'example-dev'], ['sync-local', 'database', 'example-dev'], ['sync-local', 'uploads', 'example-dev'], ['xray', 'list-traces'], ['xray', 'trace', 'trace-id'], ['xray', 'trace-file', 'trace.json'], - ['ssh'], ['scp', 'app:/tmp/file', './file'], + ['db-conn'], ['ssh'], ['scp', 'app:/tmp/file', './file'], ]; test('application resolver retains the existing stack argument', async () => { diff --git a/test/db-conn.test.js b/test/db-conn.test.js new file mode 100644 index 0000000..33f535d --- /dev/null +++ b/test/db-conn.test.js @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { EventEmitter, once } from 'node:events'; +import net from 'node:net'; +import test from 'node:test'; +import command, { forwardSession, parseSession } from '../dist/lib/commands/stack/db-conn.js'; + +class Session extends EventEmitter { + writes = []; + closed = 0; + responses = []; + writeRaw(data) { this.writes.push(Buffer.from(data)); this.emit('written'); } + sendHandshakeResponse(data) { this.responses.push(data); } + close() { this.closed++; this.emit('disconnect'); } +} +const handshake = { RequestedClientActions: [{ ActionType: 'SessionType', ActionParameters: { + SessionType: 'Port', Properties: { type: 'LocalPortForwarding' }, +} }] }; +async function start(t, port = 0) { + const session = new Session(); + const forwarding = forwardSession(session, port); + t.after(() => forwarding.close()); + session.emit('handshake', handshake); + const listening = once(forwarding.server, 'listening'); + session.emit('handshakeComplete', {}); + await listening; + return { session, ...forwarding }; +} +async function connect(t, server) { + const socket = net.connect(server.address().port, '127.0.0.1'); + t.after(() => socket.destroy()); + await once(socket, 'connect'); + return socket; +} + +test('validates session input without echoing credentials in errors', () => { + const valid = { id: 'session', token: 'secret', stream_url: 'wss://example.com/session' }; + assert.deepEqual(parseSession(JSON.stringify(valid)), valid); + for (const input of ['secret', 'null', '{}', JSON.stringify({ ...valid, token: '' }), + JSON.stringify({ ...valid, stream_url: 'https://example.com' })]) { + assert.throws(() => parseSession(input), error => !error.message.includes('secret')); + } +}); + +test('published SSM reports missing port support before reading stdin or opening a socket', async () => { + await assert.rejects(command.handler({ config: {}, port: 3306 }), /socket support/); +}); + +test('validates local port before consuming session input', async () => { + for (const port of [0, -1, 65536, 1.5, NaN]) { + await assert.rejects(command.handler({ config: {}, port }), /--port/); + } +}); + +test('round-trips binary payloads larger than the old 8KB buffer and buffers the greeting', async t => { + const { session, server, done } = await start(t); + const greeting = Buffer.from([0, 255, 128, 1, 0]); + session.emit('rawOutput', greeting); + const socket = await connect(t, server); + const received = []; + let bytes = 0; + const payload = Buffer.alloc(128 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i % 256; + const all = new Promise(resolve => socket.on('data', data => { + received.push(data); bytes += data.length; + if (bytes === greeting.length + payload.length) resolve(); + })); + session.emit('rawOutput', payload); + await all; + assert.deepEqual(Buffer.concat(received), Buffer.concat([greeting, payload])); + const written = once(session, 'written'); + socket.write(payload); + await written; + // TCP may split the input; wait until all chunks have reached SSM. + while (Buffer.concat(session.writes).length < payload.length) await once(session, 'written'); + assert.deepEqual(Buffer.concat(session.writes), payload); + socket.destroy(); + await done; + assert.equal(session.closed, 1); +}); + +test('rejects a second client without sharing the database stream', async t => { + const { session, server, done } = await start(t); + const first = await connect(t, server); + const second = await connect(t, server); + await once(second, 'close'); + assert.equal(first.destroyed, false); + assert.equal(session.closed, 0); + first.destroy(); + await done; +}); + +test('pauses local writes until SSM resumes', async t => { + const { session, server, done } = await start(t); + const socket = await connect(t, server); + session.emit('pause'); + socket.write('query'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(session.writes.length, 0); + const written = once(session, 'written'); + session.emit('resume'); + await written; + assert.equal(Buffer.concat(session.writes).toString(), 'query'); + socket.destroy(); + await done; +}); + +test('bind failure rejects and closes the SSM session', async t => { + const occupied = net.createServer(); + occupied.listen(0, '127.0.0.1'); + await once(occupied, 'listening'); + t.after(() => occupied.close()); + const session = new Session(); + const forwarding = forwardSession(session, occupied.address().port); + const rejected = assert.rejects(forwarding.done, { code: 'EADDRINUSE' }); + session.emit('handshake', handshake); + session.emit('handshakeComplete', {}); + await rejected; + assert.equal(session.closed, 1); +}); + +test('unsupported actions fail handshake and release process listeners', async () => { + const count = process.listenerCount('SIGINT'); + const session = new Session(); + const { done } = forwardSession(session, 0); + const rejected = assert.rejects(done, /Unsupported/); + session.emit('handshake', { RequestedClientActions: [...handshake.RequestedClientActions, { ActionType: 'KMSEncryption' }] }); + await rejected; + assert.equal(session.responses[0].ProcessedClientActions[1].ActionStatus, 3); + assert.equal(session.closed, 1); + assert.equal(process.listenerCount('SIGINT'), count); +}); + +test('stops if output exceeds buffer limit before a client connects', async () => { + const session = new Session(); + const { done } = forwardSession(session, 0); + const rejected = assert.rejects(done, /buffer limit/); + session.emit('rawOutput', Buffer.alloc(16 * 1024 * 1024 + 1)); + await rejected; + assert.equal(session.closed, 1); +}); + +test('remote disconnect closes the local listener and client', async t => { + const { session, server, done } = await start(t); + const socket = await connect(t, server); + const closed = once(socket, 'close'); + const rejected = assert.rejects(done, /lost connection/); + session.emit('disconnect', 'lost connection'); + await rejected; + await closed; + assert.equal(server.listening, false); +}); From aa70cfa22f2e604844f5d98a3f3117d7454832e8 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 15 Oct 2026 09:20:22 +0200 Subject: [PATCH 03/11] Group database connections under database connect --- README.md | 11 ++++++----- .../stack/{db-conn.ts => database/connect.ts} | 4 ++-- lib/commands/stack/database/index.ts | 13 +++++++++++++ test/commands.test.js | 2 +- test/{db-conn.test.js => database-connect.test.js} | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) rename lib/commands/stack/{db-conn.ts => database/connect.ts} (98%) create mode 100644 lib/commands/stack/database/index.ts rename test/{db-conn.test.js => database-connect.test.js} (99%) diff --git a/README.md b/README.md index bec0315..715270e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Always use `altis-cli help` for the most up-to-date list of commands. * `tasks` - List, cancel, or stream running tasks. Example: `altis-cli app tasks example-dev-01` * `tasks cancel ` - Cancel a running task. Example: `altis-cli app tasks cancel --yes` * `tasks logs ` - Stream logs for a running task. Example: `altis-cli app tasks logs ` + * `database connect` - Open an experimental database tunnel from session JSON on stdin. * `database-tables [stack]` - List database tables. Example: `altis-cli app database-tables example-dev-01` * `uploads-prefixes [stack]` - List uploads prefixes. Example: `altis-cli app uploads-prefixes example-dev-01` * `import database --from ` - Import database from another app. Example: `altis-cli app import database example-dev-01 --from example-stag-01` @@ -145,10 +146,10 @@ live in `lib/types/xray.ts`. Neither builds nor installs require a frontend chec ### Experimental database tunnel -`altis-cli app db-conn --port 3307` accepts database SSM session JSON on stdin: +`altis-cli app database connect --port 3307` accepts database SSM session JSON on stdin: ```sh -cat session.json | altis-cli app db-conn --port 3307 +cat session.json | altis-cli app database connect --port 3307 ``` The JSON must contain `id` (SSM SessionId), `stream_url` (StreamUrl), and @@ -162,7 +163,7 @@ This remains a development prototype. It requires the binary socket and handshake API from [humanmade/ssm#2](https://github.com/humanmade/ssm/pull/2), which is absent from the currently declared published dependency. The command reports that limitation before opening a connection. No stack argument is -accepted yet: [Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547) -adds an application method but does not expose a REST endpoint to create these -sessions. Automatic session creation, dependency integration, and a live +accepted yet. [Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547) +adds session creation and cleanup endpoints that still need integrating into +this command. Automatic session creation, dependency integration, and a live SSM/MySQL test are required before this command is ready to ship. diff --git a/lib/commands/stack/db-conn.ts b/lib/commands/stack/database/connect.ts similarity index 98% rename from lib/commands/stack/db-conn.ts rename to lib/commands/stack/database/connect.ts index 1f0db77..69183f0 100644 --- a/lib/commands/stack/db-conn.ts +++ b/lib/commands/stack/database/connect.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { createServer, type Socket } from 'node:net'; import { Duplex } from 'node:stream'; import pkg from '@humanmade/ssm'; -import type { CliArguments, CommandDefinition } from '../../types/cli.ts'; +import type { CliArguments, CommandDefinition } from '../../../types/cli.ts'; interface SessionData { id: string; @@ -156,7 +156,7 @@ export function forwardSession(session: PortSession, port = 3306) { } export default { - command: 'db-conn', + command: 'connect', describe: 'Experimental database tunnel using session JSON from stdin (requires SSM socket support).', builder: parser => { parser.option('port', { describe: 'Local loopback port to listen on.', type: 'number', default: 3306 }); diff --git a/lib/commands/stack/database/index.ts b/lib/commands/stack/database/index.ts new file mode 100644 index 0000000..b4b960c --- /dev/null +++ b/lib/commands/stack/database/index.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import type { CommandBuilder, CommandDefinition } from '../../../types/cli.ts'; +import buildSubcommands from '../../../buildSubcommands.ts'; + +export default { + command: 'database', + describe: 'Database commands.', + builder: async (command: CommandBuilder) => { + command.demandCommand(1); + const subcommands = await buildSubcommands(fileURLToPath(new URL('.', import.meta.url))); + command.command(subcommands); + }, +} satisfies CommandDefinition; diff --git a/test/commands.test.js b/test/commands.test.js index d80d21b..4ce8088 100644 --- a/test/commands.test.js +++ b/test/commands.test.js @@ -14,7 +14,7 @@ const commands = [ ['import', 'database', 'example-dev'], ['sync-local', 'all', 'example-dev'], ['sync-local', 'database', 'example-dev'], ['sync-local', 'uploads', 'example-dev'], ['xray', 'list-traces'], ['xray', 'trace', 'trace-id'], ['xray', 'trace-file', 'trace.json'], - ['db-conn'], ['ssh'], ['scp', 'app:/tmp/file', './file'], + ['database', 'connect'], ['ssh'], ['scp', 'app:/tmp/file', './file'], ]; test('application resolver retains the existing stack argument', async () => { diff --git a/test/db-conn.test.js b/test/database-connect.test.js similarity index 99% rename from test/db-conn.test.js rename to test/database-connect.test.js index 33f535d..7b3f9e5 100644 --- a/test/db-conn.test.js +++ b/test/database-connect.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { EventEmitter, once } from 'node:events'; import net from 'node:net'; import test from 'node:test'; -import command, { forwardSession, parseSession } from '../dist/lib/commands/stack/db-conn.js'; +import command, { forwardSession, parseSession } from '../dist/lib/commands/stack/database/connect.js'; class Session extends EventEmitter { writes = []; From 6588bea970f72c79b7e02ec8441f1b7c678bb4b1 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 15 Oct 2026 09:29:56 +0200 Subject: [PATCH 04/11] Add database tunnel, mysql CLI, and Sequel commands --- README.md | 50 +++--- lib/commands/stack/database/cli.ts | 9 + lib/commands/stack/database/sequel.ts | 9 + lib/commands/stack/database/tunnel.ts | 9 + .../connect.ts => database-tunnel.ts} | 36 +--- lib/database.ts | 165 ++++++++++++++++++ test-native/cli.test.js | 3 + test/commands.test.js | 2 +- ...onnect.test.js => database-tunnel.test.js} | 7 +- test/database.test.js | 158 +++++++++++++++++ 10 files changed, 389 insertions(+), 59 deletions(-) create mode 100644 lib/commands/stack/database/cli.ts create mode 100644 lib/commands/stack/database/sequel.ts create mode 100644 lib/commands/stack/database/tunnel.ts rename lib/{commands/stack/database/connect.ts => database-tunnel.ts} (74%) create mode 100644 lib/database.ts rename test/{database-connect.test.js => database-tunnel.test.js} (94%) create mode 100644 test/database.test.js diff --git a/README.md b/README.md index 715270e..44408a8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ Always use `altis-cli help` for the most up-to-date list of commands. * `tasks` - List, cancel, or stream running tasks. Example: `altis-cli app tasks example-dev-01` * `tasks cancel ` - Cancel a running task. Example: `altis-cli app tasks cancel --yes` * `tasks logs ` - Stream logs for a running task. Example: `altis-cli app tasks logs ` - * `database connect` - Open an experimental database tunnel from session JSON on stdin. + * `database tunnel [stack]` - Forward a local port to the primary database. + * `database cli [stack]` - Open the local `mysql` client through a tunnel. + * `database sequel [stack]` - Open Sequel Ace/Sequel Pro through a tunnel (macOS). * `database-tables [stack]` - List database tables. Example: `altis-cli app database-tables example-dev-01` * `uploads-prefixes [stack]` - List uploads prefixes. Example: `altis-cli app uploads-prefixes example-dev-01` * `import database --from ` - Import database from another app. Example: `altis-cli app import database example-dev-01 --from example-stag-01` @@ -144,26 +146,34 @@ CLI endpoint additions live in `lib/types/api.ts`; X-Ray summaries and statistic live in `lib/types/xray.ts`. Neither builds nor installs require a frontend checkout. `fetchJSON()` describes expected API responses without validating them at runtime. -### Experimental database tunnel - -`altis-cli app database connect --port 3307` accepts database SSM session JSON on stdin: +### Database connections ```sh -cat session.json | altis-cli app database connect --port 3307 +altis-cli app database tunnel example-dev-01 --port 3307 +altis-cli app database cli example-dev-01 --port 3307 +altis-cli app database sequel example-dev-01 --port 3307 ``` -The JSON must contain `id` (SSM SessionId), `stream_url` (StreamUrl), and -`token` (TokenValue). Treat this file as a credential. The session must use -`AWS-StartPortForwardingSessionToRemoteHost`, targeting the sandbox instance -and the desired database host on port 3306. The local listener binds to -`127.0.0.1` and supports one database client connection; create a new session -after that client disconnects. Ctrl-C closes the tunnel. - -This remains a development prototype. It requires the binary socket and -handshake API from [humanmade/ssm#2](https://github.com/humanmade/ssm/pull/2), -which is absent from the currently declared published dependency. The command -reports that limitation before opening a connection. No stack argument is -accepted yet. [Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547) -adds session creation and cleanup endpoints that still need integrating into -this command. Automatic session creation, dependency integration, and a live -SSM/MySQL test are required before this command is ready to ship. +All three commands create an SSM tunnel to the application's primary database, +listening on `127.0.0.1`. Omit the stack argument to select an application +interactively. `--port` defaults to 3306. Closing the client or pressing Ctrl-C +closes the tunnel and requests remote session termination from Vantage. + +`tunnel` keeps the connection open for your own database client and credentials. +`cli` launches the local `mysql` executable with the terminal attached. +`sequel` opens Sequel Ace or Sequel Pro on macOS, matching local-server's +`composer server db sequel` naming. Keep the terminal running while the GUI is +connected. The current transport supports one client connection per session. + +`cli` and `sequel` obtain credentials from the existing database-server endpoint; +they require an account allowed to retrieve those credentials. Passwords are +written to private temporary files, removed when the command ends, and excluded +from process arguments and environment variables. + +These commands require the creation and cleanup endpoints in +[Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547), plus the +binary socket and handshake API from +[humanmade/ssm#2](https://github.com/humanmade/ssm/pull/2). Published SSM 0.0.3 +currently lacks that API; the commands report this before creating a remote +session. Dependency integration and a real SSM/MySQL test remain necessary +before production use. diff --git a/lib/commands/stack/database/cli.ts b/lib/commands/stack/database/cli.ts new file mode 100644 index 0000000..85ba1f3 --- /dev/null +++ b/lib/commands/stack/database/cli.ts @@ -0,0 +1,9 @@ +import type { CommandDefinition } from '../../../types/cli.ts'; +import { databaseOptions, runDatabase } from '../../../database.ts'; + +export default { + command: 'cli [stack]', + describe: 'Open the mysql command-line client through a database tunnel.', + builder: databaseOptions, + handler: argv => runDatabase(argv, 'cli'), +} satisfies CommandDefinition; diff --git a/lib/commands/stack/database/sequel.ts b/lib/commands/stack/database/sequel.ts new file mode 100644 index 0000000..3cd1ef6 --- /dev/null +++ b/lib/commands/stack/database/sequel.ts @@ -0,0 +1,9 @@ +import type { CommandDefinition } from '../../../types/cli.ts'; +import { databaseOptions, runDatabase } from '../../../database.ts'; + +export default { + command: 'sequel [stack]', + describe: 'Open Sequel Ace or Sequel Pro through a database tunnel (macOS).', + builder: databaseOptions, + handler: argv => runDatabase(argv, 'sequel'), +} satisfies CommandDefinition; diff --git a/lib/commands/stack/database/tunnel.ts b/lib/commands/stack/database/tunnel.ts new file mode 100644 index 0000000..bd2bea1 --- /dev/null +++ b/lib/commands/stack/database/tunnel.ts @@ -0,0 +1,9 @@ +import type { CommandDefinition } from '../../../types/cli.ts'; +import { databaseOptions, runDatabase } from '../../../database.ts'; + +export default { + command: 'tunnel [stack]', + describe: 'Forward a local port to the application’s primary database.', + builder: databaseOptions, + handler: argv => runDatabase(argv, 'tunnel'), +} satisfies CommandDefinition; diff --git a/lib/commands/stack/database/connect.ts b/lib/database-tunnel.ts similarity index 74% rename from lib/commands/stack/database/connect.ts rename to lib/database-tunnel.ts index 69183f0..0dbefa0 100644 --- a/lib/commands/stack/database/connect.ts +++ b/lib/database-tunnel.ts @@ -1,8 +1,5 @@ -import { readFileSync } from 'node:fs'; import { createServer, type Socket } from 'node:net'; import { Duplex } from 'node:stream'; -import pkg from '@humanmade/ssm'; -import type { CliArguments, CommandDefinition } from '../../../types/cli.ts'; interface SessionData { id: string; @@ -28,7 +25,7 @@ export function parseSession(raw: string): SessionData { try { data = JSON.parse(raw); } catch { - throw new Error('Expected session JSON on stdin (id, stream_url, token).'); + throw new Error('Expected database session JSON (id, stream_url, token).'); } if (!data || typeof data !== 'object' || !['id', 'stream_url', 'token'].every( key => typeof (data as Record)[key] === 'string' && (data as Record)[key].length > 0 @@ -154,34 +151,3 @@ export function forwardSession(session: PortSession, port = 3306) { // Do not send application-level keepalive data into the database stream. return { server, done, close: () => stop() }; } - -export default { - command: 'connect', - describe: 'Experimental database tunnel using session JSON from stdin (requires SSM socket support).', - builder: parser => { - parser.option('port', { describe: 'Local loopback port to listen on.', type: 'number', default: 3306 }); - }, - handler: async (argv: CliArguments) => { - const port = argv.port ?? 3306; - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error('--port must be an integer between 1 and 65535.'); - } - if (process.stdin.isTTY) { - throw new Error('Pipe database SSM session JSON into this command. Vantage session creation is not implemented yet.'); - } - // Check the prototype before opening a WebSocket. Published SSM releases - // currently support terminal sessions only; see humanmade/ssm#2. - const Session = pkg.default; - const prototype = Session.prototype as unknown as Partial; - if (typeof prototype.writeRaw !== 'function' || typeof prototype.sendHandshakeResponse !== 'function') { - throw new Error('Database tunnels require @humanmade/ssm socket support (humanmade/ssm#2); the installed version does not support it.'); - } - const data = parseSession(readFileSync(0, 'utf8')); - const session = new Session(data.stream_url, data.id, data.token) as unknown as PortSession; - const forwarding = forwardSession(session, port); - forwarding.server.on('listening', () => { - console.log(`Database tunnel listening on 127.0.0.1:${port}. One client connection per session; Ctrl-C to close.`); - }); - await forwarding.done; - }, -} satisfies CommandDefinition; diff --git a/lib/database.ts b/lib/database.ts new file mode 100644 index 0000000..c2c6249 --- /dev/null +++ b/lib/database.ts @@ -0,0 +1,165 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import open from 'open'; +import pkg from '@humanmade/ssm'; +import { forwardSession, parseSession } from './database-tunnel.ts'; +import type { PortSession } from './database-tunnel.ts'; +import type { CliArguments, CommandBuilder } from './types/cli.ts'; +import type { DatabaseServerResponse } from './types/rest-api.ts'; +import { fetchJSON, getStack } from './commands/stack/util.ts'; +import Vantage from './vantage.ts'; + +interface DatabaseSession { + id: number; + aws_ssm_session_id: string; + stream_url: string; + token: string; +} +type DatabaseCredentials = Pick; +type SessionFactory = (url: string, id: string, token: string) => PortSession; + +function sessionFactory(): SessionFactory { + const Session = pkg.default; + const prototype = Session.prototype as unknown as Partial; + if (typeof prototype.writeRaw !== 'function' || typeof prototype.sendHandshakeResponse !== 'function') { + throw new Error('Database tunnels require @humanmade/ssm socket support (humanmade/ssm#2); the installed version does not support it.'); + } + return (url, id, token) => new Session(url, id, token) as unknown as PortSession; +} + +export function databaseOptions(parser: CommandBuilder) { + return parser.option('port', { describe: 'Local loopback port to listen on.', type: 'number', default: 3306 }); +} + +/** Create one tunnel and terminate the owned AWS session on every exit path. */ +export async function createDatabaseTunnel(api: Vantage, stack: string, port: number, createSession = sessionFactory()) { + const url = `stack/applications/${encodeURIComponent(stack)}/database/sessions`; + const record = await fetchJSON(api, url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ local_port: port }), + }); + if (!record || !Number.isInteger(record.id) || record.id < 1) { + throw new Error('Invalid database session response: missing Vantage session ID.'); + } + const cleanup = () => fetchJSON(api, `${url}/${record.id}`, { method: 'DELETE' }); + let forwarding: ReturnType; + try { + const data = parseSession(JSON.stringify({ id: record.aws_ssm_session_id, stream_url: record.stream_url, token: record.token })); + forwarding = forwardSession(createSession(data.stream_url, data.id, data.token), port); + } catch (error) { + await cleanup(); + throw error; + } + const done = forwarding.done.finally(cleanup); + try { + await Promise.race([ + once(forwarding.server, 'listening'), + done.then(() => { throw new Error('Database tunnel closed before it started listening.'); }), + ]); + } catch (error) { + forwarding.close(); + await done.catch(() => {}); + throw error; + } + return { port, done, close: () => { forwarding.close(); return done; } }; +} + +function mysqlQuote(value: string) { + return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"'; +} + +export function mysqlConfig(credentials: DatabaseCredentials, port: number) { + const options = { host: '127.0.0.1', port: String(port), protocol: 'TCP', + user: credentials.username, password: credentials.password, database: credentials.database }; + return '[client]\n' + Object.entries(options).map(([key, value]) => `${key}=${mysqlQuote(value)}`).join('\n') + '\n'; +} + +function xml(value: string) { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +export function sequelConfig(credentials: DatabaseCredentials, port: number, stack: string) { + const fields = { name: stack, host: '127.0.0.1', user: credentials.username, + password: credentials.password, database: credentials.database, rdbms_type: 'mysql', type: 'StandardConnection' }; + return ` + + +auto_connect +ContentFilters +dataconnection +${Object.entries(fields).map(([key, value]) => `${key}${xml(value)}`).join('\n')} +port${port} +session +queryFavoritesqueryHistory +rdbms_typemysql +encryptedformatconnection +version1 +\n`; +} + +/** Keep credentials out of process arguments, environment variables, and logs. */ +export async function withConnectionFile(filename: string, content: string, run: (file: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), 'altis-database-')); + try { + const file = path.join(directory, filename); + await writeFile(file, content, { mode: 0o600 }); + return await run(file); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +export async function launchMysql(file: string, tunnel: { port: number; done: Promise; close(): Promise }, launch = spawn) { + const child = launch('mysql', [`--defaults-file=${file}`, '--protocol=TCP', '--host=127.0.0.1', `--port=${tunnel.port}`], { stdio: 'inherit' }); + try { + const exited = new Promise((resolve, reject) => { + child.once('error', (error: NodeJS.ErrnoException) => reject(new Error(error.code === 'ENOENT' + ? 'The mysql client is not installed or is not on PATH.' : 'Could not start the mysql client.'))); + child.once('exit', (code, signal) => code === 0 || signal === 'SIGINT' ? resolve() + : reject(new Error(`mysql exited ${signal ? `with signal ${signal}` : `with status ${code}`}.`))); + }); + await Promise.race([exited, tunnel.done.then(() => exited)]); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill(); + await tunnel.close(); + } +} + +export async function runDatabase(argv: CliArguments, mode: 'tunnel' | 'cli' | 'sequel') { + const port = argv.port ?? 3306; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535.'); + } + if (mode === 'sequel' && process.platform !== 'darwin') { + throw new Error('database sequel is supported on macOS. Use database tunnel with your preferred database client.'); + } + const createSession = sessionFactory(); + const stack = await getStack(argv); + const api = new Vantage(argv.config); + let credentials: DatabaseCredentials | undefined; + if (mode !== 'tunnel') { + credentials = await fetchJSON(api, `stack/applications/${encodeURIComponent(stack)}/database-server`); + if (!credentials || typeof credentials.username !== 'string' || !credentials.username + || typeof credentials.database !== 'string' || typeof credentials.password !== 'string' || !credentials.password) { + throw new Error('Database credentials are not available to this account. Use database tunnel with your own credentials.'); + } + } + const tunnel = await createDatabaseTunnel(api, stack, port, createSession); + console.log(`Database tunnel for ${stack} listening on 127.0.0.1:${port}. Ctrl-C to close.`); + try { + if (mode === 'cli') { + await withConnectionFile('client.cnf', mysqlConfig(credentials!, port), file => launchMysql(file, tunnel)); + } else if (mode === 'sequel') { + await withConnectionFile('connection.spf', sequelConfig(credentials!, port, stack), async file => { + await open(file, { wait: false }); + await tunnel.done; + }); + } else { + await tunnel.done; + } + } finally { + await tunnel.close(); + } +} diff --git a/test-native/cli.test.js b/test-native/cli.test.js index f0ffdad..3af1c4e 100644 --- a/test-native/cli.test.js +++ b/test-native/cli.test.js @@ -37,6 +37,9 @@ test('native source executable reads package metadata without a build', () => { test('native command discovery imports existing commands and nested namespaces', () => { assert.match(run(['--help']), /Commands:/); for (const scope of ['app', 'stack']) { + for (const action of ['tunnel', 'cli', 'sequel']) { + assert.match(run([scope, 'database', action, '--help']), new RegExp(`database ${action}`)); + } assert.match(run([scope, 'variables', '--help']), /variables \[stack\]/); assert.match(run([scope, 'sync-local', 'database', 'example-dev', '--help']), /database/); assert.match(run([scope, 'xray', 'list-traces', '--help']), /list-traces/); diff --git a/test/commands.test.js b/test/commands.test.js index 4ce8088..3ea1674 100644 --- a/test/commands.test.js +++ b/test/commands.test.js @@ -14,7 +14,7 @@ const commands = [ ['import', 'database', 'example-dev'], ['sync-local', 'all', 'example-dev'], ['sync-local', 'database', 'example-dev'], ['sync-local', 'uploads', 'example-dev'], ['xray', 'list-traces'], ['xray', 'trace', 'trace-id'], ['xray', 'trace-file', 'trace.json'], - ['database', 'connect'], ['ssh'], ['scp', 'app:/tmp/file', './file'], + ['database', 'tunnel'], ['database', 'cli'], ['database', 'sequel'], ['ssh'], ['scp', 'app:/tmp/file', './file'], ]; test('application resolver retains the existing stack argument', async () => { diff --git a/test/database-connect.test.js b/test/database-tunnel.test.js similarity index 94% rename from test/database-connect.test.js rename to test/database-tunnel.test.js index 7b3f9e5..41fc3eb 100644 --- a/test/database-connect.test.js +++ b/test/database-tunnel.test.js @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import { EventEmitter, once } from 'node:events'; import net from 'node:net'; import test from 'node:test'; -import command, { forwardSession, parseSession } from '../dist/lib/commands/stack/database/connect.js'; +import { forwardSession, parseSession } from '../dist/lib/database-tunnel.js'; +import command from '../dist/lib/commands/stack/database/tunnel.js'; class Session extends EventEmitter { writes = []; @@ -41,11 +42,11 @@ test('validates session input without echoing credentials in errors', () => { } }); -test('published SSM reports missing port support before reading stdin or opening a socket', async () => { +test('published SSM reports missing port support before requesting a session or opening a socket', async () => { await assert.rejects(command.handler({ config: {}, port: 3306 }), /socket support/); }); -test('validates local port before consuming session input', async () => { +test('validates local port before requesting a session', async () => { for (const port of [0, -1, 65536, 1.5, NaN]) { await assert.rejects(command.handler({ config: {}, port }), /--port/); } diff --git a/test/database.test.js b/test/database.test.js new file mode 100644 index 0000000..56c338c --- /dev/null +++ b/test/database.test.js @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { EventEmitter, once } from 'node:events'; +import { access, readFile, stat } from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import test from 'node:test'; +import { Response } from 'node-fetch'; +import { createDatabaseTunnel, launchMysql, mysqlConfig, sequelConfig, withConnectionFile } from '../dist/lib/database.js'; + +const handshake = { RequestedClientActions: [{ ActionType: 'SessionType', ActionParameters: { + SessionType: 'Port', Properties: { type: 'LocalPortForwarding' }, +} }] }; +class Session extends EventEmitter { + closed = 0; + writeRaw() {} + sendHandshakeResponse() {} + close() { this.closed++; this.emit('disconnect'); } +} +function apiFixture(record = { id: 123, aws_ssm_session_id: 'aws-session', stream_url: 'wss://example.test', token: 'secret' }) { + const calls = []; + return { + calls, + fetch: async (url, options) => { + calls.push({ url, options }); + return options.method === 'DELETE' ? new Response(null, { status: 204 }) : new Response(JSON.stringify(record)); + }, + }; +} +function sessionFactory() { + const session = new Session(); + queueMicrotask(() => { + session.emit('handshake', handshake); + session.emit('handshakeComplete'); + }); + return session; +} +async function unusedPort() { + const server = net.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +test('Vantage tunnel uses the AWS ID for transport and Vantage ID for cleanup exactly once', async () => { + const api = apiFixture(); + const port = await unusedPort(); + const tunnel = await createDatabaseTunnel(api, 'example-app', port, (url, id, token) => { + assert.equal(url, 'wss://example.test'); + assert.equal(id, 'aws-session'); + assert.equal(token, 'secret'); + return sessionFactory(); + }); + assert.deepEqual(JSON.parse(api.calls[0].options.body), { local_port: port }); + assert.equal(api.calls[0].url, 'stack/applications/example-app/database/sessions'); + await tunnel.close(); + await tunnel.close(); + assert.equal(api.calls.length, 2); + assert.equal(api.calls[1].options.method, 'DELETE'); + assert.equal(api.calls[1].url, 'stack/applications/example-app/database/sessions/123'); +}); + +test('invalid connection data triggers remote cleanup without exposing the token', async () => { + const api = apiFixture({ id: 123, aws_ssm_session_id: 'aws-session', stream_url: 'https://invalid.test', token: 'secret' }); + await assert.rejects(createDatabaseTunnel(api, 'example-app', 3306, sessionFactory), error => !error.message.includes('secret')); + assert.equal(api.calls.at(-1).options.method, 'DELETE'); +}); + +test('a session constructor failure triggers remote cleanup', async () => { + const api = apiFixture(); + await assert.rejects(createDatabaseTunnel(api, 'example-app', 3306, () => { throw new Error('failed'); }), /failed/); + assert.equal(api.calls.at(-1).options.method, 'DELETE'); +}); + +test('bind failure terminates the remote session', async t => { + const occupied = net.createServer(); + occupied.listen(0, '127.0.0.1'); + await once(occupied, 'listening'); + t.after(() => occupied.close()); + const api = apiFixture(); + await assert.rejects(createDatabaseTunnel(api, 'example-app', occupied.address().port, sessionFactory), { code: 'EADDRINUSE' }); + assert.equal(api.calls.filter(call => call.options.method === 'DELETE').length, 1); +}); + +test('disconnect before handshake fails promptly and terminates the remote session', async () => { + const api = apiFixture(); + await assert.rejects(createDatabaseTunnel(api, 'example-app', 3306, () => { + const session = new Session(); + queueMicrotask(() => session.emit('disconnect', 'Connection lost')); + return session; + }), /Connection lost/); + assert.equal(api.calls.at(-1).options.method, 'DELETE'); +}); + +test('connection files are private and removed after success and failure', async () => { + for (const fail of [false, true]) { + let filename; + const operation = withConnectionFile('client.cnf', 'password=secret', async file => { + filename = file; + assert.equal((await stat(file)).mode & 0o777, 0o600); + assert.equal((await stat(path.dirname(file))).mode & 0o777, 0o700); + assert.equal(await readFile(file, 'utf8'), 'password=secret'); + if (fail) throw new Error('launch failed'); + }); + if (fail) await assert.rejects(operation, /launch failed/); + else await operation; + await assert.rejects(access(filename)); + await assert.rejects(access(path.dirname(filename))); + } +}); + +test('mysql options quote credentials and force TCP to the loopback tunnel', () => { + const config = mysqlConfig({ username: 'admin', password: 'a"\\b\n!include /tmp/other', database: 'wordpress' }, 3307); + assert.match(config, /host="127\.0\.0\.1"/); + assert.match(config, /port="3307"/); + assert.match(config, /protocol="TCP"/); + assert.ok(config.includes('password="a\\"\\\\b\\n!include /tmp/other"')); + assert.ok(!config.includes('\n!include')); +}); + +test('Sequel connection uses loopback with XML-escaped credentials and no SSH settings', () => { + const config = sequelConfig({ username: 'a&b', password: '"', database: 'wordpress' }, 3307, 'example-prod'); + assert.ok(config.includes('host127.0.0.1')); + assert.ok(config.includes('port3307')); + assert.ok(config.includes('a&b')); + assert.ok(config.includes('<secret>"')); + assert.ok(!config.includes('ssh_host')); +}); + +test('mysql launch inherits the terminal and never passes credentials in argv or environment', async () => { + const calls = []; + let closed = 0; + await launchMysql('/tmp/private/client.cnf', { port: 3307, done: new Promise(() => {}), close: async () => { closed++; } }, (...args) => { + calls.push(args); + const child = new EventEmitter(); + child.exitCode = null; child.signalCode = null; + child.kill = () => assert.fail('a finished client should not be killed'); + queueMicrotask(() => { child.exitCode = 0; child.emit('exit', 0, null); }); + return child; + }); + assert.deepEqual(calls, [['mysql', ['--defaults-file=/tmp/private/client.cnf', '--protocol=TCP', '--host=127.0.0.1', '--port=3307'], { stdio: 'inherit' }]]); + assert.equal(closed, 1); +}); + +test('missing mysql client reports an actionable error and closes the tunnel', async () => { + let closed = 0; + await assert.rejects(launchMysql('/tmp/private/client.cnf', { + port: 3307, done: new Promise(() => {}), close: async () => { closed++; }, + }, () => { + const child = new EventEmitter(); + child.exitCode = null; child.signalCode = null; + child.kill = () => {}; + queueMicrotask(() => child.emit('error', Object.assign(new Error(), { code: 'ENOENT' }))); + return child; + }), /mysql client is not installed/); + assert.equal(closed, 1); +}); From 2821fd58bd7bedbe973e5474b44a3753459a4365 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 15 Oct 2026 12:19:34 +0200 Subject: [PATCH 05/11] Preserve UTF-8 input in interactive SSM sessions --- lib/commands/stack/ssh.ts | 7 ++++--- test/ssh.test.js | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/commands/stack/ssh.ts b/lib/commands/stack/ssh.ts index 4ad57b9..58674db 100644 --- a/lib/commands/stack/ssh.ts +++ b/lib/commands/stack/ssh.ts @@ -5,6 +5,7 @@ import pkg from '@humanmade/ssm'; const AWSSSMSession = pkg.default; import { getStack } from './util.ts'; import Vantage from '../../vantage.ts'; +import { StringDecoder } from 'node:string_decoder'; // Keep the socket alive by pinging every 30s. const IDLE_INTERVAL = 30_000; @@ -15,10 +16,12 @@ export function attachSessionInput( session: Pick { let msg = ''; - for ( const byte of data.values() ) { + for ( const char of decoder.write( data ) ) { + const byte = char.codePointAt( 0 ); // ~ byte, indicates start of control code. if ( lastWasNewline && byte === 0x7E && !startedSshControl ) { startedSshControl = true; @@ -27,8 +30,6 @@ export function attachSessionInput( session: Pick { assert.equal(closed, true); }); +test('SSH input preserves UTF-8 characters split across terminal chunks', async () => { + const input = new PassThrough(); + const writes = []; + const detach = attachSessionInput({ write: value => writes.push(value), close() {} }, input, new PassThrough()); + const text = 'café 日本語 🐙\r'; + for (const byte of Buffer.from(text)) input.write(Buffer.from([byte])); + await new Promise(resolve => setImmediate(resolve)); + detach(); + assert.equal(writes.join(''), text); +}); + test('SSH keepalive pings the active session', () => { let callback; let pings = 0; From 135d3d17dd08436b247edab14b04afaf4920fbf2 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 15 Oct 2026 12:30:25 +0200 Subject: [PATCH 06/11] Exit live-test shells before closing SSM sockets --- scripts/live-ssm.js | 11 ++++++++--- test/live-ssm.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 test/live-ssm.test.js diff --git a/scripts/live-ssm.js b/scripts/live-ssm.js index d50b35d..13577f1 100644 --- a/scripts/live-ssm.js +++ b/scripts/live-ssm.js @@ -94,9 +94,14 @@ export class LiveSession { async close() { if (this.disconnected) return; - this.session.close(); - try { await this.waitClosed(); } - finally { if (!this.disconnected) this.session.webSocket?.terminate?.(); } + try { + // Closing the WebSocket can leave docker exec's shell running. + this.session.write('exit\n'); + await this.waitClosed(); + } finally { + this.session.close(); + if (!this.disconnected) this.session.webSocket?.terminate?.(); + } } } diff --git a/test/live-ssm.test.js b/test/live-ssm.test.js new file mode 100644 index 0000000..6ef262a --- /dev/null +++ b/test/live-ssm.test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; +import { LiveSession } from '../scripts/live-ssm.js'; + +test('live runner waits for remote shell exit before closing its socket', async () => { + const session = new EventEmitter(); + const events = []; + session.write = command => { + events.push(command); + queueMicrotask(() => { + events.push('remote exit'); + session.emit('disconnect'); + }); + }; + session.close = () => events.push('socket close'); + const client = new LiveSession(session, 1000); + await client.close(); + await client.close(); + assert.deepEqual(events, ['exit\n', 'remote exit', 'socket close']); +}); + +test('live runner releases the socket when it cannot send remote exit', async () => { + const session = new EventEmitter(); + const events = []; + session.write = () => { throw new Error('socket unavailable'); }; + session.close = () => events.push('close'); + session.webSocket = { terminate: () => events.push('terminate') }; + const client = new LiveSession(session, 1000); + await assert.rejects(client.close(), /socket unavailable/); + assert.deepEqual(events, ['close', 'terminate']); +}); From 073d1d031c0f99d77a90c36c1f0f02ead241f4fe Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 16 Oct 2026 09:09:11 +0200 Subject: [PATCH 07/11] Use published SSM 0.0.4 socket support --- README.md | 9 ++++----- lib/database.ts | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- test/database-tunnel.test.js | 17 +++++++++++++++-- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 44408a8..2c125d4 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,7 @@ from process arguments and environment variables. These commands require the creation and cleanup endpoints in [Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547), plus the -binary socket and handshake API from -[humanmade/ssm#2](https://github.com/humanmade/ssm/pull/2). Published SSM 0.0.3 -currently lacks that API; the commands report this before creating a remote -session. Dependency integration and a real SSM/MySQL test remain necessary -before production use. +binary socket and handshake API in `@humanmade/ssm` 0.0.4, included as a +dependency. The transport has passed live SSM/MySQL tests with TLS, large +results, and binary data. The deployed Vantage endpoints, complete database +commands, and Sequel GUI still need end-to-end validation before production use. diff --git a/lib/database.ts b/lib/database.ts index c2c6249..7165350 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -25,7 +25,7 @@ function sessionFactory(): SessionFactory { const Session = pkg.default; const prototype = Session.prototype as unknown as Partial; if (typeof prototype.writeRaw !== 'function' || typeof prototype.sendHandshakeResponse !== 'function') { - throw new Error('Database tunnels require @humanmade/ssm socket support (humanmade/ssm#2); the installed version does not support it.'); + throw new Error('Database tunnels require the socket support in @humanmade/ssm 0.0.4 or newer; the installed version does not support it.'); } return (url, id, token) => new Session(url, id, token) as unknown as PortSession; } diff --git a/package-lock.json b/package-lock.json index 3c90b19..bc667b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@automattic/vip-search-replace": "^2.0.0", - "@humanmade/ssm": "^0.0.3", + "@humanmade/ssm": "^0.0.4", "ansi-escapes": "^2.0.0", "application-config": "^1.0.1", "base64-stream": "^1.0.0", @@ -78,14 +78,14 @@ } }, "node_modules/@humanmade/ssm": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@humanmade/ssm/-/ssm-0.0.3.tgz", - "integrity": "sha512-3APnrGDZ/NnrxJSJIEnyKmQ4iq3xHxxwb5OoCKQAqNrrtTaJj/LPoxQP6a2EDAI48EO2czIIgiKIGGRMcsUn9w==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@humanmade/ssm/-/ssm-0.0.4.tgz", + "integrity": "sha512-7IlBz+NcAYNWyTNwCson3CzaOGLKj+uM+oURCZOAD3Ne/G82K6M5ukEZY78GXlWZhkq0v2HsMrm2iJjekhAnbg==", "license": "Apache-2.0", "dependencies": { "isomorphic-ws": "^4.0.1", "js-sha256": "^0.9.0", - "uuid": "^8.3.2", + "uuid": "^11.1.1", "ws": "^7.4.5" } }, diff --git a/package.json b/package.json index f512915..5031b32 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@automattic/vip-search-replace": "^2.0.0", - "@humanmade/ssm": "^0.0.3", + "@humanmade/ssm": "^0.0.4", "ansi-escapes": "^2.0.0", "application-config": "^1.0.1", "base64-stream": "^1.0.0", diff --git a/test/database-tunnel.test.js b/test/database-tunnel.test.js index 41fc3eb..9500870 100644 --- a/test/database-tunnel.test.js +++ b/test/database-tunnel.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { EventEmitter, once } from 'node:events'; import net from 'node:net'; import test from 'node:test'; +import pkg from '@humanmade/ssm'; import { forwardSession, parseSession } from '../dist/lib/database-tunnel.js'; import command from '../dist/lib/commands/stack/database/tunnel.js'; @@ -42,8 +43,20 @@ test('validates session input without echoing credentials in errors', () => { } }); -test('published SSM reports missing port support before requesting a session or opening a socket', async () => { - await assert.rejects(command.handler({ config: {}, port: 3306 }), /socket support/); +test('installed SSM provides the binary socket and handshake APIs', () => { + assert.equal(typeof pkg.default.prototype.writeRaw, 'function'); + assert.equal(typeof pkg.default.prototype.sendHandshakeResponse, 'function'); +}); + +test('an older SSM reports missing port support before requesting a session or opening a socket', async () => { + const prototype = pkg.default.prototype; + const original = Object.getOwnPropertyDescriptor(prototype, 'writeRaw'); + try { + delete prototype.writeRaw; + await assert.rejects(command.handler({ config: {}, port: 3306 }), /socket support/); + } finally { + Object.defineProperty(prototype, 'writeRaw', original); + } }); test('validates local port before requesting a session', async () => { From abeb96c4117fb7a559360771ab3fe5c27a67f285 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 16 Oct 2026 09:11:28 +0200 Subject: [PATCH 08/11] Remove database README additions --- README.md | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/README.md b/README.md index 2c125d4..01812c3 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,6 @@ Always use `altis-cli help` for the most up-to-date list of commands. * `tasks` - List, cancel, or stream running tasks. Example: `altis-cli app tasks example-dev-01` * `tasks cancel ` - Cancel a running task. Example: `altis-cli app tasks cancel --yes` * `tasks logs ` - Stream logs for a running task. Example: `altis-cli app tasks logs ` - * `database tunnel [stack]` - Forward a local port to the primary database. - * `database cli [stack]` - Open the local `mysql` client through a tunnel. - * `database sequel [stack]` - Open Sequel Ace/Sequel Pro through a tunnel (macOS). * `database-tables [stack]` - List database tables. Example: `altis-cli app database-tables example-dev-01` * `uploads-prefixes [stack]` - List uploads prefixes. Example: `altis-cli app uploads-prefixes example-dev-01` * `import database --from ` - Import database from another app. Example: `altis-cli app import database example-dev-01 --from example-stag-01` @@ -145,34 +142,3 @@ npm test CLI endpoint additions live in `lib/types/api.ts`; X-Ray summaries and statistics live in `lib/types/xray.ts`. Neither builds nor installs require a frontend checkout. `fetchJSON()` describes expected API responses without validating them at runtime. - -### Database connections - -```sh -altis-cli app database tunnel example-dev-01 --port 3307 -altis-cli app database cli example-dev-01 --port 3307 -altis-cli app database sequel example-dev-01 --port 3307 -``` - -All three commands create an SSM tunnel to the application's primary database, -listening on `127.0.0.1`. Omit the stack argument to select an application -interactively. `--port` defaults to 3306. Closing the client or pressing Ctrl-C -closes the tunnel and requests remote session termination from Vantage. - -`tunnel` keeps the connection open for your own database client and credentials. -`cli` launches the local `mysql` executable with the terminal attached. -`sequel` opens Sequel Ace or Sequel Pro on macOS, matching local-server's -`composer server db sequel` naming. Keep the terminal running while the GUI is -connected. The current transport supports one client connection per session. - -`cli` and `sequel` obtain credentials from the existing database-server endpoint; -they require an account allowed to retrieve those credentials. Passwords are -written to private temporary files, removed when the command ends, and excluded -from process arguments and environment variables. - -These commands require the creation and cleanup endpoints in -[Vantage #1547](https://github.com/humanmade/vantage-backend/pull/1547), plus the -binary socket and handshake API in `@humanmade/ssm` 0.0.4, included as a -dependency. The transport has passed live SSM/MySQL tests with TLS, large -results, and binary data. The deployed Vantage endpoints, complete database -commands, and Sequel GUI still need end-to-end validation before production use. From 10cf4b7b5b337b45cd71330edf9b0a746514958d Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 16 Oct 2026 09:12:35 +0200 Subject: [PATCH 09/11] Restore database command reference entries --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 01812c3..65b0e1e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Always use `altis-cli help` for the most up-to-date list of commands. * `tasks` - List, cancel, or stream running tasks. Example: `altis-cli app tasks example-dev-01` * `tasks cancel ` - Cancel a running task. Example: `altis-cli app tasks cancel --yes` * `tasks logs ` - Stream logs for a running task. Example: `altis-cli app tasks logs ` + * `database tunnel [stack]` - Forward a local port to the primary database. + * `database cli [stack]` - Open the local `mysql` client through a tunnel. + * `database sequel [stack]` - Open Sequel Ace/Sequel Pro through a tunnel (macOS). * `database-tables [stack]` - List database tables. Example: `altis-cli app database-tables example-dev-01` * `uploads-prefixes [stack]` - List uploads prefixes. Example: `altis-cli app uploads-prefixes example-dev-01` * `import database --from ` - Import database from another app. Example: `altis-cli app import database example-dev-01 --from example-stag-01` From 270cb7fa1493ddd92d0956a2eabc2fde3ef63fdc Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 16 Oct 2026 09:37:16 +0200 Subject: [PATCH 10/11] Use configured Dashboard for OAuth and reject failed token exchanges --- lib/authorize.ts | 40 +++++++++++------- lib/vantage.ts | 6 +-- test/authorize.test.js | 93 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 test/authorize.test.js diff --git a/lib/authorize.ts b/lib/authorize.ts index 2554652..92e97e3 100644 --- a/lib/authorize.ts +++ b/lib/authorize.ts @@ -12,7 +12,7 @@ interface AuthorizationOptions { port: number; render: (title: string, message?: string) => string; } -class Authenticator { +export class Authenticator { options: AuthorizationOptions; server: http.Server; state: string; @@ -33,7 +33,7 @@ class Authenticator { }; const url = `${ this.options.endpoints.authorize }&${ qs.stringify( params ) }`; - open( url ); + return open( url ); } exchangeCode( code: string ) { @@ -45,6 +45,7 @@ class Authenticator { client_secret: this.options.secret, }; const opts = { + signal: AbortSignal.timeout( 30_000 ), method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -52,14 +53,19 @@ class Authenticator { body: qs.stringify( params ), }; return fetch( this.options.endpoints.exchange, opts ) - .then( resp => resp.json() ) + .then( async resp => { + if ( ! resp.ok ) throw new Error( `OAuth token exchange failed (HTTP ${ resp.status }).` ); + try { return await resp.json(); } + catch { throw new Error( 'OAuth token exchange returned invalid JSON.' ); } + } ) .then( data => { - const token = (data as { access_token: string }).access_token; + const token = data && typeof data === 'object' && 'access_token' in data ? data.access_token : undefined; + if ( typeof token !== 'string' || ! token.trim() ) throw new Error( 'OAuth token exchange did not return an access token.' ); return token; } ); } - handleRequest( req: http.IncomingMessage, res: http.ServerResponse ) { + async handleRequest( req: http.IncomingMessage, res: http.ServerResponse ) { const params = new URL( req.url || '/', `http://localhost:${ this.options.port }` ).searchParams; const states = params.getAll( 'state' ); const code = params.getAll( 'code' ).join( ',' ); @@ -69,18 +75,24 @@ class Authenticator { if ( states.length !== 1 || states[0] !== this.state ) { res.writeHead( 400 ); res.end( this.options.render( 'Invalid state parameter.' ) ); - return Promise.resolve( null ); + return null; } if ( ! code ) { res.writeHead( 400 ); res.end( this.options.render( 'Missing code parameter.' ) ); - return Promise.resolve( null ); + return null; } - res.writeHead( 200 ); - res.end( this.options.render( 'Authenticated altis-cli!', 'You can now close this window and go back to your terminal.' ) ); - - return this.exchangeCode( code ); + try { + const token = await this.exchangeCode( code ); + res.writeHead( 200 ); + res.end( this.options.render( 'Authenticated altis-cli!', 'You can now close this window and go back to your terminal.' ) ); + return token; + } catch ( error ) { + res.writeHead( 502 ); + res.end( this.options.render( 'Authentication failed.', 'Token exchange failed. See your terminal for details.' ) ); + throw error; + } } start() { @@ -100,12 +112,12 @@ class Authenticator { reject( new Error( `Port ${ this.options.port } is already in use.` ) ); return; } + reject( err ); } ); - this.server.listen( this.options.port, () => this.startAuthorization() ); + this.server.listen( this.options.port, () => { this.startAuthorization().catch( reject ); } ); } ); - this.promise.then( () => this.server.close() ); - this.promise.catch( err => this.server.close() ); + void this.promise.then( () => this.server.close(), () => this.server.close() ); return this.promise; } } diff --git a/lib/vantage.ts b/lib/vantage.ts index aa0044c..4c6d32e 100644 --- a/lib/vantage.ts +++ b/lib/vantage.ts @@ -31,8 +31,8 @@ const REGIONS = [ ]; const STACK_CACHE_LIFETIME = 24 * 60 * 60 * 1000; -const AUTH_ENDPOINT = 'https://dashboard.altis-dxp.com/wp-login.php?action=oauth2_authorize'; const BASE_URL = process.env.VANTAGE_URL || 'https://dashboard.altis-dxp.com/api/'; +const AUTH_ENDPOINT = new URL('/wp-login.php?action=oauth2_authorize', BASE_URL).href; const TOKEN_ENDPOINT = `${ BASE_URL }oauth2/access_token`; const OAUTH_KEY = 'za6qrd2teuev'; const OAUTH_SECRET = 'kZCn1dluko2lout6gnDqsGqhYn9a7ICFX8YdOhBSd8hhD1w3'; @@ -109,9 +109,9 @@ class VantageAPI { }, }; this.authPromise = authorize( opts ) - .then( token => { + .then( async token => { // Store for later use. - this.config.set( 'vantage_token', token ); + await this.config.set( 'vantage_token', token ); return { token }; } ); return this.authPromise; diff --git a/test/authorize.test.js b/test/authorize.test.js new file mode 100644 index 0000000..81106bd --- /dev/null +++ b/test/authorize.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; +import { Authenticator } from '../dist/lib/authorize.js'; + +const options = exchange => ({ + endpoints: { authorize: 'https://staging.example/wp-login.php?action=oauth2_authorize', exchange }, + key: 'test-client', secret: 'test-secret', port: 0, + render: (title, message = '') => `${title} ${message}`, +}); +async function endpoint(t, handler) { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => { server.closeAllConnections(); server.close(); }); + return `http://127.0.0.1:${server.address().port}/api/oauth2/access_token`; +} + +test('OAuth login and exchange follow VANTAGE_URL and authentication waits for token persistence', async t => { + const previous = process.env.VANTAGE_URL; + process.env.VANTAGE_URL = 'https://staging.example/api/'; + t.after(() => { if (previous === undefined) delete process.env.VANTAGE_URL; else process.env.VANTAGE_URL = previous; }); + let endpoints; + t.mock.method(Authenticator.prototype, 'start', function() { + endpoints = this.options.endpoints; + return Promise.resolve('synthetic-token'); + }); + const { default: Vantage } = await import('../dist/lib/vantage.js'); + let save; + let completed = false; + const saving = new Promise(resolve => { save = resolve; }); + const client = new Vantage({ get() {}, set(key, value) { + assert.equal(key, 'vantage_token'); assert.equal(value, 'synthetic-token'); return saving; + } }); + const authenticating = client.authenticate().then(value => { completed = true; return value; }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(endpoints, { + authorize: 'https://staging.example/wp-login.php?action=oauth2_authorize', + exchange: 'https://staging.example/api/oauth2/access_token', + }); + assert.equal(completed, false); + save(); + assert.deepEqual(await authenticating, { token: 'synthetic-token' }); +}); + +test('token exchange rejects HTTP errors, invalid JSON, and missing tokens without echoing responses', async t => { + for (const [status, body, expected] of [ + [401, 'private server response', /HTTP 401/], + [200, 'private server response', /invalid JSON/], + [200, '{"error":"private server response"}', /did not return an access token/], + [200, '{"access_token":""}', /did not return an access token/], + ]) { + const url = await endpoint(t, (_req, res) => { res.writeHead(status); res.end(body); }); + const auth = new Authenticator(options(url)); + await assert.rejects(auth.exchangeCode('test-code'), error => expected.test(error.message) && !error.message.includes('private server response')); + } +}); + +test('callback only displays success after receiving a valid token', async t => { + let answer; + const requested = new Promise(resolve => { answer = resolve; }); + let response; + const url = await endpoint(t, (_req, res) => { response = res; answer(); }); + const auth = new Authenticator(options(url)); + let status, page; + const result = auth.handleRequest({ url: `/?code=test-code&state=${encodeURIComponent(auth.state)}` }, { + setHeader() {}, writeHead(value) { status = value; }, end(value) { page = value; }, + }); + await requested; + assert.equal(page, undefined); + response.end('{"access_token":"synthetic-token"}'); + assert.equal(await result, 'synthetic-token'); + assert.equal(status, 200); + assert.match(page, /Authenticated altis-cli!/); +}); + +test('failed callback rejects the login promise, displays failure, and closes the callback server', async t => { + const url = await endpoint(t, (_req, res) => { res.writeHead(401); res.end('{}'); }); + const auth = new Authenticator(options(url)); + let browser; + t.mock.method(auth, 'startAuthorization', async () => { + browser = fetch(`http://127.0.0.1:${auth.server.address().port}/?code=test-code&state=${encodeURIComponent(auth.state)}`); + await browser; + }); + await assert.rejects(auth.start(), /HTTP 401/); + const response = await browser; + assert.equal(response.status, 502); + const page = await response.text(); + assert.match(page, /Authentication failed/); + assert.doesNotMatch(page, /Authenticated altis-cli!/); + assert.equal(auth.server.listening, false); +}); From aafdb968ea643927b7a551b7b3cb3ac8b5b40126 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Fri, 16 Oct 2026 09:44:00 +0200 Subject: [PATCH 11/11] Allow configuring OAuth client credentials for staging --- lib/vantage.ts | 4 ++-- test/authorize.test.js | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/vantage.ts b/lib/vantage.ts index 4c6d32e..9c6258b 100644 --- a/lib/vantage.ts +++ b/lib/vantage.ts @@ -34,8 +34,8 @@ const STACK_CACHE_LIFETIME = 24 * 60 * 60 * 1000; const BASE_URL = process.env.VANTAGE_URL || 'https://dashboard.altis-dxp.com/api/'; const AUTH_ENDPOINT = new URL('/wp-login.php?action=oauth2_authorize', BASE_URL).href; const TOKEN_ENDPOINT = `${ BASE_URL }oauth2/access_token`; -const OAUTH_KEY = 'za6qrd2teuev'; -const OAUTH_SECRET = 'kZCn1dluko2lout6gnDqsGqhYn9a7ICFX8YdOhBSd8hhD1w3'; +const OAUTH_KEY = process.env.VANTAGE_CLIENT_ID || 'za6qrd2teuev'; +const OAUTH_SECRET = process.env.VANTAGE_CLIENT_SECRET || 'kZCn1dluko2lout6gnDqsGqhYn9a7ICFX8YdOhBSd8hhD1w3'; const OAUTH_PORT = 7277; // ASCII "H" + "M" class VantageAPI { diff --git a/test/authorize.test.js b/test/authorize.test.js index 81106bd..c9b84a6 100644 --- a/test/authorize.test.js +++ b/test/authorize.test.js @@ -18,12 +18,16 @@ async function endpoint(t, handler) { } test('OAuth login and exchange follow VANTAGE_URL and authentication waits for token persistence', async t => { - const previous = process.env.VANTAGE_URL; + const previous = { VANTAGE_URL: process.env.VANTAGE_URL, VANTAGE_CLIENT_ID: process.env.VANTAGE_CLIENT_ID, VANTAGE_CLIENT_SECRET: process.env.VANTAGE_CLIENT_SECRET }; process.env.VANTAGE_URL = 'https://staging.example/api/'; - t.after(() => { if (previous === undefined) delete process.env.VANTAGE_URL; else process.env.VANTAGE_URL = previous; }); + process.env.VANTAGE_CLIENT_ID = 'staging-test-client'; + process.env.VANTAGE_CLIENT_SECRET = 'staging-test-secret'; + t.after(() => { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); let endpoints; t.mock.method(Authenticator.prototype, 'start', function() { endpoints = this.options.endpoints; + assert.equal(this.options.key, 'staging-test-client'); + assert.equal(this.options.secret, 'staging-test-secret'); return Promise.resolve('synthetic-token'); }); const { default: Vantage } = await import('../dist/lib/vantage.js');