backend: harden rootless app lifecycle orchestration

This commit is contained in:
archipelago
2026-06-11 00:24:32 -04:00
parent 09ec64932f
commit c393b96da3
56 changed files with 7543 additions and 1994 deletions
+3 -3
View File
@@ -8,9 +8,9 @@ pub mod runtime;
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
pub use health_monitor::HealthMonitor;
pub use manifest::{
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
Volume,
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, HealthCheck,
HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider,
SecurityPolicy, Volume,
};
pub use podman_client::{
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
+454 -9
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -42,6 +42,9 @@ pub struct AppDefinition {
#[serde(default)]
pub volumes: Vec<Volume>,
#[serde(default)]
pub files: Vec<GeneratedFile>,
#[serde(default)]
pub environment: Vec<String>,
@@ -216,6 +219,8 @@ pub struct SecurityPolicy {
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)]
@@ -263,6 +268,14 @@ pub struct Volume {
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")]
@@ -302,8 +315,16 @@ impl AppManifest {
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.app.id.is_empty() {
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
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
@@ -355,6 +376,11 @@ impl AppManifest {
"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"
)));
}
}
// custom_args: no empty strings (would inject literal "" into
@@ -447,6 +473,11 @@ impl AppManifest {
}
}
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
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" {
@@ -466,6 +497,11 @@ impl AppManifest {
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",
@@ -478,6 +514,45 @@ impl AppManifest {
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"
)));
}
}
@@ -485,6 +560,195 @@ impl AppManifest {
}
}
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 !seen_host.insert((port.host, protocol.to_string())) {
return Err(ManifestError::Invalid(format!(
"ports contains duplicate host binding {}/{}",
port.host, protocol
)));
}
}
Ok(())
}
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()`
@@ -864,6 +1128,38 @@ app:
);
}
#[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#"
@@ -1089,6 +1385,157 @@ app:
}
}
#[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",
),
(
"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 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();
@@ -1099,7 +1546,6 @@ app:
let mut failures: Vec<String> = Vec::new();
let mut modern_count = 0usize;
let mut legacy_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) {
@@ -1121,15 +1567,14 @@ app:
failures.push(format!("{}: {err}", path.display()));
}
} else {
legacy_count += 1;
failures.push(format!(
"{}: expected modern app-schema manifest",
path.display()
));
}
}
assert!(modern_count > 0, "no modern app-schema manifests found");
assert!(
legacy_count > 0,
"expected at least one legacy manifest shape"
);
assert!(
failures.is_empty(),
+4 -4
View File
@@ -56,9 +56,9 @@ pub enum ContainerState {
impl From<&str> for ContainerState {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"created" => ContainerState::Created,
"created" | "initialized" => ContainerState::Created,
"running" => ContainerState::Running,
"stopping" => ContainerState::Stopping,
"stopping" | "removing" => ContainerState::Stopping,
"stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited,
"paused" => ContainerState::Paused,
@@ -129,7 +129,6 @@ impl PodmanClient {
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:8081",
"portainer" => "http://localhost:9000",
"saleor" => "http://localhost:9011",
"uptime-kuma" => "http://localhost:3002",
"fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
@@ -390,7 +389,7 @@ impl PodmanClient {
"cap_add": cap_add,
"cap_drop": cap_drop,
"read_only_filesystem": manifest.app.security.readonly_root,
"no_new_privileges": true,
"no_new_privileges": manifest.app.security.no_new_privileges,
"restart_policy": "unless-stopped",
"restart_tries": 5,
"netns": {
@@ -635,6 +634,7 @@ fn podman_network_settings(
Some("bridge") => ("bridge", None),
Some("none") => ("none", None),
Some("slirp4netns") => ("slirp4netns", None),
Some("pasta") => ("pasta", None),
Some("private") => ("private", None),
Some(custom) => ("bridge", Some(custom.to_string())),
None if network_policy == "host" => ("host", None),
+31 -6
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use tokio::process::Command as TokioCommand;
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const PODMAN_CLI_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
#[async_trait]
@@ -150,7 +151,25 @@ impl ContainerRuntime for PodmanRuntime {
if is_missing_container_error(&stderr) {
return Ok(());
}
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
let zero_timeout = self.podman_cli(&["rm", "-f", "--time", "0", name]).await?;
if zero_timeout.status.success() {
return Ok(());
}
let _ = self.podman_cli(&["container", "cleanup", name]).await;
let cleanup_rm = self.podman_cli(&["rm", "-f", name]).await?;
if cleanup_rm.status.success() {
return Ok(());
}
let cleanup_stderr = String::from_utf8_lossy(&cleanup_rm.stderr);
if is_missing_container_error(&cleanup_stderr) {
return Ok(());
}
Err(api_err.context(format!(
"podman rm fallback failed: {}; cleanup rm failed: {}",
stderr.trim(),
cleanup_stderr.trim()
)))
}
}
}
@@ -196,20 +215,26 @@ impl ContainerRuntime for PodmanRuntime {
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
// `podman image exists` returns 0 if present, 1 if absent. Any other
// exit code is an environment failure we should surface.
let output = self.podman_cli(&["image", "exists", image_ref]).await?;
// Avoid `podman image exists`: on production nodes with a stressed
// rootless store it can hang even when targeted at one image. A bounded
// inspect is the local-storage probe the trait contract describes.
let output = self
.podman_cli_timeout(
&["image", "inspect", image_ref],
PODMAN_CLI_IMAGE_CHECK_TIMEOUT,
)
.await?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!(
"podman image exists {image_ref} exited with {code}: {stderr}"
"podman image inspect {image_ref} exited with {code}: {stderr}"
))
}
None => Err(anyhow::anyhow!(
"podman image exists {image_ref} terminated by signal"
"podman image inspect {image_ref} terminated by signal"
)),
}
}