Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion deploy/node/node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 41 additions & 13 deletions deploy/node/node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("/")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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


Expand All @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions deploy/node/test_generation_review_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion deploy/node/test_node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 14 additions & 1 deletion deploy/node/test_node_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 6 additions & 2 deletions services/core/cmd/sandbox-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading