diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06ac38097..7f30b0802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: docker-launcher-build-and-verify: name: "Build MPC Launcher Docker image and verify" - runs-on: warp-ubuntu-2404-x64-2x + runs-on: warp-ubuntu-2404-x64-8x timeout-minutes: 60 permissions: contents: read @@ -75,10 +75,16 @@ jobs: with: persist-credentials: false - - name: Install skopeo + - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y skopeo + sudo apt-get install -y skopeo liblzma-dev + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-provider: "warpbuild" - name: Build launcher docker image and verify its hash shell: bash diff --git a/.github/workflows/docker_build_launcher.yml b/.github/workflows/docker_build_launcher.yml index 2cab91641..b0d69d76f 100644 --- a/.github/workflows/docker_build_launcher.yml +++ b/.github/workflows/docker_build_launcher.yml @@ -13,7 +13,7 @@ on: jobs: build-and-push-images: name: "Build and push Docker launcher image with commit hash" - runs-on: warp-ubuntu-2404-x64-2x + runs-on: warp-ubuntu-2404-x64-8x permissions: contents: read @@ -23,17 +23,23 @@ jobs: with: persist-credentials: false + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y skopeo liblzma-dev + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-provider: "warpbuild" + - name: Login to Docker Hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Install skopeo - run: | - sudo apt-get update - sudo apt-get install -y skopeo - - name: Build and push launcher image run: | export LAUNCHER_IMAGE_NAME=mpc-launcher diff --git a/Cargo.lock b/Cargo.lock index 0d4b1757b..2cbcd7bee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4937,6 +4937,20 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "launcher-interface" +version = "3.6.0" +dependencies = [ + "assert_matches", + "derive_more 2.1.1", + "insta", + "mpc-primitives", + "near-mpc-bounded-collections", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -5385,6 +5399,7 @@ dependencies = [ "derive_more 2.1.1", "hex", "include-measurements", + "launcher-interface", "mpc-primitives", "serde", "serde_json", @@ -10630,6 +10645,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "tee-launcher" +version = "3.6.0" +dependencies = [ + "assert_matches", + "backon", + "clap", + "dstack-sdk", + "launcher-interface", + "near-mpc-bounded-collections", + "reqwest 0.12.28", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 1.0.6+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "tempfile" version = "3.26.0" diff --git a/Cargo.toml b/Cargo.toml index 9dc34786e..343e0a959 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/foreign-chain-inspector", "crates/foreign-chain-rpc-interfaces", "crates/include-measurements", + "crates/launcher-interface", "crates/mpc-attestation", "crates/near-mpc-bounded-collections", "crates/near-mpc-contract-interface", @@ -23,6 +24,7 @@ members = [ "crates/node-types", "crates/primitives", "crates/tee-authority", + "crates/tee-launcher", "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-utils", @@ -44,6 +46,7 @@ contract-history = { path = "crates/contract-history" } foreign-chain-inspector = { path = "crates/foreign-chain-inspector" } foreign-chain-rpc-interfaces = { path = "crates/foreign-chain-rpc-interfaces" } include-measurements = { path = "crates/include-measurements" } +launcher-interface = { path = "crates/launcher-interface" } mpc-attestation = { path = "crates/mpc-attestation" } mpc-contract = { path = "crates/contract", features = ["dev-utils"] } mpc-node = { path = "crates/node" } diff --git a/crates/launcher-interface/Cargo.toml b/crates/launcher-interface/Cargo.toml new file mode 100644 index 000000000..db901977d --- /dev/null +++ b/crates/launcher-interface/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "launcher-interface" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +derive_more = { workspace = true } +mpc-primitives = { workspace = true } +near-mpc-bounded-collections = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +insta = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/launcher-interface/src/lib.rs b/crates/launcher-interface/src/lib.rs new file mode 100644 index 000000000..f8c0d456a --- /dev/null +++ b/crates/launcher-interface/src/lib.rs @@ -0,0 +1,176 @@ +pub const MPC_IMAGE_HASH_EVENT: &str = "mpc-image-digest"; + +pub mod types { + use std::fmt; + use std::str::FromStr; + + use mpc_primitives::hash::MpcDockerImageHash; + use serde::{Deserialize, Serialize}; + + /// JSON structure for the approved hashes file written by the MPC node, and read by the launcher. + #[derive(Debug, Serialize, Deserialize)] + pub struct ApprovedHashes { + pub approved_hashes: near_mpc_bounded_collections::NonEmptyVec, + } + + impl ApprovedHashes { + pub fn newest_approved_hash(&self) -> &DockerSha256Digest { + self.approved_hashes.first() + } + } + + const SHA256_PREFIX: &str = "sha256:"; + + #[derive(Debug, Clone, PartialEq, Eq, derive_more::From)] + pub struct DockerSha256Digest(MpcDockerImageHash); + + impl fmt::Display for DockerSha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{SHA256_PREFIX}{}", self.0.as_hex()) + } + } + + #[derive(Debug, thiserror::Error)] + pub enum DockerDigestParseError { + #[error("missing {SHA256_PREFIX} prefix")] + MissingPrefix, + #[error(transparent)] + InvalidHash(#[from] mpc_primitives::hash::Hash32ParseError), + } + + impl DockerSha256Digest { + pub fn as_raw_hex(&self) -> String { + self.0.as_hex() + } + } + + impl FromStr for DockerSha256Digest { + type Err = DockerDigestParseError; + + fn from_str(s: &str) -> Result { + let hex_str = s + .strip_prefix(SHA256_PREFIX) + .ok_or(DockerDigestParseError::MissingPrefix)?; + Ok(DockerSha256Digest(hex_str.parse()?)) + } + } + + impl Serialize for DockerSha256Digest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for DockerSha256Digest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } + } +} + +mod paths {} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + + use super::types::{ApprovedHashes, DockerDigestParseError, DockerSha256Digest}; + use mpc_primitives::hash::MpcDockerImageHash; + + fn sample_digest() -> DockerSha256Digest { + let hash: MpcDockerImageHash = [0xab; 32].into(); + DockerSha256Digest::from(hash) + } + + #[test] + fn serialize_docker_digest() { + let digest = sample_digest(); + let json = serde_json::to_value(&digest).unwrap(); + insta::assert_json_snapshot!("docker_digest", json); + } + + #[test] + fn roundtrip_docker_digest() { + let digest = sample_digest(); + let serialized = serde_json::to_string(&digest).unwrap(); + let deserialized: DockerSha256Digest = serde_json::from_str(&serialized).unwrap(); + insta::assert_json_snapshot!( + "docker_digest_roundtrip", + serde_json::to_value(&deserialized).unwrap() + ); + } + + #[test] + fn deserialize_rejects_missing_prefix() { + let json = serde_json::json!( + "abababababababababababababababababababababababababababababababababab" + ); + assert_matches!( + serde_json::from_value::(json), + Err(ref e) if e.to_string().contains("missing sha256: prefix") + ); + } + + #[test] + fn deserialize_rejects_invalid_hex() { + let json = serde_json::json!("sha256:not_valid_hex!"); + assert_matches!(serde_json::from_value::(json), Err(_)); + } + + #[test] + fn deserialize_rejects_wrong_length() { + let json = serde_json::json!("sha256:abab"); + assert_matches!(serde_json::from_value::(json), Err(_)); + } + + #[test] + fn display_docker_digest() { + let digest = sample_digest(); + insta::assert_snapshot!("docker_digest_display", digest.to_string()); + } + + #[test] + fn parse_docker_digest() { + let input = "sha256:abababababababababababababababababababababababababababababababab"; + let parsed: DockerSha256Digest = input.parse().unwrap(); + assert_eq!(parsed.to_string(), input); + } + + #[test] + fn parse_rejects_missing_prefix() { + let result = "abababababababababababababababababababababababababababababababababab" + .parse::(); + assert_matches!(result, Err(DockerDigestParseError::MissingPrefix)); + } + + #[test] + fn parse_rejects_invalid_hex() { + let result = "sha256:not_valid_hex!".parse::(); + assert_matches!(result, Err(DockerDigestParseError::InvalidHash(_))); + } + + #[test] + fn parse_rejects_wrong_length() { + let result = "sha256:abab".parse::(); + assert_matches!(result, Err(DockerDigestParseError::InvalidHash(_))); + } + + #[test] + fn serialize_approved_hashes_file() { + let file = ApprovedHashes { + approved_hashes: near_mpc_bounded_collections::NonEmptyVec::from_vec(vec![ + sample_digest(), + ]) + .unwrap(), + }; + let json = serde_json::to_value(&file).unwrap(); + insta::assert_json_snapshot!("approved_hashes_file", json); + } +} diff --git a/crates/launcher-interface/src/snapshots/launcher_interface__tests__approved_hashes_file.snap b/crates/launcher-interface/src/snapshots/launcher_interface__tests__approved_hashes_file.snap new file mode 100644 index 000000000..61f7f301c --- /dev/null +++ b/crates/launcher-interface/src/snapshots/launcher_interface__tests__approved_hashes_file.snap @@ -0,0 +1,9 @@ +--- +source: crates/launcher-interface/src/lib.rs +expression: json +--- +{ + "approved_hashes": [ + "sha256:abababababababababababababababababababababababababababababababab" + ] +} diff --git a/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest.snap b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest.snap new file mode 100644 index 000000000..268452a91 --- /dev/null +++ b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest.snap @@ -0,0 +1,5 @@ +--- +source: crates/launcher-interface/src/lib.rs +expression: json +--- +"sha256:abababababababababababababababababababababababababababababababab" diff --git a/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_display.snap b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_display.snap new file mode 100644 index 000000000..44b276fe5 --- /dev/null +++ b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_display.snap @@ -0,0 +1,5 @@ +--- +source: crates/launcher-interface/src/lib.rs +expression: digest.to_string() +--- +sha256:abababababababababababababababababababababababababababababababab diff --git a/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_roundtrip.snap b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_roundtrip.snap new file mode 100644 index 000000000..568aec643 --- /dev/null +++ b/crates/launcher-interface/src/snapshots/launcher_interface__tests__docker_digest_roundtrip.snap @@ -0,0 +1,5 @@ +--- +source: crates/launcher-interface/src/lib.rs +expression: "serde_json::to_value(&deserialized).unwrap()" +--- +"sha256:abababababababababababababababababababababababababababababababab" diff --git a/crates/mpc-attestation/Cargo.toml b/crates/mpc-attestation/Cargo.toml index 3d41c2776..7922a8230 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -13,6 +13,7 @@ borsh = { workspace = true } derive_more = { workspace = true } hex = { workspace = true } include-measurements = { workspace = true } +launcher-interface = { workspace = true } mpc-primitives = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index e463e6b68..b1fec58ed 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -13,14 +13,13 @@ pub use attestation::measurements::ExpectedMeasurements; use mpc_primitives::hash::{LauncherDockerComposeHash, MpcDockerImageHash}; use borsh::{BorshDeserialize, BorshSerialize}; +use launcher_interface::MPC_IMAGE_HASH_EVENT; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use crate::alloc::format; use crate::alloc::string::ToString; -const MPC_IMAGE_HASH_EVENT: &str = "mpc-image-digest"; - // TODO(#1639): extract timestamp from certificate itself pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24 * 7; // 7 days diff --git a/crates/near-mpc-bounded-collections/src/bounded_vec.rs b/crates/near-mpc-bounded-collections/src/bounded_vec.rs index da327888f..c41d83b42 100644 --- a/crates/near-mpc-bounded-collections/src/bounded_vec.rs +++ b/crates/near-mpc-bounded-collections/src/bounded_vec.rs @@ -1,5 +1,6 @@ use std::{ convert::{TryFrom, TryInto}, + ops::Deref, slice::{Iter, IterMut}, vec, }; @@ -20,6 +21,14 @@ pub struct BoundedVec Deref for BoundedVec { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + /// BoundedVec errors #[derive(Error, PartialEq, Eq, Debug, Clone)] pub enum BoundedVecOutOfBounds { diff --git a/crates/tee-launcher/Cargo.toml b/crates/tee-launcher/Cargo.toml new file mode 100644 index 000000000..e0bc44094 --- /dev/null +++ b/crates/tee-launcher/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "tee-launcher" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[[bin]] +name = "tee-launcher" +path = "src/main.rs" + +[features] +integration-test = [] + +[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 } + +[lints] +workspace = true diff --git a/crates/tee-launcher/docker-compose.tee.template.yml b/crates/tee-launcher/docker-compose.tee.template.yml new file mode 100644 index 000000000..6312625e1 --- /dev/null +++ b/crates/tee-launcher/docker-compose.tee.template.yml @@ -0,0 +1,20 @@ +services: + mpc-node: + image: "{{IMAGE}}" + container_name: "{{CONTAINER_NAME}}" + security_opt: + - no-new-privileges:true + ports: {{PORTS}} + environment: + - "DSTACK_ENDPOINT={{DSTACK_UNIX_SOCKET}}" + volumes: + - "{{MPC_CONFIG_HOST_PATH}}:{{MPC_CONFIG_CONTAINER_PATH}}:ro" + - /tapp:/tapp:ro + - shared-volume:/mnt/shared + - mpc-data:/data + - "{{DSTACK_UNIX_SOCKET}}:{{DSTACK_UNIX_SOCKET}}" + command: ["start-with-config-file", "{{MPC_CONFIG_CONTAINER_PATH}}"] + +volumes: + shared-volume: + mpc-data: diff --git a/crates/tee-launcher/docker-compose.template.yml b/crates/tee-launcher/docker-compose.template.yml new file mode 100644 index 000000000..29b44651e --- /dev/null +++ b/crates/tee-launcher/docker-compose.template.yml @@ -0,0 +1,17 @@ +services: + mpc-node: + image: "{{IMAGE}}" + container_name: "{{CONTAINER_NAME}}" + security_opt: + - no-new-privileges:true + ports: {{PORTS}} + volumes: + - "{{MPC_CONFIG_HOST_PATH}}:{{MPC_CONFIG_CONTAINER_PATH}}:ro" + - /tapp:/tapp:ro + - shared-volume:/mnt/shared + - mpc-data:/data + command: ["start-with-config-file", "{{MPC_CONFIG_CONTAINER_PATH}}"] + +volumes: + shared-volume: + mpc-data: diff --git a/crates/tee-launcher/src/constants.rs b/crates/tee-launcher/src/constants.rs new file mode 100644 index 000000000..9cdf69794 --- /dev/null +++ b/crates/tee-launcher/src/constants.rs @@ -0,0 +1,7 @@ +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 inside the container where the MPC config file is bind-mounted. +pub(crate) const MPC_CONFIG_CONTAINER_PATH: &str = "/mnt/shared/mpc-config"; diff --git a/crates/tee-launcher/src/docker_types.rs b/crates/tee-launcher/src/docker_types.rs new file mode 100644 index 000000000..6c3bf27ce --- /dev/null +++ b/crates/tee-launcher/src/docker_types.rs @@ -0,0 +1,150 @@ +use launcher_interface::types::DockerSha256Digest; +use serde::{Deserialize, Serialize}; + +/// Partial response +#[derive(Debug, Deserialize, Serialize)] +pub struct DockerTokenResponse { + pub token: String, +} + +/// 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 enum ManifestResponse { + /// Multi-platform manifest (OCI image index). + #[serde(rename = "application/vnd.oci.image.index.v1+json")] + ImageIndex { manifests: Vec }, + + /// 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 struct ManifestEntry { + pub digest: String, + pub platform: ManifestPlatform, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ManifestPlatform { + pub architecture: String, + pub os: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ManifestConfig { + pub 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::(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::(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::(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::(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::(json); + + // then + assert_matches!(result, Ok(resp) => { + assert_eq!(resp.token, "abc.def.ghi"); + }); + } +} diff --git a/crates/tee-launcher/src/error.rs b/crates/tee-launcher/src/error.rs new file mode 100644 index 000000000..03295a6c8 --- /dev/null +++ b/crates/tee-launcher/src/error.rs @@ -0,0 +1,85 @@ +use launcher_interface::types::DockerSha256Digest; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LauncherError { + #[error("EmitEvent failed while extending RTMR3: {0}")] + DstackEmitEventFailed(String), + + #[error("MPC_HASH_OVERRIDE invalid: {0}")] + InvalidHashOverride(String), + + #[error("Image hash not found among tags")] + ImageHashNotFoundAmongTags, + + #[error("Failed to get auth token from registry: {0}")] + RegistryAuthFailed(String), + + #[error("docker run failed for validated hash")] + DockerRunFailed { + image_hash: DockerSha256Digest, + inner: std::io::Error, + }, + + #[error("docker run failed for validated hash")] + DockerRunFailedExitStatus { + image_hash: DockerSha256Digest, + output: String, + }, + + #[error("Failed to read {path}: {source}")] + FileRead { + path: String, + source: std::io::Error, + }, + + #[error("Failed to write {path}: {source}")] + FileWrite { + path: String, + source: std::io::Error, + }, + + #[error("Failed to create temp file: {0}")] + TempFileCreate(std::io::Error), + + #[error("Failed to parse {path}: {source}")] + JsonParse { + path: String, + source: serde_json::Error, + }, + + #[error("Failed to parse {path}: {source}")] + TomlParse { + path: String, + source: toml::de::Error, + }, + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("Registry response parse error: {0}")] + RegistryResponseParse(String), + + #[error("Invalid manifest URL: {0}")] + InvalidManifestUrl(String), + + #[error("The selected image failed digest validation: {0}")] + ImageDigestValidationFailed(#[from] ImageDigestValidationFailed), +} + +#[derive(Error, Debug)] +pub enum ImageDigestValidationFailed { + #[error("manifest digest lookup failed: {0}")] + ManifestDigestLookupFailed(String), + #[error("docker pull failed for {0}")] + DockerPullFailed(String), + #[error("docker inspect failed for {0}")] + DockerInspectFailed(String), + #[error( + "pulled image has mismatching digest. pulled: {pulled_digest}, expected: {expected_digest}" + )] + PulledImageHasMismatchedDigest { + expected_digest: DockerSha256Digest, + pulled_digest: DockerSha256Digest, + }, +} diff --git a/crates/tee-launcher/src/main.rs b/crates/tee-launcher/src/main.rs new file mode 100644 index 000000000..cb670a47d --- /dev/null +++ b/crates/tee-launcher/src/main.rs @@ -0,0 +1,759 @@ +use std::io::Write; +use std::process::Command; +use std::{collections::VecDeque, time::Duration}; + +use backon::{ExponentialBuilder, Retryable}; +use clap::Parser; +use launcher_interface::MPC_IMAGE_HASH_EVENT; +use launcher_interface::types::{ApprovedHashes, DockerSha256Digest}; + +use constants::*; +use docker_types::*; +use error::*; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}; + +use types::*; +use url::Url; + +mod constants; +mod docker_types; +mod error; +mod types; + +const COMPOSE_TEMPLATE: &str = include_str!("../docker-compose.template.yml"); +const COMPOSE_TEE_TEMPLATE: &str = include_str!("../docker-compose.tee.template.yml"); + +const DOCKER_AUTH_ACCEPT_HEADER_VALUE: HeaderValue = + HeaderValue::from_static("application/vnd.docker.distribution.manifest.v2+json"); + +const DOCKER_CONTENT_DIGEST_HEADER: &str = "Docker-Content-Digest"; + +const AMD64: &str = "amd64"; +const LINUX: &str = "linux"; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .init(); + + if let Err(e) = run().await { + tracing::error!("Error: {e}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), LauncherError> { + tracing::info!("start"); + + let args = CliArgs::parse(); + + tracing::info!(platform = ?args.platform, "starting launcher"); + + // Load dstack user config (TOML) + let config_contents = std::fs::read_to_string(DSTACK_USER_CONFIG_FILE).map_err(|source| { + LauncherError::FileRead { + path: DSTACK_USER_CONFIG_FILE.to_string(), + source, + } + })?; + + let dstack_config: Config = + toml::from_str(&config_contents).map_err(|source| LauncherError::TomlParse { + path: DSTACK_USER_CONFIG_FILE.to_string(), + source, + })?; + + let approved_hashes_file = std::fs::OpenOptions::new() + .read(true) + .open(IMAGE_DIGEST_FILE) + .map_err(|source| LauncherError::FileRead { + path: IMAGE_DIGEST_FILE.to_string(), + source, + }); + + let approved_hashes_on_disk: Option = match approved_hashes_file { + Err(err) => { + tracing::warn!( + ?err, + default_image_digest = ?args.default_image_digest, + "approved hashes file does not exist on disk, falling back to default digest" + ); + None + } + Ok(file) => { + let parsed: ApprovedHashes = + serde_json::from_reader(file).map_err(|source| LauncherError::JsonParse { + path: IMAGE_DIGEST_FILE.to_string(), + source, + })?; + Some(parsed) + } + }; + + let image_hash = select_image_hash( + approved_hashes_on_disk.as_ref(), + &args.default_image_digest, + dstack_config.launcher_config.mpc_hash_override.as_ref(), + )?; + + let () = validate_image_hash(&dstack_config.launcher_config, image_hash.clone()).await?; + + let should_extend_rtmr_3 = args.platform == Platform::Tee; + + if should_extend_rtmr_3 { + let dstack_client = dstack_sdk::dstack_client::DstackClient::new(Some(DSTACK_UNIX_SOCKET)); + + // EmitEvent with the image digest + dstack_client + .emit_event( + MPC_IMAGE_HASH_EVENT.to_string(), + // TODO: mpc binary has to go back from back hex as well. Just send the raw bytes as payload. + image_hash.as_raw_hex().as_bytes().to_vec(), + ) + .await + .map_err(|e| LauncherError::DstackEmitEventFailed(e.to_string()))?; + } + + let mpc_binary_config_path = std::path::Path::new("/tmp/mpc-config"); + let mpc_config_toml = toml::to_string(&dstack_config.mpc_config) + .expect("re-serializing a toml::Table always succeeds"); + std::fs::write(mpc_binary_config_path, mpc_config_toml.as_bytes()).map_err(|source| { + LauncherError::FileWrite { + path: mpc_binary_config_path.display().to_string(), + source, + } + })?; + + launch_mpc_container( + args.platform, + &image_hash, + mpc_binary_config_path, + &dstack_config.docker_command_config, + )?; + + Ok(()) +} + +/// Select which image hash to use, given the approved hashes file (if present), +/// a fallback default digest, and an optional user override. +/// +/// Selection rules: +/// - If the approved hashes file is absent → use `default_digest` +/// - If `override_hash` is set and appears in the approved list → use it +/// - If `override_hash` is set but NOT in the approved list → error +/// - Otherwise → use the newest approved hash (first in the list) +fn select_image_hash( + approved_hashes: Option<&ApprovedHashes>, + default_digest: &DockerSha256Digest, + override_hash: Option<&DockerSha256Digest>, +) -> Result { + let Some(approved) = approved_hashes else { + tracing::info!("no approved hashes file, using default digest"); + return Ok(default_digest.clone()); + }; + + if let Some(override_image) = override_hash { + tracing::info!(?override_image, "override mpc image hash provided"); + if !approved.approved_hashes.contains(override_image) { + return Err(LauncherError::InvalidHashOverride(format!( + "MPC_HASH_OVERRIDE={override_image} does not match any approved hash", + ))); + } + return Ok(override_image.clone()); + } + + let selected = approved.newest_approved_hash().clone(); + tracing::info!(?selected, "selected newest approved hash"); + Ok(selected) +} + +async fn get_manifest_digest( + config: &LauncherConfig, + expected_image_digest: &DockerSha256Digest, +) -> Result { + let mut tags: VecDeque = config.image_tags.iter().cloned().collect(); + + // We need an authorization token to fetch manifests. + // TODO: this still has the registry hard-coded in the url. also, if we use a different registry, we need a different auth-endpoint + let token_url = format!( + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:{}:pull", + config.image_name + ); + + let reqwest_client = reqwest::Client::new(); + + let token_request_response = reqwest_client + .get(token_url) + .send() + .await + .map_err(|e| LauncherError::RegistryAuthFailed(e.to_string()))?; + + let status = token_request_response.status(); + if !status.is_success() { + return Err(LauncherError::RegistryAuthFailed(format!( + "token request returned non-success status: {status}" + ))); + } + + let token_response: DockerTokenResponse = token_request_response + .json() + .await + .map_err(|e| LauncherError::RegistryAuthFailed(e.to_string()))?; + + while let Some(tag) = tags.pop_front() { + let manifest_url: Url = format!( + "https://{}/v2/{}/manifests/{tag}", + config.registry, config.image_name + ) + .parse() + .map_err(|_| { + LauncherError::InvalidManifestUrl(format!( + "https://{}/v2/{}/manifests/{tag}", + config.registry, config.image_name + )) + })?; + + let authorization_value: HeaderValue = format!("Bearer {}", token_response.token) + .parse() + .expect("bearer token received from docker auth is a valid header value"); + + let headers = HeaderMap::from_iter([ + (ACCEPT, DOCKER_AUTH_ACCEPT_HEADER_VALUE), + (AUTHORIZATION, authorization_value), + ]); + + let request_timeout = Duration::from_secs(config.rpc_request_timeout_secs); + let backoff = ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(config.rpc_request_interval_secs)) + .with_factor(1.5) + .with_max_delay(Duration::from_secs(60)) + .with_max_times(config.rpc_max_attempts as usize); + + let request_future = || async { + reqwest_client + .get(manifest_url.clone()) + .headers(headers.clone()) + .timeout(request_timeout) + .send() + .await? + .error_for_status() + }; + + let request_with_retry_future = request_future + .retry(backoff) + .when(|_: &reqwest::Error| true) + .notify(|err, dur| { + tracing::warn!( + ?manifest_url, + ?dur, + ?err, + "failed to fetch manifest, retrying" + ); + }); + + let Ok(resp) = request_with_retry_future.await else { + tracing::warn!( + ?manifest_url, + "exceeded max RPC attempts. \ + Will continue in the hopes of finding the matching image hash among remaining tags" + ); + continue; + }; + + let response_headers = resp.headers().clone(); + let manifest: ManifestResponse = resp + .json() + .await + .map_err(|e| LauncherError::RegistryResponseParse(e.to_string()))?; + + match manifest { + ManifestResponse::ImageIndex { manifests } => { + // Multi-platform manifest; scan for amd64/linux + manifests + .into_iter() + .filter(|manifest| { + manifest.platform.architecture == AMD64 && manifest.platform.os == LINUX + }) + .for_each(|manifest| tags.push_back(manifest.digest)); + } + ManifestResponse::DockerV2 { config } | ManifestResponse::OciManifest { config } => { + if config.digest != *expected_image_digest { + continue; + } + + let Some(content_digest) = response_headers + .get(DOCKER_CONTENT_DIGEST_HEADER) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + else { + continue; + }; + + return Ok(content_digest); + } + } + } + + Err(LauncherError::ImageHashNotFoundAmongTags) +} + +/// Returns if the given image digest is valid (pull + manifest + digest match). +/// Does NOT extend RTMR3 and does NOT run the container. +async fn validate_image_hash( + launcher_config: &LauncherConfig, + image_hash: DockerSha256Digest, +) -> Result<(), ImageDigestValidationFailed> { + let manifest_digest = get_manifest_digest(launcher_config, &image_hash) + .await + .map_err(|e| ImageDigestValidationFailed::ManifestDigestLookupFailed(e.to_string()))?; + let image_name = &launcher_config.image_name; + + let name_and_digest = format!("{image_name}@{manifest_digest}"); + + // Pull + let pull = Command::new("docker") + .args(["pull", &name_and_digest]) + .output() + .map_err(|e| ImageDigestValidationFailed::DockerPullFailed(e.to_string()))?; + + let pull_failed = !pull.status.success(); + if pull_failed { + return Err(ImageDigestValidationFailed::DockerPullFailed( + "docker pull terminated with unsuccessful status".to_string(), + )); + } + + // Verify digest + let inspect = Command::new("docker") + .args([ + "image", + "inspect", + "--format", + "{{index .ID}}", + &name_and_digest, + ]) + .output() + .map_err(|e| ImageDigestValidationFailed::DockerInspectFailed(e.to_string()))?; + + let docker_inspect_failed = !inspect.status.success(); + if docker_inspect_failed { + return Err(ImageDigestValidationFailed::DockerInspectFailed( + "docker inspect terminated with unsuccessful status".to_string(), + )); + } + + let pulled_digest = String::from_utf8_lossy(&inspect.stdout) + .trim() + .to_string() + .parse() + .expect("is valid digest"); + + if pulled_digest != image_hash { + return Err( + ImageDigestValidationFailed::PulledImageHasMismatchedDigest { + pulled_digest, + expected_digest: image_hash, + }, + ); + } + + Ok(()) +} + +fn render_compose_file( + platform: Platform, + mpc_config_file: &std::path::Path, + docker_flags: &DockerLaunchFlags, + image_digest: &DockerSha256Digest, +) -> Result { + let template = match platform { + Platform::Tee => COMPOSE_TEE_TEMPLATE, + Platform::NonTee => COMPOSE_TEMPLATE, + }; + + let ports: Vec = docker_flags + .port_mappings + .ports + .iter() + .map(PortMapping::docker_compose_value) + .collect(); + let ports_json = serde_json::to_string(&ports).expect("port list is serializable"); + + let rendered = template + .replace("{{IMAGE}}", &image_digest.to_string()) + .replace("{{CONTAINER_NAME}}", MPC_CONTAINER_NAME) + .replace( + "{{MPC_CONFIG_HOST_PATH}}", + &mpc_config_file.display().to_string(), + ) + .replace("{{MPC_CONFIG_CONTAINER_PATH}}", MPC_CONFIG_CONTAINER_PATH) + .replace("{{DSTACK_UNIX_SOCKET}}", DSTACK_UNIX_SOCKET) + .replace("{{PORTS}}", &ports_json); + + tracing::info!(compose = %rendered, "rendered docker-compose file"); + + let mut file = tempfile::NamedTempFile::new().map_err(LauncherError::TempFileCreate)?; + file.write_all(rendered.as_bytes()) + .map_err(|source| LauncherError::FileWrite { + path: file.path().display().to_string(), + source, + })?; + + Ok(file) +} + +fn launch_mpc_container( + platform: Platform, + valid_hash: &DockerSha256Digest, + mpc_config_file: &std::path::Path, + docker_flags: &DockerLaunchFlags, +) -> Result<(), LauncherError> { + tracing::info!("Launching MPC node with validated hash: {valid_hash}",); + + let compose_file = render_compose_file(platform, mpc_config_file, docker_flags, valid_hash)?; + let compose_path = compose_file.path().display().to_string(); + + // Remove any existing container from a previous run (by name, independent of compose file) + let _ = Command::new("docker") + .args(["rm", "-f", MPC_CONTAINER_NAME]) + .output(); + + let run_output = Command::new("docker") + .args(["compose", "-f", &compose_path, "up", "-d"]) + .output() + .map_err(|inner| LauncherError::DockerRunFailed { + image_hash: valid_hash.clone(), + inner, + })?; + + if !run_output.status.success() { + let stderr = String::from_utf8_lossy(&run_output.stderr); + let stdout = String::from_utf8_lossy(&run_output.stdout); + tracing::error!(%stderr, %stdout, "docker compose up failed"); + return Err(LauncherError::DockerRunFailedExitStatus { + image_hash: valid_hash.clone(), + output: stderr.into_owned(), + }); + } + + tracing::info!("MPC launched successfully."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use assert_matches::assert_matches; + use launcher_interface::types::{ApprovedHashes, DockerSha256Digest}; + use near_mpc_bounded_collections::NonEmptyVec; + + use crate::constants::*; + use crate::error::LauncherError; + use crate::render_compose_file; + use crate::select_image_hash; + use crate::types::*; + + const SAMPLE_CONFIG_PATH: &str = "/tapp/mpc-config.json"; + + fn render( + platform: Platform, + config_path: &str, + flags: &DockerLaunchFlags, + digest: &DockerSha256Digest, + ) -> String { + let file = render_compose_file(platform, Path::new(config_path), flags, digest).unwrap(); + std::fs::read_to_string(file.path()).unwrap() + } + + fn digest(hex_char: char) -> DockerSha256Digest { + format!( + "sha256:{}", + std::iter::repeat_n(hex_char, 64).collect::() + ) + .parse() + .unwrap() + } + + fn sample_digest() -> DockerSha256Digest { + digest('a') + } + + fn approved_file(hashes: Vec) -> ApprovedHashes { + ApprovedHashes { + approved_hashes: NonEmptyVec::from_vec(hashes).unwrap(), + } + } + + fn empty_docker_flags() -> DockerLaunchFlags { + serde_json::from_value(serde_json::json!({ + "port_mappings": {"ports": []} + })) + .unwrap() + } + + fn docker_flags_with_port() -> DockerLaunchFlags { + serde_json::from_value(serde_json::json!({ + "port_mappings": {"ports": [{"src": 11780, "dst": 11780}]} + })) + .unwrap() + } + + #[test] + fn tee_mode_includes_dstack_env_and_volume() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::Tee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains(&format!("DSTACK_ENDPOINT={DSTACK_UNIX_SOCKET}"))); + assert!(rendered.contains(&format!("{DSTACK_UNIX_SOCKET}:{DSTACK_UNIX_SOCKET}"))); + } + + #[test] + fn nontee_mode_excludes_dstack() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(!rendered.contains("DSTACK_ENDPOINT")); + assert!(!rendered.contains(DSTACK_UNIX_SOCKET)); + } + + #[test] + fn includes_security_opts_and_required_volumes() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains("no-new-privileges:true")); + assert!(rendered.contains("/tapp:/tapp:ro")); + assert!(rendered.contains("shared-volume:/mnt/shared")); + assert!(rendered.contains("mpc-data:/data")); + assert!(rendered.contains(&format!("container_name: \"{MPC_CONTAINER_NAME}\""))); + } + + #[test] + fn mounts_config_file_read_only() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains(&format!( + "{SAMPLE_CONFIG_PATH}:{MPC_CONFIG_CONTAINER_PATH}:ro" + ))); + } + + #[test] + fn includes_start_with_config_file_command() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains("start-with-config-file")); + assert!(rendered.contains(MPC_CONFIG_CONTAINER_PATH)); + } + + #[test] + fn image_is_set() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains(&format!("image: \"{digest}\""))); + } + + #[test] + fn includes_ports() { + // given + let flags = docker_flags_with_port(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(rendered.contains("11780:11780")); + } + + #[test] + fn no_env_section_in_nontee_mode() { + // given + let flags = empty_docker_flags(); + let digest = sample_digest(); + + // when + let rendered = render(Platform::NonTee, SAMPLE_CONFIG_PATH, &flags, &digest); + + // then + assert!(!rendered.contains("environment:")); + } + + // --- select_image_hash --- + + #[test] + fn select_hash_override_present_and_in_approved_list() { + // given + let override_digest = digest('b'); + let approved = approved_file(vec![digest('c'), override_digest.clone(), digest('d')]); + + // when + let result = select_image_hash(Some(&approved), &digest('f'), Some(&override_digest)); + + // then + assert_matches!(result, Ok(selected) => { + assert_eq!(selected, override_digest); + }); + } + + #[test] + fn select_hash_override_not_in_approved_list() { + // given + let override_digest = digest('b'); + let approved = approved_file(vec![digest('c'), digest('d')]); + + // when + let result = select_image_hash(Some(&approved), &digest('f'), Some(&override_digest)); + + // then + assert_matches!(result, Err(LauncherError::InvalidHashOverride(_))); + } + + #[test] + fn select_hash_no_override_picks_newest() { + // given - first entry is "newest" + let newest = digest('a'); + let approved = approved_file(vec![newest.clone(), digest('b'), digest('c')]); + + // when + let result = select_image_hash(Some(&approved), &digest('f'), None); + + // then + assert_matches!(result, Ok(selected) => { + assert_eq!(selected, newest); + }); + } + + #[test] + fn select_hash_missing_file_falls_back_to_default() { + // given + let default = digest('d'); + + // when + let result = select_image_hash(None, &default, None); + + // then + assert_matches!(result, Ok(selected) => { + assert_eq!(selected, default); + }); + } + + #[test] + fn select_hash_missing_file_ignores_override() { + // given - override is set but file is missing, so default wins + let default = digest('d'); + let override_digest = digest('b'); + + // when + let result = select_image_hash(None, &default, Some(&override_digest)); + + // then + assert_matches!(result, Ok(selected) => { + assert_eq!(selected, default); + }); + } + + // --- approved_hashes JSON key alignment --- + + #[test] + fn approved_hashes_json_key_is_approved_hashes() { + // given - the JSON field name must match between launcher and MPC node + let file = approved_file(vec![sample_digest()]); + + // when + let json = serde_json::to_value(&file).unwrap(); + + // then + assert!(json.get("approved_hashes").is_some()); + } +} + +/// Integration tests requiring network access and Docker Hub. +/// Run with: cargo test -p tee-launcher --features integration-test +#[cfg(all(test, feature = "integration-test"))] +mod integration_tests { + use super::*; + use assert_matches::assert_matches; + + const TEST_DIGEST: &str = + "sha256:f2472280c437efc00fa25a030a24990ae16c4fbec0d74914e178473ce4d57372"; + const TEST_TAG: &str = "83b52da4e2270c688cdd30da04f6b9d3565f25bb"; + const TEST_IMAGE_NAME: &str = "nearone/testing"; + const TEST_REGISTRY: &str = "registry.hub.docker.com"; + + fn test_launcher_config() -> LauncherConfig { + LauncherConfig { + image_tags: near_mpc_bounded_collections::NonEmptyVec::from_vec(vec![TEST_TAG.into()]) + .unwrap(), + image_name: TEST_IMAGE_NAME.into(), + registry: TEST_REGISTRY.into(), + rpc_request_timeout_secs: 10, + rpc_request_interval_secs: 1, + rpc_max_attempts: 20, + mpc_hash_override: None, + } + } + + #[tokio::test] + async fn get_manifest_digest_resolves_known_image() { + // given + let config = test_launcher_config(); + let expected_digest: DockerSha256Digest = TEST_DIGEST.parse().unwrap(); + + // when + let result = get_manifest_digest(&config, &expected_digest).await; + + // then + assert!(result.is_ok(), "get_manifest_digest failed: {result:?}"); + } + + #[tokio::test] + async fn validate_image_hash_succeeds_for_known_image() { + // given + let config = test_launcher_config(); + let expected_digest: DockerSha256Digest = TEST_DIGEST.parse().unwrap(); + + // when + let result = validate_image_hash(&config, expected_digest).await; + + // then + assert_matches!(result, Ok(_)); + } +} diff --git a/crates/tee-launcher/src/types.rs b/crates/tee-launcher/src/types.rs new file mode 100644 index 000000000..c85480eb3 --- /dev/null +++ b/crates/tee-launcher/src/types.rs @@ -0,0 +1,301 @@ +use std::net::Ipv4Addr; +use std::num::NonZeroU16; + +use launcher_interface::types::DockerSha256Digest; +use url::Host; + +use clap::{Parser, ValueEnum}; +use near_mpc_bounded_collections::NonEmptyVec; +use serde::{Deserialize, Serialize}; + +/// CLI arguments parsed from environment variables via clap. +#[derive(Parser, Debug)] +#[command(name = "tee-launcher")] +pub struct CliArgs { + /// Platform mode: TEE or NONTEE + #[arg(long, env = "PLATFORM")] + pub platform: Platform, + + #[arg(long, env = "DOCKER_CONTENT_TRUST")] + // ensure that `docker_content_trust` is enabled. + docker_content_trust: DockerContentTrust, + + /// Fallback image digest when the approved-hashes file is absent + #[arg(long, env = "DEFAULT_IMAGE_DIGEST")] + pub default_image_digest: DockerSha256Digest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum DockerContentTrust { + #[value(name = "1")] + Enabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Platform { + #[value(name = "TEE")] + Tee, + #[value(name = "NONTEE")] + NonTee, +} + +/// Typed representation of the dstack user config file (`/tapp/user_config`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub launcher_config: LauncherConfig, + pub docker_command_config: DockerLaunchFlags, + /// Opaque MPC node configuration table. + /// The launcher does not interpret these fields — they are re-serialized + /// to a TOML string, written to a file on disk, and mounted into the + /// container for the MPC binary to consume via `start-with-config-file`. + pub mpc_config: toml::Table, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LauncherConfig { + /// Docker image tags to search (from `MPC_IMAGE_TAGS`, comma-separated). + pub image_tags: NonEmptyVec, + /// Docker image name (from `MPC_IMAGE_NAME`). + pub image_name: String, + /// Docker registry (from `MPC_REGISTRY`). + pub registry: String, + /// Per-request timeout for registry RPC calls (from `RPC_REQUEST_TIMEOUT_SECS`). + pub rpc_request_timeout_secs: u64, + /// Delay between registry RPC retries (from `RPC_REQUEST_INTERVAL_SECS`). + pub rpc_request_interval_secs: u64, + /// Maximum registry RPC attempts (from `RPC_MAX_ATTEMPTS`). + pub rpc_max_attempts: u32, + /// Optional hash override that bypasses registry lookup (from `MPC_HASH_OVERRIDE`). + pub mpc_hash_override: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerLaunchFlags { + pub port_mappings: PortMappings, +} + +/// A `--add-host` entry: `hostname:IPv4`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostEntry { + pub hostname: Host, + pub ip: Ipv4Addr, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortMappings { + pub ports: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortMapping { + pub(crate) src: NonZeroU16, + pub(crate) dst: NonZeroU16, +} + +impl PortMapping { + /// Returns e.g. `"11780:11780"` for use in docker-compose port lists. + pub fn docker_compose_value(&self) -> String { + format!("{}:{}", self.src, self.dst) + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use std::net::Ipv4Addr; + use std::num::NonZeroU16; + + use super::*; + + // --- HostEntry deserialization --- + + #[test] + fn host_entry_valid_deserialization() { + // given + let json = serde_json::json!({"hostname": {"Domain": "node.local"}, "ip": "192.168.1.1"}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Ok(entry) => { + assert_eq!(entry.ip, Ipv4Addr::new(192, 168, 1, 1)); + }); + } + + #[test] + fn host_entry_rejects_invalid_ip() { + // given + let json = serde_json::json!({"hostname": {"Domain": "node.local"}, "ip": "not-an-ip"}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Err(_)); + } + + #[test] + fn host_entry_rejects_plain_string_as_hostname() { + // given - url::Host requires tagged variant, plain string is rejected + let json = serde_json::json!({"hostname": "node.local", "ip": "192.168.1.1"}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Err(_)); + } + + #[test] + fn host_entry_rejects_injection_string_as_hostname() { + // given + let json = serde_json::json!({"hostname": "--env LD_PRELOAD=hack.so", "ip": "192.168.1.1"}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Err(_)); + } + + // --- PortMapping deserialization --- + + #[test] + fn port_mapping_valid_deserialization() { + // given + let json = serde_json::json!({"src": 11780, "dst": 11780}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Ok(_)); + } + + #[test] + fn port_mapping_rejects_zero_port() { + // given + let json = serde_json::json!({"src": 0, "dst": 11780}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Err(_)); + } + + #[test] + fn port_mapping_rejects_out_of_range_port() { + // given + let json = serde_json::json!({"src": 65536, "dst": 11780}); + + // when + let result = serde_json::from_value::(json); + + // then + assert_matches!(result, Err(_)); + } + + // --- docker_compose_value output format --- + + #[test] + fn port_mapping_docker_compose_value() { + // given + let mapping = PortMapping { + src: NonZeroU16::new(11780).unwrap(), + dst: NonZeroU16::new(11780).unwrap(), + }; + + // when + let value = mapping.docker_compose_value(); + + // then + assert_eq!(value, "11780:11780"); + } + + // --- Config full deserialization (TOML) --- + + #[test] + fn config_deserializes_valid_toml() { + // given + let toml_str = r#" +[launcher_config] +image_tags = ["tag1"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [{ src = 11780, dst = 11780 }] + +[mpc_config] +home_dir = "/data" +some_opaque_field = true +"#; + + // when + let result = toml::from_str::(toml_str); + + // then + assert_matches!(result, Ok(config) => { + assert_eq!(config.launcher_config.image_name, "nearone/mpc-node"); + assert_eq!(config.mpc_config["home_dir"].as_str(), Some("/data")); + assert_eq!(config.mpc_config["some_opaque_field"].as_bool(), Some(true)); + }); + } + + #[test] + fn config_mpc_config_round_trips_to_toml_string() { + // given + let toml_str = r#" +[launcher_config] +image_tags = ["tag1"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [{ src = 11780, dst = 11780 }] + +[mpc_config] +home_dir = "/data" +arbitrary_key = "arbitrary_value" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + + // when — re-serialize the opaque table (what the launcher writes to disk) + let serialized = toml::to_string(&config.mpc_config).unwrap(); + + // then + assert!(serialized.contains("home_dir")); + assert!(serialized.contains("arbitrary_key")); + } + + #[test] + fn config_rejects_missing_required_field() { + // given - mpc_config is missing + let toml_str = r#" +[launcher_config] +image_tags = ["tag1"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [] +"#; + + // when + let result = toml::from_str::(toml_str); + + // then + assert_matches!(result, Err(_)); + } +} diff --git a/deployment/Dockerfile-launcher b/deployment/Dockerfile-launcher index 557e01c07..b39c42608 100644 --- a/deployment/Dockerfile-launcher +++ b/deployment/Dockerfile-launcher @@ -8,11 +8,10 @@ RUN \ --mount=type=bind,source=./deployment/repro-sources-list.sh,target=/usr/local/bin/repro-sources-list.sh \ repro-sources-list.sh && \ apt-get update && \ - apt-get install -y --no-install-recommends docker.io docker-compose curl jq python3 && \ + apt-get install -y --no-install-recommends docker.io libssl3 ca-certificates && \ : "Clean up for improving reproducibility" && \ rm -rf /var/log/* /var/cache/ldconfig/aux-cache -COPY --chmod=0755 tee_launcher/launcher.py /scripts/ -ENV PATH="/scripts:${PATH}" +COPY --chmod=0755 target/release/tee-launcher /usr/local/bin/tee-launcher RUN mkdir -p /app-data && mkdir -p /mnt/shared -CMD ["python3", "/scripts/launcher.py"] +CMD ["tee-launcher"] diff --git a/deployment/build-images.sh b/deployment/build-images.sh index 933531a66..cc4bfe02e 100755 --- a/deployment/build-images.sh +++ b/deployment/build-images.sh @@ -117,6 +117,8 @@ get_image_hash() { } if $USE_LAUNCHER; then + cargo build -p tee-launcher --release --locked + launcher_binary_hash=$(sha256sum target/release/tee-launcher | cut -d' ' -f1) build_reproducible_image $LAUNCHER_IMAGE_NAME $DOCKERFILE_LAUNCHER launcher_image_hash=$(get_image_hash $LAUNCHER_IMAGE_NAME) fi @@ -182,5 +184,6 @@ if $USE_NODE_GCP; then echo "node gcp docker image hash: $node_gcp_image_hash" fi if $USE_LAUNCHER; then + echo "launcher binary hash: $launcher_binary_hash" echo "launcher docker image hash: $launcher_image_hash" fi diff --git a/deployment/localnet/tee/frodo_conf.json b/deployment/localnet/tee/frodo_conf.json new file mode 100644 index 000000000..081733e2e --- /dev/null +++ b/deployment/localnet/tee/frodo_conf.json @@ -0,0 +1,21 @@ +{ + "MPC_IMAGE_NAME": "nearone/mpc-node", + "MPC_IMAGE_TAGS": "main-260e88b", + "MPC_REGISTRY": "registry.hub.docker.com", + "MPC_ACCOUNT_ID": "frodo.test.near", + "MPC_LOCAL_ADDRESS": "127.0.0.1", + "MPC_SECRET_STORE_KEY": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "MPC_CONTRACT_ID": "mpc-contract.test.near", + "MPC_ENV": "mpc-localnet", + "MPC_HOME_DIR": "/data", + "RUST_BACKTRACE": "full", + "RUST_LOG": "info", + "NEAR_BOOT_NODES": [ + "ed25519:BGa4WiBj43Mr66f9Ehf6swKtR6wZmWuwCsV3s4PSR3nx@${MACHINE_IP}:24566" + ], + "PORTS": [ + "8080:8080", + "24566:24566", + "13001:13001" + ] +} \ No newline at end of file diff --git a/deployment/localnet/tee/sam.toml b/deployment/localnet/tee/sam.toml new file mode 100644 index 000000000..f9bc42440 --- /dev/null +++ b/deployment/localnet/tee/sam.toml @@ -0,0 +1,56 @@ +[launcher_config] +image_tags = ["main-260e88b"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [ + { src = 8080, dst = 8080 }, + { src = 24566, dst = 24566 }, + { src = 13002, dst = 13002 }, +] + +[mpc_config] +home_dir = "/data" + +[mpc_config.secrets] +secret_store_key_hex = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +backup_encryption_key_hex = "0000000000000000000000000000000000000000000000000000000000000000" + +[mpc_config.tee.authority] +type = "local" + +[mpc_config.node] +my_near_account_id = "sam.test.near" +near_responder_account_id = "sam.test.near" +number_of_responder_keys = 1 +web_ui = "0.0.0.0:8080" +migration_web_ui = "0.0.0.0:8078" +cores = 4 + +[mpc_config.node.indexer] +validate_genesis = false +sync_mode = "Latest" +concurrency = 1 +mpc_contract_id = "mpc-contract.test.near" +finality = "optimistic" + +[mpc_config.node.triple] +concurrency = 2 +desired_triples_to_buffer = 128 +timeout_sec = 60 +parallel_triple_generation_stagger_time_sec = 1 + +[mpc_config.node.presignature] +concurrency = 4 +desired_presignatures_to_buffer = 64 +timeout_sec = 60 + +[mpc_config.node.signature] +timeout_sec = 60 + +[mpc_config.node.ckd] +timeout_sec = 60 diff --git a/deployment/localnet/tee/sam_conf.json b/deployment/localnet/tee/sam_conf.json new file mode 100644 index 000000000..8c307acd1 --- /dev/null +++ b/deployment/localnet/tee/sam_conf.json @@ -0,0 +1,21 @@ +{ + "MPC_IMAGE_NAME": "nearone/mpc-node", + "MPC_IMAGE_TAGS": "main-260e88b", + "MPC_REGISTRY": "registry.hub.docker.com", + "MPC_ACCOUNT_ID": "sam.test.near", + "MPC_LOCAL_ADDRESS": "127.0.0.1", + "MPC_SECRET_STORE_KEY": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "MPC_CONTRACT_ID": "mpc-contract.test.near", + "MPC_ENV": "mpc-localnet", + "MPC_HOME_DIR": "/data", + "RUST_BACKTRACE": "full", + "RUST_LOG": "info", + "NEAR_BOOT_NODES": [ + "ed25519:BGa4WiBj43Mr66f9Ehf6swKtR6wZmWuwCsV3s4PSR3nx@${MACHINE_IP}:24566" + ], + "PORTS": [ + "8080:8080", + "24566:24566", + "13002:13002" + ] +} \ No newline at end of file diff --git a/deployment/testnet/frodo.json b/deployment/testnet/frodo.json new file mode 100644 index 000000000..4827afc67 --- /dev/null +++ b/deployment/testnet/frodo.json @@ -0,0 +1,37 @@ +{ + "launcher_config": { + "image_tags": ["barak-doc-update_localnet_guide-b12bc7d"], + "image_name": "nearone/mpc-node", + "registry": "registry.hub.docker.com", + "rpc_request_timeout_secs": 10, + "rpc_request_interval_secs": 1, + "rpc_max_attempts": 20, + "mpc_hash_override": null + }, + "docker_command_config": { + "extra_hosts": { + "hosts": [] + }, + "port_mappings": { + "ports": [ + { "src": 8080, "dst": 8080 }, + { "src": 24567, "dst": 24567 }, + { "src": 13001, "dst": 13001 }, + { "src": 80, "dst": 80 } + ] + } + }, + "mpc_passthrough_env": { + "mpc_account_id": "$FRODO_ACCOUNT", + "mpc_local_address": "127.0.0.1", + "mpc_secret_key_store": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "mpc_backup_encryption_key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "mpc_env": "Testnet", + "mpc_home_dir": "/data", + "mpc_contract_id": "$MPC_CONTRACT_ACCOUNT", + "mpc_responder_id": "$FRODO_ACCOUNT", + "near_boot_nodes": "$BOOTNODES", + "rust_backtrace": "full", + "rust_log": "info" + } +} diff --git a/deployment/testnet/frodo.toml b/deployment/testnet/frodo.toml new file mode 100644 index 000000000..e3a7599ce --- /dev/null +++ b/deployment/testnet/frodo.toml @@ -0,0 +1,57 @@ +[launcher_config] +image_tags = ["barak-doc-update_localnet_guide-b12bc7d"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [ + { src = 8080, dst = 8080 }, + { src = 24567, dst = 24567 }, + { src = 13001, dst = 13001 }, + { src = 80, dst = 80 }, +] + +[mpc_config] +home_dir = "/data" + +[mpc_config.secrets] +secret_store_key_hex = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +backup_encryption_key_hex = "0000000000000000000000000000000000000000000000000000000000000000" + +[mpc_config.tee.authority] +type = "local" + +[mpc_config.node] +my_near_account_id = "$FRODO_ACCOUNT" +near_responder_account_id = "$FRODO_ACCOUNT" +number_of_responder_keys = 1 +web_ui = "0.0.0.0:8080" +migration_web_ui = "0.0.0.0:8078" +cores = 4 + +[mpc_config.node.indexer] +validate_genesis = false +sync_mode = "Latest" +concurrency = 1 +mpc_contract_id = "$MPC_CONTRACT_ACCOUNT" +finality = "optimistic" + +[mpc_config.node.triple] +concurrency = 2 +desired_triples_to_buffer = 128 +timeout_sec = 60 +parallel_triple_generation_stagger_time_sec = 1 + +[mpc_config.node.presignature] +concurrency = 4 +desired_presignatures_to_buffer = 64 +timeout_sec = 60 + +[mpc_config.node.signature] +timeout_sec = 60 + +[mpc_config.node.ckd] +timeout_sec = 60 diff --git a/deployment/testnet/sam.toml b/deployment/testnet/sam.toml new file mode 100644 index 000000000..5d9c9586c --- /dev/null +++ b/deployment/testnet/sam.toml @@ -0,0 +1,57 @@ +[launcher_config] +image_tags = ["barak-doc-update_localnet_guide-b12bc7d"] +image_name = "nearone/mpc-node" +registry = "registry.hub.docker.com" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [ + { src = 8080, dst = 8080 }, + { src = 24567, dst = 24567 }, + { src = 13002, dst = 13002 }, + { src = 80, dst = 80 }, +] + +[mpc_config] +home_dir = "/data" + +[mpc_config.secrets] +secret_store_key_hex = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +backup_encryption_key_hex = "0000000000000000000000000000000000000000000000000000000000000000" + +[mpc_config.tee.authority] +type = "local" + +[mpc_config.node] +my_near_account_id = "$SAM_ACCOUNT" +near_responder_account_id = "$SAM_ACCOUNT" +number_of_responder_keys = 1 +web_ui = "0.0.0.0:8080" +migration_web_ui = "0.0.0.0:8078" +cores = 4 + +[mpc_config.node.indexer] +validate_genesis = false +sync_mode = "Latest" +concurrency = 1 +mpc_contract_id = "$MPC_CONTRACT_ACCOUNT" +finality = "optimistic" + +[mpc_config.node.triple] +concurrency = 2 +desired_triples_to_buffer = 128 +timeout_sec = 60 +parallel_triple_generation_stagger_time_sec = 1 + +[mpc_config.node.presignature] +concurrency = 4 +desired_presignatures_to_buffer = 64 +timeout_sec = 60 + +[mpc_config.node.signature] +timeout_sec = 60 + +[mpc_config.node.ckd] +timeout_sec = 60 diff --git a/flake.nix b/flake.nix index da3d9bf7e..7f223d4c2 100644 --- a/flake.nix +++ b/flake.nix @@ -179,14 +179,14 @@ strictDeps = true; packages = - dockerTools ++ - llvmTools ++ - rustTools ++ - cargoTools ++ - pythonTools ++ - nearTools ++ - miscTools ++ - buildLibs; + dockerTools + ++ llvmTools + ++ rustTools + ++ cargoTools + ++ pythonTools + ++ nearTools + ++ miscTools + ++ buildLibs; env = envCommon // envDarwin; diff --git a/localnet/tee/scripts/deploy-tee-localnet.sh b/localnet/tee/scripts/deploy-tee-localnet.sh index 6af59068f..f8369628b 100644 --- a/localnet/tee/scripts/deploy-tee-localnet.sh +++ b/localnet/tee/scripts/deploy-tee-localnet.sh @@ -144,11 +144,25 @@ MODE="${MODE:-testnet}" # testnet|localnet # templates live here (UPDATED for move to localnet/tee/scripts) ENV_TPL="$REPO_ROOT/localnet/tee/scripts/node.env.tpl" if [ "$MODE" = "localnet" ]; then - CONF_TPL="$REPO_ROOT/localnet/tee/scripts/node.conf.localnet.tpl" + CONF_TPL="$REPO_ROOT/localnet/tee/scripts/node.conf.localnet.toml.tpl" else CONF_TPL="$REPO_ROOT/localnet/tee/scripts/node.conf.tpl" fi +# Convert comma-separated "src:dst" port string to TOML inline table array entries. +# E.g. "8080:8080,24566:24566" -> " { src = 8080, dst = 8080 },\n { src = 24566, dst = 24566 }," +ports_to_toml() { + local ports="$1" result="" + IFS=',' read -ra pairs <<< "$ports" + for pair in "${pairs[@]}"; do + local src="${pair%%:*}" + local dst="${pair##*:}" + result+=" { src = $src, dst = $dst }, +" + done + echo -n "$result" +} + WORKDIR="/tmp/$USER/mpc_testnet_scale/$MPC_NETWORK_NAME" mkdir -p "$WORKDIR" @@ -713,7 +727,7 @@ render_node_files_range() { local env_out conf_out env_out="$WORKDIR/node${i}.env" - conf_out="$WORKDIR/node${i}.conf" + conf_out="$WORKDIR/node${i}.toml" export APP_NAME="$app_name" export VMM_RPC @@ -749,6 +763,8 @@ render_node_files_range() { export MPC_SECRET_STORE_KEY="$(printf '%032x' "$i")" export MPC_CONTRACT_ID="$MPC_CONTRACT_ACCOUNT" export PORTS="8080:8080,24566:24566,${future_port}:${future_port}" + export PORTS_TOML + PORTS_TOML="$(ports_to_toml "$PORTS")" export NEAR_BOOT_NODES="ed25519:BGa4WiBj43Mr66f9Ehf6swKtR6wZmWuwCsV3s4PSR3nx@${MACHINE_IP}:24566" envsubst <"$ENV_TPL" >"$env_out" diff --git a/localnet/tee/scripts/how-to-run-localnet-tee-setup-script.md b/localnet/tee/scripts/how-to-run-localnet-tee-setup-script.md index 88b0084ef..d0c001ebb 100644 --- a/localnet/tee/scripts/how-to-run-localnet-tee-setup-script.md +++ b/localnet/tee/scripts/how-to-run-localnet-tee-setup-script.md @@ -152,7 +152,7 @@ All generated files are stored under: ``` Important artifacts: -- `node{i}.conf`, `node{i}.env` +- `node{i}.toml`, `node{i}.env` - `keys.json` - `init_args.json` diff --git a/localnet/tee/scripts/node.conf.localnet.toml.tpl b/localnet/tee/scripts/node.conf.localnet.toml.tpl new file mode 100644 index 000000000..2c1b44ab0 --- /dev/null +++ b/localnet/tee/scripts/node.conf.localnet.toml.tpl @@ -0,0 +1,53 @@ +[launcher_config] +image_tags = ["${MPC_IMAGE_TAGS}"] +image_name = "${MPC_IMAGE_NAME}" +registry = "${MPC_REGISTRY}" +rpc_request_timeout_secs = 10 +rpc_request_interval_secs = 1 +rpc_max_attempts = 20 + +[docker_command_config.port_mappings] +ports = [ +${PORTS_TOML}] + +[mpc_config] +home_dir = "/data" + +[mpc_config.secrets] +secret_store_key_hex = "${MPC_SECRET_STORE_KEY}" +backup_encryption_key_hex = "0000000000000000000000000000000000000000000000000000000000000000" + +[mpc_config.tee.authority] +type = "local" + +[mpc_config.node] +my_near_account_id = "${MPC_ACCOUNT_ID}" +near_responder_account_id = "${MPC_ACCOUNT_ID}" +number_of_responder_keys = 1 +web_ui = "0.0.0.0:8080" +migration_web_ui = "0.0.0.0:8078" +cores = 4 + +[mpc_config.node.indexer] +validate_genesis = false +sync_mode = "Latest" +concurrency = 1 +mpc_contract_id = "${MPC_CONTRACT_ID}" +finality = "optimistic" + +[mpc_config.node.triple] +concurrency = 2 +desired_triples_to_buffer = 128 +timeout_sec = 60 +parallel_triple_generation_stagger_time_sec = 1 + +[mpc_config.node.presignature] +concurrency = 4 +desired_presignatures_to_buffer = 64 +timeout_sec = 60 + +[mpc_config.node.signature] +timeout_sec = 60 + +[mpc_config.node.ckd] +timeout_sec = 60 diff --git a/localnet/tee/scripts/node.conf.localnet.tpl b/localnet/tee/scripts/node.conf.localnet.tpl.bak similarity index 100% rename from localnet/tee/scripts/node.conf.localnet.tpl rename to localnet/tee/scripts/node.conf.localnet.tpl.bak diff --git a/localnet/tee/scripts/single-node.sh b/localnet/tee/scripts/single-node.sh index 13b62d135..c1a0eb435 100755 --- a/localnet/tee/scripts/single-node.sh +++ b/localnet/tee/scripts/single-node.sh @@ -132,13 +132,26 @@ DISK="${DISK:-500G}" REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" TEE_LAUNCHER_DIR="$REPO_ROOT/tee_launcher" ENV_TPL="${ENV_TPL:-$REPO_ROOT/localnet/tee/scripts/node.env.tpl}" -CONF_TPL="${CONF_TPL:-$REPO_ROOT/localnet/tee/scripts/node.conf.localnet.tpl}" +CONF_TPL="${CONF_TPL:-$REPO_ROOT/localnet/tee/scripts/node.conf.localnet.toml.tpl}" + +# Convert comma-separated "src:dst" port string to TOML inline table array entries. +ports_to_toml() { + local ports="$1" result="" + IFS=',' read -ra pairs <<< "$ports" + for pair in "${pairs[@]}"; do + local src="${pair%%:*}" + local dst="${pair##*:}" + result+=" { src = $src, dst = $dst }, +" + done + echo -n "$result" +} WORKDIR="${WORKDIR:-$(mktemp -d /tmp/mpc_localnet_one_node.XXXXXX)}" mkdir -p "$WORKDIR" log "Work directory: $WORKDIR" ENV_OUT="$WORKDIR/node.env" -CONF_OUT="$WORKDIR/node.conf" +CONF_OUT="$WORKDIR/node.toml" PUBLIC_DATA_JSON_OUT="${PUBLIC_DATA_JSON_OUT:-$WORKDIR/public_data.json}" near_account_exists() { @@ -193,6 +206,8 @@ render_env_and_conf() { export MPC_CONTRACT_ID="$CONTRACT_ACCOUNT" export MPC_SECRET_STORE_KEY="${MPC_SECRET_STORE_KEY:-00000000000000000000000000000000}" export PORTS="${PORTS:-8080:8080,24566:24566,${FUTURE_PORT}:${FUTURE_PORT}}" + export PORTS_TOML + PORTS_TOML="$(ports_to_toml "$PORTS")" envsubst <"$ENV_TPL" >"$ENV_OUT" envsubst <"$CONF_TPL" >"$CONF_OUT" diff --git a/tee_launcher/launcher_docker_compose.yaml b/tee_launcher/launcher_docker_compose.yaml index db24475aa..b59b44b32 100644 --- a/tee_launcher/launcher_docker_compose.yaml +++ b/tee_launcher/launcher_docker_compose.yaml @@ -2,7 +2,7 @@ version: '3.8' services: launcher: - image: nearone/mpc-launcher@sha256:84c7537a2f84d3477eac2e5ef3ba0765b5d688f86096947eea4744ce25b27054 + image: nearone/mpc-launcher@sha256:85a4fa6d1eec05e8f43dba17d3f4368f89719a2a06b9e2051d84813c3f651068 container_name: launcher diff --git a/tee_launcher/launcher_docker_compose_nontee.yaml b/tee_launcher/launcher_docker_compose_nontee.yaml index 48b0bc4fc..fd7101b55 100644 --- a/tee_launcher/launcher_docker_compose_nontee.yaml +++ b/tee_launcher/launcher_docker_compose_nontee.yaml @@ -1,12 +1,12 @@ services: launcher: - image: nearone/mpc-launcher@sha256:84c7537a2f84d3477eac2e5ef3ba0765b5d688f86096947eea4744ce25b27054 + image: nearone/mpc-launcher@sha256:70e6d08328123b44406523af3147aebad37a9472839b8ebf0a303cecd7174fb0 container_name: "${LAUNCHER_IMAGE_NAME}" environment: - PLATFORM=NONTEE - DOCKER_CONTENT_TRUST=1 - - DEFAULT_IMAGE_DIGEST=sha256:9143bc98aaae3408c14cf4490d7b0e96a5a32d989ec865a0cf8dde391831a7a9 # 3.6.0 release + - DEFAULT_IMAGE_DIGEST=sha256:e7d1df7453b9bb9e89969f02a0ae59c3c2743cd895963e97a8e7666defbf4dab # latest main with config file support volumes: - /var/run/docker.sock:/var/run/docker.sock