Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/tunnelAgentHostService.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { Tunnel } from '@microsoft/dev-tunnels-contracts';
7
import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management';
8
import { createHash } from 'crypto';
9
import type WebSocket from 'ws';
10
import { Emitter, Event } from '../../../base/common/event.js';
11
import { Disposable } from '../../../base/common/lifecycle.js';
12
import { generateUuid } from '../../../base/common/uuid.js';
13
import { ILogService } from '../../log/common/log.js';
14
import {
15
ITunnelAgentHostMainService,
16
TUNNEL_ADDRESS_PREFIX,
17
TUNNEL_AGENT_HOST_PORT,
18
TUNNEL_LAUNCHER_LABEL,
19
TUNNEL_MIN_PROTOCOL_VERSION,
20
TunnelTags,
21
type ITunnelConnectResult,
22
type ITunnelInfo,
23
type ITunnelRelayMessage,
24
} from '../common/tunnelAgentHost.js';
25
26
const LOG_PREFIX = '[TunnelAgentHost]';
27
28
/**
29
* Derive a connection token from a tunnel ID using the same convention
30
* as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs).
31
*/
32
function deriveConnectionToken(tunnelId: string): string {
33
const hash = createHash('sha256');
34
hash.update(tunnelId);
35
let result = hash.digest('base64url');
36
if (result.startsWith('-')) {
37
result = 'a' + result;
38
}
39
return result;
40
}
41
42
/** State for a single active tunnel relay connection. */
43
class TunnelConnection extends Disposable {
44
private readonly _onDidClose = this._register(new Emitter<void>());
45
readonly onDidClose = this._onDidClose.event;
46
47
private _closed = false;
48
49
constructor(
50
readonly connectionId: string,
51
readonly address: string,
52
readonly name: string,
53
readonly connectionToken: string,
54
private readonly _relay: { send: (data: string) => void; close: () => void },
55
private readonly _relayClient: { dispose(): void },
56
) {
57
super();
58
}
59
60
override dispose(): void {
61
if (!this._closed) {
62
this._closed = true;
63
this._relay.close();
64
this._relayClient.dispose();
65
this._onDidClose.fire();
66
}
67
super.dispose();
68
}
69
70
relaySend(data: string): void {
71
this._relay.send(data);
72
}
73
}
74
75
export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService {
76
declare readonly _serviceBrand: undefined;
77
78
private readonly _onDidRelayMessage = this._register(new Emitter<ITunnelRelayMessage>());
79
readonly onDidRelayMessage: Event<ITunnelRelayMessage> = this._onDidRelayMessage.event;
80
81
private readonly _onDidRelayClose = this._register(new Emitter<string>());
82
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
83
84
private readonly _connections = new Map<string, TunnelConnection>();
85
86
constructor(
87
@ILogService private readonly _logService: ILogService,
88
) {
89
super();
90
}
91
92
async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise<ITunnelInfo[]> {
93
const client = await this._createManagementClient(token, authProvider);
94
const results: ITunnelInfo[] = [];
95
const seen = new Set<string>();
96
97
try {
98
// Enumerate all tunnels with the vscode-server-launcher label
99
const tunnels = await client.listTunnels(undefined, undefined, {
100
labels: [TUNNEL_LAUNCHER_LABEL],
101
requireAllLabels: true,
102
includePorts: true,
103
tokenScopes: ['connect'],
104
});
105
106
for (const tunnel of tunnels) {
107
const info = this._parseTunnelInfo(tunnel);
108
if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {
109
results.push(info);
110
seen.add(info.tunnelId);
111
}
112
}
113
} catch (err) {
114
this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err);
115
}
116
117
// Look up additional tunnels by name
118
if (additionalTunnelNames) {
119
for (const tunnelName of additionalTunnelNames) {
120
try {
121
const [tunnel] = await client.listTunnels(undefined, undefined, {
122
labels: [tunnelName, TUNNEL_LAUNCHER_LABEL],
123
requireAllLabels: true,
124
includePorts: true,
125
tokenScopes: ['connect'],
126
limit: 1,
127
});
128
if (tunnel) {
129
const info = this._parseTunnelInfo(tunnel);
130
if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) {
131
results.push(info);
132
seen.add(info.tunnelId);
133
}
134
}
135
} catch (err) {
136
this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err);
137
}
138
}
139
}
140
141
this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);
142
return results;
143
}
144
145
async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise<ITunnelConnectResult> {
146
// Tear down any existing connection to this tunnel first.
147
// Each connect() call creates a fresh relay with its own protocol
148
// session, so the old one must be closed to avoid conflicts.
149
for (const [id, conn] of this._connections) {
150
if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) {
151
this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`);
152
this._connections.delete(id);
153
conn.dispose();
154
break;
155
}
156
}
157
158
const client = await this._createManagementClient(token, authProvider);
159
const connectionId = generateUuid();
160
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;
161
162
this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`);
163
164
// Get the full tunnel with endpoints and access tokens
165
const tunnel: Tunnel = { tunnelId, clusterId };
166
const resolved = await client.getTunnel(tunnel, {
167
includePorts: true,
168
tokenScopes: ['connect'],
169
});
170
171
if (!resolved) {
172
throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`);
173
}
174
175
// Connect to the tunnel relay
176
const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections');
177
const relayClient = new TunnelRelayTunnelClient(client);
178
relayClient.acceptLocalConnectionsForForwardedPorts = false;
179
if (resolved.endpoints) {
180
relayClient.endpoints = resolved.endpoints;
181
}
182
183
await relayClient.connect(resolved);
184
this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`);
185
186
// Wait for the agent host port to become available
187
await relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT);
188
189
// Connect to the forwarded port — returns a Duplex stream
190
const portStream = await relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT);
191
this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`);
192
193
// Derive connection token from tunnel ID (matches CLI convention)
194
const connectionToken = deriveConnectionToken(tunnelId);
195
196
// Parse display name from tags
197
const tags = new TunnelTags(resolved.labels);
198
const name = tags.name || resolved.name || tunnelId;
199
200
// Create WebSocket over the port stream
201
const relay = await this._createWebSocketRelay(
202
portStream,
203
connectionToken,
204
connectionId,
205
);
206
207
const conn = new TunnelConnection(
208
connectionId,
209
address,
210
name,
211
connectionToken,
212
relay,
213
relayClient,
214
);
215
216
conn.onDidClose(() => {
217
this._connections.delete(connectionId);
218
this._onDidRelayClose.fire(connectionId);
219
});
220
221
this._connections.set(connectionId, conn);
222
return { connectionId, address, name, connectionToken };
223
}
224
225
async relaySend(connectionId: string, message: string): Promise<void> {
226
const conn = this._connections.get(connectionId);
227
if (conn) {
228
conn.relaySend(message);
229
}
230
}
231
232
async disconnect(connectionId: string): Promise<void> {
233
const conn = this._connections.get(connectionId);
234
if (conn) {
235
conn.dispose();
236
}
237
}
238
239
private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise<TunnelManagementHttpClient> {
240
const mgmt = await import('@microsoft/dev-tunnels-management');
241
const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`;
242
243
return new mgmt.TunnelManagementHttpClient(
244
'vscode-sessions',
245
mgmt.ManagementApiVersions.Version20230927preview,
246
async () => authHeader,
247
);
248
}
249
250
private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined {
251
const labels = tunnel.labels ?? [];
252
const tags = new TunnelTags(labels);
253
254
if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) {
255
return undefined;
256
}
257
258
const tunnelId = tunnel.tunnelId;
259
const clusterId = tunnel.clusterId;
260
if (!tunnelId || !clusterId) {
261
return undefined;
262
}
263
264
const name = tags.name || tunnel.name || tunnelId;
265
const rawCount = tunnel.status?.hostConnectionCount;
266
const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0);
267
return {
268
tunnelId,
269
clusterId,
270
name,
271
tags: labels,
272
protocolVersion: tags.protocolVersion,
273
hostConnectionCount,
274
};
275
}
276
277
private async _createWebSocketRelay(
278
portStream: NodeJS.ReadWriteStream,
279
connectionToken: string,
280
connectionId: string,
281
): Promise<{ send: (data: string) => void; close: () => void }> {
282
const WS = await import('ws');
283
284
return new Promise((resolve, reject) => {
285
// Construct WebSocket URL — the stream is already connected to the right port
286
let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`;
287
if (connectionToken) {
288
url += `?tkn=${encodeURIComponent(connectionToken)}`;
289
}
290
291
// Create WebSocket over the existing stream from the tunnel relay
292
const ws = new WS.WebSocket(url, {
293
createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'],
294
});
295
296
ws.on('open', () => {
297
this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`);
298
resolve({
299
send: (data: string) => {
300
if (ws.readyState === ws.OPEN) {
301
ws.send(data);
302
}
303
},
304
close: () => ws.close(),
305
});
306
});
307
308
ws.on('message', (data: WebSocket.RawData) => {
309
let text: string;
310
if (Array.isArray(data)) {
311
text = Buffer.concat(data).toString();
312
} else if (data instanceof ArrayBuffer) {
313
text = Buffer.from(new Uint8Array(data)).toString();
314
} else {
315
text = data.toString();
316
}
317
this._onDidRelayMessage.fire({ connectionId, data: text });
318
});
319
320
ws.on('close', () => {
321
const conn = this._connections.get(connectionId);
322
if (conn) {
323
conn.dispose();
324
}
325
});
326
327
ws.on('error', (wsErr: unknown) => {
328
this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
329
reject(wsErr);
330
});
331
});
332
}
333
}
334
335