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}
31
32impl LandlockManager {
33 pub fn new() -> Self {
34 Self { applied: false }
35 }
36
37 pub fn apply_container_policy(&mut self) -> Result<bool> {
51 self.apply_container_policy_with_mode(false)
52 }
53
54 pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
60 let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
64 let target_access = AccessFs::from_all(TARGET_ABI);
65
66 if min_access != target_access {
69 info!(
70 "Landlock ABI: target={:?}, minimum_production={:?}",
71 TARGET_ABI, MINIMUM_PRODUCTION_ABI
72 );
73 }
74
75 match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
80 Ok(_) => {
81 info!("Landlock ABI >= V3 confirmed");
82 Ok(())
83 }
84 Err(e) => {
85 let msg = format!(
86 "Kernel Landlock ABI is below minimum required version (V3): {}",
87 e
88 );
89 if production_mode {
90 Err(ll_err(e))
91 } else {
92 warn!("{}", msg);
93 Ok(())
94 }
95 }
96 }
97 }
98
99 pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
104 if self.applied {
105 debug!("Landlock policy already applied, skipping");
106 return Ok(true);
107 }
108
109 info!("Applying Landlock filesystem policy");
110
111 match self.build_and_restrict() {
112 Ok(status) => match status {
113 RulesetStatus::FullyEnforced => {
114 self.applied = true;
115 info!("Landlock policy fully enforced");
116 Ok(true)
117 }
118 RulesetStatus::PartiallyEnforced => {
119 self.applied = true;
120 info!("Landlock policy partially enforced (kernel lacks some access rights)");
121 Ok(true)
122 }
123 RulesetStatus::NotEnforced => {
124 if best_effort {
125 warn!("Landlock not enforced (kernel does not support Landlock)");
126 Ok(false)
127 } else {
128 Err(NucleusError::LandlockError(
129 "Landlock not enforced (kernel does not support Landlock)".to_string(),
130 ))
131 }
132 }
133 },
134 Err(e) => {
135 if best_effort {
136 warn!(
137 "Failed to apply Landlock policy: {} (continuing without Landlock)",
138 e
139 );
140 Ok(false)
141 } else {
142 Err(e)
143 }
144 }
145 }
146 }
147
148 fn build_and_restrict(&self) -> Result<RulesetStatus> {
150 let access_all = AccessFs::from_all(TARGET_ABI);
151 let access_read = AccessFs::from_read(TARGET_ABI);
152
153 let access_read_exec = access_read | AccessFs::Execute;
155
156 let mut access_tmp = access_all;
159 access_tmp.remove(AccessFs::Execute);
160
161 let mut ruleset = Ruleset::default()
162 .handle_access(access_all)
163 .map_err(ll_err)?
164 .create()
165 .map_err(ll_err)?;
166
167 if let Ok(fd) = PathFd::new("/") {
170 ruleset = ruleset
171 .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
172 .map_err(ll_err)?;
173 }
174
175 const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
178 for path in MANDATORY_PATHS {
179 if !std::path::Path::new(path).exists() {
180 warn!(
181 "Landlock: mandatory path {} does not exist; container may not function correctly",
182 path
183 );
184 }
185 }
186
187 for path in &["/bin", "/usr", "/sbin"] {
189 if let Ok(fd) = PathFd::new(path) {
190 ruleset = ruleset
191 .add_rule(PathBeneath::new(fd, access_read_exec))
192 .map_err(ll_err)?;
193 }
194 }
195
196 for path in &["/lib", "/lib64", "/lib32"] {
198 if let Ok(fd) = PathFd::new(path) {
199 ruleset = ruleset
200 .add_rule(PathBeneath::new(fd, access_read))
201 .map_err(ll_err)?;
202 }
203 }
204
205 for path in &["/etc", "/dev", "/proc"] {
207 if let Ok(fd) = PathFd::new(path) {
208 ruleset = ruleset
209 .add_rule(PathBeneath::new(fd, access_read))
210 .map_err(ll_err)?;
211 }
212 }
213
214 if let Ok(fd) = PathFd::new("/tmp") {
216 ruleset = ruleset
217 .add_rule(PathBeneath::new(fd, access_tmp))
218 .map_err(ll_err)?;
219 }
220
221 if let Ok(fd) = PathFd::new("/nix/store") {
223 ruleset = ruleset
224 .add_rule(PathBeneath::new(fd, access_read_exec))
225 .map_err(ll_err)?;
226 }
227
228 if let Ok(fd) = PathFd::new("/run/secrets") {
230 ruleset = ruleset
231 .add_rule(PathBeneath::new(fd, access_read))
232 .map_err(ll_err)?;
233 }
234
235 if let Ok(fd) = PathFd::new("/context") {
237 ruleset = ruleset
238 .add_rule(PathBeneath::new(fd, access_read))
239 .map_err(ll_err)?;
240 }
241
242 let status = ruleset.restrict_self().map_err(ll_err)?;
243 Ok(status.ruleset)
244 }
245
246 pub fn is_applied(&self) -> bool {
248 self.applied
249 }
250}
251
252impl Default for LandlockManager {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258fn ll_err(e: RulesetError) -> NucleusError {
260 NucleusError::LandlockError(e.to_string())
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn test_landlock_manager_initial_state() {
269 let mgr = LandlockManager::new();
270 assert!(!mgr.is_applied());
271 }
272
273 #[test]
274 fn test_apply_idempotent() {
275 let mut mgr = LandlockManager::new();
276 let _ = mgr.apply_container_policy_with_mode(true);
278 let result = mgr.apply_container_policy_with_mode(true);
280 assert!(result.is_ok());
281 }
282
283 #[test]
284 fn test_best_effort_on_unsupported_kernel() {
285 let mut mgr = LandlockManager::new();
286 let result = mgr.apply_container_policy_with_mode(true);
288 assert!(result.is_ok());
289 }
290
291 fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
294 let fn_start = source
295 .find(fn_signature)
296 .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
297 let after = &source[fn_start..];
298 let open = after
299 .find('{')
300 .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
301 let mut depth = 0u32;
302 let mut end = open;
303 for (i, ch) in after[open..].char_indices() {
304 match ch {
305 '{' => depth += 1,
306 '}' => {
307 depth -= 1;
308 if depth == 0 {
309 end = open + i + 1;
310 break;
311 }
312 }
313 _ => {}
314 }
315 }
316 &after[..end]
317 }
318
319 #[test]
320 fn test_policy_covers_nix_store_and_secrets() {
321 let source = include_str!("landlock.rs");
327 let fn_body = extract_fn_body(source, "fn build_and_restrict");
328 assert!(
329 fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
330 "Landlock build_and_restrict must include a rule for /nix/store or /nix"
331 );
332 assert!(
333 fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
334 "Landlock build_and_restrict must include a rule for /run/secrets"
335 );
336 }
337
338 #[test]
339 fn test_tmp_access_excludes_execute() {
340 let access_all = AccessFs::from_all(TARGET_ABI);
344 let mut access_tmp = access_all;
345 access_tmp.remove(AccessFs::Execute);
346 assert!(!access_tmp.contains(AccessFs::Execute));
347 assert!(access_tmp.contains(AccessFs::WriteFile));
349 assert!(access_tmp.contains(AccessFs::RemoveFile));
350 }
351
352 #[test]
353 fn test_not_enforced_returns_error_in_strict_mode() {
354 let source = include_str!("landlock.rs");
356 let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
357 let not_enforced_start = fn_body
359 .find("NotEnforced")
360 .expect("function must handle NotEnforced status");
361 let rest = &fn_body[not_enforced_start..];
363 let arm_end = rest
364 .find("RestrictionStatus::")
365 .unwrap_or(rest.len().min(500));
366 let not_enforced_block = &rest[..arm_end];
367 assert!(
368 not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
369 "NotEnforced must return Err when best_effort=false. Block: {}",
370 not_enforced_block
371 );
372 }
373}