1375 lines
49 KiB
Rust
1375 lines
49 KiB
Rust
//! `idunn-host`: the actuator body on a managed host.
|
|
//!
|
|
//! One process per host, started at logon in the session the workload needs.
|
|
//! It dials the Idunn hub, proves itself with the host's service identity,
|
|
//! and executes what `host_actuator` defines: fetch and build the exact
|
|
//! source, install the sealed release, start the workload as a child of this
|
|
//! process, report what it can prove about that process, stop it when told.
|
|
//! It keeps no transaction state; a restarted actuator finds a running
|
|
//! workload again by pid and creation time.
|
|
//!
|
|
//! Only the process layer is Windows-specific and it is confined to
|
|
//! `platform`. Everything else is the same code Idunn runs on yggdrasil.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs::{self, File};
|
|
use std::io::Write;
|
|
use std::net::{SocketAddr, UdpSocket};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
|
use cultnet_rs::{
|
|
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions,
|
|
IdunnExpectedIncarnationRecord, IdunnRuntimeActivationRecord, ServiceIdentitySigner,
|
|
ServiceIdentityTrustAnchor, open_service_identity_at,
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use crate::control_plane::read_trust_anchor;
|
|
use crate::deployment::{
|
|
ArtifactSource, HostWorkloadBinding, IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT,
|
|
IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, LaunchArgument, OperatorBinding, TargetDeclaration,
|
|
};
|
|
use crate::deployment_plan::{ArtifactReceipt, CompiledDeploymentPlan, SealedRelease};
|
|
use crate::drivers::{
|
|
HostWorkloadObservation, InstalledReleaseObservation, copy_artifact, copy_tree,
|
|
digest_artifact, release_artifact, remove_tree_inside, sha256_id, write_runtime_bundle_records,
|
|
};
|
|
use crate::host_actuator::{
|
|
HOST_ACTUATOR_CHANNEL, HOST_ACTUATOR_CONNECTION_ID, HOST_ACTUATOR_PING_MS,
|
|
HOST_ACTUATOR_SESSION_TIMEOUT_MS, HostActuatorEnvelope, HostActuatorReport,
|
|
HostActuatorRequest, IdunnHostActuatorIdentity,
|
|
};
|
|
|
|
pub const HOST_ACTUATOR_CONFIG_SCHEMA: &str = "gamecult.idunn.host_actuator_config.v1";
|
|
/// The workload reads its activation credential from the file named by
|
|
/// `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`. On a single-user workstation a
|
|
/// parent-only descriptor would protect it from nobody the file ACL does not
|
|
/// already exclude; the file lives inside the runtime bundle, which is inside
|
|
/// the user's own profile.
|
|
const ACTIVATION_CREDENTIAL_FILE: &str = "activation-credential";
|
|
const REDIAL_AFTER: Duration = Duration::from_secs(2);
|
|
const LOOP_POLL: Duration = Duration::from_millis(20);
|
|
const STOP_WAIT: Duration = Duration::from_secs(10);
|
|
/// Exit code reported for a process that was gone before its exit could be
|
|
/// read: the actuator restarted, or the pid was reused. Distinct from every
|
|
/// code a process can return on purpose.
|
|
pub const EXIT_CODE_VANISHED: u32 = u32::MAX;
|
|
|
|
/// Host environment a build step or workload inherits. Everything else in
|
|
/// the actuator's environment stays with the actuator. Cargo and git need
|
|
/// the toolchain paths and a temp directory; nothing needs the rest.
|
|
const PASSTHROUGH_ENVIRONMENT: &[&str] = &[
|
|
"PATH",
|
|
"PATHEXT",
|
|
"SystemRoot",
|
|
"SystemDrive",
|
|
"windir",
|
|
"ComSpec",
|
|
"TEMP",
|
|
"TMP",
|
|
"USERPROFILE",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
"LOCALAPPDATA",
|
|
"APPDATA",
|
|
"ProgramData",
|
|
"ProgramFiles",
|
|
"ProgramFiles(x86)",
|
|
"PUBLIC",
|
|
"USERNAME",
|
|
"COMPUTERNAME",
|
|
"NUMBER_OF_PROCESSORS",
|
|
"PROCESSOR_ARCHITECTURE",
|
|
"CARGO_HOME",
|
|
"RUSTUP_HOME",
|
|
];
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HostActuatorConfig {
|
|
pub schema: String,
|
|
pub host: String,
|
|
pub idunn_endpoint: String,
|
|
pub identity_store: PathBuf,
|
|
pub idunn_trust_anchor: PathBuf,
|
|
pub work_root: PathBuf,
|
|
}
|
|
|
|
impl HostActuatorConfig {
|
|
pub fn parse(text: &str) -> Result<Self> {
|
|
let config: Self = toml::from_str(text).context("decoding host actuator config")?;
|
|
ensure!(
|
|
config.schema == HOST_ACTUATOR_CONFIG_SCHEMA,
|
|
"unsupported host actuator config schema {:?}",
|
|
config.schema
|
|
);
|
|
ensure!(
|
|
!config.host.is_empty(),
|
|
"host actuator config names no host"
|
|
);
|
|
config
|
|
.idunn_endpoint
|
|
.parse::<SocketAddr>()
|
|
.context("idunn_endpoint is not a socket address")?;
|
|
for (label, path) in [
|
|
("identity_store", &config.identity_store),
|
|
("idunn_trust_anchor", &config.idunn_trust_anchor),
|
|
("work_root", &config.work_root),
|
|
] {
|
|
ensure!(path.is_absolute(), "{label} must be an absolute path");
|
|
}
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn load(path: &Path) -> Result<Self> {
|
|
let text = fs::read_to_string(path)
|
|
.with_context(|| format!("reading host actuator config {}", path.display()))?;
|
|
Self::parse(&text)
|
|
}
|
|
|
|
fn idunn_endpoint(&self) -> SocketAddr {
|
|
self.idunn_endpoint
|
|
.parse()
|
|
.expect("validated at parse time")
|
|
}
|
|
|
|
fn sources(&self) -> PathBuf {
|
|
self.work_root.join("sources")
|
|
}
|
|
|
|
fn staging(&self) -> PathBuf {
|
|
self.work_root.join("staging")
|
|
}
|
|
|
|
fn logs(&self) -> PathBuf {
|
|
self.work_root.join("logs")
|
|
}
|
|
}
|
|
|
|
pub fn run(args: impl IntoIterator<Item = String>) -> Result<()> {
|
|
let mut args = args.into_iter();
|
|
let command = args.next().unwrap_or_else(|| "--help".into());
|
|
let mut config_path = None;
|
|
while let Some(argument) = args.next() {
|
|
match argument.as_str() {
|
|
"--config" => {
|
|
config_path = Some(PathBuf::from(
|
|
args.next().context("--config requires a path")?,
|
|
))
|
|
}
|
|
other => bail!("unknown idunn-host option {other:?}\n\n{}", usage()),
|
|
}
|
|
}
|
|
match command.as_str() {
|
|
"serve" => serve(&HostActuatorConfig::load(
|
|
&config_path.context("idunn-host serve requires --config")?,
|
|
)?),
|
|
"validate" => {
|
|
let config = HostActuatorConfig::load(
|
|
&config_path.context("idunn-host validate requires --config")?,
|
|
)?;
|
|
let actuator = Actuator::open(config)?;
|
|
println!(
|
|
"host {} identity {} idunn {} anchor {}",
|
|
actuator.config.host,
|
|
actuator.signer.entry().identity_id,
|
|
actuator.config.idunn_endpoint,
|
|
actuator.idunn_anchor.identity_id
|
|
);
|
|
Ok(())
|
|
}
|
|
"--help" | "-h" | "help" => bail!(usage()),
|
|
other => bail!("unknown idunn-host command {other:?}\n\n{}", usage()),
|
|
}
|
|
}
|
|
|
|
fn usage() -> &'static str {
|
|
"Idunn host actuator\n\n\
|
|
idunn-host serve --config PATH\n\
|
|
idunn-host validate --config PATH\n\n\
|
|
Dials the Idunn host actuator hub named in the config and executes the\n\
|
|
runner and workload consequences for this host. Decides nothing."
|
|
}
|
|
|
|
fn serve(config: &HostActuatorConfig) -> Result<()> {
|
|
let mut actuator = Actuator::open(config.clone())?;
|
|
actuator.log(&format!(
|
|
"idunn-host {} for host {} dialling {}",
|
|
env!("CARGO_PKG_VERSION"),
|
|
config.host,
|
|
config.idunn_endpoint
|
|
));
|
|
loop {
|
|
match actuator.session() {
|
|
Ok(()) => actuator.log("session closed"),
|
|
Err(error) => actuator.log(&format!("session ended: {error:#}")),
|
|
}
|
|
thread::sleep(REDIAL_AFTER);
|
|
}
|
|
}
|
|
|
|
struct BuildInFlight {
|
|
sequence: u64,
|
|
handle: JoinHandle<Result<(SealedRelease, PathBuf)>>,
|
|
}
|
|
|
|
struct Actuator {
|
|
config: HostActuatorConfig,
|
|
signer: ServiceIdentitySigner<IdunnHostActuatorIdentity>,
|
|
idunn_anchor: ServiceIdentityTrustAnchor,
|
|
/// Children this actuator started, by runtime instance id. A child stays
|
|
/// here after it exits so its exit code can be reported.
|
|
spawned: BTreeMap<String, Child>,
|
|
build: Option<BuildInFlight>,
|
|
user_sid: String,
|
|
}
|
|
|
|
impl Actuator {
|
|
fn open(config: HostActuatorConfig) -> Result<Self> {
|
|
let signer = open_service_identity_at::<IdunnHostActuatorIdentity>(&config.identity_store)
|
|
.context("opening the host actuator identity")?;
|
|
let idunn_anchor =
|
|
read_trust_anchor::<cultnet_rs::IdunnServiceIdentity>(&config.idunn_trust_anchor)
|
|
.context("reading the Idunn public anchor")?;
|
|
for directory in [config.sources(), config.staging(), config.logs()] {
|
|
fs::create_dir_all(&directory)
|
|
.with_context(|| format!("creating {}", directory.display()))?;
|
|
}
|
|
let user_sid = platform::current_user_sid()?;
|
|
Ok(Self {
|
|
config,
|
|
signer,
|
|
idunn_anchor,
|
|
spawned: BTreeMap::new(),
|
|
build: None,
|
|
user_sid,
|
|
})
|
|
}
|
|
|
|
fn log(&self, message: &str) {
|
|
let line = format!(
|
|
"{} {message}\n",
|
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
|
);
|
|
eprint!("{line}");
|
|
if let Ok(mut file) = fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(self.config.logs().join("idunn-host.log"))
|
|
{
|
|
let _ = file.write_all(line.as_bytes());
|
|
}
|
|
}
|
|
|
|
/// One dial: Hello, Welcome, then requests until the session ends.
|
|
fn session(&mut self) -> Result<()> {
|
|
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
|
socket.set_nonblocking(true)?;
|
|
let mut client =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client(
|
|
format!("idunn-host-{}", self.config.host),
|
|
socket,
|
|
self.config.idunn_endpoint(),
|
|
HOST_ACTUATOR_CONNECTION_ID,
|
|
))?;
|
|
let hello = HostActuatorEnvelope::report(
|
|
&self.config.host,
|
|
&[],
|
|
0,
|
|
&HostActuatorReport::Hello {
|
|
platform: std::env::consts::OS.into(),
|
|
actuator_version: env!("CARGO_PKG_VERSION").into(),
|
|
},
|
|
&self.signer,
|
|
)?;
|
|
client.connect(hello.encode()?)?;
|
|
let mut nonce: Option<Vec<u8>> = None;
|
|
let mut last_sequence = 0_u64;
|
|
let mut last_ping = Instant::now();
|
|
loop {
|
|
client.poll_resends()?;
|
|
while let Some(frame) = client.receive_once()? {
|
|
if frame.channel_id != HOST_ACTUATOR_CHANNEL {
|
|
continue;
|
|
}
|
|
if let Err(error) =
|
|
self.handle_frame(&mut client, &mut nonce, &mut last_sequence, &frame.payload)
|
|
{
|
|
self.log(&format!("request refused: {error:#}"));
|
|
}
|
|
}
|
|
if let Some(reason) = client.disconnect_reason() {
|
|
bail!("Idunn disconnected: {}", String::from_utf8_lossy(reason));
|
|
}
|
|
if client.check_timeout(HOST_ACTUATOR_SESSION_TIMEOUT_MS) {
|
|
bail!("Idunn went silent");
|
|
}
|
|
if last_ping.elapsed() >= Duration::from_millis(HOST_ACTUATOR_PING_MS) {
|
|
client.ping(Vec::new())?;
|
|
last_ping = Instant::now();
|
|
}
|
|
if let Some(nonce) = &nonce {
|
|
self.poll_build(&mut client, nonce)?;
|
|
}
|
|
thread::sleep(LOOP_POLL);
|
|
}
|
|
}
|
|
|
|
fn reply(
|
|
&self,
|
|
client: &mut CultNetRudpSocketTransportConnection,
|
|
nonce: &[u8],
|
|
sequence: u64,
|
|
report: &HostActuatorReport,
|
|
) -> Result<()> {
|
|
let envelope =
|
|
HostActuatorEnvelope::report(&self.config.host, nonce, sequence, report, &self.signer)?;
|
|
client.send(HOST_ACTUATOR_CHANNEL, envelope.encode()?)
|
|
}
|
|
|
|
fn handle_frame(
|
|
&mut self,
|
|
client: &mut CultNetRudpSocketTransportConnection,
|
|
nonce: &mut Option<Vec<u8>>,
|
|
last_sequence: &mut u64,
|
|
payload: &[u8],
|
|
) -> Result<()> {
|
|
let envelope = HostActuatorEnvelope::decode(payload)?;
|
|
let request = envelope.open_request(&self.config.host, &self.idunn_anchor)?;
|
|
if let HostActuatorRequest::Welcome = request {
|
|
ensure!(
|
|
nonce.is_none(),
|
|
"Idunn welcomed a session that was already open"
|
|
);
|
|
ensure!(
|
|
envelope.session_nonce.len() >= 16,
|
|
"Welcome carries too short a nonce"
|
|
);
|
|
*nonce = Some(envelope.session_nonce.clone());
|
|
*last_sequence = envelope.sequence;
|
|
self.log("attached to Idunn");
|
|
return self.reply(
|
|
client,
|
|
&envelope.session_nonce,
|
|
envelope.sequence,
|
|
&HostActuatorReport::Welcomed,
|
|
);
|
|
}
|
|
let session_nonce = nonce
|
|
.as_ref()
|
|
.context("a request arrived before Welcome")?
|
|
.clone();
|
|
ensure!(
|
|
envelope.session_nonce == session_nonce,
|
|
"request carries another session's nonce"
|
|
);
|
|
ensure!(
|
|
envelope.sequence > *last_sequence,
|
|
"request sequence {} does not advance past {}",
|
|
envelope.sequence,
|
|
*last_sequence
|
|
);
|
|
*last_sequence = envelope.sequence;
|
|
let sequence = envelope.sequence;
|
|
if let HostActuatorRequest::Materialize {
|
|
transaction_id,
|
|
plan,
|
|
sealed_at_unix_millis,
|
|
} = request
|
|
{
|
|
if self.build.is_some() {
|
|
return self.reply(
|
|
client,
|
|
&session_nonce,
|
|
sequence,
|
|
&HostActuatorReport::Failed {
|
|
error: "a build is already in flight on this host".into(),
|
|
},
|
|
);
|
|
}
|
|
self.log(&format!("materializing {transaction_id}"));
|
|
let config = self.config.clone();
|
|
let handle = thread::spawn(move || {
|
|
materialize(&config, &transaction_id, &plan, sealed_at_unix_millis)
|
|
});
|
|
self.build = Some(BuildInFlight { sequence, handle });
|
|
return Ok(());
|
|
}
|
|
let report = match self.execute(request) {
|
|
Ok(report) => report,
|
|
Err(error) => {
|
|
self.log(&format!("request {sequence} failed: {error:#}"));
|
|
HostActuatorReport::Failed {
|
|
error: format!("{error:#}"),
|
|
}
|
|
}
|
|
};
|
|
self.reply(client, &session_nonce, sequence, &report)
|
|
}
|
|
|
|
fn poll_build(
|
|
&mut self,
|
|
client: &mut CultNetRudpSocketTransportConnection,
|
|
nonce: &[u8],
|
|
) -> Result<()> {
|
|
let finished = self
|
|
.build
|
|
.as_ref()
|
|
.is_some_and(|build| build.handle.is_finished());
|
|
if !finished {
|
|
return Ok(());
|
|
}
|
|
let build = self.build.take().expect("checked above");
|
|
let report = match build.handle.join() {
|
|
Ok(Ok((release, root))) => {
|
|
self.log(&format!("sealed {}", release.sealed_release_id));
|
|
HostActuatorReport::Materialized {
|
|
release,
|
|
root: root.display().to_string(),
|
|
}
|
|
}
|
|
Ok(Err(error)) => {
|
|
self.log(&format!("materialize failed: {error:#}"));
|
|
HostActuatorReport::Failed {
|
|
error: format!("{error:#}"),
|
|
}
|
|
}
|
|
Err(_) => HostActuatorReport::Failed {
|
|
error: "the build thread panicked".into(),
|
|
},
|
|
};
|
|
self.reply(client, nonce, build.sequence, &report)
|
|
}
|
|
|
|
fn execute(&mut self, request: HostActuatorRequest) -> Result<HostActuatorReport> {
|
|
match request {
|
|
HostActuatorRequest::Welcome | HostActuatorRequest::Materialize { .. } => {
|
|
bail!("handled before execute")
|
|
}
|
|
HostActuatorRequest::Install {
|
|
plan,
|
|
release,
|
|
root,
|
|
} => {
|
|
let installed = self.install(&plan, &release, Path::new(&root))?;
|
|
Ok(HostActuatorReport::Installed { installed })
|
|
}
|
|
HostActuatorRequest::PrepareActivation {
|
|
plan,
|
|
expected,
|
|
activation,
|
|
credential,
|
|
} => {
|
|
self.prepare_activation(&plan, &expected, &activation, &credential)?;
|
|
Ok(HostActuatorReport::Prepared { activation })
|
|
}
|
|
HostActuatorRequest::Start {
|
|
plan,
|
|
release,
|
|
installed,
|
|
expected,
|
|
activation,
|
|
} => {
|
|
let observation =
|
|
self.start(&plan, &release, &installed, &expected, &activation)?;
|
|
Ok(HostActuatorReport::Started { observation })
|
|
}
|
|
HostActuatorRequest::Observe {
|
|
expected,
|
|
activation,
|
|
prior,
|
|
} => {
|
|
let observation = self.observe(&expected, &activation, &prior)?;
|
|
Ok(HostActuatorReport::Observed { observation })
|
|
}
|
|
HostActuatorRequest::Stop { observation } => {
|
|
self.stop(&observation)?;
|
|
Ok(HostActuatorReport::Stopped)
|
|
}
|
|
HostActuatorRequest::Probe { prior } => Ok(HostActuatorReport::Probed {
|
|
running: self.is_running(&prior)?,
|
|
}),
|
|
HostActuatorRequest::Discard {
|
|
plan,
|
|
expected,
|
|
activation,
|
|
} => {
|
|
self.discard(&plan, &expected, &activation)?;
|
|
Ok(HostActuatorReport::Discarded)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn workload_binding<'a>(
|
|
&self,
|
|
binding: &'a OperatorBinding,
|
|
) -> Result<&'a HostWorkloadBinding> {
|
|
let workload = binding.workload.host()?;
|
|
ensure!(
|
|
workload.host == self.config.host,
|
|
"plan is bound to host {:?}, this actuator is {:?}",
|
|
workload.host,
|
|
self.config.host
|
|
);
|
|
Ok(workload)
|
|
}
|
|
|
|
fn install(
|
|
&self,
|
|
plan: &CompiledDeploymentPlan,
|
|
release: &SealedRelease,
|
|
root: &Path,
|
|
) -> Result<InstalledReleaseObservation> {
|
|
plan.validate()?;
|
|
release.validate_against(plan)?;
|
|
let (_, binding) = plan.parsed_inputs()?;
|
|
let workload = self.workload_binding(&binding)?;
|
|
let release_root = PathBuf::from(&workload.release_root);
|
|
fs::create_dir_all(&release_root)?;
|
|
let destination = release_root.join(&release.sealed_release_id);
|
|
if destination.exists() && verify_artifacts(&destination, release).is_err() {
|
|
remove_tree_inside(&release_root, &destination)?;
|
|
}
|
|
if !destination.exists() {
|
|
ensure!(
|
|
root.is_dir(),
|
|
"staging root {} is absent; materialize again",
|
|
root.display()
|
|
);
|
|
copy_tree(root, &destination)?;
|
|
}
|
|
verify_artifacts(&destination, release)?;
|
|
Ok(InstalledReleaseObservation {
|
|
sealed_release_id: release.sealed_release_id.clone(),
|
|
root: destination,
|
|
})
|
|
}
|
|
|
|
fn bundle_path(
|
|
&self,
|
|
workload: &HostWorkloadBinding,
|
|
activation: &IdunnRuntimeActivationRecord,
|
|
) -> PathBuf {
|
|
PathBuf::from(&workload.runtime_root).join(&activation.runtime_instance_id)
|
|
}
|
|
|
|
fn prepare_activation(
|
|
&self,
|
|
plan: &CompiledDeploymentPlan,
|
|
expected: &IdunnExpectedIncarnationRecord,
|
|
activation: &IdunnRuntimeActivationRecord,
|
|
credential: &[u8],
|
|
) -> Result<()> {
|
|
plan.validate()?;
|
|
expected.validate()?;
|
|
activation.validate()?;
|
|
ensure!(
|
|
activation.expected_projection_sha256 == expected.canonical_sha256()?,
|
|
"activation belongs to another Expected"
|
|
);
|
|
ensure!(
|
|
credential.len() == 32,
|
|
"activation credential is not a 32-byte seed"
|
|
);
|
|
let (_, binding) = plan.parsed_inputs()?;
|
|
let workload = self.workload_binding(&binding)?;
|
|
let bundle = self.bundle_path(workload, activation);
|
|
if bundle.exists() {
|
|
fs::remove_dir_all(&bundle)?;
|
|
}
|
|
write_runtime_bundle_records(&bundle, expected, activation)?;
|
|
fs::write(bundle.join(ACTIVATION_CREDENTIAL_FILE), credential)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn launch_environment(
|
|
&self,
|
|
workload: &HostWorkloadBinding,
|
|
bundle: &Path,
|
|
) -> Result<BTreeMap<String, String>> {
|
|
let mut environment = workload.environment.clone();
|
|
for (name, path) in &workload.secret_files {
|
|
ensure!(
|
|
Path::new(path).is_file(),
|
|
"secret file {path} bound as {name} is absent on this host"
|
|
);
|
|
ensure!(
|
|
environment.insert(name.clone(), path.clone()).is_none(),
|
|
"workload environment and secret bindings collide on {name}"
|
|
);
|
|
}
|
|
ensure!(
|
|
environment
|
|
.insert(
|
|
IDUNN_RUNTIME_BUNDLE_ENVIRONMENT.into(),
|
|
bundle.display().to_string(),
|
|
)
|
|
.is_none(),
|
|
"operator binding attempts to replace the Idunn runtime bundle"
|
|
);
|
|
ensure!(
|
|
environment
|
|
.insert(
|
|
IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT.into(),
|
|
bundle
|
|
.join(ACTIVATION_CREDENTIAL_FILE)
|
|
.display()
|
|
.to_string(),
|
|
)
|
|
.is_none(),
|
|
"operator binding attempts to replace the activation credential"
|
|
);
|
|
Ok(environment)
|
|
}
|
|
|
|
fn launch_command(
|
|
declaration: &TargetDeclaration,
|
|
workload: &HostWorkloadBinding,
|
|
installed: &Path,
|
|
) -> Result<(PathBuf, Vec<String>)> {
|
|
let artifact = release_artifact(declaration, &declaration.service.executable_artifact)?;
|
|
let executable = installed.join(&artifact.destination);
|
|
ensure!(executable.is_file(), "sealed service executable is absent");
|
|
let mut arguments = Vec::new();
|
|
for argument in &declaration.service.arguments {
|
|
arguments.push(match argument {
|
|
LaunchArgument::Literal { value } => value.clone(),
|
|
LaunchArgument::Binding { name } => workload
|
|
.argument_bindings
|
|
.get(name)
|
|
.with_context(|| format!("no argument binding {name}"))?
|
|
.clone(),
|
|
});
|
|
}
|
|
Ok((executable, arguments))
|
|
}
|
|
|
|
fn start(
|
|
&mut self,
|
|
plan: &CompiledDeploymentPlan,
|
|
release: &SealedRelease,
|
|
installed: &InstalledReleaseObservation,
|
|
expected: &IdunnExpectedIncarnationRecord,
|
|
activation: &IdunnRuntimeActivationRecord,
|
|
) -> Result<HostWorkloadObservation> {
|
|
plan.validate()?;
|
|
release.validate_against(plan)?;
|
|
expected.validate()?;
|
|
activation.validate()?;
|
|
ensure!(
|
|
expected.plan_id == plan.plan_id
|
|
&& expected.sealed_release_id == release.sealed_release_id
|
|
&& activation.expected_projection_sha256 == expected.canonical_sha256()?,
|
|
"workload inputs do not describe one sealed incarnation"
|
|
);
|
|
let (declaration, binding) = plan.parsed_inputs()?;
|
|
let workload = self.workload_binding(&binding)?;
|
|
verify_artifacts(&installed.root, release)?;
|
|
let bundle = self.bundle_path(workload, activation);
|
|
ensure!(
|
|
bundle.join("expected.cc").is_file()
|
|
&& bundle.join("activation.cc").is_file()
|
|
&& bundle.join(ACTIVATION_CREDENTIAL_FILE).is_file(),
|
|
"runtime bundle {} is not prepared",
|
|
bundle.display()
|
|
);
|
|
let (executable, arguments) =
|
|
Self::launch_command(&declaration, workload, &installed.root)?;
|
|
let (executable_sha256, _) = digest_artifact(&executable)?;
|
|
let executable_sha256 = format!("sha256-{executable_sha256}");
|
|
ensure!(
|
|
executable_sha256 == expected.artifact_sha256,
|
|
"installed executable differs from Expected"
|
|
);
|
|
let environment = self.launch_environment(workload, &bundle)?;
|
|
if let Some(mut previous) = self.spawned.remove(&activation.runtime_instance_id) {
|
|
if previous.try_wait()?.is_none() {
|
|
bail!("a child for this activation is already running");
|
|
}
|
|
}
|
|
let stdout = File::create(bundle.join("stdout.log"))?;
|
|
let stderr = File::create(bundle.join("stderr.log"))?;
|
|
let mut command = Command::new(&executable);
|
|
command
|
|
.args(&arguments)
|
|
.current_dir(&installed.root)
|
|
.env_clear()
|
|
.envs(passthrough_environment())
|
|
.envs(&environment)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::from(stdout))
|
|
.stderr(Stdio::from(stderr));
|
|
platform::detach(&mut command);
|
|
let child = command
|
|
.spawn()
|
|
.with_context(|| format!("starting {}", executable.display()))?;
|
|
let pid = child.id();
|
|
self.spawned
|
|
.insert(activation.runtime_instance_id.clone(), child);
|
|
self.log(&format!(
|
|
"started {} pid {pid} from {}",
|
|
activation.runtime_instance_id,
|
|
executable.display()
|
|
));
|
|
let command_line = rmp_serde::to_vec(&(executable.display().to_string(), &arguments))?;
|
|
let facts = platform::process_facts(pid)?.context("the started process is already gone")?;
|
|
Ok(HostWorkloadObservation {
|
|
host: self.config.host.clone(),
|
|
actuator_identity_id: self.signer.entry().identity_id.clone(),
|
|
process_id: pid,
|
|
process_creation_time: facts.creation_time,
|
|
session_id: facts.session_id,
|
|
user_sid: self.user_sid.clone(),
|
|
executable: executable.display().to_string(),
|
|
executable_sha256,
|
|
command_line_sha256: sha256_id(&command_line),
|
|
environment_names: environment.keys().cloned().collect(),
|
|
environment_contract_sha256: sha256_id(&rmp_serde::to_vec(&environment)?),
|
|
runtime_bundle: bundle.display().to_string(),
|
|
runtime_instance_id: activation.runtime_instance_id.clone(),
|
|
activation_signer_identity_id: activation.activation_signer_identity_id.clone(),
|
|
activation_signer_public_key: activation.activation_signer_public_key.clone(),
|
|
exit_code: self.exit_code(&activation.runtime_instance_id, pid, facts.creation_time)?,
|
|
})
|
|
}
|
|
|
|
/// `Some` once the process behind (pid, creation time) is gone. A child
|
|
/// this actuator spawned reports its real exit code; any other process
|
|
/// is vanished or running, nothing finer.
|
|
fn exit_code(
|
|
&mut self,
|
|
runtime_instance_id: &str,
|
|
pid: u32,
|
|
creation_time: u64,
|
|
) -> Result<Option<u32>> {
|
|
if let Some(child) = self.spawned.get_mut(runtime_instance_id) {
|
|
if child.id() == pid {
|
|
return Ok(match child.try_wait()? {
|
|
Some(status) => {
|
|
Some(status.code().map_or(EXIT_CODE_VANISHED, |code| code as u32))
|
|
}
|
|
None => None,
|
|
});
|
|
}
|
|
}
|
|
Ok(match platform::process_facts(pid)? {
|
|
Some(facts) if facts.creation_time == creation_time => facts.exit_code,
|
|
_ => Some(EXIT_CODE_VANISHED),
|
|
})
|
|
}
|
|
|
|
fn observe(
|
|
&mut self,
|
|
expected: &IdunnExpectedIncarnationRecord,
|
|
activation: &IdunnRuntimeActivationRecord,
|
|
prior: &HostWorkloadObservation,
|
|
) -> Result<HostWorkloadObservation> {
|
|
expected.validate()?;
|
|
activation.validate()?;
|
|
ensure!(
|
|
activation.runtime_instance_id == prior.runtime_instance_id,
|
|
"observation belongs to another activation"
|
|
);
|
|
ensure!(
|
|
prior.host == self.config.host,
|
|
"observation names another host"
|
|
);
|
|
let exit_code = self.exit_code(
|
|
&prior.runtime_instance_id,
|
|
prior.process_id,
|
|
prior.process_creation_time,
|
|
)?;
|
|
let mut observation = prior.clone();
|
|
observation.exit_code = exit_code;
|
|
if exit_code.is_none() {
|
|
let facts = platform::process_facts(prior.process_id)?
|
|
.context("the process disappeared between two reads")?;
|
|
ensure!(
|
|
facts.creation_time == prior.process_creation_time,
|
|
"pid {} now belongs to another process",
|
|
prior.process_id
|
|
);
|
|
ensure!(
|
|
same_path(&facts.image_path, &prior.executable),
|
|
"pid {} now runs {} instead of {}",
|
|
prior.process_id,
|
|
facts.image_path,
|
|
prior.executable
|
|
);
|
|
let (executable_sha256, _) = digest_artifact(Path::new(&prior.executable))?;
|
|
ensure!(
|
|
format!("sha256-{executable_sha256}") == prior.executable_sha256,
|
|
"the installed executable changed under the running process"
|
|
);
|
|
}
|
|
Ok(observation)
|
|
}
|
|
|
|
fn is_running(&mut self, prior: &HostWorkloadObservation) -> Result<bool> {
|
|
Ok(self
|
|
.exit_code(
|
|
&prior.runtime_instance_id,
|
|
prior.process_id,
|
|
prior.process_creation_time,
|
|
)?
|
|
.is_none())
|
|
}
|
|
|
|
fn stop(&mut self, observation: &HostWorkloadObservation) -> Result<()> {
|
|
ensure!(
|
|
observation.host == self.config.host,
|
|
"observation names another host"
|
|
);
|
|
if !self.is_running(observation)? {
|
|
self.spawned.remove(&observation.runtime_instance_id);
|
|
return Ok(());
|
|
}
|
|
self.log(&format!(
|
|
"stopping {} pid {}",
|
|
observation.runtime_instance_id, observation.process_id
|
|
));
|
|
platform::terminate(observation.process_id, observation.process_creation_time)?;
|
|
let deadline = Instant::now() + STOP_WAIT;
|
|
while self.is_running(observation)? {
|
|
ensure!(
|
|
Instant::now() < deadline,
|
|
"pid {} did not exit within {STOP_WAIT:?}",
|
|
observation.process_id
|
|
);
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
self.spawned.remove(&observation.runtime_instance_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn discard(
|
|
&mut self,
|
|
plan: &CompiledDeploymentPlan,
|
|
expected: &IdunnExpectedIncarnationRecord,
|
|
activation: &IdunnRuntimeActivationRecord,
|
|
) -> Result<()> {
|
|
ensure!(
|
|
activation.expected_projection_sha256 == expected.canonical_sha256()?,
|
|
"discarded activation does not belong to the prepared Expected"
|
|
);
|
|
let (_, binding) = plan.parsed_inputs()?;
|
|
let workload = self.workload_binding(&binding)?;
|
|
if let Some(mut child) = self.spawned.remove(&activation.runtime_instance_id) {
|
|
if child.try_wait()?.is_none() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
let bundle = self.bundle_path(workload, activation);
|
|
if bundle.exists() {
|
|
fs::remove_dir_all(&bundle)
|
|
.with_context(|| format!("removing runtime bundle {}", bundle.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn same_path(left: &str, right: &str) -> bool {
|
|
let normalize = |value: &str| value.replace('/', "\\").to_ascii_lowercase();
|
|
normalize(left) == normalize(right)
|
|
|| fs::canonicalize(left)
|
|
.ok()
|
|
.zip(fs::canonicalize(right).ok())
|
|
.is_some_and(|(left, right)| left == right)
|
|
}
|
|
|
|
fn passthrough_environment() -> BTreeMap<String, String> {
|
|
PASSTHROUGH_ENVIRONMENT
|
|
.iter()
|
|
.filter_map(|name| {
|
|
std::env::var(name)
|
|
.ok()
|
|
.map(|value| (name.to_string(), value))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn verify_artifacts(root: &Path, release: &SealedRelease) -> Result<()> {
|
|
for artifact in &release.artifacts {
|
|
let path = root.join(&artifact.destination);
|
|
ensure!(path.exists(), "artifact {} is absent", artifact.artifact_id);
|
|
let (sha256, size_bytes) = digest_artifact(&path)?;
|
|
ensure!(
|
|
format!("sha256-{sha256}") == artifact.sha256 && size_bytes == artifact.size_bytes,
|
|
"artifact {} differs from its sealed digest",
|
|
artifact.artifact_id
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// git on a Windows host may be configured to rewrite line endings on
|
|
/// checkout; the frozen tree is byte-exact and so must the build input be.
|
|
fn git(directory: &Path, args: &[&str]) -> Result<String> {
|
|
let output = Command::new("git")
|
|
.args(["-c", "core.autocrlf=false", "-c", "core.eol=lf"])
|
|
.args(args)
|
|
.current_dir(directory)
|
|
.env_clear()
|
|
.envs(passthrough_environment())
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.context("running git")?;
|
|
ensure!(
|
|
output.status.success(),
|
|
"git {} failed: {}",
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
);
|
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
}
|
|
|
|
/// Fetch the exact revision, prove it is the tree the plan froze, run the
|
|
/// steps, collect the artifacts, seal. Runs on a thread; touches only the
|
|
/// work root.
|
|
fn materialize(
|
|
config: &HostActuatorConfig,
|
|
transaction_id: &str,
|
|
plan: &CompiledDeploymentPlan,
|
|
sealed_at_unix_millis: u64,
|
|
) -> Result<(SealedRelease, PathBuf)> {
|
|
plan.validate()?;
|
|
let (declaration, binding) = plan.parsed_inputs()?;
|
|
let workload = binding.workload.host()?;
|
|
ensure!(
|
|
workload.host == config.host,
|
|
"plan is bound to host {:?}, this actuator is {:?}",
|
|
workload.host,
|
|
config.host
|
|
);
|
|
ensure!(
|
|
plan.source.gitlinks.is_empty(),
|
|
"host-native materialization does not fetch gitlinks yet"
|
|
);
|
|
ensure!(
|
|
declaration.external_inputs.is_empty(),
|
|
"host-native materialization does not fetch external inputs"
|
|
);
|
|
ensure!(
|
|
transaction_id
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
|
"transaction id is not a path component"
|
|
);
|
|
|
|
let source = config.sources().join(transaction_id);
|
|
if source.exists() {
|
|
remove_tree_inside(&config.sources(), &source)?;
|
|
}
|
|
git(
|
|
&config.sources(),
|
|
&[
|
|
"clone",
|
|
"--quiet",
|
|
"--no-checkout",
|
|
&plan.source.origin,
|
|
&source.display().to_string(),
|
|
],
|
|
)?;
|
|
git(
|
|
&source,
|
|
&["checkout", "--quiet", "--detach", &plan.source.revision],
|
|
)?;
|
|
ensure!(
|
|
git(&source, &["rev-parse", "HEAD"])? == plan.source.revision,
|
|
"checked out revision differs from the frozen one"
|
|
);
|
|
ensure!(
|
|
git(&source, &["rev-parse", "HEAD^{tree}"])? == plan.source.source_tree,
|
|
"checked out tree differs from the frozen one"
|
|
);
|
|
let recipe = fs::read(source.join(&plan.source.recipe_path))
|
|
.context("reading the recipe from the fetched tree")?;
|
|
ensure!(
|
|
recipe == plan.recipe_blob,
|
|
"recipe in the fetched tree differs from the plan's recipe bytes ({} vs {} bytes)",
|
|
recipe.len(),
|
|
plan.recipe_blob.len()
|
|
);
|
|
|
|
for step in &declaration.steps {
|
|
let runner = binding.runners[&step.runner].host_native()?;
|
|
ensure!(!step.argv.is_empty(), "step {} has no program", step.id);
|
|
ensure!(
|
|
runner.allowed_programs.contains(&step.argv[0]),
|
|
"runner {} does not admit program {}",
|
|
step.runner,
|
|
step.argv[0]
|
|
);
|
|
let mut environment = passthrough_environment();
|
|
environment.extend(runner.environment.clone());
|
|
environment.insert(
|
|
declaration.source_stamp_environment.clone(),
|
|
plan.source.revision.clone(),
|
|
);
|
|
for required in &step.required_environment {
|
|
ensure!(
|
|
environment.contains_key(required),
|
|
"step {} lacks required environment {required}",
|
|
step.id
|
|
);
|
|
}
|
|
let working_directory = source.join(&step.working_directory);
|
|
let log_path = config
|
|
.logs()
|
|
.join(format!("{transaction_id}-{}.log", step.id));
|
|
let log = File::create(&log_path)?;
|
|
let status = Command::new(&step.argv[0])
|
|
.args(&step.argv[1..])
|
|
.current_dir(&working_directory)
|
|
.env_clear()
|
|
.envs(&environment)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::from(log.try_clone()?))
|
|
.stderr(Stdio::from(log))
|
|
.status()
|
|
.with_context(|| format!("running step {}", step.id))?;
|
|
ensure!(
|
|
status.success(),
|
|
"step {} exited with {status}; log at {}",
|
|
step.id,
|
|
log_path.display()
|
|
);
|
|
}
|
|
|
|
let staging = config.staging().join(transaction_id);
|
|
if staging.exists() {
|
|
remove_tree_inside(&config.staging(), &staging)?;
|
|
}
|
|
fs::create_dir_all(&staging)?;
|
|
let mut artifacts = Vec::new();
|
|
for artifact in &declaration.artifacts {
|
|
let source_path = match artifact.source_kind {
|
|
ArtifactSource::RunnerOutput | ArtifactSource::WorktreeTree => {
|
|
source.join(&artifact.source)
|
|
}
|
|
};
|
|
ensure!(
|
|
source_path.exists(),
|
|
"declared artifact output {} is absent",
|
|
artifact.id
|
|
);
|
|
let destination = staging.join(&artifact.destination);
|
|
ensure!(
|
|
destination.starts_with(&staging),
|
|
"artifact destination escaped its staging root"
|
|
);
|
|
copy_artifact(&source, &source_path, &destination)?;
|
|
let (sha256, size_bytes) = digest_artifact(&destination)?;
|
|
if let Some(expected) = &artifact.expected_sha256 {
|
|
ensure!(
|
|
&sha256 == expected,
|
|
"artifact {} differs from its recipe-pinned digest",
|
|
artifact.id
|
|
);
|
|
}
|
|
artifacts.push(ArtifactReceipt {
|
|
artifact_id: artifact.id.clone(),
|
|
destination: artifact.destination.clone(),
|
|
sha256: format!("sha256-{sha256}"),
|
|
size_bytes,
|
|
executable: artifact.executable,
|
|
});
|
|
}
|
|
let release = SealedRelease::new(plan, artifacts, Vec::new(), sealed_at_unix_millis)?;
|
|
Ok((release, staging))
|
|
}
|
|
|
|
/// Everything that asks the operating system about a process.
|
|
#[cfg(windows)]
|
|
mod platform {
|
|
use super::*;
|
|
use windows_sys::Win32::Foundation::{
|
|
CloseHandle, FILETIME, GetLastError, HANDLE, LocalFree, STILL_ACTIVE,
|
|
};
|
|
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
|
|
use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
|
|
use windows_sys::Win32::System::RemoteDesktop::ProcessIdToSessionId;
|
|
use windows_sys::Win32::System::Threading::{
|
|
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, GetCurrentProcess, GetExitCodeProcess,
|
|
GetProcessTimes, OpenProcess, OpenProcessToken, PROCESS_NAME_WIN32,
|
|
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, QueryFullProcessImageNameW,
|
|
TerminateProcess,
|
|
};
|
|
|
|
pub struct ProcessFacts {
|
|
pub creation_time: u64,
|
|
pub image_path: String,
|
|
pub session_id: u32,
|
|
pub exit_code: Option<u32>,
|
|
}
|
|
|
|
struct Handle(HANDLE);
|
|
|
|
impl Drop for Handle {
|
|
fn drop(&mut self) {
|
|
// SAFETY: the handle was returned by OpenProcess and is closed once.
|
|
unsafe {
|
|
CloseHandle(self.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open(pid: u32, access: u32) -> Result<Option<Handle>> {
|
|
// SAFETY: OpenProcess has no memory-safety preconditions.
|
|
let handle = unsafe { OpenProcess(access, 0, pid) };
|
|
if handle.is_null() {
|
|
// SAFETY: GetLastError is always safe to call.
|
|
let error = unsafe { GetLastError() };
|
|
const ERROR_INVALID_PARAMETER: u32 = 87;
|
|
const ERROR_ACCESS_DENIED: u32 = 5;
|
|
return match error {
|
|
ERROR_INVALID_PARAMETER => Ok(None),
|
|
ERROR_ACCESS_DENIED => bail!("access denied opening pid {pid}"),
|
|
other => bail!("OpenProcess({pid}) failed with Win32 error {other}"),
|
|
};
|
|
}
|
|
Ok(Some(Handle(handle)))
|
|
}
|
|
|
|
fn filetime_u64(value: FILETIME) -> u64 {
|
|
(u64::from(value.dwHighDateTime) << 32) | u64::from(value.dwLowDateTime)
|
|
}
|
|
|
|
pub fn process_facts(pid: u32) -> Result<Option<ProcessFacts>> {
|
|
let Some(handle) = open(pid, PROCESS_QUERY_LIMITED_INFORMATION)? else {
|
|
return Ok(None);
|
|
};
|
|
let zero = FILETIME {
|
|
dwLowDateTime: 0,
|
|
dwHighDateTime: 0,
|
|
};
|
|
let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero);
|
|
// SAFETY: the handle is open with query access and every out-pointer is valid.
|
|
let ok =
|
|
unsafe { GetProcessTimes(handle.0, &mut creation, &mut exit, &mut kernel, &mut user) };
|
|
ensure!(ok != 0, "GetProcessTimes({pid}) failed");
|
|
let mut code = 0_u32;
|
|
// SAFETY: as above.
|
|
let ok = unsafe { GetExitCodeProcess(handle.0, &mut code) };
|
|
ensure!(ok != 0, "GetExitCodeProcess({pid}) failed");
|
|
let exit_code = if code == STILL_ACTIVE as u32 {
|
|
None
|
|
} else {
|
|
Some(code)
|
|
};
|
|
let mut buffer = vec![0_u16; 32_768];
|
|
let mut length = buffer.len() as u32;
|
|
// SAFETY: the buffer is valid for `length` UTF-16 units.
|
|
let ok = unsafe {
|
|
QueryFullProcessImageNameW(
|
|
handle.0,
|
|
PROCESS_NAME_WIN32,
|
|
buffer.as_mut_ptr(),
|
|
&mut length,
|
|
)
|
|
};
|
|
let image_path = if ok != 0 {
|
|
String::from_utf16_lossy(&buffer[..length as usize])
|
|
} else {
|
|
// A process that has exited still answers times and exit code but
|
|
// may no longer name its image.
|
|
ensure!(
|
|
exit_code.is_some(),
|
|
"QueryFullProcessImageNameW({pid}) failed"
|
|
);
|
|
String::new()
|
|
};
|
|
let mut session_id = 0_u32;
|
|
// SAFETY: the out-pointer is valid.
|
|
let ok = unsafe { ProcessIdToSessionId(pid, &mut session_id) };
|
|
ensure!(
|
|
ok != 0 || exit_code.is_some(),
|
|
"ProcessIdToSessionId({pid}) failed"
|
|
);
|
|
Ok(Some(ProcessFacts {
|
|
creation_time: filetime_u64(creation),
|
|
image_path,
|
|
session_id,
|
|
exit_code,
|
|
}))
|
|
}
|
|
|
|
pub fn terminate(pid: u32, creation_time: u64) -> Result<()> {
|
|
let Some(facts) = process_facts(pid)? else {
|
|
return Ok(());
|
|
};
|
|
ensure!(
|
|
facts.creation_time == creation_time,
|
|
"refusing to terminate pid {pid}: it is now another process"
|
|
);
|
|
if facts.exit_code.is_some() {
|
|
return Ok(());
|
|
}
|
|
let Some(handle) = open(pid, PROCESS_TERMINATE)? else {
|
|
return Ok(());
|
|
};
|
|
// SAFETY: the handle has terminate access.
|
|
let ok = unsafe { TerminateProcess(handle.0, 1) };
|
|
ensure!(ok != 0, "TerminateProcess({pid}) failed");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn detach(command: &mut Command) {
|
|
use std::os::windows::process::CommandExt;
|
|
command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
|
|
}
|
|
|
|
pub fn current_user_sid() -> Result<String> {
|
|
let mut token: HANDLE = std::ptr::null_mut();
|
|
// SAFETY: the current process pseudo-handle is always valid.
|
|
let ok = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) };
|
|
ensure!(ok != 0, "OpenProcessToken failed");
|
|
let token = Handle(token);
|
|
let mut length = 0_u32;
|
|
// SAFETY: a null buffer with zero length asks for the required size.
|
|
unsafe {
|
|
GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut length);
|
|
}
|
|
ensure!(length > 0, "GetTokenInformation reported no size");
|
|
let mut buffer = vec![0_u8; length as usize];
|
|
// SAFETY: the buffer is at least `length` bytes.
|
|
let ok = unsafe {
|
|
GetTokenInformation(
|
|
token.0,
|
|
TokenUser,
|
|
buffer.as_mut_ptr().cast(),
|
|
length,
|
|
&mut length,
|
|
)
|
|
};
|
|
ensure!(ok != 0, "GetTokenInformation failed");
|
|
// SAFETY: the buffer holds a TOKEN_USER written by the call above.
|
|
let user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) };
|
|
let mut string_sid: *mut u16 = std::ptr::null_mut();
|
|
// SAFETY: the SID pointer comes from the token buffer, still alive.
|
|
let ok = unsafe { ConvertSidToStringSidW(user.User.Sid, &mut string_sid) };
|
|
ensure!(
|
|
ok != 0 && !string_sid.is_null(),
|
|
"ConvertSidToStringSidW failed"
|
|
);
|
|
let mut length = 0;
|
|
// SAFETY: the string is NUL-terminated by the API.
|
|
while unsafe { *string_sid.add(length) } != 0 {
|
|
length += 1;
|
|
}
|
|
// SAFETY: `length` units are valid.
|
|
let sid =
|
|
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(string_sid, length) });
|
|
// SAFETY: the API allocated the string with LocalAlloc.
|
|
unsafe {
|
|
LocalFree(string_sid.cast());
|
|
}
|
|
Ok(sid)
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
mod platform {
|
|
use super::*;
|
|
|
|
pub struct ProcessFacts {
|
|
pub creation_time: u64,
|
|
pub image_path: String,
|
|
pub session_id: u32,
|
|
pub exit_code: Option<u32>,
|
|
}
|
|
|
|
pub fn process_facts(_pid: u32) -> Result<Option<ProcessFacts>> {
|
|
bail!("idunn-host observes processes on Windows only")
|
|
}
|
|
|
|
pub fn terminate(_pid: u32, _creation_time: u64) -> Result<()> {
|
|
bail!("idunn-host stops processes on Windows only")
|
|
}
|
|
|
|
pub fn detach(_command: &mut Command) {}
|
|
|
|
pub fn current_user_sid() -> Result<String> {
|
|
bail!("idunn-host runs on Windows only")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn config_is_strict_and_absolute() {
|
|
let root = if cfg!(windows) {
|
|
"C:/GameCult/idunn"
|
|
} else {
|
|
"/gamecult/idunn"
|
|
};
|
|
let good = format!(
|
|
r#"
|
|
schema = "gamecult.idunn.host_actuator_config.v1"
|
|
host = "raven"
|
|
idunn_endpoint = "10.77.0.1:17890"
|
|
identity_store = "{root}/host-actuator-identity.cc"
|
|
idunn_trust_anchor = "{root}/idunn-public-anchor.cc"
|
|
work_root = "{root}/work"
|
|
"#
|
|
);
|
|
let config = HostActuatorConfig::parse(&good).expect("valid config");
|
|
assert_eq!(config.host, "raven");
|
|
assert!(HostActuatorConfig::parse(&good.replace("10.77.0.1:17890", "yggdrasil")).is_err());
|
|
assert!(HostActuatorConfig::parse(&good.replace("host_actuator_config.v1", "v0")).is_err());
|
|
assert!(HostActuatorConfig::parse(&format!("{good}\nextra = 1\n")).is_err());
|
|
assert!(HostActuatorConfig::parse(&good.replace(&format!("{root}/work"), "work")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_is_an_allowlist_not_the_whole_environment() {
|
|
let environment = passthrough_environment();
|
|
for name in environment.keys() {
|
|
assert!(PASSTHROUGH_ENVIRONMENT.contains(&name.as_str()), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_step_program_outside_the_allowlist_is_refused() {
|
|
let allowed: BTreeSet<String> = ["cargo".to_string()].into();
|
|
assert!(allowed.contains("cargo"));
|
|
assert!(!allowed.contains("powershell"));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn the_actuator_can_observe_itself_and_a_child() -> Result<()> {
|
|
let facts = platform::process_facts(std::process::id())?.expect("own process");
|
|
assert!(facts.creation_time > 0);
|
|
assert!(facts.exit_code.is_none());
|
|
assert!(facts.image_path.to_ascii_lowercase().ends_with(".exe"));
|
|
let sid = platform::current_user_sid()?;
|
|
assert!(sid.starts_with("S-1-"), "{sid}");
|
|
|
|
let mut command = Command::new("cmd.exe");
|
|
command
|
|
.args(["/c", "exit 7"])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
platform::detach(&mut command);
|
|
let mut child = command.spawn()?;
|
|
let pid = child.id();
|
|
let status = child.wait()?;
|
|
assert_eq!(status.code(), Some(7));
|
|
// After wait the handle is closed and the pid may be gone or reused;
|
|
// either answer is a non-running answer.
|
|
match platform::process_facts(pid)? {
|
|
None => {}
|
|
Some(facts) => assert!(facts.exit_code.is_some() || facts.creation_time > 0),
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|