1use sysknife_types::CallerRole;
2
3pub const OBSERVER_GROUP: &str = "sysknife-observer";
4pub const DEV_GROUP: &str = "sysknife-dev";
5pub const ADMIN_GROUP: &str = "sysknife-admin";
6pub const BOOT_GROUP: &str = "sysknife-boot";
7pub const WHEEL_GROUP: &str = "wheel";
8
9pub fn highest_role_from_groups<I, S>(groups: I) -> CallerRole
10where
11 I: IntoIterator<Item = S>,
12 S: AsRef<str>,
13{
14 groups
15 .into_iter()
16 .map(|group| role_for_group(group.as_ref()))
17 .fold(CallerRole::Observer, higher_role)
18}
19
20fn role_for_group(group: &str) -> CallerRole {
21 match group {
22 BOOT_GROUP => CallerRole::Boot,
23 ADMIN_GROUP | WHEEL_GROUP => CallerRole::Admin,
24 DEV_GROUP => CallerRole::Dev,
25 OBSERVER_GROUP => CallerRole::Observer,
26 _ => CallerRole::Observer,
27 }
28}
29
30fn higher_role(current: CallerRole, candidate: CallerRole) -> CallerRole {
31 current.max(candidate)
32}
33
34pub fn group_for_role(role: CallerRole) -> &'static str {
41 match role {
42 CallerRole::Boot => BOOT_GROUP,
43 CallerRole::Admin => ADMIN_GROUP,
44 CallerRole::Dev => DEV_GROUP,
45 CallerRole::Observer => OBSERVER_GROUP,
46 }
47}
48
49pub fn denial_message(action: &str, caller: CallerRole, required: CallerRole) -> String {
55 format!(
56 "action '{action}' requires the {required:?} role, but you have {caller:?}. \
57 Join the group that grants it: sudo usermod -aG {group} $USER, \
58 then log out and back in (group membership only applies to a new login).",
59 group = group_for_role(required),
60 )
61}
62
63pub fn validate_token_against_file(
87 presented_token: &str,
88 token_path: &std::path::Path,
89) -> Option<CallerRole> {
90 if presented_token.is_empty() {
91 return None;
92 }
93 match token_file_permissions_ok(token_path) {
94 Ok(true) => {}
95 Ok(false) => return None,
96 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
97 eprintln!(
98 "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
99 token_path.display()
100 );
101 return None;
102 }
103 Err(e) => {
104 eprintln!(
105 "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
106 token_path.display()
107 );
108 return None;
109 }
110 }
111 let stored = match std::fs::read_to_string(token_path) {
114 Ok(s) => s,
115 Err(e) => {
116 eprintln!(
117 "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
118 token_path.display()
119 );
120 return None;
121 }
122 };
123 let stored = stored.trim();
124 if stored.is_empty() {
125 return None;
126 }
127 if stored.len() != presented_token.len() {
134 return None;
135 }
136 use subtle::ConstantTimeEq;
137 if stored
138 .as_bytes()
139 .ct_eq(presented_token.as_bytes())
140 .unwrap_u8()
141 != 1
142 {
143 return None;
144 }
145 Some(token_role())
146}
147
148fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
154 use std::os::unix::fs::PermissionsExt;
155
156 let mode = std::fs::metadata(token_path)?.permissions().mode();
157 if mode & 0o077 != 0 {
158 eprintln!(
159 "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
160 readable by group or other (any local user could authenticate with it). \
161 Rejecting vsock auth — fix with: chmod 600 {}",
162 token_path.display(),
163 mode & 0o7777,
164 token_path.display()
165 );
166 return Ok(false);
167 }
168 Ok(true)
169}
170
171pub fn token_role() -> CallerRole {
179 let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
180 match raw.trim().to_ascii_lowercase().as_str() {
181 "observer" => CallerRole::Observer,
182 "admin" => CallerRole::Admin,
183 "boot" => CallerRole::Boot,
184 "dev" | "" => CallerRole::Dev,
185 other => {
186 eprintln!(
187 "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
188 failing closed to Observer (read-only)"
189 );
190 CallerRole::Observer
191 }
192 }
193}
194
195pub fn default_token_path() -> std::path::PathBuf {
197 sysknife_core::config::prefs_path()
198 .parent()
199 .unwrap_or_else(|| {
200 eprintln!(
201 "[sysknife-daemon] WARNING: prefs_path() has no parent; \
202 falling back to /tmp for token file — this is a misconfiguration"
203 );
204 std::path::Path::new("/tmp")
205 })
206 .join("token")
207}
208
209#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn secure(path: &std::path::Path) {
221 use std::os::unix::fs::PermissionsExt;
222 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
223 }
224
225 fn role(groups: &[&str]) -> CallerRole {
226 highest_role_from_groups(groups.iter().copied())
227 }
228
229 #[test]
240 fn every_role_maps_back_to_the_group_that_grants_it() {
241 assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
242 assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
243 assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
244 assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
245 }
246
247 #[test]
248 fn the_group_a_role_maps_to_actually_grants_that_role() {
249 for wanted in [
252 CallerRole::Observer,
253 CallerRole::Dev,
254 CallerRole::Admin,
255 CallerRole::Boot,
256 ] {
257 let group = group_for_role(wanted);
258 assert_eq!(
259 role(&[group]),
260 wanted,
261 "joining {group} must grant {wanted:?}"
262 );
263 }
264 }
265
266 #[test]
267 fn a_denial_says_which_group_to_join_and_how() {
268 let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
269 assert!(msg.contains("AptInstall"), "names the action: {msg}");
270 assert!(msg.contains("Observer"), "names the current role: {msg}");
271 assert!(msg.contains("Dev"), "names the required role: {msg}");
272 assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
273 assert!(msg.contains("usermod"), "gives the command: {msg}");
274 assert!(
275 msg.contains("log out") || msg.contains("new login"),
276 "group membership needs a fresh session; say so: {msg}"
277 );
278 }
279
280 #[test]
281 fn empty_groups_resolves_to_observer() {
282 assert_eq!(role(&[]), CallerRole::Observer);
283 }
284
285 #[test]
286 fn unknown_group_resolves_to_observer() {
287 assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
288 }
289
290 #[test]
291 fn lacs_observer_group_resolves_to_observer() {
292 assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
293 }
294
295 #[test]
296 fn lacs_dev_group_resolves_to_dev() {
297 assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
298 }
299
300 #[test]
301 fn lacs_admin_group_resolves_to_admin() {
302 assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
303 }
304
305 #[test]
306 fn wheel_group_resolves_to_admin() {
307 assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
308 }
309
310 #[test]
311 fn lacs_boot_group_resolves_to_boot() {
312 assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
313 }
314
315 #[test]
316 fn highest_role_wins_when_multiple_groups_present() {
317 assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
319 }
320
321 #[test]
322 fn boot_role_beats_admin_and_wheel() {
323 assert_eq!(
324 role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
325 CallerRole::Boot
326 );
327 }
328
329 #[test]
330 fn mixed_known_and_unknown_groups_returns_highest_known() {
331 assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
332 }
333
334 #[test]
337 fn valid_token_matches_and_returns_dev_by_default() {
338 let dir = tempfile::tempdir().unwrap();
339 let path = dir.path().join("token");
340 std::fs::write(&path, "secret123").unwrap();
341 secure(&path);
342 assert_eq!(
343 validate_token_against_file("secret123", &path),
344 Some(CallerRole::Dev)
345 );
346 }
347
348 #[test]
349 fn token_file_with_trailing_newline_still_matches() {
350 let dir = tempfile::tempdir().unwrap();
352 let path = dir.path().join("token");
353 std::fs::write(&path, "secret123\n").unwrap();
354 secure(&path);
355 assert_eq!(
356 validate_token_against_file("secret123", &path),
357 Some(CallerRole::Dev)
358 );
359 }
360
361 #[test]
362 fn wrong_token_returns_none() {
363 let dir = tempfile::tempdir().unwrap();
364 let path = dir.path().join("token");
365 std::fs::write(&path, "correct\n").unwrap();
366 secure(&path);
367 assert_eq!(validate_token_against_file("wrong", &path), None);
368 }
369
370 #[test]
371 fn absent_token_file_returns_none() {
372 let dir = tempfile::tempdir().unwrap();
373 let path = dir.path().join("nonexistent");
374 assert_eq!(validate_token_against_file("any", &path), None);
375 }
376
377 #[test]
378 fn empty_presented_token_is_always_rejected() {
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("token");
381 std::fs::write(&path, "").unwrap();
382 secure(&path);
383 assert_eq!(validate_token_against_file("", &path), None);
384 }
385
386 #[test]
387 fn empty_stored_token_is_rejected_even_with_matching_presented() {
388 let dir = tempfile::tempdir().unwrap();
389 let path = dir.path().join("token");
390 std::fs::write(&path, "\n").unwrap();
391 secure(&path);
392 assert_eq!(validate_token_against_file("", &path), None);
393 }
394
395 #[test]
404 fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
405 let dir = tempfile::tempdir().unwrap();
406 let path = dir.path().join("token");
407 std::fs::write(&path, "abcdefghi").unwrap();
411 secure(&path);
412
413 assert_eq!(
415 validate_token_against_file("abcdefghi", &path),
416 Some(CallerRole::Dev)
417 );
418 assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
420 assert_eq!(validate_token_against_file("abcdefghX", &path), None);
422 assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
424 }
425
426 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
429
430 fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
431 let _guard = ENV_LOCK.lock().unwrap();
432 match val {
433 Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
434 None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
435 }
436 f();
437 std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
438 }
439
440 #[test]
441 fn token_role_defaults_to_dev_when_unset() {
442 with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
443 }
444
445 #[test]
446 fn token_role_explicit_dev() {
447 with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
448 }
449
450 #[test]
451 fn token_role_observer() {
452 with_role_env(Some("observer"), || {
453 assert_eq!(token_role(), CallerRole::Observer)
454 });
455 }
456
457 #[test]
458 fn token_role_admin() {
459 with_role_env(Some("admin"), || {
460 assert_eq!(token_role(), CallerRole::Admin)
461 });
462 }
463
464 #[test]
465 fn token_role_boot() {
466 with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
467 }
468
469 #[test]
470 fn token_role_unknown_value_fails_closed_to_observer() {
471 with_role_env(Some("superuser"), || {
474 assert_eq!(token_role(), CallerRole::Observer)
475 });
476 }
477
478 #[test]
479 fn token_role_is_case_insensitive() {
480 with_role_env(Some("ADMIN"), || {
481 assert_eq!(token_role(), CallerRole::Admin)
482 });
483 }
484
485 #[test]
486 fn token_role_trims_surrounding_whitespace() {
487 with_role_env(Some(" admin\n"), || {
490 assert_eq!(token_role(), CallerRole::Admin)
491 });
492 }
493}