Skip to content

Commit e346175

Browse files
committed
sandlock-oci: wire networking and HTTP ACL from io.sandlock.<section>.<field> annotations
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 766af10 commit e346175

1 file changed

Lines changed: 304 additions & 8 deletions

File tree

crates/sandlock-oci/src/policy.rs

Lines changed: 304 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,103 @@ pub struct OciPolicy {
6767
/// `builder.user(...)`; core decides whether a user namespace is actually
6868
/// needed (it self-skips when the identity already matches the runtime).
6969
pub run_user: Option<RunAs>,
70+
71+
// Networking + HTTP ACL, sourced from `io.sandlock.*` OCI annotations (the
72+
// runtime spec has no native field for these). Multi-value lists use `;` as
73+
// the separator. Enforcement is the same seccomp-notif machinery the
74+
// supervisor already runs for filesystem/proc interception.
75+
/// Outbound allowlist (`io.sandlock.network.allow`).
76+
#[serde(default)]
77+
pub net_allow: Vec<String>,
78+
/// Outbound denylist (`io.sandlock.network.deny`).
79+
#[serde(default)]
80+
pub net_deny: Vec<String>,
81+
/// Bind allowlist port specs (`io.sandlock.network.allow_bind`).
82+
#[serde(default)]
83+
pub net_allow_bind: Vec<String>,
84+
/// Bind denylist port specs (`io.sandlock.network.deny_bind`).
85+
#[serde(default)]
86+
pub net_deny_bind: Vec<String>,
87+
/// Transparent port remapping for multi-sandbox isolation
88+
/// (`io.sandlock.network.port_remap`). Defaults to on so container
89+
/// servers can bind; the annotation is an explicit opt-out.
90+
#[serde(default = "default_port_remap")]
91+
pub port_remap: bool,
92+
/// HTTP ACL allow rules (`io.sandlock.http.allow`).
93+
#[serde(default)]
94+
pub http_allow: Vec<String>,
95+
/// HTTP ACL deny rules (`io.sandlock.http.deny`).
96+
#[serde(default)]
97+
pub http_deny: Vec<String>,
98+
/// TCP ports to intercept for the HTTP ACL (`io.sandlock.http.ports`).
99+
#[serde(default)]
100+
pub http_ports: Vec<u16>,
101+
/// Host-side PEM CA cert for HTTPS MITM (`io.sandlock.config.http_ca`), bundle-relative.
102+
#[serde(default)]
103+
pub http_ca: Option<PathBuf>,
104+
/// Host-side PEM CA key for HTTPS MITM (`io.sandlock.config.http_key`), bundle-relative.
105+
#[serde(default)]
106+
pub http_key: Option<PathBuf>,
107+
/// Sandbox-virtual trust bundles to splice the MITM CA into (`io.sandlock.config.http_inject_ca`).
108+
#[serde(default)]
109+
pub http_inject_ca: Vec<PathBuf>,
110+
/// Host-side path to write the active MITM CA cert (`io.sandlock.config.http_ca_out`), bundle-relative.
111+
#[serde(default)]
112+
pub http_ca_out: Option<PathBuf>,
113+
}
114+
115+
/// Annotation key prefix for sandlock-specific OCI configuration.
116+
const SANDLOCK_ANN_PREFIX: &str = "io.sandlock.";
117+
118+
/// Look up a `io.sandlock.<key>` annotation value.
119+
fn annotation<'a>(
120+
annotations: Option<&'a HashMap<String, String>>,
121+
key: &str,
122+
) -> Option<&'a str> {
123+
annotations
124+
.and_then(|m| m.get(&format!("{SANDLOCK_ANN_PREFIX}{key}")))
125+
.map(String::as_str)
126+
}
127+
128+
/// Split a `;`-separated annotation list into trimmed, non-empty items.
129+
///
130+
/// `;` is the separator (not `,`) because `,` is significant *inside* net specs
131+
/// (`github.com:22,443` is two ports on one host) and HTTP rules contain spaces,
132+
/// so `;` is the only delimiter unambiguous across every grammar.
133+
fn annotation_list(value: Option<&str>) -> Vec<String> {
134+
value
135+
.map(|s| {
136+
s.split(';')
137+
.map(str::trim)
138+
.filter(|item| !item.is_empty())
139+
.map(String::from)
140+
.collect()
141+
})
142+
.unwrap_or_default()
143+
}
144+
145+
/// Serde default for [`OciPolicy::port_remap`]: on, matching `from_spec`.
146+
fn default_port_remap() -> bool {
147+
true
148+
}
149+
150+
/// Parse a boolean annotation: `true`/`1`/`yes`/`on` (case-insensitive) are true.
151+
fn annotation_bool(value: Option<&str>) -> bool {
152+
matches!(
153+
value.map(|s| s.trim().to_ascii_lowercase()).as_deref(),
154+
Some("true" | "1" | "yes" | "on")
155+
)
156+
}
157+
158+
/// Resolve a host-side annotation path: absolute paths as-is, relative ones
159+
/// against the OCI bundle directory so CA material can travel with the bundle.
160+
fn resolve_host_path(bundle: &Path, value: &str) -> PathBuf {
161+
let p = PathBuf::from(value);
162+
if p.is_absolute() {
163+
p
164+
} else {
165+
bundle.join(p)
166+
}
70167
}
71168

72169
impl OciPolicy {
@@ -192,6 +289,49 @@ impl OciPolicy {
192289
RunAs { uid: u.uid(), gid: u.gid() }
193290
});
194291

292+
// Networking + HTTP ACL from `io.sandlock.*` annotations. The OCI runtime
293+
// spec carries no native outbound allow/deny or transparent-proxy config,
294+
// so it travels in annotations; `;` separates multi-value lists.
295+
let ann = spec.annotations().as_ref();
296+
let net_allow = annotation_list(annotation(ann, "network.allow"));
297+
let net_deny = annotation_list(annotation(ann, "network.deny"));
298+
let net_allow_bind = annotation_list(annotation(ann, "network.allow_bind"));
299+
let net_deny_bind = annotation_list(annotation(ann, "network.deny_bind"));
300+
// Container workloads legitimately run servers (nginx, etc.). sandlock
301+
// gates bind() by default-deny (Landlock BIND_TCP), so without port
302+
// remapping an in-container server fails bind() with EACCES and the
303+
// container exits, which in turn hangs any readiness/verification exec
304+
// that waits on it. Remapped binds are emulated on-behalf and succeed,
305+
// using the requested port when free and a fresh host port only on
306+
// conflict, so co-located sandboxes never collide on a host port.
307+
// Default on; `io.sandlock.network.port_remap: "false"` opts out.
308+
let port_remap =
309+
annotation(ann, "network.port_remap").is_none_or(|v| annotation_bool(Some(v)));
310+
let http_allow = annotation_list(annotation(ann, "http.allow"));
311+
let http_deny = annotation_list(annotation(ann, "http.deny"));
312+
let http_ports = annotation_list(annotation(ann, "http.ports"))
313+
.iter()
314+
.map(|s| {
315+
s.parse::<u16>().map_err(|_| {
316+
anyhow::anyhow!(
317+
"invalid io.sandlock.http.ports entry {:?}: expected a port number",
318+
s
319+
)
320+
})
321+
})
322+
.collect::<Result<Vec<u16>>>()?;
323+
// CA cert/key/output are host-side files the supervisor reads or writes,
324+
// so resolve them relative to the bundle. The inject targets are
325+
// sandbox-virtual paths (e.g. /etc/ssl/certs/ca-certificates.crt) that
326+
// core resolves through the chroot/mounts, so pass them through as-is.
327+
let http_ca = annotation(ann, "config.http_ca").map(|p| resolve_host_path(bundle, p));
328+
let http_key = annotation(ann, "config.http_key").map(|p| resolve_host_path(bundle, p));
329+
let http_ca_out = annotation(ann, "config.http_ca_out").map(|p| resolve_host_path(bundle, p));
330+
let http_inject_ca = annotation_list(annotation(ann, "config.http_inject_ca"))
331+
.into_iter()
332+
.map(PathBuf::from)
333+
.collect();
334+
195335
Ok(OciPolicy {
196336
rootfs,
197337
fs_read,
@@ -206,6 +346,18 @@ impl OciPolicy {
206346
cpu_cores,
207347
scratch_dirs,
208348
run_user,
349+
net_allow,
350+
net_deny,
351+
net_allow_bind,
352+
net_deny_bind,
353+
port_remap,
354+
http_allow,
355+
http_deny,
356+
http_ports,
357+
http_ca,
358+
http_key,
359+
http_inject_ca,
360+
http_ca_out,
209361
})
210362
}
211363

@@ -268,14 +420,45 @@ impl OciPolicy {
268420
builder = builder.user(ru.uid, ru.gid);
269421
}
270422

271-
// Container workloads legitimately run servers (nginx, etc.). sandlock
272-
// gates bind() by default-deny (Landlock BIND_TCP), so without this an
273-
// in-container server fails bind() with EACCES and the container exits,
274-
// which in turn hangs any readiness/verification exec that waits on it.
275-
// Enable port remapping: binds are emulated on-behalf and succeed,
276-
// using the requested port when free and a fresh host port only on
277-
// conflict, so co-located sandboxes never collide on a host port.
278-
builder = builder.port_remap(true);
423+
// Networking + HTTP ACL. Each is a no-op when its annotation was absent.
424+
// The network seccomp handlers register automatically once the policy
425+
// carries destination/bind rules, so no OCI-side enforcement is needed.
426+
for spec in &self.net_allow {
427+
builder = builder.net_allow(spec.clone());
428+
}
429+
for spec in &self.net_deny {
430+
builder = builder.net_deny(spec.clone());
431+
}
432+
for spec in &self.net_allow_bind {
433+
builder = builder.net_allow_bind(spec.clone());
434+
}
435+
for spec in &self.net_deny_bind {
436+
builder = builder.net_deny_bind(spec.clone());
437+
}
438+
if self.port_remap {
439+
builder = builder.port_remap(true);
440+
}
441+
for rule in &self.http_allow {
442+
builder = builder.http_allow(rule);
443+
}
444+
for rule in &self.http_deny {
445+
builder = builder.http_deny(rule);
446+
}
447+
for port in &self.http_ports {
448+
builder = builder.http_port(*port);
449+
}
450+
if let Some(ref ca) = self.http_ca {
451+
builder = builder.http_ca(ca);
452+
}
453+
if let Some(ref key) = self.http_key {
454+
builder = builder.http_key(key);
455+
}
456+
for path in &self.http_inject_ca {
457+
builder = builder.http_inject_ca(path);
458+
}
459+
if let Some(ref out) = self.http_ca_out {
460+
builder = builder.http_ca_out(out);
461+
}
279462

280463
builder.build_unchecked().map_err(Into::into)
281464
}
@@ -481,6 +664,119 @@ mod tests {
481664
.unwrap()
482665
}
483666

667+
fn spec_with_annotations(pairs: &[(&str, &str)]) -> Spec {
668+
let mut annotations = HashMap::new();
669+
for (k, v) in pairs {
670+
annotations.insert(k.to_string(), v.to_string());
671+
}
672+
SpecBuilder::default()
673+
.version("1.0.2")
674+
.root(RootBuilder::default().path("rootfs").readonly(false).build().unwrap())
675+
.annotations(annotations)
676+
.build()
677+
.unwrap()
678+
}
679+
680+
#[test]
681+
fn from_spec_parses_network_annotations() {
682+
let dir = tempdir().unwrap();
683+
let bundle = dir.path();
684+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
685+
686+
let spec = spec_with_annotations(&[
687+
("io.sandlock.network.allow", "api.openai.com:443; github.com:22,443"),
688+
("io.sandlock.network.deny", "10.0.0.0/8"),
689+
("io.sandlock.network.allow_bind", "8080,9000-9005"),
690+
("io.sandlock.network.deny_bind", "22"),
691+
("io.sandlock.network.port_remap", "true"),
692+
]);
693+
let policy = OciPolicy::from_spec(&spec, bundle, "test").unwrap();
694+
695+
// `;` separates rules; `,` stays inside a single rule.
696+
assert_eq!(policy.net_allow, vec!["api.openai.com:443", "github.com:22,443"]);
697+
assert_eq!(policy.net_deny, vec!["10.0.0.0/8"]);
698+
assert_eq!(policy.net_allow_bind, vec!["8080,9000-9005"]);
699+
assert_eq!(policy.net_deny_bind, vec!["22"]);
700+
assert!(policy.port_remap);
701+
}
702+
703+
#[test]
704+
fn from_spec_parses_http_annotations_and_resolves_paths() {
705+
let dir = tempdir().unwrap();
706+
let bundle = dir.path();
707+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
708+
709+
let spec = spec_with_annotations(&[
710+
("io.sandlock.http.allow", "GET api.internal/v1/*; POST api.internal/submit"),
711+
("io.sandlock.http.deny", "* */admin/*"),
712+
("io.sandlock.http.ports", "80;8080"),
713+
("io.sandlock.config.http_ca", "ca.pem"),
714+
("io.sandlock.config.http_key", "/abs/key.pem"),
715+
("io.sandlock.config.http_inject_ca", "/etc/ssl/certs/ca-certificates.crt"),
716+
]);
717+
let policy = OciPolicy::from_spec(&spec, bundle, "test").unwrap();
718+
719+
assert_eq!(policy.http_allow, vec!["GET api.internal/v1/*", "POST api.internal/submit"]);
720+
assert_eq!(policy.http_deny, vec!["* */admin/*"]);
721+
assert_eq!(policy.http_ports, vec![80, 8080]);
722+
// Relative host path resolves against the bundle; absolute stays as-is.
723+
assert_eq!(policy.http_ca, Some(bundle.join("ca.pem")));
724+
assert_eq!(policy.http_key, Some(PathBuf::from("/abs/key.pem")));
725+
// Inject targets are sandbox-virtual, passed through unchanged.
726+
assert_eq!(policy.http_inject_ca, vec![PathBuf::from("/etc/ssl/certs/ca-certificates.crt")]);
727+
}
728+
729+
#[test]
730+
fn from_spec_rejects_malformed_http_port() {
731+
let dir = tempdir().unwrap();
732+
let bundle = dir.path();
733+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
734+
let spec = spec_with_annotations(&[("io.sandlock.http.ports", "not-a-port")]);
735+
assert!(OciPolicy::from_spec(&spec, bundle, "test").is_err());
736+
}
737+
738+
#[test]
739+
fn from_spec_without_network_annotations_is_empty() {
740+
let dir = tempdir().unwrap();
741+
let bundle = dir.path();
742+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
743+
let policy = OciPolicy::from_spec(&minimal_spec(), bundle, "test").unwrap();
744+
assert!(policy.net_allow.is_empty());
745+
assert!(policy.net_deny.is_empty());
746+
// Port remapping stays on by default so container servers can bind.
747+
assert!(policy.port_remap);
748+
assert!(policy.http_ports.is_empty());
749+
assert!(policy.http_ca.is_none());
750+
}
751+
752+
#[test]
753+
fn port_remap_annotation_opts_out() {
754+
let dir = tempdir().unwrap();
755+
let bundle = dir.path();
756+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
757+
let spec = spec_with_annotations(&[("io.sandlock.network.port_remap", "false")]);
758+
let policy = OciPolicy::from_spec(&spec, bundle, "test").unwrap();
759+
assert!(!policy.port_remap);
760+
let sandbox = policy.to_sandbox().unwrap();
761+
assert!(!sandbox.port_remap);
762+
}
763+
764+
#[test]
765+
fn to_sandbox_wires_network_policy() {
766+
let dir = tempdir().unwrap();
767+
let bundle = dir.path();
768+
fs::create_dir_all(bundle.join("rootfs")).unwrap();
769+
let spec = spec_with_annotations(&[
770+
("io.sandlock.network.allow", "api.openai.com:443"),
771+
("io.sandlock.network.port_remap", "true"),
772+
]);
773+
let policy = OciPolicy::from_spec(&spec, bundle, "test").unwrap();
774+
let sandbox = policy.to_sandbox().unwrap();
775+
// The builder parsed the allow spec into a rule, and the bool passed through.
776+
assert!(!sandbox.net_allow.is_empty());
777+
assert!(sandbox.port_remap);
778+
}
779+
484780
#[test]
485781
fn from_spec_parses_rootfs_and_cwd() {
486782
let dir = tempdir().unwrap();

0 commit comments

Comments
 (0)