Merge opt-in app owner identity placeholder

nevent1qqs9d76qm6f5xj2vrtjfnkqz5exrc8r0s9zev4f672kqyd0wjh7wwvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcx2tvaw
This commit is contained in:
archipelago
2026-10-05 09:29:18 -04:00
6 changed files with 435 additions and 7 deletions
@@ -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.
+172
View File
@@ -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();
+60 -3
View File
@@ -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);
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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. |