From 1c36fa59896e70baa56f909b1026ca670933da51 Mon Sep 17 00:00:00 2001 From: Nick Wilkens Date: Tue, 30 Sep 2025 16:54:39 -0400 Subject: [PATCH 1/2] Add console WebSocket endpoint to CloudAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /:account/machines/:machine/console endpoint for WebSocket-based console access to all VM brands. Changes: - Add lib/endpoints/console.js with ConsoleConnectionFSM - Add MachineHasNoConsoleError error type - Mount console endpoint in app.js - WebSocket upgrade with binary protocol - TCP proxy to console (vmadmd) via CNAPI - Support for all brands (KVM serial console, zone console for others) Console connections: - Upgrade HTTP to WebSocket (binary protocol) - Query CNAPI for console host/port - Establish TCP connection to vmadmd console proxy - Bidirectional byte stream proxying - Proper error handling and cleanup API version: 9.0.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/app.js | 2 + lib/endpoints/console.js | 300 +++++++++++++++++++++++++++++++++++++++ lib/errors.js | 13 ++ 3 files changed, 315 insertions(+) create mode 100644 lib/endpoints/console.js diff --git a/lib/app.js b/lib/app.js index e7e79e1b..79b84b7d 100644 --- a/lib/app.js +++ b/lib/app.js @@ -58,6 +58,7 @@ var auditLogger = require('./audit_logger'); var rules = require('./rules'); var volumeEndpoints = require('./endpoints/volumes'); var vnc = require('./endpoints/vnc'); +var consoleEndpoint = require('./endpoints/console'); var accessKeysEndpoints = require('./endpoints/accesskeys'); // Account users, roles and policies: @@ -553,6 +554,7 @@ module.exports = { rules.mount(server, machineThrottle); vnc.mount(server, machineThrottle); + consoleEndpoint.mount(server, machineThrottle); users.mount(server, userThrottle(config, 'users'), config); policies.mount(server, userThrottle(config, 'policies'), config); diff --git a/lib/endpoints/console.js b/lib/endpoints/console.js new file mode 100644 index 00000000..9acc1208 --- /dev/null +++ b/lib/endpoints/console.js @@ -0,0 +1,300 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2025 Joyent, Inc. + */ + +var assert = require('assert-plus'); +var restify = require('restify'); +var util = require('util'); +var watershed = require('watershed'); +var mooremachine = require('mooremachine'); +var net = require('net'); + +var errors = require('../errors'); +var shed = new watershed.Watershed(); + +var MachineHasNoConsoleError = errors.MachineHasNoConsoleError; +var MachineStoppedError = errors.MachineStoppedError; +var UpgradeRequiredError = errors.UpgradeRequiredError; + +function mount(server, before, pre) { + assert.object(server, 'server'); + assert.ok(before, 'before'); + assert.optionalArrayOfFunc(pre, 'pre'); + + pre = pre || []; + + server.get({ + path: '/:account/machines/:machine/console', + name: 'ConnectMachineConsole', + version: [ '9.0.0' ] + }, before, connectConsole); + + return server; +} + +function connectConsole(req, res, next) { + var vm = req.machine; + + // All brands support console access + // KVM: serial console, others: zone console + if (vm.state !== 'running') { + next(new MachineStoppedError()); + return; + } + if (!res.claimUpgrade) { + var msg = 'Console connect endpoint is a websocket and must be upgraded'; + next(new UpgradeRequiredError(msg)); + return; + } + + /* + * Since cloudapi still runs with restify request domains enabled, we need + * to exit that domain here if we want any errors in the Console FSM to be + * reported sensibly (since the request will end from restify's + * perspective once we send the 101). + * + * This can be removed once domains and the uncaughtException handler are + * turned off for cloudapi. + */ + var reqdom = process.domain; + + if (reqdom && reqdom.domain) { + reqdom.exit(); + } + + var fsm = new ConsoleConnectionFSM(req, res, next); + fsm.start(); + + if (reqdom && reqdom.domain) { + reqdom.enter(); + } +} + + +function ConsoleConnectionFSM(req, res, next) { + this.req = req; + this.res = res; + this.next = next; + this.err = undefined; + this.log = undefined; + this.upgrade = undefined; + this.ws = undefined; + this.socket = undefined; + this.host = undefined; + this.port = undefined; + this.type = undefined; + + mooremachine.FSM.call(this, 'init'); +} + +util.inherits(ConsoleConnectionFSM, mooremachine.FSM); + +ConsoleConnectionFSM.prototype.state_init = function state_init(S) { + S.on(this, 'startAsserted', function handleStarted() { + S.gotoState('upgrade'); + }); +}; + +ConsoleConnectionFSM.prototype.state_reject = function state_rejectsock() { + var err = new restify.InternalServerError(); + var code = err.statusCode; + var data = JSON.stringify(err.body); + this.upgrade.socket.write('HTTP/1.1 ' + code + ' Upgrade Rejected\r\n' + + 'Connection: close\r\n' + + 'Content-Type: application/json\r\n' + + 'Content-Length: ' + data.length + '\r\n\r\n'); + this.upgrade.socket.end(data); + this.next(); +}; + +ConsoleConnectionFSM.prototype.state_upgrade = function state_upgrade(S) { + try { + this.upgrade = this.res.claimUpgrade(); + /* + * For console connections, we want low latency for interactive + * terminal sessions. Disable Nagle's algorithm so that keystrokes + * are sent immediately. + */ + this.upgrade.socket.setNoDelay(true); + + this.ws = shed.accept(this.req, this.upgrade.socket, this.upgrade.head, + false, ['binary', 'console']); + } catch (ex) { + this.log.error(ex, 'websocket upgrade failed'); + S.gotoState('reject'); + return; + } + /* + * From restify's perspective, the HTTP request ends here. We set the + * statusCode so that the audit logs show that we upgraded to websockets. + */ + this.res.statusCode = 101; + this.next(); + + /* Now we continue on to use the websocket. */ + S.gotoState('getport'); +}; + +ConsoleConnectionFSM.prototype.state_getport = function state_getport(S) { + var vm = this.req.machine; + var uri = '/servers/' + vm.compute_node + '/vms/' + vm.id + '/console'; + var self = this; + this.req.sdc.cnapi.get(uri, S.callback(function gotConsoleDetails(err, obj) { + if (err) { + self.log.error(err, 'failed to fetch VM console details from CNAPI'); + self.err = new restify.InternalServerError('Failed to retrieve ' + + 'console socket details'); + S.gotoState('error'); + return; + } + if (typeof (obj.host) !== 'string' || typeof (obj.port) !== 'number') { + self.log.error({ obj: obj }, 'CNAPI returned invalid VM console obj'); + self.err = new restify.InternalServerError('Failed to retrieve ' + + 'console socket details'); + S.gotoState('error'); + return; + } + self.host = obj.host; + self.port = obj.port; + self.type = obj.type || 'unknown'; + self.log = self.log.child({ + consoleHost: obj.host, + consolePort: obj.port, + consoleType: obj.type + }); + self.log.debug('cnapi returned address for console'); + S.gotoState('connect'); + })); + S.on(this.ws, 'error', function onWsError(_err) { + S.gotoState('error'); + }); +}; + +ConsoleConnectionFSM.prototype.state_error = function state_error() { + this.log.warn(this.err, 'console connection exited with error'); + if (this.ws) { + try { + this.ws.end(JSON.stringify({ type: 'error', error: this.err })); + } catch (_ex) { + this.ws.destroy(); + this.ws = null; + } + } + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } +}; + +ConsoleConnectionFSM.prototype.state_connect = function state_connect(S) { + var self = this; + + S.on(this.ws, 'error', function connectWsError(err) { + self.err = err; + S.gotoState('error'); + }); + S.on(this.ws, 'end', function connectWsEnd() { + S.gotoState('ws_ended'); + }); + + this.socket = net.createConnection({ + allowHalfOpen: true, + host: this.host, + port: this.port + }); + + S.on(this.socket, 'connect', function connected() { + S.gotoState('connected'); + }); + S.on(this.socket, 'error', function connectSockErr(err) { + self.log.error(err, 'failed to connect to console endpoint'); + self.err = new restify.InternalServerError('Failed to connect to ' + + 'console server'); + S.gotoState('error'); + }); + S.timeout(5000, function connectTimeout() { + self.log.error('timeout while connecting to console endpoint'); + self.err = new restify.InternalServerError('Timeout while connecting ' + + 'to console server'); + S.gotoState('error'); + }); +}; + +ConsoleConnectionFSM.prototype.state_connected = function state_connected(S) { + var self = this; + this.socket.setNoDelay(true); + + S.on(this.ws, 'error', function consoleWsError(err) { + self.log.error(err, 'error on websocket connection to client'); + self.err = err; + S.gotoState('error'); + }); + S.on(this.ws, 'end', function consoleWsEnd() { + S.gotoState('ws_ended'); + }); + S.on(this.ws, 'connectionReset', function consoleWsReset() { + S.gotoState('ws_ended'); + }); + + S.on(this.socket, 'end', function consoleSockEnd() { + S.gotoState('sock_ended'); + }); + S.on(this.socket, 'error', function consoleSockErr(err) { + self.log.error(err, 'error on console connection'); + S.gotoState('error'); + }); + + S.on(this.ws, 'binary', function consoleWsGotData(buf) { + self.socket.write(buf); + }); + S.on(this.socket, 'readable', function consoleSockGotData() { + var buf; + while ((buf = self.socket.read()) !== null) { + self.ws.send(buf); + } + }); +}; + +ConsoleConnectionFSM.prototype.state_ws_ended = function state_ws_ended(S) { + S.on(this.socket, 'close', function consoleSockClose() { + S.gotoState('closed'); + }); + S.timeout(5000, function consoleSockCloseTimeout() { + S.gotoState('error'); + }); + this.socket.end(); + this.socket = null; +}; + +ConsoleConnectionFSM.prototype.state_sock_ended = function state_sock_ended(S) { + this.ws.end('Remote connection closed'); + this.ws = null; + S.gotoState('closed'); +}; + +ConsoleConnectionFSM.prototype.state_closed = function state_closed() { + if (this.socket) { + this.socket.destroy(); + } + this.socket = null; + if (this.ws) { + this.ws.destroy(); + } + this.ws = null; +}; + +ConsoleConnectionFSM.prototype.start = function start() { + this.log = this.req.log.child({ component: 'ConsoleConnectionFSM' }); + + this.emit('startAsserted'); +}; + +module.exports = { + mount: mount +}; diff --git a/lib/errors.js b/lib/errors.js index fbd23574..2d58d0a3 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -417,6 +417,18 @@ MachineHasNoVNCError.prototype.restCode = 'MachineHasNoVNCError'; MachineHasNoVNCError.prototype.statusCode = 400; MachineHasNoVNCError.prototype.description = 'Instance does not support VNC'; +function MachineHasNoConsoleError(brand) { + assert.string(brand, 'brand'); + + var errMsg = format('Instance type %s does not support console connections', + brand); + _FriendlySigRestError.call(this, null, errMsg); +} +util.inherits(MachineHasNoConsoleError, _FriendlySigRestError); +MachineHasNoConsoleError.prototype.restCode = 'MachineHasNoConsoleError'; +MachineHasNoConsoleError.prototype.statusCode = 400; +MachineHasNoConsoleError.prototype.description = 'Instance does not support console'; + function MachineStoppedError() { _FriendlySigRestError.call(this, null, 'Cannot connect to a stopped machine'); @@ -506,6 +518,7 @@ module.exports = { VolumesNotReachableError: VolumesNotReachableError, MachineHasNoVNCError: MachineHasNoVNCError, + MachineHasNoConsoleError: MachineHasNoConsoleError, MachineStoppedError: MachineStoppedError, UpgradeRequiredError: UpgradeRequiredError, From d4e5a47d2e366664234422f7cd1b0af30ce70d38 Mon Sep 17 00:00:00 2001 From: Nick Wilkens Date: Mon, 12 Jan 2026 09:44:41 -0500 Subject: [PATCH 2/2] Fix undefined logger in state_upgrade error path Initialize this.log early in state_upgrade to prevent crash when WebSocket upgrade fails. Previously, this.log was only set in start(), causing 'this.log.error(ex, ...)' in the catch block to fail with "Cannot read property 'error' of undefined". Also update comment to clarify that most (not all) brands support console access, matching the existence of MachineHasNoConsoleError. Addresses PR review feedback from https://github.com/TritonDataCenter/sdc-cloudapi/pull/152 Co-Authored-By: Claude Opus 4.5 --- lib/endpoints/console.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/endpoints/console.js b/lib/endpoints/console.js index 9acc1208..6ee38bbd 100644 --- a/lib/endpoints/console.js +++ b/lib/endpoints/console.js @@ -41,8 +41,10 @@ function mount(server, before, pre) { function connectConsole(req, res, next) { var vm = req.machine; - // All brands support console access - // KVM: serial console, others: zone console + // Most brands support console access: + // - KVM/Bhyve: serial console + // - Joyent/LX: zone console + // MachineHasNoConsoleError is available for future brand restrictions. if (vm.state !== 'running') { next(new MachineStoppedError()); return; @@ -114,6 +116,10 @@ ConsoleConnectionFSM.prototype.state_reject = function state_rejectsock() { }; ConsoleConnectionFSM.prototype.state_upgrade = function state_upgrade(S) { + // Initialize logger early in case of errors during upgrade + if (!this.log) { + this.log = this.req.log.child({ component: 'ConsoleConnectionFSM' }); + } try { this.upgrade = this.res.claimUpgrade(); /*