Recognize only active owned Indee API restart override

This commit is contained in:
archipelago
2026-10-08 00:10:03 -04:00
parent 902333e336
commit 884ea49238
@@ -9,12 +9,183 @@ use sha2::{Digest, Sha256};
use std::{ use std::{
collections::HashMap, collections::HashMap,
future::Future, future::Future,
io::Write, io::{Read, Write},
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf}, path::{Path, PathBuf},
time::Duration, time::Duration,
}; };
fn owned_directory(path: &Path) -> Result<()> {
let meta = std::fs::symlink_metadata(path)?;
anyhow::ensure!(
meta.is_dir()
&& !meta.file_type().is_symlink()
&& meta.uid() == unsafe { libc::geteuid() }
&& meta.mode() & 0o022 == 0,
"Maintenance ownership directory changed"
);
Ok(())
}
fn owned_record(path: &Path, limit: u64, exact_private: bool) -> Result<Vec<u8>> {
let mut file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let meta = file.metadata()?;
anyhow::ensure!(
meta.is_file()
&& meta.uid() == unsafe { libc::geteuid() }
&& meta.mode() & 0o022 == 0
&& (!exact_private || meta.mode() & 0o777 == 0o600)
&& meta.len() <= limit,
"Maintenance ownership record changed"
);
let mut bytes = Vec::new();
Read::by_ref(&mut file)
.take(limit + 1)
.read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() as u64 <= limit,
"Maintenance ownership record exceeds bound"
);
Ok(bytes)
}
/// The sole admitted drop-in is the currently held Indee API restart fence.
/// This never grants ownership to arbitrary operator or historical overrides.
fn validate_maintenance_override(
data: &Path,
runtime: &Path,
name: &str,
dropins: &str,
restart: &str,
) -> Result<()> {
if dropins.is_empty() {
return Ok(());
}
anyhow::ensure!(
name == "indeedhub-api" && restart == "no",
"External service override refused"
);
owned_directory(data)?;
for relative in [
"update-transactions",
"update-transactions/holds",
"update-transactions/supervised",
"update-transactions/indeehub-maintenance",
"app-maintenance",
] {
owned_directory(&data.join(relative))?;
}
let operation = String::from_utf8(owned_record(
&data.join("update-transactions/holds/indeedhub-api"),
128,
false,
)?)?;
anyhow::ensure!(
uuid::Uuid::parse_str(&operation)?.to_string() == operation,
"Invalid maintenance hold owner"
);
anyhow::ensure!(
owned_record(&data.join("app-maintenance/indeedhub"), 128, false)? == operation.as_bytes(),
"Maintenance admission fence owner changed"
);
let maintenance_dir = data
.join("update-transactions/indeehub-maintenance")
.join(&operation);
owned_directory(&maintenance_dir)?;
let journal: serde_json::Value = serde_json::from_slice(&owned_record(
&maintenance_dir.join("journal.json"),
16 * 1024 * 1024,
false,
)?)?;
let native: serde_json::Value = serde_json::from_slice(&owned_record(
&data
.join("update-transactions/supervised")
.join(format!("{operation}.json")),
16 * 1024 * 1024,
false,
)?)?;
anyhow::ensure!(
native["id"].as_str() == Some(operation.as_str())
&& native["package"] == "indeedhub"
&& matches!(native["schema"].as_u64(), Some(1 | 2))
&& native["cleanup_done"] == false
&& matches!(
native["phase"].as_str(),
Some("Editing" | "Starting" | "Restoring" | "Restored" | "Committed")
),
"Native maintenance operation is not active"
);
anyhow::ensure!(
journal["operation_id"].as_str() == Some(operation.as_str())
&& matches!(
journal["phase"].as_str(),
Some("Prepared" | "Drained" | "Recovering")
),
"Maintenance journal owner is not active"
);
let originals = native["members"]
.as_array()
.context("Missing native members")?;
let matching: Vec<_> = originals
.iter()
.filter(|m| m["original"]["name"] == name)
.collect();
anyhow::ensure!(matching.len() == 1, "Ambiguous native API identity");
let original = &matching[0]["original"];
let members = journal["original_members"]
.as_array()
.context("Missing maintenance members")?;
let matching: Vec<_> = members.iter().filter(|m| m["name"] == name).collect();
anyhow::ensure!(matching.len() == 1, "Ambiguous maintenance API identity");
let member = matching[0];
let body = original["body"]
.as_str()
.context("Missing original API unit")?;
anyhow::ensure!(
["container_id", "image", "config_sha256"]
.iter()
.all(|key| original[*key]
.as_str()
.is_some_and(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())))
&& member["container_id"] == original["container_id"]
&& member["image_id"] == original["image"]
&& member["config_sha256"] == original["config_sha256"]
&& original["running"].is_boolean()
&& member["running"] == original["running"]
&& member["unit_sha256"] == hex::encode(Sha256::digest(body.as_bytes())),
"Maintenance API binding changed"
);
let bytes = format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n");
let saved = &journal["api_restart_override"];
anyhow::ensure!(
saved["operation_id"].as_str() == Some(operation.as_str())
&& saved["released"] == false
&& saved["sha256"] == hex::encode(Sha256::digest(bytes.as_bytes())),
"Maintenance restart override obligation changed"
);
owned_directory(runtime)?;
for relative in [
"systemd",
"systemd/user",
"systemd/user/indeedhub-api.service.d",
] {
owned_directory(&runtime.join(relative))?;
}
let expected = runtime
.join("systemd/user/indeedhub-api.service.d")
.join(format!("zz-archipelago-maintenance-{operation}.conf"));
anyhow::ensure!(
expected.to_str() == Some(dropins),
"Unexpected or additional service override"
);
anyhow::ensure!(
owned_record(&expected, 512, true)? == bytes.as_bytes(),
"Maintenance restart override bytes changed"
);
Ok(())
}
pub(crate) trait DrainBarrier: Sync { pub(crate) trait DrainBarrier: Sync {
/// Called after the node persisted original writable recovery images. /// Called after the node persisted original writable recovery images.
/// Persist ownership before blocking admissions or stopping writers. Return /// Persist ownership before blocking admissions or stopping writers. Return
@@ -449,6 +620,7 @@ impl<B: DrainBarrier> SystemdSupervisor<B> {
"--property=SourcePath", "--property=SourcePath",
"--property=DropInPaths", "--property=DropInPaths",
"--property=LoadState", "--property=LoadState",
"--property=Restart",
]) ])
.await?; .await?;
let values: HashMap<_, _> = output let values: HashMap<_, _> = output
@@ -457,12 +629,22 @@ impl<B: DrainBarrier> SystemdSupervisor<B> {
.collect(); .collect();
anyhow::ensure!( anyhow::ensure!(
values.get("LoadState") == Some(&"loaded") values.get("LoadState") == Some(&"loaded")
&& values.get("DropInPaths") == Some(&"")
&& values && values
.get("SourcePath") .get("SourcePath")
.is_some_and(|path| Path::new(path) == expected), .is_some_and(|path| Path::new(path) == expected),
"Service is not owned by the exact original source Quadlet or has external overrides" "Service is not owned by the exact original source Quadlet or has external overrides"
); );
validate_maintenance_override(
&self.data_dir,
&PathBuf::from(format!("/run/user/{}", unsafe { libc::geteuid() })),
name,
values
.get("DropInPaths")
.context("Missing service override inventory")?,
values
.get("Restart")
.context("Missing service restart policy")?,
)?;
let meta = std::fs::symlink_metadata(&expected)?; let meta = std::fs::symlink_metadata(&expected)?;
anyhow::ensure!( anyhow::ensure!(
meta.is_file() meta.is_file()
@@ -680,6 +862,200 @@ impl<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
struct OverrideFixture {
_root: tempfile::TempDir,
data: PathBuf,
runtime: PathBuf,
override_path: PathBuf,
native_path: PathBuf,
journal_path: PathBuf,
}
fn private_write(path: &Path, bytes: &[u8]) {
std::fs::write(path, bytes).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
impl OverrideFixture {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let data = root.path().join("data");
let runtime = root.path().join("runtime");
let operation = uuid::Uuid::new_v4().to_string();
let maintenance = data
.join("update-transactions/indeehub-maintenance")
.join(&operation);
for path in [
data.join("update-transactions/holds"),
data.join("update-transactions/supervised"),
maintenance.clone(),
data.join("app-maintenance"),
runtime.join("systemd/user/indeedhub-api.service.d"),
] {
std::fs::create_dir_all(path).unwrap();
}
private_write(
&data.join("update-transactions/holds/indeedhub-api"),
operation.as_bytes(),
);
private_write(
&data.join("app-maintenance/indeedhub"),
operation.as_bytes(),
);
let body = "[Container]\nContainerName=indeedhub-api\n";
let original = serde_json::json!({"name":"indeedhub-api","container_id":"a".repeat(64),"image":"b".repeat(64),"config_sha256":"c".repeat(64),"body":body,"running":true});
let native = serde_json::json!({"schema":2,"id":operation,"package":"indeedhub","phase":"Restoring","cleanup_done":false,"members":[{"original":original}]});
let bytes =
format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n");
let journal = serde_json::json!({"operation_id":operation,"phase":"Recovering","original_members":[{"name":"indeedhub-api","container_id":original["container_id"],"image_id":original["image"],"config_sha256":original["config_sha256"],"unit_sha256":hex::encode(Sha256::digest(body.as_bytes())),"running":true}],"api_restart_override":{"operation_id":operation,"released":false,"sha256":hex::encode(Sha256::digest(bytes.as_bytes()))}});
let override_path = runtime
.join("systemd/user/indeedhub-api.service.d")
.join(format!("zz-archipelago-maintenance-{operation}.conf"));
let native_path = data
.join("update-transactions/supervised")
.join(format!("{operation}.json"));
let journal_path = maintenance.join("journal.json");
private_write(&override_path, bytes.as_bytes());
private_write(&native_path, &serde_json::to_vec(&native).unwrap());
private_write(&journal_path, &serde_json::to_vec(&journal).unwrap());
Self {
_root: root,
data,
runtime,
override_path,
native_path,
journal_path,
}
}
fn validate(&self) -> Result<()> {
validate_maintenance_override(
&self.data,
&self.runtime,
"indeedhub-api",
self.override_path.to_str().unwrap(),
"no",
)
}
}
#[test]
fn only_exact_active_api_restart_override_is_owned() {
let f = OverrideFixture::new();
f.validate().unwrap();
for (name, paths, restart) in [
(
"indeedhub-ffmpeg",
f.override_path.display().to_string(),
"no",
),
(
"indeedhub-api",
format!("{} /other.conf", f.override_path.display()),
"no",
),
(
"indeedhub-api",
f.override_path.display().to_string(),
"always",
),
] {
assert!(
validate_maintenance_override(&f.data, &f.runtime, name, &paths, restart).is_err()
);
}
// Services without overrides preserve the existing ordinary ownership path.
validate_maintenance_override(
Path::new("/absent"),
Path::new("/absent"),
"other",
"",
"always",
)
.unwrap();
}
#[test]
fn restart_override_requires_matching_live_native_and_controller_obligations() {
for (native, key, value) in [
(
true,
"/id",
serde_json::json!(uuid::Uuid::new_v4().to_string()),
),
(true, "/cleanup_done", serde_json::json!(true)),
(true, "/phase", serde_json::json!("Aborted")),
(
true,
"/members/0/original/container_id",
serde_json::json!("d".repeat(64)),
),
(
false,
"/operation_id",
serde_json::json!(uuid::Uuid::new_v4().to_string()),
),
(false, "/phase", serde_json::json!("Released")),
(
false,
"/api_restart_override/released",
serde_json::json!(true),
),
(
false,
"/api_restart_override/sha256",
serde_json::json!("d".repeat(64)),
),
] {
let f = OverrideFixture::new();
let path = if native {
&f.native_path
} else {
&f.journal_path
};
let mut record: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
*record.pointer_mut(key).unwrap() = value;
private_write(path, &serde_json::to_vec(&record).unwrap());
assert!(f.validate().is_err(), "Accepted changed binding: {key}");
}
let f = OverrideFixture::new();
private_write(
&f.data.join("app-maintenance/indeedhub"),
uuid::Uuid::new_v4().to_string().as_bytes(),
);
assert!(f.validate().is_err());
}
#[test]
fn restart_override_refuses_tamper_symlink_and_writable_ownership_paths() {
let f = OverrideFixture::new();
let original = std::fs::read(&f.override_path).unwrap();
private_write(&f.override_path, b"[Service]\nRestart=always\n");
assert!(f.validate().is_err());
private_write(&f.override_path, &original);
std::fs::set_permissions(&f.override_path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(f.validate().is_err());
private_write(&f.override_path, &original);
let foreign = f.runtime.join("foreign");
private_write(&foreign, &original);
std::fs::remove_file(&f.override_path).unwrap();
std::os::unix::fs::symlink(&foreign, &f.override_path).unwrap();
assert!(f.validate().is_err());
assert_eq!(std::fs::read(&foreign).unwrap(), original);
std::fs::remove_file(&f.override_path).unwrap();
private_write(&f.override_path, &original);
std::fs::set_permissions(
f.override_path.parent().unwrap(),
std::fs::Permissions::from_mode(0o777),
)
.unwrap();
assert!(f.validate().is_err());
std::fs::set_permissions(
f.override_path.parent().unwrap(),
std::fs::Permissions::from_mode(0o700),
)
.unwrap();
f.validate().unwrap();
let real = f.journal_path.with_extension("owned");
std::fs::rename(&f.journal_path, &real).unwrap();
std::os::unix::fs::symlink(&real, &f.journal_path).unwrap();
assert!(f.validate().is_err());
}
struct UnusedBarrier; struct UnusedBarrier;
impl DrainBarrier for UnusedBarrier { impl DrainBarrier for UnusedBarrier {
async fn acquire(&self, _: &str, _: &[Unit], _: bool) -> Result<()> { async fn acquire(&self, _: &str, _: &[Unit], _: bool) -> Result<()> {