diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 1d5ff4f8..39832a7c 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -176,6 +176,8 @@ pub struct QuadletUnit { /// for rotation-drift detection. pub labels: Vec<(String, String)>, pub devices: Vec, + /// Namespaced sysctls (`Sysctl=k=v`), already allow-listed by the manifest. + pub sysctls: Vec<(String, String)>, pub add_hosts: Vec<(String, String)>, pub network_aliases: Vec, pub entrypoint: Option>, @@ -307,6 +309,9 @@ impl QuadletUnit { for dev in &self.devices { let _ = writeln!(s, "AddDevice={dev}"); } + for (k, v) in &self.sysctls { + let _ = writeln!(s, "Sysctl={k}={v}"); + } for (name, ip) in &self.add_hosts { let _ = writeln!(s, "AddHost={name}:{ip}"); } @@ -521,6 +526,11 @@ impl QuadletUnit { }) .collect(), devices: app.devices.clone(), + sysctls: app + .sysctls + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], // Container always answers to its own name; manifest extras add the // short hostnames peers bake in (e.g. indeedhub api/minio/relay). @@ -1487,6 +1497,7 @@ app: "RELAY_NAME=Archipelago Nostr Relay".into(), ], devices: vec!["/dev/kvm".into()], + sysctls: vec![("net.ipv4.ip_forward".into(), "1".into())], add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]), command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()], @@ -1503,6 +1514,7 @@ app: assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret")); assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\"")); assert!(s.contains("AddDevice=/dev/kvm")); + assert!(s.contains("Sysctl=net.ipv4.ip_forward=1")); assert!(s.contains("AddHost=host.archipelago:10.89.0.1")); assert!(s.contains("ReadOnly=true")); assert!(s.contains("NoNewPrivileges=true")); @@ -1524,6 +1536,7 @@ app: assert!(!s.contains("PublishPort=")); assert!(!s.contains("Environment=")); assert!(!s.contains("AddDevice=")); + assert!(!s.contains("Sysctl=")); assert!(!s.contains("AddHost=")); assert!(!s.contains("ReadOnly=")); assert!(!s.contains("NoNewPrivileges=")); @@ -1621,6 +1634,31 @@ app: assert!(!s.contains("Network=host")); } + #[test] + fn from_manifest_renders_namespaced_sysctls() { + let yaml = r#" +app: + id: vpn-exit + name: VPN Exit + version: 1.0.0 + container: + image: test/vpn:1.0.0 + network: pasta + devices: [/dev/net/tun] + sysctls: + net.ipv4.ip_forward: "1" + security: + capabilities: [NET_ADMIN, NET_RAW] +"#; + let m = AppManifest::parse(yaml).expect("manifest must parse"); + let s = QuadletUnit::from_manifest(&m, "vpn-exit").render(); + + assert!(s.contains("Network=pasta")); + assert!(s.contains("AddDevice=/dev/net/tun")); + assert!(s.contains("Sysctl=net.ipv4.ip_forward=1")); + assert!(s.contains("AddCapability=NET_ADMIN")); + } + #[test] fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() { let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")) diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index f9ffe101..41dee0ef 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use thiserror::Error; #[derive(Debug, Error)] @@ -54,6 +54,12 @@ pub struct AppDefinition { #[serde(default)] pub devices: Vec, + /// Namespaced kernel parameters for the app's OWN network namespace + /// (podman `--sysctl`). Allow-listed to [`ALLOWED_SYSCTLS`] and rejected + /// under host networking, where they would change the host itself. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub sysctls: BTreeMap, + #[serde(default)] pub interfaces: HashMap, @@ -1009,6 +1015,11 @@ impl AppManifest { } validate_environment(&self.app.environment)?; validate_devices(&self.app.devices)?; + validate_sysctls( + &self.app.sysctls, + self.app.container.network.as_deref(), + &self.app.security.network_policy, + )?; // Volume tmpfs_options: only meaningful for type: tmpfs. for (i, v) in self.app.volumes.iter().enumerate() { @@ -1342,6 +1353,45 @@ fn validate_devices(devices: &[String]) -> Result<(), ManifestError> { Ok(()) } +/// Sysctls an app may set. Each is scoped to the container's own network +/// namespace, so it cannot reach the host. Packet forwarding is what a +/// routing app (a VPN exit) needs, and rootless `/proc/sys` is read-only +/// inside the container, so it can only be set at create time. +pub const ALLOWED_SYSCTLS: &[&str] = &["net.ipv4.ip_forward", "net.ipv6.conf.all.forwarding"]; + +fn validate_sysctls( + sysctls: &BTreeMap, + network: Option<&str>, + network_policy: &str, +) -> Result<(), ManifestError> { + if sysctls.is_empty() { + return Ok(()); + } + let host_network = match network { + Some(n) => n == "host", + None => network_policy == "host", + }; + if host_network { + return Err(ManifestError::Invalid( + "sysctls require the app's own network namespace, not host networking".into(), + )); + } + for (key, value) in sysctls { + if !ALLOWED_SYSCTLS.contains(&key.as_str()) { + return Err(ManifestError::Invalid(format!( + "sysctls.{key} is not allowed (allowed: {})", + ALLOWED_SYSCTLS.join(", ") + ))); + } + if value != "0" && value != "1" { + return Err(ManifestError::Invalid(format!( + "sysctls.{key} must be \"0\" or \"1\"" + ))); + } + } + Ok(()) +} + fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> { let path = std::path::Path::new(source); if !path.is_absolute() { @@ -2784,6 +2834,72 @@ app: assert_eq!(m.app.ports[2].bind, ""); } + fn sysctl_manifest(network: &str, sysctls: &str) -> String { + format!( + r#" +app: + id: sysctl-app + name: Sysctl App + version: 1.0.0 + container: + image: test/image:1.0.0 + network: {network} + sysctls: +{sysctls} +"# + ) + } + + #[test] + fn forwarding_sysctls_parse_in_own_netns() { + let m = AppManifest::parse(&sysctl_manifest( + "pasta", + " net.ipv4.ip_forward: \"1\"\n net.ipv6.conf.all.forwarding: \"0\"", + )) + .expect("allow-listed forwarding sysctls must validate"); + assert_eq!(m.app.sysctls["net.ipv4.ip_forward"], "1"); + assert_eq!(m.app.sysctls["net.ipv6.conf.all.forwarding"], "0"); + } + + #[test] + fn sysctls_absent_by_default_and_not_serialized() { + let m = AppManifest::parse( + "app:\n id: plain\n name: Plain\n version: 1.0.0\n container:\n image: test/image:1.0.0\n", + ) + .unwrap(); + assert!(m.app.sysctls.is_empty()); + assert!(!serde_yaml::to_string(&m).unwrap().contains("sysctls")); + } + + #[test] + fn unsafe_sysctls_are_rejected() { + let cases = [ + ( + sysctl_manifest("pasta", " kernel.core_pattern: \"|/bin/sh\""), + "not allowed", + ), + ( + sysctl_manifest("pasta", " net.ipv4.ip_forward: \"2\""), + "must be \"0\" or \"1\"", + ), + ( + sysctl_manifest("host", " net.ipv4.ip_forward: \"1\""), + "own network namespace", + ), + ( + // No explicit network: the host policy still means the host netns. + sysctl_manifest("pasta", " net.ipv4.ip_forward: \"1\"") + .replace(" network: pasta\n", "") + .replace(" sysctls:", " security:\n network_policy: host\n sysctls:"), + "own network namespace", + ), + ]; + for (yaml, expected) in cases { + let msg = AppManifest::parse(&yaml).unwrap_err().to_string(); + assert!(msg.contains(expected), "expected '{expected}', got: {msg}"); + } + } + #[test] fn reviewed_host_bind_exceptions_parse() { let yaml = r#" diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index db8c9a55..28ab2914 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -439,6 +439,7 @@ impl PodmanClient { "devices": manifest.app.devices.iter().map(|d| { serde_json::json!({"path": d}) }).collect::>(), + "sysctl": manifest.app.sysctls, "resource_limits": resource_limits, "cap_add": cap_add, "cap_drop": cap_drop, diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 5b318a18..e12c844c 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -712,6 +712,9 @@ impl ContainerRuntime for DockerRuntime { for device in &manifest.app.devices { cmd.arg("--device").arg(device); } + for (key, value) in &manifest.app.sysctls { + cmd.arg("--sysctl").arg(format!("{key}={value}")); + } // Environment variables for env in &manifest.app.environment { diff --git a/docs/APP-PACKAGING-MIGRATION-PLAN.md b/docs/APP-PACKAGING-MIGRATION-PLAN.md index b72410d4..15001fc4 100644 --- a/docs/APP-PACKAGING-MIGRATION-PLAN.md +++ b/docs/APP-PACKAGING-MIGRATION-PLAN.md @@ -35,6 +35,7 @@ As of the current `1.8-alpha` workstream: - Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration). - Local image builds are represented with `container.build`; pulled images are represented with `container.image`. - Data ownership repair is represented with `container.data_uid`. +- Per-app network-namespace kernel parameters are represented with `app.sysctls`, allow-listed to packet forwarding (added for rootless VPN exits such as nostr-vpn). - Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`. - Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`. - App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet. diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index b364e509..f4d034f6 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -124,6 +124,7 @@ app: | `app.environment` | Static `KEY=value` environment entries | | `app.health_check` | HTTP or TCP health check settings | | `app.devices` | Explicit device paths | +| `app.sysctls` | Namespaced packet-forwarding sysctls for the app's own network namespace (allow-listed; not with host networking) | | `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) | | `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` | diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index 68ce533b..667b4285 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -74,6 +74,7 @@ because a wrong source produces a confident wrong verdict. | `environment` | list of string | — | `- KEY=value` pairs (static). | | `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. | | `devices` | list of string | — | Host device paths; must start with `/dev/`. | +| `sysctls` | map | — | Kernel parameters for the app's **own** network namespace (podman `--sysctl`, Quadlet `Sysctl=`). Allow-list: `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`; values `"0"`/`"1"`. Rejected under host networking. Needed by routing apps because rootless `/proc/sys` is read-only inside the container. | | `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. | | `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). | | `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). | @@ -116,6 +117,9 @@ Validation (enforced at `AppManifest::validate()`): FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN). - `network_policy` must be exactly `isolated`, `bridge`, or `host`. - No `container:`/`ns:` network modes; devices must be `/dev/*`. +- `sysctls` keys must be on `ALLOWED_SYSCTLS` (packet forwarding only) and + need the app's own network namespace — never host networking, where they + would change the host. - Bind-mount sources are confined to `/var/lib/archipelago` (reviewed exceptions: the rootless podman socket and dbus). - `derived_env` templates may only use the placeholder allow-list; diff --git a/docs/quadlet-compilation.md b/docs/quadlet-compilation.md index a98aa486..40f8088c 100644 --- a/docs/quadlet-compilation.md +++ b/docs/quadlet-compilation.md @@ -43,6 +43,8 @@ PublishPort=::/ Environment== # non-secret env only Secret=,type=env,target= # secrets by REFERENCE, never value Volume=: +AddDevice= # manifest devices +Sysctl== # manifest sysctls (own netns, allow-listed) ReadOnly=true # when security.readonly_root NoNewPrivileges=true # when security.no_new_privileges HealthCmd= # from the health_check block