gardesk/garcard / 37b841d

Browse files

close sprint two blockers

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
37b841dc7b138df51b29d45d034f2396f6e2caa9
Parents
617b876
Tree
eb65dc2

8 changed files

StatusFile+-
M README.md 1 0
M examples/config.toml 4 0
A examples/sprint-02-validation-report-2026-02-18.md 46 0
M examples/sprint-02-validation.md 17 0
M examples/validate-sprint-02.sh 14 10
M garcard/src/agent.rs 110 19
M garcard/src/config.rs 39 0
M garcard/src/daemon.rs 67 0
README.mdmodified
@@ -25,6 +25,7 @@ Environment overrides:
2525
 7. `GARCARD_POLKIT_HELPER_SOCKET`
2626
 8. `GARCARD_PROMPT_COMMAND`
2727
 9. `GARCARD_PROMPT_TIMEOUT_SECS`
28
+10. `GARCARD_BACKEND_HEALTHCHECK_SECS`
2829
 
2930
 See `examples/config.toml` for a starter file.
3031
 
examples/config.tomlmodified
@@ -20,3 +20,7 @@ polkit_object_path = "/org/gardesk/Garcard/AuthAgent"
2020
 
2121
 # Locale sent to polkit authority registration.
2222
 locale = "en_US.UTF-8"
23
+
24
+# Backend health check interval in seconds.
25
+# Failed checks trigger a reconnect attempt for the selected auth backend.
26
+backend_healthcheck_secs = "5"
examples/sprint-02-validation-report-2026-02-18.mdadded
@@ -0,0 +1,46 @@
1
+# Sprint 02 Validation Report (2026-02-18)
2
+
3
+## Environment
4
+1. Host: `mizu` (NixOS)
5
+2. Daemon run mode: `GARCARD_AGENT_BACKEND=polkit`
6
+3. Socket: `/run/user/1000/garcard.sock`
7
+
8
+## Baseline Checks
9
+1. `garcardctl ping` -> success.
10
+2. `garcardctl status` -> running with `agent_backend: polkit`.
11
+3. `garcardctl auth-summary` -> `idle`.
12
+
13
+## Live Policy Checks
14
+1. `pkcheck --allow-user-interaction --process $$ --action-id org.freedesktop.login1.power-off`
15
+Result:
16
+1. Exit code `0`.
17
+2. No challenge emitted (policy already authorized in this session context).
18
+
19
+2. `pkcheck --allow-user-interaction --process $$ --action-id com.mesonbuild.install.run`
20
+Result:
21
+1. Exit code `1` (`Not authorized.`).
22
+2. Daemon logs showed live challenge callback:
23
+   - `Started active polkit auth request ...`
24
+   - `Processing polkit auth request ...`
25
+   - `Starting helper authentication dialog ...`
26
+3. `garcardctl auth-summary` after call -> `timeout`.
27
+
28
+## Reconnect Validation
29
+1. `kill -HUP <garcard-pid>` (forced reconnect path).
30
+2. Daemon logs:
31
+   - `Received SIGHUP; forcing backend reconnect`
32
+   - `Unregistered polkit authentication agent`
33
+   - `Registered polkit authentication agent`
34
+3. Post-check:
35
+   - `garcardctl status` remained responsive.
36
+   - `garcardctl auth-summary` remained consistent.
37
+
38
+## Optional Root-Level Disruption Check
39
+1. Attempted `systemctl restart polkit`.
40
+2. Result: `Access denied` (no root/system permission from this validation context).
41
+3. Root-level service restart scenario remains for host-owner execution if desired.
42
+
43
+## Conclusion
44
+1. Sprint 02 auth callback path is live and receiving real polkit challenge requests.
45
+2. Timeout state is observable and propagated through IPC summary.
46
+3. Backend reconnect behavior is validated through forced reconnect workflow.
examples/sprint-02-validation.mdmodified
@@ -58,3 +58,20 @@ Expected:
5858
 1. One active request at a time.
5959
 2. Additional requests queue and process FIFO.
6060
 3. No deadlock after cancel/failure/success.
61
+
62
+## Backend Reconnect (Bus/Authority Disruption)
63
+1. Start daemon with visible logs:
64
+   - `RUST_LOG=garcard=debug cargo run -p garcard -- daemon`
65
+2. In another terminal, capture status:
66
+   - `cargo run -q -p garcardctl -- status`
67
+3. Force reconnect path without root:
68
+   - `kill -HUP <garcard-pid>`
69
+4. Wait at least one health interval (default 5s), then check status and logs.
70
+5. Optional root-level disruption check:
71
+   - `sudo systemctl restart polkit`
72
+
73
+Expected:
74
+1. Log shows forced reconnect path (`Received SIGHUP; forcing backend reconnect`).
75
+2. Backend re-registers without daemon process restart.
76
+3. `garcardctl status` remains responsive during/after reconnect attempt.
77
+4. Optional root-level disruption should trigger maintenance reconnect attempts.
examples/validate-sprint-02.shmodified
@@ -3,25 +3,29 @@ set -euo pipefail
33
 
44
 ACTION_ID="${1:-org.freedesktop.login1.power-off}"
55
 
6
-if ! command -v garcardctl >/dev/null 2>&1; then
7
-  echo "garcardctl not found in PATH"
8
-  echo "Run from this repo with: cargo run -p garcardctl -- <command>"
9
-  exit 1
10
-fi
11
-
126
 if ! command -v pkcheck >/dev/null 2>&1; then
137
   echo "pkcheck not found; install polkit tools to run live auth validation"
148
   exit 1
159
 fi
1610
 
11
+if command -v garcardctl >/dev/null 2>&1; then
12
+  GARCARDCTL=(garcardctl)
13
+else
14
+  GARCARDCTL=(cargo run -q -p garcardctl --)
15
+fi
16
+
17
+run_garcardctl() {
18
+  "${GARCARDCTL[@]}" "$@"
19
+}
20
+
1721
 echo "[1/5] Check daemon connectivity"
18
-garcardctl ping
22
+run_garcardctl ping
1923
 
2024
 echo "[2/5] Check daemon status"
21
-garcardctl status
25
+run_garcardctl status
2226
 
2327
 echo "[3/5] Check pre-auth summary"
24
-garcardctl auth-summary
28
+run_garcardctl auth-summary
2529
 
2630
 echo "[4/5] Trigger interactive policy check"
2731
 echo "Action ID: ${ACTION_ID}"
@@ -33,7 +37,7 @@ set -e
3337
 echo "pkcheck exit code: ${PKCHECK_RC}"
3438
 
3539
 echo "[5/5] Check post-auth summary"
36
-garcardctl auth-summary
40
+run_garcardctl auth-summary
3741
 
3842
 cat <<'EOF'
3943
 Exit code hints:
garcard/src/agent.rsmodified
@@ -16,6 +16,9 @@ pub trait AuthAgentBackend {
1616
     fn name(&self) -> &'static str;
1717
     fn register(&mut self) -> Result<()>;
1818
     fn unregister(&mut self) -> Result<()>;
19
+    fn maintain(&mut self) -> Result<()> {
20
+        Ok(())
21
+    }
1922
 }
2023
 
2124
 /// Placeholder backend used during Sprint 01 scaffolding.
@@ -472,6 +475,21 @@ impl PolkitAgent {
472475
         )?;
473476
         Ok(proxy)
474477
     }
478
+
479
+    fn peer_proxy(connection: &Connection) -> Result<Proxy<'_>> {
480
+        let proxy = Proxy::new(
481
+            connection,
482
+            "org.freedesktop.PolicyKit1",
483
+            "/org/freedesktop/PolicyKit1/Authority",
484
+            "org.freedesktop.DBus.Peer",
485
+        )?;
486
+        Ok(proxy)
487
+    }
488
+
489
+    fn ping_authority(connection: &Connection) -> Result<()> {
490
+        let _: () = Self::peer_proxy(connection)?.call("Ping", &())?;
491
+        Ok(())
492
+    }
475493
 }
476494
 
477495
 impl AuthAgentBackend for PolkitAgent {
@@ -506,7 +524,7 @@ impl AuthAgentBackend for PolkitAgent {
506524
                 &(
507525
                     &self.subject,
508526
                     self.locale.as_str(),
509
-                    self.object_path.clone(),
527
+                    self.object_path.to_string(),
510528
                 ),
511529
             )?;
512530
             Ok(())
@@ -534,22 +552,29 @@ impl AuthAgentBackend for PolkitAgent {
534552
         }
535553
 
536554
         if let Some(connection) = &self.connection {
537
-            match Self::proxy(connection)?.call::<_, _, ()>(
538
-                "UnregisterAuthenticationAgent",
539
-                &(
540
-                    &self.subject,
541
-                    self.locale.as_str(),
542
-                    self.object_path.clone(),
543
-                ),
544
-            ) {
545
-                Ok(()) => {
546
-                    tracing::info!(
547
-                        backend = self.name(),
548
-                        "Unregistered polkit authentication agent"
549
-                    );
550
-                }
555
+            match Self::proxy(connection) {
556
+                Ok(proxy) => match proxy.call::<_, _, ()>(
557
+                    "UnregisterAuthenticationAgent",
558
+                    &(&self.subject, self.object_path.to_string()),
559
+                ) {
560
+                    Ok(()) => {
561
+                        tracing::info!(
562
+                            backend = self.name(),
563
+                            "Unregistered polkit authentication agent"
564
+                        );
565
+                    }
566
+                    Err(err) => {
567
+                        tracing::warn!(
568
+                            error = %err,
569
+                            "Failed to unregister polkit authentication agent"
570
+                        );
571
+                    }
572
+                },
551573
                 Err(err) => {
552
-                    tracing::warn!(error = %err, "Failed to unregister polkit authentication agent");
574
+                    tracing::warn!(
575
+                        error = %err,
576
+                        "Failed to create polkit authority proxy during unregister"
577
+                    );
553578
                 }
554579
             }
555580
 
@@ -566,18 +591,78 @@ impl AuthAgentBackend for PolkitAgent {
566591
         self.connection = None;
567592
         Ok(())
568593
     }
594
+
595
+    fn maintain(&mut self) -> Result<()> {
596
+        if !self.registered {
597
+            return self.register();
598
+        }
599
+
600
+        let is_healthy = self
601
+            .connection
602
+            .as_ref()
603
+            .map(|connection| Self::ping_authority(connection).is_ok())
604
+            .unwrap_or(false);
605
+        if is_healthy {
606
+            return Ok(());
607
+        }
608
+
609
+        tracing::warn!(
610
+            backend = self.name(),
611
+            "Polkit backend health check failed; attempting reconnect"
612
+        );
613
+        let _ = self.unregister();
614
+        self.register()
615
+    }
569616
 }
570617
 
571618
 fn build_subject() -> Subject {
619
+    if let Some(session_id) = current_session_id() {
620
+        let mut details = HashMap::new();
621
+        let value = zbus::zvariant::Value::from(session_id.as_str());
622
+        if let Ok(session_value) = OwnedValue::try_from(value) {
623
+            details.insert("session-id".to_string(), session_value);
624
+            return ("unix-session".to_string(), details);
625
+        }
626
+    }
627
+
572628
     let mut details = HashMap::new();
573629
     details.insert("pid".to_string(), OwnedValue::from(std::process::id()));
574630
     details.insert(
575631
         "uid".to_string(),
576632
         OwnedValue::from(nix::unistd::geteuid().as_raw()),
577633
     );
634
+    if let Some(start_time) = process_start_time_ticks() {
635
+        details.insert("start-time".to_string(), OwnedValue::from(start_time));
636
+    } else {
637
+        tracing::warn!("Unable to determine process start-time for polkit subject");
638
+    }
578639
     ("unix-process".to_string(), details)
579640
 }
580641
 
642
+fn process_start_time_ticks() -> Option<u64> {
643
+    let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
644
+    let (_head, tail) = stat.rsplit_once(") ")?;
645
+    let mut fields = tail.split_whitespace();
646
+    fields.nth(19)?.parse::<u64>().ok()
647
+}
648
+
649
+fn current_session_id() -> Option<String> {
650
+    if let Ok(raw) = std::env::var("XDG_SESSION_ID") {
651
+        let trimmed = raw.trim();
652
+        if !trimmed.is_empty() {
653
+            return Some(trimmed.to_string());
654
+        }
655
+    }
656
+
657
+    let raw = std::fs::read_to_string("/proc/self/sessionid").ok()?;
658
+    let trimmed = raw.trim();
659
+    if trimmed.is_empty() || trimmed == "4294967295" {
660
+        return None;
661
+    }
662
+
663
+    Some(trimmed.to_string())
664
+}
665
+
581666
 #[cfg(test)]
582667
 mod tests {
583668
     use super::*;
@@ -608,9 +693,15 @@ mod tests {
608693
     #[test]
609694
     fn subject_uses_unix_process_kind() {
610695
         let subject = build_subject();
611
-        assert_eq!(subject.0, "unix-process");
612
-        assert!(subject.1.contains_key("pid"));
613
-        assert!(subject.1.contains_key("uid"));
696
+        match subject.0.as_str() {
697
+            "unix-session" => assert!(subject.1.contains_key("session-id")),
698
+            "unix-process" => {
699
+                assert!(subject.1.contains_key("pid"));
700
+                assert!(subject.1.contains_key("uid"));
701
+                assert!(subject.1.contains_key("start-time"));
702
+            }
703
+            other => panic!("unexpected subject kind: {other}"),
704
+        }
614705
     }
615706
 
616707
     #[test]
garcard/src/config.rsmodified
@@ -12,6 +12,7 @@ pub struct Config {
1212
     pub agent_backend: AgentBackendMode,
1313
     pub polkit_object_path: String,
1414
     pub locale: String,
15
+    pub backend_healthcheck_secs: u64,
1516
 }
1617
 
1718
 impl Default for Config {
@@ -22,6 +23,7 @@ impl Default for Config {
2223
             agent_backend: AgentBackendMode::Auto,
2324
             polkit_object_path: "/org/gardesk/Garcard/AuthAgent".to_string(),
2425
             locale: std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
26
+            backend_healthcheck_secs: 5,
2527
         }
2628
     }
2729
 }
@@ -50,6 +52,12 @@ impl Config {
5052
         if let Some(raw_locale) = std::env::var_os("GARCARD_LOCALE") {
5153
             cfg.locale = raw_locale.to_string_lossy().to_string();
5254
         }
55
+        if let Some(raw_interval) = std::env::var_os("GARCARD_BACKEND_HEALTHCHECK_SECS") {
56
+            cfg.backend_healthcheck_secs = parse_positive_secs(&raw_interval.to_string_lossy())
57
+                .with_context(|| {
58
+                    "Invalid GARCARD_BACKEND_HEALTHCHECK_SECS (expected positive integer seconds)"
59
+                })?;
60
+        }
5361
 
5462
         Ok(cfg)
5563
     }
@@ -84,6 +92,12 @@ impl Config {
8492
         if let Some(locale) = file_cfg.locale {
8593
             self.locale = locale;
8694
         }
95
+        if let Some(backend_healthcheck_secs) = file_cfg.backend_healthcheck_secs {
96
+            self.backend_healthcheck_secs = parse_positive_secs(&backend_healthcheck_secs)
97
+                .with_context(|| {
98
+                "Invalid backend_healthcheck_secs in config file (expected positive integer seconds)"
99
+            })?;
100
+        }
87101
 
88102
         Ok(())
89103
     }
@@ -126,6 +140,7 @@ struct FileConfig {
126140
     agent_backend: Option<String>,
127141
     polkit_object_path: Option<String>,
128142
     locale: Option<String>,
143
+    backend_healthcheck_secs: Option<String>,
129144
 }
130145
 
131146
 fn config_path() -> Option<PathBuf> {
@@ -143,6 +158,17 @@ fn parse_octal_mode(raw: &str) -> Result<u32> {
143158
     Ok(mode)
144159
 }
145160
 
161
+fn parse_positive_secs(raw: &str) -> Result<u64> {
162
+    let trimmed = raw.trim();
163
+    let secs = trimmed
164
+        .parse::<u64>()
165
+        .with_context(|| format!("failed to parse seconds value: {trimmed}"))?;
166
+    if secs == 0 {
167
+        anyhow::bail!("seconds must be greater than zero");
168
+    }
169
+    Ok(secs)
170
+}
171
+
146172
 #[cfg(test)]
147173
 mod tests {
148174
     use super::*;
@@ -168,6 +194,7 @@ socket_mode = "640"
168194
 agent_backend = "stub"
169195
 polkit_object_path = "/org/gardesk/Garcard/TestAgent"
170196
 locale = "C"
197
+backend_healthcheck_secs = "7"
171198
 "#,
172199
         )
173200
         .expect("parse file config");
@@ -183,6 +210,7 @@ locale = "C"
183210
             Some("/org/gardesk/Garcard/TestAgent")
184211
         );
185212
         assert_eq!(parsed.locale.as_deref(), Some("C"));
213
+        assert_eq!(parsed.backend_healthcheck_secs.as_deref(), Some("7"));
186214
     }
187215
 
188216
     #[test]
@@ -206,4 +234,15 @@ locale = "C"
206234
         let err = AgentBackendMode::from_str("bad").expect_err("should fail");
207235
         assert!(err.to_string().contains("unsupported backend mode"));
208236
     }
237
+
238
+    #[test]
239
+    fn parse_positive_secs_rejects_zero() {
240
+        let err = parse_positive_secs("0").expect_err("zero should fail");
241
+        assert!(err.to_string().contains("greater than zero"));
242
+    }
243
+
244
+    #[test]
245
+    fn parse_positive_secs_accepts_positive_integer() {
246
+        assert_eq!(parse_positive_secs("9").expect("valid"), 9);
247
+    }
209248
 }
garcard/src/daemon.rsmodified
@@ -7,6 +7,7 @@ use serde_json::json;
77
 use std::os::unix::fs::PermissionsExt;
88
 use std::path::{Path, PathBuf};
99
 use std::sync::Arc;
10
+use std::time::Duration;
1011
 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1112
 use tokio::net::{UnixListener, UnixStream};
1213
 use tokio::sync::mpsc;
@@ -64,16 +65,37 @@ pub async fn run(config: Config) -> Result<()> {
6465
         .context("Failed to register SIGTERM handler")?;
6566
     let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
6667
         .context("Failed to register SIGINT handler")?;
68
+    let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
69
+        .context("Failed to register SIGHUP handler")?;
70
+    let mut backend_maintenance =
71
+        tokio::time::interval(Duration::from_secs(config.backend_healthcheck_secs));
72
+    backend_maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6773
 
6874
     tracing::info!(
6975
         socket = %config.socket_path.display(),
7076
         pid = state.status().pid,
7177
         backend = backend.name(),
78
+        backend_healthcheck_secs = config.backend_healthcheck_secs,
7279
         "garcard daemon started"
7380
     );
7481
 
7582
     loop {
7683
         tokio::select! {
84
+            _ = backend_maintenance.tick() => {
85
+                if let Err(err) = backend.maintain() {
86
+                    tracing::warn!(
87
+                        error = %err,
88
+                        backend = backend.name(),
89
+                        "Backend maintenance check failed"
90
+                    );
91
+                }
92
+            }
93
+            _ = sighup.recv() => {
94
+                tracing::info!("Received SIGHUP; forcing backend reconnect");
95
+                if let Err(err) = reconnect_backend(backend.as_mut()) {
96
+                    tracing::warn!(error = %err, backend = backend.name(), "Forced backend reconnect failed");
97
+                }
98
+            }
7799
             _ = sigterm.recv() => {
78100
                 tracing::info!("Received SIGTERM");
79101
                 break;
@@ -114,6 +136,12 @@ pub async fn run(config: Config) -> Result<()> {
114136
     Ok(())
115137
 }
116138
 
139
+fn reconnect_backend(backend: &mut dyn AuthAgentBackend) -> Result<()> {
140
+    backend.unregister()?;
141
+    backend.register()?;
142
+    Ok(())
143
+}
144
+
117145
 fn init_backend(config: &Config, auth_state: Arc<AuthState>) -> Result<Box<dyn AuthAgentBackend>> {
118146
     match config.agent_backend {
119147
         AgentBackendMode::Stub => {
@@ -241,6 +269,9 @@ fn dispatch(
241269
 #[cfg(test)]
242270
 mod tests {
243271
     use super::*;
272
+    use anyhow::Result as AnyResult;
273
+    use std::sync::Arc;
274
+    use std::sync::atomic::{AtomicUsize, Ordering};
244275
 
245276
     fn fake_state() -> RuntimeState {
246277
         RuntimeState::new("/tmp/garcard-test.sock".to_string(), "test-backend")
@@ -276,10 +307,46 @@ mod tests {
276307
             agent_backend: AgentBackendMode::Auto,
277308
             polkit_object_path: "invalid path".to_string(),
278309
             locale: "C".to_string(),
310
+            backend_healthcheck_secs: 5,
279311
         };
280312
 
281313
         let backend = init_backend(&config, Arc::new(AuthState::default()))
282314
             .expect("auto mode should fall back");
283315
         assert_eq!(backend.name(), "stub-polkit-agent");
284316
     }
317
+
318
+    struct TrackingBackend {
319
+        unregister_calls: Arc<AtomicUsize>,
320
+        register_calls: Arc<AtomicUsize>,
321
+    }
322
+
323
+    impl AuthAgentBackend for TrackingBackend {
324
+        fn name(&self) -> &'static str {
325
+            "tracking-backend"
326
+        }
327
+
328
+        fn register(&mut self) -> AnyResult<()> {
329
+            self.register_calls.fetch_add(1, Ordering::Relaxed);
330
+            Ok(())
331
+        }
332
+
333
+        fn unregister(&mut self) -> AnyResult<()> {
334
+            self.unregister_calls.fetch_add(1, Ordering::Relaxed);
335
+            Ok(())
336
+        }
337
+    }
338
+
339
+    #[test]
340
+    fn reconnect_backend_calls_unregister_then_register() {
341
+        let unregister_calls = Arc::new(AtomicUsize::new(0));
342
+        let register_calls = Arc::new(AtomicUsize::new(0));
343
+        let mut backend = TrackingBackend {
344
+            unregister_calls: Arc::clone(&unregister_calls),
345
+            register_calls: Arc::clone(&register_calls),
346
+        };
347
+
348
+        reconnect_backend(&mut backend).expect("reconnect");
349
+        assert_eq!(unregister_calls.load(Ordering::Relaxed), 1);
350
+        assert_eq!(register_calls.load(Ordering::Relaxed), 1);
351
+    }
285352
 }