From a5a8ccea491b53d8c1134934e1a5d94b07579d70 Mon Sep 17 00:00:00 2001 From: OAC Core Date: Sat, 3 Oct 2026 13:52:15 +0800 Subject: [PATCH] Node link: allow non-loopback http origin behind allow_insecure_origin Rebase the Go part of the node-link change onto the post-refactor layout and re-apply the installer change at deploy/node/. Retain the enrollment policy in the node identity (identity.json allow_insecure_origin), validate it through a single validateEndpoint rule with explicit endpoint variants, and read it back on enroll/refresh/connect. The installer accepts --allow-insecure-origin, records the policy only when opted in, forwards it to `oac-node register`, and matches it on reruns and readiness. Generation preparation validates the retained source_url under the retained identity's policy instead of always requiring HTTPS. Default behavior and on-disk bytes are unchanged; TLS certificate verification is never relaxed. Baseline: origin/main @ 3f0a737b. The Go diff is byte-identical to PR #6, whose blind review and independent verification passed on the old baseline (OAC-4 2026-10-03 04:53 summary). Tracks OAC-4 (task D). Co-authored-by: multica-agent --- deploy/node/node_generations.py | 4 +- deploy/node/node_install.py | 54 ++++++++--- .../test_generation_review_regressions.py | 26 +++++ deploy/node/test_node_install.py | 54 ++++++++++- deploy/node/test_node_readiness.py | 15 ++- services/core/cmd/sandbox-node/main.go | 8 +- services/core/cmd/sandbox-node/main_test.go | 10 +- services/core/internal/sandbox/node/agent.go | 4 +- .../internal/sandbox/node/capacity_test.go | 2 +- .../internal/sandbox/node/connection_test.go | 2 +- .../sandbox/node/creation_settlement_test.go | 2 +- .../internal/sandbox/node/docker_live_test.go | 2 +- .../core/internal/sandbox/node/enrollment.go | 6 +- .../node/generation_connection_test.go | 2 +- .../node/health_connection_linux_test.go | 2 +- .../core/internal/sandbox/node/identity.go | 47 +++++++--- .../core/internal/sandbox/node/node_test.go | 94 ++++++++++++++++++- .../sandbox/node/observations_test.go | 2 +- .../internal/sandbox/node/recovery_test.go | 14 +-- .../internal/sandbox/node/timeout_test.go | 2 +- 20 files changed, 298 insertions(+), 54 deletions(-) diff --git a/deploy/node/node_generations.py b/deploy/node/node_generations.py index 738c9b765..294de99d2 100644 --- a/deploy/node/node_generations.py +++ b/deploy/node/node_generations.py @@ -398,7 +398,9 @@ def prepare(args, installer): break if not finalized or value is None or not image_available(value, installer): settings = installer.private_json(root / "preparation.json") - args.source_url = installer.origin(settings["source_url"]) + # The retained identity records the enrollment policy; a node enrolled + # with allow_insecure_origin may keep an http source_url in preparation.json. + args.source_url = installer.origin(settings["source_url"], bool(identity.get("allow_insecure_origin", False))) args.bundle = None manifest, sums = installer.metadata(args.source_url, prefix="releases/" + runtime["source_commit"] + "/") try: diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 62cc7b31f..ead705341 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -71,7 +71,7 @@ class RuntimeDownloadError(InstallError): NOTHING_CHANGED = " Nothing was changed." -def origin(value): +def origin(value, allow_insecure_origin=False): try: parsed = urlsplit(value) parsed.port @@ -84,7 +84,7 @@ def origin(value): 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)): + or (parsed.scheme == "http" and not local and not allow_insecure_origin)): raise argparse.ArgumentTypeError("Use an HTTPS origin, or loopback HTTP for a local node") return value.rstrip("/") @@ -397,6 +397,10 @@ def register_node(root, args, token, helper_archive=None): state = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url, "source_commit": manifest["source_commit"], "generation": args.configuration["generation"], "specification_digest": args.configuration["specification_digest"]} + if args.allow_insecure_origin: + # Only an opted-in installation records the policy, so a default node's + # installation.json and registered.json stay byte-for-byte unchanged. + state["allow_insecure_origin"] = True write_once(root / "installation.json", json_text(state)) node_generations.record_root_runtime(root, args, manifest, sums, sys.modules[__name__]) for name in names: @@ -437,10 +441,13 @@ def register_node(root, args, token, helper_archive=None): with os.fdopen(descriptor, "w") as secret: secret.write(token) install_display.step("Registering this node with Core") + register = [str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), + "--state-dir", str(root / "state/node"), "--core-url", args.core_url, "--name", socket.gethostname(), + "--enrollment-token-file", secret_path] + if args.allow_insecure_origin: + register.append("--allow-insecure-origin") try: - checked([str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"), - "--core-url", args.core_url, "--name", socket.gethostname(), - "--enrollment-token-file", secret_path], REGISTRATION_UNCONFIRMED, explain=registration_failure) + checked(register, REGISTRATION_UNCONFIRMED, explain=registration_failure) except AddressChanged: discard_unregistered(root) raise @@ -953,10 +960,10 @@ def quote(value): + "\n\n[Install]\nWantedBy=multi-user.target\n") -def recorded_origin(value, source): +def recorded_origin(value, source, allow_insecure_origin=False): """A Core address read from a file, checked with the same rule as the command line.""" try: - return origin(value) + return origin(value, allow_insecure_origin) except (argparse.ArgumentTypeError, TypeError, AttributeError): raise InstallError(source + " holds an invalid Core address; preserve it and inspect the host." + NOTHING_CHANGED) from None @@ -970,7 +977,9 @@ def node_record(installation_id): if any(record.get(key) != value for key, value in expected.items()) or record.get("provider") not in DEVICE_GROUPS: raise InstallError(str(SYSTEM_RECORDS / (installation_id + ".json")) + " is not this installer's record; preserve " "it and inspect the host." + NOTHING_CHANGED) - recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json"))) + # Validate the recorded address under the policy the record itself carries, so + # uninstall can read an http record without the original command's flag. + recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json")), bool(record.get("allow_insecure_origin", False))) return record @@ -990,6 +999,9 @@ def install_system(args, token): if record["core_url"] != args.core_url: raise InstallError("This host's node uses " + record["core_url"] + ", but this command uses " + args.core_url + ". Remove the node on the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED) + if bool(record.get("allow_insecure_origin", False)) != args.allow_insecure_origin: + raise InstallError("This host's node was installed with a different insecure-origin policy; remove the node on " + "the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED) install_display.step("Checking host requirements") group, details = provider_group(provider) if record is None: @@ -1006,9 +1018,13 @@ def install_system(args, token): child_docker_config() root = SERVICE_HOME / ".oac/nodes" / args.installation_id unit = SYSTEM_UNITS / unit_name(args.installation_id) - root_file(SYSTEM_RECORDS / (args.installation_id + ".json"), - json_text({"format": 1, "installation_id": args.installation_id, "provider": provider, - "core_url": args.core_url, "node_root": str(root), "unit": str(unit)})) + record_state = {"format": 1, "installation_id": args.installation_id, "provider": provider, + "core_url": args.core_url, "node_root": str(root), "unit": str(unit)} + if args.allow_insecure_origin: + # An opted-in installation records the policy so uninstall and reruns can + # validate the address; a default record keeps its existing bytes. + record_state["allow_insecure_origin"] = True + root_file(SYSTEM_RECORDS / (args.installation_id + ".json"), json_text(record_state)) args.system, args.provider = True, provider run_as(account, prepare_service_node, args, token, helper_archive) root_file(unit, system_unit(root, provider)) @@ -1210,6 +1226,7 @@ def wait_ready(root, args, timeout=60): identity = stored["identity"] credential = stored["credential"] if (len(raw) > 16384 or stored["core_url"] != args.core_url + or bool(stored.get("allow_insecure_origin", False)) != args.allow_insecure_origin or identity["installation_id"] != args.installation_id or identity["provider"] != args.provider or str(uuid.UUID(identity["node_id"])) != identity["node_id"] or not re.fullmatch(r"[0-9a-f]{64}", credential)): @@ -1257,9 +1274,11 @@ def read_token(args): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group() - source.add_argument("--source-url", type=origin) + source.add_argument("--source-url") source.add_argument("--bundle", type=Path) - parser.add_argument("--core-url", type=origin) + parser.add_argument("--core-url") + parser.add_argument("--allow-insecure-origin", action="store_true", + help="Allow a non-loopback plaintext http Core origin (development and test only)") 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") @@ -1273,6 +1292,15 @@ def main(argv=None): args = parser.parse_args(argv) if args.no_color: os.environ["NO_COLOR"] = "1" + # The origin rule depends on --allow-insecure-origin, which argparse's per-value + # type cannot see, so validate both addresses after the whole command line is read. + for name in ("source_url", "core_url"): + value = getattr(args, name) + if value is not None: + try: + setattr(args, name, origin(value, args.allow_insecure_origin)) + except argparse.ArgumentTypeError as error: + parser.error(str(error)) if str(uuid.UUID(args.installation_id)) != args.installation_id: raise InstallError("Installation ID must be a canonical UUID") if args.update: diff --git a/deploy/node/test_generation_review_regressions.py b/deploy/node/test_generation_review_regressions.py index 548e47a9e..fe4a8c453 100644 --- a/deploy/node/test_generation_review_regressions.py +++ b/deploy/node/test_generation_review_regressions.py @@ -55,6 +55,32 @@ def interrupted(path, value): self.assertFalse(path.with_suffix('.preparing').exists()) self.assertTrue(node_generations.image_available(installer.private_json(path), installer)) + def test_prepare_validates_retained_source_url_under_the_enrollment_policy(self): + case = self.fixture(test_node_install.NodeInstallTests) + case.payloads["runtime/seccomp.json"] = b"{}" + case.refresh_manifest() + case.install() + configuration = json.loads(case.configuration_response(None).read()) + configuration["generation"] = 2 + case.args.generation = 2 + case.args.specification_digest = configuration["specification_digest"] + # A node enrolled with the switch may keep an http source_url in preparation.json. + (case.root / "preparation.json").write_text(json.dumps({"source_url": "http://private.example"})) + identity_path = case.root / "state/node/identity.json" + identity = installer.private_json(identity_path) + with mock.patch.object(installer.node_spec, "fetch", return_value=configuration): + with self.assertRaises(Exception) as strict: + node_generations.prepare(case.args, installer) + self.assertNotIsInstance(strict.exception, installer.RuntimeDownloadError) + self.assertIn("HTTPS", str(strict.exception)) + # The policy recorded in the retained identity admits it; the next step is the download. + identity["allow_insecure_origin"] = True + identity_path.write_text(json.dumps(identity)) + with mock.patch.object(installer.node_spec, "fetch", return_value=configuration), \ + mock.patch.object(installer, "metadata", side_effect=installer.RuntimeDownloadError("origin accepted")): + with self.assertRaisesRegex(installer.RuntimeDownloadError, "origin accepted"): + node_generations.prepare(case.args, installer) + def test_unresolved_import_remains_discoverable_and_collectible(self): case = self.fixture(test_node_install.NodeInstallTests) case.containerd = True diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index afbea2a90..0e4a244fd 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -51,7 +51,8 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.home = Path(temporary.name).resolve() self.args = argparse.Namespace(source_url="https://console.example", core_url="https://172.29.144.1:24443", - provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe") + provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", + allow_insecure_origin=False) self.root = self.home / ".oac/nodes" / self.args.installation_id self.manifest = {"platform": "linux/amd64", "source_commit": "a" * 40, "images": {"runtime": "sha256:" + "b" * 64}, "image_manifest_digests": {"runtime": "sha256:" + "c" * 64}, @@ -1026,6 +1027,57 @@ def test_origin_rejects_remote_http_credentials_paths_and_redirects(self): with self.assertRaisesRegex(installer.InstallError, "redirects"): installer.NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.example") + def test_origin_admits_non_loopback_http_only_with_the_explicit_flag(self): + self.assertEqual(installer.origin("http://private.example:8091/", True), "http://private.example:8091") + # The relaxed rule admits only the scheme; every other origin rule still holds. + for value in ("http://user:pass@private.example", "http://private.example/v1", "http://private.example?x=1", + "ftp://private.example", ""): + with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): + installer.origin(value, True) + + def test_main_gates_a_non_loopback_http_origin_on_the_flag(self): + base = ["--source-url", "http://console.example", "--core-url", "http://core.example", + "--installation-id", self.args.installation_id, "--enrollment-token-stdin"] + with mock.patch.object(installer.sys, "stdin", io.StringIO("synthetic-once-token\n")), \ + mock.patch.object(installer.os, "geteuid", return_value=0), \ + mock.patch.object(installer, "install_system") as install: + with self.assertRaises(SystemExit): + installer.main(base) + install.assert_not_called() + installer.main(base + ["--allow-insecure-origin"]) + self.assertTrue(install.call_args.args[0].allow_insecure_origin) + self.assertEqual(install.call_args.args[0].core_url, "http://core.example") + self.assertEqual(install.call_args.args[0].source_url, "http://console.example") + + def test_register_passes_the_insecure_origin_flag_only_when_enabled(self): + # Artifact downloads stay on HTTPS here: the distribution downloader's own + # HTTPS rule is a separate gate, reported rather than relaxed by this change. + self.args.allow_insecure_origin = True + self.install() + registers = [arguments for arguments, _ in self.calls if "register" in arguments] + self.assertEqual(len(registers), 1) + self.assertIn("--allow-insecure-origin", registers[0]) + self.assertTrue(json.loads((self.root / "registered.json").read_text())["allow_insecure_origin"]) + + def test_default_install_omits_the_policy_and_the_register_flag(self): + self.install() + registers = [arguments for arguments, _ in self.calls if "register" in arguments] + self.assertEqual(len(registers), 1) + self.assertNotIn("--allow-insecure-origin", registers[0]) + self.assertNotIn("allow_insecure_origin", json.loads((self.root / "installation.json").read_text())) + self.assertNotIn("allow_insecure_origin", json.loads((self.root / "registered.json").read_text())) + + def test_node_record_validates_an_http_address_under_its_recorded_policy(self): + record = {"installation_id": self.args.installation_id, "provider": "docker", "core_url": "http://private.example", + "node_root": str(installer.SERVICE_HOME / ".oac/nodes" / self.args.installation_id), + "unit": str(installer.SYSTEM_UNITS / installer.unit_name(self.args.installation_id))} + # A record without the policy (an older install) keeps the strict rule. + with mock.patch.object(installer, "read_root_json", return_value=record): + with self.assertRaisesRegex(installer.InstallError, "invalid Core address"): + installer.node_record(self.args.installation_id) + with mock.patch.object(installer, "read_root_json", return_value=dict(record, allow_insecure_origin=True)): + self.assertEqual(installer.node_record(self.args.installation_id)["core_url"], "http://private.example") + class NodePrerequisiteTests(unittest.TestCase): def test_preflight_rejects_missing_kvm_before_downloads(self): diff --git a/deploy/node/test_node_readiness.py b/deploy/node/test_node_readiness.py index b0b3e726b..e8851ef2c 100644 --- a/deploy/node/test_node_readiness.py +++ b/deploy/node/test_node_readiness.py @@ -21,7 +21,7 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() self.args = argparse.Namespace(core_url="https://core.example", provider="docker", - installation_id="94be54a1-138c-4f30-bc87-b13686272dbe") + installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", allow_insecure_origin=False) self.identity = {"node_id": "634d97be-e54d-40f0-9468-ae6b62be85bf", "installation_id": self.args.installation_id, "provider": self.args.provider, "deployment_generation": 1, "specification_digest": "b" * 64} self.path = self.root / "state/node/identity.json" @@ -84,6 +84,19 @@ def test_response_cannot_substitute_another_node(self): with self.assertRaisesRegex(installer.InstallError, "different node identity"): installer.wait_ready(self.root, self.args) + def test_wait_ready_matches_the_retained_insecure_origin_policy(self): + # A command that asks for the relaxed policy must match the retained identity. + self.args.allow_insecure_origin = True + with mock.patch.object(installer, "open_request") as request: + with self.assertRaisesRegex(installer.InstallError, "identity differs"): + installer.wait_ready(self.root, self.args) + request.assert_not_called() + stored = json.loads(self.path.read_text()) + stored["allow_insecure_origin"] = True + self.path.write_text(json.dumps(stored)) + with mock.patch.object(installer, "open_request", return_value=self.response(connected=True, provider_ready=True)): + installer.wait_ready(self.root, self.args) + class MetadataRetryTests(unittest.TestCase): def test_fetch_retries_transient_failure_with_bounded_delays(self): diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index 68749e61f..a91403643 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -53,15 +53,19 @@ func run(ctx context.Context, args []string) error { flags := flag.NewFlagSet("sandbox-node "+args[0], flag.ContinueOnError) configFile := flags.String("config", "", "absolute provider configuration file") stateDir := flags.String("state-dir", "", "absolute private node state directory") - coreURL := flags.String("core-url", "", "Core HTTPS origin (register only)") + coreURL := flags.String("core-url", "", "Core HTTPS origin, or a non-loopback http origin with --allow-insecure-origin (register only)") name := flags.String("name", "sandbox-node", "display name (register only)") tokenFile := flags.String("enrollment-token-file", "", "private single-use enrollment token file (register only)") + allowInsecureOrigin := flags.Bool("allow-insecure-origin", false, "Allow a non-loopback plaintext http Core origin (development and test only; register only)") if err := flags.Parse(args[1:]); err != nil { return err } if flags.NArg() != 0 { return errors.New("unexpected arguments") } + if args[0] == "run" && *allowInsecureOrigin { + return errors.New("--allow-insecure-origin applies only to register; run uses the retained identity") + } if !filepath.IsAbs(*configFile) || !filepath.IsAbs(*stateDir) { return errors.New("config and state-dir must be absolute paths") } @@ -119,7 +123,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, *allowInsecureOrigin); err != nil { return err } probeCtx, stopProbe := context.WithTimeout(ctx, 5*time.Second) diff --git a/services/core/cmd/sandbox-node/main_test.go b/services/core/cmd/sandbox-node/main_test.go index 4da4298bc..7b716cb93 100644 --- a/services/core/cmd/sandbox-node/main_test.go +++ b/services/core/cmd/sandbox-node/main_test.go @@ -52,13 +52,21 @@ 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) return err } +func TestRunRejectsTheEnrollmentOnlyOriginFlag(t *testing.T) { + // run never chooses a policy: it uses the one retained at registration. + err := run(t.Context(), []string{"run", "--config", "/missing/provider.json", "--state-dir", "/missing/state", "--allow-insecure-origin"}) + if err == nil || !strings.Contains(err.Error(), "--allow-insecure-origin applies only to register") { + t.Fatalf("run accepted the register-only flag: %v", err) + } +} + func TestProtocolVersionRequiresNoProviderOrIdentity(t *testing.T) { // This is a binary capability check, not a provider readiness probe. if err := run(t.Context(), []string{"protocol-version"}); err != nil { diff --git a/services/core/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go index cb86b6eac..d51fbe614 100644 --- a/services/core/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -102,7 +102,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 = stored.coreEndpoint(""); err != nil { return err } a := &agent{config: config, stored: stored, queue: make(chan work, maxPending)} @@ -179,7 +179,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 := a.stored.coreEndpoint("/api/v1/sandbox-node/connect") 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 1de42f08a..371e93261 100644 --- a/services/core/internal/sandbox/node/capacity_test.go +++ b/services/core/internal/sandbox/node/capacity_test.go @@ -26,7 +26,7 @@ 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) } diff --git a/services/core/internal/sandbox/node/connection_test.go b/services/core/internal/sandbox/node/connection_test.go index 30f88ecd8..f92ff76d6 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 02ef95248..e1f2dc8ec 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 5862b308c..dc5f1acd9 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 4ec8660b9..73add97d7 100644 --- a/services/core/internal/sandbox/node/enrollment.go +++ b/services/core/internal/sandbox/node/enrollment.go @@ -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 := stored.coreEndpoint("/api/v1/sandbox-node/identity") 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 = stored.coreEndpoint("/api/v1/sandbox-node/enroll") 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 := stored.coreEndpoint("/api/v1/sandbox-node/identity") 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 d39b68459..66e65fd0a 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/health_connection_linux_test.go b/services/core/internal/sandbox/node/health_connection_linux_test.go index c2c8fefae..9f58af06a 100644 --- a/services/core/internal/sandbox/node/health_connection_linux_test.go +++ b/services/core/internal/sandbox/node/health_connection_linux_test.go @@ -33,7 +33,7 @@ func TestHostHealthReconnectStartsFreshInterval(t *testing.T) { } })) defer server.Close() - a := agent{config: AgentConfig{CoreURL: server.URL, StateDirectory: t.TempDir(), Identity: identity(), Probe: probe}, stored: StoredIdentity{OwnerEpoch: 1}} + a := agent{config: AgentConfig{CoreURL: server.URL, StateDirectory: t.TempDir(), Identity: identity(), Probe: probe}, stored: StoredIdentity{CoreURL: server.URL, OwnerEpoch: 1}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for i := 0; i < 2; i++ { diff --git a/services/core/internal/sandbox/node/identity.go b/services/core/internal/sandbox/node/identity.go index 79567666e..5a4e7de6a 100644 --- a/services/core/internal/sandbox/node/identity.go +++ b/services/core/internal/sandbox/node/identity.go @@ -16,10 +16,11 @@ import ( ) type StoredIdentity struct { - Identity Identity `json:"identity"` - Credential string `json:"credential"` - CoreURL string `json:"core_url"` - OwnerEpoch uint64 `json:"owner_epoch"` + Identity Identity `json:"identity"` + Credential string `json:"credential"` + CoreURL string `json:"core_url"` + OwnerEpoch uint64 `json:"owner_epoch"` + AllowInsecureOrigin bool `json:"allow_insecure_origin,omitempty"` } func lockDirectory(dir string) (func(), error) { @@ -99,16 +100,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, allowInsecureOrigin 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, allowInsecureOrigin) } -func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { - if _, err := endpoint(coreURL, ""); err != nil { +func initIdentity(dir, coreURL string, identity Identity, allowInsecureOrigin bool) (StoredIdentity, error) { + if _, err := validateEndpoint(coreURL, "", allowInsecureOrigin); err != nil { return StoredIdentity{}, err } stored, err := readIdentity(dir) @@ -117,7 +118,7 @@ func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error if expected.NodeID == "" { expected.NodeID = stored.Identity.NodeID } - if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL { + if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL || stored.AllowInsecureOrigin != allowInsecureOrigin { return StoredIdentity{}, errors.New("node configuration does not match its retained identity") } return stored, nil @@ -135,7 +136,7 @@ func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error if _, err = rand.Read(secret); err != nil { return StoredIdentity{}, err } - stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL} + stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL, AllowInsecureOrigin: allowInsecureOrigin} if err = writeIdentity(dir, stored); err != nil { return StoredIdentity{}, err } @@ -150,14 +151,38 @@ func LoadIdentity(dir string) (StoredIdentity, error) { return readIdentity(dir) } +// endpoint is the default Core origin contract: HTTPS, or plaintext HTTP only +// for a loopback address. func endpoint(raw, path string) (string, error) { + return validateEndpoint(raw, path, false) +} + +// endpointAllowingInsecureOrigin accepts a non-loopback plaintext HTTP origin. It +// exists only for a node whose retained identity recorded that enrollment policy. +func endpointAllowingInsecureOrigin(raw, path string) (string, error) { + return validateEndpoint(raw, path, true) +} + +// coreEndpoint resolves the retained Core origin under the policy recorded when +// the node was enrolled. The enrollment flag has exactly one home: identity.json. +func (s StoredIdentity) coreEndpoint(path string) (string, error) { + if s.AllowInsecureOrigin { + return endpointAllowingInsecureOrigin(s.CoreURL, path) + } + return endpoint(s.CoreURL, path) +} + +// validateEndpoint is the single origin rule. TLS certificate verification is +// never relaxed here; allowInsecureOrigin only admits a plaintext HTTP scheme. +func validateEndpoint(raw, path string, allowInsecureOrigin 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 && !allowInsecureOrigin) { 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 2d375be1c..23b21e47c 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,7 +260,7 @@ 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) } @@ -275,7 +275,7 @@ 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 { @@ -294,6 +294,92 @@ func TestCoreURLRejectsRemotePlaintextAndCredentials(t *testing.T) { t.Fatalf("rejected %q: %v", raw, err) } } + // The explicit variant admits a non-loopback plaintext origin but keeps every + // other origin rule: credentials, query, fragment, path and non-http schemes. + if got, err := endpointAllowingInsecureOrigin("http://core.example.test:8080", "/api/v1/sandbox-node/connect"); err != nil || got != "http://core.example.test:8080/api/v1/sandbox-node/connect" { + t.Fatalf("relaxed endpoint = %q, %v", got, err) + } + for _, raw := range []string{"http://user:pass@example.com", "http://example.com/path", "http://example.com/?x=1", "ftp://example.com"} { + if _, err := endpointAllowingInsecureOrigin(raw, "/x"); err == nil { + t.Fatalf("relaxed endpoint accepted %q", raw) + } + } +} + +func TestInsecureOriginPolicyPersistsInRetainedIdentity(t *testing.T) { + dir := stateDir(t) + id := identity() + stored, err := InitIdentity(dir, "http://core.example.test:8080", id, true) + if err != nil { + t.Fatal(err) + } + if !stored.AllowInsecureOrigin { + t.Fatal("InitIdentity did not return the enrollment policy") + } + reloaded, err := LoadIdentity(dir) + if err != nil || !reloaded.AllowInsecureOrigin || reloaded.CoreURL != "http://core.example.test:8080" { + t.Fatalf("policy was not retained: %+v, %v", reloaded, err) + } + if got, err := reloaded.coreEndpoint("/api/v1/sandbox-node/connect"); err != nil || got != "http://core.example.test:8080/api/v1/sandbox-node/connect" { + t.Fatalf("retained endpoint = %q, %v", got, err) + } + if _, err := InitIdentity(dir, "http://core.example.test:8080", id, false); err == nil { + t.Fatal("a rerun silently changed the retained origin policy") + } +} + +func TestDefaultIdentityOmitsAndRejectsInsecureOrigin(t *testing.T) { + dir := stateDir(t) + if _, err := InitIdentity(dir, "http://core.example.test", identity(), false); err == nil { + t.Fatal("default enrollment accepted a non-loopback http origin") + } + stored, err := InitIdentity(dir, "https://core.example.test", identity(), false) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "identity.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "allow_insecure_origin") { + t.Fatalf("a default identity changed its on-disk shape: %s", raw) + } + if _, err := stored.coreEndpoint("/x"); err != nil { + t.Fatalf("https endpoint rejected: %v", err) + } +} + +func TestOldIdentityWithoutPolicyStaysStrict(t *testing.T) { + // An identity.json written before the field existed decodes to the strict policy. + dir := stateDir(t) + stored := StoredIdentity{Identity: identity(), Credential: strings.Repeat("ab", 32), CoreURL: "http://core.example.test"} + if err := writeIdentity(dir, stored); err != nil { + t.Fatal(err) + } + decoded, err := LoadIdentity(dir) + if err != nil { + t.Fatal(err) + } + if decoded.AllowInsecureOrigin { + t.Fatal("a missing field enabled the relaxed policy") + } + if _, err := decoded.coreEndpoint("/x"); err == nil { + t.Fatal("an old identity accepted a non-loopback http origin") + } +} + +func TestEnrollUsesRetainedInsecureOriginPolicy(t *testing.T) { + dir := stateDir(t) + id := identity() + if _, err := InitIdentity(dir, "http://core.invalid:8080", id, true); err != nil { + t.Fatal(err) + } + if _, err := Enroll(context.Background(), "http://core.invalid:8080", dir, "token", EnrollmentRequest{Name: "x"}); err == nil || strings.Contains(err.Error(), "requires HTTPS") { + t.Fatalf("Enroll did not use the retained policy: %v", err) + } + if _, err := RefreshIdentity(context.Background(), dir); err == nil || strings.Contains(err.Error(), "requires HTTPS") { + t.Fatalf("RefreshIdentity did not use the retained policy: %v", err) + } } 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 f9b7e1656..7b03b0c3c 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 23ba863e3..33212c135 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 c849b853d..5d0f29bea 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) }