Skip to content

Commit 2da64c3

Browse files
committed
fix: pass proxy environment through to ssh
1 parent 5c870da commit 2da64c3

6 files changed

Lines changed: 120 additions & 71 deletions

File tree

src/api/proxy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ const DEFAULT_PORTS: Record<string, number> = {
1212
wss: 443,
1313
};
1414

15+
/** Join a no-proxy list into a comma string, dropping blanks. */
16+
export function joinNoProxy(
17+
entries: string[] | null | undefined,
18+
): string | undefined {
19+
return (
20+
entries
21+
?.map((entry) => entry.trim())
22+
.filter(Boolean)
23+
.join(",") || undefined
24+
);
25+
}
26+
1527
/**
1628
* @param {string|object} url - The URL, or the result from url.parse.
1729
* @param {string} httpProxy - The proxy URL to use.

src/api/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type WorkspaceConfiguration } from "vscode";
44

55
import { expandPath } from "../util";
66

7-
import { getProxyForUrl } from "./proxy";
7+
import { getProxyForUrl, joinNoProxy } from "./proxy";
88

99
/**
1010
* Return whether the API will need a token for authorization.
@@ -56,7 +56,7 @@ export async function createHttpAgent(
5656
url,
5757
cfg.get("http.proxy"),
5858
cfg.get("coder.proxyBypass"),
59-
httpNoProxy?.map((noProxy) => noProxy.trim())?.join(","),
59+
joinNoProxy(httpNoProxy),
6060
);
6161
},
6262
headers,

src/core/dismissibleNotifier.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ import type { Memento } from "vscode";
55
const DISMISS = "Don't Show Again";
66

77
/** globalState keys under which "Don't Show Again" dismissals are stored. */
8-
export const DISMISSIBLE_NOTIFICATION_KEYS = [
9-
"coder.proxyUseLocalServerWarningDismissed",
10-
] as const;
11-
128
export type DismissibleNotificationKey =
13-
(typeof DISMISSIBLE_NOTIFICATION_KEYS)[number];
9+
"coder.proxyUseLocalServerWarningDismissed";
1410

1511
export class DismissibleNotifier {
1612
public constructor(private readonly globalState: Memento) {}

src/remote/environment.ts

Lines changed: 16 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getProxyForUrl } from "../api/proxy";
1+
import { joinNoProxy } from "../api/proxy";
22

33
import type { WorkspaceConfiguration } from "vscode";
44

@@ -30,34 +30,31 @@ export const SSH_PROXY_SETTINGS: ReadonlyArray<{
3030
* to disk and multiple windows onto the same workspace stay independent.
3131
*
3232
* Best-effort: only processes spawned afterwards inherit the change. MS VS Code
33-
* with `remote.SSH.useLocalServer=false` spawns ssh off a path that does not
34-
* inherit, so propagation there needs `useLocalServer=true`. Returns a disposable
35-
* that restores the previous values.
33+
* with `remote.SSH.useLocalServer=false` spawns ssh through a code path that
34+
* does not inherit, so propagation there needs `useLocalServer=true`. Returns a
35+
* disposable that restores the previous values.
3636
*/
3737
export function applySshEnvironment(
38-
baseUrl: string,
3938
cfg: Pick<WorkspaceConfiguration, "get">,
4039
env: Environment = process.env,
4140
): { dispose(): void } {
42-
return applyEnvironment(getSshProxyEnvironment(baseUrl, cfg), env);
41+
return applyEnvironment(getSshProxyEnvironment(cfg), env);
4342
}
4443

4544
/**
4645
* The proxy portion of the SSH environment. Exposed so callers can check whether
47-
* a proxy actually applies to a deployment via `.HTTP_PROXY`.
46+
* proxy settings are configured via `.HTTP_PROXY`.
4847
*/
4948
export function getSshProxyEnvironment(
50-
baseUrl: string,
5149
cfg: Pick<WorkspaceConfiguration, "get">,
5250
): SshEnvironment {
53-
const httpProxy = getSetting(cfg, "http.proxy");
54-
const noProxy = getSetting(cfg, "coder.proxyBypass") ?? getHttpNoProxy(cfg);
55-
const proxy = httpProxy
56-
? getProxyForUrl(baseUrl, httpProxy, noProxy, undefined)
57-
: "";
51+
const httpProxy = trimmed(cfg.get<string | null>("http.proxy"));
52+
const noProxy =
53+
trimmed(cfg.get<string | null>("coder.proxyBypass")) ??
54+
joinNoProxy(cfg.get<string[]>("http.noProxy"));
5855

5956
return {
60-
...(proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy } : {}),
57+
...(httpProxy ? { HTTP_PROXY: httpProxy, HTTPS_PROXY: httpProxy } : {}),
6158
...(noProxy ? { NO_PROXY: noProxy } : {}),
6259
};
6360
}
@@ -71,10 +68,9 @@ function applyEnvironment(
7168
if (value === undefined) {
7269
continue;
7370
}
74-
for (const envKey of getEnvKeys(env, key)) {
75-
previous.push([envKey, Object.hasOwn(env, envKey), env[envKey]]);
76-
env[envKey] = value;
77-
}
71+
const previousValue = env[key];
72+
previous.push([key, previousValue !== undefined, previousValue]);
73+
env[key] = value;
7874
}
7975

8076
let disposed = false;
@@ -84,8 +80,7 @@ function applyEnvironment(
8480
return;
8581
}
8682
disposed = true;
87-
for (let i = previous.length - 1; i >= 0; i--) {
88-
const [key, existed, value] = previous[i];
83+
for (const [key, existed, value] of previous) {
8984
if (existed) {
9085
env[key] = value;
9186
} else {
@@ -96,29 +91,6 @@ function applyEnvironment(
9691
};
9792
}
9893

99-
function getEnvKeys(env: Environment, key: string): string[] {
100-
const keys = Object.keys(env).filter(
101-
(envKey) => envKey.toLowerCase() === key.toLowerCase(),
102-
);
103-
return keys.length > 0 ? keys : [key];
104-
}
105-
106-
function getSetting(
107-
cfg: Pick<WorkspaceConfiguration, "get">,
108-
setting: string,
109-
): string | undefined {
110-
const value = cfg.get<string | null>(setting);
94+
function trimmed(value: string | null | undefined): string | undefined {
11195
return typeof value === "string" ? value.trim() || undefined : undefined;
11296
}
113-
114-
function getHttpNoProxy(
115-
cfg: Pick<WorkspaceConfiguration, "get">,
116-
): string | undefined {
117-
return (
118-
cfg
119-
.get<string[]>("http.noProxy", [])
120-
.map((value) => value.trim())
121-
.filter(Boolean)
122-
.join(",") || undefined
123-
);
124-
}

src/remote/remote.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ export class Remote {
213213

214214
try {
215215
disposables.push(
216-
applySshEnvironment(baseUrl, vscode.workspace.getConfiguration()),
216+
applySshEnvironment(vscode.workspace.getConfiguration()),
217217
);
218-
await this.warnIfProxyEnvNotInherited(baseUrl);
218+
await this.warnIfProxyEnvNotInherited();
219219
// Create OAuth session manager for this remote deployment
220220
const remoteOAuthManager = OAuthSessionManager.create(
221221
{ url: baseUrl, safeHostname: parts.safeHostname },
@@ -840,17 +840,17 @@ export class Remote {
840840
/**
841841
* MS VS Code with `remote.SSH.useLocalServer=false` spawns ssh without
842842
* inheriting process.env, so the proxy variables never reach it. Warn once and
843-
* offer to enable the local server when a proxy applies to this deployment.
843+
* offer to enable the local server when proxy settings are configured.
844844
*
845845
* Blocks setup with a modal: the write must land before ssh spawns (which
846846
* happens after setup returns) for it to apply without a reload. Catches
847847
* internally so a failure here never aborts the connection.
848848
*/
849-
private async warnIfProxyEnvNotInherited(baseUrl: string): Promise<void> {
849+
private async warnIfProxyEnvNotInherited(): Promise<void> {
850850
try {
851851
const cfg = vscode.workspace.getConfiguration();
852-
// HTTP_PROXY is only set when a proxy actually applies to this deployment.
853-
if (!getSshProxyEnvironment(baseUrl, cfg).HTTP_PROXY) {
852+
// HTTP_PROXY is only set when http.proxy is configured.
853+
if (!getSshProxyEnvironment(cfg).HTTP_PROXY) {
854854
return;
855855
}
856856
if (cfg.get<boolean>("remote.SSH.useLocalServer") !== false) {

test/unit/remote/environment.test.ts

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88

99
import { MockConfigurationProvider } from "../../mocks/testHelpers";
1010

11-
const URL = "https://coder.example.com";
1211
const proxy = "http://proxy.example.com:8080";
1312

1413
function setup() {
@@ -32,12 +31,16 @@ describe("getSshProxyEnvironment", () => {
3231
expected: { HTTP_PROXY: proxy, HTTPS_PROXY: proxy },
3332
},
3433
{
35-
name: "drops the proxy when the deployment is bypassed",
34+
name: "passes through the proxy when the deployment is bypassed",
3635
settings: {
3736
"http.proxy": proxy,
3837
"coder.proxyBypass": "coder.example.com",
3938
},
40-
expected: { NO_PROXY: "coder.example.com" },
39+
expected: {
40+
HTTP_PROXY: proxy,
41+
HTTPS_PROXY: proxy,
42+
NO_PROXY: "coder.example.com",
43+
},
4144
},
4245
{
4346
name: "falls back to http.noProxy when coder.proxyBypass is unset",
@@ -64,17 +67,35 @@ describe("getSshProxyEnvironment", () => {
6467
NO_PROXY: "primary.example.com",
6568
},
6669
},
70+
{
71+
name: "ignores a whitespace-only http.proxy",
72+
settings: { "http.proxy": " " },
73+
expected: {},
74+
},
75+
{
76+
name: "falls back to http.noProxy when coder.proxyBypass is whitespace",
77+
settings: {
78+
"http.proxy": proxy,
79+
"coder.proxyBypass": " ",
80+
"http.noProxy": ["fallback.example.com"],
81+
},
82+
expected: {
83+
HTTP_PROXY: proxy,
84+
HTTPS_PROXY: proxy,
85+
NO_PROXY: "fallback.example.com",
86+
},
87+
},
6788
])("$name", ({ settings, expected }) => {
6889
const { config } = setup();
6990

70-
expect(getSshProxyEnvironment(URL, config(settings))).toEqual(expected);
91+
expect(getSshProxyEnvironment(config(settings))).toEqual(expected);
7192
});
7293

7394
it("ignores an existing env proxy when http.proxy is unset", () => {
7495
const { config } = setup();
7596
vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example.com:8080");
7697

77-
expect(getSshProxyEnvironment(URL, config())).toEqual({});
98+
expect(getSshProxyEnvironment(config())).toEqual({});
7899
});
79100
});
80101

@@ -84,7 +105,6 @@ describe("applySshEnvironment", () => {
84105
const env: Record<string, string | undefined> = {};
85106

86107
const applied = applySshEnvironment(
87-
URL,
88108
config({
89109
"http.proxy": proxy,
90110
"coder.proxyBypass": "internal.example.com",
@@ -101,28 +121,42 @@ describe("applySshEnvironment", () => {
101121
expect(env).toEqual({});
102122
});
103123

104-
it("overwrites and restores existing lowercase variables", () => {
124+
it("does not overwrite existing lowercase variables", () => {
105125
const { config } = setup();
106126
const original = {
107127
http_proxy: "http://old-http-proxy.example.com:8080",
108128
https_proxy: "http://old-https-proxy.example.com:8080",
109129
};
110130
const env: Record<string, string | undefined> = { ...original };
111131

112-
const applied = applySshEnvironment(
113-
URL,
114-
config({ "http.proxy": proxy }),
115-
env,
116-
);
117-
expect(env).toEqual({ http_proxy: proxy, https_proxy: proxy });
132+
const applied = applySshEnvironment(config({ "http.proxy": proxy }), env);
133+
expect(env).toEqual({
134+
...original,
135+
HTTP_PROXY: proxy,
136+
HTTPS_PROXY: proxy,
137+
});
118138

119139
applied.dispose();
120140
expect(env).toEqual(original);
121141
});
122142

143+
it("restores existing case-insensitive variables", () => {
144+
const { config } = setup();
145+
const original = "http://old-http-proxy.example.com:8080";
146+
const env = caseInsensitiveEnvironment({ http_proxy: original });
147+
148+
const applied = applySshEnvironment(config({ "http.proxy": proxy }), env);
149+
expect(env.HTTP_PROXY).toBe(proxy);
150+
expect(env.http_proxy).toBe(proxy);
151+
152+
applied.dispose();
153+
expect(env.HTTP_PROXY).toBe(original);
154+
expect(env.http_proxy).toBe(original);
155+
});
156+
123157
it("propagates proxy variables to newly spawned child processes", () => {
124158
const { config } = setup();
125-
const applied = applySshEnvironment(URL, config({ "http.proxy": proxy }));
159+
const applied = applySshEnvironment(config({ "http.proxy": proxy }));
126160

127161
try {
128162
expect(getProxyEnvFromChild()).toEqual({ http: proxy, https: proxy });
@@ -132,6 +166,41 @@ describe("applySshEnvironment", () => {
132166
});
133167
});
134168

169+
function caseInsensitiveEnvironment(
170+
values: Record<string, string>,
171+
): Record<string, string | undefined> {
172+
return new Proxy(values, {
173+
get(target, property) {
174+
if (typeof property !== "string") {
175+
return undefined;
176+
}
177+
return target[getCaseInsensitiveKey(target, property) ?? property];
178+
},
179+
set(target, property, value) {
180+
if (typeof property !== "string") {
181+
return false;
182+
}
183+
target[getCaseInsensitiveKey(target, property) ?? property] = value;
184+
return true;
185+
},
186+
deleteProperty(target, property) {
187+
if (typeof property !== "string") {
188+
return false;
189+
}
190+
return delete target[getCaseInsensitiveKey(target, property) ?? property];
191+
},
192+
});
193+
}
194+
195+
function getCaseInsensitiveKey(
196+
values: Record<string, string | undefined>,
197+
key: string,
198+
): string | undefined {
199+
return Object.keys(values).find(
200+
(valueKey) => valueKey.toLowerCase() === key.toLowerCase(),
201+
);
202+
}
203+
135204
function getProxyEnvFromChild(): { http: string; https: string } {
136205
const result = spawnSync(
137206
process.execPath,

0 commit comments

Comments
 (0)