feat(manifest): add allow-listed per-netns sysctls primitive
Routing apps (a rootless VPN exit) need packet forwarding in their own network namespace, but /proc/sys is read-only inside a rootless container, so it can only be set at create time. Add `app.sysctls`, allow-listed to net.ipv4.ip_forward / net.ipv6.conf.all.forwarding with values "0"/"1", and rejected under host networking where it would change the host. Rendered on all three create paths: podman CLI --sysctl, the libpod spec `sysctl` map, and Quadlet `Sysctl=`. Absent by default and not serialized when empty, so existing manifests and units are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,8 @@ pub struct QuadletUnit {
|
||||
/// for rotation-drift detection.
|
||||
pub labels: Vec<(String, String)>,
|
||||
pub devices: Vec<String>,
|
||||
/// 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<String>,
|
||||
pub entrypoint: Option<Vec<String>>,
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String, String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub interfaces: HashMap<String, AppInterface>,
|
||||
|
||||
@@ -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<String, String>,
|
||||
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#"
|
||||
|
||||
@@ -439,6 +439,7 @@ impl PodmanClient {
|
||||
"devices": manifest.app.devices.iter().map(|d| {
|
||||
serde_json::json!({"path": d})
|
||||
}).collect::<Vec<_>>(),
|
||||
"sysctl": manifest.app.sysctls,
|
||||
"resource_limits": resource_limits,
|
||||
"cap_add": cap_add,
|
||||
"cap_drop": cap_drop,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -43,6 +43,8 @@ PublishPort=<bind>:<host>:<container>/<proto>
|
||||
Environment=<KEY>=<value> # non-secret env only
|
||||
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
||||
Volume=<source>:<target><opts>
|
||||
AddDevice=<path> # manifest devices
|
||||
Sysctl=<key>=<value> # manifest sysctls (own netns, allow-listed)
|
||||
ReadOnly=true # when security.readonly_root
|
||||
NoNewPrivileges=true # when security.no_new_privileges
|
||||
HealthCmd=<cmd> # from the health_check block
|
||||
|
||||
Reference in New Issue
Block a user