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 validate_token_against_file(
58 presented_token: &str,
59 token_path: &std::path::Path,
60) -> Option<CallerRole> {
61 if presented_token.is_empty() {
62 return None;
63 }
64 match token_file_permissions_ok(token_path) {
65 Ok(true) => {}
66 Ok(false) => return None,
67 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
68 eprintln!(
69 "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
70 token_path.display()
71 );
72 return None;
73 }
74 Err(e) => {
75 eprintln!(
76 "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
77 token_path.display()
78 );
79 return None;
80 }
81 }
82 let stored = match std::fs::read_to_string(token_path) {
85 Ok(s) => s,
86 Err(e) => {
87 eprintln!(
88 "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
89 token_path.display()
90 );
91 return None;
92 }
93 };
94 let stored = stored.trim();
95 if stored.is_empty() {
96 return None;
97 }
98 if stored.len() != presented_token.len() {
105 return None;
106 }
107 use subtle::ConstantTimeEq;
108 if stored
109 .as_bytes()
110 .ct_eq(presented_token.as_bytes())
111 .unwrap_u8()
112 != 1
113 {
114 return None;
115 }
116 Some(token_role())
117}
118
119fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
125 use std::os::unix::fs::PermissionsExt;
126
127 let mode = std::fs::metadata(token_path)?.permissions().mode();
128 if mode & 0o077 != 0 {
129 eprintln!(
130 "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
131 readable by group or other (any local user could authenticate with it). \
132 Rejecting vsock auth — fix with: chmod 600 {}",
133 token_path.display(),
134 mode & 0o7777,
135 token_path.display()
136 );
137 return Ok(false);
138 }
139 Ok(true)
140}
141
142pub fn token_role() -> CallerRole {
150 let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
151 match raw.trim().to_ascii_lowercase().as_str() {
152 "observer" => CallerRole::Observer,
153 "admin" => CallerRole::Admin,
154 "boot" => CallerRole::Boot,
155 "dev" | "" => CallerRole::Dev,
156 other => {
157 eprintln!(
158 "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
159 failing closed to Observer (read-only)"
160 );
161 CallerRole::Observer
162 }
163 }
164}
165
166pub fn default_token_path() -> std::path::PathBuf {
168 sysknife_core::config::prefs_path()
169 .parent()
170 .unwrap_or_else(|| {
171 eprintln!(
172 "[sysknife-daemon] WARNING: prefs_path() has no parent; \
173 falling back to /tmp for token file — this is a misconfiguration"
174 );
175 std::path::Path::new("/tmp")
176 })
177 .join("token")
178}
179
180#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn secure(path: &std::path::Path) {
192 use std::os::unix::fs::PermissionsExt;
193 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
194 }
195
196 fn role(groups: &[&str]) -> CallerRole {
197 highest_role_from_groups(groups.iter().copied())
198 }
199
200 #[test]
201 fn empty_groups_resolves_to_observer() {
202 assert_eq!(role(&[]), CallerRole::Observer);
203 }
204
205 #[test]
206 fn unknown_group_resolves_to_observer() {
207 assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
208 }
209
210 #[test]
211 fn lacs_observer_group_resolves_to_observer() {
212 assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
213 }
214
215 #[test]
216 fn lacs_dev_group_resolves_to_dev() {
217 assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
218 }
219
220 #[test]
221 fn lacs_admin_group_resolves_to_admin() {
222 assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
223 }
224
225 #[test]
226 fn wheel_group_resolves_to_admin() {
227 assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
228 }
229
230 #[test]
231 fn lacs_boot_group_resolves_to_boot() {
232 assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
233 }
234
235 #[test]
236 fn highest_role_wins_when_multiple_groups_present() {
237 assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
239 }
240
241 #[test]
242 fn boot_role_beats_admin_and_wheel() {
243 assert_eq!(
244 role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
245 CallerRole::Boot
246 );
247 }
248
249 #[test]
250 fn mixed_known_and_unknown_groups_returns_highest_known() {
251 assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
252 }
253
254 #[test]
257 fn valid_token_matches_and_returns_dev_by_default() {
258 let dir = tempfile::tempdir().unwrap();
259 let path = dir.path().join("token");
260 std::fs::write(&path, "secret123").unwrap();
261 secure(&path);
262 assert_eq!(
263 validate_token_against_file("secret123", &path),
264 Some(CallerRole::Dev)
265 );
266 }
267
268 #[test]
269 fn token_file_with_trailing_newline_still_matches() {
270 let dir = tempfile::tempdir().unwrap();
272 let path = dir.path().join("token");
273 std::fs::write(&path, "secret123\n").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 wrong_token_returns_none() {
283 let dir = tempfile::tempdir().unwrap();
284 let path = dir.path().join("token");
285 std::fs::write(&path, "correct\n").unwrap();
286 secure(&path);
287 assert_eq!(validate_token_against_file("wrong", &path), None);
288 }
289
290 #[test]
291 fn absent_token_file_returns_none() {
292 let dir = tempfile::tempdir().unwrap();
293 let path = dir.path().join("nonexistent");
294 assert_eq!(validate_token_against_file("any", &path), None);
295 }
296
297 #[test]
298 fn empty_presented_token_is_always_rejected() {
299 let dir = tempfile::tempdir().unwrap();
300 let path = dir.path().join("token");
301 std::fs::write(&path, "").unwrap();
302 secure(&path);
303 assert_eq!(validate_token_against_file("", &path), None);
304 }
305
306 #[test]
307 fn empty_stored_token_is_rejected_even_with_matching_presented() {
308 let dir = tempfile::tempdir().unwrap();
309 let path = dir.path().join("token");
310 std::fs::write(&path, "\n").unwrap();
311 secure(&path);
312 assert_eq!(validate_token_against_file("", &path), None);
313 }
314
315 #[test]
324 fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
325 let dir = tempfile::tempdir().unwrap();
326 let path = dir.path().join("token");
327 std::fs::write(&path, "abcdefghi").unwrap();
331 secure(&path);
332
333 assert_eq!(
335 validate_token_against_file("abcdefghi", &path),
336 Some(CallerRole::Dev)
337 );
338 assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
340 assert_eq!(validate_token_against_file("abcdefghX", &path), None);
342 assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
344 }
345
346 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
349
350 fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
351 let _guard = ENV_LOCK.lock().unwrap();
352 match val {
353 Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
354 None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
355 }
356 f();
357 std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
358 }
359
360 #[test]
361 fn token_role_defaults_to_dev_when_unset() {
362 with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
363 }
364
365 #[test]
366 fn token_role_explicit_dev() {
367 with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
368 }
369
370 #[test]
371 fn token_role_observer() {
372 with_role_env(Some("observer"), || {
373 assert_eq!(token_role(), CallerRole::Observer)
374 });
375 }
376
377 #[test]
378 fn token_role_admin() {
379 with_role_env(Some("admin"), || {
380 assert_eq!(token_role(), CallerRole::Admin)
381 });
382 }
383
384 #[test]
385 fn token_role_boot() {
386 with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
387 }
388
389 #[test]
390 fn token_role_unknown_value_fails_closed_to_observer() {
391 with_role_env(Some("superuser"), || {
394 assert_eq!(token_role(), CallerRole::Observer)
395 });
396 }
397
398 #[test]
399 fn token_role_is_case_insensitive() {
400 with_role_env(Some("ADMIN"), || {
401 assert_eq!(token_role(), CallerRole::Admin)
402 });
403 }
404
405 #[test]
406 fn token_role_trims_surrounding_whitespace() {
407 with_role_env(Some(" admin\n"), || {
410 assert_eq!(token_role(), CallerRole::Admin)
411 });
412 }
413}