Qualify durable purchase and media primitives and preserve app launch paths
This commit is contained in:
@@ -512,6 +512,82 @@ mod lifecycle_regression_tests {
|
||||
assert_eq!(main.lan_config.as_deref(), Some("kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
|
||||
let mut entry = installing_fixture();
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.installed = Some(InstalledPackageDataEntry {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
last_backup: None,
|
||||
interface_addresses: HashMap::from([(
|
||||
"main".into(),
|
||||
InterfaceAddress {
|
||||
lan_address: Some("https://localhost:7443/old?gate=retained#position".into()),
|
||||
tor_address: "existing.onion".into(),
|
||||
},
|
||||
)]),
|
||||
status: ServiceStatus::Stopped,
|
||||
});
|
||||
let manifest = serde_json::json!({"app":{"interfaces":{"main":{
|
||||
"type":"ui", "port":80, "protocol":"http", "path":"/browse"
|
||||
}}}});
|
||||
for _ in 0..2 {
|
||||
apply_manifest_value(&manifest, &mut entry);
|
||||
let main = &entry.installed.as_ref().unwrap().interface_addresses["main"];
|
||||
assert_eq!(
|
||||
main.lan_address.as_deref(),
|
||||
Some("https://localhost:7443/browse?gate=retained#position")
|
||||
);
|
||||
assert_eq!(main.tor_address, "existing.onion");
|
||||
assert_eq!(entry.state, PackageState::Stopped);
|
||||
assert_eq!(entry.ui_ready, Some(false));
|
||||
}
|
||||
entry
|
||||
.installed
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.interface_addresses
|
||||
.get_mut("main")
|
||||
.unwrap()
|
||||
.lan_address = None;
|
||||
apply_manifest_value(&manifest, &mut entry);
|
||||
assert!(
|
||||
entry.installed.as_ref().unwrap().interface_addresses["main"]
|
||||
.lan_address
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_path_preserves_authority_and_optional_query() {
|
||||
assert_eq!(
|
||||
manifest_launch_path("http://localhost:7475", "/browse"),
|
||||
Some("http://localhost:7475/browse".into())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest_launch_path(
|
||||
"https://localhost:7443/old?keep=1#old",
|
||||
"/browse?view=all#new"
|
||||
),
|
||||
Some("https://localhost:7443/browse?view=all#new".into())
|
||||
);
|
||||
for path in [
|
||||
"https://foreign.invalid/",
|
||||
"//foreign.invalid/",
|
||||
"/\\foreign.invalid/",
|
||||
"browse",
|
||||
"/bad\npath",
|
||||
] {
|
||||
assert!(
|
||||
manifest_launch_path("https://localhost:7443", path).is_none(),
|
||||
"{path:?}"
|
||||
);
|
||||
}
|
||||
assert!(manifest_launch_path("", "/browse").is_none());
|
||||
assert!(manifest_launch_path("file:///tmp/local", "/browse").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
||||
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
||||
@@ -761,6 +837,38 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
|
||||
entry.manifest.tier = Some(tier);
|
||||
}
|
||||
}
|
||||
// Runtime discovery owns origins, ports and availability; the reviewed
|
||||
// manifest owns the UI's entry path. Otherwise every scan resets a declared
|
||||
// /browse or /admin entry point to the container root.
|
||||
if let (Some(installed), Some(interfaces)) = (
|
||||
entry.installed.as_mut(),
|
||||
app.get("interfaces").and_then(|v| v.as_object()),
|
||||
) {
|
||||
for (id, address) in &mut installed.interface_addresses {
|
||||
let Some(interface) = interfaces.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if interface
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ui")
|
||||
!= "ui"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(path) = interface.get("path").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(lan) = address.lan_address.as_mut() {
|
||||
if let Some(updated) = manifest_launch_path(lan, path) {
|
||||
*lan = updated;
|
||||
}
|
||||
}
|
||||
if let Some(updated) = manifest_launch_path(&address.tor_address, path) {
|
||||
address.tor_address = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Once installed, the scanner owns UI detection (including companion UIs).
|
||||
// Only seed classification while there is no observed runtime package.
|
||||
if entry.installed.is_some() {
|
||||
@@ -790,6 +898,33 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a local entry path without changing the scanner-confirmed authority.
|
||||
fn manifest_launch_path(address: &str, path: &str) -> Option<String> {
|
||||
if !path.starts_with('/')
|
||||
|| path.starts_with("//")
|
||||
|| path.contains('\\')
|
||||
|| path.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = reqwest::Url::parse(address).ok()?;
|
||||
if !matches!(base.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let mut updated = base.join(path).ok()?;
|
||||
if updated.origin() != base.origin() {
|
||||
return None;
|
||||
}
|
||||
// A plain path must not discard an existing gate/deep-link query.
|
||||
if !path.contains('?') {
|
||||
updated.set_query(base.query());
|
||||
}
|
||||
if !path.contains('#') {
|
||||
updated.set_fragment(base.fragment());
|
||||
}
|
||||
Some(updated.into())
|
||||
}
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
let mut meta = match app_id {
|
||||
"bitcoin-core" => AppMetadata {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod npm;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
pub mod registration_pin;
|
||||
pub mod secrets;
|
||||
pub mod traits;
|
||||
pub mod ui_detection;
|
||||
|
||||
@@ -3915,6 +3915,27 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
if manifest.app.container.media_registration_identity {
|
||||
// Only opted-in manifests read the existing appliance identity. A
|
||||
// missing key/public-key mismatch is an error, never key generation.
|
||||
let identity =
|
||||
crate::identity::NodeIdentity::load_existing(&self.data_dir.join("identity"))
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
self.node_pubkey_hex().await? == identity.pubkey_hex(),
|
||||
"Existing node public key disagrees with its signing identity"
|
||||
);
|
||||
let data_dir = self.data_dir.clone();
|
||||
let app_id = manifest.app.id.clone();
|
||||
let pin = tokio::task::spawn_blocking(move || {
|
||||
crate::container::registration_pin::ensure_for_installation(
|
||||
&data_dir, &app_id, &identity,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.context("Application registration pin worker failed")??;
|
||||
crate::container::registration_pin::apply_environment(manifest, &pin)?;
|
||||
}
|
||||
if manifest.app.id == "nginx-proxy-manager" {
|
||||
crate::container::npm::resolve_storage()
|
||||
.await?
|
||||
@@ -6433,6 +6454,100 @@ app:
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opted_in_media_registration_pins_preserve_audience_through_env_reconciliation() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut orch =
|
||||
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||
orch.set_data_dir(root.path().to_owned());
|
||||
orch.set_secrets_dir(root.path().join("secrets"));
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
let key_before = tokio::fs::read(root.path().join("identity/node_key"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut app = pull_manifest("indeedhub-api", "fixture:1");
|
||||
app.app.container.media_registration_identity = true;
|
||||
app.app
|
||||
.environment
|
||||
.push("ARCHIPELAGO_REGISTRATION_AUDIENCE=untrusted-manifest-value".into());
|
||||
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||
let first = app.app.environment.clone();
|
||||
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||
assert_eq!(app.app.environment, first);
|
||||
let pin = crate::container::registration_pin::load_existing(
|
||||
root.path(),
|
||||
"indeedhub-api",
|
||||
&identity,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(first.contains(&format!(
|
||||
"ARCHIPELAGO_REGISTRATION_NODE_PUBLIC_KEY={}",
|
||||
identity.pubkey_hex()
|
||||
)));
|
||||
assert!(first.contains(&format!(
|
||||
"ARCHIPELAGO_REGISTRATION_NODE_DID={}",
|
||||
identity.did_key().unwrap()
|
||||
)));
|
||||
assert!(first.contains(&format!(
|
||||
"ARCHIPELAGO_REGISTRATION_AUDIENCE={}",
|
||||
pin.app_audience
|
||||
)));
|
||||
assert!(!first.iter().any(|entry| entry.contains("_ENABLED=")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(root.path().join("identity/node_key"))
|
||||
.await
|
||||
.unwrap(),
|
||||
key_before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_apps_do_not_require_identity_or_get_registration_pins() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut orch =
|
||||
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||
orch.set_data_dir(root.path().to_owned());
|
||||
orch.set_secrets_dir(root.path().join("secrets"));
|
||||
let mut app = pull_manifest("ordinary-app", "fixture:1");
|
||||
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||
assert!(!root.path().join("identity").exists());
|
||||
assert!(!root.path().join("app-registration-pins").exists());
|
||||
assert!(!app
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|entry| entry.starts_with("ARCHIPELAGO_REGISTRATION_")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registration_pins_require_existing_matching_node_keys() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||
rt.clone(),
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
);
|
||||
orch.set_data_dir(root.path().to_owned());
|
||||
orch.set_secrets_dir(root.path().join("secrets"));
|
||||
let mut app = pull_manifest("indeedhub-api", "fixture:1");
|
||||
app.app.container.media_registration_identity = true;
|
||||
assert!(orch.resolve_dynamic_env(&mut app).await.is_err());
|
||||
assert!(!root.path().join("identity").exists());
|
||||
crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(root.path().join("identity/node_key.pub"), [3u8; 32])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch.resolve_dynamic_env(&mut app).await.is_err());
|
||||
assert!(!root.path().join("app-registration-pins").exists());
|
||||
assert!(rt.calls().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
|
||||
// The owners must be exactly the identities the app signer offers:
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Installer-owned public identity bindings for opted-in media-registration apps.
|
||||
//! No identity generation, app enablement, signing permission or data deletion.
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::AppManifest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ROOT: &str = "app-registration-pins";
|
||||
const MAX_RECORD: u64 = 16 * 1024;
|
||||
const ENV_NAMES: [&str; 3] = [
|
||||
"ARCHIPELAGO_REGISTRATION_NODE_PUBLIC_KEY",
|
||||
"ARCHIPELAGO_REGISTRATION_NODE_DID",
|
||||
"ARCHIPELAGO_REGISTRATION_AUDIENCE",
|
||||
];
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegistrationPin {
|
||||
version: u8,
|
||||
pub app_id: String,
|
||||
pub node_public_key: String,
|
||||
pub node_did: String,
|
||||
pub app_audience: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Marker {
|
||||
version: u8,
|
||||
app_id: String,
|
||||
pin_sha256: String,
|
||||
}
|
||||
fn valid_app_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& !id.starts_with('-')
|
||||
&& !id.ends_with('-')
|
||||
&& !id.contains("--")
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_lowercase() || v.is_ascii_digit() || v == b'-')
|
||||
}
|
||||
fn paths(data_dir: &Path, app_id: &str) -> Result<(PathBuf, PathBuf, PathBuf)> {
|
||||
anyhow::ensure!(
|
||||
valid_app_id(app_id),
|
||||
"Invalid application registration scope"
|
||||
);
|
||||
let root = data_dir.join(ROOT);
|
||||
Ok((
|
||||
root.clone(),
|
||||
root.join(format!("{app_id}.json")),
|
||||
root.join(format!("{app_id}.initialized.json")),
|
||||
))
|
||||
}
|
||||
fn private_root(path: &Path) -> Result<File> {
|
||||
let dir = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(path)?;
|
||||
let metadata = dir.metadata()?;
|
||||
anyhow::ensure!(
|
||||
metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0,
|
||||
"Application registration pins must remain private to the node service"
|
||||
);
|
||||
Ok(dir)
|
||||
}
|
||||
fn lock(root: &File) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if unsafe { libc::flock(root.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if !matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
|
||||
) {
|
||||
return Err(error.into());
|
||||
}
|
||||
anyhow::ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Application registration provisioning is busy; retry the same install"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
|
||||
let file = match OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
|
||||
.open(path)
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.context("Application registration pin is unavailable; preserve it for recovery")
|
||||
}
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
anyhow::ensure!(
|
||||
metadata.is_file() && metadata.len() <= MAX_RECORD && metadata.mode() & 0o077 == 0,
|
||||
"Invalid application registration pin storage"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_RECORD + 1).read_to_end(&mut bytes)?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() as u64 <= MAX_RECORD,
|
||||
"Application registration pin is too large"
|
||||
);
|
||||
Ok(Some(serde_json::from_slice(&bytes).context(
|
||||
"Damaged application registration pin; do not replace it",
|
||||
)?))
|
||||
}
|
||||
fn persist<T: Serialize>(root: &Path, path: &Path, value: &T) -> Result<()> {
|
||||
let bytes = serde_json::to_vec(value)?;
|
||||
let mut pending = tempfile::NamedTempFile::new_in(root)?;
|
||||
pending.write_all(&bytes)?;
|
||||
pending.as_file().sync_all()?;
|
||||
pending.persist_noclobber(path).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"Could not commit application registration pin: {}",
|
||||
error.error
|
||||
)
|
||||
})?;
|
||||
private_root(root)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn validate(
|
||||
pin: &RegistrationPin,
|
||||
app_id: &str,
|
||||
identity: &crate::identity::NodeIdentity,
|
||||
) -> Result<()> {
|
||||
let audience =
|
||||
uuid::Uuid::parse_str(&pin.app_audience).context("Invalid saved application audience")?;
|
||||
anyhow::ensure!(pin.version == 1 && pin.app_id == app_id
|
||||
&& pin.node_public_key == identity.pubkey_hex() && pin.node_did == identity.did_key()?
|
||||
&& audience.get_version_num() == 4 && audience.get_variant() == uuid::Variant::RFC4122
|
||||
&& audience.to_string() == pin.app_audience,
|
||||
"Application registration identity changed; preserve the existing pin and migrate explicitly");
|
||||
Ok(())
|
||||
}
|
||||
fn commitment(pin: &RegistrationPin) -> Result<String> {
|
||||
Ok(hex::encode(Sha256::digest(serde_json::to_vec(pin)?)))
|
||||
}
|
||||
fn validate_marker(marker: &Marker, pin: &RegistrationPin) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
marker.version == 1 && marker.app_id == pin.app_id && marker.pin_sha256 == commitment(pin)?,
|
||||
"Application registration initialization record changed; preserve both records"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Installer-only provisioning. Run on a blocking worker. Existing data is never
|
||||
/// replaced or repaired by generating another audience. Does not load/create keys.
|
||||
pub fn ensure_for_installation(
|
||||
data_dir: &Path,
|
||||
app_id: &str,
|
||||
identity: &crate::identity::NodeIdentity,
|
||||
) -> Result<RegistrationPin> {
|
||||
let (root, path, marker_path) = paths(data_dir, app_id)?;
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.mode(0o700);
|
||||
if let Err(error) = builder.create(&root) {
|
||||
if error.kind() != std::io::ErrorKind::AlreadyExists {
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
let held = private_root(&root)?;
|
||||
File::open(data_dir)?.sync_all()?;
|
||||
lock(&held)?;
|
||||
let marker: Option<Marker> = read(&marker_path)?;
|
||||
let pin = match read::<RegistrationPin>(&path)? {
|
||||
Some(pin) => {
|
||||
validate(&pin, app_id, identity)?;
|
||||
pin
|
||||
}
|
||||
None => {
|
||||
anyhow::ensure!(marker.is_none(), "Previously provisioned application pin is missing; recover it instead of creating another audience");
|
||||
let pin = RegistrationPin {
|
||||
version: 1,
|
||||
app_id: app_id.into(),
|
||||
node_public_key: identity.pubkey_hex(),
|
||||
node_did: identity.did_key()?,
|
||||
app_audience: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
persist(&root, &path, &pin)?;
|
||||
pin
|
||||
}
|
||||
};
|
||||
if let Some(marker) = marker {
|
||||
validate_marker(&marker, &pin)?;
|
||||
} else {
|
||||
persist(
|
||||
&root,
|
||||
&marker_path,
|
||||
&Marker {
|
||||
version: 1,
|
||||
app_id: app_id.into(),
|
||||
pin_sha256: commitment(&pin)?,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
held.sync_all()?;
|
||||
Ok(pin)
|
||||
}
|
||||
|
||||
/// Caller-side lookup never creates installation state. Require this before
|
||||
/// accepting a native registration request for the installed application scope.
|
||||
pub fn load_existing(
|
||||
data_dir: &Path,
|
||||
app_id: &str,
|
||||
identity: &crate::identity::NodeIdentity,
|
||||
) -> Result<RegistrationPin> {
|
||||
let (root, path, marker_path) = paths(data_dir, app_id)?;
|
||||
let held = private_root(&root)?;
|
||||
lock(&held)?;
|
||||
let pin: RegistrationPin =
|
||||
read(&path)?.context("Application registration identity is not provisioned")?;
|
||||
validate(&pin, app_id, identity)?;
|
||||
let marker: Marker =
|
||||
read(&marker_path)?.context("Application registration provisioning is incomplete")?;
|
||||
validate_marker(&marker, &pin)?;
|
||||
Ok(pin)
|
||||
}
|
||||
|
||||
/// Installer identity pins are authoritative. No flag enabling registration or
|
||||
/// publication is set, and non-opted-in apps are not modified.
|
||||
pub fn apply_environment(manifest: &mut AppManifest, pin: &RegistrationPin) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.media_registration_identity && manifest.app.id == pin.app_id,
|
||||
"Registration pin does not belong to this opted-in application"
|
||||
);
|
||||
anyhow::ensure!(!manifest.app.container.derived_env.iter().any(|entry| ENV_NAMES.contains(&entry.key.as_str()))
|
||||
&& !manifest.app.container.secret_env.iter().any(|entry| ENV_NAMES.contains(&entry.key.as_str()))
|
||||
&& !manifest.app.container.secret_env_refs.iter().any(|entry| ENV_NAMES.contains(&entry.env_key.as_str())),
|
||||
"Registration identity variables cannot be replaced by derived or secret environment entries");
|
||||
manifest.app.environment.retain(|entry| {
|
||||
!entry
|
||||
.split_once('=')
|
||||
.is_some_and(|(key, _)| ENV_NAMES.contains(&key))
|
||||
});
|
||||
manifest.app.environment.extend([
|
||||
format!("{}={}", ENV_NAMES[0], pin.node_public_key),
|
||||
format!("{}={}", ENV_NAMES[1], pin.node_did),
|
||||
format!("{}={}", ENV_NAMES[2], pin.app_audience),
|
||||
]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
async fn fixture() -> (tempfile::TempDir, crate::identity::NodeIdentity) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let identity = crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
(root, identity)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn audience_is_stable_across_retries_reconstruction_and_other_apps_are_distinct() {
|
||||
let (root, identity) = fixture().await;
|
||||
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
||||
assert!(!root.path().join(ROOT).exists());
|
||||
let original_key = std::fs::read(root.path().join("identity/node_key")).unwrap();
|
||||
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||
let reloaded = crate::identity::NodeIdentity::load_existing(&root.path().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ensure_for_installation(root.path(), "indeedhub-api", &reloaded).unwrap(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
load_existing(root.path(), "indeedhub-api", &reloaded).unwrap(),
|
||||
first
|
||||
);
|
||||
assert_ne!(
|
||||
ensure_for_installation(root.path(), "another-app", &reloaded)
|
||||
.unwrap()
|
||||
.app_audience,
|
||||
first.app_audience
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("identity/node_key")).unwrap(),
|
||||
original_key
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn concurrent_installers_persist_one_audience() {
|
||||
let (root, identity) = fixture().await;
|
||||
let identity = Arc::new(identity);
|
||||
let workers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let identity = identity.clone();
|
||||
let path = root.path().to_owned();
|
||||
std::thread::spawn(move || {
|
||||
ensure_for_installation(&path, "indeedhub-api", &identity).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let results: Vec<_> = workers
|
||||
.into_iter()
|
||||
.map(|worker| worker.join().unwrap())
|
||||
.collect();
|
||||
assert!(results.iter().all(|pin| pin == &results[0]));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_missing_or_foreign_pin_is_preserved_without_rotation() {
|
||||
let (root, identity) = fixture().await;
|
||||
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||
let (_, path, marker) = paths(root.path(), "indeedhub-api").unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let marker_bytes = std::fs::read(&marker).unwrap();
|
||||
let (_other_root, other_identity) = fixture().await;
|
||||
assert!(ensure_for_installation(root.path(), "indeedhub-api", &other_identity).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
std::fs::write(&path, b"damaged").unwrap();
|
||||
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"damaged");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(std::fs::read(&marker).unwrap(), marker_bytes);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert_eq!(
|
||||
load_existing(root.path(), "indeedhub-api", &identity).unwrap(),
|
||||
first
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn interrupted_marker_commit_finishes_with_the_same_pin() {
|
||||
let (root, identity) = fixture().await;
|
||||
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||
let (_, path, marker) = paths(root.path(), "indeedhub-api").unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
std::fs::remove_file(marker).unwrap();
|
||||
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
||||
assert_eq!(
|
||||
ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap(),
|
||||
first
|
||||
);
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn no_pin_symlink_or_manifest_override_is_followed() {
|
||||
let (root, identity) = fixture().await;
|
||||
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||
let (_, path, _) = paths(root.path(), "indeedhub-api").unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(root.path().join("identity/node_key"), &path).unwrap();
|
||||
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||
let mut manifest = AppManifest::parse("app:\n id: indeedhub-api\n name: Fixture\n version: '1'\n container:\n image: fixture:1\n media_registration_identity: true\n environment:\n - ARCHIPELAGO_REGISTRATION_AUDIENCE=body-value\n").unwrap();
|
||||
apply_environment(&mut manifest, &pin).unwrap();
|
||||
let first = manifest.app.environment.clone();
|
||||
apply_environment(&mut manifest, &pin).unwrap();
|
||||
assert_eq!(manifest.app.environment, first);
|
||||
assert!(first.contains(&format!(
|
||||
"ARCHIPELAGO_REGISTRATION_AUDIENCE={}",
|
||||
pin.app_audience
|
||||
)));
|
||||
assert!(!first.iter().any(|entry| entry.contains("_ENABLED=")));
|
||||
manifest.app.container.media_registration_identity = false;
|
||||
assert!(apply_environment(&mut manifest, &pin).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user