feat(container): manifest-declared generated secrets + companion/quadlet hardening

Generated-secrets system: apps declare `generated_secrets` in their manifest
(kinds hex16/hex32/bcrypt); `container::secrets::ensure_generated_secrets`
materialises them 0600/rootless in resolve_dynamic_env — idempotent and
self-healing (recovers wrongly root-owned secrets with no privilege). Replaces
per-app Rust (deletes ensure_fmcd_password). fedimint-clientd/gateway manifests
now declare fmcd-password / fedimint-gateway-hash.

companion.rs: rebuild the auto-built :latest image when its build context changes
(staleness check) so baked-in fixes (e.g. guardian-UI CSS) actually reach nodes.

quadlet.rs: skip PublishPort under Network=host (podman rejects the combo, exit
125) + regression tests.

UI: "Fedimint Guardian" rename, fedimint-clientd/nostr-rs-relay/meshtastic tagged
as Services (headless backends), gateway icon fallback.

Deployed + verified on .228 (generated-secrets fixed fedimint-gateway start;
grafana/strfry orphan crash-loop units removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-21 05:11:07 -04:00
co-authored by Claude Opus 4.8
parent db7d424bff
commit 03a4ee1b30
16 changed files with 521 additions and 52 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
pub use health_monitor::HealthMonitor;
pub use manifest::{
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
SecretsProvider, SecurityPolicy, Volume,
GeneratedSecret, HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits,
SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy, Volume,
};
pub use podman_client::{
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
+70
View File
@@ -122,6 +122,18 @@ pub struct ContainerConfig {
#[serde(default)]
pub secret_env: Vec<SecretEnv>,
/// Secrets the orchestrator generates on first use when absent, so an app
/// installs from its manifest alone — no host provisioning, no per-app Rust.
/// Materialised before `secret_env` is resolved, written `0600` and owned by
/// the unprivileged (rootless) service user. Idempotent and self-healing: a
/// file that already exists and is readable is left untouched; one that is
/// present-but-unreadable (e.g. wrongly created `root`-owned) is recreated
/// in place via the service-owned secrets dir — no `chown`, no privilege.
///
/// Example: `- { name: fmcd-password, kind: hex16 }`
#[serde(default)]
pub generated_secrets: Vec<GeneratedSecret>,
/// Rootless-mapped UID:GID applied to the container's data directory
/// (the `bind`-mounted host path with `target` inside the container's
/// data root) before creation. Mirrors `SPEC_DATA_UID`.
@@ -151,6 +163,42 @@ pub struct SecretEnv {
pub secret_file: String,
}
/// How a [`GeneratedSecret`] is produced. Each kind is deterministic in shape
/// (so the orchestrator knows which files to expect) but random in value.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SecretGenKind {
/// 16 random bytes, lowercase hex (32 chars). Service passwords/API tokens.
Hex16,
/// 32 random bytes, lowercase hex (64 chars). Longer keys/cookies.
Hex32,
/// A random password and its bcrypt hash. `<name>` holds the bcrypt hash
/// (what a server is configured with); the plaintext is stored alongside as
/// `<name>.pw` for any client that must authenticate. `secret_env` injects
/// whichever file it references.
Bcrypt,
}
/// A secret materialised by the orchestrator on demand. See
/// [`ContainerConfig::generated_secrets`]. `name` is a bare filename under the
/// secrets dir — validated (no `/`, no `..`) at [`AppManifest::validate`] time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GeneratedSecret {
pub name: String,
pub kind: SecretGenKind,
}
impl GeneratedSecret {
/// Every file this secret materialises, in the order they should be written
/// (primary first). A consumer references one of these via `secret_env`.
pub fn target_files(&self) -> Vec<String> {
match self.kind {
SecretGenKind::Hex16 | SecretGenKind::Hex32 => vec![self.name.clone()],
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
}
}
}
fn default_pull_policy() -> String {
"if-not-present".to_string()
}
@@ -487,6 +535,28 @@ impl AppManifest {
}
}
// generated_secrets: bare-filename names, unique across every file the
// set materialises (so a Bcrypt's `.pw` sibling can't collide with
// another secret). Path-safety mirrors secret_env.
{
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
for (i, g) in self.app.container.generated_secrets.iter().enumerate() {
if g.name.is_empty() || g.name.contains('/') || g.name.contains("..") {
return Err(ManifestError::Invalid(format!(
"container.generated_secrets[{}].name must be a bare filename (no '/', no '..'), got '{}'",
i, g.name
)));
}
for f in g.target_files() {
if !names.insert(f.clone()) {
return Err(ManifestError::Invalid(format!(
"container.generated_secrets produces duplicate file '{f}'"
)));
}
}
}
}
// data_uid: if set, must look like "NNNNN:NNNNN".
if let Some(u) = &self.app.container.data_uid {
let parts: Vec<&str> = u.split(':').collect();