-
Notifications
You must be signed in to change notification settings - Fork 43
fix: propagate proxy settings to SSH environment #1012
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import * as vscode from "vscode"; | ||
|
|
||
| import type { Memento } from "vscode"; | ||
|
|
||
| const DISMISS = "Don't Show Again"; | ||
|
|
||
| /** globalState keys under which "Don't Show Again" dismissals are stored. */ | ||
| export const DISMISSIBLE_NOTIFICATION_KEYS = [ | ||
| "coder.proxyUseLocalServerWarningDismissed", | ||
| ] as const; | ||
|
|
||
| export type DismissibleNotificationKey = | ||
| (typeof DISMISSIBLE_NOTIFICATION_KEYS)[number]; | ||
|
|
||
| export class DismissibleNotifier { | ||
| public constructor(private readonly globalState: Memento) {} | ||
|
|
||
| /** | ||
| * Show a warning notification with a "Don't Show Again" button that persists | ||
| * dismissal under `key`. Returns the chosen action, or undefined if dismissed, | ||
| * closed, or already dismissed before. Pass `modal` to block until the user | ||
| * answers; non-modal toasts can auto-dismiss, so blocking callers must set it. | ||
| */ | ||
| public async showDismissible( | ||
| key: DismissibleNotificationKey, | ||
| message: string, | ||
| { | ||
| actions = [], | ||
| modal = false, | ||
| }: { actions?: string[]; modal?: boolean } = {}, | ||
| ): Promise<string | undefined> { | ||
| if (this.globalState.get<boolean>(key)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const choice = await vscode.window.showWarningMessage( | ||
| message, | ||
| { modal }, | ||
| ...actions, | ||
| DISMISS, | ||
| ); | ||
|
|
||
| if (choice === DISMISS) { | ||
| await this.globalState.update(key, true); | ||
| return undefined; | ||
| } | ||
| return choice; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { getProxyForUrl } from "../api/proxy"; | ||
|
|
||
| import type { WorkspaceConfiguration } from "vscode"; | ||
|
|
||
| type Environment = Record<string, string | undefined>; | ||
| type PreviousValue = [key: string, existed: boolean, value: string | undefined]; | ||
| type SshEnvironment = Partial< | ||
| Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string> | ||
| >; | ||
|
|
||
| /** | ||
| * The settings {@link getSshProxyEnvironment} reads, paired with display titles. | ||
| * Watch these to prompt for a reload when the SSH proxy environment changes. | ||
| */ | ||
| export const SSH_PROXY_SETTINGS: ReadonlyArray<{ | ||
| setting: string; | ||
| title: string; | ||
| }> = [ | ||
| { setting: "http.proxy", title: "HTTP Proxy" }, | ||
| { setting: "http.noProxy", title: "HTTP No Proxy" }, | ||
| { setting: "coder.proxyBypass", title: "Proxy Bypass" }, | ||
| ]; | ||
|
|
||
| /** | ||
| * Sets SSH-related environment variables on this extension host's process.env so | ||
| * the spawned `coder ssh` ProxyCommand inherits them. For now that is just the | ||
| * proxy configuration (HTTP_PROXY/HTTPS_PROXY/NO_PROXY): the coder CLI reads them | ||
| * like any Go HTTP client and has no proxy flag. We mutate process.env rather | ||
| * than baking values into the SSH config so no credentialed proxy URL is written | ||
| * to disk and multiple windows onto the same workspace stay independent. | ||
| * | ||
| * Best-effort: only processes spawned afterwards inherit the change. MS VS Code | ||
| * with `remote.SSH.useLocalServer=false` spawns ssh off a path that does not | ||
| * inherit, so propagation there needs `useLocalServer=true`. Returns a disposable | ||
| * that restores the previous values. | ||
|
Comment on lines
+32
to
+35
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would the location of the SSH binary change whether environment variables are inherited? Or by path do you mean the code path and not the binary path? If I understand correctly, And then |
||
| */ | ||
| export function applySshEnvironment( | ||
| baseUrl: string, | ||
| cfg: Pick<WorkspaceConfiguration, "get">, | ||
| env: Environment = process.env, | ||
| ): { dispose(): void } { | ||
| return applyEnvironment(getSshProxyEnvironment(baseUrl, cfg), env); | ||
| } | ||
|
|
||
| /** | ||
| * The proxy portion of the SSH environment. Exposed so callers can check whether | ||
| * a proxy actually applies to a deployment via `.HTTP_PROXY`. | ||
| */ | ||
| export function getSshProxyEnvironment( | ||
| baseUrl: string, | ||
| cfg: Pick<WorkspaceConfiguration, "get">, | ||
| ): SshEnvironment { | ||
| const httpProxy = getSetting(cfg, "http.proxy"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nbd but could just do |
||
| const noProxy = getSetting(cfg, "coder.proxyBypass") ?? getHttpNoProxy(cfg); | ||
| const proxy = httpProxy | ||
| ? getProxyForUrl(baseUrl, httpProxy, noProxy, undefined) | ||
|
Comment on lines
+55
to
+56
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we call this differently here than in the plugin? We could just pass the fallback as the fourth option to get the same behavior, right? I think maybe it is so we can use Actually, there is no need to even set Or, maybe better to skip |
||
| : ""; | ||
|
|
||
| return { | ||
| ...(proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy } : {}), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it right to set both |
||
| ...(noProxy ? { NO_PROXY: noProxy } : {}), | ||
| }; | ||
| } | ||
|
|
||
| function applyEnvironment( | ||
| values: SshEnvironment, | ||
| env: Environment, | ||
| ): { dispose(): void } { | ||
| const previous: PreviousValue[] = []; | ||
| for (const [key, value] of Object.entries(values)) { | ||
| if (value === undefined) { | ||
| continue; | ||
| } | ||
| for (const envKey of getEnvKeys(env, key)) { | ||
| previous.push([envKey, Object.hasOwn(env, envKey), env[envKey]]); | ||
| env[envKey] = value; | ||
| } | ||
| } | ||
|
|
||
| let disposed = false; | ||
| return { | ||
| dispose: () => { | ||
| if (disposed) { | ||
| return; | ||
| } | ||
| disposed = true; | ||
| for (let i = previous.length - 1; i >= 0; i--) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this not using a |
||
| const [key, existed, value] = previous[i]; | ||
| if (existed) { | ||
| env[key] = value; | ||
| } else { | ||
| delete env[key]; | ||
| } | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function getEnvKeys(env: Environment, key: string): string[] { | ||
| const keys = Object.keys(env).filter( | ||
| (envKey) => envKey.toLowerCase() === key.toLowerCase(), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Environment variables are case-sensitive (at least, in Linux, Windows might be different) so I think we should not compare with lowercase or we technically cause collisions. |
||
| ); | ||
| return keys.length > 0 ? keys : [key]; | ||
| } | ||
|
|
||
| function getSetting( | ||
| cfg: Pick<WorkspaceConfiguration, "get">, | ||
| setting: string, | ||
| ): string | undefined { | ||
| const value = cfg.get<string | null>(setting); | ||
| return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| } | ||
|
|
||
| function getHttpNoProxy( | ||
| cfg: Pick<WorkspaceConfiguration, "get">, | ||
| ): string | undefined { | ||
| return ( | ||
| cfg | ||
| .get<string[]>("http.noProxy", []) | ||
| .map((value) => value.trim()) | ||
| .filter(Boolean) | ||
| .join(",") || undefined | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,4 @@ | ||
| import { isAxiosError } from "axios"; | ||
| import { type Api } from "coder/site/src/api/api"; | ||
| import { | ||
| type Workspace, | ||
| type WorkspaceAgent, | ||
| } from "coder/site/src/api/typesGenerated"; | ||
| import * as fs from "node:fs/promises"; | ||
| import * as os from "node:os"; | ||
| import * as path from "node:path"; | ||
|
|
@@ -20,18 +15,11 @@ import { extractAgents } from "../api/api-helper"; | |
| import { AuthInterceptor } from "../api/authInterceptor"; | ||
| import { CoderApi } from "../api/coderApi"; | ||
| import { needToken } from "../api/utils"; | ||
| import { type Commands } from "../commands"; | ||
| import { | ||
| CONFIG_CHANGE_DEBOUNCE_MS, | ||
| watchConfigurationChanges, | ||
| } from "../configWatcher"; | ||
| import { version as cliVersion } from "../core/cliExec"; | ||
| import { type CliManager } from "../core/cliManager"; | ||
| import { type ServiceContainer } from "../core/container"; | ||
| import { type ContextManager } from "../core/contextManager"; | ||
| import { type StartupMode } from "../core/mementoManager"; | ||
| import { type PathResolver } from "../core/pathResolver"; | ||
| import { type SecretsManager } from "../core/secretsManager"; | ||
| import { toError } from "../error/errorUtils"; | ||
| import { featureSetForVersion, type FeatureSet } from "../featureSet"; | ||
| import { Inbox } from "../inbox"; | ||
|
|
@@ -40,8 +28,6 @@ import { | |
| RemoteSetupTelemetry, | ||
| type RemoteSetupTracer, | ||
| } from "../instrumentation/remoteSetup"; | ||
| import { type Logger } from "../logging/logger"; | ||
| import { type LoginCoordinator } from "../login/loginCoordinator"; | ||
| import { OAuthSessionManager } from "../oauth/sessionManager"; | ||
| import { | ||
| type CliAuth, | ||
|
|
@@ -61,6 +47,11 @@ import { | |
| import { vscodeProposed } from "../vscodeProposed"; | ||
| import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; | ||
|
|
||
| import { | ||
| applySshEnvironment, | ||
| getSshProxyEnvironment, | ||
| SSH_PROXY_SETTINGS, | ||
| } from "./environment"; | ||
| import { | ||
| SshConfig, | ||
| type SshValues, | ||
|
|
@@ -73,6 +64,23 @@ import { SshProcessMonitor } from "./sshProcess"; | |
| import { computeSshProperties, sshSupportsSetEnv } from "./sshSupport"; | ||
| import { WorkspaceStateMachine } from "./workspaceStateMachine"; | ||
|
|
||
| import type { Api } from "coder/site/src/api/api"; | ||
| import type { | ||
| Workspace, | ||
| WorkspaceAgent, | ||
| } from "coder/site/src/api/typesGenerated"; | ||
|
|
||
| import type { Commands } from "../commands"; | ||
| import type { CliManager } from "../core/cliManager"; | ||
| import type { ServiceContainer } from "../core/container"; | ||
| import type { ContextManager } from "../core/contextManager"; | ||
| import type { DismissibleNotifier } from "../core/dismissibleNotifier"; | ||
| import type { StartupMode } from "../core/mementoManager"; | ||
| import type { PathResolver } from "../core/pathResolver"; | ||
| import type { SecretsManager } from "../core/secretsManager"; | ||
| import type { Logger } from "../logging/logger"; | ||
| import type { LoginCoordinator } from "../login/loginCoordinator"; | ||
|
|
||
| export interface RemoteDetails extends vscode.Disposable { | ||
| safeHostname: string; | ||
| url: string; | ||
|
|
@@ -103,6 +111,7 @@ export class Remote { | |
| private readonly contextManager: ContextManager; | ||
| private readonly secretsManager: SecretsManager; | ||
| private readonly loginCoordinator: LoginCoordinator; | ||
| private readonly dismissibleNotifier: DismissibleNotifier; | ||
| private readonly setupTelemetry: RemoteSetupTelemetry; | ||
| private readonly authTelemetry: AuthTelemetry; | ||
|
|
||
|
|
@@ -117,6 +126,7 @@ export class Remote { | |
| this.contextManager = serviceContainer.getContextManager(); | ||
| this.secretsManager = serviceContainer.getSecretsManager(); | ||
| this.loginCoordinator = serviceContainer.getLoginCoordinator(); | ||
| this.dismissibleNotifier = serviceContainer.getDismissibleNotifier(); | ||
| this.setupTelemetry = new RemoteSetupTelemetry( | ||
| serviceContainer.getTelemetryService(), | ||
| ); | ||
|
|
@@ -202,6 +212,10 @@ export class Remote { | |
| const { args, parts, workspaceName, baseUrl, token, disposables } = context; | ||
|
|
||
| try { | ||
| disposables.push( | ||
| applySshEnvironment(baseUrl, vscode.workspace.getConfiguration()), | ||
| ); | ||
| await this.warnIfProxyEnvNotInherited(baseUrl); | ||
| // Create OAuth session manager for this remote deployment | ||
| const remoteOAuthManager = OAuthSessionManager.create( | ||
| { url: baseUrl, safeHostname: parts.safeHostname }, | ||
|
|
@@ -454,6 +468,11 @@ export class Remote { | |
| title: "SSH Flags", | ||
| getValue: () => getSshFlags(vscode.workspace.getConfiguration()), | ||
| }, | ||
| ...SSH_PROXY_SETTINGS.map(({ setting, title }) => ({ | ||
| setting, | ||
| title, | ||
| getValue: () => vscode.workspace.getConfiguration().get(setting), | ||
| })), | ||
| ]; | ||
| if (featureSet.proxyLogDirectory) { | ||
| settingsToWatch.push({ | ||
|
|
@@ -818,6 +837,54 @@ export class Remote { | |
| return this.pathResolver.getProxyLogPath(); | ||
| } | ||
|
|
||
| /** | ||
| * MS VS Code with `remote.SSH.useLocalServer=false` spawns ssh without | ||
| * inheriting process.env, so the proxy variables never reach it. Warn once and | ||
| * offer to enable the local server when a proxy applies to this deployment. | ||
| * | ||
| * Blocks setup with a modal: the write must land before ssh spawns (which | ||
| * happens after setup returns) for it to apply without a reload. Catches | ||
| * internally so a failure here never aborts the connection. | ||
| */ | ||
| private async warnIfProxyEnvNotInherited(baseUrl: string): Promise<void> { | ||
| try { | ||
| const cfg = vscode.workspace.getConfiguration(); | ||
| // HTTP_PROXY is only set when a proxy actually applies to this deployment. | ||
| if (!getSshProxyEnvironment(baseUrl, cfg).HTTP_PROXY) { | ||
| return; | ||
| } | ||
| if (cfg.get<boolean>("remote.SSH.useLocalServer") !== false) { | ||
| return; | ||
| } | ||
|
|
||
| const ENABLE = "Enable Local Server"; | ||
| const choice = await this.dismissibleNotifier.showDismissible( | ||
| "coder.proxyUseLocalServerWarningDismissed", | ||
| "Your proxy settings may not reach the SSH connection because `remote.SSH.useLocalServer` is disabled. Enable it so Coder can apply the proxy to the connection.", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have we validated this? I wonder if VS Code might actually explicitly include |
||
| { actions: [ENABLE], modal: true }, | ||
| ); | ||
| if (choice !== ENABLE) { | ||
| return; | ||
| } | ||
|
|
||
| // Use the jsonc writer, not cfg.update which can hang during remote setup. | ||
| // No reload needed: ssh hasn't spawned yet, so it picks this up on connect. | ||
| const ok = await applySettingOverrides( | ||
| this.pathResolver.getUserSettingsPath(), | ||
| [{ key: "remote.SSH.useLocalServer", value: true }], | ||
| this.logger, | ||
| ); | ||
| if (!ok) { | ||
| this.logger.warn("Failed to enable remote.SSH.useLocalServer"); | ||
| } | ||
|
Comment on lines
+870
to
+879
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we change the setting through the API or does it only let us change our own settings? I think I remember trying it through the API before but it was not possible. |
||
| } catch (error) { | ||
| this.logger.debug( | ||
| "Failed to surface proxy useLocalServer warning", | ||
| error, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builds the ProxyCommand for SSH connections to Coder workspaces. | ||
| * Uses `coder ssh` for modern deployments with wildcard support, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not
enum? This looks basically like an enum.Or, if the idea is that we want to pass string literals then we could just do
type DISMISSIBLE_NOTIFICATION_KEYS = "key" | "key2"since it does not look like we use the const for anything right now.But if we will need to iterate over it or something like that in the future, then ignore me.