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 if role_rank(&candidate) > role_rank(¤t) {
32 candidate
33 } else {
34 current
35 }
36}
37
38pub(crate) fn role_rank(role: &CallerRole) -> u8 {
39 match role {
40 CallerRole::Observer => 0,
41 CallerRole::Dev => 1,
42 CallerRole::Admin => 2,
43 CallerRole::Boot => 3,
44 }
45}
46
47pub fn validate_token_against_file(
71 presented_token: &str,
72 token_path: &std::path::Path,
73) -> Option<CallerRole> {
74 if presented_token.is_empty() {
75 return None;
76 }
77 match token_file_permissions_ok(token_path) {
78 Ok(true) => {}
79 Ok(false) => return None,
80 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81 eprintln!(
82 "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
83 token_path.display()
84 );
85 return None;
86 }
87 Err(e) => {
88 eprintln!(
89 "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
90 token_path.display()
91 );
92 return None;
93 }
94 }
95 let stored = match std::fs::read_to_string(token_path) {
98 Ok(s) => s,
99 Err(e) => {
100 eprintln!(
101 "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
102 token_path.display()
103 );
104 return None;
105 }
106 };
107 let stored = stored.trim();
108 if stored.is_empty() {
109 return None;
110 }
111 if stored.len() != presented_token.len() {
118 return None;
119 }
120 use subtle::ConstantTimeEq;
121 if stored
122 .as_bytes()
123 .ct_eq(presented_token.as_bytes())
124 .unwrap_u8()
125 != 1
126 {
127 return None;
128 }
129 Some(token_role())
130}
131
132fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
138 use std::os::unix::fs::PermissionsExt;
139
140 let mode = std::fs::metadata(token_path)?.permissions().mode();
141 if mode & 0o077 != 0 {
142 eprintln!(
143 "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
144 readable by group or other (any local user could authenticate with it). \
145 Rejecting vsock auth — fix with: chmod 600 {}",
146 token_path.display(),
147 mode & 0o7777,
148 token_path.display()
149 );
150 return Ok(false);
151 }
152 Ok(true)
153}
154
155pub fn token_role() -> CallerRole {
163 let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
164 match raw.trim().to_ascii_lowercase().as_str() {
165 "observer" => CallerRole::Observer,
166 "admin" => CallerRole::Admin,
167 "boot" => CallerRole::Boot,
168 "dev" | "" => CallerRole::Dev,
169 other => {
170 eprintln!(
171 "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
172 failing closed to Observer (read-only)"
173 );
174 CallerRole::Observer
175 }
176 }
177}
178
179pub fn default_token_path() -> std::path::PathBuf {
181 sysknife_core::config::prefs_path()
182 .parent()
183 .unwrap_or_else(|| {
184 eprintln!(
185 "[sysknife-daemon] WARNING: prefs_path() has no parent; \
186 falling back to /tmp for token file — this is a misconfiguration"
187 );
188 std::path::Path::new("/tmp")
189 })
190 .join("token")
191}
192
193#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn secure(path: &std::path::Path) {
205 use std::os::unix::fs::PermissionsExt;
206 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
207 }
208
209 fn role(groups: &[&str]) -> CallerRole {
210 highest_role_from_groups(groups.iter().copied())
211 }
212
213 #[test]
214 fn empty_groups_resolves_to_observer() {
215 assert_eq!(role(&[]), CallerRole::Observer);
216 }
217
218 #[test]
219 fn unknown_group_resolves_to_observer() {
220 assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
221 }
222
223 #[test]
224 fn lacs_observer_group_resolves_to_observer() {
225 assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
226 }
227
228 #[test]
229 fn lacs_dev_group_resolves_to_dev() {
230 assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
231 }
232
233 #[test]
234 fn lacs_admin_group_resolves_to_admin() {
235 assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
236 }
237
238 #[test]
239 fn wheel_group_resolves_to_admin() {
240 assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
241 }
242
243 #[test]
244 fn lacs_boot_group_resolves_to_boot() {
245 assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
246 }
247
248 #[test]
249 fn highest_role_wins_when_multiple_groups_present() {
250 assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
252 }
253
254 #[test]
255 fn boot_role_beats_admin_and_wheel() {
256 assert_eq!(
257 role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
258 CallerRole::Boot
259 );
260 }
261
262 #[test]
263 fn mixed_known_and_unknown_groups_returns_highest_known() {
264 assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
265 }
266
267 #[test]
270 fn valid_token_matches_and_returns_dev_by_default() {
271 let dir = tempfile::tempdir().unwrap();
272 let path = dir.path().join("token");
273 std::fs::write(&path, "secret123").unwrap();
274 secure(&path);
275 assert_eq!(
276 validate_token_against_file("secret123", &path),
277 Some(CallerRole::Dev)
278 );
279 }
280
281 #[test]
282 fn token_file_with_trailing_newline_still_matches() {
283 let dir = tempfile::tempdir().unwrap();
285 let path = dir.path().join("token");
286 std::fs::write(&path, "secret123\n").unwrap();
287 secure(&path);
288 assert_eq!(
289 validate_token_against_file("secret123", &path),
290 Some(CallerRole::Dev)
291 );
292 }
293
294 #[test]
295 fn wrong_token_returns_none() {
296 let dir = tempfile::tempdir().unwrap();
297 let path = dir.path().join("token");
298 std::fs::write(&path, "correct\n").unwrap();
299 secure(&path);
300 assert_eq!(validate_token_against_file("wrong", &path), None);
301 }
302
303 #[test]
304 fn absent_token_file_returns_none() {
305 let dir = tempfile::tempdir().unwrap();
306 let path = dir.path().join("nonexistent");
307 assert_eq!(validate_token_against_file("any", &path), None);
308 }
309
310 #[test]
311 fn empty_presented_token_is_always_rejected() {
312 let dir = tempfile::tempdir().unwrap();
313 let path = dir.path().join("token");
314 std::fs::write(&path, "").unwrap();
315 secure(&path);
316 assert_eq!(validate_token_against_file("", &path), None);
317 }
318
319 #[test]
320 fn empty_stored_token_is_rejected_even_with_matching_presented() {
321 let dir = tempfile::tempdir().unwrap();
322 let path = dir.path().join("token");
323 std::fs::write(&path, "\n").unwrap();
324 secure(&path);
325 assert_eq!(validate_token_against_file("", &path), None);
326 }
327
328 #[test]
337 fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
338 let dir = tempfile::tempdir().unwrap();
339 let path = dir.path().join("token");
340 std::fs::write(&path, "abcdefghi").unwrap();
344 secure(&path);
345
346 assert_eq!(
348 validate_token_against_file("abcdefghi", &path),
349 Some(CallerRole::Dev)
350 );
351 assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
353 assert_eq!(validate_token_against_file("abcdefghX", &path), None);
355 assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
357 }
358
359 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
362
363 fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
364 let _guard = ENV_LOCK.lock().unwrap();
365 match val {
366 Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
367 None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
368 }
369 f();
370 std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
371 }
372
373 #[test]
374 fn token_role_defaults_to_dev_when_unset() {
375 with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
376 }
377
378 #[test]
379 fn token_role_explicit_dev() {
380 with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
381 }
382
383 #[test]
384 fn token_role_observer() {
385 with_role_env(Some("observer"), || {
386 assert_eq!(token_role(), CallerRole::Observer)
387 });
388 }
389
390 #[test]
391 fn token_role_admin() {
392 with_role_env(Some("admin"), || {
393 assert_eq!(token_role(), CallerRole::Admin)
394 });
395 }
396
397 #[test]
398 fn token_role_boot() {
399 with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
400 }
401
402 #[test]
403 fn token_role_unknown_value_fails_closed_to_observer() {
404 with_role_env(Some("superuser"), || {
407 assert_eq!(token_role(), CallerRole::Observer)
408 });
409 }
410
411 #[test]
412 fn token_role_is_case_insensitive() {
413 with_role_env(Some("ADMIN"), || {
414 assert_eq!(token_role(), CallerRole::Admin)
415 });
416 }
417
418 #[test]
419 fn token_role_trims_surrounding_whitespace() {
420 with_role_env(Some(" admin\n"), || {
423 assert_eq!(token_role(), CallerRole::Admin)
424 });
425 }
426}