diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a3de5caf..4b608a06 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -49,7 +49,7 @@ function installation() { return { // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, - local_only: local, source_commit: release.source_commit, + local_only: local, insecure_public_url: false, source_commit: release.source_commit, configuration: { path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z", settings: [ diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 1f90001d..57fd2e73 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { InstallationNotice } from "./InstallationNotice"; const installation: CoreInstallation = { - object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", + object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", insecure_public_url: false, source_commit: null, local_only: true, configuration: null, address_bindings: { nodes: 0, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, }; diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index af0a07e1..6ec811d3 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -90,6 +90,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback. // The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once. const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null; + // Only a plain-HTTP origin needs the installer's opt-in, and nodeSourceUrl returns one only for + // an installation that accepted it. + const insecureSourceUrl = Boolean(publicUrl?.startsWith("http://")); const available = consoleConfig.node_installer; const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; @@ -136,7 +139,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // Expired only once a read begun after the expiry found no node for the command. const expired = lapsed && fresh && checked !== null && checked.startedAt >= expiresAt && checked.nodes === nodes; const command = enrollment && provider && available && publicUrl && (registered || !expired) && !ready - ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256 }) : ""; + ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, allowInsecureCoreUrl: insecureSourceUrl }) : ""; const nodeId = node?.id ?? null; const polling = open && enrollment !== null && !ready && (registered || !expired); const check = useCallback(async () => { diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index d6806825..e2f7be0f 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -12,9 +12,17 @@ describe("HTTPS origin", () => { describe("node command source", () => { it("is the installation's public URL, never a loopback, missing or plain HTTP one", () => { - expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false })).toBe("https://core.example.com:8443"); - expect(nodeSourceUrl({ public_url: "https://127.0.0.1:8091", local_only: true })).toBeNull(); - expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); - expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull(); + expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false, insecure_public_url: false })).toBe("https://core.example.com:8443"); + expect(nodeSourceUrl({ public_url: "https://127.0.0.1:8091", local_only: true, insecure_public_url: false })).toBeNull(); + expect(nodeSourceUrl({ public_url: null, local_only: false, insecure_public_url: false })).toBeNull(); + expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false, insecure_public_url: false })).toBeNull(); + }); + it("keeps a plain HTTP one only for an installation that opted into a trusted network", () => { + expect(nodeSourceUrl({ public_url: "http://core.internal:8091", local_only: false, insecure_public_url: true })).toBe("http://core.internal:8091"); + expect(nodeSourceUrl({ public_url: " http://core.internal:8091/ ", local_only: false, insecure_public_url: true })).toBe("http://core.internal:8091"); + for (const public_url of ["http://user:secret@core.internal", "http://core.internal/v1", "ws://core.internal:8091", "https://core.example#", "", "core.internal:8091"]) { + expect(nodeSourceUrl({ public_url, local_only: false, insecure_public_url: true })).toBeNull(); + } + expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true, insecure_public_url: true })).toBeNull(); }); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index 091a2705..302d9fd3 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -17,10 +17,15 @@ export function httpsOrigin(value: string): string | null { * Where the node commands download the installer, and the `--source-url` they * pass it: the installation's public URL, whose reverse proxy sends * `/node-install/*` to this console. Unlike the browser's address, it is the - * same from every machine. Null when other machines can't use it: loopback - * (`local_only`), missing, or not an HTTPS origin. + * same from every machine, and the node, its installer and this console accept + * it only as HTTPS — or as plain HTTP on a trusted network, when the + * installation opted in (`insecure_public_url`). Null when other machines + * can't use it: loopback (`local_only`), missing, or neither of the two. */ -export function nodeSourceUrl(installation: Pick): string | null { +export function nodeSourceUrl(installation: Pick): string | null { if (installation.local_only || !installation.public_url) return null; - return httpsOrigin(installation.public_url); + const insecure = installation.insecure_public_url; + const candidate = installation.public_url.trim().replace(/\/$/, ""); + const pattern = insecure ? /^https?:\/\/[^/?#\\\s@]+$/i : /^https:\/\/[^/?#\\\s@]+$/i; + return pattern.test(candidate) && isValidDirectCoreBaseUrl(candidate, insecure) ? candidate : null; } diff --git a/apps/web/src/features/sandbox/enrollment-command.test.ts b/apps/web/src/features/sandbox/enrollment-command.test.ts index 180c4e1b..b17cc374 100644 --- a/apps/web/src/features/sandbox/enrollment-command.test.ts +++ b/apps/web/src/features/sandbox/enrollment-command.test.ts @@ -32,6 +32,12 @@ $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,N expect(nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, force: true }).split("\n").at(-1)) .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("passes the installation's plain-HTTP opt-in only when it asked for it", () => { + const args = { token: "secret'onetime", coreUrl: "http://core.internal:8091", sourceUrl: "http://core.internal:8091", provider: "docker" as const, installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest }; + expect(nodeInstallCommand(args).split("\n").at(-1)).toMatch(/--installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + expect(nodeInstallCommand({ ...args, allowInsecureCoreUrl: true }).split("\n").at(-1)).toMatch(/--installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --allow-insecure-core-url\)$/); + expect(nodeInstallCommand({ ...args, allowInsecureCoreUrl: false })).toBe(nodeInstallCommand(args)); + }); 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'"); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index f67f7d30..c4a17995 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -27,10 +27,13 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT * standard input (`printf` is a shell builtin), never in an argument, the * environment or sudo's command line. */ -export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: { +export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest, allowInsecureCoreUrl = false }: { token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; + /** The installation's opt-in, so the installer and the node accept a plain-HTTP origin on a trusted network. */ + allowInsecureCoreUrl?: boolean; }): string { - return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; + const insecure = allowInsecureCoreUrl ? " --allow-insecure-core-url" : ""; + return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)}${insecure})`; } /** diff --git a/apps/web/src/lib/connection.ts b/apps/web/src/lib/connection.ts index ae61346c..a77ee7ee 100644 --- a/apps/web/src/lib/connection.ts +++ b/apps/web/src/lib/connection.ts @@ -23,12 +23,17 @@ function hasExplicitUserInfo(candidate: string): boolean { return authority.includes("@"); } -export function isValidDirectCoreBaseUrl(value: string): boolean { +/** + * Whether the value is a Core origin a direct caller may use. Plain HTTP qualifies only for a + * loopback host, or when the caller says the installation opted into a trusted network; that + * decision stays with the caller. + */ +export function isValidDirectCoreBaseUrl(value: string, allowInsecureHTTP = false): boolean { try { const candidate = value.trim(); if (candidate.includes("?") || candidate.includes("#") || hasExplicitUserInfo(candidate)) return false; const url = new URL(candidate); - const secureTransport = url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHostname(url.hostname)); + const secureTransport = url.protocol === "https:" || (url.protocol === "http:" && (allowInsecureHTTP || isLoopbackHostname(url.hostname))); return secureTransport && !url.username && !url.password && !url.search && !url.hash; } catch { return false; diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 40e722ad..8b99b312 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -136,6 +136,7 @@ Core writes this record in the same transaction that creates the Session. Later | `public_url` | The [`public_url`](../../docs/configuration.md#settings) setting: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset | | `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys. Null when `public_url` is null | | `local_only` | True when `public_url` names a loopback host, which only the Core host reaches | +| `insecure_public_url` | True when `OAC_PUBLIC_URL_INSECURE` accepts a plain-HTTP `public_url` on a host that is not loopback, so nodes may enroll over it | | `source_commit` | The full source commit Core was built from; null for development builds | | `configuration` | The installer's snapshot of `config.json`; null when the installer did not start Core | | `address_bindings` | What a change of `public_url` affects, counted on each read | diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index f15a9312..fde59d00 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -443,6 +443,9 @@ definitions: description: OAC_INSTALLATION_ID; null when Core runs without the sandbox manager. type: string x-nullable: true + insecure_public_url: + description: True when OAC_PUBLIC_URL_INSECURE accepts a plain-HTTP public URL on a host that is not loopback, so nodes may enroll over that origin. False unless the operator opted in. + type: boolean local_only: description: True when public_url names a loopback host, reachable only from the Core host. type: boolean diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 071bb2c4..d8008116 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -138,6 +138,7 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent | `public_url` | `public_url` 设置([设置](../../../docs/zh/configuration.md#settings)):应用程序、节点、沙箱和自托管执行器使用的源地址。未设置时为 null | | `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL`。当 `public_url` 为 null 时为 null | | `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 | +| `insecure_public_url` | 当 `OAC_PUBLIC_URL_INSECURE` 允许在非回环主机上使用明文 `public_url` 时为 True,节点可经该地址注册 | | `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null | | `configuration` | 安装器对 `config.json` 的快照;安装器未启动 Core 时为 null | | `address_bindings` | 更改 `public_url` 所影响的内容,每次读取都会重新统计 | diff --git a/deploy/install/distribution.py b/deploy/install/distribution.py index c31fcbe9..1f4db69f 100644 --- a/deploy/install/distribution.py +++ b/deploy/install/distribution.py @@ -96,6 +96,21 @@ def artifact(manifest, name): return entry +# Plain HTTP reaches another machine only for an installation whose administrator opted into a +# trusted network. The installer records that, and calls allow_plain_http once before any download, +# so every guard here stays closed by default. +_plain_http = False + + +def allow_plain_http(allowed=True): + global _plain_http + _plain_http = bool(allowed) + + +def plain_http_allowed(): + return _plain_http + + def safe_url(value): try: parsed = urlsplit(value) @@ -108,18 +123,22 @@ def safe_url(value): pass if (not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment or any(c.isspace() for c in value) - or '\\' in value or parsed.scheme != 'https' and not (parsed.scheme == 'http' and loopback)): + or '\\' in value or parsed.scheme != 'https' + and not (parsed.scheme == 'http' and (loopback or plain_http_allowed()))): raise ValueError() except ValueError: - raise ArtifactError('Artifact downloads require HTTPS; loopback HTTP is only for local testing') from None + raise ArtifactError('Artifact downloads require HTTPS; plain HTTP is only for a loopback host ' + 'or an installation that opted into a trusted network') from None return value class ArtifactRedirect(urllib.request.HTTPRedirectHandler): - """Only artifact bytes may follow HTTPS redirects; metadata stays on Core.""" + """Only artifact bytes may follow redirects, and only to an origin safe_url accepts; metadata + stays on Core.""" def redirect_request(self, request, fp, code, msg, headers, newurl): safe_url(newurl) - if urlsplit(newurl).scheme != 'https' or request.get_method() not in ('GET', 'HEAD'): + scheme = urlsplit(newurl).scheme + if not (scheme == 'https' or (scheme == 'http' and plain_http_allowed())) or request.get_method() not in ('GET', 'HEAD'): raise ArtifactError('Artifact redirects require HTTPS') # Carry resume headers, never credentials or cookies, to a release/CDN host. forwarded = {name: value for name, value in request.header_items() diff --git a/deploy/install/node_generations.py b/deploy/install/node_generations.py index 738c9b76..336ecaf5 100644 --- a/deploy/install/node_generations.py +++ b/deploy/install/node_generations.py @@ -379,6 +379,9 @@ def prepare(args, installer): args.provider = args.configuration["provider"] configurations = retained_configs(root, installer) base = installer.private_json(root / "provider.json") + # Every generation this node serves keeps the opt-in the installation recorded. + args.allow_insecure_core_url = bool(base.get("insecure_core_url")) + installer.distribution.allow_plain_http(args.allow_insecure_core_url) runtime = args.configuration["specification"]["runtime"] value = configurations.get(args.generation) finalized = target.exists() or base["generation"] == args.generation diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 62cc7b31..8efbbd20 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -71,24 +71,42 @@ class RuntimeDownloadError(InstallError): NOTHING_CHANGED = " Nothing was changed." +def local_origin(value): + """True when an origin names a loopback host, which plain HTTP is always allowed for.""" + parsed = urlsplit(value) + try: + return parsed.hostname == "localhost" or ipaddress.ip_address(parsed.hostname).is_loopback + except (ValueError, TypeError): + return parsed.hostname == "localhost" + + def origin(value): try: parsed = urlsplit(value) parsed.port except ValueError: raise argparse.ArgumentTypeError("Invalid Core origin") from None - try: - local = parsed.hostname == "localhost" or ipaddress.ip_address(parsed.hostname).is_loopback - except (ValueError, TypeError): - local = parsed.hostname == "localhost" if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in ("", "/") - or any(c.isspace() for c in value) or any(c in value for c in "?#\\") - or (parsed.scheme == "http" and not local)): - raise argparse.ArgumentTypeError("Use an HTTPS origin, or loopback HTTP for a local node") + or any(c.isspace() for c in value) or any(c in value for c in "?#\\")): + raise argparse.ArgumentTypeError("Invalid Core origin") return value.rstrip("/") +def enforce_origin_scheme(parser, args): + """Plain HTTP reaches another machine only with the administrator's explicit opt-in, which this + installation records and the node then accepts. Everything that origin carries, including the + node credential and its WebSocket, then travels unencrypted. The opt-in is refused when nothing + needs it, so a forgotten or stale value fails instead of standing in for a policy nothing + applies.""" + plain = [value for value in (args.core_url, args.source_url) + if value and urlsplit(value).scheme == "http" and not local_origin(value)] + if plain and not args.allow_insecure_core_url: + parser.error("Use an HTTPS origin, or --allow-insecure-core-url on a trusted network") + if args.allow_insecure_core_url and not plain: + parser.error("--allow-insecure-core-url requires a plain-HTTP origin on a host that is not loopback") + + def checked(arguments, failure, explain=None, **kwargs): # explain(stderr) may replace the failure with an InstallError carrying fixed text only. try: @@ -288,6 +306,8 @@ def micro_home(installation_id): def provider_config(root, args, manifest, runtime_image): result = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url + "/api/v1", "specification": args.configuration["specification"], "generation": args.configuration["generation"]} + if getattr(args, "allow_insecure_core_url", False): + result["insecure_core_url"] = True if args.provider == "docker": result["docker"] = {"host": "unix:///var/run/docker.sock", "image": runtime_image, "network": "oac-node-" + args.installation_id, @@ -1263,6 +1283,8 @@ def main(argv=None): parser.add_argument("--provider", choices=("docker", "microsandbox"), help="Optional assertion; Core owns provider selection") parser.add_argument("--installation-id", required=True) parser.add_argument("--enrollment-token-stdin", action="store_true", help="Read the one-time enrollment token from standard input") + parser.add_argument("--allow-insecure-core-url", action="store_true", + help="Accept a plain-HTTP Core URL on a host that is not loopback, for an installation that opted into a trusted network") parser.add_argument("--generation-action", choices=("prepare", "collect"), help=argparse.SUPPRESS) parser.add_argument("--generation", type=int, help=argparse.SUPPRESS) parser.add_argument("--specification-digest", help=argparse.SUPPRESS) @@ -1298,6 +1320,8 @@ def main(argv=None): parser.error("--force applies only to --uninstall") if not (args.source_url or args.bundle) or not args.core_url: parser.error("--source-url (or --bundle) and --core-url are required") + enforce_origin_scheme(parser, args) + distribution.allow_plain_http(args.allow_insecure_core_url) if args.bundle is not None and (not args.bundle.is_absolute() or args.bundle.resolve() != args.bundle): raise InstallError("Local bundle must be an absolute directory without symlinks") token = read_token(args) diff --git a/deploy/install/test_distribution.py b/deploy/install/test_distribution.py index 32a7eb70..c57fbb51 100644 --- a/deploy/install/test_distribution.py +++ b/deploy/install/test_distribution.py @@ -161,6 +161,18 @@ def test_url_and_path_boundaries(self): distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'link') + def test_plain_http_downloads_need_the_installation_opt_in(self): + with self.assertRaises(distribution.DistributionError): + distribution.safe_url('http://private.example/node-install/manifest.json') + self.addCleanup(distribution.allow_plain_http, False) + distribution.allow_plain_http(True) + self.assertEqual(distribution.safe_url('http://private.example/node-install/manifest.json'), + 'http://private.example/node-install/manifest.json') + # Credentials, fragments and paths stay refused even with the opt-in. + for value in ('http://user:secret@private.example/a', 'http://private.example/a#f'): + with self.subTest(value=value), self.assertRaises(distribution.DistributionError): + distribution.safe_url(value) + def test_artifact_downgrades_and_metadata_redirects_are_refused(self): self.status = 302 self.redirect_target = 'http://127.0.0.1:1' diff --git a/deploy/install/test_node_install.py b/deploy/install/test_node_install.py index afbea2a9..c5bcf695 100644 --- a/deploy/install/test_node_install.py +++ b/deploy/install/test_node_install.py @@ -1018,14 +1018,44 @@ def test_changed_local_micro_resources_cannot_reconnect(self): self.assertEqual(target.read_bytes(), before) self.assertFalse(any("register" in call or "enable" in call for call, _ in self.calls)) - def test_origin_rejects_remote_http_credentials_paths_and_redirects(self): - for value in ("http://private.example", "https://user@core.example", "https://@core.example", "https://core.example/v1", "https://core.example?", "https://core.example#", "https://core.example\\path", "https://core.example:bad", ""): + def test_origin_rejects_credentials_paths_and_redirects(self): + for value in ("https://user@core.example", "https://@core.example", "https://core.example/v1", "https://core.example?", "https://core.example#", "https://core.example\\path", "https://core.example:bad", ""): with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): installer.origin(value) self.assertEqual(installer.origin("http://[::1]:8091/"), "http://[::1]:8091") + # The scheme policy needs the administrator's opt-in, so origin() keeps a structurally + # valid remote plain-HTTP origin and that check decides. + self.assertEqual(installer.origin("http://private.example"), "http://private.example") + self.assertFalse(installer.local_origin("http://10.0.0.5:8091")) + self.assertTrue(installer.local_origin("http://127.0.0.1:8091")) with self.assertRaisesRegex(installer.InstallError, "redirects"): installer.NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.example") + def test_plain_http_origin_needs_the_recorded_opt_in(self): + def refused(core_url, source_url, flag): + parser = mock.Mock() + installer.enforce_origin_scheme(parser, argparse.Namespace(core_url=core_url, source_url=source_url, allow_insecure_core_url=flag)) + return parser.error.called + # A remote plain-HTTP origin is refused without the opt-in, and accepted with it. + self.assertTrue(refused("http://private.example", None, False)) + self.assertFalse(refused("http://private.example", "http://private.example", True)) + # Loopback plain HTTP needs no opt-in, and the opt-in is refused when nothing needs it. + self.assertFalse(refused("http://127.0.0.1:8091", None, False)) + self.assertTrue(refused("http://127.0.0.1:8091", None, True)) + self.assertTrue(refused("https://core.example", None, True)) + self.assertFalse(refused("https://core.example", None, False)) + + def test_provider_config_records_the_plain_http_opt_in(self): + args = argparse.Namespace(installation_id=self.args.installation_id, provider="docker", + core_url="https://172.29.144.1:24443", configuration={"specification": {}, "generation": 1}) + recorded = installer.provider_config(self.root, args, self.manifest, "sha256:" + "b" * 64) + self.assertNotIn("insecure_core_url", recorded) + args.allow_insecure_core_url = True + args.core_url = "http://172.29.144.1:24443" + recorded = installer.provider_config(self.root, args, self.manifest, "sha256:" + "b" * 64) + self.assertTrue(recorded["insecure_core_url"]) + self.assertEqual(recorded["core_url"], "http://172.29.144.1:24443/api/v1") + class NodePrerequisiteTests(unittest.TestCase): def test_preflight_rejects_missing_kvm_before_downloads(self): diff --git a/docs/configuration.md b/docs/configuration.md index d653d1bd..33574840 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,6 +42,24 @@ When nodes, hosted sandboxes or self-hosted executors are bound to the current a - Existing sandboxes and executors keep working only while the old address still reaches this Core. A managed domain change replaces the previous domain route. - Self-hosted executors must restart with the new `remote_url`, and their installer refuses an installation made for the old address: create new self-hosted Sessions and connect their hosts again. +### Plain HTTP on a trusted network + +`install.sh`, `oac apply` and `web.core_url` require HTTPS for a non-loopback origin. Core itself accepts one more case, for a deployment you start yourself — the [standalone Compose template](./getting-started/install-options.md#docker-compose-and-hosting-platforms), your own service unit, or a development check-out: + +| Variable | Value | +| --- | --- | +| `OAC_PUBLIC_URL` | the plain origin, such as `http://10.0.0.5:8091` | +| `OAC_PUBLIC_URL_INSECURE` | `1` | + +The opt-in follows `core.runtime_history.insecure`: it defaults to off, is refused for an `https://` origin and refused without an origin, and any value other than `0` or `1` stops Core at startup. While it is on, everything the origin carries — Project API keys, daemon credentials, node credentials and the daemon WebSocket — travels unencrypted, so keep it on a network you control. + +What else changes, and what does not: + +- Core reports `local_only: false` for that origin, so Web stops showing the configure-HTTPS notice. +- Web issues node commands over that origin too. Core reports `insecure_public_url: true`, and the generated command passes `--allow-insecure-core-url` to the node installer, which records the opt-in in the node's `provider.json`; the node, its installer and the artifacts it downloads then accept the plain origin. The installer refuses the flag when nothing needs it, so a stale console fails instead of widening the policy. +- A provider whose guests must reach Core, such as E2B, still needs an address those guests can reach; a private address is not one. +- It is a process variable, not a `config.json` key. `oac apply` regenerates `generated/core.env`, so an `install.sh` installation cannot keep the opt-in. + ### Settings `core.runtime_history.headers` may hold export credentials. They stay in the `0600` `config.json` and the generated file Core reads, and never appear in `oac` output or in Core's settings snapshot. Model providers are not process settings; see [Default models](#default-models). @@ -169,6 +187,7 @@ Core reads only its environment. The installer renders `generated/core.env` from | Variable | Set from | | --- | --- | | `OAC_PUBLIC_URL` | `public_url`, or Core's loopback origin. Core derives the daemon WebSocket URL, the self-hosted `remote_url`, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers. Without it, Core runs no Runtime gateway and executes no Sessions | +| `OAC_PUBLIC_URL_INSECURE` | `0` or `1`; empty or omitted means `0`. `1` accepts a plain `http://` `OAC_PUBLIC_URL` on a host that is not loopback, as described in [Plain HTTP on a trusted network](#plain-http-on-a-trusted-network). Refused with an `https://` origin, refused without an origin, and refused for any other value | | `OAC_ADDR` | The installer sets `:8091` in the container. Independently started Core defaults to `127.0.0.1:8091` when unset or empty | | `OAC_DATABASE_URL` | The installation's PostgreSQL without a password, plus `core.database_pool` as `pool_*` query parameters | | `OAC_DATABASE_PASSWORD_FILE` | `secrets/database.password`. The URL must then carry no password; migrations and the maintenance commands read the file too | diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 2e506380..2155eab7 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -115,6 +115,8 @@ The proxy must: Run the proxy on the Core host while Core and Web listen on loopback, the default. `oac status` prints these routes with your addresses and ports. +**No certificate authority, and the network is trusted.** `install.sh` and `oac apply` require HTTPS for a non-loopback origin. If you start Core yourself — the [Compose template](#docker-compose-and-hosting-platforms) or your own service — `OAC_PUBLIC_URL_INSECURE=1` accepts a plain `http://` `OAC_PUBLIC_URL`, for example `http://10.0.0.5:8091`; see [plain HTTP on a trusted network](../configuration.md#plain-http-on-a-trusted-network). The console then offers node commands over that origin, and the installer records the opt-in in the node it installs. + **Caddy** obtains the certificate itself and passes Host and WebSockets by default: ```caddyfile diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 668eceb4..c64d0a81 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -51,7 +51,7 @@ The installer shows each phase as it runs and, once Core confirms the node, a su - Docker: rootful Docker Engine running, its socket `/var/run/docker.sock` owned by the `docker` group with mode `0660`, enforcing CPU and memory limits (cgroup v2). - microsandbox: `/dev/kvm` in the `kvm` group (hardware or nested virtualization), and the libraries microsandbox links (glibc). - CPUs and memory for at least one sandbox of the installation's size, and about 2 GB of disk for the Runtime image. -- HTTPS access to the console and Core at the public URL; sandboxes reach Core too. +- HTTPS access to the console and Core at the public URL; sandboxes reach Core too. An installation that opted into [plain HTTP on a trusted network](../configuration.md#plain-http-on-a-trusted-network) is the one exception: its command passes `--allow-insecure-core-url`, and the node accepts that origin. ### Download through a proxy diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index c68c8000..abe0c344 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -75,7 +75,7 @@ In an archived project the section hides **Issue credential** and **Rotate** beh | Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`); `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting, and the popover then shows only Core's status. The [Core metrics contract](../../contracts/agents-api/core-metrics.md) defines every measurement | -`local_only`, or a `public_url` that is not an HTTPS origin, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and System then show a visible warning with Core's configuration path and apply command as copyable values; when `configuration` is null, they state that the path and command are unavailable. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. +`local_only`, or a `public_url` that is neither an HTTPS origin nor a plain one the installation opted into (`insecure_public_url`), stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and System then show a visible warning with Core's configuration path and apply command as copyable values; when `configuration` is null, they state that the path and command are unavailable. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. Wherever a new key is shown, and without any key on an active project's page, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key, or a placeholder for a key of the project), with `curl` and Python examples for `GET /v1/agents` and `POST /v1/agents/sessions`, and sends none of them. When the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url`. @@ -101,7 +101,7 @@ The list carries each harness's configuration, so the console does not read `GET | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health; an empty `core_url` (a node Core did not enroll) is unknown, not old. **Add node** follows only the node whose `enrollment_id` equals its command's | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash | -| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is not an HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | +| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is neither HTTPS nor an origin the installation opted into), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion, and a Clean up the host dialog then gives the host's uninstall command (requiring root or sudo; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) | | Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index ec0bdb73..ca26acb5 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -44,6 +44,24 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 - 只有在旧地址仍可访问此 Core 时,现有沙箱和执行器才会继续工作。托管域更改会替换先前的域路由。 - 自托管执行器必须使用新的 `remote_url` 重启,并且其安装程序会拒绝为旧地址进行的安装:请创建新的自托管 Session,然后重新连接其主机。 +### 受信网络上的明文 HTTP {#plain-http-on-a-trusted-network} + +对于非回环源地址,`install.sh`、`oac apply` 和 `web.core_url` 都要求 HTTPS。Core 本身还接受一种情况,适用于你自行启动的部署——[独立 Compose 模板](getting-started/install-options.md#docker-compose-and-hosting-platforms)、你自己的服务单元,或开发用的代码检出: + +| 变量 | 值 | +| --- | --- | +| `OAC_PUBLIC_URL` | 明文源地址,例如 `http://10.0.0.5:8091` | +| `OAC_PUBLIC_URL_INSECURE` | `1` | + +该选项与 `core.runtime_history.insecure` 一致:默认关闭;与 `https://` 源地址一起使用时会被拒绝;没有源地址时会被拒绝;除 `0` 或 `1` 之外的任何值都会阻止 Core 启动。启用期间,该源地址承载的一切内容——Project API 密钥、守护进程凭据、节点凭据以及守护进程 WebSocket——都以明文传输,因此请仅在你能完全控制的网络中使用。 + +其他变化与不变之处: + +- Core 对该源地址报告 `local_only: false`,因此 Web 不再显示“配置 HTTPS”的提示。 +- Web 也会经该源地址生成节点命令。Core 报告 `insecure_public_url: true`,生成的命令向节点安装程序传递 `--allow-insecure-core-url`,安装程序把该选项记录进节点的 `provider.json`;此后节点、其安装程序以及它下载的构件都接受明文源地址。当没有任何需要时安装程序会拒绝该选项,因此过期的控制台会失败,而不是扩大策略。 +- 若某个提供程序的访客必须访问 Core(例如 E2B),则仍然需要一个访客可访问的地址;私有地址不属于此类地址。 +- 它是进程环境变量,而不是 `config.json` 的键。`oac apply` 会重新生成 `generated/core.env`,因此 `install.sh` 安装无法保留该选项。 + ### 设置 {#settings} `core.runtime_history.headers` 可以包含导出凭据。这些凭据保存在权限为 `0600` 的 `config.json` 和 Core 读取的生成文件中,绝不会出现在 `oac` 输出或 Core 的设置快照中。模型提供商不属于进程设置;请参阅[默认模型](#default-models)。 @@ -173,6 +191,7 @@ Core 只读取其环境。安装程序会根据 `config.json` 生成 `generated/ | 变量 | 设置来源 | | --- | --- | | `OAC_PUBLIC_URL` | `public_url`,或 Core 的回环源地址。Core 从中派生守护进程 WebSocket URL、自托管 `remote_url`、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生。未设置时,Core 不运行 Runtime 网关,也不执行任何 Session | +| `OAC_PUBLIC_URL_INSECURE` | `0` 或 `1`;为空或省略表示 `0`。`1` 允许在非回环主机上使用明文 `http://` 的 `OAC_PUBLIC_URL`,详见[受信网络上的明文 HTTP](#plain-http-on-a-trusted-network)。与 `https://` 源地址、缺少源地址或任何其他值一起使用时会被拒绝 | | `OAC_ADDR` | 安装程序在容器中设置为 `:8091`。独立启动的 Core 在未设置或为空时,默认使用 `127.0.0.1:8091` | | `OAC_DATABASE_URL` | 该安装不含密码的 PostgreSQL URL,并将 `core.database_pool` 作为 `pool_*` 查询参数附加到其中 | | `OAC_DATABASE_PASSWORD_FILE` | `secrets/database.password`。此时 URL 不得包含密码;迁移和维护命令也会读取该文件 | diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index 03b553f3..1c306aec 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -118,6 +118,8 @@ docker compose -f compose.yaml run --rm credentials 按照默认配置,让 Core 和 Web 在回环地址上监听,并在 Core 主机上运行反向代理。`oac status` 会使用你的地址和端口打印这些路由。 +**没有证书颁发机构,且网络可信。** 对于非回环源地址,`install.sh` 和 `oac apply` 要求 HTTPS。如果你自行启动 Core——[Compose 模板](#docker-compose-and-hosting-platforms)或你自己的服务——可用 `OAC_PUBLIC_URL_INSECURE=1` 接受明文 `http://` 的 `OAC_PUBLIC_URL`,例如 `http://10.0.0.5:8091`;请参阅[受信网络上的明文 HTTP](../configuration.md#plain-http-on-a-trusted-network)。此后控制台会经该源地址提供节点命令,安装程序会把该选项记录到它安装的节点中。 + **Caddy** 会自行获取证书,默认传递 Host 并支持 WebSockets: ```caddyfile diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 80370c96..ba6a1acc 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -53,7 +53,7 @@ printf '%s\n' '' | $s python3 "$d/node-install.pyz" ${NO_COLOR - Docker:正在运行的 rootful Docker Engine,其 `/var/run/docker.sock` 套接字属于 `docker` 组,权限为 `0660`,并强制执行 CPU 和内存限制(cgroup v2)。 - microsandbox:`/dev/kvm` 属于 `kvm` 组(硬件或嵌套虚拟化),并具有 microsandbox 链接的库(glibc)。 - CPU 和内存至少足以运行一个所配置规格的沙箱,以及约 2 GB 的 Runtime 镜像磁盘空间。 -- 可通过公开 URL 以 HTTPS 访问控制台和 Core;沙箱也能访问 Core。 +- 可通过公开 URL 以 HTTPS 访问控制台和 Core;沙箱也能访问 Core。唯一例外是[在受信网络上启用了明文 HTTP](../configuration.md#plain-http-on-a-trusted-network) 的安装:其命令会传递 `--allow-insecure-core-url`,节点也接受该源地址。 ### 通过代理下载 {#download-through-a-proxy} diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 62d278c0..39583074 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -77,7 +77,7 @@ source_hash: 0b1593010b41cf81e705ccf5cdb6df3c15e358b6da6b79ab588c552baa903481 | 安装 | `GET /core/v1/installation` | System 的 Installation 信息(`public_url`、`api_base_url`、`installation_id`、`source_commit`)和只读 Startup 设置(`path` 下的 `configuration.settings`,以及 `apply_command` 和 `applied_at`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`);Core 拒绝的 Sandbox 配置旁的 `path` 和 `apply_command`。如果敏感设置包含值,或存在未知成员,读取会失败;`configuration: null` 会显示一条说明 | | Core 指标 | `GET /core/v1/metrics?range=` | Core 指标页面;Overview 上的 Core 弹出内容。不存在该路由的 Core(404)会显示为未报告数据,此时弹出内容仅显示 Core 状态。[Core metrics contract](../../../contracts/agents-api/zh/core-metrics.md) 定义了每项度量 | -如果为 `local_only`,或者 `public_url` 不是 HTTPS 来源,Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 System 会显示醒目警告,其中 Core 的配置路径和 apply command 为可复制值;当 `configuration` 为 null 时,它们会说明路径和命令不可用。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 +如果为 `local_only`,或者 `public_url` 既不是 HTTPS 来源、也不是该安装启用了明文 HTTP 的源地址(`insecure_public_url`),Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 System 会显示醒目警告,其中 Core 的配置路径和 apply command 为可复制值;当 `configuration` 为 null 时,它们会说明路径和命令不可用。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 无论是在显示新密钥时,还是在活动项目页面没有显示任何密钥时,控制台都会提供 `OPENAI_BASE_URL`(安装的 `api_base_url`)和 `OPENAI_API_KEY`(新密钥,或项目密钥的占位符)的 shell 导出变量,以及针对 `GET /v1/agents` 和 `POST /v1/agents/sessions` 的 `curl` 和 Python 示例,但不会发送其中任何调用。当安装为 `local_only` 时,控制台会说明 API 只能在 Core 所在计算机上访问;当缺少 `api_base_url` 时,则会提示设置 `public_url`。 @@ -103,7 +103,7 @@ source_hash: 0b1593010b41cf81e705ccf5cdb6df3c15e358b6da6b79ab588c552baa903481 | Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中的节点容量。在线节点的 `diagnostic`(`docker_unavailable`、`docker_limits_unsupported`、`runtime_image_unavailable`、`kvm_unavailable`、`microsandbox_artifacts_unavailable`、`capacity_insufficient`、`provider_unavailable`;任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在上述每个页面及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态;如果 `core_url` 为空(Core 未注册该节点),则状态为未知,而不是旧地址。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | | 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics 节点对话框:主机自最近一次心跳以来的 CPU 忙碌占比和内存使用量,以及页面所选范围内二者的历史记录。**Edit node** 读取 `host.effective_cpu_cores` 和 `host.total_memory_bytes`,用于在每个 Sandbox 大小旁显示主机容量,以及最多可容纳多少个该大小的 Sandbox | | 分配 | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes 页面;Sandbox metrics。在 microsandbox 下,节点页面根据 `compute_phase_changed_at` 显示每个分配处于计算阶段的时间,并在分配暂停时估算 Core 回收它的时间(该时间加上部署的 `suspension.retention_seconds`);时间为 null 时显示短横线 | -| 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**:管理员先设置节点的 Sandbox 限制(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 不是 HTTPS 来源),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | +| 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**:管理员先设置节点的 Sandbox 限制(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 既不是 HTTPS、也不是该安装启用了明文 HTTP 的源地址),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | | 更新节点 | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**:同时修改名称和 Sandbox 限制(保留数量限制仅适用于 microsandbox;在 Docker 下,Core 会将其设为活动数量限制) | | 移除节点 | `DELETE /core/v1/sandbox/nodes/{node_id}` | 确认后移除节点;只有 Core 确认删除后该行才会消失,随后 Clean up the host 对话框会提供主机的卸载命令(需要 root 或 sudo;对于使用不同于部署地址的地址注册的节点,还需要使用 `--force`,以跳过安装程序与 Core 的确认) | | Runtime 观测 | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics:每个项目的托管 Runtimes,每项均以所属项目为标签;E2B Sandbox 对话框还会将 `observation.disk` 显示为已用量/限制值(其他位置为 null) | diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 5089dfdf..96787eb3 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -326,7 +326,7 @@ describe("AdminClient installation", () => { const port = { key: "ports.core", value: 8091, default: 8091, changeable: true, sensitive: false, restarts: ["core"] }; const headers = { key: "core.runtime_history.headers", value: null, default: null, configured: true, changeable: true, sensitive: true, restarts: ["core"] }; const installation = { - object: "core.installation", installation_id: resourceId, public_url: "https://core.example", api_base_url: "https://core.example/v1", + object: "core.installation", installation_id: resourceId, public_url: "https://core.example", api_base_url: "https://core.example/v1", insecure_public_url: false, local_only: false, source_commit: "a".repeat(40), configuration: { path: "/home/alice/.oac/core/config.json", apply_command: "/home/alice/.oac/core/oac apply", applied_at: "2026-09-25T09:30:00Z", settings: [port, headers] }, address_bindings: { nodes: 2, nodes_on_other_address: 1, hosted_sandboxes: 3, self_hosted_executors: 1 }, diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index a6f3a557..236c9447 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -283,11 +283,11 @@ function projectInstallationSetting(value: unknown): CoreInstallationSetting { } /** Sensitive settings carry no value, keys are unique and binding counts are consistent. */ export function projectInstallation(value: unknown): CoreInstallation { - const installation = record(value, ["object", "installation_id", "public_url", "api_base_url", "local_only", "source_commit", "configuration", "address_bindings"]); + const installation = record(value, ["object", "installation_id", "public_url", "api_base_url", "local_only", "insecure_public_url", "source_commit", "configuration", "address_bindings"]); const origin = installation.public_url; if (installation.object !== "core.installation" || (installation.installation_id !== null && canonicalUuid(installation.installation_id) === null) || (origin !== null && typeof origin !== "string") || installation.api_base_url !== (typeof origin === "string" ? `${origin}/v1` : null) || - typeof installation.local_only !== "boolean" || + typeof installation.local_only !== "boolean" || typeof installation.insecure_public_url !== "boolean" || (installation.source_commit !== null && (typeof installation.source_commit !== "string" || !/^[0-9a-f]{40}$/.test(installation.source_commit)))) return invalidAdminResponse(); const bindings = record(installation.address_bindings, ["nodes", "nodes_on_other_address", "hosted_sandboxes", "self_hosted_executors"]); if (![bindings.nodes, bindings.nodes_on_other_address, bindings.hosted_sandboxes, bindings.self_hosted_executors].every(isNonnegativeInteger) || diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index b11ba2dd..cb4dcdf9 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -219,6 +219,8 @@ export interface CoreInstallation { api_base_url: string | null; /** True when `public_url` is a loopback origin that only the Core host reaches. */ local_only: boolean; + /** True when Core accepts a plain-HTTP `public_url` on a host that is not loopback, so nodes may enroll over it. */ + insecure_public_url: boolean; /** Full source commit Core was built from; null for development builds. */ source_commit: string | null; /** Null when the installer did not start Core. */ diff --git a/services/core/cmd/sandbox-node/generations.go b/services/core/cmd/sandbox-node/generations.go index 3cd17d93..bc75b6b2 100644 --- a/services/core/cmd/sandbox-node/generations.go +++ b/services/core/cmd/sandbox-node/generations.go @@ -24,11 +24,11 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf if stateDir != filepath.Join(root, "state", "node") { return errors.New("generation state must belong to the installed node root") } - stored, err := node.RefreshIdentity(ctx, stateDir) + base, err := providerconfig.Load(configFile) if err != nil { return err } - base, err := providerconfig.Load(configFile) + stored, err := node.RefreshIdentity(ctx, stateDir, base.InsecureCoreURL) if err != nil { return err } @@ -163,7 +163,7 @@ func runGenerations(ctx context.Context, registry *providerconfig.Registry, conf return err } defer manager.Close() - return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) + return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, InsecureCoreURL: base.InsecureCoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) } func buildGeneration(registry *providerconfig.Registry, config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index 68749e61..dd3dbeb9 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -119,7 +119,7 @@ func run(ctx context.Context, args []string) error { if token == "" || len(token) > 4096 { return errors.New("invalid enrollment token") } - if _, err = node.InitIdentity(*stateDir, *coreURL, expected); err != nil { + if _, err = node.InitIdentity(*stateDir, *coreURL, expected, config.InsecureCoreURL); err != nil { return err } probeCtx, stopProbe := context.WithTimeout(ctx, 5*time.Second) @@ -128,13 +128,13 @@ func run(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("local provider readiness check failed (%s): %w", sandbox.NodeDiagnostic(err), err) } - stored, err := node.Enroll(ctx, *coreURL, *stateDir, token, node.EnrollmentRequest{Name: *name}) + stored, err := node.Enroll(ctx, *coreURL, *stateDir, token, node.EnrollmentRequest{Name: *name}, config.InsecureCoreURL) if err != nil { return err } return json.NewEncoder(os.Stdout).Encode(node.EnrollmentResponse{MaxActive: stored.Identity.MaxActive, MaxRetained: stored.Identity.MaxRetained, SpecificationDigest: stored.Identity.SpecificationDigest, DeploymentGeneration: stored.Identity.DeploymentGeneration, NodeID: stored.Identity.NodeID, InstallationID: stored.Identity.InstallationID, Provider: stored.Identity.Provider}) } - stored, err := node.RefreshIdentity(ctx, *stateDir) + stored, err := node.RefreshIdentity(ctx, *stateDir, config.InsecureCoreURL) if err != nil { return err } @@ -145,5 +145,5 @@ func run(ctx context.Context, args []string) error { if *coreURL != "" && *coreURL != stored.CoreURL { return errors.New("run uses the retained Core URL") } - return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: *stateDir, Identity: stored.Identity, Credential: stored.Credential, Provider: built.Provider, Probe: probe}) + return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, InsecureCoreURL: config.InsecureCoreURL, StateDirectory: *stateDir, Identity: stored.Identity, Credential: stored.Credential, Provider: built.Provider, Probe: probe}) } diff --git a/services/core/cmd/sandbox-node/main_test.go b/services/core/cmd/sandbox-node/main_test.go index 4da4298b..bda9e80d 100644 --- a/services/core/cmd/sandbox-node/main_test.go +++ b/services/core/cmd/sandbox-node/main_test.go @@ -52,10 +52,10 @@ func refreshIdentity(t *testing.T, coreURL string) error { t.Fatal(err) } identity := node.Identity{InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64)} - if _, err := node.InitIdentity(dir, coreURL, identity); err != nil { + if _, err := node.InitIdentity(dir, coreURL, identity, false); err != nil { t.Fatal(err) } - _, err := node.RefreshIdentity(t.Context(), dir) + _, err := node.RefreshIdentity(t.Context(), dir, false) return err } diff --git a/services/core/cmd/server/installation.go b/services/core/cmd/server/installation.go index a929acd4..87ebf42c 100644 --- a/services/core/cmd/server/installation.go +++ b/services/core/cmd/server/installation.go @@ -15,8 +15,9 @@ var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) // installationFacts reports what GET /core/v1/installation serves: Core's own // environment and build, plus the installer's settings snapshot. Core never // acts on the snapshot; it only reports it. -func installationFacts(publicURL string) (api.Installation, error) { +func installationFacts(publicURL string, insecurePublicURL bool) (api.Installation, error) { var facts api.Installation + facts.InsecurePublicURL = insecurePublicURL if id := os.Getenv("OAC_INSTALLATION_ID"); id != "" { facts.InstallationID = &id } diff --git a/services/core/cmd/server/installation_test.go b/services/core/cmd/server/installation_test.go new file mode 100644 index 00000000..918d4464 --- /dev/null +++ b/services/core/cmd/server/installation_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +func TestInstallationFactsReportThePlainHTTPOptIn(t *testing.T) { + facts, err := installationFacts("http://10.0.0.5:8091", true) + if err != nil || facts.PublicURL == nil || *facts.PublicURL != "http://10.0.0.5:8091" || facts.LocalOnly || !facts.InsecurePublicURL { + t.Fatalf("opted-in origin: %+v %v", facts, err) + } + facts, err = installationFacts("https://core.example", false) + if err != nil || facts.InsecurePublicURL || facts.LocalOnly { + t.Fatalf("default origin: %+v %v", facts, err) + } + facts, err = installationFacts("", false) + if err != nil || facts.PublicURL != nil || facts.APIBaseURL != nil || facts.InsecurePublicURL { + t.Fatalf("unset origin: %+v %v", facts, err) + } +} diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 8bf6d7c1..98e143c3 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -81,6 +81,10 @@ func run() error { if err := validateProcessConfiguration(); err != nil { return err } + insecurePublicURL, err := publicURLInsecure() + if err != nil { + return err + } public, err := publicURL() if err != nil { return err @@ -178,7 +182,7 @@ func run() error { if err != nil { return err } - installation, err := installationFacts(public) + installation, err := installationFacts(public, insecurePublicURL) if err != nil { return err } diff --git a/services/core/cmd/server/process_configuration.go b/services/core/cmd/server/process_configuration.go index 4b41c3be..df6402cf 100644 --- a/services/core/cmd/server/process_configuration.go +++ b/services/core/cmd/server/process_configuration.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" @@ -31,14 +32,47 @@ func validateProcessConfiguration() error { return nil } +// publicURLInsecure reads OAC_PUBLIC_URL_INSECURE. Omitted or empty selects the +// default; only "0" and "1" are accepted. +func publicURLInsecure() (bool, error) { + value := os.Getenv("OAC_PUBLIC_URL_INSECURE") + if value == "" { + return false, nil + } + if value != "0" && value != "1" { + return false, errors.New("OAC_PUBLIC_URL_INSECURE must be 0 or 1") + } + return value == "1", nil +} + // publicURL reads OAC_PUBLIC_URL, the one origin applications, nodes, // sandboxes and self-hosted executors use. An empty result disables daemon -// transport, as for a Core without execution. +// transport, as for a Core without execution. OAC_PUBLIC_URL_INSECURE=1 accepts +// plain HTTP for a host that is not loopback, for a deployment that stays on a +// trusted network and has no certificate authority. The flag is meaningless +// without an origin and refused for an HTTPS one, so a forgotten or stale value +// stops startup instead of standing in for a policy nothing applies. func publicURL() (string, error) { + insecure, err := publicURLInsecure() + if err != nil { + return "", err + } value := os.Getenv("OAC_PUBLIC_URL") if value == "" { + if insecure { + return "", errors.New("OAC_PUBLIC_URL_INSECURE requires OAC_PUBLIC_URL") + } return "", nil } + if insecure { + if deployment.ValidateCoreURLInsecure(value) != nil { + return "", errors.New("OAC_PUBLIC_URL must be a canonical HTTP or HTTPS origin without path, credentials, query or fragment, such as http://core.internal:8091, while OAC_PUBLIC_URL_INSECURE is 1") + } + if strings.HasPrefix(value, "https://") { + return "", errors.New("OAC_PUBLIC_URL_INSECURE requires an http OAC_PUBLIC_URL") + } + return value, nil + } if deployment.ValidateCoreURL(value) != nil { return "", errors.New("OAC_PUBLIC_URL must be a canonical HTTPS origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted only for a loopback host") } diff --git a/services/core/cmd/server/process_configuration_test.go b/services/core/cmd/server/process_configuration_test.go index ff9f9ab2..2322e669 100644 --- a/services/core/cmd/server/process_configuration_test.go +++ b/services/core/cmd/server/process_configuration_test.go @@ -42,3 +42,39 @@ func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { } } } + +func TestPublicURLInsecureOptOut(t *testing.T) { + t.Setenv("OAC_PUBLIC_URL_INSECURE", "0") + t.Setenv("OAC_PUBLIC_URL", "http://core.internal:8091") + if _, err := publicURL(); err == nil { + t.Fatal("accepted plain HTTP outside loopback without the opt-in") + } + t.Setenv("OAC_PUBLIC_URL_INSECURE", "1") + for _, value := range []string{"http://core.internal:8091", "http://10.0.0.5:8091", "http://localhost:8091"} { + t.Setenv("OAC_PUBLIC_URL", value) + if got, err := publicURL(); err != nil || got != value { + t.Fatal(value, got, err) + } + } + for _, value := range []string{"http://core.internal:8091/", "http://core.internal:8091/v1", "ws://core.internal:8091", "http://user:secret@core.internal"} { + t.Setenv("OAC_PUBLIC_URL", value) + if _, err := publicURL(); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("accepted or echoed", value, err) + } + } + t.Setenv("OAC_PUBLIC_URL", "https://core.example") + if _, err := publicURL(); err == nil { + t.Fatal("accepted the opt-in for an HTTPS public URL") + } + t.Setenv("OAC_PUBLIC_URL", "") + if _, err := publicURL(); err == nil { + t.Fatal("accepted the opt-in without a public URL") + } + t.Setenv("OAC_PUBLIC_URL", "http://core.internal:8091") + for _, value := range []string{"true", "yes", "2", "unused-secret"} { + t.Setenv("OAC_PUBLIC_URL_INSECURE", value) + if _, err := publicURL(); err == nil || strings.Contains(err.Error(), value) { + t.Fatal("accepted or echoed", value, err) + } + } +} diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go index d7328cec..9b970110 100644 --- a/services/core/internal/api/installation.go +++ b/services/core/internal/api/installation.go @@ -27,6 +27,9 @@ type Installation struct { APIBaseURL *string `json:"api_base_url" extensions:"x-nullable"` // True when public_url names a loopback host, reachable only from the Core host. LocalOnly bool `json:"local_only"` + // True when OAC_PUBLIC_URL_INSECURE accepts a plain-HTTP public URL on a host that is not + // loopback, so nodes may enroll over that origin. False unless the operator opted in. + InsecurePublicURL bool `json:"insecure_public_url"` // Full source commit Core was built from; null for development builds. SourceCommit *string `json:"source_commit" extensions:"x-nullable"` // The installer's settings snapshot (OAC_SETTINGS_FILE); null when the installer did not start Core. diff --git a/services/core/internal/deployment/public_url.go b/services/core/internal/deployment/public_url.go index a676955f..2ab27a1b 100644 --- a/services/core/internal/deployment/public_url.go +++ b/services/core/internal/deployment/public_url.go @@ -12,6 +12,18 @@ import ( // credential. Plain HTTP is reserved for explicit loopback development hosts. // OAC_PUBLIC_URL must pass it. func ValidateCoreURL(value string) error { + return validateCoreURL(value, false) +} + +// ValidateCoreURLInsecure accepts every origin ValidateCoreURL accepts and adds +// plain HTTP for a host that is not loopback. The caller owns that decision: +// every credential this origin carries, including Project API keys and machine +// credentials, then travels unencrypted. +func ValidateCoreURLInsecure(value string) error { + return validateCoreURL(value, true) +} + +func validateCoreURL(value string, allowInsecureHTTP bool) error { u, err := url.Parse(value) if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { return ErrInvalidInput @@ -43,7 +55,7 @@ func ValidateCoreURL(value string) error { } } } - if u.Scheme != "https" && !(u.Scheme == "http" && loopback) { + if u.Scheme != "https" && !(u.Scheme == "http" && (loopback || allowInsecureHTTP)) { return ErrInvalidInput } return nil diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go index 8cefc2f8..5e4d0545 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -34,6 +34,32 @@ func TestValidateCoreURL(t *testing.T) { } } +// ValidateCoreURLInsecure adds plain HTTP on a host that is not loopback. The +// installer and the node-side check keep the default rule, so these extra cases +// stay out of deploy/install/test_install.py. +func TestValidateCoreURLInsecure(t *testing.T) { + for _, value := range []string{ + "https://core.example", "https://core.example:8443", "https://[2001:db8::1]", + "http://localhost:8091", "http://127.0.0.2:8091", "http://[::1]:8091", + "http://core.example", "http://core.example:8091", "http://10.0.0.5:8091", "http://[2001:db8::1]:8091", "http://core", + } { + if err := ValidateCoreURLInsecure(value); err != nil { + t.Errorf("valid insecure Core URL %q rejected: %v", value, err) + } + } + for _, value := range []string{ + "", "wss://core.example", "ws://core.example", "ftp://core.example", "http://core.example/", "http://core.example/path", + "http://user:secret@core.example", "http://core.example?", "http://core.example?q=x", "http://core.example#x", + "http://CORE.example", "http://core.example:", "http://core.example:0", "http://core.example:65536", + "http://core.example:0080", "http://core.example:0443", "http://core.example\\evil", "http://[not-an-ip]", + "http://-core.example", "http://core..example", "http://core_example", "http://core.example.", "http://bücher.example", + } { + if err := ValidateCoreURLInsecure(value); !errors.Is(err, ErrInvalidInput) { + t.Errorf("invalid insecure Core URL %q accepted: %v", value, err) + } + } +} + func TestParseID(t *testing.T) { id := uuid.New() if got, err := parseID(strings.ToUpper(id.String())); err != nil || got != id.String() { diff --git a/services/core/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go index cb86b6ea..14de95d0 100644 --- a/services/core/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -17,13 +17,16 @@ import ( ) type AgentConfig struct { - Generations *GenerationManager - CoreURL string - StateDirectory string - Identity Identity - Credential string - Provider sandbox.SandboxProvider - Probe func(context.Context) (Health, error) + Generations *GenerationManager + CoreURL string + // InsecureCoreURL lets this node use a plain-HTTP Core URL on a host that is not + // loopback, as the node installer recorded for an installation that opted in. + InsecureCoreURL bool + StateDirectory string + Identity Identity + Credential string + Provider sandbox.SandboxProvider + Probe func(context.Context) (Health, error) // Dialer is optional, primarily for an operator-supplied TLS trust configuration. Dialer *websocket.Dialer } @@ -102,7 +105,7 @@ func Run(ctx context.Context, config AgentConfig) error { if config.CoreURL != stored.CoreURL { return sandbox.ErrOwnership } - if _, err = endpoint(config.CoreURL, ""); err != nil { + if _, err = endpoint(config.CoreURL, "", config.InsecureCoreURL); err != nil { return err } a := &agent{config: config, stored: stored, queue: make(chan work, maxPending)} @@ -179,7 +182,7 @@ func (a *agent) health(ctx context.Context, host *hostHealthSampler) (Health, er return h, e } func (a *agent) connect(ctx context.Context) error { - endpointURL, err := endpoint(a.config.CoreURL, "/api/v1/sandbox-node/connect") + endpointURL, err := endpoint(a.config.CoreURL, "/api/v1/sandbox-node/connect", a.config.InsecureCoreURL) if err != nil { return err } diff --git a/services/core/internal/sandbox/node/capacity_test.go b/services/core/internal/sandbox/node/capacity_test.go index 1de42f08..ba49ecc6 100644 --- a/services/core/internal/sandbox/node/capacity_test.go +++ b/services/core/internal/sandbox/node/capacity_test.go @@ -26,11 +26,11 @@ func TestCapacityRefreshUsesAuthenticatedCoreIdentity(t *testing.T) { defer server.Close() dir := stateDir(t) var err error - stored, err = InitIdentity(dir, server.URL, id) + stored, err = InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } - refreshed, err := RefreshIdentity(t.Context(), dir) + refreshed, err := RefreshIdentity(t.Context(), dir, false) if err != nil || refreshed.Identity.MaxActive != 2 || refreshed.Identity.MaxRetained != 8 || refreshed.Credential != stored.Credential { t.Fatal("approved capacity not refreshed", err) } @@ -39,7 +39,7 @@ func TestCapacityRefreshUsesAuthenticatedCoreIdentity(t *testing.T) { t.Fatal("approved capacity not persisted", err) } approved.InstallationID = "another-installation" - if _, err = RefreshIdentity(t.Context(), dir); err == nil { + if _, err = RefreshIdentity(t.Context(), dir, false); err == nil { t.Fatal("foreign identity accepted") } after, _ := LoadIdentity(dir) diff --git a/services/core/internal/sandbox/node/connection_test.go b/services/core/internal/sandbox/node/connection_test.go index 30f88ecd..f92ff76d 100644 --- a/services/core/internal/sandbox/node/connection_test.go +++ b/services/core/internal/sandbox/node/connection_test.go @@ -141,7 +141,7 @@ func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/creation_settlement_test.go b/services/core/internal/sandbox/node/creation_settlement_test.go index 02ef9524..e1f2dc8e 100644 --- a/services/core/internal/sandbox/node/creation_settlement_test.go +++ b/services/core/internal/sandbox/node/creation_settlement_test.go @@ -51,7 +51,7 @@ func TestNodeCarriesCreationSettlementWithoutConvertingFailureToSuccess(t *testi server := httptest.NewServer(hub) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/docker_live_test.go b/services/core/internal/sandbox/node/docker_live_test.go index 5862b308..dc5f1acd 100644 --- a/services/core/internal/sandbox/node/docker_live_test.go +++ b/services/core/internal/sandbox/node/docker_live_test.go @@ -49,7 +49,7 @@ func TestDockerNodeTransportLifecycle(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/enrollment.go b/services/core/internal/sandbox/node/enrollment.go index 4ec8660b..3dcad354 100644 --- a/services/core/internal/sandbox/node/enrollment.go +++ b/services/core/internal/sandbox/node/enrollment.go @@ -16,7 +16,7 @@ import ( // Enroll consumes a short-lived enrollment token. InitIdentity must have been // called with the local provider identity before invoking this function. -func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentRequest) (StoredIdentity, error) { +func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentRequest, insecureCoreURL bool) (StoredIdentity, error) { release, err := lockDirectory(dir) if err != nil { return StoredIdentity{}, err @@ -35,7 +35,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq input.CoreURL = coreURL client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} // This also recovers a consumed registration whose success response was lost. - target, err := endpoint(coreURL, "/api/v1/sandbox-node/identity") + target, err := endpoint(coreURL, "/api/v1/sandbox-node/identity", insecureCoreURL) if err != nil { return StoredIdentity{}, err } @@ -50,7 +50,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq return retainEnrollment(dir, stored, out) } } - target, err = endpoint(coreURL, "/api/v1/sandbox-node/enroll") + target, err = endpoint(coreURL, "/api/v1/sandbox-node/enroll", insecureCoreURL) if err != nil { return StoredIdentity{}, err } @@ -111,7 +111,7 @@ func retainEnrollment(dir string, stored StoredIdentity, out EnrollmentResponse) // RefreshIdentity reads approved capacity using the retained credential before // reconnecting. Core remains authoritative after an administrator changes it. -func RefreshIdentity(ctx context.Context, dir string) (StoredIdentity, error) { +func RefreshIdentity(ctx context.Context, dir string, insecureCoreURL bool) (StoredIdentity, error) { release, err := lockDirectory(dir) if err != nil { return StoredIdentity{}, err @@ -121,7 +121,7 @@ func RefreshIdentity(ctx context.Context, dir string) (StoredIdentity, error) { if err != nil { return StoredIdentity{}, err } - target, err := endpoint(stored.CoreURL, "/api/v1/sandbox-node/identity") + target, err := endpoint(stored.CoreURL, "/api/v1/sandbox-node/identity", insecureCoreURL) if err != nil { return StoredIdentity{}, err } diff --git a/services/core/internal/sandbox/node/generation_connection_test.go b/services/core/internal/sandbox/node/generation_connection_test.go index d39b6845..66e65fd0 100644 --- a/services/core/internal/sandbox/node/generation_connection_test.go +++ b/services/core/internal/sandbox/node/generation_connection_test.go @@ -41,7 +41,7 @@ func TestGenerationWireRoutesOldOwnershipAndCurrentTargetSeparately(t *testing.T defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/identity.go b/services/core/internal/sandbox/node/identity.go index 79567666..cf6d23ae 100644 --- a/services/core/internal/sandbox/node/identity.go +++ b/services/core/internal/sandbox/node/identity.go @@ -99,16 +99,16 @@ func writeIdentity(dir string, s StoredIdentity) error { // InitIdentity creates the credential before any enrollment request. A lost // enrollment response can therefore be recovered by authenticating this identity. -func InitIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { +func InitIdentity(dir, coreURL string, identity Identity, insecureCoreURL bool) (StoredIdentity, error) { release, err := lockDirectory(dir) if err != nil { return StoredIdentity{}, err } defer release() - return initIdentity(dir, coreURL, identity) + return initIdentity(dir, coreURL, identity, insecureCoreURL) } -func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { - if _, err := endpoint(coreURL, ""); err != nil { +func initIdentity(dir, coreURL string, identity Identity, insecureCoreURL bool) (StoredIdentity, error) { + if _, err := endpoint(coreURL, "", insecureCoreURL); err != nil { return StoredIdentity{}, err } stored, err := readIdentity(dir) @@ -150,14 +150,19 @@ func LoadIdentity(dir string) (StoredIdentity, error) { return readIdentity(dir) } -func endpoint(raw, path string) (string, error) { +// endpoint is the one place a node decides which Core origins it accepts. Plain HTTP is +// reserved for a loopback host, or for the installation's explicit opt-in that the node +// installer recorded; the caller owns that decision, and every credential this origin +// carries, including the node credential, then travels unencrypted. +func endpoint(raw, path string, insecureCoreURL bool) (string, error) { u, err := url.Parse(raw) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { return "", errors.New("node Core URL must be an origin") } if u.Scheme != "https" { ip := net.ParseIP(u.Hostname()) - if u.Scheme != "http" || !(u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { + loopback := u.Hostname() == "localhost" || ip != nil && ip.IsLoopback() + if u.Scheme != "http" || !(loopback || insecureCoreURL) { return "", errors.New("remote node Core URL requires HTTPS") } } diff --git a/services/core/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go index 2d375be1..ccf1ff28 100644 --- a/services/core/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -97,7 +97,7 @@ func TestLostCreateResponseDoesNotReplayAndReconnectSerializesCleanup(t *testing defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -202,7 +202,7 @@ func TestAgentRejectsDuplicateSequenceAndRetainsEpoch(t *testing.T) { })) defer server.Close() dir := stateDir(t) - stored, e := InitIdentity(dir, server.URL, id) + stored, e := InitIdentity(dir, server.URL, id, false) if e != nil { t.Fatal(e) } @@ -260,14 +260,14 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { defer server.Close() dir := stateDir(t) var err error - stored, err = InitIdentity(dir, server.URL, id) + stored, err = InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } - if _, err = Enroll(context.Background(), server.URL, dir, "enrollment", EnrollmentRequest{Name: "test"}); err == nil { + if _, err = Enroll(context.Background(), server.URL, dir, "enrollment", EnrollmentRequest{Name: "test"}, false); err == nil { t.Fatal("lost response reported success") } - recovered, err := Enroll(context.Background(), server.URL, dir, "enrollment", EnrollmentRequest{Name: "test"}) + recovered, err := Enroll(context.Background(), server.URL, dir, "enrollment", EnrollmentRequest{Name: "test"}, false) if err != nil { t.Fatal(err) } @@ -275,25 +275,37 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { t.Fatal("enrollment was replayed, identity rotated or approved capacity lost") } id.MaxActive, id.MaxRetained = 0, 0 - if retry, err := InitIdentity(dir, server.URL, id); err != nil || retry != recovered { + if retry, err := InitIdentity(dir, server.URL, id, false); err != nil || retry != recovered { t.Fatal("register retry treated absent local capacity as an override", err) } - if _, err := Enroll(t.Context(), server.URL, dir, "consumed", EnrollmentRequest{Name: "test"}); err != nil || enrollments != 1 { + if _, err := Enroll(t.Context(), server.URL, dir, "consumed", EnrollmentRequest{Name: "test"}, false); err != nil || enrollments != 1 { t.Fatal("register retry failed to recover approved identity", err) } } func TestCoreURLRejectsRemotePlaintextAndCredentials(t *testing.T) { for _, raw := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/path"} { - if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll"); err == nil { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll", false); err == nil { t.Fatalf("accepted %q", raw) } } for _, raw := range []string{"https://core.example.test:9443", "http://127.0.0.1:8080", "http://[::1]:8080"} { - if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll"); err != nil { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll", false); err != nil { t.Fatalf("rejected %q: %v", raw, err) } } + // The installer's recorded opt-in adds plain HTTP on a host that is not loopback, and + // nothing else: another scheme, credentials and a path stay refused. + for _, raw := range []string{"http://core.internal:8091", "http://10.0.0.5:8091"} { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll", true); err != nil { + t.Fatalf("rejected %q with the opt-in: %v", raw, err) + } + } + for _, raw := range []string{"ws://core.internal:8091", "ftp://core.internal:8091", "https://user:pass@example.com", "https://example.com/path"} { + if _, err := endpoint(raw, "/api/v1/sandbox-node/enroll", true); err == nil { + t.Fatalf("accepted %q with the opt-in", raw) + } + } } func TestHealthSendsOnlyFixedDiagnosticCode(t *testing.T) { diff --git a/services/core/internal/sandbox/node/observations_test.go b/services/core/internal/sandbox/node/observations_test.go index f9b7e165..7b03b0c3 100644 --- a/services/core/internal/sandbox/node/observations_test.go +++ b/services/core/internal/sandbox/node/observations_test.go @@ -43,7 +43,7 @@ func observationTarget(r sandbox.Reference, installation string) runtimeobs.Targ func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { t.Helper() dir := stateDir(t) - stored, err := InitIdentity(dir, url, id) + stored, err := InitIdentity(dir, url, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/recovery_test.go b/services/core/internal/sandbox/node/recovery_test.go index 23ba863e..33212c13 100644 --- a/services/core/internal/sandbox/node/recovery_test.go +++ b/services/core/internal/sandbox/node/recovery_test.go @@ -33,7 +33,7 @@ func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock(); h := current; mu.Unlock(); h.ServeHTTP(w, r) })) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestAgentRejectsOwnerEpochRollback(t *testing.T) { })) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestHeartbeatAcknowledgementKeepsIdleConnectionAlive(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -209,7 +209,7 @@ func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -255,13 +255,13 @@ func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { id := identity() dir := stateDir(t) - stored, err := InitIdentity(dir, "https://core.example.test", id) + stored, err := InitIdentity(dir, "https://core.example.test", id, false) if err != nil { t.Fatal(err) } mismatch := id mismatch.BackendFingerprint = strings.Repeat("2", 64) - if _, err = InitIdentity(dir, stored.CoreURL, mismatch); err == nil { + if _, err = InitIdentity(dir, stored.CoreURL, mismatch, false); err == nil { t.Fatal("adopted wrong backend") } original, err := LoadIdentity(dir) @@ -271,7 +271,7 @@ func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { if err = os.WriteFile(filepath.Join(dir, "identity.json"), []byte("corrupt"), 0600); err != nil { t.Fatal(err) } - if _, err = InitIdentity(dir, stored.CoreURL, id); err == nil { + if _, err = InitIdentity(dir, stored.CoreURL, id, false); err == nil { t.Fatal("corrupt identity replaced") } if err = os.Remove(filepath.Join(dir, "identity.json")); err != nil { diff --git a/services/core/internal/sandbox/node/timeout_test.go b/services/core/internal/sandbox/node/timeout_test.go index c849b853..5d0f29be 100644 --- a/services/core/internal/sandbox/node/timeout_test.go +++ b/services/core/internal/sandbox/node/timeout_test.go @@ -82,7 +82,7 @@ func TestQueuedMutationExpiresWithoutExecution(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 22e789f4..54d20f04 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -17,13 +17,16 @@ import ( ) type Config struct { - Specification sandbox.DeploymentSpec `json:"specification"` - Generation uint64 `json:"generation"` - CoreURL string `json:"core_url"` - Provider string `json:"provider"` - InstallationID string `json:"installation_id"` - Docker *Docker `json:"docker,omitempty"` - Microsandbox *Microsandbox `json:"microsandbox,omitempty"` + Specification sandbox.DeploymentSpec `json:"specification"` + Generation uint64 `json:"generation"` + CoreURL string `json:"core_url"` + // Set by the node installer only when the administrator asked for it: this node may use a + // plain-HTTP Core URL on a host that is not loopback, as the installation opted into. + InsecureCoreURL bool `json:"insecure_core_url"` + Provider string `json:"provider"` + InstallationID string `json:"installation_id"` + Docker *Docker `json:"docker,omitempty"` + Microsandbox *Microsandbox `json:"microsandbox,omitempty"` } type Docker struct {