Path: blob/main/src/vs/platform/agentHost/node/tunnelHostMainService.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Tunnel } from '@microsoft/dev-tunnels-contracts';6import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management';7import { connect } from 'net';8import { hostname } from 'os';9import { Emitter, Event } from '../../../base/common/event.js';10import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js';11import { joinPath } from '../../../base/common/resources.js';12import { IConfigurationService } from '../../configuration/common/configuration.js';13import { INativeEnvironmentService } from '../../environment/common/environment.js';14import { ILogger, ILoggerService } from '../../log/common/log.js';15import { localize } from '../../../nls.js';16import { CONFIGURATION_KEY_HOST_NAME } from '../../remoteTunnel/common/remoteTunnel.js';17import {18ITunnelAgentHostHostingService,19PROTOCOL_VERSION_TAG_PREFIX,20TUNNEL_AGENT_HOST_PORT,21TUNNEL_HOST_LOG_ID,22TUNNEL_LAUNCHER_LABEL,23TUNNEL_MIN_PROTOCOL_VERSION,24type ITunnelHostInfo,25type TunnelHostStatus,26} from '../common/tunnelAgentHost.js';27import type { IAgentHostSocketInfo } from '../common/agentService.js';2829/** State of a currently hosted tunnel. */30interface IActiveTunnel {31readonly info: ITunnelHostInfo;32readonly tunnel: Tunnel;33readonly host: { dispose(): void };34readonly client: TunnelManagementHttpClient;35}3637export class TunnelHostMainService extends Disposable implements ITunnelAgentHostHostingService {38declare readonly _serviceBrand: undefined;3940private readonly _onDidChangeStatus = this._register(new Emitter<TunnelHostStatus>());41readonly onDidChangeStatus: Event<TunnelHostStatus> = this._onDidChangeStatus.event;4243private readonly _activeTunnel = this._register(new MutableDisposable());44private _active: IActiveTunnel | undefined;4546private readonly _logger: ILogger;4748constructor(49@IConfigurationService private readonly _configurationService: IConfigurationService,50@ILoggerService loggerService: ILoggerService,51@INativeEnvironmentService environmentService: INativeEnvironmentService,52) {53super();5455this._logger = this._register(loggerService.createLogger(56joinPath(environmentService.logsHome, `${TUNNEL_HOST_LOG_ID}.log`),57{ id: TUNNEL_HOST_LOG_ID, name: localize('tunnelHost.log', "Remote Connections") },58));59}6061async startHosting(token: string, authProvider: 'github' | 'microsoft', socketInfo: IAgentHostSocketInfo): Promise<ITunnelHostInfo> {62// Stop any existing tunnel first63if (this._active) {64await this.stopHosting();65}6667const tunnelName = this._getTunnelName();68this._logger.info(`Starting tunnel hosting as '${tunnelName}'...`);6970const client = await this._createManagementClient(token, authProvider);7172// Create tunnel with agent host port and appropriate labels73const protocolVersionTag = `${PROTOCOL_VERSION_TAG_PREFIX}${TUNNEL_MIN_PROTOCOL_VERSION}`;7475const newTunnel: Tunnel = {76ports: [{77portNumber: TUNNEL_AGENT_HOST_PORT,78protocol: 'https',79}],80labels: [TUNNEL_LAUNCHER_LABEL, tunnelName, protocolVersionTag],81};8283const tunnelRequestOptions = {84tokenScopes: ['host', 'connect'],85includePorts: true,86};8788const tunnel = await client.createOrUpdateTunnel(newTunnel, tunnelRequestOptions);89this._logger.info(`Tunnel created: ${tunnel.tunnelId} in cluster ${tunnel.clusterId}`);9091// Host the tunnel using TunnelRelayTunnelHost.92// We disable automatic local port forwarding so that we can capture93// the raw data stream and pipe it into the agent host process94// directly, without needing a physical TCP listener on port 31546.95const { TunnelRelayTunnelHost } = await import('@microsoft/dev-tunnels-connections');96const host = new TunnelRelayTunnelHost(client);97host.forwardConnectionsToLocalPorts = false;98host.trace = (_level: unknown, _eventId: unknown, msg: string) => {99this._logger.debug(`relay: ${msg}`);100};101102// When a remote client connects to the tunnel port, the SDK fires103// the forwardedPortConnecting event with the port number and an104// SshStream for the connection. We pipe that stream into a105// connection to the local agent host process.106const { socketPath } = socketInfo;107108host.forwardedPortConnecting((e: { port: number; stream: NodeJS.ReadWriteStream }) => {109if (e.port === TUNNEL_AGENT_HOST_PORT) {110this._logger.info(`Incoming connection on port ${TUNNEL_AGENT_HOST_PORT}, piping to local agent host`);111this._pipeToLocalAgentHost(e.stream, socketPath);112} else {113this._logger.warn(`Unexpected port ${e.port}, closing stream`);114e.stream.end?.();115}116});117118await host.connect(tunnel);119this._logger.info(`Tunnel relay host connected`);120121const domain = tunnel.ports?.[0]?.portForwardingUris?.[0] ?? `${tunnel.tunnelId}.${tunnel.clusterId}.devtunnels.ms`;122const info: ITunnelHostInfo = {123tunnelName,124tunnelId: tunnel.tunnelId!,125clusterId: tunnel.clusterId!,126domain: typeof domain === 'string' ? domain : `${tunnel.tunnelId}.${tunnel.clusterId}.devtunnels.ms`,127};128129this._active = { info, tunnel, host, client };130this._activeTunnel.value = {131dispose: () => {132host.dispose();133this._active = undefined;134}135};136137this._onDidChangeStatus.fire({ active: true, info });138return info;139}140141async stopHosting(): Promise<void> {142if (!this._active) {143return;144}145146const { tunnel, client } = this._active;147this._logger.info(`Stopping tunnel hosting...`);148149// Delete the tunnel from the management service before150// tearing down the local relay so we can retry on failure151try {152await client.deleteTunnel(tunnel);153this._logger.info(`Tunnel deleted`);154} catch (err) {155this._logger.warn(`Failed to delete tunnel`, err);156}157158this._activeTunnel.clear();159160this._onDidChangeStatus.fire({ active: false });161}162163async getStatus(): Promise<TunnelHostStatus> {164if (this._active) {165return { active: true, info: this._active.info };166}167return { active: false };168}169170/**171* Get the sanitized tunnel name from configuration or OS hostname.172*/173private _getTunnelName(): string {174let name = this._configurationService.getValue<string>(CONFIGURATION_KEY_HOST_NAME) || hostname();175name = name.replace(/^-+/g, '').replace(/[^\w-]/g, '').substring(0, 20);176return name || 'vscode';177}178179private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise<TunnelManagementHttpClient> {180const mgmt = await import('@microsoft/dev-tunnels-management');181const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`;182183return new mgmt.TunnelManagementHttpClient(184'vscode-sessions',185mgmt.ManagementApiVersions.Version20230927preview,186async () => authHeader,187);188}189190/**191* Pipe an incoming tunnel stream to the local agent host.192* The SshStream from the dev tunnels SDK is a Node.js duplex stream — we193* connect to the agent host's local socket and bidirectionally pipe data.194*/195private _pipeToLocalAgentHost(incomingStream: NodeJS.ReadWriteStream, socketPath: string): void {196const socket = connect(socketPath);197198socket.on('connect', () => {199this._logger.debug(`Connected to local agent host socket`);200incomingStream.pipe(socket);201socket.pipe(incomingStream);202});203204socket.on('error', (err) => {205this._logger.error(`Socket error`, err);206incomingStream.end?.();207});208209incomingStream.on('error', () => {210socket.destroy();211});212}213214override dispose(): void {215if (this._active) {216// Best-effort cleanup on dispose — don't await217this.stopHosting().catch(() => { /* ignore */ });218}219super.dispose();220}221}222223224