diff --git a/extensions/configuration-editing/schemas/attachContainer.schema.json b/extensions/configuration-editing/schemas/attachContainer.schema.json index ec765b8fc24..96c03edabd8 100644 --- a/extensions/configuration-editing/schemas/attachContainer.schema.json +++ b/extensions/configuration-editing/schemas/attachContainer.schema.json @@ -54,6 +54,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -100,6 +105,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [{ "body": { "onAutoForward": "ignore" } }], diff --git a/extensions/configuration-editing/schemas/devContainer.schema.generated.json b/extensions/configuration-editing/schemas/devContainer.schema.generated.json index 90e70c0125a..58d810ff4d6 100644 --- a/extensions/configuration-editing/schemas/devContainer.schema.generated.json +++ b/extensions/configuration-editing/schemas/devContainer.schema.generated.json @@ -162,6 +162,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -215,6 +220,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ @@ -461,6 +471,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -514,6 +529,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ @@ -736,6 +756,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -789,6 +814,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ @@ -977,6 +1007,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -1030,6 +1065,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ @@ -1187,6 +1227,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -1240,6 +1285,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ @@ -1329,4 +1379,4 @@ "additionalProperties": false } ] -} \ No newline at end of file +} diff --git a/extensions/configuration-editing/schemas/devContainer.schema.src.json b/extensions/configuration-editing/schemas/devContainer.schema.src.json index 2987aad16c5..8c5dbce1ac1 100644 --- a/extensions/configuration-editing/schemas/devContainer.schema.src.json +++ b/extensions/configuration-editing/schemas/devContainer.schema.src.json @@ -68,6 +68,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "default": { @@ -120,6 +125,11 @@ "type": "string", "description": "Label that will be shown in the UI for this port.", "default": "Application" + }, + "requireLocalPort": { + "type": "boolean", + "markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.", + "default": false } }, "defaultSnippets": [ diff --git a/src/vs/workbench/contrib/remote/common/remote.contribution.ts b/src/vs/workbench/contrib/remote/common/remote.contribution.ts index c5b45193a55..9dd984dacf8 100644 --- a/src/vs/workbench/contrib/remote/common/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/common/remote.contribution.ts @@ -178,6 +178,11 @@ Registry.as(ConfigurationExtensions.Configuration) type: 'string', description: localize('remote.portsAttributes.label', "Label that will be shown in the UI for this port."), default: localize('remote.portsAttributes.labelDefault', "Application") + }, + 'requireLocalPort': { + type: 'boolean', + markdownDescription: localize('remote.portsAttributes.requireLocalPort', "When true, a modal dialog will show if the chosen local port isn't used for forwarding."), + default: false } }, default: { @@ -216,6 +221,11 @@ Registry.as(ConfigurationExtensions.Configuration) type: 'string', description: localize('remote.portsAttributes.label', "Label that will be shown in the UI for this port."), default: localize('remote.portsAttributes.labelDefault', "Application") + }, + 'requireLocalPort': { + type: 'boolean', + markdownDescription: localize('remote.portsAttributes.requireLocalPort', "When true, a modal dialog will show if the chosen local port isn't used for forwarding."), + default: false } }, defaultSnippets: [{ body: { onAutoForward: 'ignore' } }], diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index efae86516e4..eae45b2d6b2 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as nls from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; @@ -21,6 +22,8 @@ import { hash } from 'vs/base/common/hash'; import { ILogService } from 'vs/platform/log/common/log'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { flatten } from 'vs/base/common/arrays'; +import Severity from 'vs/base/common/severity'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; @@ -141,10 +144,11 @@ export enum OnPortForward { Ignore = 'ignore' } -interface Attributes { +export interface Attributes { label: string | undefined; onAutoForward: OnPortForward | undefined, elevateIfNeeded: boolean | undefined; + requireLocalPort: boolean | undefined; } interface PortRange { start: number, end: number } @@ -182,7 +186,8 @@ export class PortsAttributes extends Disposable { const attributes: Attributes = { label: undefined, onAutoForward: undefined, - elevateIfNeeded: undefined + elevateIfNeeded: undefined, + requireLocalPort: undefined }; while (index >= 0) { const found = this.portsAttributes[index]; @@ -190,15 +195,18 @@ export class PortsAttributes extends Disposable { attributes.onAutoForward = found.onAutoForward ?? attributes.onAutoForward; attributes.elevateIfNeeded = (found.elevateIfNeeded !== undefined) ? found.elevateIfNeeded : attributes.elevateIfNeeded; attributes.label = found.label ?? attributes.label; + attributes.requireLocalPort = found.requireLocalPort; } else { // It's a range or regex, which means that if the attribute is already set, we keep it attributes.onAutoForward = attributes.onAutoForward ?? found.onAutoForward; attributes.elevateIfNeeded = (attributes.elevateIfNeeded !== undefined) ? attributes.elevateIfNeeded : found.elevateIfNeeded; attributes.label = attributes.label ?? found.label; + attributes.requireLocalPort = (attributes.requireLocalPort !== undefined) ? attributes.requireLocalPort : undefined; } index = this.findNextIndex(port, commandLine, this.portsAttributes, index + 1); } - if (attributes.onAutoForward !== undefined || attributes.elevateIfNeeded !== undefined || attributes.label !== undefined) { + if (attributes.onAutoForward !== undefined || attributes.elevateIfNeeded !== undefined + || attributes.label !== undefined || attributes.requireLocalPort !== undefined) { return attributes; } @@ -265,7 +273,8 @@ export class PortsAttributes extends Disposable { key: key, elevateIfNeeded: setting.elevateIfPrivileged, onAutoForward: setting.onAutoForward, - label: setting.label + label: setting.label, + requireLocalPort: setting.requireLocalPort }); } @@ -274,7 +283,8 @@ export class PortsAttributes extends Disposable { this.defaultPortAttributes = { elevateIfNeeded: defaults.elevateIfNeeded, label: defaults.label, - onAutoForward: defaults.onAutoForward + onAutoForward: defaults.onAutoForward, + requireLocalPort: defaults.requireLocalPort }; } @@ -313,6 +323,8 @@ export class PortsAttributes extends Disposable { } } +const MISMATCH_LOCAL_PORT_COOLDOWN = 10 * 1000; // 10 seconds + export class TunnelModel extends Disposable { readonly forwarded: Map; readonly detected: Map; @@ -344,7 +356,8 @@ export class TunnelModel extends Disposable { @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService private readonly remoteAuthorityResolverService: IRemoteAuthorityResolverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IDialogService private readonly dialogService: IDialogService ) { super(); this.configPortsAttributes = new PortsAttributes(configurationService); @@ -458,10 +471,29 @@ export class TunnelModel extends Disposable { } } + private mismatchCooldown = new Date(); + private async showPortMismatchModalIfNeeded(tunnel: RemoteTunnel, expectedLocal: number, attributes: Attributes | undefined) { + if (!tunnel.tunnelLocalPort || !attributes?.requireLocalPort) { + return; + } + if (tunnel.tunnelLocalPort === expectedLocal) { + return; + } + + const newCooldown = new Date(); + if ((this.mismatchCooldown.getTime() + MISMATCH_LOCAL_PORT_COOLDOWN) > newCooldown.getTime()) { + return; + } + this.mismatchCooldown = newCooldown; + const mismatchString = nls.localize('remote.localPortMismatch.single', "Local port {0} could not be used for forwarding to remote port {1}.\n\nThis usually happens when there is already another process using local port {0}.\n\nPort number {2} has been used instead.", + expectedLocal, tunnel.tunnelRemotePort, tunnel.tunnelLocalPort); + return this.dialogService.show(Severity.Info, mismatchString, [nls.localize('remote.localPortMismatch.Ok', "Ok")]); + } + async forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore: boolean = true): Promise { const existingTunnel = mapHasAddressLocalhostOrAllInterfaces(this.forwarded, remote.host, remote.port); - const port = local !== undefined ? local : remote.port; - const attributes = (await this.getAttributes([port]))?.get(port); + const attributes = (await this.getAttributes([remote.port]))?.get(remote.port); + const localPort = (local !== undefined) ? local : remote.port; if (!existingTunnel) { const authority = this.environmentService.remoteAuthority; @@ -469,7 +501,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, (!elevateIfNeeded) ? attributes?.elevateIfNeeded : elevateIfNeeded, isPublic); + const tunnel = await this.tunnelService.openTunnel(addressProvider, remote.host, remote.port, localPort, (!elevateIfNeeded) ? attributes?.elevateIfNeeded : elevateIfNeeded, isPublic); if (tunnel && tunnel.localAddress) { const matchingCandidate = mapHasAddressLocalhostOrAllInterfaces(this._candidates ?? new Map(), remote.host, remote.port); const newForward: Tunnel = { @@ -490,6 +522,7 @@ export class TunnelModel extends Disposable { this.forwarded.set(key, newForward); this.remoteTunnels.set(key, tunnel); await this.storeForwarded(); + await this.showPortMismatchModalIfNeeded(tunnel, localPort, attributes); this._onForwardPort.fire(newForward); return tunnel; } @@ -682,7 +715,8 @@ export class TunnelModel extends Disposable { mergedAttributes.set(port, { elevateIfNeeded: config?.elevateIfNeeded, label: config?.label, - onAutoForward: config?.onAutoForward ?? PortsAttributes.providedActionToAction(provider?.autoForwardAction) + onAutoForward: config?.onAutoForward ?? PortsAttributes.providedActionToAction(provider?.autoForwardAction), + requireLocalPort: config?.requireLocalPort }); }); @@ -742,9 +776,11 @@ class RemoteExplorerService implements IRemoteExplorerService { @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, - @ILogService logService: ILogService + @ILogService logService: ILogService, + @IDialogService dialogService: IDialogService ) { - this._tunnelModel = new TunnelModel(tunnelService, storageService, configurationService, environmentService, remoteAuthorityResolverService, workspaceContextService, logService); + this._tunnelModel = new TunnelModel(tunnelService, storageService, configurationService, environmentService, + remoteAuthorityResolverService, workspaceContextService, logService, dialogService); } set targetType(name: string[]) {