diff --git a/src/vs/platform/remote/common/tunnel.ts b/src/vs/platform/remote/common/tunnel.ts index b409a1d9b6262..0250f7f000cda 100644 --- a/src/vs/platform/remote/common/tunnel.ts +++ b/src/vs/platform/remote/common/tunnel.ts @@ -5,6 +5,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; +import { isWindows } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; @@ -30,6 +31,10 @@ export interface TunnelCreationOptions { elevationRequired?: boolean; } +export interface TunnelProviderFeatures { + elevation: boolean; +} + export interface ITunnelProvider { forwardPort(tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions): Promise | undefined; } @@ -40,10 +45,11 @@ export interface ITunnelService { readonly tunnels: Promise; readonly onTunnelOpened: Event; readonly onTunnelClosed: Event<{ host: string, port: number }>; + readonly canElevate: boolean; - openTunnel(addressProvider: IAddressProvider | undefined, remoteHost: string | undefined, remotePort: number, localPort?: number): Promise | undefined; + openTunnel(addressProvider: IAddressProvider | undefined, remoteHost: string | undefined, remotePort: number, localPort?: number, elevateIfNeeded?: boolean): Promise | undefined; closeTunnel(remoteHost: string, remotePort: number): Promise; - setTunnelProvider(provider: ITunnelProvider | undefined): IDisposable; + setTunnelProvider(provider: ITunnelProvider | undefined, features: TunnelProviderFeatures): IDisposable; } export function extractLocalHostUriMetaDataForPortMapping(uri: URI): { address: string, port: number } | undefined { @@ -74,6 +80,10 @@ function getOtherLocalhost(host: string): string | undefined { return (host === 'localhost') ? '127.0.0.1' : ((host === '127.0.0.1') ? 'localhost' : undefined); } +export function isPortPrivileged(port: number): boolean { + return !isWindows && (port < 1024); +} + export abstract class AbstractTunnelService implements ITunnelService { declare readonly _serviceBrand: undefined; @@ -83,18 +93,22 @@ export abstract class AbstractTunnelService implements ITunnelService { public onTunnelClosed: Event<{ host: string, port: number }> = this._onTunnelClosed.event; protected readonly _tunnels = new Map }>>(); protected _tunnelProvider: ITunnelProvider | undefined; + protected _canElevate: boolean = false; public constructor( @ILogService protected readonly logService: ILogService ) { } - setTunnelProvider(provider: ITunnelProvider | undefined): IDisposable { + setTunnelProvider(provider: ITunnelProvider | undefined, features: TunnelProviderFeatures): IDisposable { + this._tunnelProvider = provider; if (!provider) { + // clear features + this._canElevate = false; return { dispose: () => { } }; } - this._tunnelProvider = provider; + this._canElevate = features.elevation; return { dispose: () => { this._tunnelProvider = undefined; @@ -102,6 +116,10 @@ export abstract class AbstractTunnelService implements ITunnelService { }; } + public get canElevate(): boolean { + return this._canElevate; + } + public get tunnels(): Promise { return new Promise(async (resolve) => { const tunnels: RemoteTunnel[] = []; @@ -129,7 +147,7 @@ export abstract class AbstractTunnelService implements ITunnelService { this._tunnels.clear(); } - openTunnel(addressProvider: IAddressProvider | undefined, remoteHost: string | undefined, remotePort: number, localPort: number): Promise | undefined { + openTunnel(addressProvider: IAddressProvider | undefined, remoteHost: string | undefined, remotePort: number, localPort?: number, elevateIfNeeded: boolean = false): Promise | undefined { if (!addressProvider) { return undefined; } @@ -138,7 +156,7 @@ export abstract class AbstractTunnelService implements ITunnelService { remoteHost = 'localhost'; } - const resolvedTunnel = this.retainOrCreateTunnel(addressProvider, remoteHost, remotePort, localPort); + const resolvedTunnel = this.retainOrCreateTunnel(addressProvider, remoteHost, remotePort, localPort, elevateIfNeeded); if (!resolvedTunnel) { return resolvedTunnel; } @@ -238,15 +256,11 @@ export abstract class AbstractTunnelService implements ITunnelService { return portMap ? portMap.get(remotePort) : undefined; } - protected abstract retainOrCreateTunnel(addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort?: number): Promise | undefined; - - protected isPortPrivileged(port: number): boolean { - return port < 1024; - } + protected abstract retainOrCreateTunnel(addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort: number | undefined, elevateIfNeeded: boolean): Promise | undefined; } export class TunnelService extends AbstractTunnelService { - protected retainOrCreateTunnel(_addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort?: number | undefined): Promise | undefined { + protected retainOrCreateTunnel(_addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort: number | undefined, elevateIfNeeded: boolean): Promise | undefined { const existing = this.getTunnelFromMap(remoteHost, remotePort); if (existing) { ++existing.refcount; @@ -256,7 +270,7 @@ export class TunnelService extends AbstractTunnelService { if (this._tunnelProvider) { const preferredLocalPort = localPort === undefined ? remotePort : localPort; const tunnelOptions = { remoteAddress: { host: remoteHost, port: remotePort }, localAddressPort: localPort }; - const creationInfo = { elevationRequired: this.isPortPrivileged(preferredLocalPort) }; + const creationInfo = { elevationRequired: elevateIfNeeded ? isPortPrivileged(preferredLocalPort) : false }; const tunnel = this._tunnelProvider.forwardPort(tunnelOptions, creationInfo); if (tunnel) { this.addTunnelToMap(remoteHost, remotePort, tunnel); diff --git a/src/vs/platform/remote/node/tunnelService.ts b/src/vs/platform/remote/node/tunnelService.ts index b48b54838d0ee..5080db52375cb 100644 --- a/src/vs/platform/remote/node/tunnelService.ts +++ b/src/vs/platform/remote/node/tunnelService.ts @@ -11,7 +11,7 @@ import { NodeSocket } from 'vs/base/parts/ipc/node/ipc.net'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { connectRemoteAgentTunnel, IConnectionOptions, IAddressProvider, ISocketFactory } from 'vs/platform/remote/common/remoteAgentConnection'; -import { AbstractTunnelService, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; +import { AbstractTunnelService, isPortPrivileged, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; import { nodeSocketFactory } from 'vs/platform/remote/node/nodeSocketFactory'; import { ISignService } from 'vs/platform/sign/common/sign'; @@ -139,7 +139,7 @@ export class BaseTunnelService extends AbstractTunnelService { super(logService); } - protected retainOrCreateTunnel(addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort?: number): Promise | undefined { + protected retainOrCreateTunnel(addressProvider: IAddressProvider, remoteHost: string, remotePort: number, localPort: number | undefined, elevateIfNeeded: boolean): Promise | undefined { const existing = this.getTunnelFromMap(remoteHost, remotePort); if (existing) { ++existing.refcount; @@ -148,7 +148,7 @@ export class BaseTunnelService extends AbstractTunnelService { if (this._tunnelProvider) { const preferredLocalPort = localPort === undefined ? remotePort : localPort; - const creationInfo = { elevationRequired: this.isPortPrivileged(preferredLocalPort) }; + const creationInfo = { elevationRequired: elevateIfNeeded ? isPortPrivileged(preferredLocalPort) : false }; const tunnelOptions = { remoteAddress: { host: remoteHost, port: remotePort }, localAddressPort: localPort }; const tunnel = this._tunnelProvider.forwardPort(tunnelOptions, creationInfo); if (tunnel) { diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 7a8667537a32b..8eaa108a01190 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -234,10 +234,18 @@ declare module 'vscode' { */ tunnelFactory?: (tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions) => Thenable | undefined; - /** + /**p * Provides filtering for candidate ports. */ showCandidatePort?: (host: string, port: number, detail: string) => Thenable; + + /** + * Lets the resolver declare which tunnel factory features it supports. + * UNDER DISCUSSION! MAY CHANGE SOON. + */ + tunnelFeatures?: { + elevation: boolean; + }; } export namespace workspace { diff --git a/src/vs/workbench/api/browser/mainThreadTunnelService.ts b/src/vs/workbench/api/browser/mainThreadTunnelService.ts index 97e31283b679e..d5cf75289a028 100644 --- a/src/vs/workbench/api/browser/mainThreadTunnelService.ts +++ b/src/vs/workbench/api/browser/mainThreadTunnelService.ts @@ -7,7 +7,7 @@ import { MainThreadTunnelServiceShape, IExtHostContext, MainContext, ExtHostCont import { TunnelDto } from 'vs/workbench/api/common/extHostTunnelService'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { CandidatePort, IRemoteExplorerService, makeAddress } from 'vs/workbench/services/remote/common/remoteExplorerService'; -import { ITunnelProvider, ITunnelService, TunnelCreationOptions, TunnelOptions } from 'vs/platform/remote/common/tunnel'; +import { ITunnelProvider, ITunnelService, TunnelCreationOptions, TunnelProviderFeatures, TunnelOptions } from 'vs/platform/remote/common/tunnel'; import { Disposable } from 'vs/base/common/lifecycle'; import type { TunnelDescription } from 'vs/platform/remote/common/remoteAuthorityResolver'; @@ -52,7 +52,7 @@ export class MainThreadTunnelService extends Disposable implements MainThreadTun this.remoteExplorerService.onFoundNewCandidates(candidates); } - async $setTunnelProvider(): Promise { + async $setTunnelProvider(features: TunnelProviderFeatures): Promise { const tunnelProvider: ITunnelProvider = { forwardPort: (tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions) => { const forward = this._proxy.$forwardPort(tunnelOptions, tunnelCreationOptions); @@ -75,7 +75,7 @@ export class MainThreadTunnelService extends Disposable implements MainThreadTun return undefined; } }; - this.tunnelService.setTunnelProvider(tunnelProvider); + this.tunnelService.setTunnelProvider(tunnelProvider, features); } dispose(): void { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 99518bee8ab3a..ab836952d1717 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -48,7 +48,7 @@ import * as search from 'vs/workbench/services/search/common/search'; import { EditorGroupColumn, SaveReason } from 'vs/workbench/common/editor'; import { ExtensionActivationReason } from 'vs/workbench/api/common/extHostExtensionActivator'; import { TunnelDto } from 'vs/workbench/api/common/extHostTunnelService'; -import { TunnelCreationOptions, TunnelOptions } from 'vs/platform/remote/common/tunnel'; +import { TunnelCreationOptions, TunnelProviderFeatures, TunnelOptions } from 'vs/platform/remote/common/tunnel'; import { Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor, InternalTimelineOptions } from 'vs/workbench/contrib/timeline/common/timeline'; import { revive } from 'vs/base/common/marshalling'; import { IProcessedOutput, INotebookDisplayOrder, NotebookCellMetadata, NotebookDocumentMetadata, ICellEditOperation, NotebookCellsChangedEventDto, NotebookDataDto, IMainCellDto, INotebookDocumentFilter, INotebookKernelInfoDto2, TransientMetadata, INotebookCellStatusBarEntry, ICellRange, INotebookDecorationRenderOptions, INotebookExclusiveDocumentFilter } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -972,7 +972,7 @@ export interface MainThreadTunnelServiceShape extends IDisposable { $openTunnel(tunnelOptions: TunnelOptions, source: string | undefined): Promise; $closeTunnel(remote: { host: string, port: number }): Promise; $getTunnels(): Promise; - $setTunnelProvider(): Promise; + $setTunnelProvider(features: TunnelProviderFeatures): Promise; $onFoundNewCandidates(candidates: { host: string, port: number, detail: string }[]): Promise; } diff --git a/src/vs/workbench/api/node/extHostTunnelService.ts b/src/vs/workbench/api/node/extHostTunnelService.ts index 2cd5c9ecc0eb7..e5610491a5b98 100644 --- a/src/vs/workbench/api/node/extHostTunnelService.ts +++ b/src/vs/workbench/api/node/extHostTunnelService.ts @@ -195,7 +195,9 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe } if (provider.tunnelFactory) { this._forwardPortProvider = provider.tunnelFactory; - await this._proxy.$setTunnelProvider(); + await this._proxy.$setTunnelProvider(provider.tunnelFeatures ?? { + elevation: false + }); } } else { this._forwardPortProvider = undefined; diff --git a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts index cf13f322b967d..275926c7c5e53 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts @@ -21,7 +21,7 @@ import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { OperatingSystem } from 'vs/base/common/platform'; -import { RemoteTunnel } from 'vs/platform/remote/common/tunnel'; +import { isPortPrivileged, ITunnelService, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IActivityService, NumberBadge } from 'vs/workbench/services/activity/common/activity'; @@ -202,7 +202,8 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon @IContextKeyService readonly contextKeyService: IContextKeyService, @IConfigurationService readonly configurationService: IConfigurationService, @IDebugService readonly debugService: IDebugService, - @IRemoteAgentService readonly remoteAgentService: IRemoteAgentService + @IRemoteAgentService readonly remoteAgentService: IRemoteAgentService, + @ITunnelService readonly tunnelService: ITunnelService ) { super(); if (!this.environmentService.remoteAuthority) { @@ -212,9 +213,9 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon remoteAgentService.getEnvironment().then(environment => { if (environment?.os === OperatingSystem.Windows) { this._register(new WindowsAutomaticPortForwarding(terminalService, notificationService, openerService, - remoteExplorerService, configurationService, debugService)); + remoteExplorerService, configurationService, debugService, tunnelService)); } else if (environment?.os === OperatingSystem.Linux) { - this._register(new LinuxAutomaticPortForwarding(configurationService, remoteExplorerService, notificationService, openerService)); + this._register(new LinuxAutomaticPortForwarding(configurationService, remoteExplorerService, notificationService, openerService, tunnelService)); } }); } @@ -228,7 +229,8 @@ class ForwardedPortNotifier extends Disposable { constructor(private readonly notificationService: INotificationService, private readonly remoteExplorerService: IRemoteExplorerService, - private readonly openerService: IOpenerService) { + private readonly openerService: IOpenerService, + private readonly tunnelService: ITunnelService) { super(); this.lastNotifyTime = new Date(); this.lastNotifyTime.setFullYear(this.lastNotifyTime.getFullYear() - 1); @@ -278,18 +280,31 @@ class ForwardedPortNotifier extends Disposable { }); } + private basicMessage(tunnel: RemoteTunnel) { + return nls.localize('remote.tunnelsView.automaticForward', "Your service running on port {0} is available. ", + tunnel.tunnelRemotePort); + } + + private linkMessage() { + return nls.localize('remote.tunnelsView.notificationLink', "[See all forwarded ports](command:{0}.focus)", TunnelPanel.ID); + } + private showNotification(tunnel: RemoteTunnel) { if (this.lastNotification) { this.lastNotification.close(); } - const address = makeAddress(tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort); - const message = nls.localize('remote.tunnelsView.automaticForward', "Your service running on port {0} is available. [See all forwarded ports](command:{1}.focus)", - tunnel.tunnelRemotePort, TunnelPanel.ID); - const browserChoice: IPromptChoice = { - label: OpenPortInBrowserAction.LABEL, - run: () => OpenPortInBrowserAction.run(this.remoteExplorerService.tunnelModel, this.openerService, address) - }; - this.lastNotification = this.notificationService.prompt(Severity.Info, message, [browserChoice], { neverShowAgain: { id: 'remote.tunnelsView.autoForwardNeverShow', isSecondary: true } }); + let message = this.basicMessage(tunnel); + const choices = [this.openChoice(tunnel)]; + + if (tunnel.tunnelLocalPort !== undefined && this.tunnelService.canElevate && isPortPrivileged(tunnel.tunnelRemotePort)) { + // Privileged ports are not on Windows, so it's safe to use "superuser" + message += nls.localize('remote.tunnelsView.elevationMessage', "You'll need to run as superuser to use port {0} locally. ", tunnel.tunnelRemotePort); + choices.unshift(this.elevateChoice(tunnel)); + } + + message += this.linkMessage(); + + this.lastNotification = this.notificationService.prompt(Severity.Info, message, choices, { neverShowAgain: { id: 'remote.tunnelsView.autoForwardNeverShow', isSecondary: true } }); this.lastShownPort = tunnel.tunnelRemotePort; this.lastNotifyTime = new Date(); this.lastNotification.onDidClose(() => { @@ -297,6 +312,37 @@ class ForwardedPortNotifier extends Disposable { this.lastShownPort = undefined; }); } + + private openChoice(tunnel: RemoteTunnel): IPromptChoice { + const address = makeAddress(tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort); + return { + label: OpenPortInBrowserAction.LABEL, + run: () => OpenPortInBrowserAction.run(this.remoteExplorerService.tunnelModel, this.openerService, address) + }; + } + + private elevateChoice(tunnel: RemoteTunnel): IPromptChoice { + return { + // Privileged ports are not on Windows, so it's ok to stick to just "sudo". + label: nls.localize('remote.tunnelsView.elevationButton', "Use Port {0} as Sudo...", tunnel.tunnelRemotePort), + run: async () => { + await this.remoteExplorerService.close({ host: tunnel.tunnelRemoteHost, port: tunnel.tunnelRemotePort }); + const newTunnel = await this.remoteExplorerService.forward({ host: tunnel.tunnelRemoteHost, port: tunnel.tunnelRemotePort }, tunnel.tunnelRemotePort, undefined, undefined, true); + if (!newTunnel) { + return; + } + if (this.lastNotification) { + this.lastNotification.close(); + } + this.lastShownPort = newTunnel.tunnelRemotePort; + this.lastNotification = this.notificationService.prompt(Severity.Info, this.basicMessage(newTunnel) + this.linkMessage(), [this.openChoice(newTunnel)], { neverShowAgain: { id: 'remote.tunnelsView.autoForwardNeverShow', isSecondary: true } }); + this.lastNotification.onDidClose(() => { + this.lastNotification = undefined; + this.lastShownPort = undefined; + }); + } + }; + } } class WindowsAutomaticPortForwarding extends Disposable { @@ -310,10 +356,11 @@ class WindowsAutomaticPortForwarding extends Disposable { readonly openerService: IOpenerService, private readonly remoteExplorerService: IRemoteExplorerService, private readonly configurationService: IConfigurationService, - private readonly debugService: IDebugService + private readonly debugService: IDebugService, + readonly tunnelService: ITunnelService ) { super(); - this.notifier = new ForwardedPortNotifier(notificationService, remoteExplorerService, openerService); + this.notifier = new ForwardedPortNotifier(notificationService, remoteExplorerService, openerService, tunnelService); this._register(configurationService.onDidChangeConfiguration((e) => { if (e.affectsConfiguration(PORT_AUTO_FORWARD_SETTING)) { this.tryStartStopUrlFinder(); @@ -372,10 +419,11 @@ class LinuxAutomaticPortForwarding extends Disposable { private readonly configurationService: IConfigurationService, readonly remoteExplorerService: IRemoteExplorerService, readonly notificationService: INotificationService, - readonly openerService: IOpenerService + readonly openerService: IOpenerService, + readonly tunnelService: ITunnelService ) { super(); - this.notifier = new ForwardedPortNotifier(notificationService, remoteExplorerService, openerService); + this.notifier = new ForwardedPortNotifier(notificationService, remoteExplorerService, openerService, tunnelService); this._register(configurationService.onDidChangeConfiguration(async (e) => { if (e.affectsConfiguration(PORT_AUTO_FORWARD_SETTING)) { await this.startStopCandidateListener(); diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 74214500bd3ec..7b2e4a85e0345 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -37,7 +37,7 @@ import { IThemeService, ThemeIcon } from 'vs/platform/theme/common/themeService' import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPane'; import { URI } from 'vs/base/common/uri'; -import { RemoteTunnel } from 'vs/platform/remote/common/tunnel'; +import { isPortPrivileged, ITunnelService, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -312,14 +312,14 @@ class TunnelTreeRenderer extends Disposable implements ITreeRenderer { const message = editableData.validationMessage(value); - if (!message || message.severity !== Severity.Error) { + if (!message) { return null; } return { content: message.content, formatContent: true, - type: MessageType.ERROR + type: message.severity === Severity.Error ? MessageType.ERROR : MessageType.INFO }; } }, @@ -763,17 +763,6 @@ export class TunnelPanelDescriptor implements IViewDescriptor { } } -function validationMessage(validationString: string | null): { content: string, severity: Severity } | null { - if (!validationString) { - return null; - } - - return { - content: validationString, - severity: Severity.Error - }; -} - namespace LabelTunnelAction { export const ID = 'remote.tunnel.label'; export const LABEL = nls.localize('remote.tunnel.label', "Set Label"); @@ -803,6 +792,7 @@ namespace LabelTunnelAction { const invalidPortString: string = nls.localize('remote.tunnelsView.portNumberValid', "Forwarded port is invalid."); const maxPortNumber: number = 65536; const invalidPortNumberString: string = nls.localize('remote.tunnelsView.portNumberToHigh', "Port number must be \u2265 0 and < {0}.", maxPortNumber); +const requiresSudoString: string = nls.localize('remote.tunnelView.inlineElevationMessage', "Requires Sudo"); export namespace ForwardPortAction { export const INLINE_ID = 'remote.tunnel.forwardInline'; @@ -811,12 +801,14 @@ export namespace ForwardPortAction { export const TREEITEM_LABEL = nls.localize('remote.tunnel.forwardItem', "Forward Port"); const forwardPrompt = nls.localize('remote.tunnel.forwardPrompt', "Port number or address (eg. 3000 or 10.10.10.10:2000)."); - function validateInput(value: string): string | null { + function validateInput(value: string, canElevate: boolean): { content: string, severity: Severity } | null { const parsed = parseAddress(value); if (!parsed) { - return invalidPortString; + return { content: invalidPortString, severity: Severity.Error }; } else if (parsed.port >= maxPortNumber) { - return invalidPortNumberString; + return { content: invalidPortNumberString, severity: Severity.Error }; + } else if (canElevate && isPortPrivileged(parsed.port)) { + return { content: requiresSudoString, severity: Severity.Info }; } return null; } @@ -831,6 +823,7 @@ export namespace ForwardPortAction { return async (accessor, arg) => { const remoteExplorerService = accessor.get(IRemoteExplorerService); const notificationService = accessor.get(INotificationService); + const tunnelService = accessor.get(ITunnelService); if (arg instanceof TunnelItem) { remoteExplorerService.forward({ host: arg.remoteHost, port: arg.remotePort }).then(tunnel => error(notificationService, tunnel, arg.remoteHost, arg.remotePort)); } else { @@ -838,11 +831,11 @@ export namespace ForwardPortAction { onFinish: async (value, success) => { let parsed: { host: string, port: number } | undefined; if (success && (parsed = parseAddress(value))) { - remoteExplorerService.forward({ host: parsed.host, port: parsed.port }).then(tunnel => error(notificationService, tunnel, parsed!.host, parsed!.port)); + remoteExplorerService.forward({ host: parsed.host, port: parsed.port }, undefined, undefined, undefined, true).then(tunnel => error(notificationService, tunnel, parsed!.host, parsed!.port)); } remoteExplorerService.setEditable(undefined, null); }, - validationMessage: (value) => validationMessage(validateInput(value)), + validationMessage: (value) => validateInput(value, tunnelService.canElevate), placeholder: forwardPrompt }); } @@ -855,14 +848,15 @@ export namespace ForwardPortAction { const notificationService = accessor.get(INotificationService); const viewsService = accessor.get(IViewsService); const quickInputService = accessor.get(IQuickInputService); + const tunnelService = accessor.get(ITunnelService); await viewsService.openView(TunnelPanel.ID, true); const value = await quickInputService.input({ prompt: forwardPrompt, - validateInput: (value) => Promise.resolve(validateInput(value)) + validateInput: (value) => Promise.resolve(validateInput(value, tunnelService.canElevate)?.content) }); let parsed: { host: string, port: number } | undefined; if (value && (parsed = parseAddress(value))) { - remoteExplorerService.forward({ host: parsed.host, port: parsed.port }).then(tunnel => error(notificationService, tunnel, parsed!.host, parsed!.port)); + remoteExplorerService.forward({ host: parsed.host, port: parsed.port }, undefined, undefined, undefined, true).then(tunnel => error(notificationService, tunnel, parsed!.host, parsed!.port)); } }; } @@ -1039,11 +1033,13 @@ namespace ChangeLocalPortAction { export const ID = 'remote.tunnel.changeLocalPort'; export const LABEL = nls.localize('remote.tunnel.changeLocalPort', "Change Local Port"); - function validateInput(value: string): string | null { + function validateInput(value: string, canElevate: boolean): { content: string, severity: Severity } | null { if (!value.match(/^[0-9]+$/)) { - return invalidPortString; + return { content: invalidPortString, severity: Severity.Error }; } else if (Number(value) >= maxPortNumber) { - return invalidPortNumberString; + return { content: invalidPortNumberString, severity: Severity.Error }; + } else if (canElevate && isPortPrivileged(Number(value))) { + return { content: requiresSudoString, severity: Severity.Info }; } return null; } @@ -1052,6 +1048,7 @@ namespace ChangeLocalPortAction { return async (accessor, arg) => { const remoteExplorerService = accessor.get(IRemoteExplorerService); const notificationService = accessor.get(INotificationService); + const tunnelService = accessor.get(ITunnelService); const context = (arg !== undefined || arg instanceof TunnelItem) ? arg : accessor.get(IContextKeyService).getContextKeyValue(TunnelViewSelectionKeyName); if (context instanceof TunnelItem) { remoteExplorerService.setEditable(context, { @@ -1060,13 +1057,13 @@ namespace ChangeLocalPortAction { if (success) { await remoteExplorerService.close({ host: context.remoteHost, port: context.remotePort }); const numberValue = Number(value); - const newForward = await remoteExplorerService.forward({ host: context.remoteHost, port: context.remotePort }, numberValue, context.name); + const newForward = await remoteExplorerService.forward({ host: context.remoteHost, port: context.remotePort }, numberValue, context.name, undefined, true); if (newForward && newForward.tunnelLocalPort !== numberValue) { notificationService.warn(nls.localize('remote.tunnel.changeLocalPortNumber', "The local port {0} is not available. Port number {1} has been used instead", value, newForward.tunnelLocalPort ?? newForward.localAddress)); } } }, - validationMessage: (value) => validationMessage(validateInput(value)), + validationMessage: (value) => validateInput(value, tunnelService.canElevate), placeholder: nls.localize('remote.tunnelsView.changePort', "New local port") }); } diff --git a/src/vs/workbench/contrib/remote/common/tunnelFactory.ts b/src/vs/workbench/contrib/remote/common/tunnelFactory.ts index b3c0f17c8b43e..801f30bca1279 100644 --- a/src/vs/workbench/contrib/remote/common/tunnelFactory.ts +++ b/src/vs/workbench/contrib/remote/common/tunnelFactory.ts @@ -42,7 +42,7 @@ export class TunnelFactoryContribution extends Disposable implements IWorkbenchC }); }); } - })); + }, environmentService.options?.tunnelProvider?.features ?? { elevation: false })); remoteExplorerService.setTunnelInformation(undefined); } } diff --git a/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts b/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts index f7112d490683b..6478fa4649c78 100644 --- a/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts +++ b/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts @@ -559,6 +559,7 @@ class SimpleTunnelService implements ITunnelService { declare readonly _serviceBrand: undefined; tunnels: Promise = Promise.resolve([]); + canElevate: boolean = false; onTunnelOpened = Event.None; onTunnelClosed = Event.None; diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index bc81f57457542..99283b3c86b7b 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -201,7 +201,7 @@ export class TunnelModel extends Disposable { } } - async forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string): Promise { + async forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean): Promise { const existingTunnel = mapHasAddressLocalhostOrAllInterfaces(this.forwarded, remote.host, remote.port); if (!existingTunnel) { const authority = this.environmentService.remoteAuthority; @@ -209,7 +209,7 @@ export class TunnelModel extends Disposable { getAddress: async () => { return (await this.remoteAuthorityResolverService.resolveAuthority(authority)).authority; } } : undefined; - const tunnel = await this.tunnelService.openTunnel(addressProvider, remote.host, remote.port, local); + const tunnel = await this.tunnelService.openTunnel(addressProvider, remote.host, remote.port, local, elevateIfNeeded); if (tunnel && tunnel.localAddress) { const matchingCandidate = mapHasAddressLocalhostOrAllInterfaces(this._candidates ?? new Map(), remote.host, remote.port); const newForward: Tunnel = { @@ -362,7 +362,7 @@ export interface IRemoteExplorerService { onDidChangeEditable: Event; setEditable(tunnelItem: ITunnelItem | undefined, data: IEditableData | null): void; getEditableData(tunnelItem: ITunnelItem | undefined): IEditableData | undefined; - forward(remote: { host: string, port: number }, localPort?: number, name?: string, source?: string): Promise; + forward(remote: { host: string, port: number }, localPort?: number, name?: string, source?: string, elevateIfNeeded?: boolean): Promise; close(remote: { host: string, port: number }): Promise; setTunnelInformation(tunnelInformation: TunnelInformation | undefined): void; setCandidateFilter(filter: ((candidates: CandidatePort[]) => Promise) | undefined): IDisposable; @@ -415,8 +415,8 @@ class RemoteExplorerService implements IRemoteExplorerService { return this._tunnelModel; } - forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string): Promise { - return this.tunnelModel.forward(remote, local, name, source); + forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean): Promise { + return this.tunnelModel.forward(remote, local, name, source, elevateIfNeeded); } close(remote: { host: string, port: number }): Promise { diff --git a/src/vs/workbench/workbench.web.api.ts b/src/vs/workbench/workbench.web.api.ts index 53e3c80ca2432..5d2040fa465c9 100644 --- a/src/vs/workbench/workbench.web.api.ts +++ b/src/vs/workbench/workbench.web.api.ts @@ -19,6 +19,7 @@ import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IProductConfiguration } from 'vs/platform/product/common/productService'; import { mark } from 'vs/base/common/performance'; import { ICredentialsProvider } from 'vs/workbench/services/credentials/common/credentials'; +import { TunnelProviderFeatures } from 'vs/platform/remote/common/tunnel'; interface IResourceUriProvider { (uri: URI): URI; @@ -46,9 +47,14 @@ interface ITunnelProvider { tunnelFactory?: ITunnelFactory; /** - * Support for filtering candidate ports + * Support for filtering candidate ports. */ showPortCandidate?: IShowPortCandidate; + + /** + * The features that the tunnel provider supports. + */ + features?: TunnelProviderFeatures; } interface ITunnelFactory {