Path: blob/main/src/vs/workbench/api/node/extHostTunnelService.ts
3296 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 * as fs from 'fs';6import { exec } from 'child_process';7import { VSBuffer } from '../../../base/common/buffer.js';8import { Emitter } from '../../../base/common/event.js';9import { DisposableStore } from '../../../base/common/lifecycle.js';10import { MovingAverage } from '../../../base/common/numbers.js';11import { isLinux } from '../../../base/common/platform.js';12import * as resources from '../../../base/common/resources.js';13import { URI } from '../../../base/common/uri.js';14import * as pfs from '../../../base/node/pfs.js';15import { ISocket, SocketCloseEventType } from '../../../base/parts/ipc/common/ipc.net.js';16import { ILogService } from '../../../platform/log/common/log.js';17import { ManagedSocket, RemoteSocketHalf, connectManagedSocket } from '../../../platform/remote/common/managedSocket.js';18import { ManagedRemoteConnection } from '../../../platform/remote/common/remoteAuthorityResolver.js';19import { ISignService } from '../../../platform/sign/common/sign.js';20import { isAllInterfaces, isLocalhost } from '../../../platform/tunnel/common/tunnel.js';21import { NodeRemoteTunnel } from '../../../platform/tunnel/node/tunnelService.js';22import { IExtHostInitDataService } from '../common/extHostInitDataService.js';23import { IExtHostRpcService } from '../common/extHostRpcService.js';24import { ExtHostTunnelService } from '../common/extHostTunnelService.js';25import { CandidatePort, parseAddress } from '../../services/remote/common/tunnelModel.js';26import * as vscode from 'vscode';27import { IExtHostConfiguration } from '../common/extHostConfiguration.js';2829export function getSockets(stdout: string): Record<string, { pid: number; socket: number }> {30const lines = stdout.trim().split('\n');31const mapped: { pid: number; socket: number }[] = [];32lines.forEach(line => {33const match = /\/proc\/(\d+)\/fd\/\d+ -> socket:\[(\d+)\]/.exec(line)!;34if (match && match.length >= 3) {35mapped.push({36pid: parseInt(match[1], 10),37socket: parseInt(match[2], 10)38});39}40});41const socketMap = mapped.reduce((m: Record<string, typeof mapped[0]>, socket) => {42m[socket.socket] = socket;43return m;44}, {});45return socketMap;46}4748export function loadListeningPorts(...stdouts: string[]): { socket: number; ip: string; port: number }[] {49const table = ([] as Record<string, string>[]).concat(...stdouts.map(loadConnectionTable));50return [51...new Map(52table.filter(row => row.st === '0A')53.map(row => {54const address = row.local_address.split(':');55return {56socket: parseInt(row.inode, 10),57ip: parseIpAddress(address[0]),58port: parseInt(address[1], 16)59};60}).map(port => [port.ip + ':' + port.port, port])61).values()62];63}6465export function parseIpAddress(hex: string): string {66let result = '';67if (hex.length === 8) {68for (let i = hex.length - 2; i >= 0; i -= 2) {69result += parseInt(hex.substr(i, 2), 16);70if (i !== 0) {71result += '.';72}73}74} else {75// Nice explanation of host format in tcp6 file: https://serverfault.com/questions/592574/why-does-proc-net-tcp6-represents-1-as-100076for (let i = 0; i < hex.length; i += 8) {77const word = hex.substring(i, i + 8);78let subWord = '';79for (let j = 8; j >= 2; j -= 2) {80subWord += word.substring(j - 2, j);81if ((j === 6) || (j === 2)) {82// Trim leading zeros83subWord = parseInt(subWord, 16).toString(16);84result += `${subWord}`;85subWord = '';86if (i + j !== hex.length - 6) {87result += ':';88}89}90}91}92}93return result;94}9596export function loadConnectionTable(stdout: string): Record<string, string>[] {97const lines = stdout.trim().split('\n');98const names = lines.shift()!.trim().split(/\s+/)99.filter(name => name !== 'rx_queue' && name !== 'tm->when');100const table = lines.map(line => line.trim().split(/\s+/).reduce((obj: Record<string, string>, value, i) => {101obj[names[i] || i] = value;102return obj;103}, {}));104return table;105}106107function knownExcludeCmdline(command: string): boolean {108if (command.length > 500) {109return false;110}111return !!command.match(/.*\.vscode-server-[a-zA-Z]+\/bin.*/)112|| (command.indexOf('out/server-main.js') !== -1)113|| (command.indexOf('_productName=VSCode') !== -1);114}115116export function getRootProcesses(stdout: string) {117const lines = stdout.trim().split('\n');118const mapped: { pid: number; cmd: string; ppid: number }[] = [];119lines.forEach(line => {120const match = /^\d+\s+\D+\s+root\s+(\d+)\s+(\d+).+\d+\:\d+\:\d+\s+(.+)$/.exec(line)!;121if (match && match.length >= 4) {122mapped.push({123pid: parseInt(match[1], 10),124ppid: parseInt(match[2]),125cmd: match[3]126});127}128});129return mapped;130}131132export async function findPorts(connections: { socket: number; ip: string; port: number }[], socketMap: Record<string, { pid: number; socket: number }>, processes: { pid: number; cwd: string; cmd: string }[]): Promise<CandidatePort[]> {133const processMap = processes.reduce((m: Record<string, typeof processes[0]>, process) => {134m[process.pid] = process;135return m;136}, {});137138const ports: CandidatePort[] = [];139connections.forEach(({ socket, ip, port }) => {140const pid = socketMap[socket] ? socketMap[socket].pid : undefined;141const command: string | undefined = pid ? processMap[pid]?.cmd : undefined;142if (pid && command && !knownExcludeCmdline(command)) {143ports.push({ host: ip, port, detail: command, pid });144}145});146return ports;147}148149export function tryFindRootPorts(connections: { socket: number; ip: string; port: number }[], rootProcessesStdout: string, previousPorts: Map<number, CandidatePort & { ppid: number }>): Map<number, CandidatePort & { ppid: number }> {150const ports: Map<number, CandidatePort & { ppid: number }> = new Map();151const rootProcesses = getRootProcesses(rootProcessesStdout);152153for (const connection of connections) {154const previousPort = previousPorts.get(connection.port);155if (previousPort) {156ports.set(connection.port, previousPort);157continue;158}159const rootProcessMatch = rootProcesses.find((value) => value.cmd.includes(`${connection.port}`));160if (rootProcessMatch) {161let bestMatch = rootProcessMatch;162// There are often several processes that "look" like they could match the port.163// The one we want is usually the child of the other. Find the most child process.164let mostChild: { pid: number; cmd: string; ppid: number } | undefined;165do {166mostChild = rootProcesses.find(value => value.ppid === bestMatch.pid);167if (mostChild) {168bestMatch = mostChild;169}170} while (mostChild);171ports.set(connection.port, { host: connection.ip, port: connection.port, pid: bestMatch.pid, detail: bestMatch.cmd, ppid: bestMatch.ppid });172} else {173ports.set(connection.port, { host: connection.ip, port: connection.port, ppid: Number.MAX_VALUE });174}175}176177return ports;178}179180export class NodeExtHostTunnelService extends ExtHostTunnelService {181private _initialCandidates: CandidatePort[] | undefined = undefined;182private _foundRootPorts: Map<number, CandidatePort & { ppid: number }> = new Map();183private _candidateFindingEnabled: boolean = false;184185constructor(186@IExtHostRpcService extHostRpc: IExtHostRpcService,187@IExtHostInitDataService private readonly initData: IExtHostInitDataService,188@ILogService logService: ILogService,189@ISignService private readonly signService: ISignService,190@IExtHostConfiguration private readonly configurationService: IExtHostConfiguration,191) {192super(extHostRpc, initData, logService);193if (isLinux && initData.remote.isRemote && initData.remote.authority) {194this._proxy.$setRemoteTunnelService(process.pid);195this.setInitialCandidates();196}197}198199override async $registerCandidateFinder(enable: boolean): Promise<void> {200if (enable && this._candidateFindingEnabled) {201// already enabled202return;203}204205this._candidateFindingEnabled = enable;206let oldPorts: { host: string; port: number; detail?: string }[] | undefined = undefined;207208// If we already have found initial candidates send those immediately.209if (this._initialCandidates) {210oldPorts = this._initialCandidates;211await this._proxy.$onFoundNewCandidates(this._initialCandidates);212}213214// Regularly scan to see if the candidate ports have changed.215const movingAverage = new MovingAverage();216let scanCount = 0;217while (this._candidateFindingEnabled) {218const startTime = new Date().getTime();219const newPorts = (await this.findCandidatePorts()).filter(candidate => (isLocalhost(candidate.host) || isAllInterfaces(candidate.host)));220this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) found candidate ports ${newPorts.map(port => port.port).join(', ')}`);221const timeTaken = new Date().getTime() - startTime;222this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) candidate port scan took ${timeTaken} ms.`);223// Do not count the first few scans towards the moving average as they are likely to be slower.224if (scanCount++ > 3) {225movingAverage.update(timeTaken);226}227if (!oldPorts || (JSON.stringify(oldPorts) !== JSON.stringify(newPorts))) {228oldPorts = newPorts;229await this._proxy.$onFoundNewCandidates(oldPorts);230}231const delay = this.calculateDelay(movingAverage.value);232this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) next candidate port scan in ${delay} ms.`);233await (new Promise<void>(resolve => setTimeout(() => resolve(), delay)));234}235}236237private calculateDelay(movingAverage: number) {238// Some local testing indicated that the moving average might be between 50-100 ms.239return Math.max(movingAverage * 20, 2000);240}241242private async setInitialCandidates(): Promise<void> {243this._initialCandidates = await this.findCandidatePorts();244this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) Initial candidates found: ${this._initialCandidates.map(c => c.port).join(', ')}`);245}246247private async findCandidatePorts(): Promise<CandidatePort[]> {248let tcp: string = '';249let tcp6: string = '';250try {251tcp = await fs.promises.readFile('/proc/net/tcp', 'utf8');252tcp6 = await fs.promises.readFile('/proc/net/tcp6', 'utf8');253} catch (e) {254// File reading error. No additional handling needed.255}256const connections: { socket: number; ip: string; port: number }[] = loadListeningPorts(tcp, tcp6);257258const procSockets: string = await (new Promise(resolve => {259exec('ls -l /proc/[0-9]*/fd/[0-9]* | grep socket:', (error, stdout, stderr) => {260resolve(stdout);261});262}));263const socketMap = getSockets(procSockets);264265const procChildren = await pfs.Promises.readdir('/proc');266const processes: {267pid: number; cwd: string; cmd: string;268}[] = [];269for (const childName of procChildren) {270try {271const pid: number = Number(childName);272const childUri = resources.joinPath(URI.file('/proc'), childName);273const childStat = await fs.promises.stat(childUri.fsPath);274if (childStat.isDirectory() && !isNaN(pid)) {275const cwd = await fs.promises.readlink(resources.joinPath(childUri, 'cwd').fsPath);276const cmd = await fs.promises.readFile(resources.joinPath(childUri, 'cmdline').fsPath, 'utf8');277processes.push({ pid, cwd, cmd });278}279} catch (e) {280//281}282}283284const unFoundConnections: { socket: number; ip: string; port: number }[] = [];285const filteredConnections = connections.filter((connection => {286const foundConnection = socketMap[connection.socket];287if (!foundConnection) {288unFoundConnections.push(connection);289}290return foundConnection;291}));292293const foundPorts = findPorts(filteredConnections, socketMap, processes);294let heuristicPorts: CandidatePort[] | undefined;295this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) number of possible root ports ${unFoundConnections.length}`);296if (unFoundConnections.length > 0) {297const rootProcesses: string = await (new Promise(resolve => {298exec('ps -F -A -l | grep root', (error, stdout, stderr) => {299resolve(stdout);300});301}));302this._foundRootPorts = tryFindRootPorts(unFoundConnections, rootProcesses, this._foundRootPorts);303heuristicPorts = Array.from(this._foundRootPorts.values());304this.logService.trace(`ForwardedPorts: (ExtHostTunnelService) heuristic ports ${heuristicPorts.map(heuristicPort => heuristicPort.port).join(', ')}`);305306}307return foundPorts.then(foundCandidates => {308if (heuristicPorts) {309return foundCandidates.concat(heuristicPorts);310} else {311return foundCandidates;312}313});314}315316private async defaultTunnelHost(): Promise<string> {317const settingValue = (await this.configurationService.getConfigProvider()).getConfiguration('remote').get('localPortHost');318return (!settingValue || settingValue === 'localhost') ? '127.0.0.1' : '0.0.0.0';319}320321protected override makeManagedTunnelFactory(authority: vscode.ManagedResolvedAuthority): vscode.RemoteAuthorityResolver['tunnelFactory'] {322return async (tunnelOptions) => {323const t = new NodeRemoteTunnel(324{325commit: this.initData.commit,326quality: this.initData.quality,327logService: this.logService,328ipcLogger: null,329// services and address providers have stubs since we don't need330// the connection identification that the renderer process uses331remoteSocketFactoryService: {332_serviceBrand: undefined,333async connect(_connectTo: ManagedRemoteConnection, path: string, query: string, debugLabel: string): Promise<ISocket> {334const result = await authority.makeConnection();335return ExtHostManagedSocket.connect(result, path, query, debugLabel);336},337register() {338throw new Error('not implemented');339},340},341addressProvider: {342getAddress() {343return Promise.resolve({344connectTo: new ManagedRemoteConnection(0),345connectionToken: authority.connectionToken,346});347},348},349signService: this.signService,350},351await this.defaultTunnelHost(),352tunnelOptions.remoteAddress.host || 'localhost',353tunnelOptions.remoteAddress.port,354tunnelOptions.localAddressPort,355);356357await t.waitForReady();358359const disposeEmitter = new Emitter<void>();360361return {362localAddress: parseAddress(t.localAddress) ?? t.localAddress,363remoteAddress: { port: t.tunnelRemotePort, host: t.tunnelRemoteHost },364onDidDispose: disposeEmitter.event,365dispose: () => {366t.dispose();367disposeEmitter.fire();368disposeEmitter.dispose();369},370};371};372}373}374375class ExtHostManagedSocket extends ManagedSocket {376public static connect(377passing: vscode.ManagedMessagePassing,378path: string, query: string, debugLabel: string,379): Promise<ExtHostManagedSocket> {380const d = new DisposableStore();381const half: RemoteSocketHalf = {382onClose: d.add(new Emitter()),383onData: d.add(new Emitter()),384onEnd: d.add(new Emitter()),385};386387d.add(passing.onDidReceiveMessage(d => half.onData.fire(VSBuffer.wrap(d))));388d.add(passing.onDidEnd(() => half.onEnd.fire()));389d.add(passing.onDidClose(error => half.onClose.fire({390type: SocketCloseEventType.NodeSocketCloseEvent,391error,392hadError: !!error393})));394395const socket = new ExtHostManagedSocket(passing, debugLabel, half);396socket._register(d);397return connectManagedSocket(socket, path, query, debugLabel, half);398}399400constructor(401private readonly passing: vscode.ManagedMessagePassing,402debugLabel: string,403half: RemoteSocketHalf,404) {405super(debugLabel, half);406}407408public override write(buffer: VSBuffer): void {409this.passing.send(buffer.buffer);410}411protected override closeRemote(): void {412this.passing.end();413}414415public override async drain(): Promise<void> {416await this.passing.drain?.();417}418}419420421