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:
2026-09-30 21:16:53 +00:00
co-authored by Claude Opus 5.5
parent 02b840f2d1
commit e42bd26ec7
8 changed files with 167 additions and 1 deletions
+117 -1
View File
@@ -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#"
+1
View File
@@ -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,
+3
View File
@@ -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 {