Skip to content
Draft

TEST #582

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion code/extensions/che-port/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
"Other"
],
"activationEvents": [
"*"
"*",
"onTunnel"
],
"enabledApiProposals": [
"tunnels",
"tunnelFactory",
"resolvers"
],
"capabilities": {
"virtualWorkspaces": true,
Expand Down
36 changes: 34 additions & 2 deletions code/extensions/che-port/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (c) 2022-2025 Red Hat, Inc.
* Copyright (c) 2022-2026 Red Hat, Inc.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
Expand All @@ -13,8 +13,11 @@
import * as vscode from 'vscode';

import { PortsPlugin } from './ports-plugin';
import { CheTunnelProvider } from './tunnel-provider';

let portsPlugin: PortsPlugin | undefined;
let tunnelProvider: CheTunnelProvider | undefined;
let tunnelProviderDisposable: vscode.Disposable | undefined;

export async function activate(context: vscode.ExtensionContext): Promise<void> {
// if not in a che context, do nothing
Expand All @@ -23,11 +26,40 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
}

portsPlugin = new PortsPlugin(context);
return portsPlugin.start();
await portsPlugin.start();

// Register tunnel provider for localhost port forwarding
// This enables extensions like GitLab Duo that use webviews with localhost servers
tunnelProvider = new CheTunnelProvider(portsPlugin);

try {
tunnelProviderDisposable = await vscode.workspace.registerTunnelProvider(tunnelProvider, {
tunnelFeatures: {
elevation: false,
privacyOptions: [
{ id: 'public', label: 'Public', themeIcon: 'globe' }
],
protocol: true
}
});
context.subscriptions.push(tunnelProviderDisposable);
console.log('[che-port] Tunnel provider registered successfully');
} catch (error) {
console.error('[che-port] Failed to register tunnel provider:', error);
}
}

export function deactivate(): void {
if (tunnelProvider) {
tunnelProvider.dispose();
tunnelProvider = undefined;
}
if (tunnelProviderDisposable) {
tunnelProviderDisposable.dispose();
tunnelProviderDisposable = undefined;
}
if (portsPlugin) {
portsPlugin.stop();
portsPlugin = undefined;
}
}
38 changes: 37 additions & 1 deletion code/extensions/che-port/src/ports-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { EndpointsTreeDataProvider } from './endpoints-tree-data-provider';
import { ListeningPort } from './listening-port';
import { PortChangesDetector } from './port-changes-detector';
import { PortForwardServer } from './port-forward-server';
import { RedirectPortManager } from './tunnel-provider';

/**
* Plugin that is monitoring new port being opened and closed.
Expand All @@ -35,7 +36,7 @@ export interface ForwardedPort {
endpoint: Endpoint;
}

export class PortsPlugin {
export class PortsPlugin implements RedirectPortManager {
// constants
public static readonly LISTEN_ALL_IPV4 = '0.0.0.0';
public static readonly LISTEN_ALL_IPV6 = '::';
Expand Down Expand Up @@ -299,4 +300,39 @@ export class PortsPlugin {
}

async stop(): Promise<void> { }

// RedirectPortManager interface implementation

/**
* Acquire an available code-redirect endpoint for tunnel provider.
* This is used by CheTunnelProvider to get a redirect slot.
*/
acquireRedirectEndpoint(): Endpoint | undefined {
if (this.redirectPorts.length === 0) {
return undefined;
}
return this.redirectPorts.pop();
}

/**
* Release a previously acquired redirect endpoint back to the pool.
* Called when a tunnel is disposed.
*/
releaseRedirectEndpoint(endpoint: Endpoint): void {
// Only add back if it's a valid redirect endpoint and not already in the pool
if (endpoint.name.startsWith(PortsPlugin.SERVER_REDIRECT_PATTERN)) {
const alreadyExists = this.redirectPorts.some(e => e.targetPort === endpoint.targetPort);
if (!alreadyExists) {
this.redirectPorts.push(endpoint);
this.outputChannel.appendLine(`[PortsPlugin] Released redirect endpoint ${endpoint.name} back to pool`);
}
}
}

/**
* Get the output channel for logging from tunnel provider.
*/
getOutputChannel(): vscode.OutputChannel {
return this.outputChannel;
}
}
162 changes: 162 additions & 0 deletions code/extensions/che-port/src/tunnel-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**********************************************************************
* Copyright (c) 2026 Red Hat, Inc.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
***********************************************************************/

/* eslint-disable header/header */

import * as vscode from 'vscode';
import { Endpoint } from './endpoint';
import { PortForwardServer } from './port-forward-server';

/**
* Represents an active tunnel created by CheTunnelProvider.
* Implements vscode.Tunnel interface for VS Code's tunnel management.
*/
class CheTunnel implements vscode.Tunnel {
private readonly onDidDisposeEmitter = new vscode.EventEmitter<void>();
readonly onDidDispose = this.onDidDisposeEmitter.event;

constructor(
public readonly remoteAddress: { port: number; host: string },
public readonly localAddress: string,
public readonly privacy: string,
public readonly protocol: string,
private readonly portForwardServer: PortForwardServer,
private readonly onDispose: () => void
) {}

dispose(): void {
this.portForwardServer.stop();
this.onDispose();
this.onDidDisposeEmitter.fire();
this.onDidDisposeEmitter.dispose();
}
}

/**
* Interface for accessing redirect ports from PortsPlugin.
*/
export interface RedirectPortManager {
/**
* Get an available code-redirect endpoint.
* Returns undefined if no redirect slots are available.
*/
acquireRedirectEndpoint(): Endpoint | undefined;

/**
* Release a previously acquired redirect endpoint.
*/
releaseRedirectEndpoint(endpoint: Endpoint): void;

/**
* Get the output channel for logging.
*/
getOutputChannel(): vscode.OutputChannel;
}

/**
* TunnelProvider implementation for Eclipse Che that uses code-redirect endpoints
* to expose localhost ports to the browser.
*
* This enables extensions like GitLab Duo that use localhost servers for webviews
* to work in browser-based VS Code environments.
*/
export class CheTunnelProvider implements vscode.TunnelProvider {
private activeTunnels: Map<number, CheTunnel> = new Map();

constructor(private readonly redirectPortManager: RedirectPortManager) {}

/**
* Provides a tunnel for the requested port by:
* 1. Acquiring an available code-redirect endpoint
* 2. Starting a TCP proxy from the redirect port to the target localhost port
* 3. Returning the public URL of the redirect endpoint
*/
async provideTunnel(
tunnelOptions: vscode.TunnelOptions,
_tunnelCreationOptions: vscode.TunnelCreationOptions,
_token: vscode.CancellationToken
): Promise<vscode.Tunnel | undefined> {
const targetPort = tunnelOptions.remoteAddress.port;
const targetHost = tunnelOptions.remoteAddress.host;
const outputChannel = this.redirectPortManager.getOutputChannel();

outputChannel.appendLine(`[TunnelProvider] Tunnel requested for ${targetHost}:${targetPort}`);

// Check if we already have a tunnel for this port
if (this.activeTunnels.has(targetPort)) {
outputChannel.appendLine(`[TunnelProvider] Reusing existing tunnel for port ${targetPort}`);
return this.activeTunnels.get(targetPort);
}

// Acquire a free code-redirect endpoint
const redirectEndpoint = this.redirectPortManager.acquireRedirectEndpoint();
if (!redirectEndpoint) {
outputChannel.appendLine(`[TunnelProvider] No free code-redirect slots available for port ${targetPort}`);
return undefined;
}

outputChannel.appendLine(
`[TunnelProvider] Using ${redirectEndpoint.name} (port ${redirectEndpoint.targetPort}) for tunnel to ${targetPort}`
);

// Start TCP proxy from redirect port to target port
const portForwardServer = new PortForwardServer(
redirectEndpoint.targetPort,
targetHost === '127.0.0.1' || targetHost === '0.0.0.0' ? 'localhost' : targetHost,
targetPort
);

try {
await portForwardServer.start();
} catch (error) {
outputChannel.appendLine(`[TunnelProvider] Failed to start port forward: ${error}`);
this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint);
return undefined;
}

// Get the public URL for the redirect endpoint
const publicUrl = redirectEndpoint.url;
if (!publicUrl) {
outputChannel.appendLine(`[TunnelProvider] Redirect endpoint ${redirectEndpoint.name} has no URL`);
portForwardServer.stop();
this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint);
return undefined;
}

outputChannel.appendLine(`[TunnelProvider] Tunnel created: localhost:${targetPort} -> ${publicUrl}`);

// Create the tunnel object
const tunnel = new CheTunnel(
{ port: targetPort, host: targetHost },
publicUrl,
'public',
tunnelOptions.protocol || 'http',
portForwardServer,
() => {
this.activeTunnels.delete(targetPort);
this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint);
outputChannel.appendLine(`[TunnelProvider] Tunnel disposed for port ${targetPort}`);
}
);

this.activeTunnels.set(targetPort, tunnel);
return tunnel;
}

/**
* Dispose all active tunnels.
*/
dispose(): void {
for (const tunnel of this.activeTunnels.values()) {
tunnel.dispose();
}
this.activeTunnels.clear();
}
}
3 changes: 3 additions & 0 deletions code/extensions/che-port/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.tunnels.d.ts",
"../../src/vscode-dts/vscode.proposed.tunnelFactory.d.ts",
"../../src/vscode-dts/vscode.proposed.resolvers.d.ts"
]
}
Loading