Merge opt-in app owner identity placeholder
nevent1qqs9d76qm6f5xj2vrtjfnkqz5exrc8r0s9zev4f672kqyd0wjh7wwvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcx2tvaw
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::identity_manager::{
|
||||
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
|
||||
};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let is_default = default_id.as_deref() == Some(&id.id);
|
||||
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
|
||||
let is_node = is_node_identity(&id, &node_pubkey_hex);
|
||||
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
|
||||
@@ -3534,6 +3534,7 @@ impl ProdContainerOrchestrator {
|
||||
host_mdns: "test.local".to_string(),
|
||||
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
node_identity_pubkeys: String::new(),
|
||||
};
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
@@ -3551,10 +3552,53 @@ impl ProdContainerOrchestrator {
|
||||
// demand (it costs a podman call) only for manifests that use
|
||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
// Likewise filled on demand, only for manifests that use
|
||||
// {{NODE_IDENTITY_PUBKEYS}}.
|
||||
node_identity_pubkeys: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr public keys of the identities the app identity picker offers, for
|
||||
/// the `{{NODE_IDENTITY_PUBKEYS}}` derived-env placeholder. The node
|
||||
/// identity is recognised the way `identity.list` marks `is_node`: by the
|
||||
/// node's ed25519 public key, read here from `identity/node_key.pub`
|
||||
/// (the file `server_info.pubkey` is derived from at startup). The record
|
||||
/// mirrored from the node key has a `node-` id, so the picker's prefix rule
|
||||
/// hides it either way. The key is only read, never created: a missing or
|
||||
/// malformed file is an error. An empty set is an error too, so an app is
|
||||
/// never handed an empty owner list.
|
||||
async fn node_identity_pubkeys(&self) -> Result<String> {
|
||||
let node_pubkey_hex = self.node_pubkey_hex().await?;
|
||||
let pubkeys = crate::identity_manager::IdentityManager::new(&self.data_dir)
|
||||
.await?
|
||||
.app_signable_nostr_pubkeys(&node_pubkey_hex)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
!pubkeys.is_empty(),
|
||||
"no user identity with a Nostr key is available for apps to sign with; \
|
||||
create one under Web5 \u{2192} Identities"
|
||||
);
|
||||
Ok(pubkeys)
|
||||
}
|
||||
|
||||
/// The node's ed25519 public key as lowercase hex, read from
|
||||
/// `identity/node_key.pub` (raw 32 bytes, as `NodeIdentity` writes it)
|
||||
/// without the logging or key creation of `NodeIdentity::load_or_create`.
|
||||
async fn node_pubkey_hex(&self) -> Result<String> {
|
||||
let path = self.data_dir.join("identity").join("node_key.pub");
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading the node public key {}", path.display()))?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() == 32,
|
||||
"node public key {} is {} bytes, expected 32",
|
||||
path.display(),
|
||||
bytes.len()
|
||||
);
|
||||
Ok(hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||
/// Defaults to `bitcoin-knots` when none is running (B12).
|
||||
@@ -3822,6 +3866,22 @@ impl ProdContainerOrchestrator {
|
||||
{
|
||||
facts.bitcoin_host = self.bitcoin_host().await;
|
||||
}
|
||||
// The identities' keys are read only for manifests that template them.
|
||||
if manifest
|
||||
.app
|
||||
.container
|
||||
.derived_env
|
||||
.iter()
|
||||
.any(|e| e.template.contains("{{NODE_IDENTITY_PUBKEYS}}"))
|
||||
{
|
||||
facts.node_identity_pubkeys =
|
||||
self.node_identity_pubkeys().await.with_context(|| {
|
||||
format!(
|
||||
"resolving {{{{NODE_IDENTITY_PUBKEYS}}}} for {}",
|
||||
manifest.app.id
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
@@ -6151,6 +6211,143 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_IDENTITY_PUBKEYS_YAML: &str = "app:\n id: wildbloom-node\n name: wildbloom-node\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: WILDBLOOM_ALLOW_PUBKEYS\n template: \"{{NODE_IDENTITY_PUBKEYS}}\"\n";
|
||||
|
||||
/// Writes `pubkey_hex` as the node public key, in `NodeIdentity`'s format.
|
||||
async fn write_node_pubkey(orch: &ProdContainerOrchestrator, pubkey_hex: &str) {
|
||||
let dir = orch.data_dir().join("identity");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("node_key.pub"), hex::decode(pubkey_hex).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
|
||||
// The owners must be exactly the identities the app signer offers:
|
||||
// the user identities, never the node's own identity.
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt).await;
|
||||
let mgr = crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected = Vec::new();
|
||||
for name in ["Personal", "Business"] {
|
||||
let r = mgr
|
||||
.create(
|
||||
name.to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
expected.push(r.nostr_pubkey.unwrap().to_ascii_lowercase());
|
||||
}
|
||||
expected.sort();
|
||||
// The node key is held by an identity with a uuid id and an ordinary
|
||||
// name, so only the `is_node` match, through the key read from
|
||||
// node_key.pub, can keep it out.
|
||||
let laptop = mgr
|
||||
.create(
|
||||
"Laptop".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!laptop.id.starts_with("node-"));
|
||||
let laptop_nostr = laptop.nostr_pubkey.clone().unwrap();
|
||||
write_node_pubkey(&orch, &laptop.pubkey_hex).await;
|
||||
|
||||
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
|
||||
let env = &manifest.app.environment;
|
||||
let want = format!("WILDBLOOM_ALLOW_PUBKEYS={}", expected.join(","));
|
||||
assert!(env.iter().any(|e| e == &want), "env was {env:?}");
|
||||
assert!(
|
||||
!env.iter().any(|e| e.contains(&laptop_nostr)),
|
||||
"node identity leaked into {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_identity_pubkeys_placeholder_refuses_an_empty_set() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt).await;
|
||||
// A node key with only the node's own identity: nothing is signable.
|
||||
let node = crate::identity::NodeIdentity::load_or_create(&orch.data_dir().join("identity"))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||
.await
|
||||
.unwrap()
|
||||
.create_from_signing_key(
|
||||
"Node".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
node.signing_key().clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("NODE_IDENTITY_PUBKEYS"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
assert!(msg.contains("no user identity"), "unexpected error: {msg}");
|
||||
assert!(
|
||||
!manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")),
|
||||
"an empty owner list must never render"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_identity_pubkeys_placeholder_needs_the_node_key_and_never_creates_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt).await;
|
||||
crate::identity_manager::IdentityManager::new(orch.data_dir())
|
||||
.await
|
||||
.unwrap()
|
||||
.create(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let identity_dir = orch.data_dir().join("identity");
|
||||
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
|
||||
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||
assert!(
|
||||
format!("{err:#}").contains("node public key"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
assert!(
|
||||
!identity_dir.join("node_key").exists(),
|
||||
"a node key was created"
|
||||
);
|
||||
assert!(!identity_dir.join("node_key.pub").exists());
|
||||
|
||||
// A malformed key file is refused, not reinterpreted.
|
||||
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
|
||||
tokio::fs::write(identity_dir.join("node_key.pub"), "ab".repeat(32))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
|
||||
assert!(
|
||||
format!("{err:#}").contains("expected 32"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
assert!(!manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")));
|
||||
}
|
||||
|
||||
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||
/// generated secret plus a secret_env that reads it, which is what makes
|
||||
/// the credential participate in secret_env_hash.
|
||||
|
||||
@@ -115,6 +115,23 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// True when `record` is the node's own identity: the one whose ed25519 key
|
||||
/// is the node key (`server_info.pubkey`). `identity.list` reports this as
|
||||
/// `is_node`, and clients must never offer it as an app signer.
|
||||
pub fn is_node_identity(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
|
||||
!node_pubkey_hex.is_empty() && record.pubkey_hex == node_pubkey_hex
|
||||
}
|
||||
|
||||
/// True when the app identity picker hides `record`, mirroring
|
||||
/// `NostrIdentityPicker.vue`'s filter exactly: the node identity
|
||||
/// (`is_node`), any `node-*` id and any identity named "Node".
|
||||
pub(crate) fn is_hidden_from_app_signer(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
|
||||
// Rust's `str::trim` keeps U+FEFF, which JS `trim()` strips.
|
||||
is_node_identity(record, node_pubkey_hex)
|
||||
|| record.id.trim().to_lowercase().starts_with("node-")
|
||||
|| record.name.trim().to_lowercase() == "node"
|
||||
}
|
||||
|
||||
impl IdentityManager {
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||
@@ -150,6 +167,25 @@ impl IdentityManager {
|
||||
Ok((identities, default_id))
|
||||
}
|
||||
|
||||
/// Nostr public keys of the identities an app may sign with through the
|
||||
/// NIP-07 bridge, as sorted, de-duplicated, comma-joined lowercase hex.
|
||||
///
|
||||
/// Leaves out what the identity picker hides (`is_hidden_from_app_signer`)
|
||||
/// and identities without a Nostr key (they cannot sign). Empty when no
|
||||
/// identity qualifies.
|
||||
pub async fn app_signable_nostr_pubkeys(&self, node_pubkey_hex: &str) -> Result<String> {
|
||||
let (identities, _) = self.list().await?;
|
||||
let mut pubkeys: Vec<String> = identities
|
||||
.iter()
|
||||
.filter(|r| !is_hidden_from_app_signer(r, node_pubkey_hex))
|
||||
.filter_map(|r| r.nostr_pubkey.as_deref())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect();
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
Ok(pubkeys.join(","))
|
||||
}
|
||||
|
||||
/// Create a new identity.
|
||||
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
@@ -966,6 +1002,142 @@ mod tests {
|
||||
assert_ne!(default_id, Some(r1.id));
|
||||
}
|
||||
|
||||
fn record(id: &str, name: &str, pubkey_hex: &str) -> IdentityRecord {
|
||||
IdentityRecord {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
purpose: IdentityPurpose::Personal,
|
||||
pubkey_hex: pubkey_hex.to_string(),
|
||||
did: String::new(),
|
||||
dht_did: None,
|
||||
created_at: String::new(),
|
||||
nostr_pubkey: None,
|
||||
nostr_npub: None,
|
||||
profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_node_identity_matches_only_the_node_pubkey() {
|
||||
let node = "ab".repeat(32);
|
||||
let other = "cd".repeat(32);
|
||||
assert!(is_node_identity(&record("uuid-1", "Laptop", &node), &node));
|
||||
assert!(!is_node_identity(
|
||||
&record("uuid-1", "Laptop", &other),
|
||||
&node
|
||||
));
|
||||
// An unknown node key matches nothing, not the records without a key.
|
||||
assert!(!is_node_identity(&record("uuid-1", "Laptop", ""), ""));
|
||||
// The id and name rules belong to the picker filter, not to `is_node`.
|
||||
assert!(!is_node_identity(
|
||||
&record("node-abc", "Node", &other),
|
||||
&node
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hidden_from_app_signer_mirrors_the_picker_rules() {
|
||||
let node = "ab".repeat(32);
|
||||
let other = "cd".repeat(32);
|
||||
let hidden = |id: &str, name: &str, pk: &str| {
|
||||
is_hidden_from_app_signer(&record(id, name, pk), &node)
|
||||
};
|
||||
// is_node: matched by key alone, whatever the id and name.
|
||||
assert!(hidden("uuid-1", "Laptop", &node));
|
||||
// node-* id, any case, surrounding whitespace ignored.
|
||||
assert!(hidden("node-0123456789abcdef", "Laptop", &other));
|
||||
assert!(hidden(" NODE-x ", "Laptop", &other));
|
||||
assert!(hidden("Node-x", "Laptop", &other));
|
||||
// The name "Node", any case, surrounding whitespace ignored.
|
||||
assert!(hidden("uuid-1", "Node", &other));
|
||||
assert!(hidden("uuid-1", " nODe\t", &other));
|
||||
// Near misses stay visible.
|
||||
assert!(!hidden("uuid-1", "Laptop", &other));
|
||||
assert!(!hidden("my-node-1", "Node 2", &other));
|
||||
assert!(!hidden("nodes", "Nodes", &other));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_signable_nostr_pubkeys_mirror_the_identity_picker() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mgr = IdentityManager::new(dir.path()).await.unwrap();
|
||||
let personal = mgr
|
||||
.create("Personal".to_string(), IdentityPurpose::Personal)
|
||||
.await
|
||||
.unwrap();
|
||||
let business = mgr
|
||||
.create("Business".to_string(), IdentityPurpose::Business)
|
||||
.await
|
||||
.unwrap();
|
||||
// The node identity as mirrored at startup: a `node-` id named
|
||||
// "Node", given a Nostr key so only the id and name rules hide it.
|
||||
let mirrored_key = SigningKey::generate(&mut OsRng);
|
||||
let mirrored = mgr
|
||||
.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, mirrored_key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mirrored.id.starts_with("node-"));
|
||||
mgr.create_nostr_key(&mirrored.id).await.unwrap();
|
||||
// A user-created identity named "Node" is hidden by the picker too.
|
||||
let named_node = mgr
|
||||
.create(" node ".to_string(), IdentityPurpose::Anonymous)
|
||||
.await
|
||||
.unwrap();
|
||||
// The node key belongs to an identity with a uuid id and an ordinary
|
||||
// name, so only the `is_node` match can hide it.
|
||||
let laptop = mgr
|
||||
.create("Laptop".to_string(), IdentityPurpose::Personal)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!laptop.id.starts_with("node-"));
|
||||
|
||||
let (all, _) = mgr.list().await.unwrap();
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|r| r.id == mirrored.id && r.nostr_pubkey.is_some()));
|
||||
assert!(all.iter().any(|r| r.id == named_node.id));
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|r| r.id == laptop.id && r.nostr_pubkey.is_some()));
|
||||
|
||||
let mut expected = vec![
|
||||
personal.nostr_pubkey.unwrap().to_ascii_lowercase(),
|
||||
business.nostr_pubkey.unwrap().to_ascii_lowercase(),
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys(&laptop.pubkey_hex)
|
||||
.await
|
||||
.unwrap(),
|
||||
expected.join(",")
|
||||
);
|
||||
// Without the node key, the same identity is offered like any other.
|
||||
let mut with_laptop = expected.clone();
|
||||
with_laptop.push(laptop.nostr_pubkey.unwrap().to_ascii_lowercase());
|
||||
with_laptop.sort();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys("").await.unwrap(),
|
||||
with_laptop.join(",")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_signable_nostr_pubkeys_is_empty_with_only_the_node_identity() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mgr = IdentityManager::new(dir.path()).await.unwrap();
|
||||
let node_key = SigningKey::generate(&mut OsRng);
|
||||
let node_pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
mgr.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, node_key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mgr.app_signable_nostr_pubkeys(&node_pubkey_hex)
|
||||
.await
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_default_shifts() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -263,7 +263,8 @@ pub struct ContainerConfig {
|
||||
|
||||
/// Derived-env entry. The template is rendered against `HostFacts` at
|
||||
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
|
||||
/// fact name is allowed (host_ip, host_mdns, disk_gb).
|
||||
/// fact name is allowed (host_ip, host_mdns, disk_gb, bitcoin_host,
|
||||
/// node_identity_pubkeys).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DerivedEnv {
|
||||
pub key: String,
|
||||
@@ -1428,6 +1429,13 @@ pub struct HostFacts {
|
||||
/// right host. Both are reachable on archy-net by their container name;
|
||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||
pub bitcoin_host: String,
|
||||
/// Nostr public keys of the node's identities that the app identity
|
||||
/// picker offers for signing (the node's own appliance key excluded),
|
||||
/// as comma-joined, sorted, lowercase 64-char hex. Lets an app grant
|
||||
/// the node's users owner rights (e.g. a Blossom server's allowed
|
||||
/// uploaders). Empty unless a manifest templates it; the orchestrator
|
||||
/// resolves it on demand and refuses to render an empty set.
|
||||
pub node_identity_pubkeys: String,
|
||||
}
|
||||
|
||||
impl HostFacts {
|
||||
@@ -1439,13 +1447,20 @@ impl HostFacts {
|
||||
host_mdns: "test-node.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
node_identity_pubkeys: "1111111111111111111111111111111111111111111111111111111111111111,2222222222222222222222222222222222222222222222222222222222222222".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &[
|
||||
"HOST_IP",
|
||||
"HOST_MDNS",
|
||||
"DISK_GB",
|
||||
"BITCOIN_HOST",
|
||||
"NODE_IDENTITY_PUBKEYS",
|
||||
];
|
||||
|
||||
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||
@@ -1529,7 +1544,8 @@ impl ContainerConfig {
|
||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
|
||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host)
|
||||
.replace("{{NODE_IDENTITY_PUBKEYS}}", &facts.node_identity_pubkeys);
|
||||
format!("{}={}", e.key, value)
|
||||
})
|
||||
.collect()
|
||||
@@ -2396,6 +2412,46 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_identity_pubkeys_placeholder_is_accepted() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: wildbloom-node
|
||||
name: Wildbloom Node
|
||||
version: 0.2.2
|
||||
container:
|
||||
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
|
||||
derived_env:
|
||||
- key: WILDBLOOM_ALLOW_PUBKEYS
|
||||
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||
"#;
|
||||
AppManifest::parse(yaml).expect("NODE_IDENTITY_PUBKEYS is a supported placeholder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_derived_env_renders_node_identity_pubkeys() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: wildbloom-node
|
||||
name: Wildbloom Node
|
||||
version: 0.2.2
|
||||
container:
|
||||
image: ghcr.io/forgesworn/wildbloom-node:0.2.2
|
||||
derived_env:
|
||||
- key: WILDBLOOM_ALLOW_PUBKEYS
|
||||
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||
"#;
|
||||
let manifest = AppManifest::parse(yaml).unwrap();
|
||||
let facts = HostFacts::sample();
|
||||
assert_eq!(
|
||||
manifest.app.container.resolve_derived_env(&facts),
|
||||
vec![format!(
|
||||
"WILDBLOOM_ALLOW_PUBKEYS={}",
|
||||
facts.node_identity_pubkeys
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_secret_file_is_rejected() {
|
||||
let yaml = r#"
|
||||
@@ -2451,6 +2507,7 @@ app:
|
||||
host_mdns: "test-node.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-core".to_string(),
|
||||
node_identity_pubkeys: String::new(),
|
||||
};
|
||||
|
||||
let out = c.resolve_derived_env(&facts);
|
||||
|
||||
Reference in New Issue
Block a user