diff --git a/crates/contract/assets/launcher_docker_compose.yaml.template b/crates/contract/assets/launcher_docker_compose.yaml.template index 050f25195c..f358be2826 100644 --- a/crates/contract/assets/launcher_docker_compose.yaml.template +++ b/crates/contract/assets/launcher_docker_compose.yaml.template @@ -2,11 +2,12 @@ version: '3.8' services: launcher: - image: nearone/mpc-launcher@sha256:7f155ebc928579ca269840d2e59f2e08e3c0cc1271c586f2e2b74bbda27ee8e1 + image: nearone/mpc-launcher@sha256:17243e65f47c69a4735c507ea91aefa0bdd761d9a267e28221232d748518bad3 container_name: launcher environment: + - PLATFORM=TEE - DOCKER_CONTENT_TRUST=1 - DEFAULT_IMAGE_DIGEST=sha256:{{DEFAULT_IMAGE_DIGEST_HASH}} diff --git a/tee_launcher/launcher.py b/tee_launcher/launcher.py index 8e5227c568..4ddb1a4ade 100644 --- a/tee_launcher/launcher.py +++ b/tee_launcher/launcher.py @@ -1,7 +1,9 @@ from collections import deque import logging import os +import stat from typing import Dict, Union +from enum import Enum import requests from subprocess import CompletedProcess, run, check_output import sys @@ -36,6 +38,18 @@ class RpcTimingConfig(NamedTuple): rpc_max_attempts: int +# +# Platform mode (MUST come from measured docker-compose env in TEE deployments) +# +ENV_VAR_PLATFORM = "PLATFORM" + + +# do not change the string values - they are used in docker-compose files. +class Platform(Enum): + TEE = "TEE" + NONTEE = "NONTEE" + + # only considered if `IMAGE_DIGEST_FILE` does not exist. ENV_VAR_DEFAULT_IMAGE_DIGEST = "DEFAULT_IMAGE_DIGEST" @@ -270,6 +284,38 @@ def parse_env_file(path: str) -> dict: return parse_env_lines(f.readlines()) +def is_unix_socket(path: str) -> bool: + try: + st = os.stat(path) + return stat.S_ISSOCK(st.st_mode) + except FileNotFoundError: + return False + except Exception: + return False + + +def parse_platform() -> Platform: + """ + Platform selection MUST be a measured input in TEE deployments. + Therefore, we only read it from process env (docker-compose 'environment'), + and never from /tapp/user_config. + """ + raw = os.environ.get(ENV_VAR_PLATFORM) + if raw is None: + raise RuntimeError( + f"{ENV_VAR_PLATFORM} must be set to one of {[p.value for p in Platform]}" + ) + + val = raw.strip() + try: + return Platform(val) + except ValueError as e: + allowed = ", ".join(p.value for p in Platform) + raise RuntimeError( + f"Invalid {ENV_VAR_PLATFORM}={raw!r}. Expected one of: {allowed}" + ) from e + + def load_rpc_timing_config(dstack_config: dict[str, str]) -> RpcTimingConfig: """ Loads dockerhub RPC timing configuration from dstack_config, @@ -440,7 +486,16 @@ def validate_image_hash( return False -def extend_rtmr3(valid_hash: str) -> None: +def extend_rtmr3(platform: Platform, valid_hash: str) -> None: + if platform == Platform.NONTEE: + logging.info("PLATFORM=NONTEE → skipping RTMR3 extension step.") + return + + if not is_unix_socket(DSTACK_UNIX_SOCKET): + raise RuntimeError( + f"PLATFORM=TEE requires dstack unix socket at {DSTACK_UNIX_SOCKET}." + ) + bare = get_bare_digest(valid_hash) logging.info(f"Extending RTMR3 with validated hash: {bare}") @@ -460,11 +515,11 @@ def extend_rtmr3(valid_hash: str) -> None: raise RuntimeError("EmitEvent failed while extending RTMR3") -def launch_mpc_container(valid_hash: str, user_env: dict) -> None: +def launch_mpc_container(platform: Platform, valid_hash: str, user_env: dict) -> None: logging.info(f"Launching MPC node with validated hash: {valid_hash}") remove_existing_container() - docker_cmd = build_docker_cmd(user_env, valid_hash) + docker_cmd = build_docker_cmd(platform, user_env, valid_hash) proc = run(docker_cmd) if proc.returncode != 0: @@ -510,6 +565,14 @@ def curl_unix_socket_post( def main(): logging.info("start") + platform = parse_platform() + logging.info(f"Launcher platform: {platform.value}") + + if platform is Platform.TEE and not is_unix_socket(DSTACK_UNIX_SOCKET): + raise RuntimeError( + f"PLATFORM=TEE requires dstack unix socket at {DSTACK_UNIX_SOCKET}." + ) + # DOCKER_CONTENT_TRUST must be enabled if os.environ.get(OS_ENV_DOCKER_CONTENT_TRUST, "0") != "1": raise RuntimeError( @@ -547,9 +610,9 @@ def main(): # Continue with validated hash logging.info(f"MPC image hash validated successfully: {selected_hash}") - extend_rtmr3(selected_hash) + extend_rtmr3(platform, selected_hash) - launch_mpc_container(selected_hash, dstack_config) + launch_mpc_container(platform, selected_hash, dstack_config) def request_until_success( @@ -685,7 +748,9 @@ def get_bare_digest(full_digest: str) -> str: return full_digest.split(":", 1)[1] -def build_docker_cmd(user_env: dict[str, str], image_digest: str) -> list[str]: +def build_docker_cmd( + platform: Platform, user_env: dict[str, str], image_digest: str +) -> list[str]: bare_digest = get_bare_digest(image_digest) docker_cmd = ["docker", "run"] @@ -693,7 +758,10 @@ def build_docker_cmd(user_env: dict[str, str], image_digest: str) -> list[str]: # Required environment variables docker_cmd += ["--env", f"MPC_IMAGE_HASH={bare_digest}"] docker_cmd += ["--env", f"MPC_LATEST_ALLOWED_HASH_FILE={IMAGE_DIGEST_FILE}"] - docker_cmd += ["--env", f"DSTACK_ENDPOINT={DSTACK_UNIX_SOCKET}"] + + if platform is Platform.TEE: + docker_cmd += ["--env", f"DSTACK_ENDPOINT={DSTACK_UNIX_SOCKET}"] + docker_cmd += ["-v", f"{DSTACK_UNIX_SOCKET}:{DSTACK_UNIX_SOCKET}"] # MPC env vars and extra config for key, value in user_env.items(): @@ -737,8 +805,6 @@ def build_docker_cmd(user_env: dict[str, str], image_digest: str) -> list[str]: "-v", "/tapp:/tapp:ro", "-v", - f"{DSTACK_UNIX_SOCKET}:{DSTACK_UNIX_SOCKET}", - "-v", "shared-volume:/mnt/shared", "-v", "mpc-data:/data", diff --git a/tee_launcher/launcher_docker_compose.yaml b/tee_launcher/launcher_docker_compose.yaml index b055040a58..5c8310f1ff 100644 --- a/tee_launcher/launcher_docker_compose.yaml +++ b/tee_launcher/launcher_docker_compose.yaml @@ -2,11 +2,12 @@ version: '3.8' services: launcher: - image: nearone/mpc-launcher@sha256:4065f2fce41415962be92471a4e793ff5147b00b2784617c7e8098be2761a875 + image: nearone/mpc-launcher@sha256:17243e65f47c69a4735c507ea91aefa0bdd761d9a267e28221232d748518bad3 container_name: launcher environment: + - PLATFORM=TEE - DOCKER_CONTENT_TRUST=1 - DEFAULT_IMAGE_DIGEST=sha256:00006c1059cc0219005b21956a4df8238b0cc33ad559a578a63169de4e28c81e diff --git a/tee_launcher/launcher_docker_compose_nontee.yaml b/tee_launcher/launcher_docker_compose_nontee.yaml new file mode 100644 index 0000000000..ad4935e794 --- /dev/null +++ b/tee_launcher/launcher_docker_compose_nontee.yaml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + launcher: + image: nearone/mpc-launcher@sha256:17243e65f47c69a4735c507ea91aefa0bdd761d9a267e28221232d748518bad3 + container_name: launcher + + environment: + - PLATFORM=NONTEE + - DOCKER_CONTENT_TRUST=1 + - DEFAULT_IMAGE_DIGEST=sha256:00006c1059cc0219005b21956a4df8238b0cc33ad559a578a63169de4e28c81e + + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./user-config.conf:/tapp/user_config:ro + - shared-volume:/mnt/shared + - mpc-data:/data + + security_opt: + - no-new-privileges:true + + read_only: true + tmpfs: + - /tmp + +volumes: + shared-volume: + name: shared-volume + mpc-data: + name: mpc-data diff --git a/tee_launcher/test_launcher_config.py b/tee_launcher/test_launcher_config.py index 2aa2328251..a211090ee6 100644 --- a/tee_launcher/test_launcher_config.py +++ b/tee_launcher/test_launcher_config.py @@ -1,4 +1,5 @@ # test_launcher_config.py + import inspect import json import tempfile @@ -22,6 +23,15 @@ ) +# Test constants for user_config content +TEST_MPC_ACCOUNT_ID = "mpc-user-123" + +TEST_PORTS_WITH_INJECTION = "11780:11780,--env BAD=1" + +TEST_EXTRA_HOSTS_WITH_IP = "node:192.168.1.1" +TEST_EXTRA_HOSTS_WITH_INJECTION = f"{TEST_EXTRA_HOSTS_WITH_IP},--volume /:/mnt" + + def make_digest_json(hashes): return json.dumps({JSON_KEY_APPROVED_HASHES: hashes}) @@ -120,21 +130,21 @@ def test_valid_port_mapping(): def test_build_docker_cmd_sanitizes_ports_and_hosts(): env = { - "PORTS": "11780:11780,--env BAD=1", - "EXTRA_HOSTS": "node:192.168.1.1,--volume /:/mnt", - "MPC_ACCOUNT_ID": "mpc-user-123", + "PORTS": TEST_PORTS_WITH_INJECTION, + "EXTRA_HOSTS": TEST_EXTRA_HOSTS_WITH_INJECTION, + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") func_name = inspect.currentframe().f_code.co_name print(f"[{func_name}] CMD:", " ".join(cmd)) assert "--env" in cmd - assert "MPC_ACCOUNT_ID=mpc-user-123" in cmd + assert f"MPC_ACCOUNT_ID={TEST_MPC_ACCOUNT_ID}" in cmd assert "-p" in cmd assert "11780:11780" in cmd assert "--add-host" in cmd - assert "node:192.168.1.1" in cmd + assert TEST_EXTRA_HOSTS_WITH_IP in cmd # Make sure injection strings were filtered assert not any("BAD=1" in arg for arg in cmd) @@ -146,7 +156,7 @@ def test_extra_hosts_does_not_allow_ld_preload(): "EXTRA_HOSTS": "host:1.2.3.4,--env LD_PRELOAD=/evil.so", "MPC_ACCOUNT_ID": "safe", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "host:1.2.3.4" in cmd assert not any("LD_PRELOAD" in arg for arg in cmd) @@ -157,7 +167,7 @@ def test_ports_does_not_allow_volume_injection(): "PORTS": "2200:2200,--volume /:/mnt", "MPC_ACCOUNT_ID": "safe", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "2200:2200" in cmd assert not any("/:/mnt" in arg for arg in cmd) @@ -168,7 +178,7 @@ def test_invalid_env_key_is_ignored(): "BAD_KEY": "should_not_be_used", "MPC_ACCOUNT_ID": "safe", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "should_not_be_used" not in " ".join(cmd) assert "MPC_ACCOUNT_ID=safe" in cmd @@ -178,7 +188,7 @@ def test_protocol_upgrade_override_is_allowed(): env = { "NEAR_TESTS_PROTOCOL_UPGRADE_OVERRIDE": "now", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "NEAR_TESTS_PROTOCOL_UPGRADE_OVERRIDE=now" in " ".join(cmd) @@ -187,7 +197,7 @@ def test_mpc_backup_encryption_key_is_allowed(): env = { "MPC_BACKUP_ENCRYPTION_KEY_HEX": "0000000000000000000000000000000000000000000000000000000000000000", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert ( "MPC_BACKUP_ENCRYPTION_KEY_HEX=0000000000000000000000000000000000000000000000000000000000000000" @@ -200,7 +210,7 @@ def test_malformed_extra_host_is_ignored(): "EXTRA_HOSTS": "badhostentry,no-colon,also--bad", "MPC_ACCOUNT_ID": "safe", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "--add-host" not in cmd # All malformed entries should be skipped @@ -209,7 +219,7 @@ def test_env_value_with_shell_injection_is_handled_safely(): env = { "MPC_ACCOUNT_ID": "safe; rm -rf /", } - cmd = build_docker_cmd(env, "sha256:abc123") + cmd = build_docker_cmd(launcher.Platform.TEE, env, "sha256:abc123") assert "--env" in cmd assert "MPC_ACCOUNT_ID=safe; rm -rf /" in cmd @@ -227,7 +237,7 @@ def test_parse_and_build_docker_cmd_full_flow(): env = parse_env_string(config_str) image_hash = env.get("IMAGE_HASH", "sha256:default") - cmd = build_docker_cmd(env, image_hash) + cmd = build_docker_cmd(launcher.Platform.TEE, env, image_hash) print(f"[{inspect.currentframe().f_code.co_name}] CMD: {' '.join(cmd)}") @@ -247,12 +257,12 @@ def test_parse_and_build_docker_cmd_full_flow(): def test_ld_preload_injection_blocked1(): # Set up the environment variable with a dangerous LD_PRELOAD value malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "--env LD_PRELOAD": "/path/to/my/malloc.so", # The dangerous value } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not included in the command assert "--env" in docker_cmd # Ensure there is an env var @@ -273,12 +283,12 @@ def test_ld_preload_injection_blocked1(): def test_ld_preload_in_extra_hosts1(): # Set up environment with malicious EXRA_HOSTS containing LD_PRELOAD malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "EXTRA_HOSTS": "host1:192.168.0.1,host2:192.168.0.2,--env LD_PRELOAD=/path/to/my/malloc.so", } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not part of the extra hosts in the docker command assert "--add-host" in docker_cmd # Ensure extra hosts are included @@ -294,12 +304,12 @@ def test_ld_preload_in_extra_hosts1(): def test_ld_preload_in_ports1(): # Set up environment with malicious PORTS containing LD_PRELOAD malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "PORTS": "11780:11780,--env LD_PRELOAD=/path/to/my/malloc.so", } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not part of the port mappings in the docker command assert "-p" in docker_cmd # Ensure port mappings are included @@ -318,12 +328,12 @@ def test_ld_preload_in_ports1(): def test_ld_preload_in_mpc_account_id(): # Set up environment with malicious EXRA_HOSTS containing LD_PRELOAD malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123, --env LD_PRELOAD=/path/to/my/malloc.so", + "MPC_ACCOUNT_ID": f"{TEST_MPC_ACCOUNT_ID}, --env LD_PRELOAD=/path/to/my/malloc.so", "EXTRA_HOSTS": "host1:192.168.0.1,host2:192.168.0.2", } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not part of the extra hosts in the docker command assert "--add-host" in docker_cmd # Ensure extra hosts are included @@ -340,12 +350,12 @@ def test_ld_preload_in_mpc_account_id(): def test_ld_preload_injection_blocked2(): # Set up the environment variable with a dangerous LD_PRELOAD value malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "-e LD_PRELOAD": "/path/to/my/malloc.so", # The dangerous value } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") assert ( "-e LD_PRELOAD" not in docker_cmd @@ -359,12 +369,12 @@ def test_ld_preload_injection_blocked2(): def test_ld_preload_in_extra_hosts2(): # Set up environment with malicious EXRA_HOSTS containing LD_PRELOAD malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "EXTRA_HOSTS": "host1:192.168.0.1,host2:192.168.0.2,-e LD_PRELOAD=/path/to/my/malloc.so", } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not part of the extra hosts in the docker command assert "--add-host" in docker_cmd # Ensure extra hosts are included @@ -375,12 +385,12 @@ def test_ld_preload_in_extra_hosts2(): def test_ld_preload_in_ports2(): # Set up environment with malicious PORTS containing LD_PRELOAD malicious_env = { - "MPC_ACCOUNT_ID": "mpc-user-123", + "MPC_ACCOUNT_ID": TEST_MPC_ACCOUNT_ID, "PORTS": "11780:11780,-e LD_PRELOAD=/path/to/my/malloc.so", } # Call build_docker_cmd to generate the docker command - docker_cmd = build_docker_cmd(malicious_env, "sha256:abc123") + docker_cmd = build_docker_cmd(launcher.Platform.TEE, malicious_env, "sha256:abc123") # Check that LD_PRELOAD is not part of the port mappings in the docker command assert "-p" in docker_cmd # Ensure port mappings are included @@ -496,3 +506,298 @@ def test_validate_image_hash(): RPC_MAX_ATTEMPTS, ) assert result is True, "validate_image_hash() failed for test image" + + +# test launcher support for non TEE images. + + +class DummyProc: + def __init__(self, returncode=0, stdout=b"", stderr=b""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +@pytest.fixture +def base_env(monkeypatch): + # Required by launcher + monkeypatch.setenv(launcher.OS_ENV_DOCKER_CONTENT_TRUST, "1") + monkeypatch.setenv(launcher.ENV_VAR_DEFAULT_IMAGE_DIGEST, "sha256:" + "a" * 64) + + +def test_parse_platform_missing(monkeypatch, base_env): + monkeypatch.delenv(launcher.ENV_VAR_PLATFORM, raising=False) + with pytest.raises(RuntimeError): + launcher.parse_platform() + + +@pytest.mark.parametrize("val", ["", "foo", "TEE as", "NON_TEE", "1", "tee", "nontee"]) +def test_parse_platform_invalid(monkeypatch, base_env, val): + monkeypatch.setenv(launcher.ENV_VAR_PLATFORM, val) + with pytest.raises(RuntimeError): + launcher.parse_platform() + + +@pytest.mark.parametrize( + "val,expected", + [ + ("TEE", launcher.Platform.TEE), + ("NONTEE", launcher.Platform.NONTEE), + ], +) +def test_parse_platform_valid(monkeypatch, base_env, val, expected): + monkeypatch.setenv(launcher.ENV_VAR_PLATFORM, val) + assert launcher.parse_platform() is expected + + +def test_extend_rtmr3_nontee_skips_dstack(monkeypatch, base_env): + called = {"count": 0} + + def fake_curl(*args, **kwargs): + called["count"] += 1 + return DummyProc(returncode=0) + + monkeypatch.setattr(launcher, "curl_unix_socket_post", fake_curl) + + launcher.extend_rtmr3(launcher.Platform.NONTEE, "sha256:" + "b" * 64) + assert called["count"] == 0 + + +def test_extend_rtmr3_tee_requires_socket(monkeypatch, base_env): + monkeypatch.setattr(launcher, "is_unix_socket", lambda p: False) + with pytest.raises(RuntimeError): + launcher.extend_rtmr3(launcher.Platform.TEE, "sha256:" + "b" * 64) + + +def test_extend_rtmr3_tee_getquote_fail(monkeypatch, base_env): + monkeypatch.setattr(launcher, "is_unix_socket", lambda p: True) + + def fake_curl(endpoint, payload, capture_output=False): + # Fail only GetQuote + if endpoint == "GetQuote": + return DummyProc(returncode=7) + return DummyProc(returncode=0) + + monkeypatch.setattr(launcher, "curl_unix_socket_post", fake_curl) + with pytest.raises(RuntimeError, match="GetQuote failed"): + launcher.extend_rtmr3(launcher.Platform.TEE, "sha256:" + "b" * 64) + + +def test_extend_rtmr3_tee_emitevent_fail(monkeypatch, base_env): + monkeypatch.setattr(launcher, "is_unix_socket", lambda p: True) + + def fake_curl(endpoint, payload, capture_output=False): + if endpoint == "GetQuote": + return DummyProc(returncode=0) + if endpoint == "EmitEvent": + return DummyProc(returncode=55) + return DummyProc(returncode=0) + + monkeypatch.setattr(launcher, "curl_unix_socket_post", fake_curl) + with pytest.raises(RuntimeError, match="EmitEvent failed"): + launcher.extend_rtmr3(launcher.Platform.TEE, "sha256:" + "b" * 64) + + +def test_build_docker_cmd_nontee_no_dstack_mount(base_env): + env = { + "MPC_ACCOUNT_ID": "x", + # launcher-only env should be ignored + launcher.ENV_VAR_RPC_MAX_ATTEMPTS: "5", + } + cmd = launcher.build_docker_cmd(launcher.Platform.NONTEE, env, "sha256:" + "c" * 64) + s = " ".join(cmd) + + assert "DSTACK_ENDPOINT=" not in s + assert f"{launcher.DSTACK_UNIX_SOCKET}:{launcher.DSTACK_UNIX_SOCKET}" not in s + + +def test_build_docker_cmd_tee_has_dstack_mount(base_env): + env = {"MPC_ACCOUNT_ID": "x"} + cmd = launcher.build_docker_cmd(launcher.Platform.TEE, env, "sha256:" + "c" * 64) + s = " ".join(cmd) + + assert f"DSTACK_ENDPOINT={launcher.DSTACK_UNIX_SOCKET}" in s + assert f"{launcher.DSTACK_UNIX_SOCKET}:{launcher.DSTACK_UNIX_SOCKET}" in s + + +def test_main_tee_fails_closed_before_launch(monkeypatch, base_env): + monkeypatch.setenv(launcher.ENV_VAR_PLATFORM, launcher.Platform.TEE.value) + + monkeypatch.setattr(launcher, "is_unix_socket", lambda p: False) + + # prevent any real docker/network + monkeypatch.setattr( + launcher, "load_and_select_hash", lambda cfg: "sha256:" + "d" * 64 + ) + monkeypatch.setattr(launcher, "validate_image_hash", lambda *a, **k: True) + monkeypatch.setattr( + launcher, + "launch_mpc_container", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not launch")), + ) + + with pytest.raises(RuntimeError, match="requires dstack unix socket"): + launcher.main() + + +def test_main_nontee_skips_extend_and_launches(monkeypatch, base_env): + monkeypatch.setenv(launcher.ENV_VAR_PLATFORM, "NONTEE") + monkeypatch.setattr( + launcher, "is_unix_socket", lambda p: False + ) # should not matter + + monkeypatch.setattr( + launcher, "load_and_select_hash", lambda cfg: "sha256:" + "d" * 64 + ) + monkeypatch.setattr(launcher, "validate_image_hash", lambda *a, **k: True) + + called = {"extend": 0, "launch": 0} + monkeypatch.setattr( + launcher, + "extend_rtmr3", + lambda platform, h: called.__setitem__("extend", called["extend"] + 1), + ) + monkeypatch.setattr( + launcher, + "launch_mpc_container", + lambda platform, h, cfg: called.__setitem__("launch", called["launch"] + 1), + ) + + launcher.main() + assert called["extend"] == 1 + assert called["launch"] == 1 + + +def assert_subsequence(seq, subseq): + it = iter(seq) + for x in subseq: + for y in it: + if y == x: + break + else: + raise AssertionError(f"Missing subsequence item: {x}\nseq={seq}") + + +def test_main_nontee_builds_expected_mpc_docker_cmd(monkeypatch, tmp_path): + """ + Verify that launcher.main() builds the correct MPC docker command in NONTEE mode. + + Steps: + 1. Configure the launcher to run with PLATFORM=NONTEE. + 2. Set required environment variables (DOCKER_CONTENT_TRUST, DEFAULT_IMAGE_DIGEST). + 3. Create a temporary user_config file with MPC env vars, ports, and extra hosts. + 4. Simulate a missing IMAGE_DIGEST_FILE so the launcher falls back to DEFAULT_IMAGE_DIGEST. + 5. Stub image validation and docker interactions to avoid real network or docker usage. + 6. Invoke launcher.main(). + 7. Capture the docker run command used to start the MPC container. + 8. Assert that the command: + - Includes expected MPC configuration (env vars, ports, hosts, volumes). + - Does NOT include dstack socket mounts or DSTACK_ENDPOINT. + - Filters out injection attempts in ports and hosts. + - Uses the expected full image digest. + """ + # --- Arrange: environment (NONTEE) --- + monkeypatch.setenv(launcher.ENV_VAR_PLATFORM, launcher.Platform.NONTEE.value) + monkeypatch.setenv(launcher.OS_ENV_DOCKER_CONTENT_TRUST, "1") + + default_digest = "sha256:" + "a" * 64 + monkeypatch.setenv(launcher.ENV_VAR_DEFAULT_IMAGE_DIGEST, default_digest) + + # Provide a temp user config file so main() passes env into build_docker_cmd() + user_config = tmp_path / "user_config" + user_config.write_text( + "\n".join( + [ + f"MPC_ACCOUNT_ID={TEST_MPC_ACCOUNT_ID}", + f"PORTS={TEST_PORTS_WITH_INJECTION}", # injection should be ignored + f"EXTRA_HOSTS={TEST_EXTRA_HOSTS_WITH_INJECTION}", # injection should be ignored + ] + ) + + "\n" + ) + + # Point launcher at our temp config + monkeypatch.setattr(launcher, "DSTACK_USER_CONFIG_FILE", str(user_config)) + + # Make IMAGE_DIGEST_FILE "missing" so DEFAULT_IMAGE_DIGEST is used + def fake_isfile(path: str) -> bool: + if path == launcher.IMAGE_DIGEST_FILE: + return False + if path == str(user_config): + return True + return os.path.isfile(path) + + monkeypatch.setattr(launcher.os.path, "isfile", fake_isfile) + + # Avoid network/docker verification in validate_image_hash + monkeypatch.setattr(launcher, "validate_image_hash", lambda *a, **k: True) + + # Avoid remove_existing_container touching real docker + monkeypatch.setattr(launcher, "check_output", lambda *a, **k: "") + + # Capture the docker run command used to launch MPC + captured = {"docker_run_cmd": None} + + def fake_run(cmd, *args, **kwargs): + # cmd is a list[str] + if ( + isinstance(cmd, list) + and len(cmd) >= 2 + and cmd[0] == "docker" + and cmd[1] == "run" + ): + captured["docker_run_cmd"] = cmd + return DummyProc(returncode=0) + return DummyProc(returncode=0) + + monkeypatch.setattr(launcher, "run", fake_run) + + # --- Act --- + launcher.main() + + # --- Assert --- + cmd = captured["docker_run_cmd"] + assert cmd is not None, "Expected launcher to invoke 'docker run' for MPC container" + + cmd_str = " ".join(cmd) + + # NONTEE invariants + assert "DSTACK_ENDPOINT=" not in cmd_str + assert f"{launcher.DSTACK_UNIX_SOCKET}:{launcher.DSTACK_UNIX_SOCKET}" not in cmd_str + + # Expected env propagation + sanitization + assert f"MPC_ACCOUNT_ID={TEST_MPC_ACCOUNT_ID}" in cmd_str + assert "-p" in cmd and "11780:11780" in cmd_str + assert "--add-host" in cmd and TEST_EXTRA_HOSTS_WITH_IP in cmd_str + + # Injection strings filtered out + assert "BAD=1" not in cmd_str + assert "/:/mnt" not in cmd_str + + # Required mounts / flags from build_docker_cmd + assert "--security-opt" in cmd_str + assert "no-new-privileges:true" in cmd_str + assert "/tapp:/tapp:ro" in cmd_str + assert "shared-volume:/mnt/shared" in cmd_str + assert "mpc-data:/data" in cmd_str + assert f"--name {launcher.MPC_CONTAINER_NAME}" in cmd_str + + # Image digest should be the final argument and should be the FULL digest + assert cmd[-1] == default_digest + + expected_core = [ + "docker", + "run", + "--security-opt", + "no-new-privileges:true", + "-v", + "/tapp:/tapp:ro", + "-v", + "shared-volume:/mnt/shared", + "-v", + "mpc-data:/data", + "--name", + launcher.MPC_CONTAINER_NAME, + "--detach", + ] + assert_subsequence(cmd, expected_core) diff --git a/tee_launcher/using_the_launcher_in_nontee_setup.md b/tee_launcher/using_the_launcher_in_nontee_setup.md new file mode 100644 index 0000000000..a5d66bb416 --- /dev/null +++ b/tee_launcher/using_the_launcher_in_nontee_setup.md @@ -0,0 +1,147 @@ +# Running the MPC Launcher in Non-TEE Mode + +This guide describes the **high-level steps** required to run the MPC launcher **outside of a TEE**, using the same launcher and MPC Docker images as in TEE deployments. + +The goal is to allow developers and operators to exercise the **real production launcher flow** (image selection, validation, container launch, upgrades) **without relying on TEE / dstack infrastructure**. + +--- + +## Overview + +In non-TEE mode: +- The launcher runs with `PLATFORM=NONTEE` +- No dstack socket or attestation is used +- RTMR extensions are skipped +- Image hash validation and upgrade logic remain unchanged +- The MPC container is launched with DSTACK_ENDPOINT set to dstack.sock + +This provides maximum parity with production while keeping the setup simple. + +--- + +## Prerequisites + +- Docker installed and running +- Docker Compose (classic `docker-compose` or plugin `docker compose`) +- Network access to pull Docker images +- A valid MPC Docker image digest + +--- + +## Files Used + +- **Launcher Docker Compose (non-TEE)** + Example: + `./launcher_docker_compose_nontee.yaml` + +- **User configuration file** + Example: + `./user-config.conf` (mounted into the launcher container) + +--- + +## Step-by-Step Instructions + +### 1. Prepare the non-TEE docker-compose file + +Create or use a non-TEE launcher compose file with the following properties: + +- Set `PLATFORM=NONTEE` +- Mount `/var/run/docker.sock` +- Do **not** mount `/var/run/dstack.sock` +- Mount the user config file at `/tapp/user_config` +- Provide persistent volumes for shared state and MPC data + +See example: +``` +./tee_launcher/launcher_docker_compose_nontee.yaml +``` + +--- + +### 2. Prepare the user configuration file + +Create a `user-config.conf` file containing: +- MPC image selection parameters (`MPC_IMAGE_NAME`, `MPC_IMAGE_TAGS`, `MPC_REGISTRY`) +- MPC runtime configuration (account ID, contract ID, network, logging, etc.) +- Optional port mappings using `PORTS=` + +This file is read by the launcher and passed (safely) into the MPC container. + +--- + +### 3. Start the launcher + +From the directory containing the non-TEE compose file: + +```bash +docker-compose -f launcher_docker_compose_nontee.yaml up -d +``` + +--- + +### 4. Monitor launcher logs + +```bash +docker logs -f launcher +``` + +You should see: +- `Launcher platform: NONTEE` +- Image hash selection and validation +- `PLATFORM=NONTEE → skipping RTMR3 extension` +- A `docker run ...` command launching `mpc-node` + +--- + +### 5. Verify the MPC container + +Check that the MPC container is running: + +```bash +docker ps +``` + +Inspect the MPC container to confirm: +- No `DSTACK_ENDPOINT` environment variable exists +- No dstack socket is mounted +- Expected ports are published + +Example: +```bash +docker inspect mpc-node +``` + +--- + + + +--- + +## Stopping and Restarting + +Stop and remove containers: +```bash +docker-compose -f launcher_docker_compose_nontee.yaml down +``` + +Stop and remove containers **and volumes** (full reset): +```bash +docker-compose -f launcher_docker_compose_nontee.yaml down -v +``` + +Restart: +```bash +docker-compose -f launcher_docker_compose_nontee.yaml up -d +``` + +--- + +## Notes + +- Non-TEE mode is intended for **testing, development, and debugging** +- Security-sensitive behavior for TEE deployments remains unchanged +- The same launcher image and MPC image hashes are used in both modes + +--- +