Groundwork for the app gate (item 1): before anything can enforce authentication on app ports, the node has to know which ports are *supposed* to be reachable without it. `PortMapping` grows `auth` (PortAuth::Session | None, defaulting to Session) and `auth_rationale`. The default is deliberately the protected one. Every app port on this node answered with no credential at all over LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 — precisely because exposure was what you got by saying nothing. Inverting the default means a new app is protected unless its manifest argues for an exemption. Validation makes the argument mandatory: `auth: none` without a rationale is rejected, and so is a rationale without `auth: none` (that combination means the author wrote an exemption and did not get one — shipping it silently would leave them believing otherwise). 17 ports across 12 apps are declared exempt, each with its reason. They are the ports that cannot sit behind an HTTP login page at all: Lightning p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS — Zeus and remote wallets dial these directly), Bitcoin p2p gossip, electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published ports — now defaults to gated. Bitcoin's RPC 8332 is deliberately NOT exempted: it is already `bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption it does not need would put a line in the audit list that means nothing. If the loopback bind is ever dropped, it fails closed. Two corpus tests keep this honest: every shipped manifest must parse under the new rules, and the exempt set is pinned at 17 so any change to the node's unauthenticated surface has to be a deliberate edit. Tests: 73/73 archipelago-container, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2623 lines
89 KiB
Rust
2623 lines
89 KiB
Rust
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 = ©_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,
|
|
}
|
|
|
|
#[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,
|
|
/// Whether the app gate authenticates connections to this port.
|
|
/// Omitted = `session` (protected). See [`PortAuth`].
|
|
#[serde(default)]
|
|
pub auth: 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>,
|
|
}
|
|
|
|
impl From<(u16, u16)> for PortMapping {
|
|
fn from((host, container): (u16, u16)) -> Self {
|
|
PortMapping {
|
|
host,
|
|
container,
|
|
protocol: "tcp".to_string(),
|
|
bind: String::new(),
|
|
auth: PortAuth::Session,
|
|
auth_rationale: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, 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 == PortAuth::None {
|
|
exempt.push((parsed.app.id.clone(), port.host));
|
|
}
|
|
}
|
|
}
|
|
exempt.sort();
|
|
assert_eq!(
|
|
exempt.len(),
|
|
17,
|
|
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn port_auth_defaults_to_session() {
|
|
// The whole point of the default: a manifest that says nothing about
|
|
// auth must come out PROTECTED, not exposed. If this ever flips,
|
|
// every existing app silently loses its gate.
|
|
let manifest = manifest_with_port(" - host: 8080\n container: 80\n").unwrap();
|
|
assert_eq!(manifest.app.ports[0].auth, PortAuth::Session);
|
|
assert!(manifest.app.ports[0].auth_rationale.is_none());
|
|
}
|
|
|
|
#[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, 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
|
|
}
|
|
}
|