From e1e290497372aa542026991ac7c3176b36cfe73e Mon Sep 17 00:00:00 2001 From: OAC Core Date: Sat, 3 Oct 2026 02:45:49 +0800 Subject: [PATCH 1/2] Installer: add allow_insecure_origin setting Add a top-level config.json boolean allow_insecure_origin (default false) that lets public_url be a non-loopback http:// origin for development and test installations. With the switch off, origin validation, derived environment and the install summary are unchanged. - config.schema.json: new setting, derives OAC_ALLOW_INSECURE_ORIGIN and install flag --allow-insecure-origin. - config_model: thread the switch through validation; the per-field origin check and the cross-field HTTPS requirement relax only when it is true. - configuration: derive OAC_ALLOW_INSECURE_ORIGIN=1 in core.env and the Web environment only when enabled. - install.py: --allow-insecure-origin flag, relaxed public_origin parse, strict parse-time error kept when off, and node reachability for http. - install_output / oac_cli status: plaintext HTTP risk warning. - docs + regenerated config-reference tables (English and Chinese). TLS certificate verification is unchanged. Co-authored-by: multica-agent --- deploy/install/config.schema.json | 11 ++++++++ deploy/install/config_model.py | 31 +++++++++++++--------- deploy/install/configuration.py | 12 ++++++--- deploy/install/install.py | 24 ++++++++++++----- deploy/install/install_output.py | 12 +++++++++ deploy/install/oac_cli.py | 4 +++ deploy/install/test_config_model.py | 22 ++++++++++++++- deploy/install/test_install.py | 10 +++++++ deploy/install/test_install_output.py | 13 +++++++++ deploy/install/test_oac.py | 4 +++ docs/configuration.md | 3 ++- docs/getting-started/install-options.md | 5 +++- docs/getting-started/nodes.md | 2 +- docs/getting-started/self-hosted.md | 2 +- docs/zh/configuration.md | 5 ++-- docs/zh/getting-started/install-options.md | 7 +++-- docs/zh/getting-started/nodes.md | 4 +-- docs/zh/getting-started/self-hosted.md | 4 +-- scripts/config-reference.py | 2 +- 19 files changed, 140 insertions(+), 37 deletions(-) diff --git a/deploy/install/config.schema.json b/deploy/install/config.schema.json index 0afac916..3a4d2634 100644 --- a/deploy/install/config.schema.json +++ b/deploy/install/config.schema.json @@ -27,6 +27,17 @@ "install_flag": "--public-url" } }, + "allow_insecure_origin": { + "type": "boolean", + "default": false, + "description": "Allow a non-loopback HTTP public_url. For development and testing only: credentials and API keys then travel in plaintext.", + "x-oac": { + "changeable": true, + "restarts": ["core", "web"], + "derives": ["OAC_ALLOW_INSECURE_ORIGIN"], + "install_flag": "--allow-insecure-origin" + } + }, "host": { "type": "string", "default": "127.0.0.1", diff --git a/deploy/install/config_model.py b/deploy/install/config_model.py index 9ff9fc73..712e5911 100644 --- a/deploy/install/config_model.py +++ b/deploy/install/config_model.py @@ -58,10 +58,11 @@ def lookup(config, key): # Checks named by x-oac.check. Core stays the authority for its own semantic rules. -def _origin(value, https_only=False): +def _origin(value, allow_insecure=False, https_only=False): """Core's deployment.ValidateCoreURL rule, through the installer's one implementation of it.""" from configuration import valid_core_origin # configuration imports this module at load time - return valid_core_origin(value) and (not https_only or value.startswith("https://")) + origin_ok = valid_core_origin(value, allow_insecure=allow_insecure and not https_only) + return origin_ok and (not https_only or value.startswith("https://")) _DURATION_UNITS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "μs": 1e-6, "ms": 1e-3, "s": 1, "m": 60, "h": 3600} @@ -91,12 +92,15 @@ def _listen_host(value): CHECKS = { - "listen_host": (_listen_host, "must be an IPv4 or IPv6 address without a port or zone"), + "listen_host": (lambda value, allow_insecure=False: _listen_host(value), + "must be an IPv4 or IPv6 address without a port or zone"), "origin": (_origin, "must be a canonical origin such as https://core.example: lowercase, no path or " "trailing slash, and HTTP only for a loopback host"), - "https_origin": (lambda value: _origin(value, https_only=True), "must be a canonical HTTPS origin"), - "go_duration": (lambda value: duration_seconds(value) is not None, "must be a Go duration such as 30m or 1h"), - "go_duration_min_1h": (lambda value: (duration_seconds(value) or 0) >= 3600, + "https_origin": (lambda value, allow_insecure=False: _origin(value, https_only=True), + "must be a canonical HTTPS origin"), + "go_duration": (lambda value, allow_insecure=False: duration_seconds(value) is not None, + "must be a Go duration such as 30m or 1h"), + "go_duration_min_1h": (lambda value, allow_insecure=False: (duration_seconds(value) or 0) >= 3600, "must be a Go duration of at least 1h"), } @@ -108,7 +112,7 @@ def _type_ok(value, name): "null": type(None)}[name]) -def _validate(node, value, key, problems): +def _validate(node, value, key, problems, allow_insecure=False): label = key or "config.json" types = node.get("type") if types is not None: @@ -130,7 +134,7 @@ def _validate(node, value, key, problems): if isinstance(value, str) and "pattern" in node and not re.search(node["pattern"], value): problems.append(f"{label}: has an invalid format") check = annotation(node, "check") - if check and isinstance(value, str) and not CHECKS[check][0](value): + if check and isinstance(value, str) and not CHECKS[check][0](value, allow_insecure): problems.append(f"{label}: {CHECKS[check][1]}") if isinstance(value, list): if len(value) < node.get("minItems", 0): @@ -138,7 +142,7 @@ def _validate(node, value, key, problems): if node.get("uniqueItems") and len({json.dumps(item, sort_keys=True) for item in value}) != len(value): problems.append(f"{label}: lists an item twice") for index, item in enumerate(value): - _validate(node.get("items", {}), item, f"{label}[{index}]", problems) + _validate(node.get("items", {}), item, f"{label}[{index}]", problems, allow_insecure) if isinstance(value, dict): properties = node.get("properties", {}) for name in node.get("required", []): @@ -147,9 +151,9 @@ def _validate(node, value, key, problems): for name, item in value.items(): child = f"{key}.{name}" if key else name if name in properties: - _validate(properties[name], item, child, problems) + _validate(properties[name], item, child, problems, allow_insecure) elif isinstance(node.get("additionalProperties"), dict): - _validate(node["additionalProperties"], item, child, problems) + _validate(node["additionalProperties"], item, child, problems, allow_insecure) else: problems.append(f"{child}: unknown key") @@ -174,7 +178,7 @@ def validate(config): if not isinstance(config, dict): raise ConfigError(["config.json: must be a JSON object"]) problems = [] - _validate(SCHEMA, config, "", problems) + _validate(SCHEMA, config, "", problems, config.get("allow_insecure_origin") is True) if problems: raise ConfigError(problems) full = copy.deepcopy(config) @@ -193,7 +197,8 @@ def validate(config): raise ValueError() except ValueError: problems.append("public_url: managed HTTPS requires https:// followed by a DNS hostname, without a port") - if not managed and not loopback_listener(full["host"]) and not (full["public_url"] or "").startswith("https://"): + if (not managed and not loopback_listener(full["host"]) + and not (full["public_url"] or "").startswith("https://") and not full["allow_insecure_origin"]): problems.append("public_url: an HTTPS origin is required when host is not loopback") core = full["core"] if core["default_harness"] not in core["harnesses"]: diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index a09a3813..3ae5820f 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -25,10 +25,12 @@ _HOST_LABEL = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") -def valid_core_origin(value): +def valid_core_origin(value, allow_insecure=False): """Accept exactly the origins Core's deployment.ValidateCoreURL accepts (services/core/internal/deployment/public_url.go), so an - installer value never fails Core's OAC_PUBLIC_URL check at startup.""" + installer value never fails Core's OAC_PUBLIC_URL check at startup. + allow_insecure additionally accepts a non-loopback HTTP origin, which Core + accepts only when OAC_ALLOW_INSECURE_ORIGIN is set.""" if not isinstance(value, str) or any(char in value for char in "?#@\\% \t\r\n"): return False try: @@ -57,7 +59,7 @@ def valid_core_origin(value): if netloc.startswith("[") or len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")): return False loopback = host == "localhost" - return parsed.scheme == "https" or loopback + return parsed.scheme == "https" or loopback or (allow_insecure and parsed.scheme == "http") def environment_text(values, header): @@ -204,6 +206,8 @@ def core_environment(root, config, state): "OAC_EXECUTION_CONCURRENCY": str(core["execution_concurrency"]), "OAC_WRITE_AUDIT_RETENTION": core["write_audit_retention"], } + if config["allow_insecure_origin"]: + result["OAC_ALLOW_INSECURE_ORIGIN"] = "1" if (root / "native-installers/catalog.json").is_file(): result["OAC_NATIVE_INSTALLER_DIR"] = "/opt/oac/native-installers" if core["oauth_trusted_origins"]: @@ -260,6 +264,8 @@ def compose_config(root, config, state, candidate=None): "OAC_WEB_CORE_KEY_FILE": f"{RUN}/core.key", "OAC_WEB_NODE_PAYLOAD_DIR": "/node-payload", } + if config["allow_insecure_origin"]: + environment["OAC_ALLOW_INSECURE_ORIGIN"] = "1" environment.update(log_environment(config["log"])) web = {"image": images["web"], "user": identity, "restart": "unless-stopped", "ports": [service_address(config, "web") + ":8080"], "read_only": True, diff --git a/deploy/install/install.py b/deploy/install/install.py index e579ad6f..1434c250 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -126,7 +126,7 @@ def public_origin(value): value = parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl() except ValueError: value = "" - if not valid_core_origin(value): + if not valid_core_origin(value, allow_insecure=True): raise argparse.ArgumentTypeError("Public URL must be an HTTPS origin such as https://core.example, " "without path, credentials, query or fragment; plain HTTP only for a loopback host") return value @@ -136,6 +136,9 @@ def arguments(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--install-dir", type=Path) for flag, (_, node) in SETTING_ARGUMENTS.items(): + if node.get("type") == "boolean": + parser.add_argument(flag, action="store_true", help=node["description"]) + continue value_type = int if node.get("type") == "integer" else public_origin if config_model.annotation(node, "check") == "origin" else str parser.add_argument(flag, type=value_type, help=node["description"]) parser.add_argument("--config", type=Path, help="Seed a new installation's config.json from this file") @@ -143,6 +146,11 @@ def arguments(argv=None): args.install_dir = args.install_dir or Path.home() / ".oac/core" if not args.install_dir.is_absolute(): parser.error("--install-dir must be absolute") + # public_origin accepts a non-loopback HTTP origin so --allow-insecure-origin can seed one; + # without the flag the strict rule stays the parse-time error. + if args.public_url and not args.allow_insecure_origin and not valid_core_origin(args.public_url): + parser.error("argument --public-url: Public URL must be an HTTPS origin such as https://core.example, " + "without path, credentials, query or fragment; plain HTTP only for a loopback host") args.given = [name for name, value in vars(args).items() if name not in ("install_dir", "given") and value not in (None, False)] return args @@ -152,7 +160,7 @@ def seed_document(args): """The --config file, which replaces the setting flags.""" if args.config is None: return None - if any(getattr(args, name) is not None for name in SETTING_FLAGS): + if any(getattr(args, name) not in (None, False) for name in SETTING_FLAGS): raise InstallError("--config replaces the setting flags; put those settings in the file") try: document = json.loads(args.config.read_text()) @@ -186,7 +194,8 @@ def check_listeners(args, document, config): """ if document is None: names, where = {key: flag for flag, (key, _) in SETTING_ARGUMENTS.items()}, "" - given = {key for key, flag in names.items() if getattr(args, flag.removeprefix("--").replace("-", "_")) is not None} + given = {key for key, flag in names.items() + if getattr(args, flag.removeprefix("--").replace("-", "_")) not in (None, False)} else: names, where = {}, " in the --config file" given = {key for key in ("ports.core", "ports.web") if config_model.lookup(document, key) is not None} @@ -233,9 +242,10 @@ def origin_port(value): return parsed.port or (443 if parsed.scheme == "https" else 80) -def nodes_reach(public_url): - """Nodes and their sandboxes need an HTTPS public URL that is not loopback.""" - return urlsplit(public_url or "").scheme == "https" and not loopback_origin(public_url) +def nodes_reach(public_url, allow_insecure=False): + """Nodes and their sandboxes need a public URL that is not loopback; plain HTTP only with the switch.""" + scheme = urlsplit(public_url or "").scheme + return not loopback_origin(public_url) and (scheme == "https" or allow_insecure and scheme == "http") def check_compose(): @@ -527,7 +537,7 @@ def summary(root, config, fresh, selection=None, deployment=None, incomplete=Fal # The loopback Web port does not serve the public API. addresses.append("API base URL: " + api + " (local only)") install_output.summary(root, config, addresses, fresh, selection, deployment, - nodes_reach(public_url), incomplete, moved) + nodes_reach(public_url, config["allow_insecure_origin"]), incomplete, moved) def main(argv=None): diff --git a/deploy/install/install_output.py b/deploy/install/install_output.py index 40bb30e2..5620d144 100644 --- a/deploy/install/install_output.py +++ b/deploy/install/install_output.py @@ -36,6 +36,15 @@ def sandbox_lines(config, selection, deployment, reachable): return lines +def insecure_origin_warning(config): + """The plaintext warning for allow_insecure_origin, or None when the origin is safe.""" + origin = config.get("public_url") or "" + if not config.get("allow_insecure_origin") or not origin.startswith("http://"): + return None + return ("allow_insecure_origin is enabled: " + origin + " serves Core and Web over plaintext HTTP. " + "Credentials and API keys travel unencrypted; use this only on a trusted network.") + + def summary(root, config, addresses, fresh, selection, deployment, reachable, incomplete, moved=()): status = ("Services are running; sandbox setup needs attention." if incomplete else "Installation complete." if fresh else "Installation settings checked. Use Status below to inspect service health.") @@ -43,6 +52,9 @@ def summary(root, config, addresses, fresh, selection, deployment, reachable, in heading("Access") for address in addresses: print(" " + address) + warning = insecure_origin_warning(config) + if warning: + paragraph(color("Warning: " + warning, "33")) for purpose, taken, port in moved: print(f" Port {taken} was in use; {purpose} uses {port}.") heading("Sign in") diff --git a/deploy/install/oac_cli.py b/deploy/install/oac_cli.py index 0c892330..8edb1e5e 100644 --- a/deploy/install/oac_cli.py +++ b/deploy/install/oac_cli.py @@ -832,6 +832,7 @@ def written_view(root, state, config): def applied_view(values): """The config the settings last written describe.""" return {"ingress": values.get("ingress"), "public_url": values.get("public_url"), "host": values["host"], + "allow_insecure_origin": values.get("allow_insecure_origin", False), "ports": {name: values[f"ports.{name}"] for name in ("core", "web") if f"ports.{name}" in values}} @@ -876,6 +877,9 @@ def status(root, out=print): healthy = healthy and web_ok out("Web: " + ("healthy" if web_ok else "unavailable")) out("Public URL: " + (config["public_url"] or ("not configured; set up HTTPS in Web" if ingress_config.enabled(config) else "none (local access only)"))) + if config.get("allow_insecure_origin") and (config.get("public_url") or "").startswith("http://"): + out("Warning: allow_insecure_origin is enabled; Core and Web are served over plaintext HTTP, " + "and credentials and API keys travel unencrypted") out("API base URL: " + configuration.local_public_url(config) + "/v1") out("Console: " + (ingress_config.console_origin(config) if ingress_config.enabled(config) else config["public_url"] or configuration.service_origin(config, "web"))) diff --git a/deploy/install/test_config_model.py b/deploy/install/test_config_model.py index f9239505..f39641c6 100644 --- a/deploy/install/test_config_model.py +++ b/deploy/install/test_config_model.py @@ -34,7 +34,7 @@ def test_schema_uses_only_the_supported_keyword_subset(self): self.assertIs(node.get("additionalProperties"), False) def test_new_config_lists_every_setting(self): - expected = ["public_url", "host", "ports.core", "ports.web", "log.level", "log.format", + expected = ["public_url", "allow_insecure_origin", "host", "ports.core", "ports.web", "log.level", "log.format", "log.add_source", "core.execution_concurrency", "core.harnesses", "core.default_harness", "core.write_audit_retention", "core.oauth_trusted_origins", "core.database_pool.max_conns", "core.database_pool.min_conns", "core.database_pool.max_conn_lifetime", @@ -75,6 +75,26 @@ def test_invalid_settings_name_their_key_without_their_value(self): "https://[2001:db8::1]:8443", "http://[::1]:8080", "http://[::ffff:127.0.0.1]:8080"): config_model.validate(dict(base, public_url=origin)) + def test_allow_insecure_origin_switch_relaxes_only_the_https_rule(self): + base = {"format": 1, "public_url": "http://10.0.0.5:8080"} + # The switch accepts a non-loopback HTTP origin and reaches the settings snapshot and Core environment. + config = config_model.validate(dict(base, allow_insecure_origin=True)) + self.assertEqual(config["public_url"], "http://10.0.0.5:8080") + self.assertIs(config_model.values(config)["allow_insecure_origin"], True) + self.assertEqual(configuration.core_environment(Path("/installation"), config, {"installation_id": "fixture"}) + ["OAC_ALLOW_INSECURE_ORIGIN"], "1") + # Without the switch the rule is unchanged, and it is still only an origin rule: canonical-form errors stay. + with self.assertRaises(config_model.ConfigError) as raised: + config_model.validate(dict(base)) + self.assertIn("public_url: must be a canonical origin", str(raised.exception)) + with self.assertRaises(config_model.ConfigError) as raised: + config_model.validate({"format": 1, "host": "0.0.0.0"}) + self.assertIn("public_url: an HTTPS origin is required when host is not loopback", str(raised.exception)) + config_model.validate({"format": 1, "host": "0.0.0.0", "allow_insecure_origin": True}) + self.assertNotIn("OAC_ALLOW_INSECURE_ORIGIN", + configuration.core_environment(Path("/installation"), config_model.initial(), + {"installation_id": "fixture"})) + def test_generated_files_hold_no_secret_and_the_snapshot_hides_sensitive_values(self): base = Path.home() / ".oac/tests/config-model" base.mkdir(parents=True, exist_ok=True) diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 72a1b9f7..4e67fc4c 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -615,6 +615,16 @@ def test_public_url_rule_matches_core(self): self.assertFalse(install.valid_core_origin(origin), origin) self.assertFalse(config_model.CHECKS["origin"][0](origin), origin) + def test_allow_insecure_origin_flag_seeds_the_setting_and_keeps_the_default_error(self): + args = install.arguments(["--ingress", "external", "--public-url", "http://10.0.0.5:8080", + "--allow-insecure-origin"]) + self.assertTrue(args.allow_insecure_origin) + config = install.seed_config(args, None) + self.assertEqual(config["public_url"], "http://10.0.0.5:8080") + self.assertIs(config["allow_insecure_origin"], True) + with self.assertRaises(SystemExit): + install.arguments(["--public-url", "http://10.0.0.5:8080"]) + class ComposePrerequisiteTests(unittest.TestCase): def test_compose_requires_the_literal_environment_parser(self): diff --git a/deploy/install/test_install_output.py b/deploy/install/test_install_output.py index 57b0ed80..5b18e638 100644 --- a/deploy/install/test_install_output.py +++ b/deploy/install/test_install_output.py @@ -61,6 +61,19 @@ def test_progress_is_flushed_before_returning_and_errors_use_stderr(self): self.assertEqual(error.getvalue(), "Installation failed: service did not become healthy\n") self.assertNotIn("failed", stream.getvalue()) + def test_summary_warns_about_a_plaintext_http_origin(self): + stream = io.StringIO() + config = {"ports": {"core": 8091}, "public_url": "http://10.0.0.5:8080", "allow_insecure_origin": True} + with contextlib.redirect_stdout(stream): + output.summary(Path("/tmp/oac"), config, ["Console: http://10.0.0.5:8080"], True, None, None, True, False) + self.assertIn("allow_insecure_origin is enabled", stream.getvalue()) + self.assertIn("plaintext HTTP", stream.getvalue()) + safe = io.StringIO() + with contextlib.redirect_stdout(safe): + output.summary(Path("/tmp/oac"), {"ports": {"core": 8091}}, ["Console: https://core.example"], + True, None, None, True, False) + self.assertNotIn("allow_insecure_origin", safe.getvalue()) + def test_summary_groups_details_and_keeps_commands_copyable(self): stream = io.StringIO() root = Path("/tmp/install with spaces") diff --git a/deploy/install/test_oac.py b/deploy/install/test_oac.py index ef2eade1..7c227bd0 100644 --- a/deploy/install/test_oac.py +++ b/deploy/install/test_oac.py @@ -214,6 +214,10 @@ def test_rotate_core_key(self): self.assertEqual(self.host.http(url, oac_cli.bearer(old))[0], 401) self.assertConverged() + def test_status_warns_about_a_plaintext_http_origin(self): + self.install(public_url="http://10.0.0.5:8080", allow_insecure_origin=True) + self.assertTrue(any("allow_insecure_origin is enabled" in line for line in self.status()), self.output) + def test_public_url_change_lists_bindings_and_requires_confirmation(self): self.install(public_url="https://core.example") self.host.bindings.update(nodes=2, hosted_sandboxes=2) diff --git a/docs/configuration.md b/docs/configuration.md index d653d1bd..f55b0b6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,7 +51,8 @@ When nodes, hosted sandboxes or self-hosted executors are bound to the current a | --- | --- | --- | --- | --- | --- | | `$schema` | string | none | any time | none | Editor hint that points at the installed copy of this schema. Ignored. | | `format` | `1` | none | fixed | none | Configuration format for this release. Fixed after installation. | -| `public_url` | string or null (canonical origin; HTTP only on loopback) | `null` | `oac apply` | core, web | Canonical public origin of Core and Web. With managed ingress, set the DNS hostname in Web or run oac domain; certificates are automatic. With external ingress, configure your TLS reverse proxy before applying this value. | +| `public_url` | string or null (canonical origin; HTTP only on loopback unless allow_insecure_origin is true) | `null` | `oac apply` | core, web | Canonical public origin of Core and Web. With managed ingress, set the DNS hostname in Web or run oac domain; certificates are automatic. With external ingress, configure your TLS reverse proxy before applying this value. | +| `allow_insecure_origin` | boolean | `false` | `oac apply` | core, web | Allow a non-loopback HTTP public_url. For development and testing only: credentials and API keys then travel in plaintext. | | `host` | string (IPv4 or IPv6 address) | `"127.0.0.1"` | `oac apply` | core, web | Listener IP. With managed ingress only the gateway is public; Core stays on loopback. The default installer listens on all IPv4 interfaces. | | `ports.core` | integer 1024–65535 | `8091` | `oac apply` | core | Host port of the Core API. | | `ports.web` | integer 1024–65535 | `8080` | `oac apply` | web | Host port of Web. | diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 2e506380..19094c68 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -53,6 +53,7 @@ These flags seed the installation's `config.json` once. Their defaults, valid va | Flag | config.json field | | --- | --- | | `--public-url` | `public_url` | +| `--allow-insecure-origin` | `allow_insecure_origin` | | `--host` | `host` | | `--core-port` | `ports.core` | | `--web-port` | `ports.web` | @@ -61,6 +62,8 @@ These flags seed the installation's `config.json` once. Their defaults, valid va `--config FILE` seeds `config.json` from a JSON file instead of these setting flags; they cannot be combined. A `--config` document follows the schema defaults, so set `ingress: "managed"` and `host: "0.0.0.0"` in it for managed HTTPS. +`--allow-insecure-origin` seeds `allow_insecure_origin`, which lets `public_url` be a non-loopback `http://` origin. It is off by default and is for development and test installations only: credentials, API keys and Session traffic then travel in plaintext. TLS certificate verification is unchanged; see [Settings](../configuration.md#settings). + ## Installation actions These options choose an installation location or perform initial setup; they are not saved in `config.json`. @@ -81,7 +84,7 @@ To use Docker or E2B, or another size, open **System** → **Manage sandbox conf The default installation selects `--ingress managed` and `--host 0.0.0.0`. Its gateway publishes Web on `--web-port` (8080 by default), and [ports 80 and 443](#ports) once HTTPS is on. Core's `--core-port` stays on loopback and PostgreSQL stays private. `--host` accepts IPv4 or IPv6, without a port, scheme or zone. Use a concrete server IP in the browser, not a wildcard. Managed ingress needs a local Docker Unix socket. -`--ingress external` uses your own reverse proxy instead. Core and Web then listen on `--host`, loopback by default. External non-loopback listeners require an HTTPS `public_url` and a [reverse proxy](#https-and-the-reverse-proxy), and Web's domain setup is unavailable: set `public_url` in `config.json` and run `oac apply`. +`--ingress external` uses your own reverse proxy instead. Core and Web then listen on `--host`, loopback by default. External non-loopback listeners require an HTTPS `public_url` (or a non-loopback HTTP one with `allow_insecure_origin`) and a [reverse proxy](#https-and-the-reverse-proxy), and Web's domain setup is unavailable: set `public_url` in `config.json` and run `oac apply`. `--public-url` seeds a DNS-based HTTPS origin for unattended setup; with managed ingress, the certificate and connectivity checks must pass. The ingress mode is fixed for an installation. diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 668eceb4..6443c290 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -8,7 +8,7 @@ You add a node by generating a command in Web and running it on the host. The [s ## Before you add a node -- **Core has an HTTPS public URL** that the host and its sandboxes can reach. Nodes download from Core's console and connect to Core at `public_url`. Until it is set, Add node says *Configure a domain and HTTPS in System before adding nodes*; see [Configure the domain and HTTPS](./install.md#configure-the-domain-and-https), or with external ingress [change the public URL](../configuration.md#changing-the-public-url). +- **Core has an HTTPS public URL** that the host and its sandboxes can reach. Nodes download from Core's console and connect to Core at `public_url`. Until it is set, Add node says *Configure a domain and HTTPS in System before adding nodes*; see [Configure the domain and HTTPS](./install.md#configure-the-domain-and-https), or with external ingress [change the public URL](../configuration.md#changing-the-public-url). A development installation with `allow_insecure_origin` may use a non-loopback `http://` URL instead. - **The sandbox configuration is saved.** The installer saves microsandbox at the Standard size. To use Docker or another size, open **System** → **Manage sandbox configuration**, choose **Reset deployment**, then **Own machines**, the backend and a sandbox size, and **Save configuration**. Every node of an installation uses that backend. - **The console can serve the node files.** Nodes download their Runtime and provider files from the console, which redirects to the release for files it does not hold, and check each file's size and SHA-256 against the release manifest. For hosts without access to the release, install Core from the [offline bundle](./install-options.md#offline-hosts) so the console holds every file. Without the files, Add node says *This console has no node files for …*. diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 4dc25ed0..2a262661 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -20,7 +20,7 @@ The installer brings its own pinned Node.js and Harness versions (listed in [`sc The machine needs: -- HTTPS access to Core (plain HTTP only on loopback), and to the release download host unless Core carries an offline copy of the installers; +- HTTPS access to Core (plain HTTP only on loopback, or a non-loopback HTTP URL with `allow_insecure_origin`), and to the release download host unless Core carries an offline copy of the installers; - Bash for environment setup and MiniMax Code tools; on Windows, Git Bash, which Claude Code also requires; - Python and pip when the Session's packages need them; - any system packages your setup needs. The daemon never runs apt, sudo or another elevation command, so install them through the host's normal administration. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index ec0bdb73..ea0066b2 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: afc0f02492f63e2032009fc7a79aa72051ca2c377d2e7788ef6f7d1d15f8b8de +source_hash: e71222e22a81b10ffd510dec3af879b28ec52ab30f3b23f4347d7b8242e04afc --- Core 安装的每项设置都恰好只有一个归属位置。共有两类: @@ -55,7 +55,8 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | --- | --- | --- | --- | --- | --- | | `$schema` | string | none | any time | none | Editor hint that points at the installed copy of this schema. Ignored. | | `format` | `1` | none | fixed | none | Configuration format for this release. Fixed after installation. | -| `public_url` | string or null (canonical origin; HTTP only on loopback) | `null` | `oac apply` | core, web | Canonical public origin of Core and Web. With managed ingress, set the DNS hostname in Web or run oac domain; certificates are automatic. With external ingress, configure your TLS reverse proxy before applying this value. | +| `public_url` | string or null (canonical origin; HTTP only on loopback unless allow_insecure_origin is true) | `null` | `oac apply` | core, web | Canonical public origin of Core and Web. With managed ingress, set the DNS hostname in Web or run oac domain; certificates are automatic. With external ingress, configure your TLS reverse proxy before applying this value. | +| `allow_insecure_origin` | boolean | `false` | `oac apply` | core, web | Allow a non-loopback HTTP public_url. For development and testing only: credentials and API keys then travel in plaintext. | | `host` | string (IPv4 or IPv6 address) | `"127.0.0.1"` | `oac apply` | core, web | Listener IP. With managed ingress only the gateway is public; Core stays on loopback. The default installer listens on all IPv4 interfaces. | | `ports.core` | integer 1024–65535 | `8091` | `oac apply` | core | Host port of the Core API. | | `ports.web` | integer 1024–65535 | `8080` | `oac apply` | web | Host port of Web. | diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index 03b553f3..6e37f114 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- title: "安装选项与高级部署" source: docs/getting-started/install-options.md -source_hash: 7178baf93a8d8b6e7f086d73033afe4ea14afcf41c88dbcc6031a3a6dd20ca17 +source_hash: a58c8f1217140514e9f2bc84b4fed577d1bbca7e30ab950a2aceb3b699ecad6b --- [默认安装](install.md)无需任何选项。使用本页可以在现有反向代理后运行,或者在无法访问互联网时进行安装。 @@ -56,6 +56,7 @@ docker compose -f compose.yaml run --rm credentials | Flag | config.json field | | --- | --- | | `--public-url` | `public_url` | +| `--allow-insecure-origin` | `allow_insecure_origin` | | `--host` | `host` | | `--core-port` | `ports.core` | | `--web-port` | `ports.web` | @@ -64,6 +65,8 @@ docker compose -f compose.yaml run --rm credentials `--config FILE` 会改为从 JSON 文件初始化 `config.json`,而不再使用这些设置标志;两者不能结合使用。`--config` 文档遵循 schema 默认值,因此若要使用托管 HTTPS,请在其中设置 `ingress: "managed"` 和 `host: "0.0.0.0"`。 +`--allow-insecure-origin` 会初始化 `allow_insecure_origin`,它允许 `public_url` 使用非回环的 `http://` 源地址。该选项默认关闭,仅适用于开发和测试安装:凭据、API 密钥和 Session 流量随后会以明文传输。TLS 证书校验保持不变;参阅[设置](../configuration.md#settings)。 + ## 安装操作 {#installation-actions} 这些选项用于选择安装位置或执行初始设置;不会保存在 `config.json` 中。 @@ -84,7 +87,7 @@ docker compose -f compose.yaml run --rm credentials 默认安装会选择 `--ingress managed` 和 `--host 0.0.0.0`。其网关会在 `--web-port` 上发布 Web(默认为 8080),并在启用 HTTPS 后发布端口 80 和 443。Core 的 `--core-port` 保持为回环地址,PostgreSQL 保持私有。`--host` 接受 IPv4 或 IPv6 地址,但不能包含端口、协议方案或区域。请在浏览器中使用服务器的具体 IP 地址,而不是通配地址。托管入口需要本地 Docker Unix 套接字。 -`--ingress external` 会改用你自己的反向代理。随后 Core 和 Web 会在 `--host` 上监听,默认是回环地址。外部非回环监听器需要基于 HTTPS 的 `public_url` 和[反向代理](#https-and-the-reverse-proxy),并且无法使用 Web 的域名设置:请在 `config.json` 中设置 `public_url`,然后运行 `oac apply`。 +`--ingress external` 会改用你自己的反向代理。随后 Core 和 Web 会在 `--host` 上监听,默认是回环地址。外部非回环监听器需要基于 HTTPS 的 `public_url`(或在使用 `allow_insecure_origin` 时使用非回环的 `http://` `public_url`)和[反向代理](#https-and-the-reverse-proxy),并且无法使用 Web 的域名设置:请在 `config.json` 中设置 `public_url`,然后运行 `oac apply`。 `--public-url` 会为无人值守设置初始化一个基于 DNS 的 HTTPS 源地址;使用托管入口时,证书和连接检查必须通过。安装完成后,入口模式便固定不变。 diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 80370c96..e5decba1 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: e1af3f17292dc491cb7c041f9e6c7ea335ec763149368c0d4898828b210605f7 +source_hash: 10ec00613b1072139e98c8f48a0d3942a55ced4999abd4289cd1e0f00e7315f7 --- 节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -10,7 +10,7 @@ source_hash: e1af3f17292dc491cb7c041f9e6c7ea335ec763149368c0d4898828b210605f7 ## 添加节点前 {#before-you-add-a-node} -- **Core 已有主机及沙箱可访问的 HTTPS 公开 URL。** 节点从 Core 控制台下载文件,并通过 `public_url` 连接 Core。设置前,Add node 显示 *Configure a domain and HTTPS in System before adding nodes*;参阅[配置域名和 HTTPS](install.md#configure-the-domain-and-https),使用外部入口时则参阅[修改公开 URL](../configuration.md#changing-the-public-url)。 +- **Core 已有主机及沙箱可访问的 HTTPS 公开 URL。** 节点从 Core 控制台下载文件,并通过 `public_url` 连接 Core。设置前,Add node 显示 *Configure a domain and HTTPS in System before adding nodes*;参阅[配置域名和 HTTPS](install.md#configure-the-domain-and-https),使用外部入口时则参阅[修改公开 URL](../configuration.md#changing-the-public-url)。使用 `allow_insecure_origin` 的开发安装可以改用非回环的 `http://` URL。 - **沙箱配置已保存。** 安装程序会保存 Standard 规格的 microsandbox。要使用 Docker 或其他规格,打开 **System** → **Manage sandbox configuration**,选择 **Reset deployment**,然后选择 **Own machines**、后端和沙箱规格,最后选择 **Save configuration**。同一安装的所有节点使用同一后端。 - **控制台能提供节点文件。** 节点从控制台下载 Runtime 和提供商文件;控制台缺少文件时重定向到发行下载地址。节点依据发行清单检查各文件的大小和 SHA-256。主机无法访问发行下载地址时,从[离线包](install-options.md#offline-hosts)安装 Core,让控制台持有全部文件。缺少文件时,Add node 显示 *This console has no node files for …*。 diff --git a/docs/zh/getting-started/self-hosted.md b/docs/zh/getting-started/self-hosted.md index 524f13b8..266909a3 100644 --- a/docs/zh/getting-started/self-hosted.md +++ b/docs/zh/getting-started/self-hosted.md @@ -1,7 +1,7 @@ --- title: "自托管执行器" source: docs/getting-started/self-hosted.md -source_hash: c560a575e51c02ccb474a629f5655e59779643696826c7677d0b5a949be2e5fc +source_hash: ba135ebf0fe68e0a7574a16acdfa91a8396a057c72fb956ac7c9e91e05fc9f86 --- `self_hosted` Session 在应用拥有的机器上运行:工作站、虚拟机或你管理的沙箱。应用通过 `/v1` 创建 Session,并获得安装 `oac-daemon`、启动它并连接 Core 的命令。Web 在 Session 页面展示同一命令;Web 是可选的。Core 不创建、停止或回收这台机器。 @@ -22,7 +22,7 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../ 机器需要: -- 通过 HTTPS 访问 Core(仅回环地址允许明文 HTTP),以及访问发布下载主机;如果 Core 已有安装程序的离线副本,则无需后者; +- 通过 HTTPS 访问 Core(仅回环地址允许明文 HTTP;启用 `allow_insecure_origin` 时可使用非回环 HTTP URL),以及访问发布下载主机;如果 Core 已有安装程序的离线副本,则无需后者; - 用于环境设置和 MiniMax Code 工具的 Bash;Windows 上需要 Git Bash,Claude Code 也要求它; - Session 的软件包需要时,安装 Python 和 pip; - 环境设置所需的系统软件包。守护进程不运行 apt、sudo 或其他提权命令,请通过主机的常规管理方式安装。 diff --git a/scripts/config-reference.py b/scripts/config-reference.py index c8cd5489..5cbe1d65 100755 --- a/scripts/config-reference.py +++ b/scripts/config-reference.py @@ -15,7 +15,7 @@ DOCUMENT = REPOSITORY / "docs/configuration.md" BEGIN = "[//]: # (BEGIN config-reference: generated by scripts/config-reference.py from deploy/install/config.schema.json)" END = "[//]: # (END config-reference)" -CHECKS = {"listen_host": "IPv4 or IPv6 address", "origin": "canonical origin; HTTP only on loopback", "https_origin": "canonical HTTPS origin", +CHECKS = {"listen_host": "IPv4 or IPv6 address", "origin": "canonical origin; HTTP only on loopback unless allow_insecure_origin is true", "https_origin": "canonical HTTPS origin", "go_duration": "Go duration", "go_duration_min_1h": "Go duration, at least `1h`"} From 93f365c8be91b665faab5f180184f9e8b7bc9863 Mon Sep 17 00:00:00 2001 From: sunyalou Date: Sat, 3 Oct 2026 12:17:11 +0800 Subject: [PATCH 2/2] feat(web): forward --allow-insecure-origin from the node install command nodeInstallCommand gains an optional allowInsecureOrigin flag. When true it appends --allow-insecure-origin right after --core-url so the node installer accepts a plain-HTTP Core origin for development; when false or omitted the generated command is byte-for-byte unchanged. Baseline: OAC-2 feat/allow-insecure-origin (e1e29049). Tracks OAC-8. Co-authored-by: multica-agent --- .../src/features/sandbox/enrollment-command.test.ts | 13 +++++++++++++ apps/web/src/features/sandbox/enrollment-command.ts | 11 +++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/sandbox/enrollment-command.test.ts b/apps/web/src/features/sandbox/enrollment-command.test.ts index 180c4e1b..d7343497 100644 --- a/apps/web/src/features/sandbox/enrollment-command.test.ts +++ b/apps/web/src/features/sandbox/enrollment-command.test.ts @@ -19,6 +19,19 @@ printf '==> Verifying node installer...\\n' && printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status && printf '%s\\n' 'secret'\\''onetime' | $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`); }); + it("appends --allow-insecure-origin right after --core-url when the switch is on", () => { + const command = nodeInstallCommand({ token: "secret'onetime", coreUrl: "http://10.0.0.5:8080", sourceUrl: "http://10.0.0.5:8080", provider: "docker", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, allowInsecureOrigin: true }); + expect(command).toContain("--core-url 'http://10.0.0.5:8080' --allow-insecure-origin --provider 'docker'"); + // The flag is forwarded once, and only in the installer's own argument list. + expect(command.match(/--allow-insecure-origin/g)).toHaveLength(1); + expect(command.endsWith("--provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')")).toBe(true); + }); + it("leaves the command byte-for-byte unchanged when the switch is off or omitted", () => { + const args = { token: "secret'onetime", coreUrl: "https://core.example", sourceUrl: "https://console.example", provider: "docker" as const, installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest }; + expect(nodeInstallCommand({ ...args, allowInsecureOrigin: false })).toBe(install()); + expect(nodeInstallCommand(args)).toBe(install()); + expect(nodeInstallCommand({ ...args, allowInsecureOrigin: false })).not.toContain("--allow-insecure-origin"); + }); it("creates the exact uninstall commands, with no token", () => { const uninstall = () => nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest }); expect(uninstall()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index f67f7d30..ac873d66 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -25,12 +25,15 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT /** * Adds this host as a node. The one-time token reaches the installer only on * standard input (`printf` is a shell builtin), never in an argument, the - * environment or sudo's command line. + * environment or sudo's command line. `allowInsecureOrigin` forwards the + * installer's `--allow-insecure-origin`, which lets a plain-HTTP Core origin + * enroll; it stays off unless the caller explicitly asks for it, so the default + * command is byte-for-byte unchanged. */ -export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: { - token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; +export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest, allowInsecureOrigin = false }: { + token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; allowInsecureOrigin?: 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)})`; + return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)}${allowInsecureOrigin ? " --allow-insecure-origin" : ""} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; } /**