From 5d58a26cf9a53d2ef3ee947bfda70a8bcc62a2f0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 14:25:53 +0000 Subject: [PATCH 1/3] fix(install): preserve standard proxy settings for node setup --- apps/docs/content/docs/hosted-providers.mdx | 19 ++ apps/docs/content/guide-sources.json | 4 +- .../sandbox/enrollment-command.test.ts | 42 +++- .../features/sandbox/enrollment-command.ts | 6 +- deploy/install/node_install.py | 10 + deploy/install/test_node_proxy.py | 202 ++++++++++++++++++ docs/getting-started/nodes.md | 19 ++ docs/maintainers.md | 9 +- 8 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 deploy/install/test_node_proxy.py diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 25ad6bfde..ea480a59e 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -57,6 +57,25 @@ as an ordinary user is rejected before reading the enrollment token or changing the host. This requirement applies to Sandbox Provider nodes, not the native self-hosted daemon installer. +### Download through a proxy + +Export standard `http_proxy` / `https_proxy` and `no_proxy` variables before +running the generated command. Uppercase `HTTP_PROXY`, `HTTPS_PROXY` and +`NO_PROXY` work too; lowercase takes precedence when both are set (including an +empty lowercase value). Use an HTTP proxy URL, optionally with URL-encoded +credentials, and comma-separated host exclusions in `no_proxy`. A proxy for +HTTPS downloads must support CONNECT. + +The command requests sudo preservation of only these six names and keeps them +when dropping to `oac-node`. If sudo policy denies preservation, ask the host +administrator to permit those names or run from a root shell with the variables +exported there. A manually added outer `sudo` can discard them before the +command starts. Do not put credentials in command arguments or share shell traces. + +These settings apply only to installation requests. They are not saved in node +configuration or the systemd unit; the running node still needs its own network +access to Core. Without proxy variables, downloads use the existing direct path. + ### The command The command shows each installation phase as it runs. After Core confirms the diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 2d79c0904..e0c8f6943 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -14,7 +14,7 @@ "docs/api/public-agent-api.md": "5531748f97a84a5e183838cf6fd6ac9bc0c7ae169fb4c6da73a420dbcc77e255", "docs/examples.md": "0e1bdaeff51c9c36779f817be31ea9816b7d8cb2290cf8350d3c4801f436f4f9", "contracts/agents-api/environments.md": "9c0e794738c017e9dde57a659dc184d347e33730103583534c43e86d5d4183fd", - "docs/getting-started/nodes.md": "13e1f1e867d3e361ff860960ceacc2dd5806e64f4176cd33f5e49f9a9f4d57e4", + "docs/getting-started/nodes.md": "5083740ffdab44198118b2b011a31dc7aa093ca0d1f1911b5037f42345e1b2f2", "docs/getting-started/self-hosted.md": "4591e05bfa7657f647bedecd9db07b7086acd94b5548511c88f7e5e3567fdb11", "docs/self-hosted-native.md": "1d08e46b8406411f0efeab5940cbda907cd55afb8aec159f5233d2c0ef9d3d54", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", @@ -44,7 +44,7 @@ "content/docs/sessions.mdx": "c5702840e8395545552da35162724019bb0e4ae2d131a57bd71c615a75e4fe00", "content/docs/examples.mdx": "587061e65ab2e841d14980539ba94e2216d4e8135c948a852b9a8bb0359c85d7", "content/docs/environments-and-files.mdx": "8da8ba45d3c76cc016b1f640d0f3af6e6e8922e4ae0f42ba789443fe59177b9c", - "content/docs/hosted-providers.mdx": "06f0b13b053965bbdd1bfb0a7e85d19b50987841e0512fa8331fc9a67ff71fe0", + "content/docs/hosted-providers.mdx": "206609790255b549d4c4e12adc2b07709a7434bfae5b443f916a61aa506d59df", "content/docs/self-hosted-execution.mdx": "161a9000634b4075a55960556322a1b535d8157ba0a4abbeaa84c85c7292ad33", "content/docs/self-hosted-native.mdx": "b2c5b2bb2080b5338bbaa3469f196555be31089ee713d2b9f1be8d47fa7e29cc", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", diff --git a/apps/web/src/features/sandbox/enrollment-command.test.ts b/apps/web/src/features/sandbox/enrollment-command.test.ts index efb6a76ae..180c4e1b9 100644 --- a/apps/web/src/features/sandbox/enrollment-command.test.ts +++ b/apps/web/src/features/sandbox/enrollment-command.test.ts @@ -11,29 +11,33 @@ describe("sandbox connection and enrollment", () => { const install = () => nodeInstallCommand({ token: "secret'onetime", coreUrl: "https://core.example", sourceUrl: "https://console.example", provider: "docker", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest }); it("creates the exact sudo command: a leading space, the checked installer run as root, the token only on stdin", () => { expect(install()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo +export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}" +export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy" printf '\\n==> Downloading node installer...\\n' && -curl -fsS --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" && +curl -fs --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; } printf '==> Verifying node installer...\\n' && printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status && -printf '%s\\n' 'secret'\\''onetime' | $s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`); +printf '%s\\n' 'secret'\\''onetime' | $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`); }); it("creates the exact uninstall commands, with no token", () => { const uninstall = () => nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest }); expect(uninstall()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo +export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}" +export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy" printf '\\n==> Downloading node installer...\\n' && -curl -fsS --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" && +curl -fs --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; } printf '==> Verifying node installer...\\n' && printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status && -$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`); +$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`); expect(nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, force: true }).split("\n").at(-1)) - .toBe(`$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force)`); + .toBe(`$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force)`); }); it("points at the system node journal", () => { expect(nodeLogCommand("7f3c2a90-fixture")).toBe("sudo journalctl -u oac-node-7f3c2a90-fixture.service"); expect(nodeLogCommand("a b")).toBe("sudo journalctl -u 'oac-node-a b.service'"); }); // "as root": a root shell runs the sudo command without sudo. - it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no color"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => { + it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no color", "uppercase proxy", "lowercase proxy", "empty proxy", "root proxy"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => { const parent = join(homedir(), ".oac", "tests"); mkdirSync(parent, { recursive: true }); const root = mkdtempSync(join(parent, "node-command-")); @@ -57,13 +61,15 @@ const line=fs.readFileSync(0,'utf8').trimEnd(), split=line.indexOf(' '); if(split!==64) process.exit(2); const actual=crypto.createHash('sha256').update(fs.readFileSync(line.slice(split+2))).digest('hex'); process.exit(actual===line.slice(0,split)?0:1);`); - executable("id", `console.log(process.env.SCENARIO==='as root'?'0':'1000');`); + executable("id", `console.log((process.env.SCENARIO==='as root'||process.env.SCENARIO==='root proxy')?'0':'1000');`); // The shell's builtin printf feeds the token; an external one would put it in an argv. executable("printf", `require('node:fs').appendFileSync(process.env.PRINTF_REPORT,'called\\n');`); executable("sudo", `const fs=require('node:fs'), {spawnSync}=require('node:child_process'); fs.writeFileSync(process.env.SUDO_REPORT,JSON.stringify({args:process.argv.slice(2),env:process.env})); +const args=process.argv.slice(2), keep=args.shift().split('=')[1].split(','); const env={...process.env}; delete env.NO_COLOR; -process.exit(spawnSync(process.argv[2],process.argv.slice(3),{stdio:'inherit',env}).status ?? 1);`); +for(const key of Object.keys(env)) if(/_proxy$/i.test(key)&&!keep.includes(key)) delete env[key]; +process.exit(spawnSync(args[0],args.slice(1),{stdio:'inherit',env}).status ?? 1);`); executable("python3", `const fs=require('node:fs'); const stdin=fs.readFileSync(0,'utf8'); fs.writeFileSync(process.env.REPORT,JSON.stringify({args:process.argv.slice(2),env:process.env,stdin,mode:fs.statSync(process.argv[2]).mode&511})); @@ -72,11 +78,17 @@ process.exit(process.env.SCENARIO==='installer failure'?7:0);`); const token = "fixture'one-time"; try { const command = nodeInstallCommand({ token, coreUrl: "http://127.0.0.1:8091", sourceUrl: "http://localhost:8080", provider: "docker", installationId: "fixture-installation", scriptDigest: digest }); - const result = spawnSync("sh", ["-c", command], { env: { ...process.env, NO_COLOR: scenario === "no color" ? "" : undefined, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temporary, SCENARIO: scenario, FIXTURE: fixture, REPORT: report, SUDO_REPORT: sudoReport, PRINTF_REPORT: printfReport }, encoding: "utf8" }); + const proxy = "http://fixture:private%20password@proxy.example:3128"; + const proxyEnv: Record = { http_proxy: undefined, https_proxy: undefined, no_proxy: undefined, HTTP_PROXY: undefined, HTTPS_PROXY: undefined, NO_PROXY: undefined, ALL_PROXY: undefined }; + if (["uppercase proxy", "root proxy", "empty proxy"].includes(scenario)) Object.assign(proxyEnv, { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, NO_PROXY: "core.example,localhost" }); + if (scenario === "lowercase proxy") Object.assign(proxyEnv, { http_proxy: proxy, https_proxy: proxy, no_proxy: "core.example,localhost", HTTP_PROXY: "http://wrong.invalid" }); + if (scenario === "empty proxy") proxyEnv.http_proxy = ""; + const result = spawnSync("sh", ["-c", command], { env: { ...process.env, ...proxyEnv, NO_COLOR: scenario === "no color" ? "" : undefined, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temporary, SCENARIO: scenario, FIXTURE: fixture, REPORT: report, SUDO_REPORT: sudoReport, PRINTF_REPORT: printfReport }, encoding: "utf8" }); expect(result.status).toBe(scenario === "installer failure" ? 7 : scenario === "download failure" ? 22 : scenario === "checksum mismatch" ? 1 : 0); expect(result.stdout).toContain("==> Downloading node installer..."); expect(result.stdout.includes("==> Verifying node installer...")).toBe(scenario !== "download failure"); expect(result.stdout + result.stderr).not.toContain(token); + expect(result.stdout + result.stderr).not.toContain("private%20password"); expect(result.stdout + result.stderr).not.toContain("\u001b["); expect(readdirSync(temporary)).toEqual([]); expect(readdirSync(root)).not.toContain("printf.log"); @@ -87,16 +99,24 @@ process.exit(process.env.SCENARIO==='installer failure'?7:0);`); } const invocation = JSON.parse(readFileSync(report, "utf8")) as { args: string[]; env: Record; stdin: string; mode: number }; // The token arrives on stdin alone: in no argument and no environment variable, the installer's or sudo's. + expect(invocation.env.http_proxy).toBe(["uppercase proxy", "lowercase proxy", "root proxy"].includes(scenario) ? proxy : ""); + expect(invocation.args.join(" ")).not.toContain("private%20password"); + for (const key of ["http_proxy", "https_proxy", "no_proxy"]) { + const expected = proxyEnv[key] ?? proxyEnv[key.toUpperCase()] ?? ""; + expect(invocation.env[key]).toBe(expected); + expect(invocation.env[key.toUpperCase()]).toBe(expected); + } expect(invocation.stdin).toBe(`${token}\n`); expect(invocation.args.some((arg) => arg.includes("one-time"))).toBe(false); expect(Object.values(invocation.env).some((value) => value.includes("one-time"))).toBe(false); expect(invocation.args.slice(1)).toEqual([...(scenario === "no color" ? ["--no-color"] : []), "--enrollment-token-stdin", "--source-url", "http://localhost:8080", "--core-url", "http://127.0.0.1:8091", "--provider", "docker", "--installation-id", "fixture-installation"]); expect(invocation.mode & 0o077).toBe(0); - if (scenario === "as root") { + if (scenario === "as root" || scenario === "root proxy") { expect(readdirSync(root)).not.toContain("sudo.json"); } else { const sudo = JSON.parse(readFileSync(sudoReport, "utf8")) as { args: string[]; env: Record }; - expect(sudo.args.slice(0, 1)).toEqual(["python3"]); + expect(sudo.args.slice(0, 2)).toEqual(["--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY", "python3"]); + expect(sudo.args.join(" ")).not.toContain("private%20password"); expect(sudo.args.some((arg) => arg.includes("one-time"))).toBe(false); expect(Object.values(sudo.env).some((value) => value.includes("one-time"))).toBe(false); } diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index a7cdc4e1f..f67f7d301 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -10,15 +10,17 @@ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; */ function nodeInstaller(sourceUrl: string, scriptDigest: string): string { return ` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo +export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}" +export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy" printf '\\n==> Downloading node installer...\\n' && -curl -fsS --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/node-install.pyz" && +curl -fs --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; } printf '==> Verifying node installer...\\n' && printf '%s %s\\n' ${quote(scriptDigest)} "$d/node-install.pyz" | sha256sum -c --status && `; } /** Runs the downloaded installer, as root in sudo mode. */ -const runInstaller = `$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color}`; +const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color}`; /** * Adds this host as a node. The one-time token reaches the installer only on diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 427e3b56b..740329356 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -710,9 +710,19 @@ def service_child(account, function, arguments, parent, mask, read_ends, output_ if os.getppid() != parent: os._exit(1) os.umask(0o077) + # This child runs installation/removal steps, never the persistent node + # service. Keep only standard HTTP proxy settings across the UID boundary. + # curl/urllib prefer lowercase, while the Go registration command prefers + # uppercase. Give both spellings one value, including an explicit empty value. + proxies = {} + for name in ("http_proxy", "https_proxy", "no_proxy"): + value = os.environ.get(name, os.environ.get(name.upper())) + if value is not None: + proxies[name] = proxies[name.upper()] = value os.environ.clear() os.environ.update(HOME=account.pw_dir, USER=account.pw_name, LOGNAME=account.pw_name, PATH=SAFE_PATH, LANG="C.UTF-8", DOCKER_CONFIG=str(CHILD_DOCKER_CONFIG)) + os.environ.update(proxies) os.chdir(account.pw_dir) function(*arguments) code = 0 diff --git a/deploy/install/test_node_proxy.py b/deploy/install/test_node_proxy.py new file mode 100644 index 000000000..13cb77f43 --- /dev/null +++ b/deploy/install/test_node_proxy.py @@ -0,0 +1,202 @@ +"""Real HTTP/CONNECT downloads across the installer's environment reset.""" +import base64 +import contextlib +import http.server +import io +import os +from pathlib import Path +import pwd +import select +import socket +import ssl +import subprocess +import tempfile +import threading +import unittest +import urllib.request +from types import SimpleNamespace +from unittest import mock + +import distribution +import node_install as installer + +PROXIES = ("http_proxy", "https_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY") +PAYLOAD = b"node proxy download fixture" +AUTH = "Basic " + base64.b64encode(b"fixture:private password").decode() + + +@contextlib.contextmanager +def serve(handler, context=None): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + if context: + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join() + + +class Origin(http.server.BaseHTTPRequestHandler): + def do_GET(self): + # Proxy authentication belongs only to the proxy, never the download origin. + self.server.leaked_auth |= self.headers.get("Proxy-Authorization") is not None + self.send_response(200) + self.send_header("Content-Length", str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD) + + def log_message(self, *args): + pass + + +class Proxy(http.server.BaseHTTPRequestHandler): + def authorized(self): + self.server.requests.append((self.command, self.path)) + if self.server.authenticate and self.headers.get("Proxy-Authorization") != AUTH: + self.send_error(407) + return False + return True + + def do_GET(self): + if self.authorized(): + self.send_response(200) + self.send_header("Content-Length", str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD) + + def do_CONNECT(self): + if not self.authorized(): + return + with socket.create_connection(self.server.origin.server_address, timeout=5) as upstream: + self.send_response(200) + self.end_headers() + self.wfile.flush() + peers = [self.connection, upstream] + while True: + ready, _, _ = select.select(peers, [], [], 5) + if not ready: + return + for peer in ready: + data = peer.recv(65536) + if not data: + return + (upstream if peer is self.connection else self.connection).sendall(data) + + def log_message(self, *args): + pass + + +class NodeProxyTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + base = Path.home() / ".oac/tests" + base.mkdir(parents=True, exist_ok=True) + cls.temporary = tempfile.TemporaryDirectory(dir=base) + cls.directory = Path(cls.temporary.name) + key, certificate = cls.directory / "key.pem", cls.directory / "cert.pem" + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(key), "-out", str(certificate), "-days", "1", + "-subj", "/CN=downloads.invalid", + "-addext", "subjectAltName=DNS:downloads.invalid,IP:127.0.0.1"], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + cls.tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + cls.tls.load_cert_chain(certificate, key) + cls.trust = ssl.create_default_context(cafile=str(certificate)) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def child_download(self, env, url, expect_failure=False): + output, errors = io.StringIO(), io.StringIO() + account = pwd.getpwnam("nobody") if os.geteuid() == 0 else SimpleNamespace( + pw_name=pwd.getpwuid(os.getuid()).pw_name, pw_uid=os.getuid(), + pw_gid=os.getgid(), pw_dir="/") + # Real setuid when run in the disposable root container. Normal CI also + # exercises the fork/environment boundary under its existing account. + account = SimpleNamespace(pw_name=account.pw_name, pw_uid=account.pw_uid, + pw_gid=account.pw_gid, pw_dir="/") + def download(): + if os.geteuid() == 0: + raise AssertionError("download must run without root") + assert "UNRELATED_SECRET" not in os.environ + assert "PYTHONPATH" not in os.environ + assert "ALL_PROXY" not in os.environ + assert os.environ["HOME"] == "/" + for name in ("http_proxy", "https_proxy", "no_proxy"): + assert os.environ.get(name) == os.environ.get(name.upper()) + # Both metadata and artifact download openers use the retained proxy. + assert installer.fetch(url, "manifest.json").read() == PAYLOAD + with urllib.request.build_opener(distribution.ArtifactRedirect()).open(url, timeout=5) as response: + assert response.read() == PAYLOAD + print("download verified") + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.dict(os.environ, { + **env, "UNRELATED_SECRET": "not inherited", "PYTHONPATH": "/untrusted", + "ALL_PROXY": "http://must-not-be-inherited.invalid"}, clear=True)) + stack.enter_context(mock.patch.object(ssl, "_create_default_https_context", return_value=self.trust)) + stack.enter_context(mock.patch.object(installer.sys, "stdout", output)) + stack.enter_context(mock.patch.object(installer.sys, "stderr", errors)) + # Container keyring policy is independent of proxy inheritance. + stack.enter_context(mock.patch.object(installer, "libc_call")) + if os.geteuid() != 0: + stack.enter_context(mock.patch.object(os, "setgroups")) + if expect_failure: + with self.assertRaises(installer.ChildFailed): + installer.as_service_user(account, download) + else: + installer.as_service_user(account, download) + self.assertIn("download verified", output.getvalue()) + for sensitive in ("private password", "private%20password", "fixture@", AUTH): + self.assertNotIn(sensitive, output.getvalue() + errors.getvalue()) + return errors.getvalue() + + def test_proxy_only_downloads_lower_upper_and_authentication(self): + for uppercase in (False, True): + for authenticate in (False, True): + with self.subTest(uppercase=uppercase, authenticate=authenticate), serve(Origin, self.tls) as origin, serve(Proxy) as proxy: + origin.leaked_auth = False + proxy.origin, proxy.authenticate, proxy.requests = origin, authenticate, [] + value = "http://" + ("fixture:private%20password@" if authenticate else "") + "127.0.0.1:" + str(proxy.server_port) + env = {name.upper() if uppercase else name: value for name in ("http_proxy", "https_proxy")} + if not uppercase: + env.update(HTTP_PROXY="http://unreachable.invalid", HTTPS_PROXY="http://unreachable.invalid", + NO_PROXY="*", no_proxy="") + for scheme in ("http", "https"): + self.child_download(env, scheme + "://downloads.invalid") + self.assertEqual([method for method, _ in proxy.requests], ["GET", "GET", "CONNECT", "CONNECT"]) + self.assertFalse(origin.leaked_auth) + + def test_direct_and_no_proxy_bypass(self): + with serve(Origin, self.tls) as origin, serve(Proxy) as proxy: + origin.leaked_auth = False + proxy.origin, proxy.authenticate, proxy.requests = origin, True, [] + url = "https://127.0.0.1:" + str(origin.server_port) + for env in ({}, {"https_proxy": "", "HTTPS_PROXY": "http://unreachable.invalid"}, + {"HTTPS_PROXY": "http://127.0.0.1:" + str(proxy.server_port), "NO_PROXY": "irrelevant.invalid,127.0.0.1"}, + {"https_proxy": "http://127.0.0.1:" + str(proxy.server_port), "no_proxy": "127.0.0.1", + "HTTPS_PROXY": "http://unreachable.invalid", "NO_PROXY": "wrong.invalid"}): + self.child_download(env, url) + self.assertEqual(proxy.requests, []) + self.assertFalse(origin.leaked_auth) + + def test_rejected_proxy_does_not_reveal_credentials(self): + with serve(Proxy) as proxy: + proxy.authenticate, proxy.requests = True, [] + error = self.child_download({"http_proxy": "http://fixture:wrong-private%20password@127.0.0.1:" + str(proxy.server_port)}, + "http://downloads.invalid", expect_failure=True) + self.assertIn("HTTP 407", error) + + def test_installer_proxy_is_not_in_service_unit(self): + with mock.patch.dict(os.environ, {"https_proxy": "http://fixture:private%20password@proxy.invalid"}): + unit = installer.system_unit(Path("/var/lib/oac-node/.oac/nodes/fixture"), "docker") + self.assertIn("User=oac-node", unit) + self.assertNotIn("proxy", unit.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index bea43a03b..0000dfc72 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -54,6 +54,25 @@ as an ordinary user is rejected before reading the enrollment token or changing the host. This requirement applies to Sandbox Provider nodes, not the native self-hosted daemon installer. +### Download through a proxy + +Export standard `http_proxy` / `https_proxy` and `no_proxy` variables before +running the generated command. Uppercase `HTTP_PROXY`, `HTTPS_PROXY` and +`NO_PROXY` work too; lowercase takes precedence when both are set (including an +empty lowercase value). Use an HTTP proxy URL, optionally with URL-encoded +credentials, and comma-separated host exclusions in `no_proxy`. A proxy for +HTTPS downloads must support CONNECT. + +The command requests sudo preservation of only these six names and keeps them +when dropping to `oac-node`. If sudo policy denies preservation, ask the host +administrator to permit those names or run from a root shell with the variables +exported there. A manually added outer `sudo` can discard them before the +command starts. Do not put credentials in command arguments or share shell traces. + +These settings apply only to installation requests. They are not saved in node +configuration or the systemd unit; the running node still needs its own network +access to Core. Without proxy variables, downloads use the existing direct path. + ### The command The command shows each installation phase as it runs. After Core confirms the diff --git a/docs/maintainers.md b/docs/maintainers.md index 84db2bba2..a0f136560 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -519,7 +519,14 @@ can reach the administrator's terminal. That child also joins a new session keyring and dies with its parent, and root shows its output only as plain text (terminal controls become `?`). The installer turns SIGINT, SIGHUP and SIGTERM into stopping that child and what it started, which would otherwise outlive a -closed terminal. Root never runs a file that user can write, +closed terminal. The generated bootstrap requests only the six standard +HTTP/HTTPS proxy and bypass environment names through sudo, and normalizes +both spellings to the lowercase value when present, even if empty. This keeps +curl, urllib and the Go registration command on the same proxy and bypass rules. The installation child retains just those names +in addition to its fixed service-account environment. Proxy values stay out of +arguments, persisted configuration, service units and diagnostics; do not use +broad sudo environment inheritance. This is installation networking only, not +node-service proxy configuration. Root never runs a file that user can write, opens a URL it wrote, or follows a link in its home. Sudo mode never installs Docker, KVM or packages, never changes device permissions, refuses SELinux-enforcing hosts and a token in the environment, and changes nothing when a From f558cb6d496c4030a99b23fd573e1521652bbd65 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 14:41:18 +0000 Subject: [PATCH 2/3] test(web): expect proxy preservation in node commands --- apps/web/e2e/nodes.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index a130e7120..423c1541c 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -36,7 +36,7 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await expect(field).toHaveValue(/enroll_fixture_/); // The token goes on stdin to the checked installer, run with sudo unless the shell is root. await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/); - await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); + await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /); // It downloads from, and names as its source, the public URL, not the loopback address this browser uses. await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /); await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /); @@ -155,7 +155,7 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); // Removing the Core record leaves the system service for root/sudo to uninstall on its host. const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); - await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); await expect(cleanup.getByText("Installed without sudo?")).toHaveCount(0); await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveCount(0); // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. From d3e7f6b0ffe992fa81fc75971a2971031fb8cc8c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 30 Sep 2026 11:19:25 +0800 Subject: [PATCH 3/3] test(install): limit node proxy integration to Linux --- deploy/install/test_node_proxy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deploy/install/test_node_proxy.py b/deploy/install/test_node_proxy.py index 13cb77f43..2f66ef52d 100644 --- a/deploy/install/test_node_proxy.py +++ b/deploy/install/test_node_proxy.py @@ -10,6 +10,7 @@ import socket import ssl import subprocess +import sys import tempfile import threading import unittest @@ -90,6 +91,7 @@ def log_message(self, *args): pass +@unittest.skipUnless(sys.platform == "linux", "node service-user downloads require Linux") class NodeProxyTests(unittest.TestCase): @classmethod def setUpClass(cls):