From 71282c3d5299e866a38168456629dce0fd1ee855 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 8 Aug 2023 14:00:03 -0700 Subject: [PATCH] forwarding: add built-in tunnel forwarding extension (#189874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * forwarding: add built-in tunnel forwarding extension - Support public/private ports, which accounts for most of the work in the CLI. Previously ports were only privat. - Make the extension built-in. Ported from the remote-containers extension with some tweaks for privacy and durability. - This also removes the opt-in flag, by not reimplementing it 😛 Fixes https://github.com/microsoft/vscode/issues/189677 Fixes https://github.com/microsoft/vscode/issues/189678 * fixup! comments --------- Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> --- build/gulpfile.extensions.js | 1 + build/npm/dirs.js | 1 + cli/src/commands/tunnels.rs | 6 +- cli/src/tunnels.rs | 2 +- cli/src/tunnels/dev_tunnels.rs | 39 ++- .../{forwarding.rs => local_forwarding.rs} | 97 ++++-- cli/src/tunnels/port_forwarder.rs | 4 +- cli/src/tunnels/protocol.rs | 17 +- .../tunnel-forwarding/.vscode/launch.json | 15 + extensions/tunnel-forwarding/.vscodeignore | 5 + .../extension.webpack.config.js | 20 ++ extensions/tunnel-forwarding/media/icon.png | Bin 0 -> 7191 bytes extensions/tunnel-forwarding/package.json | 59 ++++ extensions/tunnel-forwarding/package.nls.json | 7 + .../tunnel-forwarding/src/deferredPromise.ts | 62 ++++ extensions/tunnel-forwarding/src/extension.ts | 293 ++++++++++++++++++ extensions/tunnel-forwarding/src/split.ts | 49 +++ extensions/tunnel-forwarding/tsconfig.json | 16 + extensions/tunnel-forwarding/yarn.lock | 8 + 19 files changed, 665 insertions(+), 36 deletions(-) rename cli/src/tunnels/{forwarding.rs => local_forwarding.rs} (77%) create mode 100644 extensions/tunnel-forwarding/.vscode/launch.json create mode 100644 extensions/tunnel-forwarding/.vscodeignore create mode 100644 extensions/tunnel-forwarding/extension.webpack.config.js create mode 100644 extensions/tunnel-forwarding/media/icon.png create mode 100644 extensions/tunnel-forwarding/package.json create mode 100644 extensions/tunnel-forwarding/package.nls.json create mode 100644 extensions/tunnel-forwarding/src/deferredPromise.ts create mode 100644 extensions/tunnel-forwarding/src/extension.ts create mode 100644 extensions/tunnel-forwarding/src/split.ts create mode 100644 extensions/tunnel-forwarding/tsconfig.json create mode 100644 extensions/tunnel-forwarding/yarn.lock diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index e2c9e3d9abaf4..bcdb206606b24 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -64,6 +64,7 @@ const compilations = [ 'search-result/tsconfig.json', 'references-view/tsconfig.json', 'simple-browser/tsconfig.json', + 'tunnel-forwarding/tsconfig.json', 'typescript-language-features/test-workspace/tsconfig.json', 'typescript-language-features/web/tsconfig.json', 'typescript-language-features/tsconfig.json', diff --git a/build/npm/dirs.js b/build/npm/dirs.js index 875390550dadf..faf3a6577a5d8 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -41,6 +41,7 @@ const dirs = [ 'extensions/references-view', 'extensions/search-result', 'extensions/simple-browser', + 'extensions/tunnel-forwarding', 'extensions/typescript-language-features', 'extensions/vscode-api-tests', 'extensions/vscode-colorize-tests', diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index 85bfecc50a58c..c1b361fc81b9f 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -35,7 +35,7 @@ use crate::{ code_server::CodeServerArgs, create_service_manager, dev_tunnels::{self, DevTunnels}, - forwarding, legal, + local_forwarding, legal, paths::get_all_servers, protocol, serve_stream, shutdown_signal::ShutdownRequest, @@ -444,7 +444,7 @@ pub async fn forward( match acquire_singleton(&ctx.paths.forwarding_lockfile()).await { Ok(SingletonConnection::Client(stream)) => { debug!(ctx.log, "starting as client to singleton"); - let r = forwarding::client(forwarding::SingletonClientArgs { + let r = local_forwarding::client(local_forwarding::SingletonClientArgs { log: ctx.log.clone(), shutdown: shutdown.clone(), stream, @@ -477,7 +477,7 @@ pub async fn forward( .start_new_launcher_tunnel(None, true, &forward_args.ports) .await?; - forwarding::server(ctx.log, tunnel, server, own_ports_rx, shutdown).await?; + local_forwarding::server(ctx.log, tunnel, server, own_ports_rx, shutdown).await?; Ok(0) } diff --git a/cli/src/tunnels.rs b/cli/src/tunnels.rs index 700516abb1f75..7378cf34afd6d 100644 --- a/cli/src/tunnels.rs +++ b/cli/src/tunnels.rs @@ -11,7 +11,7 @@ pub mod protocol; pub mod shutdown_signal; pub mod singleton_client; pub mod singleton_server; -pub mod forwarding; +pub mod local_forwarding; mod wsl_detect; mod challenge; diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 3bdc5cf189bed..12ca6b4098e99 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -23,13 +23,15 @@ use std::time::Duration; use tokio::sync::{mpsc, watch}; use tunnels::connections::{ForwardedPortConnection, RelayTunnelHost}; use tunnels::contracts::{ - Tunnel, TunnelPort, TunnelRelayTunnelEndpoint, PORT_TOKEN, TUNNEL_PROTOCOL_AUTO, + Tunnel, TunnelAccessControl, TunnelPort, TunnelRelayTunnelEndpoint, PORT_TOKEN, + TUNNEL_ACCESS_SCOPES_CONNECT, TUNNEL_PROTOCOL_AUTO, }; use tunnels::management::{ new_tunnel_management, HttpError, TunnelLocator, TunnelManagementClient, TunnelRequestOptions, NO_REQUEST_OPTIONS, }; +use super::protocol::PortPrivacy; use super::wsl_detect::is_wsl_installed; static TUNNEL_COUNT_LIMIT_NAME: &str = "TunnelsPerUserPerLocation"; @@ -164,8 +166,12 @@ impl ActiveTunnel { } /// Forwards a port over TCP. - pub async fn add_port_tcp(&self, port_number: u16) -> Result<(), AnyError> { - self.manager.add_port_tcp(port_number).await?; + pub async fn add_port_tcp( + &self, + port_number: u16, + privacy: PortPrivacy, + ) -> Result<(), AnyError> { + self.manager.add_port_tcp(port_number, privacy).await?; Ok(()) } @@ -866,13 +872,18 @@ impl ActiveTunnelManager { /// Adds a port for TCP/IP forwarding. #[allow(dead_code)] // todo: port forwarding - pub async fn add_port_tcp(&self, port_number: u16) -> Result<(), WrappedError> { + pub async fn add_port_tcp( + &self, + port_number: u16, + privacy: PortPrivacy, + ) -> Result<(), WrappedError> { self.relay .lock() .await .add_port(&TunnelPort { port_number, protocol: Some(TUNNEL_PROTOCOL_AUTO.to_owned()), + access_control: Some(privacy_to_tunnel_acl(privacy)), ..Default::default() }) .await @@ -1081,6 +1092,26 @@ fn vec_eq_as_set(a: &[String], b: &[String]) -> bool { true } +fn privacy_to_tunnel_acl(privacy: PortPrivacy) -> TunnelAccessControl { + let mut acl = TunnelAccessControl { entries: vec![] }; + + if privacy == PortPrivacy::Public { + acl.entries + .push(tunnels::contracts::TunnelAccessControlEntry { + kind: tunnels::contracts::TunnelAccessControlEntryType::Anonymous, + provider: None, + is_inherited: false, + is_deny: false, + is_inverse: false, + organization: None, + subjects: vec![], + scopes: vec![TUNNEL_ACCESS_SCOPES_CONNECT.to_string()], + }); + } + + acl +} + #[cfg(test)] mod test { use super::*; diff --git a/cli/src/tunnels/forwarding.rs b/cli/src/tunnels/local_forwarding.rs similarity index 77% rename from cli/src/tunnels/forwarding.rs rename to cli/src/tunnels/local_forwarding.rs index 1557b97c04e03..e6410860cb0e7 100644 --- a/cli/src/tunnels/forwarding.rs +++ b/cli/src/tunnels/local_forwarding.rs @@ -5,6 +5,7 @@ use std::{ collections::HashMap, + ops::{Index, IndexMut}, sync::{Arc, Mutex}, }; @@ -26,11 +27,52 @@ use super::{ protocol::{ self, forward_singleton::{PortList, SetPortsResponse}, + PortPrivacy, }, shutdown_signal::ShutdownSignal, }; -type PortMap = HashMap; +#[derive(Default, Clone)] +struct PortCount { + public: u32, + private: u32, +} + +impl Index for PortCount { + type Output = u32; + + fn index(&self, privacy: PortPrivacy) -> &Self::Output { + match privacy { + PortPrivacy::Public => &self.public, + PortPrivacy::Private => &self.private, + } + } +} + +impl IndexMut for PortCount { + fn index_mut(&mut self, privacy: PortPrivacy) -> &mut Self::Output { + match privacy { + PortPrivacy::Public => &mut self.public, + PortPrivacy::Private => &mut self.private, + } + } +} + +impl PortCount { + fn is_empty(&self) -> bool { + self.public == 0 && self.private == 0 + } + + fn primary_privacy(&self) -> PortPrivacy { + if self.public > 0 { + PortPrivacy::Public + } else { + PortPrivacy::Private + } + } +} + +type PortMap = HashMap; /// The PortForwardingHandle is given out to multiple consumers to allow /// them to set_ports that they want to be forwarded. @@ -56,23 +98,25 @@ impl PortForwardingSender { self.sender.lock().unwrap().send_modify(|v| { for p in current.iter() { if !ports.contains(p) { - match v.get(p) { - Some(1) => { - v.remove(p); - } - Some(n) => { - v.insert(*p, n - 1); - } - None => unreachable!("removed port not in map"), + let n = v.get_mut(&p.number).expect("expected port in map"); + n[p.privacy] -= 1; + if n.is_empty() { + v.remove(&p.number); } } } for p in ports.iter() { if !current.contains(p) { - match v.get(p) { - Some(n) => v.insert(*p, n + 1), - None => v.insert(*p, 1), + match v.get_mut(&p.number) { + Some(n) => { + n[p.privacy] += 1; + } + None => { + let mut pc = PortCount::default(); + pc[p.privacy] += 1; + v.insert(p.number, pc); + } }; } } @@ -116,23 +160,26 @@ impl PortForwardingReceiver { /// Applies all changes from PortForwardingHandles to the tunnel. pub async fn apply_to(&mut self, log: log::Logger, tunnel: Arc) { - let mut current = vec![]; + let mut current: PortMap = HashMap::new(); while self.receiver.changed().await.is_ok() { - let next = self.receiver.borrow().keys().copied().collect::>(); - - for p in current.iter() { - if !next.contains(p) { - match tunnel.remove_port(*p).await { - Ok(_) => info!(log, "stopped forwarding port {}", p), - Err(e) => error!(log, "failed to stop forwarding port {}: {}", p, e), + let next = self.receiver.borrow().clone(); + + for (port, count) in current.iter() { + let privacy = count.primary_privacy(); + if !matches!(next.get(port), Some(n) if n.primary_privacy() == privacy) { + match tunnel.remove_port(*port).await { + Ok(_) => info!(log, "stopped forwarding port {} at {:?}", *port, privacy), + Err(e) => error!(log, "failed to stop forwarding port {}: {}", port, e), } } } - for p in next.iter() { - if !current.contains(p) { - match tunnel.add_port_tcp(*p).await { - Ok(_) => info!(log, "forwarding port {}", p), - Err(e) => error!(log, "failed to forward port {}: {}", p, e), + + for (port, count) in next.iter() { + let privacy = count.primary_privacy(); + if !matches!(current.get(port), Some(n) if n.primary_privacy() == privacy) { + match tunnel.add_port_tcp(*port, privacy).await { + Ok(_) => info!(log, "forwarding port {} at {:?}", port, privacy), + Err(e) => error!(log, "failed to forward port {}: {}", port, e), } } } diff --git a/cli/src/tunnels/port_forwarder.rs b/cli/src/tunnels/port_forwarder.rs index 0f489f541d7c5..093c1c8176f09 100644 --- a/cli/src/tunnels/port_forwarder.rs +++ b/cli/src/tunnels/port_forwarder.rs @@ -12,7 +12,7 @@ use crate::{ util::errors::{AnyError, CannotForwardControlPort, ServerHasClosed}, }; -use super::dev_tunnels::ActiveTunnel; +use super::{dev_tunnels::ActiveTunnel, protocol::PortPrivacy}; pub enum PortForwardingRec { Forward(u16, oneshot::Sender>), @@ -87,7 +87,7 @@ impl PortForwardingProcessor { } if !self.forwarded.contains(&port) { - tunnel.add_port_tcp(port).await?; + tunnel.add_port_tcp(port, PortPrivacy::Private).await?; self.forwarded.insert(port); } diff --git a/cli/src/tunnels/protocol.rs b/cli/src/tunnels/protocol.rs index cf01917ee1eca..89c4e3842294c 100644 --- a/cli/src/tunnels/protocol.rs +++ b/cli/src/tunnels/protocol.rs @@ -214,12 +214,27 @@ pub struct ChallengeVerifyParams { pub response: String, } +#[derive(Serialize, Deserialize, PartialEq, Eq, Copy, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum PortPrivacy { + Public, + Private, +} + pub mod forward_singleton { use serde::{Deserialize, Serialize}; + use super::PortPrivacy; + pub const METHOD_SET_PORTS: &str = "set_ports"; - pub type PortList = Vec; + #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] + pub struct PortRec { + pub number: u16, + pub privacy: PortPrivacy, + } + + pub type PortList = Vec; #[derive(Serialize, Deserialize)] pub struct SetPortsParams { diff --git a/extensions/tunnel-forwarding/.vscode/launch.json b/extensions/tunnel-forwarding/.vscode/launch.json new file mode 100644 index 0000000000000..d3fabaa1a94cc --- /dev/null +++ b/extensions/tunnel-forwarding/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "env": { "VSCODE_FORWARDING_IS_DEV": "1" } // load the CLI from OSS + } + ] +} diff --git a/extensions/tunnel-forwarding/.vscodeignore b/extensions/tunnel-forwarding/.vscodeignore new file mode 100644 index 0000000000000..36e8b0714faf4 --- /dev/null +++ b/extensions/tunnel-forwarding/.vscodeignore @@ -0,0 +1,5 @@ +src/** +tsconfig.json +out/** +extension.webpack.config.js +yarn.lock \ No newline at end of file diff --git a/extensions/tunnel-forwarding/extension.webpack.config.js b/extensions/tunnel-forwarding/extension.webpack.config.js new file mode 100644 index 0000000000000..b474e65cbb130 --- /dev/null +++ b/extensions/tunnel-forwarding/extension.webpack.config.js @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); + +module.exports = withDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts', + }, + resolve: { + mainFields: ['module', 'main'] + } +}); diff --git a/extensions/tunnel-forwarding/media/icon.png b/extensions/tunnel-forwarding/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2c90d30a1f054c51157a1bb4497e79e41d10eda4 GIT binary patch literal 7191 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 Mm#*rS=-|Wt2f~m<<^TWy literal 0 HcmV?d00001 diff --git a/extensions/tunnel-forwarding/package.json b/extensions/tunnel-forwarding/package.json new file mode 100644 index 0000000000000..6bc5c446a0256 --- /dev/null +++ b/extensions/tunnel-forwarding/package.json @@ -0,0 +1,59 @@ +{ + "name": "tunnel-forwarding", + "displayName": "%displayName%", + "description": "%description%", + "version": "1.0.0", + "publisher": "vscode", + "license": "MIT", + "engines": { + "vscode": "^1.82.0" + }, + "icon": "media/icon.png", + "capabilities": { + "virtualWorkspaces": false, + "untrustedWorkspaces": { + "supported": true + } + }, + "enabledApiProposals": [ + "resolvers", + "tunnelFactory" + ], + "activationEvents": [ + "onStartupFinished" + ], + "contributes": { + "commands": [ + { + "category": "%category%", + "command": "tunnel-forwarding.showLog", + "title": "%command.showLog%", + "enablement": "tunnelForwardingHasLog" + }, + { + "category": "%category%", + "command": "tunnel-forwarding.restart", + "title": "%command.restart%", + "enablement": "tunnelForwardingIsRunning" + } + ] + }, + "main": "./out/extension", + "scripts": { + "compile": "gulp compile-extension:tunnel-forwarding", + "watch": "gulp watch-extension:tunnel-forwarding" + }, + "devDependencies": { + "@types/node": "18.x" + }, + "prettier": { + "printWidth": 100, + "trailingComma": "all", + "singleQuote": true, + "arrowParens": "avoid" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/vscode.git" + } +} diff --git a/extensions/tunnel-forwarding/package.nls.json b/extensions/tunnel-forwarding/package.nls.json new file mode 100644 index 0000000000000..1102147e8efb6 --- /dev/null +++ b/extensions/tunnel-forwarding/package.nls.json @@ -0,0 +1,7 @@ +{ + "displayName": "Local Tunnel Port Forwarding", + "description": "Allows forwarding local ports to be accessible over the internet.", + "category": "Port Forwarding", + "command.showLog": "Show Log", + "command.restart": "Restart Forwarding System" +} diff --git a/extensions/tunnel-forwarding/src/deferredPromise.ts b/extensions/tunnel-forwarding/src/deferredPromise.ts new file mode 100644 index 0000000000000..54b0737f3c0d2 --- /dev/null +++ b/extensions/tunnel-forwarding/src/deferredPromise.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type ValueCallback = (value: T | Promise) => void; + +const enum DeferredOutcome { + Resolved, + Rejected +} + +/** + * Copied from src\vs\base\common\async.ts + */ +export class DeferredPromise { + + private completeCallback!: ValueCallback; + private errorCallback!: (err: unknown) => void; + private outcome?: { outcome: DeferredOutcome.Rejected; value: any } | { outcome: DeferredOutcome.Resolved; value: T }; + + public get isRejected() { + return this.outcome?.outcome === DeferredOutcome.Rejected; + } + + public get isResolved() { + return this.outcome?.outcome === DeferredOutcome.Resolved; + } + + public get isSettled() { + return !!this.outcome; + } + + public get value() { + return this.outcome?.outcome === DeferredOutcome.Resolved ? this.outcome?.value : undefined; + } + + public readonly p: Promise; + + constructor() { + this.p = new Promise((c, e) => { + this.completeCallback = c; + this.errorCallback = e; + }); + } + + public complete(value: T) { + return new Promise(resolve => { + this.completeCallback(value); + this.outcome = { outcome: DeferredOutcome.Resolved, value }; + resolve(); + }); + } + + public error(err: unknown) { + return new Promise(resolve => { + this.errorCallback(err); + this.outcome = { outcome: DeferredOutcome.Rejected, value: err }; + resolve(); + }); + } +} diff --git a/extensions/tunnel-forwarding/src/extension.ts b/extensions/tunnel-forwarding/src/extension.ts new file mode 100644 index 0000000000000..f35d807ebe597 --- /dev/null +++ b/extensions/tunnel-forwarding/src/extension.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { DeferredPromise } from './deferredPromise'; +import { splitNewLines } from './split'; + +export const enum TunnelPrivacyId { + Private = 'private', + Public = 'public', +} + +/** + * Timeout after the last port forwarding is disposed before we'll tear down + * the CLI. This is primarily used since privacy changes to port will appear + * as a dispose+re-create call, and we don't want to have to restart the CLI. + */ +const CLEANUP_TIMEOUT = 10_000; + +const cliPath = process.env.VSCODE_FORWARDING_IS_DEV + ? path.join(__dirname, '../../../cli/target/debug/code') + : path.join( + vscode.env.appRoot, + process.platform === 'win32' ? '../../bin' : 'bin', + vscode.env.appQuality === 'stable' ? 'code-tunnel' : 'code-tunnel-insiders', + ) + (process.platform === 'win32' ? '.exe' : ''); + +class Tunnel implements vscode.Tunnel { + private readonly disposeEmitter = new vscode.EventEmitter(); + public readonly onDidDispose = this.disposeEmitter.event; + public localAddress!: string; + + constructor( + public readonly remoteAddress: { port: number; host: string }, + public readonly privacy: TunnelPrivacyId, + ) { } + + public setPortFormat(formatString: string) { + this.localAddress = formatString.replace('{port}', String(this.remoteAddress.port)); + } + + dispose() { + this.disposeEmitter.fire(); + } +} + +const enum State { + Starting, + Active, + Inactive, + Error, +} + +type StateT = + | { state: State.Inactive } + | { state: State.Starting; process: ChildProcessWithoutNullStreams; cleanupTimeout?: NodeJS.Timeout } + | { state: State.Active; portFormat: string; process: ChildProcessWithoutNullStreams; cleanupTimeout?: NodeJS.Timeout } + | { state: State.Error; error: string }; + +export async function activate(context: vscode.ExtensionContext) { + if (vscode.env.remoteAuthority) { + return; // forwarding is local-only at the moment + } + + const logger = new Logger(vscode.l10n.t('Port Forwarding')); + const provider = new TunnelProvider(logger); + + context.subscriptions.push( + vscode.commands.registerCommand('tunnel-forwarding.showLog', () => logger.show()), + vscode.commands.registerCommand('tunnel-forwarding.restart', () => provider.restart()), + + provider.onDidStateChange(s => { + vscode.commands.executeCommand('setContext', 'tunnelForwardingIsRunning', s.state !== State.Inactive); + }), + + await vscode.workspace.registerTunnelProvider( + provider, + { + tunnelFeatures: { + elevation: false, + privacyOptions: [ + { themeIcon: 'globe', id: TunnelPrivacyId.Public, label: vscode.l10n.t('Public') }, + { themeIcon: 'lock', id: TunnelPrivacyId.Private, label: vscode.l10n.t('Private') }, + ], + }, + }, + ), + ); +} + +export function deactivate() { } + +class Logger { + private outputChannel?: vscode.LogOutputChannel; + + constructor(private readonly label: string) { } + + public show(): void { + return this.outputChannel?.show(); + } + + public clear() { + this.outputChannel?.clear(); + } + + public log( + logLevel: 'trace' | 'debug' | 'info' | 'warn' | 'error', + message: string, + ...args: unknown[] + ) { + if (!this.outputChannel) { + this.outputChannel = vscode.window.createOutputChannel(this.label, { log: true }); + vscode.commands.executeCommand('setContext', 'tunnelForwardingHasLog', true); + } + this.outputChannel[logLevel](message, ...args); + } +} + +class TunnelProvider implements vscode.TunnelProvider { + private readonly tunnels = new Set(); + private readonly stateChange = new vscode.EventEmitter(); + private _state: StateT = { state: State.Inactive }; + + private get state(): StateT { + return this._state; + } + + private set state(state: StateT) { + this._state = state; + this.stateChange.fire(state); + } + + public readonly onDidStateChange = this.stateChange.event; + + constructor(private readonly logger: Logger) { } + + /** @inheritdoc */ + public async provideTunnel(tunnelOptions: vscode.TunnelOptions): Promise { + const tunnel = new Tunnel( + tunnelOptions.remoteAddress, + (tunnelOptions.privacy as TunnelPrivacyId) || TunnelPrivacyId.Private, + ); + + this.tunnels.add(tunnel); + tunnel.onDidDispose(() => { + this.tunnels.delete(tunnel); + this.updateActivePortsIfRunning(); + }); + + switch (this.state.state) { + case State.Error: + case State.Inactive: + await this.setupPortForwardingProcess(); + // fall through since state is now starting + case State.Starting: + this.updateActivePortsIfRunning(); + return new Promise((resolve, reject) => { + const l = this.stateChange.event(state => { + if (state.state === State.Active) { + tunnel.setPortFormat(state.portFormat); + l.dispose(); + resolve(tunnel); + } else if (state.state === State.Error) { + l.dispose(); + reject(new Error(state.error)); + } + }); + }); + case State.Active: + tunnel.setPortFormat(this.state.portFormat); + this.updateActivePortsIfRunning(); + return tunnel; + } + } + + /** Re/starts the port forwarding system. */ + public async restart() { + this.killRunningProcess(); + await this.setupPortForwardingProcess(); // will show progress + this.updateActivePortsIfRunning(); + } + + private isInStateWithProcess(process: ChildProcessWithoutNullStreams) { + return ( + (this.state.state === State.Starting || this.state.state === State.Active) && + this.state.process === process + ); + } + + private killRunningProcess() { + if (this.state.state === State.Starting || this.state.state === State.Active) { + this.logger.log('info', '[forwarding] no more ports, stopping forwarding CLI'); + this.state.process.kill(); + this.state = { state: State.Inactive }; + } + } + + private updateActivePortsIfRunning() { + if (this.state.state !== State.Starting && this.state.state !== State.Active) { + return; + } + + const ports = [...this.tunnels].map(t => ({ number: t.remoteAddress.port, privacy: t.privacy })); + this.state.process.stdin.write(`${JSON.stringify(ports)}\n`); + + if (ports.length === 0 && !this.state.cleanupTimeout) { + this.state.cleanupTimeout = setTimeout(() => this.killRunningProcess(), CLEANUP_TIMEOUT); + } else if (ports.length > 0 && this.state.cleanupTimeout) { + clearTimeout(this.state.cleanupTimeout); + this.state.cleanupTimeout = undefined; + } + } + + private async setupPortForwardingProcess() { + const session = await vscode.authentication.getSession('github', ['user:email', 'read:org'], { + createIfNone: true, + }); + + const args = [ + '--verbose', + 'tunnel', + 'forward-internal', + '--provider', + 'github', + '--access-token', + session.accessToken, + ]; + + this.logger.log('info', '[forwarding] starting CLI'); + const process = spawn(cliPath, args, { stdio: 'pipe' }); + this.state = { state: State.Starting, process }; + + const progressP = new DeferredPromise(); + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t({ + comment: ['do not change link format [Show Log](command), only change the text "Show Log"'], + message: 'Starting port forwarding system ([Show Log]({0}))', + args: ['command:tunnel-forwarding.showLog'] + }), + }, + () => progressP.p, + ); + + let lastPortFormat: string | undefined; + process.on('exit', status => { + const msg = `[forwarding] exited with code ${status}`; + this.logger.log('info', msg); + progressP.complete(); // make sure to clear progress on unexpected exit + if (this.isInStateWithProcess(process)) { + this.state = { state: State.Error, error: msg }; + } + }); + + process.on('error', err => { + this.logger.log('error', `[forwarding] ${err}`); + progressP.complete(); // make sure to clear progress on unexpected exit + if (this.isInStateWithProcess(process)) { + this.state = { state: State.Error, error: String(err) }; + } + }); + + process.stdout + .pipe(splitNewLines()) + .on('data', line => this.logger.log('info', `[forwarding] ${line}`)) + .resume(); + + process.stderr + .pipe(splitNewLines()) + .on('data', line => { + try { + const l: { port_format: string } = JSON.parse(line); + if (l.port_format && l.port_format !== lastPortFormat) { + this.state = { state: State.Active, portFormat: l.port_format, process }; + progressP.complete(); + } + } catch (e) { + this.logger.log('error', `[forwarding] ${line}`); + } + }) + .resume(); + + await new Promise((resolve, reject) => { + process.on('spawn', resolve); + process.on('error', reject); + }); + } +} diff --git a/extensions/tunnel-forwarding/src/split.ts b/extensions/tunnel-forwarding/src/split.ts new file mode 100644 index 0000000000000..6e9d7474604f9 --- /dev/null +++ b/extensions/tunnel-forwarding/src/split.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Transform } from 'stream'; + +export const splitNewLines = () => new StreamSplitter('\n'.charCodeAt(0)); + +/** + * Copied and simplified from src\vs\base\node\nodeStreams.ts + */ +export class StreamSplitter extends Transform { + private buffer: Buffer | undefined; + + constructor(private readonly splitter: number) { + super(); + } + + override _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null, data?: any) => void): void { + if (!this.buffer) { + this.buffer = chunk; + } else { + this.buffer = Buffer.concat([this.buffer, chunk]); + } + + let offset = 0; + while (offset < this.buffer.length) { + const index = this.buffer.indexOf(this.splitter, offset); + if (index === -1) { + break; + } + + this.push(this.buffer.subarray(offset, index + 1)); + offset = index + 1; + } + + this.buffer = offset === this.buffer.length ? undefined : this.buffer.subarray(offset); + callback(); + } + + override _flush(callback: (error?: Error | null, data?: any) => void): void { + if (this.buffer) { + this.push(this.buffer); + } + + callback(); + } +} diff --git a/extensions/tunnel-forwarding/tsconfig.json b/extensions/tunnel-forwarding/tsconfig.json new file mode 100644 index 0000000000000..4769a9faec8f9 --- /dev/null +++ b/extensions/tunnel-forwarding/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + "downlevelIteration": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", + "../../src/vscode-dts/vscode.proposed.tunnelFactory.d.ts" + ] +} diff --git a/extensions/tunnel-forwarding/yarn.lock b/extensions/tunnel-forwarding/yarn.lock new file mode 100644 index 0000000000000..8a3d10f2b65cf --- /dev/null +++ b/extensions/tunnel-forwarding/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@18.x": + version "18.15.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.13.tgz#f64277c341150c979e42b00e4ac289290c9df469" + integrity sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==