feat(server,web,desktop): admin "use system HTTP proxy" switch (#225)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8499befd4
commit
91f3292ab5
29 files changed
+710
-19
No files matched your search
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
|
||||
* desktop, "system proxy" means the real operating-system setting. The shell resolves
|
||||
* it through Electron's resolveProxy when forking the server and injects the result
|
||||
* into the child environment as HTTP_PROXY / HTTPS_PROXY — the server's dispatcher
|
||||
* (and, while the admin switch is on, agent commands) then honor it. Variables already
|
||||
* present in the shell's environment are never overridden (same idiom as
|
||||
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
|
||||
*
|
||||
* Only the entry point touches Electron, behind a dynamic import; the parsing and the
|
||||
* override check stay pure so they unit-test without an Electron runtime.
|
||||
*/
|
||||
|
||||
/** The variables injected, with the probe URL whose scheme selects the OS proxy rule. */
|
||||
const PROXY_VARS = [
|
||||
{ name: "HTTP_PROXY", lower: "http_proxy", probe: "http://example.com/" },
|
||||
{ name: "HTTPS_PROXY", lower: "https_proxy", probe: "https://example.com/" },
|
||||
] as const;
|
||||
|
||||
type ProxyVar = (typeof PROXY_VARS)[number];
|
||||
|
||||
/** The proxy variables `env` does not already define (either spelling — those are kept as-is). */
|
||||
export function unsetProxyVars(env: NodeJS.ProcessEnv): readonly ProxyVar[] {
|
||||
return PROXY_VARS.filter((v) => env[v.name] === undefined && env[v.lower] === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one PAC-style resolveProxy result to a proxy URL, or null for "connect
|
||||
* directly". The result is a `;`-separated fallback list ("PROXY host:port; DIRECT");
|
||||
* the first usable entry wins: `PROXY` → http://, `HTTPS` (a proxy reached over TLS) →
|
||||
* https://. `DIRECT` and the SOCKS flavors yield nothing — undici's dispatcher speaks
|
||||
* only HTTP(S) proxies, and injecting a socks:// URL would break every server fetch.
|
||||
*/
|
||||
export function proxyUrlFromPacResult(result: string): string | null {
|
||||
for (const entry of result.split(";")) {
|
||||
const m = /^\s*(PROXY|HTTPS)\s+(\S+)\s*$/i.exec(entry);
|
||||
if (!m) continue;
|
||||
const scheme = m[1]!.toUpperCase() === "HTTPS" ? "https" : "http";
|
||||
return `${scheme}://${m[2]!}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The proxy environment to add to the server child: the OS resolution of every proxy
|
||||
* variable the shell's own environment leaves unset. Best-effort — any resolution
|
||||
* failure injects nothing (the server then simply sees no proxy configuration).
|
||||
*/
|
||||
export async function osProxyEnv(): Promise<Record<string, string>> {
|
||||
const wanted = unsetProxyVars(process.env);
|
||||
if (wanted.length === 0) return {};
|
||||
try {
|
||||
const { session } = await import("electron");
|
||||
const env: Record<string, string> = {};
|
||||
for (const v of wanted) {
|
||||
const url = proxyUrlFromPacResult(await session.defaultSession.resolveProxy(v.probe));
|
||||
if (url !== null) env[v.name] = url;
|
||||
}
|
||||
return env;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { app, utilityProcess } from "electron";
|
||||
import type { UtilityProcess } from "electron";
|
||||
import { osProxyEnv } from "./os-proxy.js";
|
||||
import { choosePort, readPreferredPort, rememberPreferredPort } from "./port-memory.js";
|
||||
import { appOriginFor, parsePortFile } from "./util.js";
|
||||
|
||||
@@ -125,6 +126,10 @@ export async function startEmbeddedServer(opts: {
|
||||
env: {
|
||||
...process.env,
|
||||
...bundledShellEnv(),
|
||||
// OS proxy settings resolved at fork time (Electron resolveProxy) — only for the
|
||||
// proxy variables the environment leaves unset, never overriding existing values.
|
||||
// The server's "use system HTTP proxy" switch then governs whether they are used.
|
||||
...(await osProxyEnv()),
|
||||
PENGUIN_HOME: opts.dataRoot,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(requestedPort),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Pure-part tests for the OS-proxy injection (no Electron runtime): PAC-result parsing
|
||||
* and the "never override an existing variable" selection.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { proxyUrlFromPacResult, unsetProxyVars } from "../src/os-proxy.js";
|
||||
|
||||
describe("proxyUrlFromPacResult", () => {
|
||||
it("maps PROXY to an http:// URL and HTTPS to an https:// URL", () => {
|
||||
expect(proxyUrlFromPacResult("PROXY 127.0.0.1:8888")).toBe("http://127.0.0.1:8888");
|
||||
expect(proxyUrlFromPacResult("HTTPS proxy.corp.example:443")).toBe(
|
||||
"https://proxy.corp.example:443",
|
||||
);
|
||||
expect(proxyUrlFromPacResult("proxy proxy.corp.example:3128")).toBe(
|
||||
"http://proxy.corp.example:3128",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the first usable entry of a fallback list", () => {
|
||||
expect(proxyUrlFromPacResult("PROXY 10.0.0.1:8080; DIRECT")).toBe("http://10.0.0.1:8080");
|
||||
expect(proxyUrlFromPacResult("DIRECT; PROXY 10.0.0.1:8080")).toBe("http://10.0.0.1:8080");
|
||||
});
|
||||
|
||||
it("yields nothing for DIRECT and for SOCKS entries (undici cannot speak SOCKS)", () => {
|
||||
expect(proxyUrlFromPacResult("DIRECT")).toBeNull();
|
||||
expect(proxyUrlFromPacResult("SOCKS5 127.0.0.1:1080")).toBeNull();
|
||||
expect(proxyUrlFromPacResult("SOCKS 127.0.0.1:1080; DIRECT")).toBeNull();
|
||||
expect(proxyUrlFromPacResult("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsetProxyVars", () => {
|
||||
it("selects both variables on a clean environment", () => {
|
||||
expect(unsetProxyVars({}).map((v) => v.name)).toEqual(["HTTP_PROXY", "HTTPS_PROXY"]);
|
||||
});
|
||||
|
||||
it("skips a variable already present in either spelling (never overridden)", () => {
|
||||
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1" }).map((v) => v.name)).toEqual([
|
||||
"HTTPS_PROXY",
|
||||
]);
|
||||
expect(unsetProxyVars({ https_proxy: "http://a:1" }).map((v) => v.name)).toEqual([
|
||||
"HTTP_PROXY",
|
||||
]);
|
||||
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1", https_proxy: "http://a:1" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user