-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add Rust tee-launcher crate #2621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5201ba0
dbb3e1e
522758a
f184b9c
53f0420
54b446f
6befaf0
33d2405
d41dc42
7b8fae0
ae94e1b
5a7d8c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| [package] | ||
| name = "tee-launcher" | ||
| readme = "README.md" | ||
| version = { workspace = true } | ||
| license = { workspace = true } | ||
| edition = { workspace = true } | ||
|
|
||
| [[bin]] | ||
| name = "tee-launcher" | ||
| path = "src/main.rs" | ||
|
|
||
| [features] | ||
| external-services-tests = [] | ||
|
|
||
| [dependencies] | ||
| backon = { workspace = true } | ||
| clap = { workspace = true } | ||
| dstack-sdk = { workspace = true } | ||
| launcher-interface = { workspace = true } | ||
| near-mpc-bounded-collections = { workspace = true } | ||
| reqwest = { workspace = true } | ||
| serde = { workspace = true } | ||
| serde_json = { workspace = true } | ||
| tempfile = { workspace = true } | ||
| thiserror = { workspace = true } | ||
| tokio = { workspace = true } | ||
| toml = { workspace = true } | ||
| tracing = { workspace = true } | ||
| tracing-subscriber = { workspace = true } | ||
| url = { workspace = true, features = ["serde"] } | ||
|
|
||
| [dev-dependencies] | ||
| assert_matches = { workspace = true } | ||
| httpmock = { workspace = true } | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # TEE Launcher (Rust) | ||
|
|
||
| Secure launcher for initializing and attesting a Docker-based MPC node inside a TEE-enabled environment (e.g., Intel TDX via dstack). | ||
|
|
||
| Replaces the previous Python launcher (`tee_launcher/launcher.py`). | ||
|
|
||
| ## What it does | ||
|
|
||
| 1. Loads a TOML configuration file from `/tapp/user_config` | ||
| 2. Selects an approved MPC image hash (from on-disk approved list, override, or default) | ||
| 3. Validates the image by resolving it through the Docker registry and pulling by digest | ||
| 4. In TEE mode: extends RTMR3 by emitting the image digest to dstack | ||
| 5. Writes the MPC node config to a shared volume | ||
| 6. Launches the MPC container via `docker compose up -d` | ||
|
|
||
| ## CLI Arguments | ||
|
|
||
| All arguments are read from environment variables (set via docker-compose `environment`): | ||
|
|
||
| | Variable | Required | Description | | ||
| |----------|----------|-------------| | ||
| | `PLATFORM` | Yes | `TEE` or `NONTEE` | | ||
| | `DOCKER_CONTENT_TRUST` | Yes | Must be `1` | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this one is a bit weird. Why does the user need to set it if it is always 1? The other are user choices, this one is a requirement so technically should be in the section where we explain how to run this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe it is not explained well enough those are value the launcher sets, not the user
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ahh I thought the user sets them as well. For example, the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or rather, |
||
| | `DEFAULT_IMAGE_DIGEST` | Yes | Fallback `sha256:...` digest when the approved-hashes file is absent | | ||
|
|
||
| ## Configuration (TOML) | ||
|
|
||
| The launcher reads its configuration from `/tapp/user_config` as a TOML file. This is a change from the previous Python launcher which used a `.env`-style file. | ||
|
|
||
| ```toml | ||
| [launcher_config] | ||
| image_tags = ["latest"] | ||
| image_name = "nearone/mpc-node" | ||
| registry = "registry.hub.docker.com" | ||
| rpc_request_timeout_secs = 10 | ||
| rpc_request_interval_secs = 1 | ||
| rpc_max_attempts = 20 | ||
| # Optional: force selection of a specific digest (must be in approved list) | ||
| # mpc_hash_override = "sha256:abcd..." | ||
| port_mappings = [ | ||
| { host = 11780, container = 11780 }, | ||
| { host = 2200, container = 2200 }, | ||
| ] | ||
|
|
||
| # Opaque MPC node configuration. | ||
| # The launcher does not interpret these fields — they are re-serialized | ||
| # to TOML and mounted into the container at /mnt/shared/mpc-config.toml | ||
| # for the MPC binary to consume via `start-with-config-file`. | ||
| [mpc_node_config] | ||
| # ... any fields the MPC node expects | ||
| ``` | ||
|
|
||
| ### `[launcher_config]` | ||
|
|
||
| | Field | Required | Description | | ||
| |-------|----------|-------------| | ||
| | `image_tags` | Yes | Docker image tags to search, e.g. `["3.7.0"]` | | ||
| | `image_name` | Yes | Docker image name, e.g. `"nearone/mpc-node"` | | ||
| | `registry` | Yes | Docker registry hostname, e.g. `"registry.hub.docker.com"` | | ||
| | `rpc_request_timeout_secs` | Yes | Per-request timeout for registry API calls (seconds) | | ||
| | `rpc_request_interval_secs` | Yes | Initial retry interval for registry API calls (seconds) | | ||
| | `rpc_max_attempts` | Yes | Maximum registry API retry attempts | | ||
| | `mpc_hash_override` | No | Force a specific `sha256:` digest (must appear in approved list) | | ||
| | `port_mappings` | Yes | Port mappings forwarded to the MPC container (`{ host, container }` pairs) | | ||
|
|
||
| ### `[mpc_node_config]` | ||
|
|
||
| Arbitrary TOML table passed through to the MPC node. The launcher writes this verbatim to `/mnt/shared/mpc-config.toml`, which the container reads on startup. | ||
|
|
||
| ## Image Hash Selection | ||
|
|
||
| Priority order: | ||
| 1. If the approved hashes file (`/mnt/shared/image-digest.bin`) exists and `mpc_hash_override` is set: use the override (must be in the approved list) | ||
| 2. If the approved hashes file exists: use the newest approved hash (first in list) | ||
| 3. If the file is absent: fall back to `DEFAULT_IMAGE_DIGEST` | ||
|
|
||
| ## File Locations | ||
|
|
||
| | Path | Description | | ||
| |------|-------------| | ||
| | `/tapp/user_config` | TOML configuration file | | ||
| | `/mnt/shared/image-digest.bin` | JSON file with approved image hashes (written by the MPC node) | | ||
| | `/mnt/shared/mpc-config.toml` | MPC node config (written by the launcher) | | ||
| | `/var/run/dstack.sock` | dstack unix socket (TEE mode only) | | ||
|
|
||
| ## Key Differences from the Python Launcher | ||
|
|
||
| | Aspect | Python (`launcher.py`) | Rust (`tee-launcher`) | | ||
| |--------|----------------------|----------------------| | ||
| | Config format | `.env` key-value file | TOML | | ||
| | MPC node config | Environment variables passed to container | TOML file mounted into container | | ||
| | Container launch | `docker run` with flags | `docker compose up -d` with rendered template | | ||
| | RTMR3 extension | `curl` to unix socket | `dstack-sdk` native client | | ||
|
|
||
| ## Building | ||
|
|
||
| ```bash | ||
| cargo build -p tee-launcher --profile=reproducible | ||
| ``` | ||
gilcu3 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ## Testing | ||
|
|
||
| ```bash | ||
| # Unit tests | ||
| cargo nextest run --cargo-profile=test-release -p tee-launcher | ||
|
|
||
| # Integration tests (requires network access and Docker Hub) | ||
| cargo nextest run --cargo-profile=test-release -p tee-launcher --features external-services-tests | ||
| ``` | ||
barakeinav1 marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| services: | ||
| mpc-node: | ||
| image: "{{IMAGE_NAME}}@{{MANIFEST_DIGEST}}" | ||
| container_name: "{{CONTAINER_NAME}}" | ||
| security_opt: | ||
| - no-new-privileges:true | ||
| ports: {{PORTS}} | ||
| environment: | ||
| - "DSTACK_ENDPOINT={{DSTACK_UNIX_SOCKET}}" | ||
| volumes: | ||
| - /tapp:/tapp:ro | ||
| - shared-volume:/mnt/shared | ||
| - mpc-data:/data | ||
| - "{{DSTACK_UNIX_SOCKET}}:{{DSTACK_UNIX_SOCKET}}" | ||
| command: ["/app/mpc-node", "start-with-config-file", "{{MPC_CONFIG_SHARED_PATH}}"] | ||
|
|
||
| volumes: | ||
| shared-volume: | ||
| name: shared-volume | ||
| mpc-data: | ||
| name: mpc-data |
gilcu3 marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| services: | ||
| mpc-node: | ||
| image: "{{IMAGE_NAME}}@{{MANIFEST_DIGEST}}" | ||
| container_name: "{{CONTAINER_NAME}}" | ||
| security_opt: | ||
| - no-new-privileges:true | ||
| ports: {{PORTS}} | ||
| volumes: | ||
| - /tapp:/tapp:ro | ||
| - shared-volume:/mnt/shared | ||
| - mpc-data:/data | ||
| command: ["/app/mpc-node", "start-with-config-file", "{{MPC_CONFIG_SHARED_PATH}}"] | ||
|
|
||
| volumes: | ||
| shared-volume: | ||
| name: shared-volume | ||
| mpc-data: | ||
| name: mpc-data |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| pub(crate) const MPC_CONTAINER_NAME: &str = "mpc-node"; | ||
| pub(crate) const IMAGE_DIGEST_FILE: &str = "/mnt/shared/image-digest.bin"; | ||
| pub(crate) const DSTACK_UNIX_SOCKET: &str = "/var/run/dstack.sock"; | ||
| pub(crate) const DSTACK_USER_CONFIG_FILE: &str = "/tapp/user_config"; | ||
|
|
||
| /// Path on the shared volume where the launcher writes the MPC config and the | ||
| /// MPC container reads it. Both containers mount `shared-volume` at `/mnt/shared`. | ||
| pub(crate) const MPC_CONFIG_SHARED_PATH: &str = "/mnt/shared/mpc-config.toml"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| use launcher_interface::types::DockerSha256Digest; | ||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| /// Partial response <https://auth.docker.io/token> | ||
| /// | ||
| /// `Debug` is manually implemented to redact the bearer token from logs. | ||
| #[derive(Deserialize)] | ||
| pub(crate) struct DockerTokenResponse { | ||
| pub(crate) token: String, | ||
| } | ||
|
|
||
| impl std::fmt::Debug for DockerTokenResponse { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.debug_struct("DockerTokenResponse") | ||
| .field("token", &"[REDACTED]") | ||
| .finish() | ||
| } | ||
| } | ||
|
|
||
| /// Response from `GET /v2/{name}/manifests/{reference}`. | ||
| /// | ||
| /// The `mediaType` field determines the variant: | ||
| /// - OCI image index → multi-platform manifest with a list of platform entries | ||
| /// - Docker V2 / OCI manifest → single-platform manifest with a config digest | ||
| #[derive(Debug, Deserialize, Serialize)] | ||
| #[serde(tag = "mediaType")] | ||
| pub(crate) enum ManifestResponse { | ||
| /// Multi-platform manifest (OCI image index). | ||
| #[serde(rename = "application/vnd.oci.image.index.v1+json")] | ||
| ImageIndex { manifests: Vec<ManifestEntry> }, | ||
|
|
||
| /// Single-platform Docker V2 manifest. | ||
| #[serde(rename = "application/vnd.docker.distribution.manifest.v2+json")] | ||
| DockerV2 { config: ManifestConfig }, | ||
|
|
||
| /// Single-platform OCI manifest. | ||
| #[serde(rename = "application/vnd.oci.image.manifest.v1+json")] | ||
| OciManifest { config: ManifestConfig }, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize, Serialize)] | ||
| pub(crate) struct ManifestEntry { | ||
| pub(crate) digest: String, | ||
| pub(crate) platform: ManifestPlatform, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] | ||
| pub(crate) struct ManifestPlatform { | ||
| pub(crate) architecture: String, | ||
| pub(crate) os: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize, Serialize)] | ||
| pub(crate) struct ManifestConfig { | ||
| pub(crate) digest: DockerSha256Digest, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use assert_matches::assert_matches; | ||
|
|
||
| use super::*; | ||
|
|
||
| fn sample_digest_str() -> String { | ||
| format!("sha256:{}", "ab".repeat(32)) | ||
| } | ||
|
|
||
| #[test] | ||
| fn image_index_deserializes() { | ||
| // given | ||
| let json = serde_json::json!({ | ||
| "mediaType": "application/vnd.oci.image.index.v1+json", | ||
| "manifests": [ | ||
| { | ||
| "digest": "sha256:abc123", | ||
| "platform": { "architecture": "amd64", "os": "linux" } | ||
| }, | ||
| { | ||
| "digest": "sha256:def456", | ||
| "platform": { "architecture": "arm64", "os": "linux" } | ||
| } | ||
| ] | ||
| }); | ||
|
|
||
| // when | ||
| let result = serde_json::from_value::<ManifestResponse>(json); | ||
|
|
||
| // then | ||
| assert_matches!(result, Ok(ManifestResponse::ImageIndex { manifests }) => { | ||
| assert_eq!(manifests.len(), 2); | ||
| assert_eq!(manifests[0].platform, ManifestPlatform { | ||
| architecture: "amd64".into(), | ||
| os: "linux".into(), | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn docker_v2_manifest_deserializes() { | ||
| // given | ||
| let json = serde_json::json!({ | ||
| "mediaType": "application/vnd.docker.distribution.manifest.v2+json", | ||
| "config": { "digest": sample_digest_str() } | ||
| }); | ||
|
|
||
| // when | ||
| let result = serde_json::from_value::<ManifestResponse>(json); | ||
|
|
||
| // then | ||
| assert_matches!(result, Ok(ManifestResponse::DockerV2 { config }) => { | ||
| assert_eq!(config.digest.to_string(), sample_digest_str()); | ||
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn oci_manifest_deserializes() { | ||
| // given | ||
| let json = serde_json::json!({ | ||
| "mediaType": "application/vnd.oci.image.manifest.v1+json", | ||
| "config": { "digest": sample_digest_str() } | ||
| }); | ||
|
|
||
| // when | ||
| let result = serde_json::from_value::<ManifestResponse>(json); | ||
|
|
||
| // then | ||
| assert_matches!(result, Ok(ManifestResponse::OciManifest { config }) => { | ||
| assert_eq!(config.digest.to_string(), sample_digest_str()); | ||
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn unknown_media_type_is_rejected() { | ||
| // given | ||
| let json = serde_json::json!({ | ||
| "mediaType": "application/vnd.unknown.format", | ||
| "config": { "digest": sample_digest_str() } | ||
| }); | ||
|
|
||
| // when | ||
| let result = serde_json::from_value::<ManifestResponse>(json); | ||
|
|
||
| // then | ||
| assert_matches!(result, Err(_)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn docker_token_response_deserializes() { | ||
| // given | ||
| let json = serde_json::json!({ "token": "abc.def.ghi" }); | ||
|
|
||
| // when | ||
| let result = serde_json::from_value::<DockerTokenResponse>(json); | ||
|
|
||
| // then | ||
| assert_matches!(result, Ok(resp) => { | ||
| assert_eq!(resp.token, "abc.def.ghi"); | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are we sure this change does not introduce new attack vectors?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you mean the 3 env below? - we have them also before. there are part of the measured launcher compose file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant the change from using
docker runto usingdocker composeUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about this, need to investigated