@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
486 lines • 23.2 kB
JavaScript
"use strict";
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.SshChannel = void 0;
const vscode_jsonrpc_1 = require("vscode-jsonrpc");
const serviceActivation_1 = require("./services/serviceActivation");
const connectionMessages_1 = require("./messages/connectionMessages");
const transportMessages_1 = require("./messages/transportMessages");
const channelMetrics_1 = require("./metrics/channelMetrics");
const promiseCompletionSource_1 = require("./util/promiseCompletionSource");
const errors_1 = require("./errors");
const sshRequestEventArgs_1 = require("./events/sshRequestEventArgs");
const sshChannelClosedEventArgs_1 = require("./events/sshChannelClosedEventArgs");
const cancellation_1 = require("./util/cancellation");
const semaphore_1 = require("./util/semaphore");
const trace_1 = require("./trace");
const pipeExtensions_1 = require("./pipeExtensions");
const queue_1 = require("./util/queue");
/**
* Represents a channel on an SSH session. A session may include multiple channels, which
* are multiplexed over the connection. Each channel within a session has a unique integer ID.
*/
class SshChannel {
/* @internal */
constructor(connectionService, channelType, channelId, remoteChannelId, remoteMaxWindowSize, remoteMaxPacketSize, openMessage, openConfirmationMessage) {
this.connectionService = connectionService;
this.channelType = channelType;
this.channelId = channelId;
this.remoteChannelId = remoteChannelId;
this.openMessage = openMessage;
this.openConfirmationMessage = openConfirmationMessage;
this.remoteClosed = false;
this.localClosed = false;
this.sentEof = false;
this.disposed = false;
this.openSendingWindowCompletionSource = null;
this.requestCompletionSources = new queue_1.Queue();
this.sendSemaphore = new semaphore_1.Semaphore(0);
/**
* Gets an object that reports measurements about the channel.
*/
this.metrics = new channelMetrics_1.ChannelMetrics();
this.dataReceivedEmitter = new vscode_jsonrpc_1.Emitter();
this.extendedDataReceivedEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event raised when a data message is received on the channel.
*
* Users of a channel MUST add a `onDataReceived` event handler immediately after a
* channel is opened/accepted, or else all session communication will be blocked.
* (The `SshStream` class does this automatically.)
*
* The event handler must call `adjustWindow` when the data has been consumed,
* to notify the remote side that it may send more data.
*/
this.onDataReceived = this.dataReceivedEmitter.event;
this.onExtendedDataReceived = this.extendedDataReceivedEmitter.event;
this.eofEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event raised when an EOF message is received on the channel.
*/
this.onEof = this.eofEmitter.event;
this.closedEmitter = new vscode_jsonrpc_1.Emitter();
this.onClosed = this.closedEmitter.event;
this.requestEmitter = new vscode_jsonrpc_1.Emitter();
this.onRequest = this.requestEmitter.event;
/**
* Gets or sets a value indicating whether `maxWindowSize is locked, so that it cannot be
* changed after the channel is opened.
*/
/* @internal */
this.isMaxWindowSizeLocked = false;
this.remoteWindowSize = remoteMaxWindowSize;
this.maxWindowSizeValue = SshChannel.defaultMaxWindowSize;
this.windowSize = this.maxWindowSizeValue;
this.maxPacketSize = Math.min(remoteMaxPacketSize, SshChannel.defaultMaxPacketSize);
}
get session() {
return this.connectionService.session;
}
get isClosed() {
return this.localClosed || this.remoteClosed;
}
/**
* Gets the maximum window size for received data. The other side will not send more
* data than the window size until it receives an acknowledgement that some of the data was
* received and processed by this side.
*/
get maxWindowSize() {
return this.maxWindowSizeValue;
}
/**
* Sets the maximum window size for received data. The other side will not send more
* data than the window size until it receives an acknowledgement that some of the data was
* received and processed by this side.
*
* The default value is `defaultMaxWindowSize`. The value may be configured for a channel
* opened by this side by setting `ChannelOpenMessage.maxWindowSize` in the message object
* passed to `SshSession.openChannel()`, or for a channel opened by the other side by
* assigning to this property while handling the `SshSession.onChannelOpening` event.
* Changing the maximum window size at any other time is not valid because the other
* side would not be aware of the change.
*/
set maxWindowSize(value) {
if (this.isMaxWindowSizeLocked) {
throw new Error('Cannot change the max window size after opening the channel.');
}
if (value < this.maxPacketSize) {
throw new Error('Maximum window size cannot be less than maximum packet size.');
}
this.maxWindowSizeValue = value;
}
/**
* Sends a channel request and waits for a response.
*
* Note if `wantReply` is `false`, this method returns `true` immediately after sending the
* request, without waiting for a reply.
*
* @returns The authorization status of the response; if false, the other side denied the
* request.
* @throws `ObjectDisposedError` if the channel was closed before sending the request.
* @throws `SshChannelError` if the channel was closed while waiting for a reply to the request.
*/
async request(request, cancellation) {
if (!request)
throw new TypeError('Request is required.');
if (this.disposed)
throw new errors_1.ObjectDisposedError(this);
request.recipientChannel = this.remoteChannelId;
if (!request.wantReply) {
// If a reply is not requested, there's no need to set up a completion source.
await this.session.sendMessage(request, cancellation);
return true;
}
const requestCompletionSource = new promiseCompletionSource_1.PromiseCompletionSource();
if (cancellation) {
if (cancellation.isCancellationRequested)
throw new cancellation_1.CancellationError();
cancellation.onCancellationRequested(() => {
requestCompletionSource.reject(new cancellation_1.CancellationError());
});
}
this.requestCompletionSources.enqueue(requestCompletionSource);
await this.session.sendMessage(request, cancellation);
return await requestCompletionSource.promise;
}
async send(data, cancellation) {
return this.sendCommon(data, undefined, cancellation);
}
async sendExtendedData(dataTypeCode, data, cancellation) {
return this.sendCommon(data, dataTypeCode, cancellation);
}
async sendCommon(data, extendedDataType, cancellation) {
if (this.disposed)
throw new errors_1.ObjectDisposedError(this);
if (data.length === 0) {
await this.sendEof();
return;
}
else if (this.sentEof) {
throw new Error('Cannot send more data after EOF.');
}
// Prevent out-of-order message chunks even if the caller does not await.
// Also don't send until the channel is fully opened.
await this.sendSemaphore.wait(cancellation);
try {
let offset = 0;
let count = data.length;
while (count > 0) {
let packetSize = Math.min(Math.min(this.remoteWindowSize, this.maxPacketSize), count);
while (packetSize === 0) {
if (!this.openSendingWindowCompletionSource) {
this.openSendingWindowCompletionSource = new promiseCompletionSource_1.PromiseCompletionSource();
}
this.session.trace(trace_1.TraceLevel.Warning, trace_1.SshTraceEventIds.channelWaitForWindowAdjust, `${this} send window is full. Waiting for window adjustment before sending.`);
await (0, cancellation_1.withCancellation)(this.openSendingWindowCompletionSource.promise, cancellation);
this.openSendingWindowCompletionSource = null;
packetSize = Math.min(Math.min(this.remoteWindowSize, this.maxPacketSize), count);
}
let msg;
if (extendedDataType !== undefined) {
msg = new connectionMessages_1.ChannelExtendedDataMessage();
msg.dataTypeCode = extendedDataType;
}
else {
msg = new connectionMessages_1.ChannelDataMessage();
}
msg.recipientChannel = this.remoteChannelId;
// Unfortunately the data must be copied to a new buffer at this point
// to ensure it is still available to be re-sent later in case of disconnect.
msg.data = Buffer.from(data.slice(offset, offset + packetSize));
await this.session.sendMessage(msg, cancellation);
this.remoteWindowSize -= packetSize;
count -= packetSize;
offset += packetSize;
this.metrics.addBytesSent(packetSize);
}
}
finally {
this.sendSemaphore.tryRelease();
}
}
/* @internal */
enableSending() {
this.sendSemaphore.tryRelease();
}
async sendEof(cancellation) {
if (this.sentEof) {
return;
}
await this.sendSemaphore.wait(cancellation);
try {
this.sentEof = true;
const msg = new connectionMessages_1.ChannelEofMessage();
msg.recipientChannel = this.remoteChannelId;
await this.session.sendMessage(msg, cancellation);
}
finally {
this.sendSemaphore.tryRelease();
}
}
/* @internal */
async handleRequest(request, cancellation) {
if (!request.requestType) {
throw new errors_1.SshConnectionError('Channel request type not specified.', transportMessages_1.SshDisconnectReason.protocolError);
}
if (request.requestType === connectionMessages_1.ChannelRequestType.exitStatus) {
const signal = new connectionMessages_1.ChannelSignalMessage();
request.convertTo(signal);
this.exitStatus = signal.exitStatus;
return true;
}
else if (request.requestType === connectionMessages_1.ChannelRequestType.exitSignal) {
const signal = new connectionMessages_1.ChannelSignalMessage();
request.convertTo(signal);
this.exitSignal = signal.exitSignal;
this.exitErrorMessage = signal.errorMessage;
return true;
}
else if (request.requestType === connectionMessages_1.ChannelRequestType.signal) {
const signal = new connectionMessages_1.ChannelSignalMessage();
request.convertTo(signal);
}
const args = new sshRequestEventArgs_1.SshRequestEventArgs(request.requestType, request, this.session.principal, cancellation);
const serviceType = (0, serviceActivation_1.findService)(this.session.config.services, (a) => (!a.channelType || a.channelType === this.channelType) &&
a.channelRequest === request.requestType);
await this.sendSemaphore.wait(cancellation);
try {
let response = null;
if (serviceType) {
// A service was configured for activation via this session request type.
const service = this.session.activateService(serviceType);
// `onChannelRequest` should really be 'protected internal'.
await service.onChannelRequest(this, args, cancellation);
}
else {
this.requestEmitter.fire(args);
}
// TODO: do not block requests in TS (similar to CS)
// see https://dev.azure.com/devdiv/DevDiv/_git/SSH/commit/0b84a48811e2f015107c73bf4584b6c3b676a6de
if (args.responsePromise) {
response = await args.responsePromise;
args.isAuthorized = response instanceof connectionMessages_1.ChannelSuccessMessage;
}
if (request.wantReply) {
if (args.isAuthorized) {
response = response !== null && response !== void 0 ? response : new connectionMessages_1.ChannelSuccessMessage();
response.recipientChannel = this.remoteChannelId;
}
else {
response = response !== null && response !== void 0 ? response : new connectionMessages_1.ChannelFailureMessage();
response.recipientChannel = this.remoteChannelId;
}
await this.session.sendMessage(response, cancellation);
}
}
finally {
this.sendSemaphore.tryRelease();
}
return args.isAuthorized || false;
}
/* @internal */
handleResponse(result) {
const requestCompletionSource = this.requestCompletionSources.dequeue();
if (requestCompletionSource) {
requestCompletionSource.resolve(result);
}
}
/* @internal */
handleDataReceived(data) {
this.metrics.addBytesReceived(data.length);
// DataReceived handler is to adjust the window when it's done with the data.
this.dataReceivedEmitter.fire(data);
}
handleExtendedDataReceived(data) {
this.metrics.addBytesReceived(data.data.length);
this.extendedDataReceivedEmitter.fire(data);
}
/**
* Adjusts the local receiving window size by the specified amount, notifying
* the remote side that it is free to send more data.
*
* This method MUST be called either immediately or eventually by the
* `onDataReceived` event handler as incoming data is processed.
*/
adjustWindow(messageLength) {
if (this.disposed)
return;
this.windowSize -= messageLength;
if (this.windowSize <= this.maxWindowSizeValue / 2) {
const windowAdjustMessage = new connectionMessages_1.ChannelWindowAdjustMessage();
windowAdjustMessage.recipientChannel = this.remoteChannelId;
windowAdjustMessage.bytesToAdd = this.maxWindowSizeValue - this.windowSize;
this.windowSize = this.maxWindowSizeValue;
this.session.sendMessage(windowAdjustMessage).catch((e) => {
this.session.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.channelWindowAdjustFailed, `Error sending window adjust message: ${e.message}`, e);
});
}
}
/* @internal */
adjustRemoteWindow(bytesToAdd) {
this.remoteWindowSize += bytesToAdd;
if (this.openSendingWindowCompletionSource) {
this.openSendingWindowCompletionSource.resolve(undefined);
}
}
/* @internal */
handleEof() {
this.session.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.channelEofReceived, `${this} EOF received.`);
this.eofEmitter.fire();
}
close(exitStatusOrSignal, errorMessage, cancellation) {
if (exitStatusOrSignal instanceof Error) {
const error = exitStatusOrSignal;
if (!this.localClosed) {
this.localClosed = true;
this.session.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.channelClosed, `${this} Closed: ${error.message}`);
this.closedEmitter.fire(new sshChannelClosedEventArgs_1.SshChannelClosedEventArgs(error));
}
this.disposeInternal();
return;
}
if (typeof exitStatusOrSignal === 'number') {
return this.closeWithStatus(exitStatusOrSignal, errorMessage);
}
else if (typeof exitStatusOrSignal === 'string') {
return this.closeWithSignal(exitStatusOrSignal, errorMessage, cancellation);
}
else {
return this.closeDefault(exitStatusOrSignal);
}
}
async closeDefault(cancellation) {
if (!this.remoteClosed && !this.localClosed) {
this.remoteClosed = true;
await this.sendSemaphore.wait(cancellation);
try {
const closeMessage = new connectionMessages_1.ChannelCloseMessage();
closeMessage.recipientChannel = this.remoteChannelId;
await this.session.sendMessage(closeMessage);
}
catch (e) {
// The session was already closed.
}
finally {
this.sendSemaphore.tryRelease();
}
}
if (!this.localClosed) {
this.localClosed = true;
const closedMessage = this.raiseClosedEvent();
}
this.disposeInternal();
}
async closeWithStatus(exitStatus, cancellation) {
if (!this.remoteClosed && !this.localClosed) {
this.exitStatus = exitStatus;
const signalMessage = new connectionMessages_1.ChannelSignalMessage();
signalMessage.recipientChannel = this.remoteChannelId;
signalMessage.exitStatus = exitStatus;
await this.session.sendMessage(signalMessage);
}
await this.closeDefault(cancellation);
}
async closeWithSignal(exitSignal, errorMessage, cancellation) {
if (!this.remoteClosed && !this.localClosed) {
this.exitSignal = exitSignal;
this.exitErrorMessage = errorMessage;
const signalMessage = new connectionMessages_1.ChannelSignalMessage();
signalMessage.recipientChannel = this.remoteChannelId;
signalMessage.exitSignal = exitSignal;
signalMessage.errorMessage = errorMessage;
await this.session.sendMessage(signalMessage);
}
await this.closeDefault(cancellation);
}
/* @internal */
handleClose() {
if (!this.localClosed) {
this.localClosed = true;
const closedMessage = this.raiseClosedEvent(true);
}
this.disposeInternal();
}
raiseClosedEvent(closedByRemote = false) {
const metricsMessage = ` (S: ${this.metrics.bytesSent}, R: ${this.metrics.bytesReceived})`;
const originMessage = closedByRemote ? 'remotely' : 'locally';
let closedMessage;
let args;
if (typeof this.exitStatus !== 'undefined') {
args = new sshChannelClosedEventArgs_1.SshChannelClosedEventArgs(this.exitStatus);
closedMessage = `${this} closed ${originMessage}: status=${this.exitStatus}`;
}
else if (typeof this.exitSignal !== 'undefined') {
args = new sshChannelClosedEventArgs_1.SshChannelClosedEventArgs(this.exitSignal, this.exitErrorMessage);
closedMessage = `${this} closed ${originMessage}: signal=${this.exitSignal} ${this.exitErrorMessage}`;
}
else {
args = new sshChannelClosedEventArgs_1.SshChannelClosedEventArgs();
closedMessage = `${this} closed ${originMessage}.`;
}
this.session.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.channelClosed, closedMessage + metricsMessage);
this.closedEmitter.fire(args);
return closedMessage;
}
dispose() {
if (!this.disposed && !this.localClosed) {
if (!this.remoteClosed) {
this.remoteClosed = true;
const closeMessage = new connectionMessages_1.ChannelCloseMessage();
closeMessage.recipientChannel = this.remoteChannelId;
this.session.sendMessage(closeMessage).catch((e) => {
// The session was already closed, or some other sending error occurred.
// The details have already been traced.
});
}
const message = this.session.isClosed ? `${this.session} closed.` : `${this} disposed.`;
this.session.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.channelClosed, message);
const args = new sshChannelClosedEventArgs_1.SshChannelClosedEventArgs('SIGABRT', message);
this.localClosed = true;
this.closedEmitter.fire(args);
}
this.disposeInternal();
}
disposeInternal() {
if (this.disposed)
return;
this.disposed = true;
this.cancelPendingRequests();
this.connectionService.removeChannel(this);
this.sendSemaphore.dispose();
}
/**
* Pipes one SSH channel into another, relaying all data between them.
* @param toChannel Channel to which the current channel will be connected via the pipe.
* @returns A promise that resolves when the channels are closed.
*/
pipe(toChannel) {
return pipeExtensions_1.PipeExtensions.pipeChannel(this, toChannel);
}
cancelPendingRequests() {
for (const completion of this.requestCompletionSources) {
completion.resolve(false);
}
}
toString() {
return `SshChannel(Type: ${this.channelType}, Id: ${this.channelId}, RemoteId: ${this.remoteChannelId})`;
}
}
exports.SshChannel = SshChannel;
SshChannel.sessionChannelType = 'session';
/**
* Default maximum packet size. Channel data payloads larger than the max packet size will
* be broken into chunks before sending. The actual `maxPacketSize` may be smaller (but
* never larger) than the default if requested by the other side.
*/
SshChannel.defaultMaxPacketSize = connectionMessages_1.ChannelOpenMessage.defaultMaxPacketSize;
/**
* Default maximum window size for received data. The other side will not send more data than
* the window size until it receives an acknowledgement that some of the data was received and
* processed by this side. A non-default `maxWindowSize` may be configured at the time of
* opening the channel.
*/
SshChannel.defaultMaxWindowSize = connectionMessages_1.ChannelOpenMessage.defaultMaxWindowSize;
//# sourceMappingURL=sshChannel.js.map