diff --git a/Cargo.lock b/Cargo.lock index 528ac10..23ba539 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -798,6 +798,7 @@ dependencies = [ "hmac", "jsonschema", "jsonwebtoken", + "libc", "rand 0.9.5", "reqwest", "rmp-serde", diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml index c1204cb..67a1047 100644 --- a/crates/ghostlight-dungeon/Cargo.toml +++ b/crates/ghostlight-dungeon/Cargo.toml @@ -22,6 +22,7 @@ cultcache-rs.workspace = true cultnet-rs.workspace = true cultmesh-rs.workspace = true jsonwebtoken.workspace = true +libc = "0.2" hmac.workspace = true ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } rand.workspace = true diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs index 99fdd12..c2d326d 100644 --- a/crates/ghostlight-dungeon/src/heimdall.rs +++ b/crates/ghostlight-dungeon/src/heimdall.rs @@ -1,3 +1,4 @@ +use crate::idunn_health::HeimdallDependencyBinding; use aes_gcm::{ Aes256Gcm, KeyInit, aead::{Aead, Payload}, @@ -44,7 +45,10 @@ pub struct HeimdallClient { #[derive(Clone)] enum HeimdallBoundaryLocator { - Odin(SocketAddr), + Odin { + endpoint: SocketAddr, + expected: Option, + }, #[cfg(test)] Fixed(ResolvedHeimdallBoundary), } @@ -307,10 +311,14 @@ impl VerifiedSessionAdmission { } impl HeimdallClient { - pub fn from_env() -> anyhow::Result { - let odin_endpoint: SocketAddr = required_env("GHOSTLIGHT_ODIN_RUDP")? - .parse() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address")?; + pub fn from_env( + runtime_id: &str, + odin_endpoint: SocketAddr, + expected: Option, + ) -> anyhow::Result { + if runtime_id.is_empty() || runtime_id.trim() != runtime_id { + bail!("Ghostlight runtime identity is not canonical"); + } let secret_path = std::env::var_os("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE") .map(PathBuf::from) .context("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE is required")?; @@ -331,9 +339,11 @@ impl HeimdallClient { .unwrap_or_else(|_| "https://yggdrasil.gamecult.org/ghostlight/".into()), discord_guild_id: required_env("GHOSTLIGHT_DISCORD_GUILD_ID")?, discord_role_id: required_env("GHOSTLIGHT_DISCORD_ROLE_ID")?, - boundary_locator: HeimdallBoundaryLocator::Odin(odin_endpoint), - runtime_id: std::env::var("GHOSTLIGHT_RUNTIME_ID") - .unwrap_or_else(|_| "yggdrasil-ghostlight".into()), + boundary_locator: HeimdallBoundaryLocator::Odin { + endpoint: odin_endpoint, + expected, + }, + runtime_id: runtime_id.into(), shared_secret, }) } @@ -356,7 +366,7 @@ impl HeimdallClient { "heimdall.auth.logout".into(), ], }), - runtime_id: "yggdrasil-ghostlight".into(), + runtime_id: "ghostlight-test-runtime".into(), shared_secret: "fixture-secret".into(), } } @@ -725,7 +735,13 @@ impl HeimdallBoundaryLocator { operation: &str, ) -> anyhow::Result { let boundary = match self { - Self::Odin(endpoint) => discover_heimdall_boundary(*endpoint, runtime_id)?, + Self::Odin { endpoint, expected } => { + let boundary = discover_heimdall_boundary(*endpoint, runtime_id)?; + if let Some(expected) = expected { + require_expected_heimdall_boundary(&boundary, expected)?; + } + boundary + } #[cfg(test)] Self::Fixed(boundary) => boundary.clone(), }; @@ -740,6 +756,16 @@ impl HeimdallBoundaryLocator { } } +fn require_expected_heimdall_boundary( + boundary: &ResolvedHeimdallBoundary, + expected: &HeimdallDependencyBinding, +) -> anyhow::Result<()> { + if boundary.daemon_id != expected.provider_id || boundary.endpoint != expected.endpoint { + bail!("discovered Heimdall boundary differs from Expected"); + } + Ok(()) +} + fn discover_heimdall_boundary( odin_endpoint: SocketAddr, runtime_id: &str, @@ -1037,6 +1063,17 @@ mod tests { assert_eq!(resolved.endpoint, "127.0.0.1:4101".parse().unwrap()); assert_eq!(resolved.daemon_id, "yggdrasil-heimdall"); assert_eq!(resolved.operations, vec!["heimdall.auth.begin"]); + let expected = HeimdallDependencyBinding { + provider_id: "yggdrasil-heimdall".into(), + endpoint: "127.0.0.1:4101".parse().unwrap(), + }; + require_expected_heimdall_boundary(&resolved, &expected) + .expect("exact Expected Heimdall should match discovery"); + let substitute = HeimdallDependencyBinding { + provider_id: "another-heimdall".into(), + endpoint: expected.endpoint, + }; + assert!(require_expected_heimdall_boundary(&resolved, &substitute).is_err()); } #[test] diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs index 240efcd..2b192b7 100644 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ b/crates/ghostlight-dungeon/src/idunn_health.rs @@ -1,464 +1,987 @@ -use anyhow::{Context, Result, anyhow, bail}; -use cultcache_rs::{CultCacheEnvelope, SingleFileMessagePackBackingStore}; +//! Service-owned runtime presence at the Idunn/Odin authority boundary. +//! +//! Idunn supplies one immutable Expected/activation bundle, an activation-only +//! signing credential, the candidate bind, and (after Odin observes Warming) +//! one process write lease. Ghostlight reports what this process actually is; +//! it never promotes its route or manufactures Ready. + +use anyhow::{Context, Result, anyhow, bail, ensure}; +#[cfg(test)] +use cultcache_rs::CacheBackingStore; +use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; use cultnet_rs::{ CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, - GameCultProviderHealthIdentity, IdunnSignedDaemonHealthPurpose, ServiceIdentitySigner, - encode_cultnet_message_to_vec, open_service_identity_at, + CultNetRudpReliableSendStatus, CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportOptions, CultNetWireContract, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, + GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, + GameCultRuntimePresenceHealthRecord, IDUNN_EXPECTED_INCARNATION_SCHEMA, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, + IdunnProcessWriteLeaseRecord, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, + ServiceIdentitySigner, encode_cultnet_message_to_vec, open_service_identity_credential_reader, }; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -#[cfg(target_os = "linux")] -use std::os::unix::fs::MetadataExt; use std::{ + fs::{self, File}, net::{SocketAddr, UdpSocket}, path::{Path, PathBuf}, + sync::mpsc, + thread, time::{Duration, Instant}, }; +#[cfg(target_os = "linux")] +use std::{ + os::fd::{AsRawFd, FromRawFd, RawFd}, + os::unix::fs::{MetadataExt, PermissionsExt}, +}; -pub const GHOSTLIGHT_IDUNN_HEALTH_CONTRACT: &str = "ghostlight.cultnet-rudp-service-health"; +pub const GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT: &str = + "ghostlight.cultnet-rudp-service-health"; +pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; +pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; +pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; + +const TARGET: &str = "ghostlight"; +const STATE_SCHEMA_GENERATION: &str = "world-v2"; +const STATE_CONTRACT_SHA256: &str = + "sha256-bf6ec06d885a59ddb237c6224d0abb4ccceac8c7ba23761d1326d7f562a4c21e"; const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; -const IDUNN_HEALTH_RUDP_CONNECTION_ID: u32 = 0x1d0d_0001; -const GHOSTLIGHT_ACTIVATION_SCHEMA: &str = "gamecult.ghostlight.activation.v1"; -const GHOSTLIGHT_ACTIVATION_PATH: &str = "/etc/gamecult/ghostlight-dungeon/runtime/ACTIVATION"; -const GHOSTLIGHT_UNIT_PATH: &str = "/etc/systemd/system/ghostlight-dungeon.service"; -const GHOSTLIGHT_RUNTIME_ENVIRONMENT_PATH: &str = "/srv/ghostlight/deploy/runtime.env"; -const TRAFFIC_ADMISSION_PATH: &str = - "/etc/gamecult/ghostlight-dungeon/runtime/traffic-admission.cc"; -const TRAFFIC_ADMISSION_TYPE: &str = "idunn.runtime_traffic_admission"; -const TRAFFIC_ADMISSION_SCHEMA: &str = "idunn.runtime_traffic_admission.v2"; -const TRAFFIC_ADMISSION_KEY: &str = "yggdrasil-ghostlight"; +const ODIN_CULTMESH_CATALOG_CONNECTION_ID: u32 = 0x0d1d_0002; +const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity"; +const ODIN_CAPABILITY: (&str, &str, &str) = + ("odin.verse-rendezvous", "odin.verse-topology.v1", "v1"); +const CONNECTOR_CAPABILITY: (&str, &str, &str) = ( + "gamecult.codex.subscription-inference", + "gamecult.codex.transport_envelope.v2", + "v2", +); +const HEIMDALL_CAPABILITY: (&str, &str, &str) = ( + "heimdall.command-boundary", + "heimdall.command_boundary.v1", + "v1", +); +#[cfg(target_os = "linux")] +const SYSTEMD_LISTEN_FDS_START: RawFd = 3; #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct IdunnReleaseBinding { - pub(crate) release_id: String, - pub(crate) release_witness_sha256: String, - pub(crate) source_commit: String, - pub(crate) deployment_id: String, +pub(crate) struct HeimdallDependencyBinding { + pub(crate) provider_id: String, + pub(crate) endpoint: SocketAddr, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct IdunnActivationBinding { - activation_witness_sha256: String, +pub(crate) struct RuntimeDependencyBindings { + pub(crate) odin_rudp: SocketAddr, + pub(crate) connector: SocketAddr, + pub(crate) heimdall: HeimdallDependencyBinding, } -// Idunn owns and writes this CultCache schema. Ghostlight carries only the -// exact read-side projection required to consume that root traffic grant. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TrafficAdmissionConsumerProjection { - schema_version: String, - daemon_id: String, - release_id: String, - release_witness_sha256: String, - source_commit: String, - deployment_id: String, - activation_witness_sha256: String, - signed_health_sha256: String, - publisher_incarnation_id: String, - publisher_sequence: u64, - signer_identity_id: String, - runtime_process_id: u32, - runtime_process_starttime_ticks: u64, +pub(crate) struct PublishedRuntimePresence { + canonical_sha256: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RuntimeProcessInstanceBinding { - process_id: u32, - starttime_ticks: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ActivationWitness { - activation_id: String, - daemon_id: String, - release_id: String, - release_witness_sha256: String, - source_commit: String, - deployment_id: String, - unit_path: String, - unit_sha256: String, - runtime_environment_path: String, - runtime_environment_sha256: String, -} - -#[derive(Clone)] -pub(crate) struct GhostlightTrafficAdmissionGate { - path: PathBuf, - expected: TrafficAdmissionConsumerProjection, - ghostlight_gid: u32, -} - -pub(crate) struct PublishedHealthStatementIdentity { - daemon_id: String, - signed_health_sha256: String, - publisher_incarnation_id: String, - publisher_sequence: u64, - signer_identity_id: String, -} - -impl IdunnReleaseBinding { - fn validate(&self) -> Result<()> { - require_id(&self.release_id, "release id")?; - require_id(&self.deployment_id, "deployment id")?; - if !self - .release_witness_sha256 - .strip_prefix("sha256-") - .is_some_and(|digest| is_lower_hex(digest, 64)) - || !is_lower_hex(&self.source_commit, 40) - { - bail!("release health binding is malformed"); - } - Ok(()) +impl PublishedRuntimePresence { + pub(crate) fn canonical_sha256(&self) -> &str { + &self.canonical_sha256 } } -impl IdunnActivationBinding { - fn validate(&self) -> Result<()> { - require_sha256(&self.activation_witness_sha256, "activation witness digest") - } +pub(crate) struct RuntimePresencePublisher { + endpoint: SocketAddr, + bound_endpoint: String, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + provider_signer: ServiceIdentitySigner, + activation_signer: IdunnRuntimeActivationSigner, + capabilities: Vec, + write_lease_path: PathBuf, + dependency_bindings: RuntimeDependencyBindings, + publisher_sequence: u64, + active_write_lease_sha256: Option, } -impl GhostlightTrafficAdmissionGate { - pub(crate) fn from_environment( - release: &IdunnReleaseBinding, - activation: &IdunnActivationBinding, - published: &PublishedHealthStatementIdentity, +impl RuntimePresencePublisher { + pub(crate) fn from_environment(observed_bound_endpoint: SocketAddr) -> Result> { + let Some(bundle) = std::env::var_os(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT) else { + #[cfg(target_os = "linux")] + bail!("{IDUNN_RUNTIME_BUNDLE_ENVIRONMENT} is mandatory on Linux"); + #[cfg(not(target_os = "linux"))] + return Ok(None); + }; + let bundle = PathBuf::from(bundle); + require_runtime_bundle(&bundle)?; + let expected = read_expected(&bundle.join("expected.cc"))?; + let activation = read_activation(&bundle.join("activation.cc"), &expected.target)?; + + let odin_endpoint: SocketAddr = std::env::var("GHOSTLIGHT_ODIN_RUDP") + .context("GHOSTLIGHT_ODIN_RUDP is required for runtime presence")? + .parse() + .context("GHOSTLIGHT_ODIN_RUDP is not a socket address")?; + let connector_endpoint: SocketAddr = std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR") + .context("GHOSTLIGHT_CONTROLLER_CONNECTOR is required for managed runtime")? + .parse() + .context("GHOSTLIGHT_CONTROLLER_CONNECTOR is not a socket address")?; + let write_lease_path = PathBuf::from( + std::env::var_os(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT) + .with_context(|| format!("{IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT} is required"))?, + ); + require_process_write_lease_parent(&write_lease_path)?; + let (activation_credential, provider_credential) = inherited_authority_files()?; + let provider_signer = open_service_identity_credential_reader::< + GameCultProviderHealthIdentity, + >(provider_credential)?; + let activation_signer = + IdunnRuntimeActivationSigner::from_credential_reader(activation_credential)?; + Ok(Some(Self::new( + odin_endpoint, + connector_endpoint, + observed_bound_endpoint, + expected, + activation, + provider_signer, + activation_signer, + write_lease_path, + )?)) + } + + #[allow(clippy::too_many_arguments)] + fn new( + odin_endpoint: SocketAddr, + connector_endpoint: SocketAddr, + observed_bound_endpoint: SocketAddr, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + provider_signer: ServiceIdentitySigner, + activation_signer: IdunnRuntimeActivationSigner, + write_lease_path: PathBuf, ) -> Result { - #[cfg(not(target_os = "linux"))] - bail!("signed Ghostlight health and traffic admission require Linux"); + expected.validate()?; + activation.validate()?; + ensure!( + expected.target == TARGET, + "Expected belongs to another target" + ); + ensure!( + expected.health_contract == GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT, + "Expected names another Ghostlight health contract" + ); + ensure!( + expected.state_schema_generation.as_deref() == Some(STATE_SCHEMA_GENERATION) + && expected.state_contract_sha256.as_deref() == Some(STATE_CONTRACT_SHA256) + && expected.write_lease_required, + "Expected does not name Ghostlight's writable world-v2 state" + ); + ensure!( + activation.expected_projection_sha256 == expected.canonical_sha256()? + && activation.runtime_id == expected.runtime_id, + "activation does not bind the exact Expected incarnation" + ); + ensure!( + activation.activation_signer_identity_id == activation_signer.identity_id(), + "activation credential does not belong to this launch" + ); + ensure!( + expected.expected_signer_identity_id == provider_signer.entry().identity_id, + "provider credential is not Idunn's Expected signer" + ); + ensure!( + write_lease_path.is_absolute(), + "write-lease path is not absolute" + ); - let raw_path = std::env::var("GHOSTLIGHT_TRAFFIC_ADMISSION") - .context("GHOSTLIGHT_TRAFFIC_ADMISSION is required for signed health")?; - if raw_path != TRAFFIC_ADMISSION_PATH { - bail!("Ghostlight traffic admission path is not the fixed root policy path"); + let route = expected + .route + .as_ref() + .context("Ghostlight Expected has no candidate route")?; + ensure!( + route.transport == "http", + "Ghostlight candidate route is not HTTP" + ); + let expected_bind: SocketAddr = route + .candidate_endpoint + .strip_prefix("http://") + .context("Ghostlight candidate endpoint is not plain HTTP")? + .parse() + .context("Ghostlight candidate endpoint is not a socket address")?; + ensure!( + observed_bound_endpoint == expected_bind + && route.candidate_endpoint == format!("http://{observed_bound_endpoint}"), + "observed candidate bind differs from canonical Expected" + ); + + let capabilities = actual_capabilities(); + for requirement in &expected.capabilities { + ensure!( + capabilities.iter().any(|actual| { + actual.capability == requirement.capability + && actual.schema == requirement.schema + && actual.compatibility == requirement.compatibility + && actual.capacity >= requirement.minimum_capacity + }), + "Expected requires a capability this Ghostlight binary does not provide" + ); } + let dependency_bindings = + runtime_dependency_bindings(&expected, odin_endpoint, connector_endpoint)?; + Ok(Self { - path: PathBuf::from(raw_path), - expected: traffic_admission_expectation(release, activation, published)?, - ghostlight_gid: local_group_id("ghostlight")?, + endpoint: odin_endpoint, + bound_endpoint: format!("http://{observed_bound_endpoint}"), + expected, + activation, + provider_signer, + activation_signer, + capabilities, + write_lease_path, + dependency_bindings, + publisher_sequence: 0, + active_write_lease_sha256: None, }) } - pub(crate) async fn wait_until_granted(&self, timeout: Duration) -> Result<()> { - let started = tokio::time::Instant::now(); - let mut last_invalid_observation = None; + pub(crate) fn runtime_id(&self) -> &str { + &self.expected.runtime_id + } + + pub(crate) fn dependency_bindings(&self) -> &RuntimeDependencyBindings { + &self.dependency_bindings + } + + pub(crate) fn publish_warming(&mut self) -> Result { + ensure!( + self.active_write_lease_sha256.is_none(), + "an active runtime cannot return to Warming" + ); + self.publish("warming", "process-write-lease-pending", None) + } + + pub(crate) async fn wait_for_write_lease( + &self, + warming: &PublishedRuntimePresence, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; loop { - match self.is_current() { - Ok(true) => return Ok(()), - Ok(false) => last_invalid_observation = None, - Err(error) => last_invalid_observation = Some(format!("{error:#}")), + if self.write_lease_path.exists() && sibling_lock_path(&self.write_lease_path).exists() + { + return ProcessWriteLeaseGuard::acquire( + self.write_lease_path.clone(), + self.expected.clone(), + self.activation.clone(), + warming.canonical_sha256.clone(), + ); } - if started.elapsed() >= timeout { - let detail = last_invalid_observation - .as_deref() - .unwrap_or("the fixed admission record remained absent"); + if Instant::now() >= deadline { bail!( - "timed out waiting for exact sealed root traffic admission {}: {}", - self.path.display(), - detail, + "timed out waiting for process write lease {}", + self.write_lease_path.display() ); } tokio::time::sleep(Duration::from_millis(100)).await; } } - pub(crate) fn require_current(&self) -> Result<()> { - if !self.is_current()? { - bail!( - "root traffic admission disappeared from {}", - self.path.display() - ); + pub(crate) fn publish_active( + &mut self, + lease: &ProcessWriteLeaseGuard, + ) -> Result { + lease.require_current()?; + let lease_sha256 = lease.canonical_sha256().to_owned(); + match &self.active_write_lease_sha256 { + Some(current) => ensure!( + current == &lease_sha256, + "runtime attempted to replace its active write lease" + ), + None => self.active_write_lease_sha256 = Some(lease_sha256.clone()), } - Ok(()) + self.publish("active", "world-owner-serving", Some(lease_sha256)) } - fn is_current(&self) -> Result { - match std::fs::symlink_metadata(&self.path) { - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(error).with_context(|| { - format!( - "inspecting Ghostlight traffic admission {}", - self.path.display() - ) - }); - } - } - require_sealed_traffic_admission_files(&self.path, self.ghostlight_gid)?; - SingleFileMessagePackBackingStore::new(&self.path).with_read_only_shared_snapshot( - |entries| { - require_sealed_traffic_admission_files(&self.path, self.ghostlight_gid)?; - let admitted = decode_traffic_admission_entries(&entries)?; - require_exact_traffic_admission(&admitted, &self.expected) - }, - )?; - Ok(true) - } -} - -pub(crate) fn active_activation_binding( - release: &IdunnReleaseBinding, -) -> Result { - #[cfg(not(target_os = "linux"))] - bail!("signed Ghostlight health activation requires Linux"); - - release.validate()?; - let activation_path = Path::new(GHOSTLIGHT_ACTIVATION_PATH); - let unit_path = Path::new(GHOSTLIGHT_UNIT_PATH); - let runtime_environment_path = Path::new(GHOSTLIGHT_RUNTIME_ENVIRONMENT_PATH); - let activation_bytes = read_root_controlled_file(activation_path, "Ghostlight activation")?; - let unit_bytes = read_root_controlled_file(unit_path, "Ghostlight systemd unit")?; - let runtime_environment_bytes = - read_root_controlled_file(runtime_environment_path, "Ghostlight runtime environment")?; - let witness = parse_activation_witness(&activation_bytes)?; - let activation_id = std::env::var("GHOSTLIGHT_ACTIVATION_ID") - .context("GHOSTLIGHT_ACTIVATION_ID is required for signed health")?; - require_safe_token(&activation_id, "activation id")?; - let expected = ActivationWitness { - activation_id, - daemon_id: TRAFFIC_ADMISSION_KEY.into(), - release_id: release.release_id.clone(), - release_witness_sha256: release.release_witness_sha256.clone(), - source_commit: release.source_commit.clone(), - deployment_id: release.deployment_id.clone(), - unit_path: GHOSTLIGHT_UNIT_PATH.into(), - unit_sha256: format!("sha256-{}", hex(&Sha256::digest(&unit_bytes))), - runtime_environment_path: GHOSTLIGHT_RUNTIME_ENVIRONMENT_PATH.into(), - runtime_environment_sha256: format!( - "sha256-{}", - hex(&Sha256::digest(&runtime_environment_bytes)) - ), - }; - validate_activation_witness(&witness)?; - if witness != expected { - bail!("activation witness does not bind the exact Ghostlight launch inputs"); - } - let binding = IdunnActivationBinding { - activation_witness_sha256: format!("sha256-{}", hex(&Sha256::digest(&activation_bytes))), - }; - binding.validate()?; - Ok(binding) -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct IdunnSignedDaemonHealthRecord { - schema_version: String, - daemon_id: String, - health_contract: String, - source_runtime_id: String, - state: String, - detail: String, - signer_identity_id: String, - publisher_incarnation_id: String, - publisher_sequence: u64, - observed_at_unix_millis: u64, - release_id: Option, - release_witness_sha256: Option, - source_commit: Option, - deployment_id: Option, - signature_algorithm: String, - #[serde(with = "serde_bytes")] - signature: Vec, - private_state_exposed: bool, - activation_witness_sha256: Option, -} - -impl IdunnSignedDaemonHealthRecord { - const SCHEMA: &'static str = "idunn.signed_daemon_health.v1"; - - fn validate(&self) -> Result<()> { - for (value, label) in [ - (&self.daemon_id, "daemon id"), - (&self.health_contract, "health contract"), - (&self.source_runtime_id, "source runtime id"), - (&self.signer_identity_id, "signer identity id"), - (&self.publisher_incarnation_id, "publisher incarnation id"), - ] { - require_id(value, label)?; - } - if self.schema_version != Self::SCHEMA - || !matches!( - self.state.as_str(), - "active" | "warming" | "degraded" | "failed" - ) - || self.detail.len() > 512 - || self.detail.chars().any(char::is_control) - || self.publisher_sequence == 0 - || self.observed_at_unix_millis == 0 - || self.signature_algorithm != "ed25519" - || self.signature.len() != 64 - || self.private_state_exposed - { - bail!("signed daemon health shape is invalid"); - } - IdunnReleaseBinding { - release_id: self - .release_id - .clone() - .ok_or_else(|| anyhow!("signed health release id is absent"))?, - release_witness_sha256: self - .release_witness_sha256 - .clone() - .ok_or_else(|| anyhow!("signed health release witness is absent"))?, - source_commit: self - .source_commit - .clone() - .ok_or_else(|| anyhow!("signed health source commit is absent"))?, - deployment_id: self - .deployment_id - .clone() - .ok_or_else(|| anyhow!("signed health deployment id is absent"))?, - } - .validate()?; - IdunnActivationBinding { - activation_witness_sha256: self - .activation_witness_sha256 - .clone() - .ok_or_else(|| anyhow!("signed health activation witness is absent"))?, - } - .validate()?; - Ok(()) - } -} - -pub(crate) struct IdunnHealthPublisher { - endpoint: SocketAddr, - daemon_id: String, - source_runtime_id: String, - health_contract: String, - signer: ServiceIdentitySigner, - release: IdunnReleaseBinding, - activation: IdunnActivationBinding, - publisher_incarnation_id: String, - publisher_sequence: u64, -} - -impl IdunnHealthPublisher { - pub(crate) fn open( - endpoint: SocketAddr, - daemon_id: impl Into, - source_runtime_id: impl Into, - health_contract: impl Into, - identity_store: impl AsRef, - release: IdunnReleaseBinding, - activation: IdunnActivationBinding, - ) -> Result { - let daemon_id = daemon_id.into(); - let source_runtime_id = source_runtime_id.into(); - let health_contract = health_contract.into(); - require_id(&daemon_id, "Idunn daemon id")?; - require_id(&source_runtime_id, "health source runtime id")?; - require_id(&health_contract, "Idunn health contract")?; - release.validate()?; - activation.validate()?; - Ok(Self { - endpoint, - daemon_id, - source_runtime_id, - health_contract, - signer: open_service_identity_at::( - identity_store.as_ref(), - )?, - release, - activation, - publisher_incarnation_id: uuid::Uuid::new_v4().to_string(), - publisher_sequence: 0, - }) + pub(crate) fn republish_active(&mut self) -> Result { + let lease_sha256 = self + .active_write_lease_sha256 + .clone() + .context("runtime has not entered Active")?; + self.publish("active", "world-owner-serving", Some(lease_sha256)) } - pub(crate) fn publish( + fn publish( &mut self, state: &str, detail: &str, - ) -> Result { - if !matches!(state, "active" | "warming" | "degraded" | "failed") { - bail!("unsupported Idunn daemon health state"); - } - if detail.len() > 512 || detail.chars().any(char::is_control) { - bail!("Idunn daemon health detail is oversized or contains control characters"); - } + write_lease_sha256: Option, + ) -> Result { + let record = self.signed_record(state, detail, write_lease_sha256)?; + let canonical_sha256 = publish_runtime_presence(self.endpoint, &record)?; + Ok(PublishedRuntimePresence { canonical_sha256 }) + } + + fn signed_record( + &mut self, + state: &str, + detail: &str, + write_lease_sha256: Option, + ) -> Result { self.publisher_sequence = self .publisher_sequence .checked_add(1) - .ok_or_else(|| anyhow!("Idunn health publisher sequence overflow"))?; - let mut record = IdunnSignedDaemonHealthRecord { - schema_version: IdunnSignedDaemonHealthRecord::SCHEMA.into(), - daemon_id: self.daemon_id.clone(), - health_contract: self.health_contract.clone(), - source_runtime_id: self.source_runtime_id.clone(), + .ok_or_else(|| anyhow!("runtime presence sequence overflow"))?; + let observed_at_unix_millis = u64::try_from(chrono::Utc::now().timestamp_millis())?; + let mut record = GameCultRuntimePresenceHealthRecord { + schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: self.expected.target.clone(), + expected_projection_sha256: self.expected.canonical_sha256()?, + plan_id: self.expected.plan_id.clone(), + incarnation_id: self.expected.incarnation_id.clone(), + sealed_release_id: self.expected.sealed_release_id.clone(), + activation_witness_sha256: self.activation.canonical_sha256()?, + state_schema_generation: self.expected.state_schema_generation.clone(), + state_contract_sha256: self.expected.state_contract_sha256.clone(), + runtime_id: self.expected.runtime_id.clone(), + runtime_instance_id: self.activation.runtime_instance_id.clone(), + bound_endpoint: Some(self.bound_endpoint.clone()), + capabilities: self.capabilities.clone(), + health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), state: state.into(), detail: detail.into(), - signer_identity_id: self.signer.entry().identity_id.clone(), - publisher_incarnation_id: self.publisher_incarnation_id.clone(), + write_lease_sha256, + signer_identity_id: self.provider_signer.entry().identity_id.clone(), publisher_sequence: self.publisher_sequence, - observed_at_unix_millis: chrono::Utc::now().timestamp_millis().try_into()?, - release_id: Some(self.release.release_id.clone()), - release_witness_sha256: Some(self.release.release_witness_sha256.clone()), - source_commit: Some(self.release.source_commit.clone()), - deployment_id: Some(self.release.deployment_id.clone()), + observed_at_unix_millis, signature_algorithm: "ed25519".into(), signature: Vec::new(), - private_state_exposed: false, - activation_witness_sha256: Some(self.activation.activation_witness_sha256.clone()), + activation_signer_identity_id: self.activation_signer.identity_id(), + activation_signature: Vec::new(), }; - let unsigned = canonical_unsigned_record(&record)?; - let proof = self - .signer - .sign::(&unsigned); - if proof.identity_id != record.signer_identity_id { - bail!("provider-health signer identity disagrees with record"); - } - record.signature = proof.signature; + let payload = record.canonical_proof_payload()?; + let provider_proof = self + .provider_signer + .sign::(&payload); + ensure!( + provider_proof.identity_id == record.signer_identity_id, + "provider signer identity changed while publishing" + ); + record.signature = provider_proof.signature; + record.activation_signature = self.activation_signer.sign_presence_proof(&record)?; record.validate()?; - let signed_health_sha256 = - publish_signed_health(self.endpoint, &self.source_runtime_id, &record)?; - Ok(PublishedHealthStatementIdentity { - daemon_id: record.daemon_id, - signed_health_sha256, - publisher_incarnation_id: record.publisher_incarnation_id, - publisher_sequence: record.publisher_sequence, - signer_identity_id: record.signer_identity_id, - }) - } - - pub(crate) fn public_key(&self) -> &[u8] { - &self.signer.entry().public_key + Ok(record) } } -fn publish_signed_health( - endpoint: SocketAddr, - source_runtime_id: &str, - signed: &IdunnSignedDaemonHealthRecord, -) -> Result { - signed.validate()?; - let payload = rmp_serde::to_vec(signed).context("encoding canonical signed Idunn health")?; - let decoded: IdunnSignedDaemonHealthRecord = rmp_serde::from_slice(&payload)?; - if decoded != *signed || rmp_serde::to_vec(&decoded)? != payload { - bail!("signed Idunn health encoding is noncanonical"); +fn runtime_dependency_bindings( + expected: &IdunnExpectedIncarnationRecord, + configured_odin: SocketAddr, + configured_connector: SocketAddr, +) -> Result { + ensure!( + expected.dependencies.len() == 3, + "Ghostlight Expected does not carry its exact three dependencies" + ); + let odin = required_managed_dependency(expected, "shared-infrastructure", ODIN_CAPABILITY)?; + let connector = required_managed_dependency(expected, "private", CONNECTOR_CAPABILITY)?; + let heimdall = required_managed_dependency(expected, "required", HEIMDALL_CAPABILITY)?; + + let odin_endpoint = parse_dependency_socket_endpoint( + odin.provider_endpoint + .as_deref() + .context("Expected Odin dependency has no endpoint")?, + &["rudp://", "udp://"], + "Odin dependency", + )?; + ensure!( + odin_endpoint == configured_odin, + "GHOSTLIGHT_ODIN_RUDP differs from Expected Odin" + ); + let connector_endpoint = parse_dependency_socket_endpoint( + connector + .provider_endpoint + .as_deref() + .context("Expected Connector dependency has no endpoint")?, + &["tcp://"], + "Connector dependency", + )?; + ensure!( + connector_endpoint == configured_connector, + "GHOSTLIGHT_CONTROLLER_CONNECTOR differs from Expected Connector" + ); + let heimdall_endpoint = parse_dependency_socket_endpoint( + heimdall + .provider_endpoint + .as_deref() + .context("Expected Heimdall dependency has no endpoint")?, + &["rudp://", "udp://"], + "Heimdall dependency", + )?; + + Ok(RuntimeDependencyBindings { + odin_rudp: odin_endpoint, + connector: connector_endpoint, + heimdall: HeimdallDependencyBinding { + provider_id: heimdall + .provider_id + .clone() + .context("Expected Heimdall dependency has no provider")?, + endpoint: heimdall_endpoint, + }, + }) +} + +fn required_managed_dependency<'a>( + expected: &'a IdunnExpectedIncarnationRecord, + kind: &str, + identity: (&str, &str, &str), +) -> Result<&'a IdunnExpectedDependency> { + let dependency = expected + .dependencies + .iter() + .find(|dependency| { + dependency.capability == identity.0 + && dependency.schema == identity.1 + && dependency.compatibility == identity.2 + }) + .with_context(|| format!("Expected omits dependency {}", identity.0))?; + ensure!( + dependency.kind == kind + && dependency.startup == "before-promotion" + && dependency.minimum_capacity > 0 + && dependency.provider_id.is_some() + && dependency.provider_authority.as_deref() == Some("managed-incarnation") + && dependency.provider_expected_projection_sha256.is_some(), + "Expected dependency {} is unresolved or authority-incoherent", + identity.0 + ); + Ok(dependency) +} + +fn parse_dependency_socket_endpoint( + endpoint: &str, + allowed_schemes: &[&str], + label: &str, +) -> Result { + let matched_scheme = allowed_schemes + .iter() + .copied() + .find(|scheme| endpoint.starts_with(scheme)); + let socket_text = match matched_scheme { + Some(scheme) => &endpoint[scheme.len()..], + None if !endpoint.contains("://") => endpoint, + None => bail!("{label} uses an unsupported endpoint scheme"), + }; + let socket: SocketAddr = socket_text + .parse() + .with_context(|| format!("{label} endpoint is not a socket address"))?; + let canonical = matched_scheme + .map(|scheme| format!("{scheme}{socket}")) + .unwrap_or_else(|| socket.to_string()); + ensure!(endpoint == canonical, "{label} endpoint is not canonical"); + Ok(socket) +} + +fn require_runtime_bundle(bundle: &Path) -> Result<()> { + ensure!( + bundle.is_absolute(), + "Idunn runtime bundle path is not absolute" + ); + #[cfg(target_os = "linux")] + { + let metadata = fs::symlink_metadata(bundle) + .with_context(|| format!("inspecting Idunn runtime bundle {}", bundle.display()))?; + ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.gid() == 0 + && metadata.permissions().mode() & 0o022 == 0, + "Idunn runtime bundle is not a root-owned service-read-only directory" + ); + require_root_controlled_directory_chain( + bundle + .parent() + .context("Idunn runtime bundle has no parent")?, + "Idunn runtime bundle parent", + )?; + for path in [ + bundle.join("expected.cc"), + sibling_lock_path(&bundle.join("expected.cc")), + bundle.join("activation.cc"), + sibling_lock_path(&bundle.join("activation.cc")), + ] { + require_root_read_only_file(&path, "Idunn runtime bundle document")?; + } } - let signed_health_sha256 = format!("sha256-{}", hex(&Sha256::digest(&payload))); + Ok(()) +} + +fn require_process_write_lease_parent(path: &Path) -> Result<()> { + ensure!(path.is_absolute(), "write-lease path is not absolute"); + #[cfg(target_os = "linux")] + { + let parent = path + .parent() + .context("process write-lease path has no parent")?; + let metadata = fs::symlink_metadata(parent).with_context(|| { + format!("inspecting process write-lease parent {}", parent.display()) + })?; + ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.permissions().mode() & 0o022 == 0, + "process write-lease parent is not root-owned and service-nonwritable" + ); + require_root_controlled_directory_chain( + parent + .parent() + .context("process write-lease parent has no parent")?, + "process write-lease ancestor", + )?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn require_process_write_lease_custody(path: &Path) -> Result<()> { + require_process_write_lease_parent(path)?; + let parent = path + .parent() + .context("process write-lease path has no parent")?; + let parent_metadata = fs::symlink_metadata(parent)?; + for authority_file in [path.to_owned(), sibling_lock_path(path)] { + let metadata = fs::symlink_metadata(&authority_file).with_context(|| { + format!( + "inspecting process write-lease authority {}", + authority_file.display() + ) + })?; + ensure!( + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.gid() == parent_metadata.gid() + && metadata.permissions().mode() & 0o022 == 0 + && metadata.nlink() == 1, + "process write-lease authority {} is not one root-owned service-nonwritable file", + authority_file.display() + ); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn require_process_write_lease_custody(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(target_os = "linux")] +fn require_root_read_only_file(path: &Path, label: &str) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspecting {label} {}", path.display()))?; + ensure!( + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.gid() == 0 + && metadata.permissions().mode() & 0o222 == 0 + && metadata.nlink() == 1, + "{label} is not one root-owned read-only regular file" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn require_root_controlled_directory_chain(path: &Path, label: &str) -> Result<()> { + for directory in path.ancestors() { + let metadata = fs::symlink_metadata(directory) + .with_context(|| format!("inspecting {label} {}", directory.display()))?; + ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == 0 + && metadata.permissions().mode() & 0o022 == 0, + "{label} contains a non-root-controlled directory" + ); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn inherited_authority_files() -> Result<(File, File)> { + let listen_pid = std::env::var("LISTEN_PID").context("systemd did not bind authority FDs")?; + let listen_fds = std::env::var("LISTEN_FDS").context("systemd did not bind authority FDs")?; + let listen_fd_names = + std::env::var("LISTEN_FDNAMES").context("systemd did not name authority FDs")?; + authority_fd_layout( + std::process::id(), + &listen_pid, + &listen_fds, + &listen_fd_names, + )?; + ensure_descriptor_is_open(SYSTEMD_LISTEN_FDS_START, "runtime activation signer")?; + ensure_descriptor_is_open( + SYSTEMD_LISTEN_FDS_START + 1, + "stable runtime-presence signer", + )?; + // SAFETY: the exact systemd LISTEN_* contract above assigns these two + // distinct, verified-open descriptors to this PID. This function is their + // first consumer and takes sole ownership of both. + let activation = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START) }; + // SAFETY: same ownership proof; fd4 is distinct from fd3. + let provider = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START + 1) }; + // OpenFile descriptors deliberately arrive without close-on-exec. Seal + // both before inspecting either so even a rejected first credential + // cannot leave the second signer inheritable on the error path. + mark_descriptor_close_on_exec(&activation, "runtime activation signer")?; + mark_descriptor_close_on_exec(&provider, "stable runtime-presence signer")?; + protect_inherited_authority_file(&activation, Some(32), "runtime activation signer")?; + protect_inherited_authority_file(&provider, None, "stable runtime-presence signer")?; + Ok((activation, provider)) +} + +#[cfg(not(target_os = "linux"))] +fn inherited_authority_files() -> Result<(File, File)> { + bail!("Idunn authority FDs require the managed Linux runtime") +} + +#[cfg(target_os = "linux")] +fn authority_fd_layout( + process_id: u32, + listen_pid: &str, + listen_fds: &str, + listen_fd_names: &str, +) -> Result<()> { + ensure!( + listen_pid.parse::()? == process_id && listen_pid == process_id.to_string(), + "systemd authority FDs belong to another process" + ); + ensure!( + listen_fds.parse::()? == 2 && listen_fds == "2", + "systemd authority FD count is not exact" + ); + let names = listen_fd_names.split(':').collect::>(); + ensure!( + names + == [ + IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, + RUNTIME_PRESENCE_IDENTITY_FD_NAME, + ], + "systemd authority FD names or order differ from the launch contract" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_descriptor_is_open(fd: RawFd, label: &str) -> Result<()> { + // SAFETY: F_GETFD observes one integer descriptor and transfers no + // ownership. Failure leaves the descriptor untouched. + if unsafe { libc::fcntl(fd, libc::F_GETFD) } == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("opening inherited {label} descriptor")); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn protect_inherited_authority_file( + file: &File, + exact_size: Option, + label: &str, +) -> Result<()> { + let metadata = file.metadata()?; + ensure!( + metadata.is_file() + && metadata.uid() == 0 + && metadata.gid() == 0 + && metadata.permissions().mode() & 0o777 == 0o400 + && metadata.nlink() == 1 + && exact_size.map_or(metadata.len() > 0, |size| metadata.len() == size), + "inherited {label} descriptor is not one exact root-owned 0400 file" + ); + // SAFETY: F_GETFL observes integer flags on the live descriptor owned by + // `file` and transfers no ownership. + let status_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + if status_flags == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("reading inherited {label} access mode")); + } + ensure!( + status_flags & libc::O_ACCMODE == libc::O_RDONLY, + "inherited {label} descriptor is not read-only" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn mark_descriptor_close_on_exec(file: &File, label: &str) -> Result<()> { + let descriptor_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + if descriptor_flags == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("reading inherited {label} descriptor flags")); + } + // SAFETY: F_SETFD mutates flags on the descriptor owned by `file`; it + // transfers no ownership and dereferences no pointer. + if unsafe { + libc::fcntl( + file.as_raw_fd(), + libc::F_SETFD, + descriptor_flags | libc::FD_CLOEXEC, + ) + } == -1 + { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("protecting inherited {label} from child inheritance")); + } + Ok(()) +} + +pub(crate) struct ProcessWriteLeaseGuard { + path: PathBuf, + record: IdunnProcessWriteLeaseRecord, + canonical_sha256: String, + enforce_custody: bool, + release: Option>, + worker: Option>, +} + +impl ProcessWriteLeaseGuard { + fn acquire( + path: PathBuf, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + warming_presence_sha256: String, + ) -> Result { + Self::acquire_inner(path, expected, activation, warming_presence_sha256, true) + } + + #[cfg(test)] + fn acquire_fixture( + path: PathBuf, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + warming_presence_sha256: String, + ) -> Result { + Self::acquire_inner(path, expected, activation, warming_presence_sha256, false) + } + + fn acquire_inner( + path: PathBuf, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + warming_presence_sha256: String, + enforce_custody: bool, + ) -> Result { + if enforce_custody { + require_process_write_lease_custody(&path)?; + } + let worker_path = path.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::channel(); + let worker = thread::Builder::new() + .name("ghostlight-write-lease".into()) + .spawn(move || { + let store = SingleFileMessagePackBackingStore::new(&worker_path); + let result = store.with_read_only_shared_snapshot(|entries| { + if enforce_custody { + require_process_write_lease_custody(&worker_path)?; + } + let record = decode_write_lease( + &entries, + &expected, + &activation, + &warming_presence_sha256, + )?; + ready_tx + .send(Ok(record)) + .map_err(|_| anyhow!("write-lease receiver stopped"))?; + let _ = release_rx.recv(); + Ok(()) + }); + if let Err(error) = result { + let _ = ready_tx.send(Err(format!("{error:#}"))); + } + })?; + match ready_rx + .recv() + .context("write-lease custodian stopped before admission")? + { + Ok(record) => Ok(Self { + canonical_sha256: record.canonical_sha256()?, + path, + record, + enforce_custody, + release: Some(release_tx), + worker: Some(worker), + }), + Err(error) => { + let _ = worker.join(); + bail!("process write lease was not admitted: {error}") + } + } + } + + pub(crate) fn canonical_sha256(&self) -> &str { + &self.canonical_sha256 + } + + pub(crate) fn require_current(&self) -> Result<()> { + if self.enforce_custody { + require_process_write_lease_custody(&self.path)?; + } + let current = + SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()?; + let [envelope] = current.as_slice() else { + bail!("process write-lease store is absent or ambiguous"); + }; + ensure!( + envelope.key == self.record.target + && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA) + && IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)? + == self.record, + "process write lease changed after admission" + ); + Ok(()) + } +} + +impl Drop for ProcessWriteLeaseGuard { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn actual_capabilities() -> Vec { + vec![ + GameCultRuntimeCapability { + capability: "gamecult.eve.surface-provider".into(), + schema: "gamecult.eve.surface.v1".into(), + compatibility: "v1".into(), + capacity: 1, + }, + GameCultRuntimeCapability { + capability: "ghostlight.world-service".into(), + schema: "ghostlight.world_state.foundation.v0".into(), + compatibility: "foundation-v0".into(), + capacity: 1, + }, + ] +} + +fn read_expected(path: &Path) -> Result { + let envelope = read_single_envelope(path)?; + ensure!( + envelope.r#type == IdunnExpectedIncarnationRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_EXPECTED_INCARNATION_SCHEMA), + "runtime bundle Expected envelope has the wrong schema" + ); + let expected = IdunnExpectedIncarnationRecord::decode_canonical(&envelope.payload)?; + ensure!( + envelope.key == expected.target, + "Expected envelope key differs from target" + ); + Ok(expected) +} + +fn read_activation(path: &Path, target: &str) -> Result { + let envelope = read_single_envelope(path)?; + ensure!( + envelope.key == target + && envelope.r#type == IdunnRuntimeActivationRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA), + "runtime bundle activation envelope has the wrong identity" + ); + IdunnRuntimeActivationRecord::decode_canonical(&envelope.payload) +} + +fn read_single_envelope(path: &Path) -> Result { + let entries = SingleFileMessagePackBackingStore::new(path) + .pull_all_read_only_snapshot() + .with_context(|| format!("reading immutable runtime document {}", path.display()))?; + let [envelope] = entries.as_slice() else { + bail!("runtime document {} is absent or ambiguous", path.display()); + }; + Ok(envelope.clone()) +} + +fn decode_write_lease( + entries: &[CultCacheEnvelope], + expected: &IdunnExpectedIncarnationRecord, + activation: &IdunnRuntimeActivationRecord, + warming_presence_sha256: &str, +) -> Result { + let [envelope] = entries else { + bail!("process write-lease store is absent or ambiguous"); + }; + ensure!( + envelope.key == expected.target + && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA), + "process write-lease envelope has the wrong identity" + ); + let lease = IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)?; + ensure!( + lease.target == expected.target + && lease.expected_projection_sha256 == expected.canonical_sha256()? + && lease.plan_id == expected.plan_id + && lease.incarnation_id == expected.incarnation_id + && lease.sealed_release_id == expected.sealed_release_id + && lease.activation_witness_sha256 == activation.canonical_sha256()? + && lease.state_schema_generation + == expected + .state_schema_generation + .as_deref() + .context("Expected has no state schema generation")? + && lease.state_contract_sha256 + == expected + .state_contract_sha256 + .as_deref() + .context("Expected has no state contract")? + && lease.runtime_id == expected.runtime_id + && lease.runtime_instance_id == activation.runtime_instance_id + && lease.warming_presence_sha256 == warming_presence_sha256, + "process write lease does not bind this exact warming incarnation" + ); + Ok(lease) +} + +fn publish_runtime_presence( + endpoint: SocketAddr, + record: &GameCultRuntimePresenceHealthRecord, +) -> Result { + record.validate()?; + let payload = rmp_serde::to_vec(record).context("encoding canonical runtime presence")?; + let decoded: GameCultRuntimePresenceHealthRecord = rmp_serde::from_slice(&payload)?; + ensure!( + decoded == *record && rmp_serde::to_vec(&decoded)? == payload, + "runtime presence encoding is noncanonical" + ); + let canonical_sha256 = record.canonical_sha256()?; let message = CultNetMessage::DocumentPutRaw { message_id: format!( - "ghostlight-signed-health:{}:{}:{}", - signed.daemon_id, signed.publisher_incarnation_id, signed.publisher_sequence + "runtime-presence:{}:{}:{}", + record.target, record.runtime_instance_id, record.publisher_sequence ), document: CultNetRawDocumentRecord { - schema_id: IdunnSignedDaemonHealthRecord::SCHEMA.into(), - record_key: signed.daemon_id.clone(), + schema_id: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + record_key: record.target.clone(), stored_at: chrono::DateTime::from_timestamp_millis( - signed.observed_at_unix_millis.try_into()?, + record.observed_at_unix_millis.try_into()?, ) - .context("signed health observation time is invalid")? + .context("runtime presence timestamp is invalid")? .to_rfc3339(), payload_encoding: CultNetRawPayloadEncoding::Messagepack, payload, - source_runtime_id: Some(source_runtime_id.into()), - source_agent_id: Some(signed.signer_identity_id.clone()), - source_role: Some("daemon-health-publisher".into()), - tags: Some(vec![CULTNET_RUDP_PROTOCOL_ID.into()]), + source_runtime_id: Some(record.runtime_id.clone()), + source_agent_id: Some(record.signer_identity_id.clone()), + source_role: Some("runtime-presence-publisher".into()), + tags: Some(vec![ + CULTNET_RUDP_PROTOCOL_ID.into(), + "runtime-presence".into(), + ]), }, }; let bind = if endpoint.is_ipv4() { @@ -467,14 +990,14 @@ fn publish_signed_health( "[::]:0" }; let socket = UdpSocket::bind(bind) - .with_context(|| format!("binding Ghostlight Idunn RUDP sender at {bind}"))?; + .with_context(|| format!("binding Ghostlight CultNet sender at {bind}"))?; socket.set_read_timeout(Some(Duration::from_millis(100)))?; let mut transport = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( - source_runtime_id, + &record.runtime_id, socket, endpoint, - IDUNN_HEALTH_RUDP_CONNECTION_ID, + ODIN_CULTMESH_CATALOG_CONNECTION_ID, ))?; transport.connect(Vec::new())?; let deadline = Instant::now() + Duration::from_millis(500); @@ -483,618 +1006,389 @@ fn publish_signed_health( transport.poll_resends()?; if Instant::now() >= deadline { return Err(anyhow!( - "timed out connecting Ghostlight health publisher to {endpoint}" + "timed out connecting runtime-presence publisher to {endpoint}" )); } } - transport.send( + let receipt = transport.send_reliable( "schema", encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?, )?; - Ok(signed_health_sha256) -} - -fn parse_activation_witness(bytes: &[u8]) -> Result { - let text = std::str::from_utf8(bytes).context("Ghostlight activation witness is not UTF-8")?; - let body = text - .strip_suffix('\n') - .ok_or_else(|| anyhow!("Ghostlight activation witness has no canonical final newline"))?; - if body.contains('\r') { - bail!("Ghostlight activation witness has noncanonical line endings"); - } - let lines = body.split('\n').collect::>(); - let [ - schema, - activation_id, - daemon_id, - release_id, - release_witness_sha256, - source_commit, - deployment_id, - unit_path, - unit_sha256, - runtime_environment_path, - runtime_environment_sha256, - ] = lines.as_slice() - else { - bail!("Ghostlight activation witness has an unexpected field set"); - }; - if exact_witness_value(schema, "schema_version")? != GHOSTLIGHT_ACTIVATION_SCHEMA { - bail!("Ghostlight activation witness schema is not admitted"); - } - let witness = ActivationWitness { - activation_id: exact_witness_value(activation_id, "activation_id")?.into(), - daemon_id: exact_witness_value(daemon_id, "daemon_id")?.into(), - release_id: exact_witness_value(release_id, "release_id")?.into(), - release_witness_sha256: exact_witness_value( - release_witness_sha256, - "release_witness_sha256", - )? - .into(), - source_commit: exact_witness_value(source_commit, "source_commit")?.into(), - deployment_id: exact_witness_value(deployment_id, "deployment_id")?.into(), - unit_path: exact_witness_value(unit_path, "unit_path")?.into(), - unit_sha256: exact_witness_value(unit_sha256, "unit_sha256")?.into(), - runtime_environment_path: exact_witness_value( - runtime_environment_path, - "runtime_environment_path", - )? - .into(), - runtime_environment_sha256: exact_witness_value( - runtime_environment_sha256, - "runtime_environment_sha256", - )? - .into(), - }; - validate_activation_witness(&witness)?; - Ok(witness) -} - -fn exact_witness_value<'a>(line: &'a str, field: &str) -> Result<&'a str> { - let prefix = format!("{field}="); - let value = line - .strip_prefix(&prefix) - .with_context(|| format!("Ghostlight activation witness field {field} is out of order"))?; - if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { - bail!("Ghostlight activation witness field {field} is malformed"); - } - Ok(value) -} - -fn validate_activation_witness(witness: &ActivationWitness) -> Result<()> { - require_safe_token(&witness.activation_id, "activation id")?; - require_safe_token(&witness.deployment_id, "deployment id")?; - require_id(&witness.release_id, "release id")?; - if witness.daemon_id != TRAFFIC_ADMISSION_KEY - || witness.unit_path != GHOSTLIGHT_UNIT_PATH - || witness.runtime_environment_path != GHOSTLIGHT_RUNTIME_ENVIRONMENT_PATH - || !is_lower_hex(&witness.source_commit, 40) - { - bail!("Ghostlight activation witness identity is malformed"); - } - require_sha256(&witness.release_witness_sha256, "release witness digest")?; - require_sha256(&witness.unit_sha256, "systemd unit digest")?; - require_sha256( - &witness.runtime_environment_sha256, - "runtime environment digest", - )?; - Ok(()) -} - -fn traffic_admission_expectation( - release: &IdunnReleaseBinding, - activation: &IdunnActivationBinding, - published: &PublishedHealthStatementIdentity, -) -> Result { - release.validate()?; - activation.validate()?; - if published.daemon_id != TRAFFIC_ADMISSION_KEY || published.publisher_sequence != 1 { - bail!("signed health identity for traffic admission is malformed"); - } - require_sha256( - &published.signed_health_sha256, - "signed health statement digest", - )?; - require_id( - &published.signer_identity_id, - "traffic admission signer identity", - )?; - uuid::Uuid::parse_str(&published.publisher_incarnation_id) - .context("traffic admission publisher incarnation is malformed")?; - let process = current_runtime_process_instance()?; - let expected = TrafficAdmissionConsumerProjection { - schema_version: TRAFFIC_ADMISSION_SCHEMA.into(), - daemon_id: published.daemon_id.clone(), - release_id: release.release_id.clone(), - release_witness_sha256: release.release_witness_sha256.clone(), - source_commit: release.source_commit.clone(), - deployment_id: release.deployment_id.clone(), - activation_witness_sha256: activation.activation_witness_sha256.clone(), - signed_health_sha256: published.signed_health_sha256.clone(), - publisher_incarnation_id: published.publisher_incarnation_id.clone(), - publisher_sequence: published.publisher_sequence, - signer_identity_id: published.signer_identity_id.clone(), - runtime_process_id: process.process_id, - runtime_process_starttime_ticks: process.starttime_ticks, - }; - validate_traffic_admission_projection(&expected)?; - Ok(expected) -} - -fn decode_traffic_admission_entries( - entries: &[CultCacheEnvelope], -) -> Result { - let [record] = entries else { - bail!("Ghostlight traffic admission store must contain exactly one record"); - }; - if record.key != TRAFFIC_ADMISSION_KEY - || record.r#type != TRAFFIC_ADMISSION_TYPE - || record.schema_id.as_deref() != Some(TRAFFIC_ADMISSION_SCHEMA) - { - bail!("Ghostlight traffic admission store has the wrong typed envelope"); - } - let admitted: TrafficAdmissionConsumerProjection = rmp_serde::from_slice(&record.payload) - .context("decoding typed Ghostlight traffic admission record")?; - if rmp_serde::to_vec(&admitted)? != record.payload { - bail!("Ghostlight traffic admission payload is not canonical positional MessagePack"); - } - validate_traffic_admission_projection(&admitted)?; - Ok(admitted) -} - -fn validate_traffic_admission_projection( - admission: &TrafficAdmissionConsumerProjection, -) -> Result<()> { - if admission.schema_version != TRAFFIC_ADMISSION_SCHEMA - || admission.daemon_id != TRAFFIC_ADMISSION_KEY - || admission.publisher_sequence == 0 - || admission.runtime_process_id == 0 - || admission.runtime_process_starttime_ticks == 0 - || !is_lower_hex(&admission.source_commit, 40) - { - bail!("Ghostlight traffic admission typed identity is invalid"); - } - require_id(&admission.release_id, "traffic admission release id")?; - require_safe_token(&admission.deployment_id, "traffic admission deployment id")?; - require_id( - &admission.signer_identity_id, - "traffic admission signer identity", - )?; - uuid::Uuid::parse_str(&admission.publisher_incarnation_id) - .context("traffic admission publisher incarnation is malformed")?; - require_sha256( - &admission.release_witness_sha256, - "traffic admission release witness digest", - )?; - require_sha256( - &admission.activation_witness_sha256, - "traffic admission activation witness digest", - )?; - require_sha256( - &admission.signed_health_sha256, - "traffic admission signed health digest", - )?; - Ok(()) -} - -fn current_runtime_process_instance() -> Result { - let process_id = std::process::id(); - if process_id == 0 { - bail!("current Ghostlight process id is invalid"); - } - let stat = std::fs::read_to_string("/proc/self/stat") - .context("reading current Ghostlight /proc starttime")?; - Ok(RuntimeProcessInstanceBinding { - process_id, - starttime_ticks: parse_proc_stat_starttime(&stat, process_id)?, - }) -} - -fn parse_proc_stat_starttime(stat: &str, expected_process_id: u32) -> Result { - let stat = stat.strip_suffix('\n').unwrap_or(stat); - if stat.contains('\r') || stat.contains('\n') { - bail!("current Ghostlight /proc stat has noncanonical line endings"); - } - let prefix = format!("{expected_process_id} ("); - if !stat.starts_with(&prefix) { - bail!("current Ghostlight /proc stat has the wrong process id"); - } - let command_end = stat - .rfind(") ") - .context("current Ghostlight /proc stat has no command terminator")?; - let fields = stat[command_end + 2..] - .split_ascii_whitespace() - .collect::>(); - if fields.len() <= 19 || fields[0].len() != 1 { - bail!("current Ghostlight /proc stat is missing starttime"); - } - let value = fields[19]; - let starttime_ticks = value - .parse::() - .context("current Ghostlight /proc starttime is malformed")?; - if starttime_ticks == 0 || starttime_ticks.to_string() != value { - bail!("current Ghostlight /proc starttime is noncanonical"); - } - Ok(starttime_ticks) -} - -fn require_exact_traffic_admission( - admitted: &TrafficAdmissionConsumerProjection, - expected: &TrafficAdmissionConsumerProjection, -) -> Result<()> { - if admitted != expected { - bail!("root traffic admission does not match the exact startup statement"); - } - Ok(()) -} - -fn require_sealed_traffic_admission_files(path: &Path, ghostlight_gid: u32) -> Result<()> { - #[cfg(not(target_os = "linux"))] - bail!("Ghostlight traffic admission sealing requires Linux"); - - #[cfg(target_os = "linux")] - { - if path != Path::new(TRAFFIC_ADMISSION_PATH) { - bail!("Ghostlight traffic admission is not at its fixed path"); - } - let parent = path - .parent() - .context("Ghostlight traffic admission has no parent")?; - let expected_parent = Path::new("/etc/gamecult/ghostlight-dungeon/runtime"); - if parent != expected_parent { - bail!("Ghostlight traffic admission parent is not canonical"); - } - require_exact_root_group_mode( - parent, - "traffic admission directory", - ghostlight_gid, - 0o750, - true, - )?; - require_exact_root_group_mode( - path, - "traffic admission record", - ghostlight_gid, - 0o640, - false, - )?; - let lock_path = sibling_lock_path(path); - require_exact_root_group_mode( - &lock_path, - "traffic admission shared lock", - ghostlight_gid, - 0o640, - false, - )?; - for ancestor in parent.ancestors().skip(1) { - let metadata = std::fs::symlink_metadata(ancestor).with_context(|| { - format!( - "inspecting traffic admission ancestor {}", - ancestor.display() - ) - })?; - if metadata.file_type().is_symlink() - || metadata.uid() != 0 - || metadata.mode() & 0o022 != 0 - { - bail!("traffic admission ancestor is indirect or mutable outside root"); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match transport.reliable_send_status(&receipt) { + CultNetRudpReliableSendStatus::Acknowledged => break, + CultNetRudpReliableSendStatus::Invalidated => { + bail!("runtime-presence acknowledgement was invalidated") } + CultNetRudpReliableSendStatus::Pending => {} } - Ok(()) - } -} - -#[cfg(target_os = "linux")] -fn require_exact_root_group_mode( - path: &Path, - label: &str, - group_id: u32, - mode: u32, - directory: bool, -) -> Result<()> { - let metadata = std::fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - if metadata.file_type().is_symlink() - || metadata.uid() != 0 - || metadata.gid() != group_id - || metadata.mode() & 0o7777 != mode - || (directory && !metadata.file_type().is_dir()) - || (!directory && !metadata.file_type().is_file()) - { - bail!("{label} does not have the exact root:ghostlight sealed shape"); - } - Ok(()) -} - -fn local_group_id(name: &str) -> Result { - #[cfg(not(target_os = "linux"))] - bail!("local group identity verification requires Linux"); - - #[cfg(target_os = "linux")] - { - let bytes = read_root_controlled_file(Path::new("/etc/group"), "local group database")?; - let text = std::str::from_utf8(&bytes).context("local group database is not UTF-8")?; - let mut found = None; - for line in text.lines() { - let fields = line.split(':').collect::>(); - if fields.first().copied() != Some(name) { - continue; - } - let value = fields - .get(2) - .filter(|value| !value.is_empty()) - .context("local Ghostlight group has no numeric id")?; - let group_id = value - .parse::() - .context("local Ghostlight group id is malformed")?; - if group_id.to_string() != *value || found.replace(group_id).is_some() { - bail!("local Ghostlight group identity is noncanonical or duplicate"); - } + let _ = transport.receive_once()?; + transport.poll_resends()?; + if Instant::now() >= deadline { + bail!("timed out awaiting exact runtime-presence acknowledgement from {endpoint}"); } - found.context("local Ghostlight group is absent") } + Ok(canonical_sha256) } fn sibling_lock_path(path: &Path) -> PathBuf { - let mut lock_name = path + let mut name = path .file_name() .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.cc".into()); - lock_name.push(".lock"); - path.with_file_name(lock_name) -} - -fn read_root_controlled_file(path: &Path, label: &str) -> Result> { - #[cfg(not(target_os = "linux"))] - bail!("{label} root-control verification requires Linux"); - - #[cfg(target_os = "linux")] - { - if !path.is_absolute() { - bail!("{label} path is not absolute"); - } - for candidate in path.ancestors() { - let metadata = std::fs::symlink_metadata(candidate) - .with_context(|| format!("inspecting {label} path {}", candidate.display()))?; - if metadata.file_type().is_symlink() - || metadata.uid() != 0 - || metadata.mode() & 0o022 != 0 - { - bail!("{label} path is indirect or mutable outside root"); - } - } - let metadata = std::fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - if !metadata.file_type().is_file() { - bail!("{label} is not a regular file"); - } - std::fs::read(path).with_context(|| format!("reading {label} {}", path.display())) - } -} - -fn canonical_unsigned_record(record: &IdunnSignedDaemonHealthRecord) -> Result> { - let mut unsigned = record.clone(); - unsigned.signature.clear(); - let mut signed_shape = unsigned.clone(); - signed_shape.signature = vec![0; 64]; - signed_shape.validate()?; - uuid::Uuid::parse_str(&unsigned.publisher_incarnation_id) - .context("publisher incarnation id must be UUID")?; - rmp_serde::to_vec(&unsigned).context("encoding canonical unsigned Idunn health") -} - -fn require_id(value: &str, label: &str) -> Result<()> { - if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { - bail!("{label} is empty, oversized, or contains control characters"); - } - Ok(()) -} - -fn require_safe_token(value: &str, label: &str) -> Result<()> { - if value.is_empty() - || value.len() > 256 - || !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) - { - bail!("{label} is malformed"); - } - Ok(()) -} - -fn require_sha256(value: &str, label: &str) -> Result<()> { - if !value - .strip_prefix("sha256-") - .is_some_and(|digest| is_lower_hex(digest, 64)) - { - bail!("{label} is malformed"); - } - Ok(()) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + .unwrap_or_else(|| "authority.cc".into()); + name.push(".lock"); + path.with_file_name(name) } #[cfg(test)] mod tests { use super::*; use cultnet_rs::{ - GameCultProviderHealthIdentity, IdunnSignedDaemonHealthPurpose, enroll_service_identity_at, - verify_service_identity_signature, + GameCultRuntimePresenceHealthPurpose, IdunnExpectedCapability, IdunnExpectedRoute, + IdunnRuntimeActivationLaunch, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, + authenticate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, }; - #[test] - fn ghostlight_health_is_canonical_private_free_and_release_bound() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("provider.cc"); - let signer = enroll_service_identity_at::(&path).unwrap(); - let mut publisher = IdunnHealthPublisher::open( - "127.0.0.1:17870".parse().unwrap(), - "yggdrasil-ghostlight", - "ghostlight-dungeon-yggdrasil", - GHOSTLIGHT_IDUNN_HEALTH_CONTRACT, - &path, - IdunnReleaseBinding { - release_id: "release-1".into(), - release_witness_sha256: format!("sha256-{}", "a".repeat(64)), - source_commit: "b".repeat(40), - deployment_id: "deployment-1".into(), - }, - IdunnActivationBinding { - activation_witness_sha256: format!("sha256-{}", "c".repeat(64)), - }, - ) - .unwrap(); - publisher.publisher_sequence = 7; - let mut record = IdunnSignedDaemonHealthRecord { - schema_version: IdunnSignedDaemonHealthRecord::SCHEMA.into(), - daemon_id: publisher.daemon_id.clone(), - health_contract: publisher.health_contract.clone(), - source_runtime_id: publisher.source_runtime_id.clone(), - state: "active".into(), - detail: "world-kernel-serving".into(), - signer_identity_id: publisher.signer.entry().identity_id.clone(), - publisher_incarnation_id: publisher.publisher_incarnation_id.clone(), - publisher_sequence: publisher.publisher_sequence, - observed_at_unix_millis: 1_787_315_696_789, - release_id: Some(publisher.release.release_id.clone()), - release_witness_sha256: Some(publisher.release.release_witness_sha256.clone()), - source_commit: Some(publisher.release.source_commit.clone()), - deployment_id: Some(publisher.release.deployment_id.clone()), - signature_algorithm: "ed25519".into(), - signature: Vec::new(), - private_state_exposed: false, - activation_witness_sha256: Some(publisher.activation.activation_witness_sha256.clone()), + fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) + } + + struct Fixture { + publisher: RuntimePresencePublisher, + authority: cultnet_rs::VerifiedRuntimeAuthority, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + } + + fn managed_dependency( + kind: &str, + capability: &str, + schema: &str, + compatibility: &str, + provider_id: &str, + endpoint: &str, + ) -> IdunnExpectedDependency { + IdunnExpectedDependency { + kind: kind.into(), + capability: capability.into(), + schema: schema.into(), + compatibility: compatibility.into(), + minimum_capacity: 1, + startup: "before-promotion".into(), + provider_id: Some(provider_id.into()), + provider_authority: Some("managed-incarnation".into()), + provider_expected_projection_sha256: Some(digest('a')), + provider_endpoint: Some(endpoint.into()), + } + } + + fn fixture_with_state_contract(root: &Path, state_contract_sha256: &str) -> Result { + let provider_path = root.join("provider.cc"); + let provider = + enroll_service_identity_at::(&provider_path)?; + let provider_public_key = provider.entry().public_key.clone(); + let idunn = enroll_service_identity_at::(&root.join("idunn.cc"))?; + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: TARGET.into(), + plan_id: digest('1'), + incarnation_id: "ghostlight-incarnation-1".into(), + sealed_release_id: digest('2'), + source_repository: "github.com/GameCult/Ghostlight".into(), + source_revision: "3".repeat(40), + recipe_sha256: digest('4'), + runtime_id: "ghostlight-yggdrasil".into(), + expected_signer_identity_id: provider.entry().identity_id.clone(), + health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), + artifact_sha256: digest('5'), + state_schema_generation: Some(STATE_SCHEMA_GENERATION.into()), + state_contract_sha256: Some(state_contract_sha256.into()), + write_lease_required: true, + route: Some(IdunnExpectedRoute { + route_id: "ghostlight-http".into(), + transport: "http".into(), + stable_endpoint: "https://ghostlight.gamecult.org/".into(), + candidate_endpoint: "http://127.0.0.1:18831".into(), + }), + capabilities: vec![ + IdunnExpectedCapability { + capability: "gamecult.eve.surface-provider".into(), + schema: "gamecult.eve.surface.v1".into(), + compatibility: "v1".into(), + minimum_capacity: 1, + }, + IdunnExpectedCapability { + capability: "ghostlight.world-service".into(), + schema: "ghostlight.world_state.foundation.v0".into(), + compatibility: "foundation-v0".into(), + minimum_capacity: 1, + }, + ], + dependencies: vec![ + managed_dependency( + "private", + CONNECTOR_CAPABILITY.0, + CONNECTOR_CAPABILITY.1, + CONNECTOR_CAPABILITY.2, + "connector-yggdrasil", + "tcp://127.0.0.1:4103", + ), + managed_dependency( + "required", + HEIMDALL_CAPABILITY.0, + HEIMDALL_CAPABILITY.1, + HEIMDALL_CAPABILITY.2, + "heimdall-yggdrasil", + "rudp://127.0.0.1:4101", + ), + managed_dependency( + "shared-infrastructure", + ODIN_CAPABILITY.0, + ODIN_CAPABILITY.1, + ODIN_CAPABILITY.2, + "odin-yggdrasil", + "rudp://127.0.0.1:9", + ), + ], }; - let unsigned = canonical_unsigned_record(&record).unwrap(); - let proof = publisher - .signer - .sign::(&unsigned); - record.signature = proof.signature.clone(); - record.validate().unwrap(); - verify_service_identity_signature::< + let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), 100, &idunn)?; + let activation = launch.activation().clone(); + let mut activation_credential = Vec::new(); + launch.write_credential(&mut activation_credential)?; + let activation_signer = + IdunnRuntimeActivationSigner::from_credential_reader(&activation_credential[..])?; + let provider_signer = open_service_identity_credential_reader::< GameCultProviderHealthIdentity, - IdunnSignedDaemonHealthPurpose, - >(&signer.trust_anchor().unwrap(), &unsigned, &proof) - .unwrap(); - assert_eq!(record.release_id.as_deref(), Some("release-1")); - assert!(!record.private_state_exposed); - let activation_digest = format!("sha256-{}", "c".repeat(64)); - assert_eq!( - record.activation_witness_sha256.as_deref(), - Some(activation_digest.as_str()) - ); - assert_eq!(rmp_serde::to_vec(&record).unwrap()[..3], [0xdc, 0, 18]); + >(File::open(&provider_path)?)?; + let authority = verify_runtime_authority( + &expected, + &activation, + &idunn.trust_anchor()?, + &provider_public_key, + )?; + let publisher = RuntimePresencePublisher::new( + "127.0.0.1:9".parse()?, + "127.0.0.1:4103".parse()?, + "127.0.0.1:18831".parse()?, + expected.clone(), + activation.clone(), + provider_signer, + activation_signer, + root.join("write-lease.cc"), + )?; + Ok(Fixture { + publisher, + authority, + expected, + activation, + }) } - #[test] - fn activation_witness_is_strict_ordered_and_launch_bound() { - let text = format!( - "schema_version={GHOSTLIGHT_ACTIVATION_SCHEMA}\n\ - activation_id=activation-7\n\ - daemon_id={TRAFFIC_ADMISSION_KEY}\n\ - release_id={release}\n\ - release_witness_sha256=sha256-{release_witness}\n\ - source_commit={source}\n\ - deployment_id=deployment-9\n\ - unit_path={GHOSTLIGHT_UNIT_PATH}\n\ - unit_sha256=sha256-{unit}\n\ - runtime_environment_path={GHOSTLIGHT_RUNTIME_ENVIRONMENT_PATH}\n\ - runtime_environment_sha256=sha256-{runtime}\n", - release = "a".repeat(40), - release_witness = "b".repeat(64), - source = "a".repeat(40), - unit = "c".repeat(64), - runtime = "d".repeat(64), - ); - let parsed = parse_activation_witness(text.as_bytes()).unwrap(); - assert_eq!(parsed.activation_id, "activation-7"); - assert_eq!(parsed.daemon_id, TRAFFIC_ADMISSION_KEY); - - let reordered = text.replacen( - "activation_id=activation-7\ndaemon_id=yggdrasil-ghostlight", - "daemon_id=yggdrasil-ghostlight\nactivation_id=activation-7", - 1, - ); - assert!(parse_activation_witness(reordered.as_bytes()).is_err()); - assert!(parse_activation_witness(text.trim_end().as_bytes()).is_err()); + fn fixture(root: &Path) -> Result { + fixture_with_state_contract(root, STATE_CONTRACT_SHA256) } + #[cfg(target_os = "linux")] #[test] - fn typed_traffic_admission_is_canonical_and_exact_startup_bound() { - let release = IdunnReleaseBinding { - release_id: "a".repeat(40), - release_witness_sha256: format!("sha256-{}", "b".repeat(64)), - source_commit: "a".repeat(40), - deployment_id: "deployment-9".into(), - }; - let activation = IdunnActivationBinding { - activation_witness_sha256: format!("sha256-{}", "c".repeat(64)), - }; - let published = PublishedHealthStatementIdentity { - daemon_id: TRAFFIC_ADMISSION_KEY.into(), - signed_health_sha256: format!("sha256-{}", "d".repeat(64)), - publisher_incarnation_id: uuid::Uuid::new_v4().to_string(), - publisher_sequence: 1, - signer_identity_id: "signer-1".into(), - }; - let expected = traffic_admission_expectation(&release, &activation, &published).unwrap(); - let payload = rmp_serde::to_vec(&expected).unwrap(); - assert_eq!(payload.first().copied(), Some(0x9d)); - assert_eq!(expected.runtime_process_id, std::process::id()); - assert!(expected.runtime_process_starttime_ticks > 0); - let envelope = CultCacheEnvelope { - key: TRAFFIC_ADMISSION_KEY.into(), - r#type: TRAFFIC_ADMISSION_TYPE.into(), - schema_id: Some(TRAFFIC_ADMISSION_SCHEMA.into()), - stored_at: "2026-09-02T00:00:00Z".into(), - payload, - }; - assert_eq!( - decode_traffic_admission_entries(std::slice::from_ref(&envelope)).unwrap(), - expected + fn inherited_authority_fd_layout_rejects_substitution_and_extras() -> Result<()> { + let pid = std::process::id(); + let names = format!( + "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{RUNTIME_PRESENCE_IDENTITY_FD_NAME}" ); - - let mut substituted = expected.clone(); - substituted.publisher_sequence += 1; - validate_traffic_admission_projection(&substituted).unwrap(); - assert!(require_exact_traffic_admission(&substituted, &expected).is_err()); - let mut substituted_process = expected.clone(); - substituted_process.runtime_process_id += 1; - validate_traffic_admission_projection(&substituted_process).unwrap(); - assert!(require_exact_traffic_admission(&substituted_process, &expected).is_err()); - let mut substituted_starttime = expected.clone(); - substituted_starttime.runtime_process_starttime_ticks += 1; - validate_traffic_admission_projection(&substituted_starttime).unwrap(); - assert!(require_exact_traffic_admission(&substituted_starttime, &expected).is_err()); - let mut stale_schema = expected.clone(); - stale_schema.schema_version = "idunn.runtime_traffic_admission.v1".into(); - assert!(validate_traffic_admission_projection(&stale_schema).is_err()); - let named_payload = rmp_serde::to_vec_named(&expected).unwrap(); - let named = CultCacheEnvelope { - payload: named_payload, - ..envelope.clone() - }; - assert!(decode_traffic_admission_entries(&[named]).is_err()); - assert!(decode_traffic_admission_entries(&[envelope.clone(), envelope.clone()]).is_err()); - } - - #[test] - fn proc_stat_starttime_parser_binds_pid_and_kernel_tick_identity() { - let stat = - "4242 (ghostlight ) worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\n"; - assert_eq!(parse_proc_stat_starttime(stat, 4242).unwrap(), 98_765); - assert!(parse_proc_stat_starttime(stat, 4243).is_err()); + authority_fd_layout(pid, &pid.to_string(), "2", &names)?; + assert!(authority_fd_layout(pid, &(pid + 1).to_string(), "2", &names).is_err()); + assert!(authority_fd_layout(pid, &format!("0{pid}"), "2", &names).is_err()); + assert!(authority_fd_layout(pid, &pid.to_string(), "02", &names).is_err()); + assert!(authority_fd_layout(pid, &pid.to_string(), "3", &names).is_err()); assert!( - parse_proc_stat_starttime( - "4242 (ghostlight) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 0", - 4242, + authority_fd_layout( + pid, + &pid.to_string(), + "2", + &format!( + "{RUNTIME_PRESENCE_IDENTITY_FD_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" + ), ) .is_err() ); + assert!( + authority_fd_layout( + pid, + &pid.to_string(), + "2", + &format!( + "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" + ), + ) + .is_err() + ); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn close_on_exec_authority_descriptor_is_not_visible_to_a_child() -> Result<()> { + use std::{os::fd::AsRawFd, process::Command}; + + let file = tempfile::tempfile()?; + mark_descriptor_close_on_exec(&file, "test signer")?; + let descriptor = file.as_raw_fd(); + let status = Command::new("/bin/sh") + .arg("-c") + .arg(format!("test ! -e /proc/self/fd/{descriptor}")) + .status()?; + ensure!(status.success(), "child inherited the signer descriptor"); + Ok(()) + } + + #[test] + fn runtime_rejects_an_expected_projection_for_another_state_contract() { + let root = tempfile::tempdir().unwrap(); + let error = match fixture_with_state_contract(root.path(), &digest('6')) { + Ok(_) => panic!("another state contract was admitted"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("does not name Ghostlight's writable world-v2 state") + ); + } + + #[test] + fn warming_presence_is_dual_signed_and_reports_compiled_capabilities() -> Result<()> { + let root = tempfile::tempdir()?; + let mut fixture = fixture(root.path())?; + let record = + fixture + .publisher + .signed_record("warming", "process-write-lease-pending", None)?; + let bytes = rmp_serde::to_vec(&record)?; + let claim = authenticate_runtime_presence_claim( + &bytes, + &fixture.authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: record.observed_at_unix_millis, + maximum_age_millis: 1_000, + maximum_future_skew_millis: 10, + }, + )?; + assert_eq!(claim.record(), &record); + assert_eq!(record.capabilities, actual_capabilities()); + assert_eq!( + record.bound_endpoint.as_deref(), + Some("http://127.0.0.1:18831") + ); + assert!(record.write_lease_sha256.is_none()); + + let wrong_purpose = fixture + .publisher + .provider_signer + .sign::(b"another-record"); + assert_ne!(wrong_purpose.signature, record.signature); + Ok(()) + } + + #[test] + fn dependency_endpoints_fail_closed_without_scheme_coercion() -> Result<()> { + assert_eq!( + parse_dependency_socket_endpoint( + "rudp://127.0.0.1:4100", + &["rudp://", "udp://"], + "Odin dependency", + )?, + "127.0.0.1:4100".parse::()? + ); + assert!( + parse_dependency_socket_endpoint( + "tcp://127.0.0.1:4100", + &["rudp://", "udp://"], + "Odin dependency", + ) + .is_err() + ); + assert!( + parse_dependency_socket_endpoint( + "http://127.0.0.1:4101", + &["rudp://", "udp://"], + "Heimdall dependency", + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn process_write_lease_guard_holds_and_rechecks_the_exact_incarnation() -> Result<()> { + let root = tempfile::tempdir()?; + let mut fixture = fixture(root.path())?; + let warming = + fixture + .publisher + .signed_record("warming", "process-write-lease-pending", None)?; + let warming_sha256 = warming.canonical_sha256()?; + let lease = IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: fixture.expected.target.clone(), + expected_projection_sha256: fixture.expected.canonical_sha256()?, + plan_id: fixture.expected.plan_id.clone(), + incarnation_id: fixture.expected.incarnation_id.clone(), + sealed_release_id: fixture.expected.sealed_release_id.clone(), + activation_witness_sha256: fixture.activation.canonical_sha256()?, + state_schema_generation: STATE_SCHEMA_GENERATION.into(), + state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), + runtime_id: fixture.expected.runtime_id.clone(), + runtime_instance_id: fixture.activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_sha256.clone(), + lease_epoch: 1, + issued_at_unix_millis: 200, + }; + let path = root.path().join("write-lease.cc"); + SingleFileMessagePackBackingStore::new(&path).push(&CultCacheEnvelope { + key: lease.target.clone(), + r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), + payload: lease.canonical_bytes()?, + stored_at: "2026-09-03T00:00:00Z".into(), + schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), + })?; + + let guard = ProcessWriteLeaseGuard::acquire_fixture( + path.clone(), + fixture.expected.clone(), + fixture.activation.clone(), + warming_sha256, + )?; + guard.require_current()?; + assert_eq!(guard.canonical_sha256(), lease.canonical_sha256()?); + drop(guard); + + let mut wrong = lease; + wrong.runtime_instance_id = digest('8'); + let entries = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + let store = SingleFileMessagePackBackingStore::new(&path); + ensure!( + store.replace_and_append_if_snapshot_unchanged( + &entries, + vec![CultCacheEnvelope { + key: wrong.target.clone(), + r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), + payload: wrong.canonical_bytes()?, + stored_at: "2026-09-03T00:00:01Z".into(), + schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), + }], + )?, + "test lease replacement lost CAS" + ); + assert!( + ProcessWriteLeaseGuard::acquire_fixture( + path, + fixture.expected, + fixture.activation, + digest('9'), + ) + .is_err() + ); + Ok(()) } } diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 4daba0c..6264b67 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -5,8 +5,7 @@ use crate::{ eve::{self, EveCommandInvocation}, heimdall::{self, HeimdallClient}, idunn_health::{ - GHOSTLIGHT_IDUNN_HEALTH_CONTRACT, GhostlightTrafficAdmissionGate, IdunnHealthPublisher, - IdunnReleaseBinding, active_activation_binding, + IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, ProcessWriteLeaseGuard, RuntimePresencePublisher, }, mesh::{self, MeshPublisher, MeshRuntimeIdentity}, world::{ @@ -17,12 +16,11 @@ use crate::{ SubmitReceipt, WorldMailbox, WorldSnapshot, }, }; -use anyhow::{Context, bail}; +use anyhow::{Context, bail, ensure}; use axum::{ Json, Router, - extract::{Path, Request, State}, + extract::{Path, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, - middleware::{self, Next}, response::{ IntoResponse, Response, sse::{Event as SseEvent, KeepAlive, Sse}, @@ -32,8 +30,7 @@ use axum::{ use chrono::{DateTime, Utc}; use serde::Deserialize; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{convert::Infallible, fs, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use tokio::sync::{Mutex, broadcast, mpsc}; use tower_http::services::ServeDir; use tracing_subscriber::EnvFilter; @@ -50,18 +47,11 @@ struct AppState { mesh_identity: MeshRuntimeIdentity, revisions: broadcast::Sender, fatal: mpsc::UnboundedSender, - traffic: Option>, } struct ProductionAdmission { - traffic: Arc, - health: IdunnHealthPublisher, -} - -#[derive(Clone)] -struct RequestTrafficAdmission { - traffic: Arc, - fatal: mpsc::UnboundedSender, + health: RuntimePresencePublisher, + write_lease: ProcessWriteLeaseGuard, } #[derive(Debug, Deserialize)] @@ -99,16 +89,30 @@ struct ControllerActPayload { opportunity: DecisionOpportunity, } -pub(crate) async fn run() -> anyhow::Result<()> { +pub(crate) async fn run(state_root_binding: Option) -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); - let runtime_root = std::env::var_os("GHOSTLIGHT_DUNGEON_ROOT") - .map(PathBuf::from) - .unwrap_or_else(default_runtime_root); + let runtime_root = admitted_runtime_root(state_root_binding)?; let service_root = runtime_root.join("service"); - let runtime_id = - std::env::var("GHOSTLIGHT_RUNTIME_ID").unwrap_or_else(|_| "ghostlight-dungeon".into()); + // The bound socket is service observation, not configuration. Retain it + // unopened to application traffic while Warming waits for Idunn's lease. + let requested_address = candidate_bind()?; + let listener = tokio::net::TcpListener::bind(requested_address).await?; + let bound_address = listener.local_addr()?; + let (fatal, mut fatal_events) = mpsc::unbounded_channel(); + let production_admission = initialize_production_admission(bound_address).await?; + let dependency_bindings = production_admission + .as_ref() + .map(|admission| admission.health.dependency_bindings().clone()); + let runtime_id = match &production_admission { + Some(admission) => admission.health.runtime_id().to_owned(), + None => std::env::var("GHOSTLIGHT_RUNTIME_ID") + .context("GHOSTLIGHT_RUNTIME_ID is required outside managed runtime")?, + }; + if runtime_id.is_empty() || runtime_id.trim() != runtime_id { + bail!("GHOSTLIGHT_RUNTIME_ID must be a canonical non-empty identity"); + } let mesh_identity = MeshRuntimeIdentity { runtime_id: runtime_id.clone(), service_id: std::env::var("GHOSTLIGHT_SERVICE_ID") @@ -117,30 +121,19 @@ pub(crate) async fn run() -> anyhow::Result<()> { .unwrap_or_else(|_| "local".into()), }; - // Production may inspect immutable root activation inputs and the provider - // health identity, then publish one warming statement. World, session, - // controller, replay, CultMesh, and socket authority remain unopened until - // Idunn grants that exact statement. - let (fatal, mut fatal_events) = mpsc::unbounded_channel(); - let production_admission = initialize_production_admission(&mesh_identity).await?; - let mut request_admission = None; + // Production may bind but not serve its candidate socket, inspect immutable + // root activation inputs and its provider identity, then publish Warming. + // World, session, controller, replay, and CultMesh state remain unopened + // until Idunn grants the lease bound to that exact observed process. + let mut write_lease_guard = None; let mut idunn_health = match production_admission { Some(admission) => { - let ProductionAdmission { traffic, health } = admission; - traffic - .require_current() - .context("root traffic admission changed after initial grant")?; - request_admission = Some(RequestTrafficAdmission { - traffic: traffic.clone(), - fatal: fatal.clone(), - }); - let traffic_failure = fatal.clone(); - tokio::spawn(async move { - if let Err(error) = maintain_traffic_admission(traffic).await { - let _ = traffic_failure - .send(format!("root traffic admission was revoked: {error:#}")); - } - }); + let ProductionAdmission { + health, + write_lease, + } = admission; + write_lease.require_current()?; + write_lease_guard = Some(write_lease); Some(health) } None => None, @@ -148,10 +141,22 @@ pub(crate) async fn run() -> anyhow::Result<()> { tokio::task::yield_now().await; require_no_runtime_custody_failure(&mut fatal_events)?; - std::fs::create_dir_all(&service_root)?; + require_current_write_lease(write_lease_guard.as_ref())?; + if write_lease_guard.is_some() { + prepare_admitted_state_layout(&runtime_root)?; + } else { + fs::create_dir_all(&service_root)?; + } + require_current_write_lease(write_lease_guard.as_ref())?; let (world, world_owner) = WorldMailbox::open(runtime_root.join("world.cc"))?; require_no_runtime_custody_failure(&mut fatal_events)?; - let controllers = match open_controller(&world, &service_root, &runtime_id) { + let connector_endpoint = dependency_bindings + .as_ref() + .map(|bindings| bindings.connector) + .map(Ok) + .unwrap_or_else(configured_connector_endpoint)?; + let controllers = match open_controller(&world, &service_root, &runtime_id, connector_endpoint) + { Ok(runner) => Some(runner), Err(error) => { tracing::warn!(%error, "controller cognition is unavailable; world authority remains online"); @@ -163,9 +168,21 @@ pub(crate) async fn run() -> anyhow::Result<()> { .map(PathBuf::from) .unwrap_or_else(|| runtime_root.join("secrets/session-wrapping.key")); let sessions = AppSessionOwner::open(service_root.join("app-sessions-v2.cc"), wrapping_key)?; - let heimdall = Arc::new(HeimdallClient::from_env()?); + let odin_endpoint = dependency_bindings + .as_ref() + .map(|bindings| bindings.odin_rudp) + .map(Ok) + .unwrap_or_else(configured_odin_endpoint)?; + let expected_heimdall = dependency_bindings + .as_ref() + .map(|bindings| bindings.heimdall.clone()); + let heimdall = Arc::new(HeimdallClient::from_env( + &runtime_id, + odin_endpoint, + expected_heimdall, + )?); require_no_runtime_custody_failure(&mut fatal_events)?; - let mesh = match open_mesh(&service_root, &mesh_identity) { + let mesh = match open_mesh(&service_root, &mesh_identity, Some(odin_endpoint)) { Ok(mesh) => Some(mesh), Err(error) => { tracing::warn!(%error, "derived CultMesh projection is unavailable; world authority remains online"); @@ -183,9 +200,6 @@ pub(crate) async fn run() -> anyhow::Result<()> { mesh_identity: mesh_identity.clone(), revisions, fatal, - traffic: request_admission - .as_ref() - .map(|admission| admission.traffic.clone()), }; require_no_runtime_custody_failure(&mut fatal_events)?; publish_projection(&state).await?; @@ -198,31 +212,21 @@ pub(crate) async fn run() -> anyhow::Result<()> { .filter(|path| path.join("index.html").is_file()) .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../web/dist")); let app = app_router(state.clone(), web_root); - let app = match request_admission.clone() { - Some(admission) => app.layer(middleware::from_fn_with_state( - admission, - enforce_request_traffic_admission, - )), - None => app, - }; - let address: SocketAddr = std::env::var("GHOSTLIGHT_DUNGEON_BIND") - .unwrap_or_else(|_| "127.0.0.1:8831".into()) - .parse()?; require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_traffic_admission(request_admission.as_ref(), "before HTTP bind")?; - let listener = tokio::net::TcpListener::bind(address).await?; - require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_traffic_admission(request_admission.as_ref(), "after HTTP bind")?; + require_current_write_lease(write_lease_guard.as_ref())?; canonical_readiness(&state) .await .context("runtime is not ready for active signed health")?; if let Some(publisher) = idunn_health.as_mut() { + let write_lease = write_lease_guard + .as_ref() + .context("production runtime lost its process write lease")?; publisher - .publish("active", "world-owner-serving") - .context("publishing initial active Idunn health")?; + .publish_active(write_lease) + .context("publishing initial active runtime presence")?; } - require_current_traffic_admission(request_admission.as_ref(), "before HTTP serve")?; - tracing::info!(%address, "Ghostlight Dungeon world owner serving"); + require_current_write_lease(write_lease_guard.as_ref())?; + tracing::info!(address = %bound_address, "Ghostlight Dungeon world owner serving"); let server = axum::serve( listener, app.into_make_service_with_connect_info::(), @@ -233,7 +237,11 @@ pub(crate) async fn run() -> anyhow::Result<()> { result.context("world-owner task panicked")?; bail!("world-owner task stopped while the daemon was serving") } - result = maintain_runtime_health(idunn_health, state.clone()) => { + result = maintain_runtime_health( + idunn_health, + state.clone(), + write_lease_guard.as_ref(), + ) => { result?; bail!("runtime readiness owner stopped while the daemon was serving") } @@ -254,11 +262,8 @@ fn open_controller( world: &WorldMailbox, service_root: &std::path::Path, runtime_id: &str, + endpoint: SocketAddr, ) -> anyhow::Result { - let endpoint: SocketAddr = std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR") - .unwrap_or_else(|_| "127.0.0.1:4103".into()) - .parse() - .context("GHOSTLIGHT_CONTROLLER_CONNECTOR is not a socket address")?; let credential = std::env::var_os("GHOSTLIGHT_CONTROLLER_CREDENTIAL") .map(PathBuf::from) .context("GHOSTLIGHT_CONTROLLER_CREDENTIAL is required")?; @@ -285,12 +290,8 @@ fn open_controller( fn open_mesh( service_root: &std::path::Path, identity: &MeshRuntimeIdentity, + target: Option, ) -> anyhow::Result { - let target = std::env::var("GHOSTLIGHT_ODIN_RUDP") - .ok() - .map(|value| value.parse()) - .transpose() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address")?; MeshPublisher::open(service_root.join("mesh-v2.cc"), target, identity.clone()) } @@ -299,24 +300,6 @@ fn app_router(state: AppState, web_root: PathBuf) -> Router { .fallback_service(ServeDir::new(web_root).append_index_html_on_directories(true)) } -async fn enforce_request_traffic_admission( - State(admission): State, - request: Request, - next: Next, -) -> Response { - if let Err(error) = admission.traffic.require_current() { - let _ = admission - .fatal - .send(format!("request traffic admission failed: {error:#}")); - return ( - StatusCode::SERVICE_UNAVAILABLE, - "Ghostlight traffic admission is not current", - ) - .into_response(); - } - next.run(request).await -} - fn api_router(state: AppState) -> Router { Router::new() .route("/health", get(health)) @@ -374,19 +357,6 @@ async fn eve_command( State(state): State, Json(invocation): Json, ) -> Response { - // Axum has now consumed and decoded the complete command. This is the - // traffic-to-application transfer point: revocation blocks later commands, - // while WorldMailbox owns any consequence accepted below. - if let Err(error) = require_command_traffic_admission(&state) { - let _ = state - .fatal - .send(format!("command traffic admission failed: {error:#}")); - return ( - StatusCode::SERVICE_UNAVAILABLE, - "Ghostlight traffic admission is not current", - ) - .into_response(); - } if let Err(error) = eve::validate_invocation(&invocation, "https-json") { return Json(eve::command_result( &invocation, @@ -419,13 +389,6 @@ async fn eve_command( } } -fn require_command_traffic_admission(state: &AppState) -> anyhow::Result<()> { - match state.traffic.as_ref() { - Some(traffic) => traffic.require_current(), - None => Ok(()), - } -} - async fn begin_authentication(state: &AppState, invocation: EveCommandInvocation) -> Response { if let Err(error) = serde_json::from_value::(invocation.payload.clone()) { return Json(eve::command_result( @@ -1478,115 +1441,33 @@ async fn maintain_session_refresh(state: AppState) -> anyhow::Result<()> { } async fn initialize_production_admission( - identity: &MeshRuntimeIdentity, + bound_endpoint: SocketAddr, ) -> anyhow::Result> { - let endpoint = match std::env::var("GHOSTLIGHT_IDUNN_RUDP") { - Ok(endpoint) => endpoint, - Err(_) => { - #[cfg(target_os = "linux")] - bail!("GHOSTLIGHT_IDUNN_RUDP is mandatory for the Linux Ghostlight runtime"); - #[cfg(not(target_os = "linux"))] - return Ok(None); - } + let Some(mut publisher) = RuntimePresencePublisher::from_environment(bound_endpoint)? else { + return Ok(None); }; - let identity_store = std::env::var_os("GHOSTLIGHT_IDUNN_HEALTH_IDENTITY") - .map(PathBuf::from) - .context("GHOSTLIGHT_IDUNN_HEALTH_IDENTITY is required with GHOSTLIGHT_IDUNN_RUDP")?; - let daemon_id = - std::env::var("GHOSTLIGHT_IDUNN_DAEMON").unwrap_or_else(|_| "yggdrasil-ghostlight".into()); - if daemon_id != "yggdrasil-ghostlight" { - bail!("signed Ghostlight health daemon id is not the canonical Idunn identity"); - } - let release = release_health_binding()?; - let activation = active_activation_binding(&release)?; - let mut publisher = IdunnHealthPublisher::open( - endpoint.parse()?, - daemon_id, - identity.runtime_id.clone(), - std::env::var("GHOSTLIGHT_IDUNN_HEALTH_CONTRACT") - .unwrap_or_else(|_| GHOSTLIGHT_IDUNN_HEALTH_CONTRACT.into()), - identity_store, - release.clone(), - activation.clone(), - )?; - let startup = publisher - .publish("warming", "traffic-admission-pending") - .context("publishing initial warming Idunn health")?; - let traffic = - GhostlightTrafficAdmissionGate::from_environment(&release, &activation, &startup)?; - traffic.wait_until_granted(Duration::from_secs(120)).await?; + let warming = publisher + .publish_warming() + .context("publishing initial Warming runtime presence")?; + let write_lease = publisher + .wait_for_write_lease(&warming, Duration::from_secs(120)) + .await + .with_context(|| { + format!( + "waiting for process lease bound to Warming {}", + warming.canonical_sha256() + ) + })?; Ok(Some(ProductionAdmission { - traffic: Arc::new(traffic), health: publisher, + write_lease, })) } -fn release_health_binding() -> anyhow::Result { - let executable = std::env::current_exe().context("locating the active Ghostlight binary")?; - let release_root = executable - .parent() - .context("active Ghostlight binary has no release directory")?; - let witness_path = release_root.join("DEPLOYMENT"); - let witness = std::fs::read(&witness_path) - .with_context(|| format!("reading sealed release witness {}", witness_path.display()))?; - let witness_text = std::str::from_utf8(&witness).context("release witness is not UTF-8")?; - let source_commit = exact_release_field(witness_text, "source_commit")?.to_owned(); - let compiled_commit = option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development"); - if source_commit != compiled_commit || !is_lower_hex(&source_commit, 40) { - bail!("release witness does not bind the compiled Ghostlight source commit"); - } - let expected_binary_sha = exact_release_field(witness_text, "binary_sha256")?; - let binary_sha = hex(&Sha256::digest( - std::fs::read(&executable).context("reading the active Ghostlight binary")?, - )); - if expected_binary_sha != binary_sha { - bail!("release witness does not bind the active Ghostlight executable"); - } - let deployment_id = std::env::var("GHOSTLIGHT_IDUNN_DEPLOYMENT_REQUEST_ID") - .context("GHOSTLIGHT_IDUNN_DEPLOYMENT_REQUEST_ID is required for signed health")?; - if deployment_id.len() > 256 - || !deployment_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) - { - bail!("Idunn deployment request identity is malformed"); - } - Ok(IdunnReleaseBinding { - release_id: source_commit.clone(), - release_witness_sha256: format!("sha256-{}", hex(&Sha256::digest(&witness))), - source_commit, - deployment_id, - }) -} - -fn exact_release_field<'a>(witness: &'a str, name: &str) -> anyhow::Result<&'a str> { - let prefix = format!("{name}="); - let mut values = witness - .lines() - .filter_map(|line| line.strip_prefix(&prefix)); - let value = values - .next() - .with_context(|| format!("release witness has no {name}"))?; - if value.is_empty() - || value.trim() != value - || value.chars().any(char::is_control) - || values.next().is_some() - { - bail!("release witness has an invalid or duplicate {name}"); - } - Ok(value) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - async fn maintain_runtime_health( - publisher: Option, + publisher: Option, state: AppState, + write_lease: Option<&ProcessWriteLeaseGuard>, ) -> anyhow::Result<()> { let mut publisher = publisher; let mut interval = tokio::time::interval(Duration::from_secs(10)); @@ -1596,48 +1477,59 @@ async fn maintain_runtime_health( canonical_readiness(&state) .await .context("runtime is not ready for signed health")?; + require_current_write_lease(write_lease) + .context("runtime process write lease is not current")?; if let Some(mut owned) = publisher.take() { let published = tokio::task::spawn_blocking(move || { - let result = owned.publish("active", "world-owner-serving"); + let result = owned.republish_active(); (owned, result) }) .await; let Ok((returned, result)) = published else { - tracing::warn!("signed Idunn health worker panicked; publication is disabled"); + tracing::warn!("runtime-presence worker panicked; publication is disabled"); continue; }; publisher = Some(returned); if let Err(error) = result { - tracing::warn!(%error, "signed Idunn health publication failed"); + tracing::warn!(%error, "signed runtime-presence publication failed"); } } } } -async fn maintain_traffic_admission( - admission: Arc, -) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - admission - .require_current() - .context("root traffic admission was revoked or substituted")?; +fn candidate_bind() -> anyhow::Result { + match std::env::var(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT) { + Ok(value) => value + .parse() + .context("Idunn candidate bind is not a socket address"), + Err(_) => { + #[cfg(target_os = "linux")] + bail!("{IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT} is mandatory on Linux"); + #[cfg(not(target_os = "linux"))] + return Ok("127.0.0.1:8831".parse()?); + } } } -fn require_current_traffic_admission( - admission: Option<&RequestTrafficAdmission>, - phase: &str, -) -> anyhow::Result<()> { - let Some(admission) = admission else { - return Ok(()); - }; - admission - .traffic - .require_current() - .with_context(|| format!("root traffic admission changed {phase}")) +fn configured_connector_endpoint() -> anyhow::Result { + std::env::var("GHOSTLIGHT_CONTROLLER_CONNECTOR") + .unwrap_or_else(|_| "127.0.0.1:4103".into()) + .parse() + .context("GHOSTLIGHT_CONTROLLER_CONNECTOR is not a socket address") +} + +fn configured_odin_endpoint() -> anyhow::Result { + std::env::var("GHOSTLIGHT_ODIN_RUDP") + .context("GHOSTLIGHT_ODIN_RUDP is required")? + .parse() + .context("GHOSTLIGHT_ODIN_RUDP is not a socket address") +} + +fn require_current_write_lease(lease: Option<&ProcessWriteLeaseGuard>) -> anyhow::Result<()> { + match lease { + Some(lease) => lease.require_current(), + None => Ok(()), + } } fn require_no_runtime_custody_failure( @@ -1663,22 +1555,157 @@ fn default_runtime_root() -> PathBuf { } } -fn hex(bytes: &[u8]) -> String { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(DIGITS[(byte >> 4) as usize] as char); - output.push(DIGITS[(byte & 0x0f) as usize] as char); +fn admitted_runtime_root(binding: Option) -> anyhow::Result { + let Some(binding) = binding else { + #[cfg(target_os = "linux")] + bail!("--state-root is mandatory for the managed Linux runtime"); + #[cfg(not(target_os = "linux"))] + return Ok(default_runtime_root()); + }; + ensure!(binding.is_absolute(), "state-root binding is not absolute"); + let metadata = fs::symlink_metadata(&binding) + .with_context(|| format!("inspecting state-root binding {}", binding.display()))?; + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "state-root binding is not a direct directory" + ); + let canonical = fs::canonicalize(&binding) + .with_context(|| format!("canonicalizing state-root binding {}", binding.display()))?; + ensure!( + canonical == binding, + "state-root binding is indirect or non-canonical" + ); + Ok(binding) +} + +fn prepare_admitted_state_layout(runtime_root: &std::path::Path) -> anyhow::Result<()> { + let rebound = admitted_runtime_root(Some(runtime_root.to_owned()))?; + ensure!( + rebound == runtime_root, + "state-root binding changed after process-write-lease admission" + ); + let service_root = runtime_root.join("service"); + match fs::symlink_metadata(&service_root) { + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "service state directory is indirect or not a directory" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&service_root).with_context(|| { + format!( + "creating direct service state directory {}", + service_root.display() + ) + })?; + } + Err(error) => return Err(error).context("inspecting service state directory"), } - output + ensure_direct_state_directory(&service_root, "service state directory")?; + for path in [ + runtime_root.join("world.cc"), + service_root.join("app-sessions-v2.cc"), + service_root.join("controller-work.cc"), + service_root.join("mesh-v2.cc"), + ] { + require_direct_state_file_or_absent(&path)?; + require_direct_state_file_or_absent(&sibling_state_lock_path(&path))?; + } + Ok(()) +} + +fn ensure_direct_state_directory(path: &std::path::Path, label: &str) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspecting {label} {}", path.display()))?; + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "{label} is indirect or not a directory" + ); + Ok(()) +} + +fn require_direct_state_file_or_absent(path: &std::path::Path) -> anyhow::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "declared state path {} is indirect or not a regular file", + path.display() + ); + #[cfg(target_os = "linux")] + ensure!( + std::os::unix::fs::MetadataExt::nlink(&metadata) == 1, + "declared state path {} is multiply linked", + path.display() + ); + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("inspecting declared state path {}", path.display())) + } + } +} + +fn sibling_state_lock_path(path: &std::path::Path) -> PathBuf { + let mut name = path + .file_name() + .map(|value| value.to_os_string()) + .unwrap_or_else(|| "state.cc".into()); + name.push(".lock"); + path.with_file_name(name) } #[cfg(test)] mod tests { use super::*; + use crate::world::AffordanceKind; use axum::{body::Body, http::Request}; use tower::ServiceExt; + #[cfg(target_os = "linux")] + #[test] + fn managed_linux_runtime_requires_the_admitted_state_root_binding() { + assert!(admitted_runtime_root(None).is_err()); + } + + #[test] + fn admitted_state_root_must_name_the_direct_canonical_directory() { + let directory = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(directory.path()).unwrap(); + assert_eq!(admitted_runtime_root(Some(root.clone())).unwrap(), root); + assert!(admitted_runtime_root(Some(directory.path().join("missing"))).is_err()); + } + + #[test] + fn post_lease_state_layout_creates_only_the_direct_declared_directory() { + let directory = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(directory.path()).unwrap(); + prepare_admitted_state_layout(&root).unwrap(); + let service = fs::symlink_metadata(root.join("service")).unwrap(); + assert!(service.is_dir()); + assert!(!service.file_type().is_symlink()); + } + + #[cfg(target_os = "linux")] + #[test] + fn post_lease_state_layout_rejects_symlinks_and_hardlinks() { + use std::os::unix::fs::symlink; + + let symlink_directory = tempfile::tempdir().unwrap(); + let symlink_root = fs::canonicalize(symlink_directory.path()).unwrap(); + let target = symlink_root.join("other.cc"); + fs::write(&target, b"not-world").unwrap(); + symlink(&target, symlink_root.join("world.cc")).unwrap(); + assert!(prepare_admitted_state_layout(&symlink_root).is_err()); + + let hardlink_directory = tempfile::tempdir().unwrap(); + let hardlink_root = fs::canonicalize(hardlink_directory.path()).unwrap(); + let target = hardlink_root.join("other.cc"); + fs::write(&target, b"not-world").unwrap(); + fs::hard_link(&target, hardlink_root.join("world.cc")).unwrap(); + assert!(prepare_admitted_state_layout(&hardlink_root).is_err()); + } + fn controller_commit() -> crate::world::CommitReceipt { crate::world::CommitReceipt { command_id: CommandId::new(), @@ -1764,7 +1791,6 @@ mod tests { mesh_identity, revisions, fatal, - traffic: None, }; publish_projection(&state).await.unwrap(); Fixture { @@ -1970,7 +1996,7 @@ mod tests { result["message"] .as_str() .unwrap() - .contains("invalid command payload") + .contains("payload may not supply caller authority") ); } }