mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-19 17:58:39 +00:00
forwarding: add built-in tunnel forwarding extension (#189874)
* 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>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<u16, u32>;
|
||||
#[derive(Default, Clone)]
|
||||
struct PortCount {
|
||||
public: u32,
|
||||
private: u32,
|
||||
}
|
||||
|
||||
impl Index<PortPrivacy> for PortCount {
|
||||
type Output = u32;
|
||||
|
||||
fn index(&self, privacy: PortPrivacy) -> &Self::Output {
|
||||
match privacy {
|
||||
PortPrivacy::Public => &self.public,
|
||||
PortPrivacy::Private => &self.private,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<PortPrivacy> 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<u16, PortCount>;
|
||||
|
||||
/// 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<ActiveTunnel>) {
|
||||
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::<Vec<_>>();
|
||||
let next = self.receiver.borrow().clone();
|
||||
|
||||
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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Result<String, AnyError>>),
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u16>;
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
|
||||
pub struct PortRec {
|
||||
pub number: u16,
|
||||
pub privacy: PortPrivacy,
|
||||
}
|
||||
|
||||
pub type PortList = Vec<PortRec>;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SetPortsParams {
|
||||
|
||||
15
extensions/tunnel-forwarding/.vscode/launch.json
vendored
Normal file
15
extensions/tunnel-forwarding/.vscode/launch.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
5
extensions/tunnel-forwarding/.vscodeignore
Normal file
5
extensions/tunnel-forwarding/.vscodeignore
Normal file
@@ -0,0 +1,5 @@
|
||||
src/**
|
||||
tsconfig.json
|
||||
out/**
|
||||
extension.webpack.config.js
|
||||
yarn.lock
|
||||
20
extensions/tunnel-forwarding/extension.webpack.config.js
Normal file
20
extensions/tunnel-forwarding/extension.webpack.config.js
Normal file
@@ -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']
|
||||
}
|
||||
});
|
||||
BIN
extensions/tunnel-forwarding/media/icon.png
Normal file
BIN
extensions/tunnel-forwarding/media/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
59
extensions/tunnel-forwarding/package.json
Normal file
59
extensions/tunnel-forwarding/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
7
extensions/tunnel-forwarding/package.nls.json
Normal file
7
extensions/tunnel-forwarding/package.nls.json
Normal file
@@ -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"
|
||||
}
|
||||
62
extensions/tunnel-forwarding/src/deferredPromise.ts
Normal file
62
extensions/tunnel-forwarding/src/deferredPromise.ts
Normal file
@@ -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<T = unknown> = (value: T | Promise<T>) => void;
|
||||
|
||||
const enum DeferredOutcome {
|
||||
Resolved,
|
||||
Rejected
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from src\vs\base\common\async.ts
|
||||
*/
|
||||
export class DeferredPromise<T> {
|
||||
|
||||
private completeCallback!: ValueCallback<T>;
|
||||
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<T>;
|
||||
|
||||
constructor() {
|
||||
this.p = new Promise<T>((c, e) => {
|
||||
this.completeCallback = c;
|
||||
this.errorCallback = e;
|
||||
});
|
||||
}
|
||||
|
||||
public complete(value: T) {
|
||||
return new Promise<void>(resolve => {
|
||||
this.completeCallback(value);
|
||||
this.outcome = { outcome: DeferredOutcome.Resolved, value };
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
public error(err: unknown) {
|
||||
return new Promise<void>(resolve => {
|
||||
this.errorCallback(err);
|
||||
this.outcome = { outcome: DeferredOutcome.Rejected, value: err };
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
293
extensions/tunnel-forwarding/src/extension.ts
Normal file
293
extensions/tunnel-forwarding/src/extension.ts
Normal file
@@ -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<void>();
|
||||
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<Tunnel>();
|
||||
private readonly stateChange = new vscode.EventEmitter<StateT>();
|
||||
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<vscode.Tunnel> {
|
||||
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<Tunnel>((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<void>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
49
extensions/tunnel-forwarding/src/split.ts
Normal file
49
extensions/tunnel-forwarding/src/split.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
16
extensions/tunnel-forwarding/tsconfig.json
Normal file
16
extensions/tunnel-forwarding/tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
8
extensions/tunnel-forwarding/yarn.lock
Normal file
8
extensions/tunnel-forwarding/yarn.lock
Normal file
@@ -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==
|
||||
Reference in New Issue
Block a user