Files
archy/core/container/src/manifest.rs
T
ssmithx 395e26b524 feat(apps): package podsteadr as a full Archipelago app (3-container manifest set)
Adds apps/podsteadr (main Fastify+Vue app, container.build from the podsteadr
repo), apps/podsteadr-mediamtx (RTMP/WHIP ingest, HLS, recording), and
apps/podsteadr-blossom (BUD-02 media blobs), wired together on a dedicated
podsteadr-net bridge network per the multi-container pattern documented in
docs/app-developer-guide.md (indeedhub's api/relay/minio/redis/postgres
siblings). All podsteadr ports are auth: none with a rationale, since it's a
public podcast/livestream server whose RSS feeds, HLS playback, and blob
reads must stay reachable by third-party clients with no Archipelago session
— the app already gates its own sensitive routes with NIP-98 and per-stream
secret keys.

Also updates apps/PORTS.md, apps/README.md, and bumps the reviewed
unauthenticated-port count in core/container/src/manifest.rs's
unauthenticated_ports_are_all_accounted_for test (25 -> 31) to acknowledge
the six new auth:none ports. Regenerated catalog-derived files
(core/archipelago/src/fips/app_ports.rs,
neode-ui/src/views/appSession/generatedAppSessionConfig.ts) via
scripts/generate-app-catalog.py.

All three manifests pass scripts/validate-app-manifest.sh and
`cargo test -p archipelago-container manifest`.
2026-08-08 00:18:02 +00:00

2746 lines
95 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Invalid manifest: {0}")]
Invalid(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yaml::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
pub app: AppDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppDefinition {
pub id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
#[serde(default)]
pub container: ContainerConfig,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default)]
pub resources: ResourceLimits,
#[serde(default)]
pub security: SecurityPolicy,
#[serde(default)]
pub ports: Vec<PortMapping>,
#[serde(default)]
pub volumes: Vec<Volume>,
#[serde(default)]
pub files: Vec<GeneratedFile>,
#[serde(default)]
pub environment: Vec<String>,
#[serde(default)]
pub health_check: Option<HealthCheck>,
#[serde(default)]
pub devices: Vec<String>,
#[serde(default)]
pub interfaces: HashMap<String, AppInterface>,
/// Controlled post-install / pre-start lifecycle hooks. Declarative,
/// allowlisted operations run against the app's OWN container — never the
/// host. See `docs/manifest-hooks-design.md`.
#[serde(default)]
pub hooks: LifecycleHooks,
#[serde(flatten)]
pub extensions: HashMap<String, serde_yaml::Value>,
}
/// Declarative lifecycle hooks for an app. Absent = none (forward-compatible).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LifecycleHooks {
/// Run once after a successful install, with the container created + running.
#[serde(default)]
pub post_install: Vec<HookStep>,
/// Run before each start (repair/ownership). Reserved; not yet executed.
#[serde(default)]
pub pre_start: Vec<HookStep>,
}
/// A single controlled hook operation. Each list item is a one-key map, e.g.
/// `- exec: [...]` or `- copy_from_host: { src, dest }`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum HookStep {
/// Run a command vector INSIDE the app's container (`podman exec`). Never on
/// the host; inherits the container's (already dropped) capabilities.
Exec { exec: Vec<String> },
/// Copy a file from an allowlisted host root into the container. `src` is
/// relative to the allowlist (data dir / web-ui) — no absolute paths, no `..`.
CopyFromHost {
#[serde(rename = "copy_from_host")]
copy_from_host: HostCopy,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostCopy {
pub src: String,
pub dest: String,
}
impl LifecycleHooks {
fn validate(&self) -> Result<(), ManifestError> {
for step in self.post_install.iter().chain(self.pre_start.iter()) {
step.validate()?;
}
Ok(())
}
}
impl HookStep {
fn validate(&self) -> Result<(), ManifestError> {
match self {
HookStep::Exec { exec } => {
if exec.is_empty() {
return Err(ManifestError::Invalid(
"hooks: exec must be a non-empty command vector".to_string(),
));
}
}
HookStep::CopyFromHost { copy_from_host } => {
let s = &copy_from_host.src;
if s.is_empty() || s.starts_with('/') || s.contains("..") {
return Err(ManifestError::Invalid(format!(
"hooks: copy_from_host.src must be a relative allowlisted path \
(no leading '/', no '..'), got '{s}'"
)));
}
if copy_from_host.dest.is_empty() || !copy_from_host.dest.starts_with('/') {
return Err(ManifestError::Invalid(format!(
"hooks: copy_from_host.dest must be an absolute container path, got '{}'",
copy_from_host.dest
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContainerConfig {
/// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present.
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub image_signature: Option<String>,
#[serde(default = "default_pull_policy")]
pub pull_policy: String,
/// Local build source. Mutually exclusive with `image`.
#[serde(default)]
pub build: Option<BuildConfig>,
// ── Step 8b.0 additions ──────────────────────────────────────────
//
// Fields the Rust orchestrator needs to faithfully port containers
// from the legacy `scripts/container-specs.sh` registry. See
// `docs/STEP-8B-PORT-AUDIT.md` for the full justification per field.
//
// All are optional with `#[serde(default)]` so every existing manifest
// in `apps/` continues to parse unchanged.
/// Podman `--network` value. `Some("archy-net")` joins the shared
/// Archipelago bridge. `Some("host")` uses host networking.
/// `None` (the default) falls back to podman's default isolated
/// network — equivalent to today's rootless default.
///
/// `SecurityPolicy::network_policy` remains a policy knob (what the
/// firewall layer does); this field is literally the CLI flag value.
#[serde(default)]
pub network: Option<String>,
/// Extra DNS aliases the container answers to on its `network`, in addition
/// to its own container name (which is always added). Mirrors podman
/// `--network-alias`. Used by multi-container stacks whose images reference
/// peers by a short baked-in hostname — e.g. indeedhub's frontend nginx
/// proxies to `api:4000` / `minio:9000` / `relay:8080`, so the api/minio/relay
/// members declare `network_aliases: [api]` / `[minio]` / `[relay]` to keep
/// those short names resolvable on the dedicated `indeedhub-net`. Ignored for
/// slirp4netns/pasta (podman rejects aliases there).
#[serde(default)]
pub network_aliases: Vec<String>,
/// Extra positional arguments appended to the container command
/// after the image. Mirrors `SPEC_CUSTOM_ARGS` in
/// `scripts/container-specs.sh` (bitcoin-knots prune/dbcache flags,
/// filebrowser `--config /data/.filebrowser.json`, etc).
#[serde(default)]
pub custom_args: Vec<String>,
/// Entrypoint override (`podman run --entrypoint …`). When present,
/// replaces the image's default entrypoint. Mirrors `SPEC_ENTRYPOINT`
/// for fedimint-gateway's LND-aware invocation.
#[serde(default)]
pub entrypoint: Option<Vec<String>>,
/// Environment keys whose values are rendered from a small
/// allow-list of host facts (`HOST_IP`, `HOST_MDNS`, `DISK_GB`).
/// Resolved by `ContainerConfig::resolve_derived_env` at apply time
/// — never hard-coded into the manifest.
///
/// Example: `- { key: FM_P2P_URL, template: "fedimint://{{HOST_MDNS}}:8173" }`
#[serde(default)]
pub derived_env: Vec<DerivedEnv>,
/// Environment keys whose values are read from files in
/// `/var/lib/archipelago/secrets/<secret_file>`. Never logged.
/// Resolved by `ContainerConfig::resolve_secret_env` at apply time.
///
/// Example: `- { key: FM_BITCOIND_PASSWORD, secret_file: bitcoin-rpc-password }`
#[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>,
/// Self-signed TLS certificates the orchestrator materialises before the
/// container is created (so a bind-mounted cert path resolves to a real
/// file, not a stale/missing path). Like `generated_secrets`, this keeps an
/// app data-driven: a service that needs a secure context (e.g. netbird's
/// dashboard — OIDC PKCE / `window.crypto.subtle` only works over HTTPS,
/// issue #15) declares the cert here instead of relying on per-app Rust.
/// Idempotent: an entry whose `crt` and `key` already exist is left
/// untouched. SAN/CN templates are rendered against host facts at apply time.
///
/// Example: `- { crt: /var/lib/archipelago/netbird/tls.crt, key: /var/lib/archipelago/netbird/tls.key }`
#[serde(default)]
pub generated_certs: Vec<GeneratedCert>,
/// 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`.
///
/// Example: `"100070:100070"` for Postgres' mapped subuid.
#[serde(default)]
pub data_uid: Option<String>,
/// Runtime-resolved secret env entries (never serialized, never in a
/// manifest file). Populated by the orchestrator's env-resolution
/// chokepoint; the backends turn each ref into a podman secret
/// reference (`secret_env` in the API spec / `Secret=…,type=env` in
/// Quadlet) so the VALUE never lands in `podman inspect` output or a
/// unit file on disk. The dev-only docker fallback injects `value` as
/// plain env — docker has no rootless secret store.
#[serde(skip)]
pub secret_env_refs: Vec<SecretEnvRef>,
/// sha256 over all resolved secret env pairs, stamped onto the
/// container as a label so rotation is detectable as drift without
/// exposing values. None when the app has no secret env.
#[serde(skip)]
pub secret_env_hash: Option<String>,
}
/// 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).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DerivedEnv {
pub key: String,
pub template: String,
}
/// Secret-env entry. `secret_file` is resolved against a
/// `SecretsProvider` (in prod, `/var/lib/archipelago/secrets/`).
///
/// `secret_file` is restricted to a bare filename — no `/`, no `..`.
/// Validated at `AppManifest::validate` time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretEnv {
pub key: String,
pub secret_file: String,
/// When true, a missing/unreadable/empty secret skips this entry instead
/// of failing the whole resolution. For integrations that exist on some
/// nodes only (btcpay's internal-LND connection string: nodes without
/// LND must still run btcpay, just without the internal node).
#[serde(default)]
pub optional: bool,
}
/// A fully resolved secret env entry, produced at apply time. `value` lives
/// only in memory on its way to the podman secret store (or, on the dev
/// docker fallback, straight into the container env).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretEnvRef {
pub env_key: String,
/// Podman secret object name: `archy-env-<app>-<KEY>`.
pub secret_name: String,
pub value: String,
}
/// Container label carrying the combined secret-env content hash, used by
/// the reconciler to detect secret rotation as env drift.
pub const SECRET_ENV_HASH_LABEL: &str = "io.archipelago.secret-env-hash";
/// Podman secret label carrying the individual secret's content hash, used
/// to skip re-registration when nothing changed.
pub const SECRET_HASH_LABEL: &str = "io.archipelago.hash";
/// Expand `${KEY}` placeholders across plain + secret values, then split
/// the result into (plain env, secret-bearing pairs). Any plain entry whose
/// value interpolates a secret key is *tainted* — it embeds the secret and
/// must travel as a secret itself (btcpay's
/// `BTCPAY_POSTGRES=…Password=${BTCPAY_DB_PASS};…` pattern). Secret values
/// themselves are taken verbatim: a generated secret that happens to
/// contain `${` must not be mangled by expansion.
pub fn expand_and_partition_env(
plain: Vec<String>,
secrets: Vec<(String, String)>,
) -> (Vec<String>, Vec<(String, String)>) {
let plain_values: std::collections::HashMap<String, String> = plain
.iter()
.filter_map(|entry| {
let (key, value) = entry.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect();
let mut out_plain = Vec::with_capacity(plain.len());
let mut out_secret: Vec<(String, String)> = Vec::with_capacity(secrets.len());
for entry in plain {
let Some((key, value)) = entry.split_once('=') else {
out_plain.push(entry);
continue;
};
let mut expanded = value.to_string();
let mut tainted = false;
for (k, v) in &plain_values {
expanded = expanded.replace(&format!("${{{k}}}"), v);
}
for (k, v) in &secrets {
let placeholder = format!("${{{k}}}");
if expanded.contains(&placeholder) {
expanded = expanded.replace(&placeholder, v);
tainted = true;
}
}
if tainted {
out_secret.push((key.to_string(), expanded));
} else {
out_plain.push(format!("{key}={expanded}"));
}
}
out_secret.extend(secrets);
(out_plain, out_secret)
}
/// 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,
/// 32 random bytes, standard base64 (44 chars incl. padding). For services
/// that require a base64-encoded key rather than hex — e.g. netbird's relay
/// `authSecret` and the SQLite store `encryptionKey`, which base64-decode
/// their configured value (hex would decode to the wrong bytes).
Base64,
/// 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 | SecretGenKind::Base64 => {
vec![self.name.clone()]
}
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
}
}
}
/// A self-signed TLS certificate materialised by the orchestrator. See
/// [`ContainerConfig::generated_certs`]. `crt`/`key` are absolute host paths
/// (typically under `/var/lib/archipelago/<app>/`) that the container
/// bind-mounts read-only. `common_name` and `sans` are rendered against host
/// facts (`{{HOST_IP}}`) at apply time; when omitted they default to the
/// node's host IP plus `IP:127.0.0.1,DNS:localhost` so the cert is valid for
/// however the box is reached locally.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GeneratedCert {
pub crt: String,
pub key: String,
#[serde(default)]
pub common_name: Option<String>,
#[serde(default)]
pub sans: Vec<String>,
}
fn default_pull_policy() -> String {
"if-not-present".to_string()
}
/// Build a container image locally from a Dockerfile rather than pulling from a registry.
///
/// When present on `ContainerConfig`, the orchestrator runs `podman build -t <tag> -f <dockerfile> <context>`
/// before starting the container. The resulting local image is referenced by `tag`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BuildConfig {
/// Build context directory (absolute path or relative to the manifest location).
pub context: String,
/// Dockerfile path relative to `context`. Defaults to `Dockerfile`.
#[serde(default = "default_dockerfile")]
pub dockerfile: String,
/// Tag applied to the built image. Used as the container's image reference.
pub tag: String,
/// Optional `--build-arg KEY=VALUE` pairs passed to the build.
#[serde(default)]
pub build_args: HashMap<String, String>,
}
fn default_dockerfile() -> String {
"Dockerfile".to_string()
}
/// Resolved pull-or-build decision after manifest validation.
///
/// `ContainerConfig::resolve()` produces this. The orchestrator matches on it
/// to decide whether to pull a registry image or invoke a local build.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedSource {
/// Pull `image` from a registry using `pull_policy` semantics.
Pull {
image: String,
pull_policy: String,
image_signature: Option<String>,
},
/// Build locally. The resulting tag is the image reference for `podman create`.
Build(BuildConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Dependency {
Storage {
storage: String,
},
App {
app_id: String,
version: Option<String>,
},
Simple(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ResourceLimits {
#[serde(default)]
pub cpu_limit: Option<u32>,
#[serde(default)]
pub memory_limit: Option<String>,
#[serde(default)]
pub disk_limit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecurityPolicy {
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default = "default_true")]
pub readonly_root: bool,
#[serde(default = "default_true")]
pub no_new_privileges: bool,
#[serde(default = "default_network_policy")]
pub network_policy: String,
#[serde(default)]
pub apparmor_profile: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_network_policy() -> String {
"isolated".to_string()
}
/// Whether a published port must sit behind the node's app authentication
/// gate.
///
/// The default is deliberately the protected one. Every app port on this
/// node was reachable with no credential at all over LAN, Tailscale, Tor and
/// the FIPS mesh alike (reproduced 2026-08-03) precisely because exposure
/// was the thing you got by saying nothing. Making `Session` the default
/// inverts that: a new app is protected unless its manifest argues for an
/// exemption, and the exemptions are a `grep auth: none apps/` rather than a
/// discovery.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PortAuth {
/// Default. The daemon's app gate authenticates every connection: a
/// valid session (2FA honoured, since a session still pending its TOTP
/// step fails validation) or an app-scoped bearer token for machine
/// clients. Anything else gets the login page.
#[default]
Session,
/// Exempt — the gate does not touch this port.
///
/// Only legitimate when the port carries a protocol that authenticates
/// itself (LND macaroons, Lightning's noise handshake, TLS client
/// certs) or one where a login page would be meaningless and harmful
/// (Bitcoin p2p gossip, mDNS). Requires `auth_rationale`: an exemption
/// nobody can explain is an exemption nobody reviewed.
None,
/// Host-local by intent — the gate must not bind this port at all.
///
/// This exists because `bind: 127.0.0.1` is ambiguous on its own, and
/// reading intent out of it would be wrong in both directions. Two
/// unrelated situations produce an identical loopback publish:
///
/// * Bitcoin's RPC 8332 is loopback-pinned so that the LAN *cannot*
/// reach it. Fronting it with the gate would newly expose it on every
/// host address — behind a login, but exposed where it deliberately
/// was not.
/// * A gated app is loopback-pinned precisely *so that* the gate can
/// take over its external addresses; that is the whole migration.
///
/// Inferring from `bind` would break one or the other, so the intent is
/// declared. `Local` means the first case: never externally reachable,
/// gate keeps its hands off.
Local,
/// The app publishes on loopback ONLY, and the daemon owns this port's
/// external addresses — bind them and authenticate every connection.
///
/// This is the migrated end state, and it is opt-in for a reason. The
/// gate binding an address is the one action that can make a port
/// reachable where it previously was not, so it must never be something
/// a manifest gets by default or by inference. An earlier revision
/// gated any `session` port regardless of `bind`, which meant a node
/// whose manifests had not yet been updated saw the daemon publish
/// Bitcoin's loopback-only RPC on every host address (caught on
/// archi-dev-box 2026-08-03, seconds after deploy). Requiring the
/// manifest to say so means the loopback pin and the daemon takeover
/// ship together, atomically, and a stale manifest fails safe.
Gated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortMapping {
pub host: u16,
pub container: u16,
#[serde(default)]
pub protocol: String,
/// Host address to bind the publish to. Empty = all interfaces
/// (0.0.0.0). Set `127.0.0.1` to keep a port host-local; list the same
/// host/container pair twice with different binds to serve several
/// addresses (e.g. loopback + the archy-net gateway `10.89.0.1` so
/// containers keep reaching it via `host.archipelago`).
#[serde(default)]
pub bind: String,
/// Declared authentication policy, or `None` when the manifest says
/// nothing at all.
///
/// The distinction is load-bearing and was learned the hard way. A node's
/// installed manifests always lag the binary, so "absent" is the state of
/// essentially every port on every node until a signed catalog delivers
/// otherwise. Treating absent as a *value* meant the daemon acted on a
/// default the manifest never asked for: first republishing Bitcoin's
/// loopback-only RPC across the LAN, then — caught before it shipped —
/// preparing to pin LND's gRPC and REST to loopback, which would have
/// broken Zeus and every remote wallet.
///
/// So absent means "no instruction", and the daemon may only ever REPORT
/// on such a port, never change how it is published. Use
/// [`PortMapping::auth_policy`] for classification and
/// [`PortMapping::auth_is_declared`] before acting.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<PortAuth>,
/// Why this port is safe to expose unauthenticated. **Required** when
/// `auth` is `none`, rejected otherwise — a rationale on a gated port
/// means the author expected an exemption they did not get.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_rationale: Option<String>,
/// Forward the node session cookie to the app on authorised requests.
///
/// The gate normally strips its own credential before proxying — an app
/// must never be in a position to log or replay the node session. The
/// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
/// are the exception their design requires: their nginx forwards the
/// browser's session cookie to the daemon's authenticated endpoints
/// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it
/// breaks every data call behind the gate with a 401 while the page
/// shell still renders (observed as "LND UI unreachable", 2026-08-05).
/// Only meaningful on a `auth: gated` port.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub session_passthrough: bool,
}
impl PortMapping {
/// Policy to classify this port by. An undeclared port reports as
/// `Session` — i.e. it shows up in the audit as something that *should*
/// be behind the gate — because reporting an unprotected port is always
/// safe. Acting on it is not; see [`Self::auth_is_declared`].
pub fn auth_policy(&self) -> PortAuth {
self.auth.unwrap_or(PortAuth::Session)
}
/// Whether the manifest actually stated a policy. Required before the
/// daemon rewrites how a port is published: silence is not consent.
pub fn auth_is_declared(&self) -> bool {
self.auth.is_some()
}
}
impl From<(u16, u16)> for PortMapping {
fn from((host, container): (u16, u16)) -> Self {
PortMapping {
host,
container,
protocol: "tcp".to_string(),
bind: String::new(),
auth: None,
auth_rationale: None,
session_passthrough: false,
}
}
}
/// True when the host can actually bind a publish to `ip` right now.
///
/// Rootless podman forwards published ports via rootlessport, which listens
/// in the HOST network namespace — an address that only exists inside the
/// rootless netns (e.g. the archy-net bridge gateway `10.89.0.1`) fails with
/// "cannot assign requested address" and systemd crash-loops the whole unit
/// (bitcoin went down fleet-wide-capable this way, 2026-07-09 on .228).
/// Callers drop such publishes instead of passing them through: a publish
/// that cannot bind provides nothing, while the failed bind takes the
/// container down with it.
///
/// Empty (= 0.0.0.0), wildcard, and loopback binds are always accepted
/// without probing. Anything else is probed with an ephemeral-port bind.
/// Unparseable addresses return false — podman would reject them anyway.
pub fn host_can_bind_publish_ip(ip: &str) -> bool {
if ip.is_empty() {
return true;
}
let Ok(addr) = ip.parse::<std::net::IpAddr>() else {
return false;
};
if addr.is_unspecified() || addr.is_loopback() {
return true;
}
std::net::TcpListener::bind((addr, 0)).is_ok()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Volume {
#[serde(rename = "type")]
pub volume_type: String,
#[serde(default)]
pub source: String,
pub target: String,
#[serde(default)]
pub options: Vec<String>,
/// For `type: tmpfs` only. Comma-separated mount options
/// (e.g. `"rw,noexec,nosuid,size=256m"`). Ignored for bind/volume.
#[serde(default)]
pub tmpfs_options: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GeneratedFile {
pub path: String,
pub content: String,
#[serde(default)]
pub overwrite: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCheck {
#[serde(rename = "type")]
pub check_type: String,
pub endpoint: Option<String>,
pub path: Option<String>,
#[serde(default = "default_interval")]
pub interval: String,
#[serde(default = "default_timeout")]
pub timeout: String,
#[serde(default = "default_retries")]
pub retries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppInterface {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "type", default = "default_interface_type")]
pub interface_type: String,
pub port: u16,
#[serde(default = "default_http_protocol")]
pub protocol: String,
#[serde(default = "default_root_path")]
pub path: String,
}
fn default_interface_type() -> String {
"ui".to_string()
}
fn default_http_protocol() -> String {
"http".to_string()
}
fn default_root_path() -> String {
"/".to_string()
}
fn default_interval() -> String {
"30s".to_string()
}
fn default_timeout() -> String {
"5s".to_string()
}
fn default_retries() -> u32 {
3
}
impl AppManifest {
pub fn from_file(path: &std::path::Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self, ManifestError> {
let manifest: AppManifest = serde_yaml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if !is_valid_app_id(&self.app.id) {
return Err(ManifestError::Invalid(
"app.id must be lowercase ASCII letters, digits, or single hyphens".to_string(),
));
}
if self.app.name.trim().is_empty() {
return Err(ManifestError::Invalid(
"app.name cannot be empty".to_string(),
));
}
// Exactly one of container.image or container.build must be set. We can't
// default either side, because an empty-string image or an empty build block
// would be silently wrong downstream.
match (&self.app.container.image, &self.app.container.build) {
(Some(img), None) if !img.is_empty() => {}
(None, Some(b)) => {
if b.context.is_empty() {
return Err(ManifestError::Invalid(
"container.build.context cannot be empty".to_string(),
));
}
if b.tag.is_empty() {
return Err(ManifestError::Invalid(
"container.build.tag cannot be empty".to_string(),
));
}
}
(Some(_), Some(_)) => {
return Err(ManifestError::Invalid(
"container.image and container.build are mutually exclusive".to_string(),
));
}
_ => {
return Err(ManifestError::Invalid(
"container must specify either image or build".to_string(),
));
}
}
// Validate version format (semantic versioning)
if !self.app.version.chars().any(|c| c.is_ascii_digit()) {
return Err(ManifestError::Invalid(
"app.version must contain at least one digit".to_string(),
));
}
// ── Step 8b.0 field validation ────────────────────────────────
// network: allow any non-empty string; podman itself is the
// final authority (named networks, "host", "bridge", "none",
// "container:<name>", etc). Reject only the empty-string case
// so "network:" with no value is a loud error instead of a
// silent default.
if let Some(n) = &self.app.container.network {
if n.is_empty() {
return Err(ManifestError::Invalid(
"container.network cannot be empty (omit the field to use default)".to_string(),
));
}
if is_dangerous_network_mode(n) {
return Err(ManifestError::Invalid(format!(
"container.network '{n}' is not allowed in app manifests"
)));
}
}
// network_aliases: each must be a non-empty DNS label (lowercase
// alphanumeric + hyphen, no leading/trailing hyphen) so it renders as a
// valid podman --network-alias / aardvark-dns name.
for (i, alias) in self.app.container.network_aliases.iter().enumerate() {
let ok = !alias.is_empty()
&& alias.len() <= 63
&& alias
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !alias.starts_with('-')
&& !alias.ends_with('-');
if !ok {
return Err(ManifestError::Invalid(format!(
"container.network_aliases[{i}] '{alias}' must be a non-empty DNS label \
(lowercase a-z, 0-9, '-'; no leading/trailing '-')"
)));
}
}
// custom_args: no empty strings (would inject literal "" into
// the podman command line and confuse downstream parsing).
for (i, a) in self.app.container.custom_args.iter().enumerate() {
if a.is_empty() {
return Err(ManifestError::Invalid(format!(
"container.custom_args[{i}] cannot be empty"
)));
}
}
// entrypoint: present ⇒ non-empty vec, no empty elements.
if let Some(ep) = &self.app.container.entrypoint {
if ep.is_empty() {
return Err(ManifestError::Invalid(
"container.entrypoint must contain at least one element when set".to_string(),
));
}
for (i, a) in ep.iter().enumerate() {
if a.is_empty() {
return Err(ManifestError::Invalid(format!(
"container.entrypoint[{i}] cannot be empty"
)));
}
}
}
// derived_env: non-empty keys, unique keys, templates reference
// only known host-fact placeholders.
{
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (i, e) in self.app.container.derived_env.iter().enumerate() {
if e.key.is_empty() {
return Err(ManifestError::Invalid(format!(
"container.derived_env[{i}].key cannot be empty"
)));
}
if !seen.insert(e.key.as_str()) {
return Err(ManifestError::Invalid(format!(
"container.derived_env has duplicate key '{}'",
e.key
)));
}
validate_derived_template(&e.key, &e.template)?;
}
}
// secret_env: non-empty keys, unique keys, secret_file is a
// bare filename (no '/', no '..').
{
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (i, e) in self.app.container.secret_env.iter().enumerate() {
if e.key.is_empty() {
return Err(ManifestError::Invalid(format!(
"container.secret_env[{i}].key cannot be empty"
)));
}
if !seen.insert(e.key.as_str()) {
return Err(ManifestError::Invalid(format!(
"container.secret_env has duplicate key '{}'",
e.key
)));
}
if e.secret_file.is_empty()
|| e.secret_file.contains('/')
|| e.secret_file.contains("..")
{
return Err(ManifestError::Invalid(format!(
"container.secret_env[{}].secret_file must be a bare filename (no '/', no '..'), got '{}'",
i, e.secret_file
)));
}
}
}
// 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}'"
)));
}
}
}
}
// generated_certs: crt/key must be non-empty absolute paths with no
// traversal (they become bind-mount sources, same safety bar as files).
for (i, c) in self.app.container.generated_certs.iter().enumerate() {
for (field, val) in [("crt", &c.crt), ("key", &c.key)] {
if val.is_empty() || !val.starts_with('/') || val.contains("..") {
return Err(ManifestError::Invalid(format!(
"container.generated_certs[{i}].{field} must be an absolute path with no '..', got '{val}'"
)));
}
}
}
// 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();
let valid = parts.len() == 2
&& !parts[0].is_empty()
&& !parts[1].is_empty()
&& parts[0].chars().all(|c| c.is_ascii_digit())
&& parts[1].chars().all(|c| c.is_ascii_digit());
if !valid {
return Err(ManifestError::Invalid(format!(
"container.data_uid must be 'UID:GID' with numeric parts, got '{}'",
u
)));
}
}
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
// Volume tmpfs_options: only meaningful for type: tmpfs.
for (i, v) in self.app.volumes.iter().enumerate() {
if v.volume_type == "tmpfs" {
if v.target.is_empty() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] (tmpfs) must set target"
)));
}
if !v.source.is_empty() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] (tmpfs) must not set source"
)));
}
} else if v.tmpfs_options.is_some() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] sets tmpfs_options but type is '{}', not 'tmpfs'",
v.volume_type
)));
} else {
if v.volume_type != "bind" && v.volume_type != "volume" {
return Err(ManifestError::Invalid(format!(
"volumes[{i}].type must be bind, volume, or tmpfs"
)));
}
if v.source.is_empty() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] ({}) must set source",
v.volume_type
)));
}
if v.target.is_empty() {
return Err(ManifestError::Invalid(format!(
"volumes[{i}] ({}) must set target",
v.volume_type
)));
}
if v.volume_type == "bind" {
validate_bind_source(i, &v.source)?;
} else if !is_valid_named_volume(&v.source) {
return Err(ManifestError::Invalid(format!(
"volumes[{i}].source must be a safe named volume"
)));
}
validate_container_path(i, &v.target)?;
validate_volume_options(i, &v.options)?;
}
}
for (i, f) in self.app.files.iter().enumerate() {
if f.path.is_empty() {
return Err(ManifestError::Invalid(format!(
"files[{i}].path cannot be empty"
)));
}
if !std::path::Path::new(&f.path).is_absolute() {
return Err(ManifestError::Invalid(format!(
"files[{i}].path must be absolute"
)));
}
if f.content.is_empty() {
return Err(ManifestError::Invalid(format!(
"files[{i}].content cannot be empty"
)));
}
let file_path = std::path::Path::new(&f.path);
let under_bind_mount = self
.app
.volumes
.iter()
.filter(|v| v.volume_type != "tmpfs" && !v.source.is_empty())
.any(|v| file_path.starts_with(std::path::Path::new(&v.source)));
if !under_bind_mount {
return Err(ManifestError::Invalid(format!(
"files[{i}].path must live under a bind-mounted volume source"
)));
}
}
// Lifecycle hooks: declarative, allowlisted (no host exec, no absolute /
// `..` copy sources). See docs/manifest-hooks-design.md.
self.app.hooks.validate()?;
Ok(())
}
}
fn is_valid_app_id(id: &str) -> bool {
if id.is_empty() || id.starts_with('-') || id.ends_with('-') || id.contains("--") {
return false;
}
id.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_dangerous_network_mode(mode: &str) -> bool {
mode.starts_with("container:") || mode.starts_with("ns:")
}
fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
let allowed_network_policies = ["isolated", "bridge", "host"];
if !policy.network_policy.is_empty()
&& !allowed_network_policies.contains(&policy.network_policy.as_str())
{
return Err(ManifestError::Invalid(format!(
"security.network_policy must be one of {}",
allowed_network_policies.join(", ")
)));
}
let allowed_caps = [
"CHOWN",
"DAC_OVERRIDE",
"FOWNER",
"NET_ADMIN",
"NET_BIND_SERVICE",
"NET_RAW",
"SETGID",
"SETUID",
"SYS_ADMIN",
];
let mut seen = HashSet::new();
for cap in &policy.capabilities {
if !allowed_caps.contains(&cap.as_str()) {
return Err(ManifestError::Invalid(format!(
"security.capabilities contains unsupported capability '{cap}'"
)));
}
if !seen.insert(cap.as_str()) {
return Err(ManifestError::Invalid(format!(
"security.capabilities contains duplicate capability '{cap}'"
)));
}
}
Ok(())
}
fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
let mut seen_host = HashSet::new();
for (i, port) in ports.iter().enumerate() {
if port.host == 0 || port.container == 0 {
return Err(ManifestError::Invalid(format!(
"ports[{i}].host and ports[{i}].container must be non-zero"
)));
}
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
if protocol != "tcp" && protocol != "udp" {
return Err(ManifestError::Invalid(format!(
"ports[{i}].protocol must be tcp or udp"
)));
}
if !port.bind.is_empty() && port.bind.parse::<std::net::IpAddr>().is_err() {
return Err(ManifestError::Invalid(format!(
"ports[{i}].bind must be an IP address, got '{}'",
port.bind
)));
}
// An exemption from the app gate has to carry its own justification.
// Enforcing it here rather than at review time means the reason
// exists in the manifest for every exempt port, so auditing the
// node's unauthenticated surface is reading a list, not inferring
// one from silence.
match (port.auth_policy(), port.auth_rationale.as_ref()) {
(PortAuth::None, None) => {
return Err(ManifestError::Invalid(format!(
"ports[{i}] sets auth: none but no auth_rationale — an unauthenticated \
port must state why it is safe to expose"
)));
}
(PortAuth::None, Some(rationale)) if rationale.trim().is_empty() => {
return Err(ManifestError::Invalid(format!(
"ports[{i}].auth_rationale cannot be empty"
)));
}
// A rationale on a gated port means the author wrote an
// exemption and did not get one. Silently keeping the port
// protected would be safe but misleading, so say so.
(PortAuth::Session, Some(_)) => {
return Err(ManifestError::Invalid(format!(
"ports[{i}] sets auth_rationale without auth: none — the port is gated \
and the rationale has no effect"
)));
}
_ => {}
}
// The same host port may be listed more than once with different bind
// addresses (e.g. loopback + the archy-net gateway); identical
// (host, protocol, bind) triples are still rejected.
if !seen_host.insert((port.host, protocol.to_string(), port.bind.clone())) {
return Err(ManifestError::Invalid(format!(
"ports contains duplicate host binding {}/{}",
port.host, protocol
)));
}
}
Ok(())
}
fn validate_interfaces(interfaces: &HashMap<String, AppInterface>) -> Result<(), ManifestError> {
let allowed_types = ["ui", "api", "metrics"];
let allowed_protocols = ["http", "https"];
for (key, interface) in interfaces {
if !is_valid_interface_key(key) {
return Err(ManifestError::Invalid(format!(
"interfaces key '{key}' must be lowercase ASCII letters, digits, hyphens, or underscores"
)));
}
if interface.port == 0 {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.port must be non-zero"
)));
}
if !allowed_types.contains(&interface.interface_type.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.type must be one of {}",
allowed_types.join(", ")
)));
}
if !allowed_protocols.contains(&interface.protocol.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.protocol must be one of {}",
allowed_protocols.join(", ")
)));
}
if !interface.path.starts_with('/') || interface.path.chars().any(char::is_control) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.path must start with '/' and contain no control characters"
)));
}
if interface
.name
.as_ref()
.is_some_and(|name| name.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.name cannot be empty when set"
)));
}
if interface
.description
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.description cannot be empty when set"
)));
}
}
Ok(())
}
fn is_valid_interface_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
let mut seen = HashSet::new();
for (i, entry) in env.iter().enumerate() {
let Some((key, _)) = entry.split_once('=') else {
return Err(ManifestError::Invalid(format!(
"environment[{i}] must be KEY=VALUE"
)));
};
if !is_valid_env_key(key) {
return Err(ManifestError::Invalid(format!(
"environment[{i}] has invalid key '{key}'"
)));
}
if !seen.insert(key) {
return Err(ManifestError::Invalid(format!(
"environment contains duplicate key '{key}'"
)));
}
}
Ok(())
}
fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
let mut seen = HashSet::new();
for (i, device) in devices.iter().enumerate() {
if !device.starts_with("/dev/") || device.contains("..") {
return Err(ManifestError::Invalid(format!(
"devices[{i}] must be an absolute /dev path"
)));
}
if !seen.insert(device.as_str()) {
return Err(ManifestError::Invalid(format!(
"devices contains duplicate entry '{device}'"
)));
}
}
Ok(())
}
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
let path = std::path::Path::new(source);
if !path.is_absolute() {
if is_valid_named_volume(source) {
return Ok(());
}
return Err(ManifestError::Invalid(format!(
"volumes[{index}].source must be absolute for host bind mounts or a safe named volume"
)));
}
if source.contains("..") {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].source must not contain '..'"
)));
}
if source.starts_with("/var/lib/archipelago/") || is_reviewed_host_bind_exception(source) {
return Ok(());
}
Err(ManifestError::Invalid(format!(
"volumes[{index}].source must be under /var/lib/archipelago or a reviewed host-bind exception"
)))
}
fn is_reviewed_host_bind_exception(source: &str) -> bool {
source == "/run/user/1000/podman/podman.sock" || source == "/var/run/dbus"
}
fn is_valid_named_volume(source: &str) -> bool {
if source.is_empty() || source.contains('/') || source.contains("..") {
return false;
}
source
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
fn validate_container_path(index: usize, target: &str) -> Result<(), ManifestError> {
if !std::path::Path::new(target).is_absolute() || target.contains("..") {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].target must be an absolute container path without '..'"
)));
}
Ok(())
}
fn validate_volume_options(index: usize, options: &[String]) -> Result<(), ManifestError> {
let allowed = ["rw", "ro", "z", "Z", "shared", "rshared", "slave", "rslave"];
let mut seen = HashSet::new();
for option in options {
if !allowed.contains(&option.as_str()) {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].options contains unsupported option '{option}'"
)));
}
if !seen.insert(option.as_str()) {
return Err(ManifestError::Invalid(format!(
"volumes[{index}].options contains duplicate option '{option}'"
)));
}
}
Ok(())
}
/// Host facts available to `derived_env` templates at apply time.
///
/// Mirrors the values `scripts/container-specs.sh:detect_environment()`
/// computed before each reconcile pass. The Rust orchestrator computes
/// these once per reconcile tick and passes them to
/// `ContainerConfig::resolve_derived_env`.
#[derive(Debug, Clone)]
pub struct HostFacts {
/// Primary host IPv4 (e.g. from `hostname -I | awk '{print $1}'`).
/// Falls back to `127.0.0.1` on detection failure.
pub host_ip: String,
/// mDNS hostname (`<hostname>.local`). Survives DHCP churn and
/// reinstall-on-different-IP. Requires avahi-daemon on the node.
pub host_mdns: String,
/// Usable disk size in gigabytes at `/var/lib/archipelago` (or
/// `/` if the data partition is not yet mounted). Drives the
/// prune-vs-full-node decision in bitcoin-knots custom_args.
pub disk_gb: u64,
/// Container name of the running Bitcoin node — `bitcoin-knots` or
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
/// 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,
}
impl HostFacts {
/// Test-only constant fixture; do not use in production paths.
#[cfg(test)]
pub fn sample() -> Self {
Self {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-knots".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"];
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
// Unbalanced braces are a user error.
let bytes = template.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{' && bytes[i + 1] == b'{' {
let rest = &template[i + 2..];
let close = rest.find("}}").ok_or_else(|| {
ManifestError::Invalid(format!(
"container.derived_env['{key}'].template has unbalanced '{{{{' — no closing '}}}}'"
))
})?;
let name = &rest[..close];
if !DERIVED_PLACEHOLDERS.contains(&name) {
return Err(ManifestError::Invalid(format!(
"container.derived_env['{key}'].template references unknown placeholder '{{{{{name}}}}}' (supported: {})",
DERIVED_PLACEHOLDERS.join(", ")
)));
}
i = i + 2 + close + 2;
} else {
i += 1;
}
}
Ok(())
}
/// A source of named secrets. In prod this is a directory on disk
/// (`/var/lib/archipelago/secrets/`); in tests, a HashMap.
pub trait SecretsProvider {
/// Read the named secret and return its value with trailing
/// whitespace trimmed (so `echo "…" > secret-file` works without
/// injecting a newline into env).
fn read(&self, name: &str) -> Result<String, ManifestError>;
}
impl ContainerConfig {
/// Collapse the (image, build) pair into a single resolved source.
///
/// Returns `None` if the config is in an invalid state (e.g. neither field set
/// or both set). Callers should have already run `AppManifest::validate()` to
/// surface a user-facing error; this method is for internal orchestrator use
/// after validation has passed.
pub fn resolve(&self) -> Option<ResolvedSource> {
match (&self.image, &self.build) {
(Some(img), None) if !img.is_empty() => Some(ResolvedSource::Pull {
image: img.clone(),
pull_policy: self.pull_policy.clone(),
image_signature: self.image_signature.clone(),
}),
(None, Some(b)) => Some(ResolvedSource::Build(b.clone())),
_ => None,
}
}
/// The image reference used to create/inspect a container for this config.
///
/// For Pull sources this is the registry image. For Build sources this is
/// the locally-built tag. Returns `None` only for an invalid config.
pub fn image_ref(&self) -> Option<String> {
self.resolve().map(|r| match r {
ResolvedSource::Pull { image, .. } => image,
ResolvedSource::Build(b) => b.tag,
})
}
/// Render every `derived_env` entry's template against the given
/// host facts. Returns `"KEY=VALUE"` strings ready to concatenate
/// with `environment:`.
///
/// Assumes `AppManifest::validate()` has already accepted the
/// manifest — placeholder names are not re-checked here.
pub fn resolve_derived_env(&self, facts: &HostFacts) -> Vec<String> {
self.derived_env
.iter()
.map(|e| {
let value = e
.template
.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);
format!("{}={}", e.key, value)
})
.collect()
}
/// Read every `secret_env` entry's value from the provider and
/// return `"KEY=VALUE"` strings. Propagates the provider error on
/// the first missing/unreadable secret — partial resolution is not
/// useful because it silently produces a misconfigured container.
pub fn resolve_secret_env(
&self,
provider: &dyn SecretsProvider,
) -> Result<Vec<String>, ManifestError> {
Ok(self
.resolve_secret_env_pairs(provider)?
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect())
}
/// Like `resolve_secret_env` but returns (key, value) pairs — the shape
/// the podman-secret pipeline needs.
pub fn resolve_secret_env_pairs(
&self,
provider: &dyn SecretsProvider,
) -> Result<Vec<(String, String)>, ManifestError> {
let mut out = Vec::with_capacity(self.secret_env.len());
for e in &self.secret_env {
let v = match provider.read(&e.secret_file) {
Ok(v) => v,
Err(_) if e.optional => continue,
Err(err) => return Err(err),
};
// An empty secret produces e.g. `-rpcpassword=` and crashes
// the container on auth before logs are useful. Fail loud —
// unless the entry is optional, where empty means "not set".
if v.trim().is_empty() {
if e.optional {
continue;
}
return Err(ManifestError::Invalid(format!(
"secret_env {} resolved to empty value (file: {})",
e.key, e.secret_file
)));
}
out.push((e.key.clone(), v));
}
Ok(out)
}
}
/// Deterministic content hash over resolved secret env pairs (sorted by
/// key), used for the container drift label and per-secret labels.
pub fn secret_env_content_hash(pairs: &[(String, String)]) -> String {
use sha2::{Digest, Sha256};
let mut sorted: Vec<&(String, String)> = pairs.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (k, v) in sorted {
hasher.update(k.as_bytes());
hasher.update([0u8]);
hasher.update(v.as_bytes());
hasher.update([0u8]);
}
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn host_can_bind_accepts_empty_wildcard_and_loopback_without_probing() {
assert!(host_can_bind_publish_ip(""));
assert!(host_can_bind_publish_ip("0.0.0.0"));
assert!(host_can_bind_publish_ip("127.0.0.1"));
assert!(host_can_bind_publish_ip("::"));
assert!(host_can_bind_publish_ip("::1"));
}
#[test]
fn host_can_bind_rejects_addresses_absent_from_the_host() {
// TEST-NET-1 (RFC 5737) is never assigned to a real interface. The
// production case is 10.89.0.1 (podman bridge gateway, exists only
// inside the rootless netns), but that could legitimately bind on a
// rootful host, so the test probes an address that can't.
assert!(!host_can_bind_publish_ip("192.0.2.1"));
assert!(!host_can_bind_publish_ip("not-an-ip"));
}
#[test]
fn partition_taints_plain_entries_that_interpolate_secrets() {
let plain = vec![
"PLAIN=1".to_string(),
"COMPOSED=${BASE}/x".to_string(),
"DB_URL=postgres://u:${DB_PASS}@db/x".to_string(),
"BASE=/srv".to_string(),
"UNKNOWN=${NOT_DEFINED}".to_string(),
];
let secrets = vec![("DB_PASS".to_string(), "s3cret".to_string())];
let (p, s) = expand_and_partition_env(plain, secrets);
// plain-from-plain expansion stays plain; unknown placeholders stay
// literal (legacy expander parity)
assert!(p.contains(&"PLAIN=1".to_string()));
assert!(p.contains(&"COMPOSED=/srv/x".to_string()));
assert!(p.contains(&"UNKNOWN=${NOT_DEFINED}".to_string()));
// the tainted entry moved out of plain, fully expanded
assert!(!p.iter().any(|e| e.starts_with("DB_URL=")));
assert!(s.contains(&("DB_URL".to_string(), "postgres://u:s3cret@db/x".to_string())));
// the original secret rides along verbatim
assert!(s.contains(&("DB_PASS".to_string(), "s3cret".to_string())));
}
#[test]
fn secret_values_are_never_expanded() {
// A generated secret containing `${` must pass through untouched.
let secrets = vec![("WEIRD".to_string(), "pa${PLAIN}ss".to_string())];
let (_, s) = expand_and_partition_env(vec!["PLAIN=1".to_string()], secrets);
assert!(s.contains(&("WEIRD".to_string(), "pa${PLAIN}ss".to_string())));
}
#[test]
fn secret_env_hash_is_order_independent() {
let a = vec![
("K1".to_string(), "v1".to_string()),
("K2".to_string(), "v2".to_string()),
];
let b = vec![
("K2".to_string(), "v2".to_string()),
("K1".to_string(), "v1".to_string()),
];
assert_eq!(secret_env_content_hash(&a), secret_env_content_hash(&b));
let c = vec![("K1".to_string(), "CHANGED".to_string())];
assert_ne!(secret_env_content_hash(&a), secret_env_content_hash(&c));
}
#[test]
fn hooks_parse_and_validate() {
let yaml = r#"
app:
id: indeedhub
name: IndeedHub
version: 1.0.0
container:
image: test/indeedhub:1.0.0
hooks:
post_install:
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
- copy_from_host:
src: "web-ui/nostr-provider.js"
dest: "/usr/share/nginx/html/nostr-provider.js"
"#;
let m = AppManifest::parse(yaml).unwrap();
assert_eq!(m.app.hooks.post_install.len(), 2);
match &m.app.hooks.post_install[0] {
HookStep::Exec { exec } => assert_eq!(exec[0], "sed"),
_ => panic!("expected exec step"),
}
match &m.app.hooks.post_install[1] {
HookStep::CopyFromHost { copy_from_host } => {
assert_eq!(
copy_from_host.dest,
"/usr/share/nginx/html/nostr-provider.js"
)
}
_ => panic!("expected copy_from_host step"),
}
m.validate().unwrap();
}
#[test]
fn hooks_reject_absolute_or_traversal_copy_src() {
for bad in ["/etc/passwd", "../../etc/shadow", "web-ui/../../etc/x"] {
let yaml = format!(
"app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n \
hooks:\n post_install:\n - copy_from_host:\n src: \"{bad}\"\n dest: \"/x\"\n"
);
assert!(
AppManifest::parse(&yaml).is_err(),
"src '{bad}' must be rejected"
);
}
}
/// Build a manifest with one port block, so each auth case differs only
/// in the lines under test.
fn manifest_with_port(port_yaml: &str) -> Result<AppManifest, ManifestError> {
AppManifest::parse(&format!(
"app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n ports:\n{port_yaml}"
))
}
/// Every manifest we ship must satisfy the schema — including the auth
/// rules above. Without this the first exemption typo'd into a manifest
/// would only surface when a node refused to load the app.
#[test]
fn all_shipped_manifests_parse() {
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
let Ok(entries) = std::fs::read_dir(&apps) else {
return; // not a full checkout (vendored crate) — nothing to check
};
let mut checked = 0;
for entry in entries.flatten() {
let manifest = entry.path().join("manifest.yml");
if !manifest.is_file() {
continue;
}
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
AppManifest::parse(&yaml)
.unwrap_or_else(|e| panic!("{} is invalid: {e}", manifest.display()));
checked += 1;
}
assert!(checked > 40, "only found {checked} manifests — path wrong?");
}
/// The exempt set is the node's entire unauthenticated attack surface, so
/// it must stay small and deliberate. If this count moves, someone added
/// or removed an exemption and it wants a second pair of eyes.
#[test]
fn unauthenticated_ports_are_all_accounted_for() {
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
let Ok(entries) = std::fs::read_dir(&apps) else {
return;
};
let mut exempt: Vec<(String, u16)> = Vec::new();
for entry in entries.flatten() {
let manifest = entry.path().join("manifest.yml");
if !manifest.is_file() {
continue;
}
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
let parsed = AppManifest::parse(&yaml).expect("manifest valid");
for port in &parsed.app.ports {
if port.auth_policy() == PortAuth::None {
exempt.push((parsed.app.id.clone(), port.host));
}
}
}
exempt.sort();
// 31 as of the podsteadr app-package round: the prior 25 (bitcoin p2p
// (8333 ×2), core-lightning 9736/9835, electrumx 50001, fedimint
// 8173/8174, fedimint-gateway 8176/9737, gitea ssh 2222,
// lightning-stack 8091/9738/10010, lnd 9735/10009/18080, netbird
// 3478/8086/8087, pine TLS 10381 + the three voice ports
// (10200/10300/10400 — the disclosed known gap), router SSDP/mDNS
// 1900/5353) plus 6 new ones: podsteadr 8095 (web UI/API/RSS —
// third-party podcast clients and other podsteadr instances must
// fetch feeds/marketplace data with no node session; the app gates
// its own sensitive routes with NIP-98), podsteadr-blossom 8098
// (public blob reads for RSS enclosures; uploads are BUD-02
// signed-auth gated by blossom itself), podsteadr-mediamtx
// 1935/8189/8889/8890 (RTMP/ICE/WHIP ingest + HLS playback — none of
// these are HTTP-session-shaped, and publish is protocol-gated by a
// per-stream secret checked via podsteadr's own auth webhook). Every
// one is a deliberate, rationale-carrying exemption.
assert_eq!(
exempt.len(),
31,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
#[test]
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
// Two different questions, and conflating them caused both gate
// incidents. A manifest that says nothing must CLASSIFY as gated, so
// the audit reports it as something that should be protected — but it
// must not read as an instruction the daemon may act on.
let manifest = manifest_with_port(" - host: 8080\n container: 80\n").unwrap();
let port = &manifest.app.ports[0];
assert_eq!(port.auth_policy(), PortAuth::Session, "reports as gated");
assert!(!port.auth_is_declared(), "but is NOT an instruction");
assert!(port.auth.is_none());
}
#[test]
fn an_explicit_session_declaration_is_actionable() {
let manifest =
manifest_with_port(" - host: 8080\n container: 80\n auth: session\n")
.unwrap();
let port = &manifest.app.ports[0];
assert_eq!(port.auth_policy(), PortAuth::Session);
assert!(port.auth_is_declared());
}
/// The wallet constraint, in the form that actually bit. LND's gRPC and
/// REST carry `bind: ""`, so a rule keyed on `bind` alone does not save
/// them — and on a node whose manifest predates the auth field there is
/// no `auth: none` either. Undeclared must therefore be untouchable, or
/// recreating LND silently pins those ports to loopback and every remote
/// wallet stops working.
#[test]
fn an_undeclared_wallet_port_is_never_actionable() {
let manifest =
manifest_with_port(" - host: 10009\n container: 10009\n protocol: tcp\n")
.unwrap();
let port = &manifest.app.ports[0];
assert!(port.bind.is_empty(), "this is the shape that bit us");
assert!(
!port.auth_is_declared(),
"an undeclared port must never authorise republishing"
);
}
#[test]
fn port_auth_none_requires_a_rationale() {
let err = manifest_with_port(" - host: 8333\n container: 8333\n auth: none\n")
.expect_err("auth: none without a rationale must be rejected");
assert!(
err.to_string().contains("auth_rationale"),
"error should name the missing field, got: {err}"
);
}
#[test]
fn port_auth_none_rejects_a_blank_rationale() {
assert!(manifest_with_port(
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: \" \"\n"
)
.is_err());
}
#[test]
fn port_auth_none_with_a_rationale_parses() {
let manifest = manifest_with_port(
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: Bitcoin p2p gossip\n",
)
.unwrap();
assert_eq!(manifest.app.ports[0].auth, Some(PortAuth::None));
assert_eq!(
manifest.app.ports[0].auth_rationale.as_deref(),
Some("Bitcoin p2p gossip")
);
}
#[test]
fn rationale_without_auth_none_is_rejected() {
// Catches the author who wrote the justification but forgot the
// `auth: none` line: the port stays gated, and shipping it silently
// would leave them believing they had an exemption they never got.
let err = manifest_with_port(
" - host: 8080\n container: 80\n auth_rationale: I meant to exempt this\n",
)
.expect_err("a rationale on a gated port must be rejected");
assert!(err.to_string().contains("no effect"), "got: {err}");
}
#[test]
fn hooks_reject_empty_exec() {
let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n";
assert!(AppManifest::parse(yaml).is_err());
}
#[test]
fn test_manifest_parse() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:latest
"#;
let manifest = AppManifest::parse(yaml).unwrap();
assert_eq!(manifest.app.id, "test-app");
assert_eq!(manifest.app.name, "Test App");
assert_eq!(manifest.app.version, "1.0.0");
}
#[test]
fn typed_interfaces_parse_with_defaults() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
"#;
let manifest = AppManifest::parse(yaml).unwrap();
let main = manifest.app.interfaces.get("main").unwrap();
assert_eq!(main.interface_type, "ui");
assert_eq!(main.port, 8080);
assert_eq!(main.protocol, "http");
assert_eq!(main.path, "/");
}
#[test]
fn invalid_interfaces_are_rejected() {
let cases = [
(
"bad key",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
Bad Key:
port: 8080
"#,
"interfaces key",
),
(
"bad protocol",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
protocol: ftp
"#,
"interfaces.main.protocol",
),
(
"bad path",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
path: dashboard
"#,
"interfaces.main.path",
),
];
for (name, yaml, expected) in cases {
let err = AppManifest::parse(yaml).unwrap_err();
let ManifestError::Invalid(msg) = err else {
panic!("{name}: expected invalid manifest, got {err:?}");
};
assert!(
msg.contains(expected),
"{name}: expected error containing {expected:?}, got {msg:?}"
);
}
}
#[test]
fn test_manifest_validation() {
let yaml = r#"
app:
id: ""
name: Test
version: 1.0.0
container:
image: test/image:latest
"#;
let result = AppManifest::parse(yaml);
assert!(result.is_err());
}
#[test]
fn pull_source_resolves_to_pull() {
let yaml = r#"
app:
id: test-app
name: Test
version: 1.0.0
container:
image: docker.io/library/nginx:1.27
pull_policy: always
"#;
let m = AppManifest::parse(yaml).unwrap();
let src = m.app.container.resolve().unwrap();
match src {
ResolvedSource::Pull {
image, pull_policy, ..
} => {
assert_eq!(image, "docker.io/library/nginx:1.27");
assert_eq!(pull_policy, "always");
}
_ => panic!("expected Pull"),
}
assert_eq!(
m.app.container.image_ref().as_deref(),
Some("docker.io/library/nginx:1.27")
);
}
#[test]
fn build_source_resolves_to_build() {
let yaml = r#"
app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
container:
build:
context: /opt/archipelago/docker/bitcoin-ui
dockerfile: Dockerfile
tag: archy-bitcoin-ui:local
build_args:
NGINX_VERSION: "1.27"
"#;
let m = AppManifest::parse(yaml).unwrap();
let src = m.app.container.resolve().unwrap();
match src {
ResolvedSource::Build(b) => {
assert_eq!(b.context, "/opt/archipelago/docker/bitcoin-ui");
assert_eq!(b.dockerfile, "Dockerfile");
assert_eq!(b.tag, "archy-bitcoin-ui:local");
assert_eq!(b.build_args.get("NGINX_VERSION").unwrap(), "1.27");
}
_ => panic!("expected Build"),
}
assert_eq!(
m.app.container.image_ref().as_deref(),
Some("archy-bitcoin-ui:local")
);
}
#[test]
fn dockerfile_defaults_to_dockerfile() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
build:
context: /tmp
tag: x:local
"#;
let m = AppManifest::parse(yaml).unwrap();
match m.app.container.resolve().unwrap() {
ResolvedSource::Build(b) => assert_eq!(b.dockerfile, "Dockerfile"),
_ => unreachable!(),
}
}
#[test]
fn image_and_build_both_set_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
image: foo:latest
build:
context: /tmp
tag: x:local
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("mutually exclusive"),
"unexpected error: {msg}"
);
}
#[test]
fn neither_image_nor_build_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container: {}
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("either image or build"),
"unexpected error: {msg}"
);
}
#[test]
fn empty_image_string_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
image: ""
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("either image or build"),
"unexpected error: {msg}"
);
}
#[test]
fn empty_build_context_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
build:
context: ""
tag: x:local
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("context"), "unexpected error: {msg}");
}
#[test]
fn empty_build_tag_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
build:
context: /tmp
tag: ""
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("tag"), "unexpected error: {msg}");
}
#[test]
fn existing_pull_only_manifests_still_parse() {
// Backwards-compat smoke: the shape every file in apps/*/manifest.yml uses today.
let yaml = r#"
app:
id: legacy
name: Legacy App
version: 0.1.0
description: existing shape
container:
image: registry.example.com/legacy:1.2.3
image_signature: sha256:abc
ports:
- { host: 8080, container: 80 }
"#;
let m = AppManifest::parse(yaml).unwrap();
assert_eq!(m.app.container.pull_policy, "if-not-present");
matches!(
m.app.container.resolve().unwrap(),
ResolvedSource::Pull { .. }
);
}
#[test]
fn generated_files_must_live_under_bind_mounts() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /var/lib/archipelago/test-app
target: /data
files:
- path: /var/lib/archipelago/test-app/config.yaml
content: |
key: value
"#;
let manifest = AppManifest::parse(yaml).unwrap();
assert_eq!(manifest.app.files.len(), 1);
let bad = yaml.replace(
"/var/lib/archipelago/test-app/config.yaml",
"/etc/test-app/config.yaml",
);
let err = AppManifest::parse(&bad).unwrap_err();
assert!(
format!("{err}").contains("bind-mounted volume source"),
"unexpected error: {err}"
);
}
#[test]
fn empty_custom_arg_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
image: foo:latest
custom_args: [""]
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("custom_args[0]"), "unexpected error: {msg}");
}
#[test]
fn empty_entrypoint_vec_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
image: foo:latest
entrypoint: []
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("entrypoint"), "unexpected error: {msg}");
}
#[test]
fn empty_entrypoint_element_is_rejected() {
let yaml = r#"
app:
id: x
name: X
version: 1.0.0
container:
image: foo:latest
entrypoint: ["gatewayd", ""]
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("entrypoint[1]"), "unexpected error: {msg}");
}
#[test]
fn duplicate_derived_env_keys_are_rejected() {
let yaml = r#"
app:
id: fedimint
name: Fedimint
version: 0.10.0
container:
image: fedimintd:v0.10.0
derived_env:
- key: FM_API_URL
template: "ws://{{HOST_MDNS}}:8174"
- key: FM_API_URL
template: "ws://{{HOST_IP}}:8174"
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("duplicate key"), "unexpected error: {msg}");
}
#[test]
fn unknown_derived_placeholder_is_rejected() {
let yaml = r#"
app:
id: fedimint
name: Fedimint
version: 0.10.0
container:
image: fedimintd:v0.10.0
derived_env:
- key: FM_API_URL
template: "ws://{{HOSTNAME}}:8174"
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("unknown placeholder"),
"unexpected error: {msg}"
);
}
#[test]
fn path_traversal_secret_file_is_rejected() {
let yaml = r#"
app:
id: fedimint
name: Fedimint
version: 0.10.0
container:
image: fedimintd:v0.10.0
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: "../bitcoin-rpc-password"
"#;
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("bare filename"), "unexpected error: {msg}");
}
#[test]
fn resolve_derived_env_renders_host_facts() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![
DerivedEnv {
key: "FM_API_URL".to_string(),
template: "ws://{{HOST_MDNS}}:8174".to_string(),
},
DerivedEnv {
key: "INFO".to_string(),
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
},
DerivedEnv {
key: "CORE_RPC_HOST".to_string(),
template: "{{BITCOIN_HOST}}".to_string(),
},
],
secret_env: vec![],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let facts = HostFacts {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-core".to_string(),
};
let out = c.resolve_derived_env(&facts);
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
assert_eq!(out[1], "INFO=192.168.1.116-2000");
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
}
struct MapSecretsProvider {
data: HashMap<String, String>,
}
impl SecretsProvider for MapSecretsProvider {
fn read(&self, name: &str) -> Result<String, ManifestError> {
self.data
.get(name)
.cloned()
.ok_or_else(|| ManifestError::Invalid(format!("missing secret: {name}")))
}
}
#[test]
fn resolve_secret_env_reads_from_provider() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![
SecretEnv {
key: "FM_BITCOIND_PASSWORD".to_string(),
secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
},
SecretEnv {
key: "FM_GATEWAY_PASSWORD".to_string(),
secret_file: "fedimint-gateway-password".to_string(),
optional: false,
},
],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let p = MapSecretsProvider {
data: HashMap::from([
(
"bitcoin-rpc-password".to_string(),
"supersecret1".to_string(),
),
(
"fedimint-gateway-password".to_string(),
"supersecret2".to_string(),
),
]),
};
let out = c.resolve_secret_env(&p).unwrap();
assert_eq!(out[0], "FM_BITCOIND_PASSWORD=supersecret1");
assert_eq!(out[1], "FM_GATEWAY_PASSWORD=supersecret2");
}
#[test]
fn resolve_secret_env_rejects_empty_value() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![SecretEnv {
key: "BITCOIN_RPC_PASS".to_string(),
secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let p = MapSecretsProvider {
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
};
let err = c.resolve_secret_env(&p).unwrap_err();
match err {
ManifestError::Invalid(msg) => assert!(
msg.contains("BITCOIN_RPC_PASS") && msg.contains("bitcoin-rpc-password"),
"msg should name the env key + file: {msg}"
),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![
SecretEnv {
key: "REQUIRED".to_string(),
secret_file: "present".to_string(),
optional: false,
},
SecretEnv {
key: "OPT_MISSING".to_string(),
secret_file: "does-not-exist".to_string(),
optional: true,
},
SecretEnv {
key: "OPT_EMPTY".to_string(),
secret_file: "empty".to_string(),
optional: true,
},
],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let p = MapSecretsProvider {
data: HashMap::from([
("present".to_string(), "value".to_string()),
("empty".to_string(), " \n".to_string()),
]),
};
let out = c.resolve_secret_env(&p).unwrap();
assert_eq!(out, vec!["REQUIRED=value".to_string()]);
}
#[test]
fn unsafe_manifest_values_are_rejected() {
let cases = [
(
"bad app id",
r#"
app:
id: Bad_App
name: Bad
version: 1.0.0
container:
image: test/image:latest
"#,
"app.id",
),
(
"unsupported capability",
r#"
app:
id: bad-cap
name: Bad
version: 1.0.0
container:
image: test/image:latest
security:
capabilities: [SYS_MODULE]
"#,
"unsupported capability",
),
(
"docker socket bind",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
"#,
"reviewed host-bind exception",
),
(
"path-like relative bind source",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: data/cache
target: /data
"#,
"absolute for host bind mounts",
),
(
"bad environment key",
r#"
app:
id: bad-env
name: Bad
version: 1.0.0
container:
image: test/image:latest
environment:
- 1BAD=value
"#,
"invalid key",
),
(
"duplicate host port",
r#"
app:
id: bad-port
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8080, container: 80, protocol: tcp }
- { host: 8080, container: 81, protocol: tcp }
"#,
"duplicate host binding",
),
(
"duplicate host port with same bind",
r#"
app:
id: bad-port-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
"#,
"duplicate host binding",
),
(
"non-IP bind address",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: localhost }
"#,
"bind must be an IP address",
),
(
"bad device",
r#"
app:
id: bad-device
name: Bad
version: 1.0.0
container:
image: test/image:latest
devices:
- /tmp/fake-device
"#,
"absolute /dev path",
),
(
"container network namespace",
r#"
app:
id: bad-network
name: Bad
version: 1.0.0
container:
image: test/image:latest
network: container:host
"#,
"not allowed",
),
];
for (name, yaml, expected) in cases {
let err = AppManifest::parse(yaml).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(expected),
"case {name} expected '{expected}', got: {msg}"
);
}
}
#[test]
fn same_host_port_with_distinct_binds_is_valid() {
// Bitcoin RPC hardening: 8332 published on loopback + the archy-net
// gateway as two mappings of the same host port.
let m = AppManifest::parse(
r#"
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 1.0.0
container:
image: test/bitcoin:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
- { host: 8332, container: 8332, protocol: tcp, bind: 10.89.0.1 }
- { host: 8333, container: 8333, protocol: tcp }
"#,
)
.expect("distinct binds on one host port must validate");
assert_eq!(m.app.ports.len(), 3);
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
assert_eq!(m.app.ports[1].bind, "10.89.0.1");
assert_eq!(m.app.ports[2].bind, "");
}
#[test]
fn reviewed_host_bind_exceptions_parse() {
let yaml = r#"
app:
id: reviewed-binds
name: Reviewed Binds
version: 1.0.0
container:
image: test/image:latest
volumes:
- type: bind
source: /run/user/1000/podman/podman.sock
target: /var/run/docker.sock
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
"#;
AppManifest::parse(yaml).unwrap();
}
#[test]
fn parse_every_real_manifest() {
let app_manifests = list_repo_manifests();
assert!(
!app_manifests.is_empty(),
"no apps/*/manifest.yml files found"
);
let mut failures: Vec<String> = Vec::new();
let mut modern_count = 0usize;
for path in app_manifests {
let content = fs::read_to_string(&path).expect("read manifest");
let parsed_yaml: serde_yaml::Value = match serde_yaml::from_str(&content) {
Ok(v) => v,
Err(err) => {
failures.push(format!("{}: YAML parse error: {err}", path.display()));
continue;
}
};
let is_modern = parsed_yaml
.as_mapping()
.map(|m| m.contains_key(serde_yaml::Value::String("app".to_string())))
.unwrap_or(false);
if is_modern {
modern_count += 1;
if let Err(err) = AppManifest::parse(&content) {
failures.push(format!("{}: {err}", path.display()));
}
} else {
failures.push(format!(
"{}: expected modern app-schema manifest",
path.display()
));
}
}
assert!(modern_count > 0, "no modern app-schema manifests found");
assert!(
failures.is_empty(),
"manifest parse failures:\n{}",
failures.join("\n")
);
}
fn list_repo_manifests() -> Vec<PathBuf> {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
let apps_dir = repo_root.join("apps");
let mut out = Vec::new();
let Ok(entries) = fs::read_dir(apps_dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest = path.join("manifest.yml");
if manifest.exists() {
out.push(manifest);
}
}
out.sort();
out
}
}