@microsoft/dev-tunnels-ssh
Version:
SSH library for Dev Tunnels
983 lines • 55.8 kB
JavaScript
"use strict";
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
Object.defineProperty(exports, "__esModule", { value: true });
exports.SshSession = void 0;
const trace_1 = require("./trace");
const buffer_1 = require("buffer");
const vscode_jsonrpc_1 = require("vscode-jsonrpc");
const sshSessionConfiguration_1 = require("./sshSessionConfiguration");
const sshChannel_1 = require("./sshChannel");
const sshVersionInfo_1 = require("./sshVersionInfo");
const sshProtocol_1 = require("./io/sshProtocol");
const keyExchangeService_1 = require("./services/keyExchangeService");
const serviceActivation_1 = require("./services/serviceActivation");
const connectionService_1 = require("./services/connectionService");
const authenticationService_1 = require("./services/authenticationService");
const sshMessage_1 = require("./messages/sshMessage");
const kexMessages_1 = require("./messages/kexMessages");
const connectionMessages_1 = require("./messages/connectionMessages");
const authenticationMessages_1 = require("./messages/authenticationMessages");
const transportMessages_1 = require("./messages/transportMessages");
const sessionMetrics_1 = require("./metrics/sessionMetrics");
const promiseCompletionSource_1 = require("./util/promiseCompletionSource");
const sshSessionClosedEventArgs_1 = require("./events/sshSessionClosedEventArgs");
const sshRequestEventArgs_1 = require("./events/sshRequestEventArgs");
const sshAlgorithms_1 = require("./algorithms/sshAlgorithms");
const cancellation_1 = require("./util/cancellation");
const errors_1 = require("./errors");
const semaphore_1 = require("./util/semaphore");
const pipeExtensions_1 = require("./pipeExtensions");
const queue_1 = require("./util/queue");
const progress_1 = require("./progress");
const sshReportProgressEventArgs_1 = require("./events/sshReportProgressEventArgs");
/**
* Allows SSH sessions to keep track of the session number for progress
* reporting purposes.
*/
let sessionCounter = 0;
/**
* Base class for an SSH server or client connection; coordinates high-level SSH
* protocol details and dispatches messages to registered internal services.
* Enables opening and accepting `SshChannel` instances.
*/
class SshSession {
get algorithms() {
return this.protocol ? this.protocol.algorithms : null;
}
/**
* Gets an object containing claims about the server or client on the
* other end of the session, or `null` if the session is not authenticated.
*
* This property is initially `null` for an unauthenticated session. On
* successful authentication, the session Authenticating event handler
* provides a Task that returns a principal that is stored here.
*/
get principal() {
return this.principalValue;
}
/* @internal */
set principal(value) {
this.principalValue = value;
}
constructor(config, isClientSession) {
this.config = config;
this.remoteVersion = null;
this.activatedServices = new Map();
this.connectionService = null;
this.requestHandlers = new queue_1.Queue();
this.blockedMessages = [];
this.blockedMessagesSemaphore = new semaphore_1.Semaphore(1);
this.connected = false;
this.disposed = false;
this.keepAliveResponseReceived = false;
this.keepAliveFailureCount = 0;
this.keepAliveSuccessCount = 0;
/**
* Gets an object that reports current and cumulative measurements about the session.
*/
this.metrics = new sessionMetrics_1.SessionMetrics();
/* @internal */
this.reconnecting = false;
this.sessionId = null;
this.principalValue = null;
this.authenticatingEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event that is raised when a client or server is requesting authentication.
*
* See `SshAuthenticationType` for a description of the different authentication
* methods and how they map to the event-args object.
*
* After validating the credentials, the event handler must set the
* `SshAuthenticatingEventArgs.authenticationPromise` property to a task that
* resolves to a principal object to indicate successful authentication. That principal will
* then be associated with the sesssion as the `principal` property.
*/
this.onAuthenticating = this.authenticatingEmitter.event;
this.closedEmitter = new vscode_jsonrpc_1.Emitter();
this.onClosed = this.closedEmitter.event;
this.disconnectedEmitter = new vscode_jsonrpc_1.Emitter();
this.onDisconnected = this.disconnectedEmitter.event;
this.serviceActivatedEmitter = new vscode_jsonrpc_1.Emitter();
this.onServiceActivated = this.serviceActivatedEmitter.event;
this.channelOpeningEmitter = new vscode_jsonrpc_1.Emitter();
this.onChannelOpening = this.channelOpeningEmitter.event;
this.requestEmitter = new vscode_jsonrpc_1.Emitter();
this.onRequest = this.requestEmitter.event;
this.reportProgressEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event that is raised to report connection progress.
*
* Apps may use this to provide progress feedback to users during the initial
* connection or reconnection process.
*
* See `Progress` for a description of the different progress events that can be reported.
*/
this.onReportProgress = this.reportProgressEmitter.event;
this.keepAliveFailedEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event that is raised when a keep-alive message response is not received.
*/
this.onKeepAliveFailed = this.keepAliveFailedEmitter.event;
this.keepAliveSucceededEmitter = new vscode_jsonrpc_1.Emitter();
/**
* Event that is raised when a keep-alive message response is received.
*/
this.onKeepAliveSucceeded = this.keepAliveSucceededEmitter.event;
/**
* Gets or sets a function that handles trace messages associated with the session.
*
* By default, no messages are traced. To enable tracing, set this property to a function
* that routes the message to console.log, a file, or anywhere else.
*
* @param level Level of message: error, warning, info, or verbose
* @param eventId Integer identifier of the event being traced.
* @param msg Message (non-localized) describing the event.
*/
this.trace = (level, eventId, msg, err) => { };
this.isClientSession = isClientSession;
this.sessionNumber = ++sessionCounter;
if (!config)
throw new TypeError('Session configuration is required.');
if (!config.keyExchangeAlgorithms.find((a) => !!a)) {
if (config.encryptionAlgorithms.length > 0 &&
config.encryptionAlgorithms.indexOf(null) < 0) {
throw new Error('Encryption requires a key-exchange algorithm to be configured.');
}
else if (config.hmacAlgorithms.length > 0 && config.hmacAlgorithms.indexOf(null) < 0) {
throw new Error('HMAC requires a key-exchange algorithm to be configured.');
}
else if (config.publicKeyAlgorithms.length > 0 &&
config.publicKeyAlgorithms.indexOf(null) < 0) {
throw new Error('Host authentication requires a key-exchange algorithm to be configured.');
}
// No key exchange, no encryption, no HMAC.
this.kexService = null;
this.activateService(connectionService_1.ConnectionService);
}
else {
this.kexService = new keyExchangeService_1.KeyExchangeService(this);
}
config.onConfigurationChanged(() => {
const protocol = this.protocol;
if (protocol) {
protocol.traceChannelData = config.traceChannelData;
}
// Restart keep-alive timer if timeout changed
if (this.connected && !this.disposed) {
this.startKeepAliveTimer();
}
});
}
get isConnected() {
return this.connected;
}
get isClosed() {
return this.disposed;
}
get services() {
return [...this.activatedServices.values()];
}
get channels() {
var _a, _b;
return (_b = (_a = this.connectionService) === null || _a === void 0 ? void 0 : _a.channels) !== null && _b !== void 0 ? _b : [];
}
get protocolExtensions() {
var _a;
return ((_a = this.protocol) === null || _a === void 0 ? void 0 : _a.extensions) || null;
}
/**
* Gets an activated service instance by type.
*
* @returns The service instance, or `null` if the service has not been activated.
*/
getService(serviceType) {
const service = this.activatedServices.get(serviceType);
return service ? service : null;
}
/* @internal */
activateService(serviceTypeOrName) {
let serviceType;
if (typeof serviceTypeOrName === 'function') {
serviceType = serviceTypeOrName;
}
else {
const serviceName = serviceTypeOrName;
serviceType = (0, serviceActivation_1.findService)(this.config.services, (a) => a.serviceRequest === serviceName);
if (!serviceType) {
return null;
}
}
let activatedService = this.activatedServices.get(serviceType);
if (!activatedService) {
if (!this.config.services.has(serviceType)) {
throw new Error(`Service type not configured: ${serviceType.name}`);
}
const serviceConfig = this.config.services.get(serviceType);
activatedService = new serviceType(this, serviceConfig);
// This service is maintained in a separate member because it is accessed frequently.
if (serviceType === connectionService_1.ConnectionService) {
this.connectionService = activatedService;
}
this.activatedServices.set(serviceType, activatedService);
this.serviceActivatedEmitter.fire(activatedService);
}
return activatedService;
}
async connect(stream, cancellation) {
if (!stream)
throw new TypeError('A session stream is required.');
if (this.disposed)
throw new errors_1.ObjectDisposedError(this);
if (!this.connectPromise) {
this.connectPromise = this.doConnect(stream, cancellation);
}
await this.connectPromise;
}
async doConnect(stream, cancellation) {
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionConnecting, `${this} ${this.reconnecting ? 're' : ''}connecting...`);
this.raiseReportProgress(progress_1.Progress.OpeningSshSessionConnection);
this.protocol = new sshProtocol_1.SshProtocol(stream, this.config, this.metrics, this.trace);
this.protocol.kexService = this.kexService;
this.raiseReportProgress(progress_1.Progress.StartingProtocolVersionExchange);
await this.exchangeVersions(cancellation);
if (this.kexService) {
await this.encrypt(cancellation);
}
else {
// When there's no key-exchange service configured, send a key-exchange init message
// that specifies "none" for all algorithms.
await this.sendMessage(kexMessages_1.KeyExchangeInitMessage.none, cancellation);
// When encrypting, the key-exchange step will wait on the version-exchange.
// When not encrypting, it must be directly awaited.
await (0, cancellation_1.withCancellation)(this.versionExchangePromise, cancellation);
this.raiseReportProgress(progress_1.Progress.CompletedProtocolVersionExchange);
this.connected = true;
}
this.processMessages().catch((e) => {
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.unknownError, `Unhandled error processing messages: ${e.message}`, e);
});
this.startKeepAliveTimer();
this.raiseReportProgress(progress_1.Progress.OpenedSshSessionConnection);
}
async exchangeVersions(cancellation) {
const writePromise = this.protocol.writeProtocolVersion(SshSession.localVersion.toString(), cancellation);
const readPromise = this.protocol.readProtocolVersion(cancellation);
// Don't wait for and verify the other side's version info yet.
// Instead save a promise that can be awaited later.
this.versionExchangePromise = readPromise.then(async (remoteVersion) => {
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.protocolVersion, `Local version: ${SshSession.localVersion}, remote version: ${remoteVersion}`);
let errorMessage;
const remoteVersionInfo = sshVersionInfo_1.SshVersionInfo.tryParse(remoteVersion);
if (remoteVersionInfo) {
this.remoteVersion = remoteVersionInfo;
if (remoteVersionInfo.protocolVersion === '2.0') {
return;
}
errorMessage =
`Remote SSH version ${this.remoteVersion} is not supported. ` +
'This library only supports SSH v2.0.';
}
else {
errorMessage = `Could not parse remote SSH version ${remoteVersion}`;
}
await this.close(transportMessages_1.SshDisconnectReason.protocolVersionNotSupported, errorMessage, new Error(errorMessage));
});
await writePromise;
}
async encrypt(cancellation) {
var _a, _b;
const protocol = this.protocol;
if (!protocol)
throw new errors_1.ObjectDisposedError(this);
await protocol.considerReExchange(true, cancellation);
// Ensure the protocol version has been received before receiving any messages.
await (0, cancellation_1.withCancellation)(this.versionExchangePromise, cancellation);
this.raiseReportProgress(progress_1.Progress.CompletedProtocolVersionExchange);
this.connected = true;
this.raiseReportProgress(progress_1.Progress.StartingKeyExchange);
let message = null;
while (!this.isClosed &&
!((_a = this.protocol) === null || _a === void 0 ? void 0 : _a.algorithms) &&
!(message instanceof transportMessages_1.DisconnectMessage)) {
message = await protocol.receiveMessage(cancellation);
if (!message) {
break;
}
await this.handleMessage(message, cancellation);
}
this.raiseReportProgress(progress_1.Progress.CompletedKeyExchange);
if (!((_b = this.protocol) === null || _b === void 0 ? void 0 : _b.algorithms)) {
throw new errors_1.SshConnectionError('Session closed while encrypting.', transportMessages_1.SshDisconnectReason.connectionLost);
}
else if (this.protocol.algorithms.cipher) {
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionEncrypted, `${this} encrypted.`);
}
}
/* @internal */
async processMessages() {
var _a;
this.connected = true;
while (!this.disposed) {
const protocol = this.protocol;
if (!protocol) {
break;
}
let message = null;
try {
message = await protocol.receiveMessage();
}
catch (e) {
if (!(e instanceof Error))
throw e;
let reason = transportMessages_1.SshDisconnectReason.protocolError;
if (e instanceof errors_1.SshConnectionError) {
reason = (_a = e.reason) !== null && _a !== void 0 ? _a : reason;
}
else {
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.receiveMessageFailed, `Error receiving message: ${e.message}`, e);
}
await this.close(reason, e.message, e);
}
if (!message) {
await this.close(transportMessages_1.SshDisconnectReason.connectionLost, 'Connection lost.');
break;
}
let messageSuccess = false;
try {
await this.handleMessage(message);
messageSuccess = true;
}
catch (e) {
if (!(e instanceof Error))
throw e;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.handleMessageFailed, `Error handling ${message}: ${e.message}`, e);
await this.close(transportMessages_1.SshDisconnectReason.protocolError, e.message, e);
}
if (messageSuccess) {
this.keepAliveResponseReceived = true;
this.startKeepAliveTimer();
}
}
this.connected = false;
}
/**
* Checks whether the session is in a state that allows requests, such as session requests
* and open-channel requests.
*
* A session with disabled crypto (no key-exchange service) always allows requests. A
* session with enabled crypto does not allow requests until the first key-exchange has
* completed (algorithms are negotiated). If the negotiated algorithms enabled encryption,
* then the session must be authenticated (have a principal) before allowing requests.
*/
/* @internal */
get canAcceptRequests() {
var _a;
return (!this.kexService ||
(!!((_a = this.protocol) === null || _a === void 0 ? void 0 : _a.algorithms) && (!this.protocol.algorithms.cipher || !!this.principal)));
}
async sendMessage(message, cancellation) {
var _a, _b;
if (!message)
throw new TypeError('Message expected.');
if (cancellation && cancellation.isCancellationRequested)
throw new cancellation_1.CancellationError();
const protocol = this.protocol;
if (!protocol || this.disposed) {
throw new errors_1.ObjectDisposedError(this);
}
// Delay sending messages if in the middle of a key (re-)exchange.
if (this.kexService &&
this.kexService.exchanging &&
message.messageType > 4 &&
(message.messageType < 20 || message.messageType > 49)) {
this.blockedMessages.push(message);
return;
}
await this.blockedMessagesSemaphore.wait(cancellation);
let result;
try {
result = await protocol.sendMessage(message, cancellation);
this.blockedMessagesSemaphore.release();
}
catch (e) {
this.blockedMessagesSemaphore.release();
if (e instanceof errors_1.SshConnectionError) {
const ce = e;
if (ce.reason === transportMessages_1.SshDisconnectReason.connectionLost &&
((_a = this.protocolExtensions) === null || _a === void 0 ? void 0 : _a.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect))) {
// Connection-lost error when reconnect is enabled. Don't throw an error;
// the message will remain in the reconnect message cache and will be re-sent
// upon reconnection.
return;
}
}
if (!(e instanceof Error))
throw e;
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.sendMessageFailed, `Error sending ${message}: ${e.message}`, e);
throw e;
}
if (!result) {
// Sending failed due to a closed stream, but don't throw when reconnect is enabled.
// In that case the sent message is buffered and will be re-sent after reconnecting.
if (!((_b = this.protocolExtensions) === null || _b === void 0 ? void 0 : _b.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect))) {
throw new errors_1.SshConnectionError('Session disconnected.', transportMessages_1.SshDisconnectReason.connectionLost);
}
}
}
/**
* Handles an incoming message. Can be overridden by subclasses to handle additional
* message types that are registered via `SshSessionConfiguration.messages`.
*/
handleMessage(message, cancellation) {
var _a;
if (message instanceof connectionMessages_1.ConnectionMessage && this.connectionService) {
return this.connectionService.handleMessage(message, cancellation);
}
else if (message instanceof kexMessages_1.NewKeysMessage) {
return this.handleNewKeysMessage(message, cancellation);
}
else if (message instanceof kexMessages_1.KeyExchangeMessage) {
return this.handleKeyExchangeMessage(message, cancellation);
}
else if (message instanceof authenticationMessages_1.AuthenticationMessage) {
return (_a = this.getService(authenticationService_1.AuthenticationService)) === null || _a === void 0 ? void 0 : _a.handleMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.ServiceRequestMessage) {
return this.handleServiceRequestMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.ServiceAcceptMessage) {
return this.handleServiceAcceptMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.SessionRequestMessage) {
return this.handleRequestMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.SessionRequestSuccessMessage) {
return this.handleRequestSuccessMessage(message);
}
else if (message instanceof transportMessages_1.SessionRequestFailureMessage) {
return this.handleRequestFailureMessage(message);
}
else if (message instanceof transportMessages_1.ExtensionInfoMessage) {
return this.handleExtensionInfoMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.DisconnectMessage) {
return this.handleDisconnectMessage(message);
}
else if (message instanceof transportMessages_1.UnimplementedMessage) {
return this.handleUnimplementedMessage(message, cancellation);
}
else if (message instanceof transportMessages_1.DebugMessage) {
return this.handleDebugMessage(message);
}
else if (message instanceof transportMessages_1.IgnoreMessage) {
// Do nothing for ignore message
return;
}
else if (message instanceof sshMessage_1.SshMessage) {
throw new Error(`Unhandled message type: ${message.constructor.name}`);
}
else {
throw new TypeError('Message argument was ' + (message ? 'invalid type.' : 'null.'));
}
}
/* @internal */
async handleRequestMessage(message, cancellation) {
var _a;
let result = false;
let response = null;
if (message.requestType === "initial-channel-request@microsoft.com" /* ExtensionRequestTypes.initialChannelRequest */ &&
this.config.protocolExtensions.includes(sshSessionConfiguration_1.SshProtocolExtensionNames.openChannelRequest)) {
const sessionChannelRequest = message.convertTo(new transportMessages_1.SessionChannelRequestMessage());
const remoteChannelId = sessionChannelRequest.senderChannel;
const channel = this.channels.find((c) => c.remoteChannelId === remoteChannelId);
if (channel && sessionChannelRequest.request) {
sessionChannelRequest.request.wantReply = false; // Avoid redundant reply
result = await channel.handleRequest(sessionChannelRequest.request, cancellation);
}
}
else if (message.requestType === "enable-session-reconnect@microsoft.com" /* ExtensionRequestTypes.enableSessionReconnect */ &&
((_a = this.config.protocolExtensions) === null || _a === void 0 ? void 0 : _a.includes(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect))) {
if (!this.protocol.incomingMessagesHaveReconnectInfo) {
// Starting immediately after this message, all incoming messages include
// an extra field or two after the payload.
this.protocol.incomingMessagesHaveReconnectInfo = true;
this.protocol.incomingMessagesHaveLatencyInfo = this.protocol.extensions.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionLatency);
result = true;
}
}
else if (message.requestType === "keepalive@openssh.com" /* ExtensionRequestTypes.keepAliveRequest */) {
// Handle keep-alive request - always succeed and trace
this.trace(trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.keepAliveRequestReceived, `${this} Keep-alive request received`);
result = true;
}
else if (!this.canAcceptRequests) {
this.trace(trace_1.TraceLevel.Warning, trace_1.SshTraceEventIds.sessionRequestFailed, 'Session request blocked because the session is not yet authenticated.');
result = false;
}
else {
const args = new sshRequestEventArgs_1.SshRequestEventArgs(message.requestType || '', message, this.principal, cancellation);
const serviceType = (0, serviceActivation_1.findService)(this.config.services, (a) => a.sessionRequest === message.requestType);
if (serviceType) {
// A service was configured for activation via this session request type.
const service = this.activateService(serviceType);
// `onSessionRequest` should really be 'protected internal'.
await service.onSessionRequest(args, cancellation);
}
else {
// Raise a request event to let an event listener handle this request.
this.raiseSessionRequest(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;
result = response instanceof transportMessages_1.SessionRequestSuccessMessage;
}
else {
result = args.isAuthorized || false;
}
}
if (message.wantReply) {
if (result) {
if (!(response instanceof transportMessages_1.SessionRequestSuccessMessage)) {
response = new transportMessages_1.SessionRequestSuccessMessage();
}
}
else {
if (!(response instanceof transportMessages_1.SessionRequestFailureMessage)) {
response = new transportMessages_1.SessionRequestFailureMessage();
}
}
await this.sendMessage(response, cancellation);
}
}
/* @internal */
raiseReportProgress(progress) {
const args = new sshReportProgressEventArgs_1.SshReportProgressEventArgs(progress, this.sessionNumber);
this.reportProgressEmitter.fire(args);
}
/* @internal */
raiseSessionRequest(args) {
this.requestEmitter.fire(args);
}
/* @internal */
async handleServiceRequestMessage(message, cancellation) {
// Do nothing. Subclasses may override.
}
/* @internal */
async handleServiceAcceptMessage(message, cancellation) {
// Do nothing. Subclasses may override.
}
async handleKeyExchangeMessage(message, cancellation) {
if (this.kexService) {
await this.kexService.handleMessage(message, cancellation);
}
else if (!(message instanceof kexMessages_1.KeyExchangeInitMessage && message.allowsNone)) {
// The other side required some security, but it's not configured here.
await this.close(transportMessages_1.SshDisconnectReason.keyExchangeFailed, 'Encryption is disabled.');
}
}
/* @internal */
async handleNewKeysMessage(message, cancellation) {
var _a;
try {
await this.blockedMessagesSemaphore.wait(cancellation);
await this.protocol.handleNewKeys(cancellation);
if ((_a = this.algorithms) === null || _a === void 0 ? void 0 : _a.isExtensionInfoRequested) {
await this.sendExtensionInfo(cancellation);
}
try {
// Send messages that were blocked during key exchange.
while (this.blockedMessages.length > 0) {
const blockedMessage = this.blockedMessages.shift();
if (!this.protocol)
throw new errors_1.ObjectDisposedError(this);
await this.protocol.sendMessage(blockedMessage, cancellation);
}
}
catch (e) {
if (!(e instanceof Error))
throw e;
await this.close(transportMessages_1.SshDisconnectReason.protocolError, undefined, e);
}
}
finally {
this.blockedMessagesSemaphore.release();
}
}
async handleUnimplementedMessage(message, cancellation) {
if (message.unimplementedMessageType !== undefined) {
// Received a message type that is unimplemented by this side.
// Send a reply to inform the other side.
await this.sendMessage(message, cancellation);
}
else {
// This is a reply indicating this side previously sent a message type
// that is not implemented by the other side. It has already been traced.
}
}
handleDebugMessage(message) {
if (message.message) {
this.trace(message.alwaysDisplay ? trace_1.TraceLevel.Info : trace_1.TraceLevel.Verbose, trace_1.SshTraceEventIds.debugMessage, message.message);
}
}
startKeepAliveTimer() {
// Stop existing timer
if (this.keepAliveTimer) {
clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = undefined;
}
// Don't start timer if keep-alive is disabled or session is disposed/not connected
const timeoutInSeconds = this.config.keepAliveTimeoutInSeconds;
if (!timeoutInSeconds || timeoutInSeconds <= 0 || this.disposed || !this.connected) {
return;
}
// Start new timer
this.keepAliveTimer = setTimeout(async () => {
try {
await this.onKeepAliveTimeout();
}
catch (error) {
// Log error but don't let it crash the session
this.trace(trace_1.TraceLevel.Error, trace_1.SshTraceEventIds.unknownError, `Error in keep-alive timeout: ${error instanceof Error ? error.message : error}`, error instanceof Error ? error : undefined);
}
}, timeoutInSeconds * 1000);
}
async onKeepAliveTimeout() {
// Don't send keep-alive if session is disposed or not connected
if (this.disposed || !this.connected) {
// Clear the timer to prevent further timeouts
if (this.keepAliveTimer) {
clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = undefined;
}
return;
}
// Check if we can send keep-alive requests
if (!this.canAcceptRequests) {
// Schedule next timeout
this.startKeepAliveTimer();
return;
}
// Check if we received a response to the previous keep-alive request
if (!this.keepAliveResponseReceived) {
// No response received - this is a failure
this.keepAliveSuccessCount = 0;
this.keepAliveFailureCount++;
this.trace(trace_1.TraceLevel.Warning, trace_1.SshTraceEventIds.keepAliveResponseNotReceived, `${this} Keep-alive response not received (failure #${this.keepAliveFailureCount})`);
this.keepAliveFailedEmitter.fire(this.keepAliveFailureCount);
}
else {
// Response was received - success
this.keepAliveFailureCount = 0;
this.keepAliveSuccessCount++;
this.keepAliveSucceededEmitter.fire(this.keepAliveSuccessCount);
}
// Reset response flag for next cycle
this.keepAliveResponseReceived = false;
// Send keep-alive request
try {
const request = new transportMessages_1.SessionRequestMessage();
request.requestType = "keepalive@openssh.com" /* ExtensionRequestTypes.keepAliveRequest */;
request.wantReply = true; // Request reply to detect if connection is alive
await this.sendMessage(request);
}
catch (error) {
this.keepAliveSuccessCount = 0;
this.keepAliveFailureCount++;
this.keepAliveFailedEmitter.fire(this.keepAliveFailureCount);
}
// Schedule next timeout only if still connected and not disposed
if (!this.disposed && this.connected) {
this.startKeepAliveTimer();
}
}
/* @internal */
async raiseAuthenticatingEvent(args) {
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionAuthenticating, `${this} Authenticating(${args})`);
this.raiseReportProgress(progress_1.Progress.StartingSessionAuthentication);
this.authenticatingEmitter.fire(args);
let authPromise = args.authenticationPromise;
if (!authPromise) {
authPromise = Promise.resolve(null);
}
const principal = await authPromise;
this.raiseReportProgress(progress_1.Progress.CompletedSessionAuthentication);
return principal;
}
/**
* Sends a session request and waits for a response.
*
* Note if `wantReply` is `false`, this method returns `true` immediately after sending
* the request, without waiting for a response.
*
* @returns The authorization status of the response; if `false`, the other side denied the
* request.
*/
async request(request, cancellation) {
if (!request)
throw new TypeError('Request is required.');
if (!request.wantReply) {
await this.sendMessage(request, cancellation);
return true;
}
const response = await this.requestResponse(request, transportMessages_1.SessionRequestSuccessMessage, transportMessages_1.SessionRequestFailureMessage, cancellation);
return response instanceof transportMessages_1.SessionRequestSuccessMessage;
}
/**
* Sends a session request and waits for a specific type of success or failure message.
*
* @returns The success or failure response message.
*/
async requestResponse(request, successType, failureType, cancellation) {
if (!request)
throw new TypeError('Request is required.');
if (!successType)
throw new TypeError('Success response type is required.');
if (!failureType)
throw new TypeError('Failure response type is required.');
request.wantReply = true;
const requestHandler = (err, result) => {
var _a, _b;
if (err) {
requestCompletionSource.reject(err);
}
else if (requestHandler.isCancelled) {
// The completion source was already rejected with a cancellation error.
return;
}
else if (result instanceof transportMessages_1.SessionRequestFailureMessage) {
const failure = (_a = result === null || result === void 0 ? void 0 : result.convertTo(new failureType(), true)) !== null && _a !== void 0 ? _a : null;
requestCompletionSource.resolve(failure);
}
else if (result instanceof transportMessages_1.SessionRequestSuccessMessage) {
// Make a copy of the response message because the continuation may be
// asynchronous; meanwhile the receive buffer will be re-used.
const success = (_b = result === null || result === void 0 ? void 0 : result.convertTo(new successType(), true)) !== null && _b !== void 0 ? _b : null;
requestCompletionSource.resolve(success);
}
else {
requestCompletionSource.reject(new Error('Unknown response message type.'));
}
};
const requestCompletionSource = new promiseCompletionSource_1.PromiseCompletionSource();
if (cancellation) {
if (cancellation.isCancellationRequested)
throw new cancellation_1.CancellationError();
cancellation.onCancellationRequested(() => {
requestHandler.isCancelled = true;
requestCompletionSource.reject(new cancellation_1.CancellationError());
});
}
this.requestHandlers.enqueue(requestHandler);
await this.sendMessage(request, cancellation);
return await requestCompletionSource.promise;
}
handleRequestSuccessMessage(message) {
this.invokeRequestHandler(message, undefined, undefined);
}
handleRequestFailureMessage(message) {
this.invokeRequestHandler(undefined, message, undefined);
}
invokeRequestHandler(success, failure, error) {
let requestHandler;
while ((requestHandler = this.requestHandlers.dequeue())) {
requestHandler(error, success !== null && success !== void 0 ? success : failure);
// An error is provided if the session is disposing. In that case,
// all pending requests should fail with that error.
if (!error) {
break;
}
}
}
async acceptChannel(channelTypeOrCancellation, cancellation) {
const channelType = typeof channelTypeOrCancellation === 'string' ? channelTypeOrCancellation : undefined;
if (!cancellation && typeof channelTypeOrCancellation === 'object')
cancellation = channelTypeOrCancellation;
this.activateService(connectionService_1.ConnectionService);
// Prepare to accept the channel before connecting. This ensures that if the channel
// open request comes in immediately after connecting then the channel won't be missed
// in case of a task scheduling delay.
const acceptPromise = this.connectionService.acceptChannel(channelType || sshChannel_1.SshChannel.sessionChannelType, cancellation);
return await acceptPromise;
}
async openChannel(channelTypeOrOpenMessageOrCancellation, initialRequestOrCancellation, cancellation) {
let openMessage;
if (typeof channelTypeOrOpenMessageOrCancellation === 'string' ||
channelTypeOrOpenMessageOrCancellation === null) {
openMessage = new connectionMessages_1.ChannelOpenMessage();
openMessage.channelType =
channelTypeOrOpenMessageOrCancellation !== null && channelTypeOrOpenMessageOrCancellation !== void 0 ? channelTypeOrOpenMessageOrCancellation : sshChannel_1.SshChannel.sessionChannelType;
}
else if (channelTypeOrOpenMessageOrCancellation instanceof connectionMessages_1.ChannelOpenMessage) {
openMessage = channelTypeOrOpenMessageOrCancellation;
}
else {
openMessage = new connectionMessages_1.ChannelOpenMessage();
openMessage.channelType = sshChannel_1.SshChannel.sessionChannelType;
cancellation = channelTypeOrOpenMessageOrCancellation;
}
if (initialRequestOrCancellation instanceof connectionMessages_1.ChannelRequestMessage) {
return await this.openChannelWithInitialRequest(openMessage, initialRequestOrCancellation, cancellation);
}
else if (!cancellation && initialRequestOrCancellation !== null) {
cancellation = initialRequestOrCancellation;
}
this.activateService(connectionService_1.ConnectionService);
const completionSource = new promiseCompletionSource_1.PromiseCompletionSource();
await this.connectionService.openChannel(openMessage, completionSource, cancellation);
return await completionSource.promise;
}
async openChannelWithInitialRequest(openMessage, initialRequest, cancellation) {
var _a;
this.activateService(connectionService_1.ConnectionService);
const completionSource = new promiseCompletionSource_1.PromiseCompletionSource();
const channelId = await this.connectionService.openChannel(openMessage, completionSource, cancellation);
if (cancellation) {
if (cancellation.isCancellationRequested)
throw new cancellation_1.CancellationError();
cancellation.onCancellationRequested(() => completionSource.reject(new cancellation_1.CancellationError()));
}
let channel;
let requestResult;
const isExtensionSupported = this.config.protocolExtensions.includes(sshSessionConfiguration_1.SshProtocolExtensionNames.openChannelRequest) &&
((_a = this.protocolExtensions) === null || _a === void 0 ? void 0 : _a.has(sshSessionConfiguration_1.SshProtocolExtensionNames.openChannelRequest));
if (isExtensionSupported === false) {
// The local or remote side definitely doesn't support this extension. Just send a
// normal channel request after waiting for the channel open confirmation.
channel = await completionSource.promise;
requestResult = await channel.request(initialRequest, cancellation);
}
else {
// The remote side does or might support this extension. If uncertain then a reply
// is required.
const wantReply = initialRequest.wantReply || isExtensionSupported === undefined;
// Send the initial channel request message BEFORE waiting for the
// channel open confirmation.
const sessionRequest = new transportMessages_1.SessionChannelRequestMessage();
sessionRequest.requestType = "initial-channel-request@microsoft.com" /* ExtensionRequestTypes.initialChannelRequest */;
sessionRequest.senderChannel = channelId;
sessionRequest.request = initialRequest;
sessionRequest.wantReply = wantReply;
const requestPromise = this.request(sessionRequest, cancellation);
// Wait for the channel open confirmation.
channel = await completionSource.promise;
if (!wantReply) {
requestResult = true;
}
else {
// Wait for the response to the initial channel request.
requestResult = await requestPromise;
if (!requestResult && isExtensionSupported === undefined) {
// The initial request failed. This could be because the other side doesn't
// support the initial-request extension or because the request was denied.
// Try sending the request again as a regular channel request.
requestResult = await channel.request(initialRequest);
}
}
}
if (!requestResult) {
// The regular request still failed, so close the channel and throw.
await channel.close();
throw new Error('The initial channel request was denied.');
}
return channel;
}
/* @internal */
async handleChannelOpening(args, cancellation, resolveService = true) {
if (resolveService) {
const serviceType = (0, serviceActivation_1.findService)(this.config.services, (a) => a.channelType === args.channel.channelType && !a.channelRequest);
if (serviceType) {
// A service was configured for activation via this channel type.
const service = this.activateService(serviceType);
// `onChannelOpening` should really be 'protected internal'.
await service.onChannelOpening(args, cancellation);
return;
}
}
args.cancellation = cancellation !== null && cancellation !== void 0 ? cancellation : vscode_jsonrpc_1.CancellationToken.None;
this.channelOpeningEmitter.fire(args);
}
/* @internal */
async sendExtensionInfo(cancellation) {
if (!this.protocol)
return;
const message = new transportMessages_1.ExtensionInfoMessage();
message.extensionInfo = {};
for (const extensionName of this.config.protocolExtensions) {
if (extensionName === sshSessionConfiguration_1.SshProtocolExtensionNames.serverSignatureAlgorithms) {
// Send the list of enabled host key signature algorithms.
const publicKeyAlgorithms = Array.from(new Set((0, sshAlgorithms_1.algorithmNames)(this.config.publicKeyAlgorithms))).join(',');
message.extensionInfo[extensionName] = publicKeyAlgorithms;
}
else {
message.extensionInfo[extensionName] = '';
}
}
await this.protocol.sendMessage(message, cancellation);
}
async handleExtensionInfoMessage(message, cancellation) {
if (!this.protocol) {
return;
}
this.protocol.extensions = new Map();
const proposedExtensions = message.extensionInfo;
if (!proposedExtensions) {
return;
}
for (const extensionName of this.config.protocolExtensions) {
const proposedExtension = message.extensionInfo[extensionName];
if (typeof proposedExtension === 'string') {
this.protocol.extensions.set(extensionName, proposedExtension);
}
}
if (this.protocol.extensions.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect)) {
await this.enableReconnect(cancellation);
}
}
async close(reason, message, error) {
var _a, _b, _c;
if (this.disposed || !this.connected) {
return;
}
this.connected = false;
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionClosing, `${this} Close(${transportMessages_1.SshDisconnectReason[reason]}, "${message || ''}")`);
if (reason !== transportMessages_1.SshDisconnectReason.connectionLost) {
try {
const disconnectMessage = new transportMessages_1.DisconnectMessage();
disconnectMessage.reasonCode = reason;
disconnectMessage.description = message || '';
await ((_a = this.protocol) === null || _a === void 0 ? void 0 : _a.sendMessage(disconnectMessage));
}
catch (e) {
// Already disconnected.
}
}
else if (this.handleDisconnected()) {
// Keep the session in a disconnected (but not closed) state.
(_b = this.protocol) === null || _b === void 0 ? void 0 : _b.dispose();
this.trace(trace_1.TraceLevel.Info, trace_1.SshTraceEventIds.sessionDisconnected, `${this} disconnected.`);
this.disconnectedEmitter.fire();
return;
}
this.disposed = true;
this.closedError = error;
error = error !== null && error !== void 0 ? error : new errors_1.SshConnectionError(message, reason);
if (error) {
(_c = this.connectionService) === null || _c === void 0 ? void 0 : _c.close(error);
}
this.closedEmitter.fire(new sshSessionClosedEventArgs_1.SshSessionClosedEventArgs(reason, message || 'Disconnected.', error));
this.dispose();
}
/* @internal */
handleDisconnected() {
var _a, _b;
this.connectPromise = undefined;
(_a = this.kexService) === null || _a === void 0 ? void 0 : _a.abortKeyExchange();
if (!((_b = this.protocolExtensions) === null || _b === void 0 ? void 0 : _b.has(sshSessionConfiguration_1.SshProtocolExtensionNames.sessionReconnect))) {
return false;
}
return true;
}