nucleus/security/
landlock.rs1use crate::error::{NucleusError, Result};
2use landlock::{
3 Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4 RulesetStatus, ABI,
5};
6use tracing::{debug, info, warn};
7
8const TARGET_ABI: ABI = ABI::V5;
11
12const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
18
19pub struct LandlockManager {
29 applied: bool,
30 extra_rw_paths: Vec<String>,
32}
33
34impl LandlockManager {
35 pub fn new() -> Self {
36 Self {
37 applied: false,
38 extra_rw_paths: Vec::new(),
39 }
40 }
41
42 pub fn add_rw_path(&mut self, path: &str) {
45 self.extra_rw_paths.push(path.to_string());
46 }
47
48 pub fn apply_container_policy(&mut self) -> Result<bool> {
62 self.apply_container_policy_with_mode(false)
63 }
64
65 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
71 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
75 let target_access = AccessFs::from_all(TARGET_ABI);
76
77 if min_access != target_access {
80 info!(
81 "Landlock ABI: target={:?}, minimum_production={:?}",
82 TARGET_ABI, MINIMUM_PRODUCTION_ABI
83 );
84 }
85
86 match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
91 Ok(_) => {
92 info!("Landlock ABI >= V3 confirmed");
93 Ok(())
94 }
95 Err(e) => {
96 let msg = format!(
97 "Kernel Landlock ABI is below minimum required version (V3): {}",
98 e
99 );
100 if production_mode {
101 Err(ll_err(e))
102 } else {
103 warn!("{}", msg);
104 Ok(())
105 }
106 }
107 }
108 }
109
110 pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
115 if self.applied {
116 debug!("Landlock policy already applied, skipping");
117 return Ok(true);
118 }
119
120 info!("Applying Landlock filesystem policy");
121
122 match self.build_and_restrict() {
123 Ok(status) => match status {
124 RulesetStatus::FullyEnforced => {
125 self.applied = true;
126 info!("Landlock policy fully enforced");
127 Ok(true)
128 }
129 RulesetStatus::PartiallyEnforced => {
130 if best_effort {
131 self.applied = true;
132 info!(
133 "Landlock policy partially enforced (kernel lacks some access rights)"
134 );
135 Ok(true)
136 } else {
137 Err(NucleusError::LandlockError(
138 "Landlock policy only partially enforced; strict mode requires full target ABI support".to_string(),
139 ))
140 }
141 }
142 RulesetStatus::NotEnforced => {
143 if best_effort {
144 warn!("Landlock not enforced (kernel does not support Landlock)");
145 Ok(false)
146 } else {
147 Err(NucleusError::LandlockError(
148 "Landlock not enforced (kernel does not support Landlock)".to_string(),
149 ))
150 }
151 }
152 },
153 Err(e) => {
154 if best_effort {
155 warn!(
156 "Failed to apply Landlock policy: {} (continuing without Landlock)",
157 e
158 );
159 Ok(false)
160 } else {
161 Err(e)
162 }
163 }
164 }
165 }
166
167 fn build_and_restrict(&self) -> Result<RulesetStatus> {
169 let access_all = AccessFs::from_all(TARGET_ABI);
170 let access_read = AccessFs::from_read(TARGET_ABI);
171
172 let access_read_exec = access_read | AccessFs::Execute;
174
175 let mut access_tmp = access_all;
178 access_tmp.remove(AccessFs::Execute);
179
180 let mut ruleset = Ruleset::default()
181 .handle_access(access_all)
182 .map_err(ll_err)?
183 .create()
184 .map_err(ll_err)?;
185
186 if let Ok(fd) = PathFd::new("/") {
189 ruleset = ruleset
190 .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
191 .map_err(ll_err)?;
192 }
193
194 const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
197 for path in MANDATORY_PATHS {
198 if !std::path::Path::new(path).exists() {
199 warn!(
200 "Landlock: mandatory path {} does not exist; container may not function correctly",
201 path
202 );
203 }
204 }
205
206 for path in &["/bin", "/usr", "/sbin"] {
208 if let Ok(fd) = PathFd::new(path) {
209 ruleset = ruleset
210 .add_rule(PathBeneath::new(fd, access_read_exec))
211 .map_err(ll_err)?;
212 }
213 }
214
215 for path in &["/lib", "/lib64", "/lib32"] {
217 if let Ok(fd) = PathFd::new(path) {
218 ruleset = ruleset
219 .add_rule(PathBeneath::new(fd, access_read))
220 .map_err(ll_err)?;
221 }
222 }
223
224 for path in &["/etc", "/dev", "/proc"] {
226 if let Ok(fd) = PathFd::new(path) {
227 ruleset = ruleset
228 .add_rule(PathBeneath::new(fd, access_read))
229 .map_err(ll_err)?;
230 }
231 }
232
233 if let Ok(fd) = PathFd::new("/dev/shm") {
237 ruleset = ruleset
238 .add_rule(PathBeneath::new(fd, access_tmp))
239 .map_err(ll_err)?;
240 }
241
242 if let Ok(fd) = PathFd::new("/tmp") {
244 ruleset = ruleset
245 .add_rule(PathBeneath::new(fd, access_tmp))
246 .map_err(ll_err)?;
247 }
248
249 if let Ok(fd) = PathFd::new("/nix/store") {
251 ruleset = ruleset
252 .add_rule(PathBeneath::new(fd, access_read_exec))
253 .map_err(ll_err)?;
254 }
255
256 if let Ok(fd) = PathFd::new("/run/secrets") {
258 ruleset = ruleset
259 .add_rule(PathBeneath::new(fd, access_read))
260 .map_err(ll_err)?;
261 }
262
263 if let Ok(fd) = PathFd::new("/context") {
265 ruleset = ruleset
266 .add_rule(PathBeneath::new(fd, access_read))
267 .map_err(ll_err)?;
268 }
269
270 for path in &self.extra_rw_paths {
273 if let Ok(fd) = PathFd::new(path) {
274 debug!("Landlock: granting rw access to volume path {:?}", path);
275 ruleset = ruleset
276 .add_rule(PathBeneath::new(fd, access_tmp))
277 .map_err(ll_err)?;
278 }
279 }
280
281 let status = ruleset.restrict_self().map_err(ll_err)?;
282 Ok(status.ruleset)
283 }
284
285 pub fn is_applied(&self) -> bool {
287 self.applied
288 }
289}
290
291impl Default for LandlockManager {
292 fn default() -> Self {
293 Self::new()
294 }
295}
296
297fn ll_err(e: RulesetError) -> NucleusError {
299 NucleusError::LandlockError(e.to_string())
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_landlock_manager_initial_state() {
308 let mgr = LandlockManager::new();
309 assert!(!mgr.is_applied());
310 }
311
312 #[test]
313 fn test_apply_idempotent() {
314 let mut mgr = LandlockManager::new();
315 let _ = mgr.apply_container_policy_with_mode(true);
317 let result = mgr.apply_container_policy_with_mode(true);
319 assert!(result.is_ok());
320 }
321
322 #[test]
323 fn test_best_effort_on_unsupported_kernel() {
324 let mut mgr = LandlockManager::new();
325 let result = mgr.apply_container_policy_with_mode(true);
327 assert!(result.is_ok());
328 }
329
330 fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
333 let fn_start = source
334 .find(fn_signature)
335 .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
336 let after = &source[fn_start..];
337 let open = after
338 .find('{')
339 .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
340 let mut depth = 0u32;
341 let mut end = open;
342 for (i, ch) in after[open..].char_indices() {
343 match ch {
344 '{' => depth += 1,
345 '}' => {
346 depth -= 1;
347 if depth == 0 {
348 end = open + i + 1;
349 break;
350 }
351 }
352 _ => {}
353 }
354 }
355 &after[..end]
356 }
357
358 #[test]
359 fn test_policy_covers_nix_store_and_secrets() {
360 let source = include_str!("landlock.rs");
366 let fn_body = extract_fn_body(source, "fn build_and_restrict");
367 assert!(
368 fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
369 "Landlock build_and_restrict must include a rule for /nix/store or /nix"
370 );
371 assert!(
372 fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
373 "Landlock build_and_restrict must include a rule for /run/secrets"
374 );
375 }
376
377 #[test]
378 fn test_tmp_access_excludes_execute() {
379 let access_all = AccessFs::from_all(TARGET_ABI);
383 let mut access_tmp = access_all;
384 access_tmp.remove(AccessFs::Execute);
385 assert!(!access_tmp.contains(AccessFs::Execute));
386 assert!(access_tmp.contains(AccessFs::WriteFile));
388 assert!(access_tmp.contains(AccessFs::RemoveFile));
389 }
390
391 #[test]
392 fn test_not_enforced_returns_error_in_strict_mode() {
393 let source = include_str!("landlock.rs");
395 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
396 let not_enforced_start = fn_body
398 .find("NotEnforced")
399 .expect("function must handle NotEnforced status");
400 let rest = &fn_body[not_enforced_start..];
402 let arm_end = rest
403 .find("RestrictionStatus::")
404 .unwrap_or(rest.len().min(500));
405 let not_enforced_block = &rest[..arm_end];
406 assert!(
407 not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
408 "NotEnforced must return Err when best_effort=false. Block: {}",
409 not_enforced_block
410 );
411 }
412
413 #[test]
414 fn test_partially_enforced_returns_error_in_strict_mode() {
415 let source = include_str!("landlock.rs");
416 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
417 let partial_start = fn_body
418 .find("PartiallyEnforced")
419 .expect("function must handle PartiallyEnforced status");
420 let rest = &fn_body[partial_start..];
421 let arm_end = rest.find("NotEnforced").unwrap_or(rest.len().min(500));
422 let partial_block = &rest[..arm_end];
423 assert!(
424 partial_block.contains("best_effort") && partial_block.contains("Err"),
425 "PartiallyEnforced must return Err when best_effort=false. Block: {}",
426 partial_block
427 );
428 }
429}