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:
co-authored by
Claude Opus 4.8
parent
db7d424bff
commit
03a4ee1b30
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user