Path: blob/main/src/vs/platform/agentHost/node/tunnelAgentHostService.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 { createHash } from 'crypto';8import type WebSocket from 'ws';9import { Emitter, Event } from '../../../base/common/event.js';10import { Disposable } from '../../../base/common/lifecycle.js';11import { generateUuid } from '../../../base/common/uuid.js';12import { ILogService } from '../../log/common/log.js';13import {14ITunnelAgentHostMainService,15TUNNEL_ADDRESS_PREFIX,16TUNNEL_AGENT_HOST_PORT,17TUNNEL_LAUNCHER_LABEL,18TUNNEL_MIN_PROTOCOL_VERSION,19TunnelTags,20type ITunnelConnectResult,21type ITunnelInfo,22type ITunnelRelayMessage,23} from '../common/tunnelAgentHost.js';2425const LOG_PREFIX = '[TunnelAgentHost]';2627/**28* Derive a connection token from a tunnel ID using the same convention29* as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs).30*/31function deriveConnectionToken(tunnelId: string): string {32const hash = createHash('sha256');33hash.update(tunnelId);34let result = hash.digest('base64url');35if (result.startsWith('-')) {36result = 'a' + result;37}38return result;39}4041/** State for a single active tunnel relay connection. */42class TunnelConnection extends Disposable {43private readonly _onDidClose = this._register(new Emitter<void>());44readonly onDidClose = this._onDidClose.event;4546private _closed = false;4748constructor(49readonly connectionId: string,50readonly address: string,51readonly name: string,52readonly connectionToken: string,53private readonly _relay: { send: (data: string) => void; close: () => void },54private readonly _relayClient: { dispose(): void },55) {56super();57}5859override dispose(): void {60if (!this._closed) {61this._closed = true;62this._relay.close();63this._relayClient.dispose();64this._onDidClose.fire();65}66super.dispose();67}6869relaySend(data: string): void {70this._relay.send(data);71}72}7374export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService {75declare readonly _serviceBrand: undefined;7677private readonly _onDidRelayMessage = this._register(new Emitter<ITunnelRelayMessage>());78readonly onDidRelayMessage: Event<ITunnelRelayMessage> = this._onDidRelayMessage.event;7980private readonly _onDidRelayClose = this._register(new Emitter<string>());81readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;8283private readonly _connections = new Map<string, TunnelConnection>();8485constructor(86@ILogService private readonly _logService: ILogService,87) {88super();89}9091async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise<ITunnelInfo[]> {92const client = await this._createManagementClient(token, authProvider);93const results: ITunnelInfo[] = [];94const seen = new Set<string>();9596try {97// Enumerate all tunnels with the vscode-server-launcher label98const tunnels = await client.listTunnels(undefined, undefined, {99labels: [TUNNEL_LAUNCHER_LABEL],100requireAllLabels: true,101includePorts: true,102tokenScopes: ['connect'],103});104105for (const tunnel of tunnels) {106const info = this._parseTunnelInfo(tunnel);107if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {108results.push(info);109seen.add(info.tunnelId);110}111}112} catch (err) {113this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err);114}115116// Look up additional tunnels by name117if (additionalTunnelNames) {118for (const tunnelName of additionalTunnelNames) {119try {120const [tunnel] = await client.listTunnels(undefined, undefined, {121labels: [tunnelName, TUNNEL_LAUNCHER_LABEL],122requireAllLabels: true,123includePorts: true,124tokenScopes: ['connect'],125limit: 1,126});127if (tunnel) {128const info = this._parseTunnelInfo(tunnel);129if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) {130results.push(info);131seen.add(info.tunnelId);132}133}134} catch (err) {135this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err);136}137}138}139140this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);141return results;142}143144async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise<ITunnelConnectResult> {145// Tear down any existing connection to this tunnel first.146// Each connect() call creates a fresh relay with its own protocol147// session, so the old one must be closed to avoid conflicts.148for (const [id, conn] of this._connections) {149if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) {150this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`);151this._connections.delete(id);152conn.dispose();153break;154}155}156157const client = await this._createManagementClient(token, authProvider);158const connectionId = generateUuid();159const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;160161this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`);162163// Get the full tunnel with endpoints and access tokens164const tunnel: Tunnel = { tunnelId, clusterId };165const resolved = await client.getTunnel(tunnel, {166includePorts: true,167tokenScopes: ['connect'],168});169170if (!resolved) {171throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`);172}173174// Connect to the tunnel relay175const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections');176const relayClient = new TunnelRelayTunnelClient(client);177relayClient.acceptLocalConnectionsForForwardedPorts = false;178if (resolved.endpoints) {179relayClient.endpoints = resolved.endpoints;180}181182await relayClient.connect(resolved);183this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`);184185// Wait for the agent host port to become available186await relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT);187188// Connect to the forwarded port — returns a Duplex stream189const portStream = await relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT);190this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`);191192// Derive connection token from tunnel ID (matches CLI convention)193const connectionToken = deriveConnectionToken(tunnelId);194195// Parse display name from tags196const tags = new TunnelTags(resolved.labels);197const name = tags.name || resolved.name || tunnelId;198199// Create WebSocket over the port stream200const relay = await this._createWebSocketRelay(201portStream,202connectionToken,203connectionId,204);205206const conn = new TunnelConnection(207connectionId,208address,209name,210connectionToken,211relay,212relayClient,213);214215conn.onDidClose(() => {216this._connections.delete(connectionId);217this._onDidRelayClose.fire(connectionId);218});219220this._connections.set(connectionId, conn);221return { connectionId, address, name, connectionToken };222}223224async relaySend(connectionId: string, message: string): Promise<void> {225const conn = this._connections.get(connectionId);226if (conn) {227conn.relaySend(message);228}229}230231async disconnect(connectionId: string): Promise<void> {232const conn = this._connections.get(connectionId);233if (conn) {234conn.dispose();235}236}237238private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise<TunnelManagementHttpClient> {239const mgmt = await import('@microsoft/dev-tunnels-management');240const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`;241242return new mgmt.TunnelManagementHttpClient(243'vscode-sessions',244mgmt.ManagementApiVersions.Version20230927preview,245async () => authHeader,246);247}248249private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined {250const labels = tunnel.labels ?? [];251const tags = new TunnelTags(labels);252253if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) {254return undefined;255}256257const tunnelId = tunnel.tunnelId;258const clusterId = tunnel.clusterId;259if (!tunnelId || !clusterId) {260return undefined;261}262263const name = tags.name || tunnel.name || tunnelId;264const rawCount = tunnel.status?.hostConnectionCount;265const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0);266return {267tunnelId,268clusterId,269name,270tags: labels,271protocolVersion: tags.protocolVersion,272hostConnectionCount,273};274}275276private async _createWebSocketRelay(277portStream: NodeJS.ReadWriteStream,278connectionToken: string,279connectionId: string,280): Promise<{ send: (data: string) => void; close: () => void }> {281const WS = await import('ws');282283return new Promise((resolve, reject) => {284// Construct WebSocket URL — the stream is already connected to the right port285let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`;286if (connectionToken) {287url += `?tkn=${encodeURIComponent(connectionToken)}`;288}289290// Create WebSocket over the existing stream from the tunnel relay291const ws = new WS.WebSocket(url, {292createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'],293});294295ws.on('open', () => {296this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`);297resolve({298send: (data: string) => {299if (ws.readyState === ws.OPEN) {300ws.send(data);301}302},303close: () => ws.close(),304});305});306307ws.on('message', (data: WebSocket.RawData) => {308let text: string;309if (Array.isArray(data)) {310text = Buffer.concat(data).toString();311} else if (data instanceof ArrayBuffer) {312text = Buffer.from(new Uint8Array(data)).toString();313} else {314text = data.toString();315}316this._onDidRelayMessage.fire({ connectionId, data: text });317});318319ws.on('close', () => {320const conn = this._connections.get(connectionId);321if (conn) {322conn.dispose();323}324});325326ws.on('error', (wsErr: unknown) => {327this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);328reject(wsErr);329});330});331}332}333334335