Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support remote port forwarding #13439

Merged
merged 35 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
17ec00f
basics for dev-container support
jonah-iden Jan 19, 2024
5568344
basic creating and connecting to container working
jonah-iden Jan 31, 2024
ec63903
open workspace when opening container
jonah-iden Feb 7, 2024
c4883b7
save and reuse last USed container per workspace
jonah-iden Feb 7, 2024
b45fbc3
restart container if running
jonah-iden Feb 7, 2024
2735888
better container creation extension features
jonah-iden Feb 9, 2024
ad3d133
added dockerfile support
jonah-iden Feb 9, 2024
8b977e4
rebuild container if devcontainer.json has been changed since last use
jonah-iden Feb 9, 2024
5fcf759
fix build
jonah-iden Feb 9, 2024
14c0f1f
fixed checking if container needs rebuild
jonah-iden Feb 13, 2024
d670f49
working port forwarding via exec instance
jonah-iden Feb 20, 2024
c05bd30
review changes
jonah-iden Feb 27, 2024
b45fb37
fix import
jonah-iden Feb 27, 2024
4a18a6f
smaller fixes and added support for multiple devcontainer configurati…
jonah-iden Feb 29, 2024
f984ca2
basic output window for devcontainer build
jonah-iden Mar 15, 2024
1e6dab5
smaller review changes and nicer dockerfile.json detection code
jonah-iden Mar 15, 2024
c70d6f1
fixed build and docuemented implemented devcontainer.json properties
jonah-iden Mar 15, 2024
8262c58
Fix unneeded URI conversion (#13415)
Alexander-Taran Mar 4, 2024
cf1537a
Fix quickpick problems found in IDE testing (#13451)
tsmaeder Mar 5, 2024
3fff276
Fix rending of quickpick buttons (#13342)
tortmayr Mar 13, 2024
ae692d4
electron: allow accessing the metrics endpoint for performance analys…
xai Mar 13, 2024
991b4a4
fixed renaming and moving of open notebooks (#13467)
jonah-iden Mar 13, 2024
72033d0
[playwright] Update documentation
marcdumais-work Mar 13, 2024
5b03b98
basics for dev-container support
jonah-iden Jan 19, 2024
8f12cc3
basic creating and connecting to container working
jonah-iden Jan 31, 2024
2f8ae41
added dockerfile support
jonah-iden Feb 9, 2024
aad07e1
added port forwarding inlcuding ui
jonah-iden Feb 29, 2024
37bf7a5
basic port/address validation
jonah-iden Mar 1, 2024
0afebb3
fixed allready forwarded port checking
jonah-iden Mar 1, 2024
1ab4a36
rebase fixes
jonah-iden Mar 1, 2024
d92d682
Merge branch 'master' into jiden/remote-port-forwarding
jonah-iden Mar 15, 2024
170bdc5
removed unused file
jonah-iden Mar 15, 2024
28e4966
review changes
jonah-iden Mar 19, 2024
6970916
fixed widget focus and message margin
jonah-iden Mar 22, 2024
9c145d6
default port binding now shows as 0.0.0.0
jonah-iden Mar 22, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ interface ContainerTerminalSession {
executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}

interface ContainerTerminalSession {
execution: Docker.Exec,
stdout: WriteStream,
stderr: WriteStream,
executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}

export class RemoteDockerContainerConnection implements RemoteConnection {

id: string;
Expand Down Expand Up @@ -179,12 +186,12 @@ export class RemoteDockerContainerConnection implements RemoteConnection {
this.container = options.container;
}

async forwardOut(socket: Socket): Promise<void> {
async forwardOut(socket: Socket, port?: number): Promise<void> {
const node = `${this.remoteSetupResult.nodeDirectory}/bin/node`;
const devContainerServer = `${this.remoteSetupResult.applicationDirectory}/backend/dev-container-server.js`;
try {
const ttySession = await this.container.exec({
Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${this.remotePort}`],
Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${port ?? this.remotePort}`],
AttachStdin: true, AttachStdout: true, AttachStderr: true
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { nls } from '@theia/core';
import { AbstractViewContribution } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';
import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding-widget';

@injectable()
export class PortForwardingContribution extends AbstractViewContribution<PortForwardingWidget> {
constructor() {
super({
widgetId: PORT_FORWARDING_WIDGET_ID,
widgetName: nls.localizeByDefault('Ports'),
defaultWidgetOptions: {
area: 'bottom'
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { Emitter } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RemotePortForwardingProvider } from '../../electron-common/remote-port-forwarding-provider';

export interface ForwardedPort {
localPort?: number;
address?: string;
origin?: string;
editing: boolean;
}

@injectable()
export class PortForwardingService {

@inject(RemotePortForwardingProvider)
readonly provider: RemotePortForwardingProvider;

protected readonly onDidChangePortsEmitter = new Emitter<void>();
readonly onDidChangePorts = this.onDidChangePortsEmitter.event;

forwardedPorts: ForwardedPort[] = [];

forwardNewPort(origin?: string): ForwardedPort {
const index = this.forwardedPorts.push({ editing: true, origin });
return this.forwardedPorts[index - 1];
}

updatePort(port: ForwardedPort, newAdress: string): void {
const connectionPort = new URLSearchParams(location.search).get('port');
if (!connectionPort) {
// if there is no open remote connection we can't forward a port
return;
}

const parts = newAdress.split(':');
if (parts.length === 2) {
port.address = parts[0];
port.localPort = parseInt(parts[1]);
} else {
port.localPort = parseInt(parts[0]);
}

port.editing = false;

this.provider.forwardPort(parseInt(connectionPort), { port: port.localPort!, address: port.address });
this.onDidChangePortsEmitter.fire();
}

removePort(port: ForwardedPort): void {
const index = this.forwardedPorts.indexOf(port);
if (index !== -1) {
this.forwardedPorts.splice(index, 1);
this.provider.portRemoved({ port: port.localPort! });
this.onDidChangePortsEmitter.fire();
}
}

isValidAddress(address: string): boolean {
const match = address.match(/^(.*:)?\d+$/);
if (!match) {
return false;
}

const port = parseInt(address.includes(':') ? address.split(':')[1] : address);

return !this.forwardedPorts.some(p => p.localPort === port);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { OpenerService, ReactWidget } from '@theia/core/lib/browser';
import { nls, URI } from '@theia/core';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { ForwardedPort, PortForwardingService } from './port-forwarding-service';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';

export const PORT_FORWARDING_WIDGET_ID = 'port-forwarding-widget';

@injectable()
export class PortForwardingWidget extends ReactWidget {

@inject(PortForwardingService)
protected readonly portForwardingService: PortForwardingService;

@inject(OpenerService)
protected readonly openerService: OpenerService;

@inject(ClipboardService)
protected readonly clipboardService: ClipboardService;

@postConstruct()
protected init(): void {
this.id = PORT_FORWARDING_WIDGET_ID;
this.title.label = nls.localizeByDefault('Ports');
this.title.caption = this.title.label;
this.title.closable = true;
this.update();

this.portForwardingService.onDidChangePorts(() => this.update());
}

protected render(): ReactNode {
if (this.portForwardingService.forwardedPorts.length === 0) {
return <div>
<p>
{nls.localizeByDefault('No forwarded ports. Forward a port to access your locally running services over the internet.\n[Forward a Port]({0})').split('\n')[0]}
</p>
{this.renderForwardPortButton()}
</div>;
}

return <div>
<table className='port-table'>
<thead>
<tr>
<th className='port-table-header'>{nls.localizeByDefault('Port')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Address')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Running Process')}</th>
<th className='port-table-header'>{nls.localizeByDefault('Origin')}</th>
</tr>
</thead>
<tbody>
{this.portForwardingService.forwardedPorts.map(port => (
<tr key={port.localPort ?? 'editing'}>
{this.renderPortColumn(port)}
{this.renderAddressColumn(port)}
<td></td>
<td>{port.origin ? nls.localizeByDefault(port.origin) : ''}</td>
</tr>
))}
{!this.portForwardingService.forwardedPorts.some(port => port.editing) && <tr><td>{this.renderForwardPortButton()}</td></tr>}
</tbody>
</table>
</div>;
}

protected renderForwardPortButton(): ReactNode {
return <button className='theia-button' onClick={() => {
this.portForwardingService.forwardNewPort('User Forwarded');
this.update();
}
}>{nls.localizeByDefault('Forward a Port')}</button>;
}

protected renderAddressColumn(port: ForwardedPort): ReactNode {
const address = `${port.address ?? 'localhost'}:${port.localPort}`;
return <td>
<div className='button-cell'>
<span style={{ flexGrow: 1 }} className='forwarded-address' onClick={async e => {
if (e.ctrlKey) {
const uri = new URI(`http://${address}`);
(await this.openerService.getOpener(uri)).open(uri);
}
}} title={nls.localizeByDefault('Follow link') + ' (ctrl/cmd + click)'}>
{port.localPort ? address : ''}
</span>
{
port.localPort &&
<span className='codicon codicon-clippy action-label' title={nls.localizeByDefault('Copy Local Address')} onClick={() => {
this.clipboardService.writeText(address);
}}></span>
}
</div>
</td>;
}

protected renderPortColumn(port: ForwardedPort): ReactNode {
return port.editing ?
<td><PortEditingInput port={port} service={this.portForwardingService} /></td> :
<td>
<div className='button-cell'>
<span style={{ flexGrow: 1 }}>{port.localPort}</span>
<span className='codicon codicon-close action-label' title={nls.localizeByDefault('Stop Forwarding Port')} onClick={() => {
this.portForwardingService.removePort(port);
this.update();
}}></span>
</div>
</td>;
}

}

function PortEditingInput({ port, service }: { port: ForwardedPort, service: PortForwardingService }): React.JSX.Element {
const [error, setError] = React.useState(false);
return <input className={`theia-input forward-port-button${error ? ' port-edit-input-error' : ''}`} port-edit-input-error={error}
autoFocus defaultValue={port.address ? `${port.address}:${port.localPort}` : port.localPort ?? ''}
placeholder={nls.localizeByDefault('Port number or address (eg. 3000 or 10.10.10.10:2000).')}
onKeyDown={e => e.key === 'Enter' && !error && service.updatePort(port, e.currentTarget.value)}
onKeyUp={e => setError(!service.isValidAddress(e.currentTarget.value))}></input>;

}
25 changes: 22 additions & 3 deletions packages/remote/src/electron-browser/remote-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { bindContributionProvider, CommandContribution } from '@theia/core';
import { ContainerModule } from '@theia/core/shared/inversify';
import { FrontendApplicationContribution, WebSocketConnectionProvider } from '@theia/core/lib/browser';
import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser';
import { RemoteSSHContribution } from './remote-ssh-contribution';
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderPath } from '../electron-common/remote-ssh-connection-provider';
import { RemoteFrontendContribution } from './remote-frontend-contribution';
Expand All @@ -26,6 +26,12 @@ import { RemoteStatusService, RemoteStatusServicePath } from '../electron-common
import { ElectronFileDialogService } from '@theia/filesystem/lib/electron-browser/file-dialog/electron-file-dialog-service';
import { RemoteElectronFileDialogService } from './remote-electron-file-dialog-service';
import { bindRemotePreferences } from './remote-preferences';
import { PortForwardingWidget, PORT_FORWARDING_WIDGET_ID } from './port-forwarding/port-forwarding-widget';
import { PortForwardingContribution } from './port-forwarding/port-forwading-contribution';
import { PortForwardingService } from './port-forwarding/port-forwarding-service';
import { RemotePortForwardingProvider, RemoteRemotePortForwardingProviderPath } from '../electron-common/remote-port-forwarding-provider';
import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider';
import '../../src/electron-browser/style/port-forwarding-widget.css';

export default new ContainerModule((bind, _, __, rebind) => {
bind(RemoteFrontendContribution).toSelf().inSingletonScope();
Expand All @@ -42,8 +48,21 @@ export default new ContainerModule((bind, _, __, rebind) => {

bind(RemoteService).toSelf().inSingletonScope();

bind(PortForwardingWidget).toSelf();
bind(WidgetFactory).toDynamicValue(context => ({
id: PORT_FORWARDING_WIDGET_ID,
createWidget: () => context.container.get<PortForwardingWidget>(PortForwardingWidget)
}));

bindViewContribution(bind, PortForwardingContribution);
bind(PortForwardingService).toSelf().inSingletonScope();

bind(RemoteSSHConnectionProvider).toDynamicValue(ctx =>
WebSocketConnectionProvider.createLocalProxy<RemoteSSHConnectionProvider>(ctx.container, RemoteSSHConnectionProviderPath)).inSingletonScope();
ServiceConnectionProvider.createLocalProxy<RemoteSSHConnectionProvider>(ctx.container, RemoteSSHConnectionProviderPath)).inSingletonScope();
bind(RemoteStatusService).toDynamicValue(ctx =>
WebSocketConnectionProvider.createLocalProxy<RemoteStatusService>(ctx.container, RemoteStatusServicePath)).inSingletonScope();
ServiceConnectionProvider.createLocalProxy<RemoteStatusService>(ctx.container, RemoteStatusServicePath)).inSingletonScope();

bind(RemotePortForwardingProvider).toDynamicValue(ctx =>
ServiceConnectionProvider.createLocalProxy<RemotePortForwardingProvider>(ctx.container, RemoteRemotePortForwardingProviderPath)).inSingletonScope();

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/********************************************************************************
* Copyright (C) 2024 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/

.port-table {
width: 100%;
margin: calc(var(--theia-ui-padding) * 2);
table-layout: fixed;
}

.port-table-header {
text-align: left;
}

.forward-port-button {
margin-left: 0;
width: 100%;
}

.button-cell {
display: flex;
padding-right: var(--theia-ui-padding);
}

.forwarded-address:hover {
cursor: pointer;
text-decoration: underline;
}

.port-edit-input-error {
outline-color: var(--theia-inputValidation-errorBorder);
}
Loading
Loading