@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
283 lines • 13.2 kB
JavaScript
"use strict";
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.SecureStream = void 0;
const vscode_jsonrpc_1 = require("vscode-jsonrpc");
const streams_1 = require("./streams");
const sshStream_1 = require("./sshStream");
const sshSessionConfiguration_1 = require("./sshSessionConfiguration");
const transportMessages_1 = require("./messages/transportMessages");
const trace_1 = require("./trace");
const sshClientSession_1 = require("./sshClientSession");
const sshServerSession_1 = require("./sshServerSession");
const errors_1 = require("./errors");
const stream_1 = require("stream");
const promiseCompletionSource_1 = require("./util/promiseCompletionSource");
/**
* Establishes an end-to-end encrypted two-way authenticated data stream over an underlying
* transport stream, using the SSH protocol but providing simplified interface that is limited to
* a single duplex stream (channel).
*
* This class is a complement to `MultiChannelStream`, which provides only the channel-multiplexing
* functions of SSH.
*
* To establish a secure connection, the two sides first establish an insecure transport stream
* over a pipe, socket, or anything else. Then they encrypt and authenticate the connection
* before beginning to send and receive data.
*/
class SecureStream extends stream_1.Duplex {
/**
* Creates a new encrypted and authenticated stream over an underlying transport stream.
* @param transportStream Stream that is used to multiplex all the channels.
* @param credentials Client or server credentials for authenticating the secure connection.
* @param reconnectableSessions Optional parameter that enables the stream to be reconnected
* with a new transport stream after a temporary disconnection. For a stream client it is
* a boolean value; for a stream server it must be an array.
*/
constructor(transportStream, credentials, reconnectableSessions) {
super({
write(chunk, encoding, cb) {
this.connectCompletion.promise.then((stream) => {
if (!stream) {
cb(new errors_1.ObjectDisposedError('SecureStream'));
}
else {
// eslint-disable-next-line no-underscore-dangle
stream._write(chunk, encoding, cb);
}
}, cb);
},
writev(chunks, cb) {
this.connectCompletion.promise.then((stream) => {
if (!stream) {
cb(new errors_1.ObjectDisposedError('SecureStream'));
}
else {
// eslint-disable-next-line no-underscore-dangle
stream._writev(chunks, cb);
}
}, cb);
},
final(cb) {
this.connectCompletion.promise.then((stream) => {
if (!stream) {
cb(new errors_1.ObjectDisposedError('SecureStream'));
}
else {
// eslint-disable-next-line no-underscore-dangle
stream._final(cb);
}
}, cb);
},
read(size) {
this.connectCompletion.promise.then((stream) => {
if (!stream) {
this.push(null); // EOF
}
else {
// eslint-disable-next-line no-underscore-dangle
stream._read(size);
}
}, (e) => {
// The error will be thrown from the connect() method.
});
},
});
this.transportStream = transportStream;
this.clientCredentials = null;
this.serverCredentials = null;
this.connectCompletion = new promiseCompletionSource_1.PromiseCompletionSource();
this.disposed = false;
this.disposables = [];
this.disconnectedEmitter = new vscode_jsonrpc_1.Emitter();
this.onDisconnected = this.disconnectedEmitter.event;
this.closedEmitter = new vscode_jsonrpc_1.Emitter();
this.onClosed = this.closedEmitter.event;
if (!transportStream)
throw new TypeError('A transport stream is required.');
if (!credentials)
throw new TypeError('Client or server credentials are required.');
const sessionConfig = new sshSessionConfiguration_1.SshSessionConfiguration(true);
if (reconnectableSessions) {
sessionConfig.protocolExtensions.push(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect);
}
if ('username' in credentials) {
if (typeof reconnectableSessions !== 'undefined' &&
typeof reconnectableSessions !== 'boolean') {
throw new TypeError('SecureStream client reconnectable sessions must be a boolean.');
}
this.clientCredentials = credentials;
this.session = new sshClientSession_1.SshClientSession(sessionConfig);
}
else if (credentials.publicKeys) {
if (typeof reconnectableSessions !== 'undefined' &&
!Array.isArray(reconnectableSessions)) {
throw new TypeError('SecureStream server reconnectable sessions must be an array.');
}
this.serverCredentials = credentials;
this.session = new sshServerSession_1.SshServerSession(sessionConfig, reconnectableSessions);
}
else {
throw new TypeError('Client or server credentials are required.');
}
this.session.onDisconnected(this.onSessionDisconnected, this, this.disposables);
this.session.onClosed(this.onSessionClosed, this, this.disposables);
}
get trace() {
return this.session.trace;
}
set trace(trace) {
this.session.trace = trace;
}
get isClosed() {
return this.disposed || this.session.isClosed;
}
onAuthenticating(listener, thisArgs, disposables) {
return this.session.onAuthenticating(listener, thisArgs, disposables);
}
/**
* Initiates the SSH session over the transport stream by exchanging initial messages with the
* remote peer. Waits for the protocol version exchange and key exchange. Additional message
* processing is kicked off as a background promise chain.
* @param cancellation optional cancellation token.
*/
async connect(cancellation) {
let sessionConnected = false;
try {
if (this.serverCredentials) {
const serverSession = this.session;
serverSession.credentials = this.serverCredentials;
}
let stream = this.transportStream;
if (stream instanceof stream_1.Duplex) {
stream = new streams_1.NodeStream(stream);
}
await this.session.connect(stream, cancellation);
sessionConnected = true;
let channel = null;
if (this.clientCredentials) {
const clientSession = this.session;
if (!(await clientSession.authenticateServer(cancellation))) {
throw new errors_1.SshConnectionError('Server authentication failed.', transportMessages_1.SshDisconnectReason.hostKeyNotVerifiable);
}
if (!(await clientSession.authenticateClient(this.clientCredentials, cancellation))) {
throw new errors_1.SshConnectionError('Client authentication failed.', transportMessages_1.SshDisconnectReason.noMoreAuthMethodsAvailable);
}
channel = await this.session.openChannel(cancellation);
}
else {
channel = await this.session.acceptChannel(cancellation);
}
this.stream = this.createStream(channel);
// Do not forward the 'readable' event because adding a listener causes a read.
this.stream.on('data', (data) => this.emit('data', data));
this.stream.on('end', () => this.emit('end'));
this.stream.on('close', () => this.emit('close'));
this.stream.on('error', () => this.emit('error'));
channel.onClosed(() => this.dispose());
this.connectCompletion.resolve(this.stream);
}
catch (e) {
if (!(e instanceof Error))
throw e;
if (e instanceof errors_1.ObjectDisposedError && this.session.isClosed) {
// The session was closed while waiting for the channel.
// This can happen in reconnect scenarios.
this.connectCompletion.resolve(null);
}
else {
let disconnectReason = e instanceof errors_1.SshConnectionError ? e.reason : undefined;
disconnectReason !== null && disconnectReason !== void 0 ? disconnectReason : (disconnectReason = transportMessages_1.SshDisconnectReason.protocolError);
await this.session.close(disconnectReason, e.message, e);
this.connectCompletion.reject(e);
throw e;
}
}
}
/**
* Re-initiates the SSH session over a NEW transport stream by exchanging initial messages
* with the remote server. Waits for the secure reconnect handshake to complete. Additional
* message processing is kicked off as a background task chain.
*
* Applies only to a secure stream client. (The secure stream server handles reconnections
* automatically during the session handshake.)
*/
async reconnect(transportStream, cancellation) {
if (!(this.session instanceof sshClientSession_1.SshClientSession)) {
throw new Error('Cannot reconnect SecureStream server.');
}
if (!transportStream)
throw new TypeError('A transport stream is required.');
this.transportStream = transportStream;
let stream = this.transportStream;
if (stream instanceof stream_1.Duplex) {
stream = new streams_1.NodeStream(stream);
}
await this.session.reconnect(stream, cancellation);
}
/**
* Creates a stream instance for a channel. May be overridden to create a `SshStream` subclass.
*/
createStream(channel) {
return new sshStream_1.SshStream(channel);
}
dispose() {
if (!this.disposed) {
const sessionWasConnected = this.session.isConnected || this.session.isClosed;
this.disposed = true;
this.session.dispose();
this.unsubscribe();
try {
// If the session did not connect yet, it doesn't know about the stream and
// won't dispose it. So dispose it here.
if (!sessionWasConnected && this.transportStream) {
if (this.transportStream instanceof stream_1.Duplex) {
this.transportStream.end();
this.transportStream.destroy();
}
else {
this.transportStream.close().catch((e) => {
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamCloseError, `Error closing transport stream: ${e.message}`, e);
});
}
}
}
catch (e) {
if (!(e instanceof Error))
throw e;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.streamCloseError, `Error closing transport stream: ${e.message}`, e);
}
}
}
async close() {
if (!this.disposed) {
this.disposed = true;
await this.session.close(transportMessages_1.SshDisconnectReason.none, this.session.constructor.name + ' disposed.');
this.session.dispose();
this.unsubscribe();
if (this.transportStream instanceof stream_1.Duplex) {
await new Promise((resolve) => {
this.transportStream.end(resolve);
});
}
else {
await this.transportStream.close();
}
}
}
onSessionDisconnected() {
this.disconnectedEmitter.fire();
}
onSessionClosed(e) {
this.unsubscribe();
this.closedEmitter.fire(e);
}
unsubscribe() {
this.disposables.forEach((d) => d.dispose());
this.disposables = [];
}
}
exports.SecureStream = SecureStream;
//# sourceMappingURL=secureStream.js.map