feat: add NODE_IDENTITY_PUBKEYS derived-env placeholder
Lets an app grant the node's users owner rights, e.g. a Blossom server's allowed uploaders. The value is the Nostr keys of the identities the app identity picker offers for NIP-07 signing, chosen by the same rule as NostrIdentityPicker.vue, so the node's own appliance identity is never included. It is resolved only for manifests that template it, and an empty set is an error rather than an empty owner list. identity.list now shares its is_node test with the new helper.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::rpc::RpcHandler;
|
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 crate::network::did_dht;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
@@ -38,7 +40,7 @@ impl RpcHandler {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| {
|
.map(|id| {
|
||||||
let is_default = default_id.as_deref() == Some(&id.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 {
|
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||||
(
|
(
|
||||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||||
|
|||||||
@@ -3534,6 +3534,7 @@ impl ProdContainerOrchestrator {
|
|||||||
host_mdns: "test.local".to_string(),
|
host_mdns: "test.local".to_string(),
|
||||||
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
disk_gb: self.test_disk_gb.unwrap_or(1000),
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
@@ -3551,10 +3552,53 @@ impl ProdContainerOrchestrator {
|
|||||||
// demand (it costs a podman call) only for manifests that use
|
// demand (it costs a podman call) only for manifests that use
|
||||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
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
|
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||||
/// Defaults to `bitcoin-knots` when none is running (B12).
|
/// Defaults to `bitcoin-knots` when none is running (B12).
|
||||||
@@ -3822,6 +3866,22 @@ impl ProdContainerOrchestrator {
|
|||||||
{
|
{
|
||||||
facts.bitcoin_host = self.bitcoin_host().await;
|
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();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
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
|
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||||
/// generated secret plus a secret_env that reads it, which is what makes
|
/// generated secret plus a secret_env that reads it, which is what makes
|
||||||
/// the credential participate in secret_env_hash.
|
/// the credential participate in secret_env_hash.
|
||||||
|
|||||||
@@ -115,6 +115,23 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
|
|||||||
norm(a) == norm(b)
|
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 {
|
impl IdentityManager {
|
||||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||||
@@ -150,6 +167,25 @@ impl IdentityManager {
|
|||||||
Ok((identities, default_id))
|
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.
|
/// Create a new identity.
|
||||||
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
||||||
let signing_key = SigningKey::generate(&mut OsRng);
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
@@ -966,6 +1002,142 @@ mod tests {
|
|||||||
assert_ne!(default_id, Some(r1.id));
|
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]
|
#[tokio::test]
|
||||||
async fn test_delete_default_shifts() {
|
async fn test_delete_default_shifts() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ pub struct ContainerConfig {
|
|||||||
|
|
||||||
/// Derived-env entry. The template is rendered against `HostFacts` at
|
/// Derived-env entry. The template is rendered against `HostFacts` at
|
||||||
/// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DerivedEnv {
|
pub struct DerivedEnv {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
@@ -1428,6 +1429,13 @@ pub struct HostFacts {
|
|||||||
/// right host. Both are reachable on archy-net by their container name;
|
/// right host. Both are reachable on archy-net by their container name;
|
||||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||||
pub bitcoin_host: String,
|
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 {
|
impl HostFacts {
|
||||||
@@ -1439,13 +1447,20 @@ impl HostFacts {
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-knots".to_string(),
|
bitcoin_host: "bitcoin-knots".to_string(),
|
||||||
|
node_identity_pubkeys: "1111111111111111111111111111111111111111111111111111111111111111,2222222222222222222222222222222222222222222222222222222222222222".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
/// 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> {
|
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||||
@@ -1529,7 +1544,8 @@ impl ContainerConfig {
|
|||||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
.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)
|
format!("{}={}", e.key, value)
|
||||||
})
|
})
|
||||||
.collect()
|
.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]
|
#[test]
|
||||||
fn path_traversal_secret_file_is_rejected() {
|
fn path_traversal_secret_file_is_rejected() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -2451,6 +2507,7 @@ app:
|
|||||||
host_mdns: "test-node.local".to_string(),
|
host_mdns: "test-node.local".to_string(),
|
||||||
disk_gb: 2000,
|
disk_gb: 2000,
|
||||||
bitcoin_host: "bitcoin-core".to_string(),
|
bitcoin_host: "bitcoin-core".to_string(),
|
||||||
|
node_identity_pubkeys: String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let out = c.resolve_derived_env(&facts);
|
let out = c.resolve_derived_env(&facts);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ app:
|
|||||||
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
||||||
| `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected |
|
| `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected |
|
||||||
| `app.container.entrypoint` / `custom_args` | Entrypoint and command override |
|
| `app.container.entrypoint` / `custom_args` | Entrypoint and command override |
|
||||||
| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly |
|
| `app.container.derived_env` | Environment values rendered from host facts. The complete placeholder set is `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}`; an unknown name or an unbalanced `{{` is a parse error, so typos fail loudly. `{{NODE_IDENTITY_PUBKEYS}}` is the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved when the app is installed or started, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value |
|
||||||
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>`, injected as podman secrets (never visible in `podman inspect` or unit files) |
|
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>`, injected as podman secrets (never visible in `podman inspect` or unit files) |
|
||||||
| `app.container.generated_secrets` | Secrets the orchestrator creates on first use (`hex16`/`hex32`/`base64`/`bcrypt`) — self-healing, 0600, no host provisioning |
|
| `app.container.generated_secrets` | Secrets the orchestrator creates on first use (`hex16`/`hex32`/`base64`/`bcrypt`) — self-healing, 0600, no host provisioning |
|
||||||
| `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts |
|
| `app.container.generated_certs` | Self-signed TLS certs materialised before create; CN/SANs rendered from host facts |
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Exactly **one** of `image` or `build` must be present (image XOR build).
|
|||||||
| `network_aliases` | list of string | Extra DNS names on `network` (podman `--network-alias`) — lets stack members answer to short baked-in hostnames (`api`, `minio`, `relay`). |
|
| `network_aliases` | list of string | Extra DNS names on `network` (podman `--network-alias`) — lets stack members answer to short baked-in hostnames (`api`, `minio`, `relay`). |
|
||||||
| `entrypoint` | list of string | Entrypoint override. |
|
| `entrypoint` | list of string | Entrypoint override. |
|
||||||
| `custom_args` | list of string | Extra positional args appended after the image. |
|
| `custom_args` | list of string | Extra positional args appended after the image. |
|
||||||
| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). Never hard-code host specifics. |
|
| `derived_env` | list | `- { key, template }` — template rendered against host facts at apply time. The allow-list is exactly `{{HOST_IP}}`, `{{HOST_MDNS}}`, `{{DISK_GB}}`, `{{BITCOIN_HOST}}`, `{{NODE_IDENTITY_PUBKEYS}}` (`DERIVED_PLACEHOLDERS`); an unknown name or unbalanced `{{` fails validation. `{{BITCOIN_HOST}}` resolves to whichever Bitcoin app is running (`bitcoin-knots` or `bitcoin-core`, defaulting to knots). `{{NODE_IDENTITY_PUBKEYS}}` resolves to the Nostr public keys of the identities the app signer (the NIP-07 bridge's identity picker) offers, the node's own appliance identity excluded, as sorted, comma-joined 64-char hex. It is resolved at install and on every start, so changes to your identities, including removals, take effect on the app's next restart: until then a removed identity keeps its access; with no such identity the app refuses to start rather than render an empty value. Never hard-code host specifics. |
|
||||||
| `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/<secret_file>` and injected as a **podman secret**, so it never appears in `podman inspect` or unit files. `secret_file` must be a bare filename (no `/`, no `..`). |
|
| `secret_env` | list | `- { key, secret_file }` — value read from `/var/lib/archipelago/secrets/<secret_file>` and injected as a **podman secret**, so it never appears in `podman inspect` or unit files. `secret_file` must be a bare filename (no `/`, no `..`). |
|
||||||
| `generated_secrets` | list | `- { name, kind }` — orchestrator materialises the secret on first use (0600, rootless service user, idempotent + self-healing). `kind ∈ hex16 | hex32 | base64 | bcrypt` (bcrypt writes `<name>` = hash and `<name>.pw` = plaintext). |
|
| `generated_secrets` | list | `- { name, kind }` — orchestrator materialises the secret on first use (0600, rootless service user, idempotent + self-healing). `kind ∈ hex16 | hex32 | base64 | bcrypt` (bcrypt writes `<name>` = hash and `<name>.pw` = plaintext). |
|
||||||
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
| `generated_certs` | list | `- { crt, key, common_name?, sans? }` — self-signed TLS materialised before create; CN/SANs rendered against host facts. |
|
||||||
|
|||||||
Reference in New Issue
Block a user