diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 78000a33..1050c42d 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -11,14 +11,16 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; * Fixture state options: `fresh` is a new install (no project, Session or Runtime), * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" - * (loopback: only the Core machine reaches the API, and E2B is rejected) or "stale" (public, - * with a node enrolled with an earlier address), `credentials: "none"` a Core without a + * (loopback: only the Core machine reaches the API, and E2B is rejected), "stale" (public, + * with a node enrolled with an earlier address), "http" (a non-loopback HTTP address with + * allow_insecure_origin off) or "insecure" (the same address with allow_insecure_origin on), + * `credentials: "none"` a Core without a * credential encryption key, which cannot store a model provider's key, and * `installers: "none"` a console without its node installation payload, so it serves neither * the node nor the self-hosted installer. `nodeArtifacts` lists the providers the console has * node files for, both by default; as in the console, microsandbox needs Docker's files too. */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" | "http" | "insecure"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a3de5caf..eb693e42 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -29,13 +29,19 @@ const manifest = { platform: "linux/amd64", source_commit: release.source_commit /** * config.json's public_url. "public": an HTTPS address, so applications get an API base URL; * "local": the installer's loopback default, reachable only on the Core machine; - * "stale": public, with a node still enrolled with an earlier address. + * "stale": public, with a node still enrolled with an earlier address; + * "http": a non-loopback HTTP address with allow_insecure_origin off; + * "insecure": the same address with allow_insecure_origin on. */ const PUBLIC_URL = "https://core.example.com"; const LOCAL_URL = "http://127.0.0.1:8091"; +/** A non-loopback HTTP address a development installation with allow_insecure_origin may use. */ +const INSECURE_URL = "http://10.0.0.5:8080"; /** The address a node enrolled with before public_url last changed. */ const OLD_URL = "https://core-old.example.com"; -const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); +/** "insecure" and "http" share the plain-HTTP address; only "insecure" turns the switch on. */ +const httpAddress = () => state.installation === "insecure" || state.installation === "http"; +const publicUrl = () => (state.installation === "local" ? LOCAL_URL : httpAddress() ? INSECURE_URL : PUBLIC_URL); /** The digest the console reports for its self-hosted executor installer; the same value as in monitoring.spec.ts. */ /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; @@ -54,6 +60,7 @@ function installation() { path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z", settings: [ setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), + setting("allow_insecure_origin", state.installation === "insecure", false, ["core"]), setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), setting("web_listen_address", "127.0.0.1:4173", "127.0.0.1:4173", ["web"]), setting("data_dir", "/var/lib/oac", "/var/lib/oac", [], { changeable: false }), @@ -97,7 +104,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d // Self-hosted Sessions get their remote_url from public_url, as in Core. const screenshots = process.env.OAC_WEB_SCREENSHOT_DEMO === "1"; const now = Math.floor(Date.now() / 1000); - const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : PUBLIC_URL); + const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : address === "insecure" || address === "http" ? INSECURE_URL : PUBLIC_URL); const resources = buildResources(now, base.agents, base.sessions); const admin = buildAdmin(now, base, resources); // A fresh install: no project, Session or Runtime yet; Getting started leads. @@ -111,7 +118,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d violations: [], writes: [], failNext: null, nextId: 1, // Executor credential metadata by environment ID; tokens are never kept. executorCredentials: new Map(), - // How config.json's public_url is set: "public", "local" or "stale". + // How config.json's public_url is set: "public", "local", "stale", "http" or "insecure". installation: address, // "none": Core has no credential encryption key, so it cannot store a provider's key. credentialKey: credentials !== "none", diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index bc5ea436..5c762775 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -146,6 +146,34 @@ test("issues no command before the installation is read, for a loopback public U await expect(add.getByRole("button", { name: "Generate command" })).toBeVisible(); }); +test("offers a plaintext HTTP node command with a warning only when allow_insecure_origin is on", async ({ page, request }) => { + // The switch is off by default: a non-loopback HTTP public URL still blocks Add node. + await openConsole(page, request, "nodes", { installation: "http" }); + await page.getByRole("button", { name: "Add node" }).click(); + const blocked = page.getByRole("dialog", { name: "Add node" }); + await expect(blocked.getByRole("status")).toHaveText("Set a public HTTPS address before adding nodes."); + await expect(blocked.getByRole("button", { name: "Generate command" })).toHaveCount(0); + await expect(blocked.getByText(/Plaintext HTTP/)).toHaveCount(0); + // Close before reopening: the next openConsole keeps the same #nodes hash, so the page does + // not reload and a leftover overlay would intercept the next click. + await blocked.getByRole("button", { name: "Close dialog" }).click(); + await expect(blocked).toBeHidden(); + + // With the switch on, the command downloads from and names the plain-HTTP public URL, and the dialog warns. + await openConsole(page, request, "nodes", { installation: "insecure" }); + await page.getByRole("button", { name: "Add node" }).click(); + const add = page.getByRole("dialog", { name: "Add node" }); + await expect(add.getByRole("alert")).toContainText("Plaintext HTTP"); + await expect(add.getByText("Reaches http://10.0.0.5:8080, as do its sandboxes")).toBeVisible(); + await add.getByLabel("Sandboxes at once").fill("2"); + const issued = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); + await add.getByRole("button", { name: "Generate command" }).click(); + await issued; + const field = add.getByLabel("One-time enrollment command", { exact: true }); + await expect(field).toHaveValue(/curl [^\n]* 'http:\/\/10\.0\.0\.5:8080\/node-install\/node-install\.pyz' /); + await expect(field).toHaveValue(/ --source-url 'http:\/\/10\.0\.0\.5:8080' --core-url 'http:\/\/10\.0\.0\.5:8080' /); +}); + test("removes a node after confirmation", async ({ page, request }) => { await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Remove edge-03" }).click(); @@ -165,6 +193,18 @@ test("removes a node after confirmation", async ({ page, request }) => { await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused(); }); +test("gives the host's uninstall command over a plain-HTTP public URL when allow_insecure_origin is on", async ({ page, request }) => { + await openConsole(page, request, "nodes", { installation: "insecure" }); + await page.getByRole("button", { name: "Remove edge-03" }).click(); + await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); + // The node Add node enrolled over http://IP:8080 is cleaned up over the same address, not blocked on HTTPS. + const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'http:\/\/10\.0\.0\.5:8080\/node-install\/node-install\.pyz'/); + await expect(cleanup.getByText("An uninstall command needs an HTTPS public URL", { exact: false })).toHaveCount(0); + await cleanup.getByRole("button", { name: "Done" }).click(); + await expect(cleanup).toBeHidden(); +}); + test("marks a node on an old Core address in its row, beside each node's limit", async ({ page, request }) => { await openConsole(page, request, "nodes", { installation: "stale" }); const row = page.getByRole("row", { name: /core-01/ }); diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx new file mode 100644 index 00000000..15c3360b --- /dev/null +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx @@ -0,0 +1,57 @@ +import type { CoreInstallation } from "@oac/agents-client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { installationQuery } from "../../lib/installation"; +import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; + +const cleanup: NodeCleanup = { + name: "edge-01", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", + scriptDigest: "a".repeat(64), provider: "docker", oldAddress: null, +}; + +/** The installation Add node used: `allowInsecure` undefined means the snapshot predates the setting. */ +function installation(allowInsecure: boolean | undefined, publicUrl: string | null): CoreInstallation { + return { + object: "core.installation", installation_id: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", + public_url: publicUrl, api_base_url: publicUrl === null ? null : `${publicUrl}/v1`, local_only: false, + source_commit: "c".repeat(40), + configuration: { + path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-01-01T00:00:00Z", + settings: allowInsecure === undefined ? [] : [{ key: "allow_insecure_origin", value: allowInsecure, default: false, changeable: true, sensitive: false, restarts: ["core"] }], + }, + address_bindings: { nodes: 1, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, + }; +} + +function render(data: CoreInstallation): string { + const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + cache.setQueryData(installationQuery.queryKey, data); + const html = renderToStaticMarkup( {}} />); + cache.clear(); + return html; +} + +describe("the host uninstall command under allow_insecure_origin", () => { + it("generates the command for a non-loopback plain-HTTP public URL when the switch is on", () => { + const html = render(installation(true, "http://10.0.0.5:8080")); + expect(html).toContain("http://10.0.0.5:8080/node-install/node-install.pyz"); + expect(html).toContain("--uninstall --installation-id"); + }); + it("keeps the HTTPS requirement and issues no command when the switch is off", () => { + const html = render(installation(false, "http://10.0.0.5:8080")); + expect(html).not.toContain("node-install.pyz"); + expect(html).toContain("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."); + }); + it("keeps the HTTPS requirement when the snapshot predates the setting", () => { + const html = render(installation(undefined, "http://10.0.0.5:8080")); + expect(html).not.toContain("node-install.pyz"); + expect(html).toContain("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."); + }); + it("drops only the HTTPS wording when the switch is on but no public URL is usable", () => { + const html = render(installation(true, null)); + expect(html).toContain("An uninstall command needs a public URL that other machines can reach, and this installation has none."); + expect(html).not.toContain("An uninstall command needs an HTTPS public URL"); + }); +}); diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index 9f4815a3..a9b92bad 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { installationQuery } from "../../lib/installation"; -import { nodeSourceUrl } from "./core-origin"; +import { allowsInsecureOrigin, nodeSourceUrl } from "./core-origin"; import { nodeUninstallCommand } from "./enrollment-command"; import { CommandBlock } from "./node-commands"; @@ -26,14 +26,17 @@ export interface NodeCleanup { * images. Like Add node's, the command downloads from the installation's public * URL, which the dialog reads (again, if it is not at hand): until it is read, if * the read fails (with Try again), or while other machines can't use it, the - * dialog says so in place of the command. It never opens empty. + * dialog says so in place of the command. It never opens empty. With the + * installation's `allow_insecure_origin` switch on, a non-loopback plain-HTTP + * public URL is accepted, as it is for Add node. */ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) { const { t, i18n } = useTranslation("sandbox"); // Sentences run on with a space in English and without one in Chinese. const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " "); const installation = useQuery({ ...installationQuery, enabled: cleanup !== null }); - const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null; + const allowInsecure = allowsInsecureOrigin(installation.data); + const sourceUrl = installation.data ? nodeSourceUrl(installation.data, allowInsecure) : null; const command = (force = false) => cleanup && sourceUrl ? nodeUninstallCommand({ sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, force }) : ""; const stays = cleanup ? t("{{name}} is removed from Core, but its service and files stay on the host.", { name: cleanup.name }) : ""; @@ -45,7 +48,9 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle : cleanup && !sourceUrl ?

{join(stays, installation.data?.local_only && installation.data.public_url ? t("Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.", { url: installation.data.public_url }) - : t("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."))}

+ : allowInsecure + ? t("An uninstall command needs a public URL that other machines can reach, and this installation has none.") + : t("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."))}

: cleanup ?

{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}

diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 3148bc2e..5a855cfd 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -13,7 +13,7 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started"; import { harnessesQuery } from "../system/harness-queries"; -import { nodeSourceUrl } from "./core-origin"; +import { allowsInsecureOrigin, nodeSourceUrl } from "./core-origin"; import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command"; import { CommandBlock, CopyCommand, HostRequirements } from "./node-commands"; @@ -43,7 +43,8 @@ const DEFAULT_RETAINED = "8"; * sudo (or directly as root), which installs the node as a system service. * The log hint names that system service. No command is issued until the installation * is read: one whose public URL other machines can't use (loopback, as - * `local_only` says, or not HTTPS), an unreadable one, or a console that + * `local_only` says, or not HTTPS unless the installation's + * `allow_insecure_origin` switch is on), an unreadable one, or a console that * reports no node files for the deployment's provider (`node_artifacts`) says * so instead. Each opening, and each return to the window while open, reads * the installation and the console again, so a fix on the Core host shows @@ -89,7 +90,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const request = useRef(null); // 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; + const allowInsecure = allowsInsecureOrigin(installation.data); + const publicUrl = installation.data ? nodeSourceUrl(installation.data, allowInsecure) : null; + // The node commands use plain HTTP only when the development switch is on; say so where they appear. + const insecure = allowInsecure && publicUrl !== null && 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"; @@ -265,6 +269,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : sandboxDiagnosticMessage(progress.problem, locale); return createPortal(
+ {insecure ?

{t("Plaintext HTTP: allow_insecure_origin is on, so the enrollment token and the node's credentials travel unencrypted. Use this only on a trusted network.")}

: null} {!available ?

{t("This console serves no node installer. For a console deployed by hand, point OAC_WEB_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.")}

: !enrollment && blocker ? blocker.failed ?

{blocker.text}

diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 27b0ad8c..b7cc309f 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -11,6 +11,15 @@ .sandbox-summary dt { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; } .sandbox-summary dd { margin: 0; color: var(--ink); font-size: 13px; overflow-wrap: anywhere; } .sandbox-error { color: var(--danger) !important; } +/* A development installation with allow_insecure_origin: the command is plain HTTP. */ +.sandbox-insecure-origin { + padding: 10px 12px; + color: var(--fg) !important; + font-size: 12.5px; + background: color-mix(in srgb, var(--warning) 9%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--warning) 26%, var(--line)); + border-radius: 8px; +} .sandbox-empty { display: grid; justify-items: center; gap: 12px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; padding: 56px 24px; color: var(--fg-muted); } .sandbox-empty h3 { color: var(--fg); font-size: 16px; margin: 0; font-weight: 500; } .sandbox-empty p { max-width: 320px; font-size: 13px; } diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index d6806825..c126cba6 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -1,5 +1,6 @@ +import type { CoreInstallationSetting } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; -import { httpsOrigin, nodeSourceUrl } from "./core-origin"; +import { allowsInsecureOrigin, httpsOrigin, nodeSourceUrl } from "./core-origin"; describe("HTTPS origin", () => { it.each([ @@ -17,4 +18,30 @@ describe("node command source", () => { expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull(); }); + it("takes a non-loopback HTTP public URL only when allow_insecure_origin is on", () => { + expect(nodeSourceUrl({ public_url: "http://10.0.0.5:8080", local_only: false }, true)).toBe("http://10.0.0.5:8080"); + // The switch never makes a loopback address reachable from another machine. + expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true }, true)).toBeNull(); + // A path, credentials or a query are not an origin, switch or not. + expect(nodeSourceUrl({ public_url: "http://10.0.0.5:8080/v1", local_only: false }, true)).toBeNull(); + expect(nodeSourceUrl({ public_url: "http://user:secret@10.0.0.5:8080", local_only: false }, true)).toBeNull(); + }); +}); + +describe("the allow_insecure_origin switch", () => { + const setting = (key: string, value: unknown): CoreInstallationSetting => ({ key, value, default: false, changeable: true, sensitive: false, restarts: ["core"] }); + const installation = (settings: CoreInstallationSetting[] | null) => ({ + configuration: settings === null ? null : { path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-01-01T00:00:00Z", settings }, + }); + it("reads Core's settings snapshot", () => { + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", true)]))).toBe(true); + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", false)]))).toBe(false); + }); + it("is off when the snapshot lacks it or there is none", () => { + expect(allowsInsecureOrigin(installation([setting("public_url", "https://core.example")]))).toBe(false); + // Only a literal true turns it on; any other value reads as off. + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", "true")]))).toBe(false); + expect(allowsInsecureOrigin(installation(null))).toBe(false); + expect(allowsInsecureOrigin(undefined)).toBe(false); + }); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index 091a2705..12c2a789 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -3,14 +3,22 @@ import type { CoreInstallation } from "@oac/agents-client"; import { isValidDirectCoreBaseUrl } from "../../lib/connection"; /** - * The value itself when it is an HTTPS origin: no path, query, fragment or - * credentials; a single trailing slash is dropped. Otherwise null. It is kept - * as written, not normalized, so an explicit port such as :443 stays exactly - * as Core reports it. + * The value itself when it is an origin the node commands may use directly: no + * path, query, fragment or credentials; a single trailing slash is dropped. It + * is kept as written, not normalized, so an explicit port such as :443 stays + * exactly as Core reports it. HTTPS always qualifies; plain HTTP only on a + * loopback host, or on any host when `allowInsecure` is set (a development + * installation with `allow_insecure_origin`). */ -export function httpsOrigin(value: string): string | null { +function origin(value: string, allowInsecure: boolean): string | null { const candidate = value.trim().replace(/\/$/, ""); - return /^https:\/\/[^/?#\\\s@]+$/i.test(candidate) && isValidDirectCoreBaseUrl(candidate) ? candidate : null; + const pattern = allowInsecure ? /^https?:\/\/[^/?#\\\s@]+$/i : /^https:\/\/[^/?#\\\s@]+$/i; + return pattern.test(candidate) && isValidDirectCoreBaseUrl(candidate, allowInsecure) ? candidate : null; +} + +/** The HTTPS origin form, the default the node commands require. */ +export function httpsOrigin(value: string): string | null { + return origin(value, false); } /** @@ -18,9 +26,19 @@ export function httpsOrigin(value: string): string | null { * 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. + * (`local_only`), missing, or not an HTTPS origin unless `allowInsecure` is set. */ -export function nodeSourceUrl(installation: Pick): string | null { +export function nodeSourceUrl(installation: Pick, allowInsecure = false): string | null { if (installation.local_only || !installation.public_url) return null; - return httpsOrigin(installation.public_url); + return origin(installation.public_url, allowInsecure); +} + +/** + * Whether this installation allows a non-loopback plain-HTTP origin. Core's + * settings snapshot (`allow_insecure_origin`, from `OAC_ALLOW_INSECURE_ORIGIN`) + * is the only source: the console never reads that environment variable or a + * second field. Absent, the switch is off. + */ +export function allowsInsecureOrigin(installation: Pick | undefined): boolean { + return installation?.configuration?.settings.find((entry) => entry.key === "allow_insecure_origin")?.value === true; } diff --git a/apps/web/src/lib/connection.test.ts b/apps/web/src/lib/connection.test.ts index 9cc23719..00cf1da9 100644 --- a/apps/web/src/lib/connection.test.ts +++ b/apps/web/src/lib/connection.test.ts @@ -18,6 +18,15 @@ describe("Core URL checks", () => { expect(isValidDirectCoreBaseUrl(baseUrl)).toBe(true); }); + it.each([ + "http://core.example/v1", + "http://192.168.1.20:8091/v1", + "http://10.0.0.5:8080", + ])("allows a non-loopback HTTP direct Core URL only with allow_insecure_origin: %s", (baseUrl) => { + expect(isValidDirectCoreBaseUrl(baseUrl)).toBe(false); + expect(isValidDirectCoreBaseUrl(baseUrl, true)).toBe(true); + }); + it.each([ "http://core.example/v1", "http://192.168.1.20:8091/v1", @@ -38,4 +47,13 @@ describe("Core URL checks", () => { ])("rejects an unsafe direct Core URL: %s", (baseUrl) => { expect(isValidDirectCoreBaseUrl(baseUrl)).toBe(false); }); + + it.each([ + "https://user:secret@core.example/v1", + "https://core.example/v1?token=secret", + "ftp://core.example/v1", + "https://core.example/v1#secret", + ])("still rejects an unsafe direct Core URL with allow_insecure_origin: %s", (baseUrl) => { + expect(isValidDirectCoreBaseUrl(baseUrl, true)).toBe(false); + }); }); diff --git a/apps/web/src/lib/connection.ts b/apps/web/src/lib/connection.ts index ae61346c..ed3ed957 100644 --- a/apps/web/src/lib/connection.ts +++ b/apps/web/src/lib/connection.ts @@ -23,12 +23,18 @@ function hasExplicitUserInfo(candidate: string): boolean { return authority.includes("@"); } -export function isValidDirectCoreBaseUrl(value: string): boolean { +/** + * A direct Core base URL is safe when it is HTTPS, or plain HTTP on a loopback + * host. `allowInsecure` additionally accepts a non-loopback HTTP URL: the + * development-only `allow_insecure_origin` switch, never the default. TLS + * certificate verification is a separate concern and is never relaxed here. + */ +export function isValidDirectCoreBaseUrl(value: string, allowInsecure = 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:" && (allowInsecure || isLoopbackHostname(url.hostname))); return secureTransport && !url.username && !url.password && !url.search && !url.hash; } catch { return false; diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index b4b76fa5..7baba40f 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -164,10 +164,12 @@ export const chinese = { "The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.": "命令会创建 oac-node 服务用户和一个系统服务。它不安装任何软件;缺少什么时会停下并说明要装什么。", "oac-node joins the docker group, which is equivalent to root on this host.": "oac-node 会加入 docker 组,这在这台主机上等同于 root 权限。", "Set a public HTTPS address before adding nodes.": "添加节点前,请先设置一个公网 HTTPS 地址。", + "Plaintext HTTP: allow_insecure_origin is on, so the enrollment token and the node's credentials travel unencrypted. Use this only on a trusted network.": "明文 HTTP:allow_insecure_origin 已开启,注册令牌与节点凭据将以明文传输。仅可在可信网络中使用。", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", "{{name}} is removed from Core, but its service and files stay on the host.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。", "An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.": "卸载命令需要其他机器能访问的 HTTPS 公开地址,而当前安装没有。", + "An uninstall command needs a public URL that other machines can reach, and this installation has none.": "卸载命令需要其他机器能访问的公开地址,而当前安装没有。", "Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.": "其他机器无法访问本安装的公开地址 {{url}},因此无法生成卸载命令。", "Uninstall command": "卸载命令", "Copy {{command}}": "复制 {{command}}", diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 41fa81d3..431a04d0 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -98,7 +98,7 @@ With `OAC_WEB_NODE_PAYLOAD_DIR` set, the console serves the matched distribution ## Public address -The console does not configure a domain or obtain certificates. The operator's reverse proxy or hosting platform terminates HTTPS and routes to the console, and `OAC_PUBLIC_URL` records the origin that applications, nodes and executors use. The console accepts only the host of `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach it. +The console does not configure a domain or obtain certificates. The operator's reverse proxy or hosting platform terminates HTTPS and routes to the console, and `OAC_PUBLIC_URL` records the origin that applications, nodes and executors use. The console accepts only the host of `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach it. A development installation with `OAC_ALLOW_INSECURE_ORIGIN=1` may record a non-loopback `http://` origin; Add node and the host cleanup then offer plain-HTTP commands, and Add node warns that the enrollment token and the node's credentials travel unencrypted. ## Verification diff --git a/docs/zh/web/console-server.md b/docs/zh/web/console-server.md index e7b67fd2..925f3930 100644 --- a/docs/zh/web/console-server.md +++ b/docs/zh/web/console-server.md @@ -1,7 +1,7 @@ --- title: "控制台服务器" source: docs/web/console-server.md -source_hash: b0302f0cf27ccd116c4bbb9477d5853f4ae1bf6cea34c9a4e21af72d25656557 +source_hash: d001ce8017ed156adfde905d996c97e6878891f827227295e74b6bd40289c98b --- 控制台服务器(`services/web`、`oac-web` 进程)提供构建后的控制台,使用 Core 密钥认证管理员,并将已登录浏览器的 `/core/v1` 请求携带该密钥转发到 Core。浏览器不持有 Core 密钥或任何 API 密钥。应用、节点和自托管执行器经控制台到达 Core,控制台原样转发 `/v1`、`/api/v1` 和 `/docs`。 @@ -100,7 +100,7 @@ flowchart LR ## 公开地址 {#public-address} -控制台不配置域名,也不申请证书。运维人员的反向代理或托管平台终止 HTTPS 并把流量转到控制台,`OAC_PUBLIC_URL` 记录应用、节点和执行器使用的源地址。控制台只接受 `OAC_WEB_ORIGIN` 的主机,因此 DNS 重绑定不能访问它。 +控制台不配置域名,也不申请证书。运维人员的反向代理或托管平台终止 HTTPS 并把流量转到控制台,`OAC_PUBLIC_URL` 记录应用、节点和执行器使用的源地址。控制台只接受 `OAC_WEB_ORIGIN` 的主机,因此 DNS 重绑定不能访问它。使用 `OAC_ALLOW_INSECURE_ORIGIN=1` 的开发安装可以记录非回环的 `http://` 源地址;此时 Add node 与主机清理会提供明文 HTTP 命令,Add node 会警告注册令牌与节点凭据将以明文传输。 ## 验证 {#verification}