close sprint two blockers
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
37b841dc7b138df51b29d45d034f2396f6e2caa9- Parents
-
617b876 - Tree
eb65dc2
37b841d
37b841dc7b138df51b29d45d034f2396f6e2caa9617b876
eb65dc2| Status | File | + | - |
|---|---|---|---|
| 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: | |||
| 25 | 7. `GARCARD_POLKIT_HELPER_SOCKET` | 25 | 7. `GARCARD_POLKIT_HELPER_SOCKET` |
| 26 | 8. `GARCARD_PROMPT_COMMAND` | 26 | 8. `GARCARD_PROMPT_COMMAND` |
| 27 | 9. `GARCARD_PROMPT_TIMEOUT_SECS` | 27 | 9. `GARCARD_PROMPT_TIMEOUT_SECS` |
| 28 | +10. `GARCARD_BACKEND_HEALTHCHECK_SECS` | ||
| 28 | 29 | ||
| 29 | See `examples/config.toml` for a starter file. | 30 | See `examples/config.toml` for a starter file. |
| 30 | 31 | ||
examples/config.tomlmodified@@ -20,3 +20,7 @@ polkit_object_path = "/org/gardesk/Garcard/AuthAgent" | |||
| 20 | 20 | ||
| 21 | # Locale sent to polkit authority registration. | 21 | # Locale sent to polkit authority registration. |
| 22 | locale = "en_US.UTF-8" | 22 | 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: | |||
| 58 | 1. One active request at a time. | 58 | 1. One active request at a time. |
| 59 | 2. Additional requests queue and process FIFO. | 59 | 2. Additional requests queue and process FIFO. |
| 60 | 3. No deadlock after cancel/failure/success. | 60 | 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 | |||
| 3 | 3 | ||
| 4 | ACTION_ID="${1:-org.freedesktop.login1.power-off}" | 4 | ACTION_ID="${1:-org.freedesktop.login1.power-off}" |
| 5 | 5 | ||
| 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 | - | ||
| 12 | if ! command -v pkcheck >/dev/null 2>&1; then | 6 | if ! command -v pkcheck >/dev/null 2>&1; then |
| 13 | echo "pkcheck not found; install polkit tools to run live auth validation" | 7 | echo "pkcheck not found; install polkit tools to run live auth validation" |
| 14 | exit 1 | 8 | exit 1 |
| 15 | fi | 9 | fi |
| 16 | 10 | ||
| 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 | + | ||
| 17 | echo "[1/5] Check daemon connectivity" | 21 | echo "[1/5] Check daemon connectivity" |
| 18 | -garcardctl ping | 22 | +run_garcardctl ping |
| 19 | 23 | ||
| 20 | echo "[2/5] Check daemon status" | 24 | echo "[2/5] Check daemon status" |
| 21 | -garcardctl status | 25 | +run_garcardctl status |
| 22 | 26 | ||
| 23 | echo "[3/5] Check pre-auth summary" | 27 | echo "[3/5] Check pre-auth summary" |
| 24 | -garcardctl auth-summary | 28 | +run_garcardctl auth-summary |
| 25 | 29 | ||
| 26 | echo "[4/5] Trigger interactive policy check" | 30 | echo "[4/5] Trigger interactive policy check" |
| 27 | echo "Action ID: ${ACTION_ID}" | 31 | echo "Action ID: ${ACTION_ID}" |
@@ -33,7 +37,7 @@ set -e | |||
| 33 | echo "pkcheck exit code: ${PKCHECK_RC}" | 37 | echo "pkcheck exit code: ${PKCHECK_RC}" |
| 34 | 38 | ||
| 35 | echo "[5/5] Check post-auth summary" | 39 | echo "[5/5] Check post-auth summary" |
| 36 | -garcardctl auth-summary | 40 | +run_garcardctl auth-summary |
| 37 | 41 | ||
| 38 | cat <<'EOF' | 42 | cat <<'EOF' |
| 39 | Exit code hints: | 43 | Exit code hints: |
garcard/src/agent.rsmodified@@ -16,6 +16,9 @@ pub trait AuthAgentBackend { | |||
| 16 | fn name(&self) -> &'static str; | 16 | fn name(&self) -> &'static str; |
| 17 | fn register(&mut self) -> Result<()>; | 17 | fn register(&mut self) -> Result<()>; |
| 18 | fn unregister(&mut self) -> Result<()>; | 18 | fn unregister(&mut self) -> Result<()>; |
| 19 | + fn maintain(&mut self) -> Result<()> { | ||
| 20 | + Ok(()) | ||
| 21 | + } | ||
| 19 | } | 22 | } |
| 20 | 23 | ||
| 21 | /// Placeholder backend used during Sprint 01 scaffolding. | 24 | /// Placeholder backend used during Sprint 01 scaffolding. |
@@ -472,6 +475,21 @@ impl PolkitAgent { | |||
| 472 | )?; | 475 | )?; |
| 473 | Ok(proxy) | 476 | Ok(proxy) |
| 474 | } | 477 | } |
| 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 | + } | ||
| 475 | } | 493 | } |
| 476 | 494 | ||
| 477 | impl AuthAgentBackend for PolkitAgent { | 495 | impl AuthAgentBackend for PolkitAgent { |
@@ -506,7 +524,7 @@ impl AuthAgentBackend for PolkitAgent { | |||
| 506 | &( | 524 | &( |
| 507 | &self.subject, | 525 | &self.subject, |
| 508 | self.locale.as_str(), | 526 | self.locale.as_str(), |
| 509 | - self.object_path.clone(), | 527 | + self.object_path.to_string(), |
| 510 | ), | 528 | ), |
| 511 | )?; | 529 | )?; |
| 512 | Ok(()) | 530 | Ok(()) |
@@ -534,22 +552,29 @@ impl AuthAgentBackend for PolkitAgent { | |||
| 534 | } | 552 | } |
| 535 | 553 | ||
| 536 | if let Some(connection) = &self.connection { | 554 | if let Some(connection) = &self.connection { |
| 537 | - match Self::proxy(connection)?.call::<_, _, ()>( | 555 | + match Self::proxy(connection) { |
| 538 | - "UnregisterAuthenticationAgent", | 556 | + Ok(proxy) => match proxy.call::<_, _, ()>( |
| 539 | - &( | 557 | + "UnregisterAuthenticationAgent", |
| 540 | - &self.subject, | 558 | + &(&self.subject, self.object_path.to_string()), |
| 541 | - self.locale.as_str(), | 559 | + ) { |
| 542 | - self.object_path.clone(), | 560 | + Ok(()) => { |
| 543 | - ), | 561 | + tracing::info!( |
| 544 | - ) { | 562 | + backend = self.name(), |
| 545 | - Ok(()) => { | 563 | + "Unregistered polkit authentication agent" |
| 546 | - tracing::info!( | 564 | + ); |
| 547 | - backend = self.name(), | 565 | + } |
| 548 | - "Unregistered polkit authentication agent" | 566 | + Err(err) => { |
| 549 | - ); | 567 | + tracing::warn!( |
| 550 | - } | 568 | + error = %err, |
| 569 | + "Failed to unregister polkit authentication agent" | ||
| 570 | + ); | ||
| 571 | + } | ||
| 572 | + }, | ||
| 551 | Err(err) => { | 573 | 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 | + ); | ||
| 553 | } | 578 | } |
| 554 | } | 579 | } |
| 555 | 580 | ||
@@ -566,18 +591,78 @@ impl AuthAgentBackend for PolkitAgent { | |||
| 566 | self.connection = None; | 591 | self.connection = None; |
| 567 | Ok(()) | 592 | Ok(()) |
| 568 | } | 593 | } |
| 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 | + } | ||
| 569 | } | 616 | } |
| 570 | 617 | ||
| 571 | fn build_subject() -> Subject { | 618 | 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 | + | ||
| 572 | let mut details = HashMap::new(); | 628 | let mut details = HashMap::new(); |
| 573 | details.insert("pid".to_string(), OwnedValue::from(std::process::id())); | 629 | details.insert("pid".to_string(), OwnedValue::from(std::process::id())); |
| 574 | details.insert( | 630 | details.insert( |
| 575 | "uid".to_string(), | 631 | "uid".to_string(), |
| 576 | OwnedValue::from(nix::unistd::geteuid().as_raw()), | 632 | OwnedValue::from(nix::unistd::geteuid().as_raw()), |
| 577 | ); | 633 | ); |
| 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 | + } | ||
| 578 | ("unix-process".to_string(), details) | 639 | ("unix-process".to_string(), details) |
| 579 | } | 640 | } |
| 580 | 641 | ||
| 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 | + | ||
| 581 | #[cfg(test)] | 666 | #[cfg(test)] |
| 582 | mod tests { | 667 | mod tests { |
| 583 | use super::*; | 668 | use super::*; |
@@ -608,9 +693,15 @@ mod tests { | |||
| 608 | #[test] | 693 | #[test] |
| 609 | fn subject_uses_unix_process_kind() { | 694 | fn subject_uses_unix_process_kind() { |
| 610 | let subject = build_subject(); | 695 | let subject = build_subject(); |
| 611 | - assert_eq!(subject.0, "unix-process"); | 696 | + match subject.0.as_str() { |
| 612 | - assert!(subject.1.contains_key("pid")); | 697 | + "unix-session" => assert!(subject.1.contains_key("session-id")), |
| 613 | - assert!(subject.1.contains_key("uid")); | 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 | + } | ||
| 614 | } | 705 | } |
| 615 | 706 | ||
| 616 | #[test] | 707 | #[test] |
garcard/src/config.rsmodified@@ -12,6 +12,7 @@ pub struct Config { | |||
| 12 | pub agent_backend: AgentBackendMode, | 12 | pub agent_backend: AgentBackendMode, |
| 13 | pub polkit_object_path: String, | 13 | pub polkit_object_path: String, |
| 14 | pub locale: String, | 14 | pub locale: String, |
| 15 | + pub backend_healthcheck_secs: u64, | ||
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | impl Default for Config { | 18 | impl Default for Config { |
@@ -22,6 +23,7 @@ impl Default for Config { | |||
| 22 | agent_backend: AgentBackendMode::Auto, | 23 | agent_backend: AgentBackendMode::Auto, |
| 23 | polkit_object_path: "/org/gardesk/Garcard/AuthAgent".to_string(), | 24 | polkit_object_path: "/org/gardesk/Garcard/AuthAgent".to_string(), |
| 24 | locale: std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()), | 25 | locale: std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()), |
| 26 | + backend_healthcheck_secs: 5, | ||
| 25 | } | 27 | } |
| 26 | } | 28 | } |
| 27 | } | 29 | } |
@@ -50,6 +52,12 @@ impl Config { | |||
| 50 | if let Some(raw_locale) = std::env::var_os("GARCARD_LOCALE") { | 52 | if let Some(raw_locale) = std::env::var_os("GARCARD_LOCALE") { |
| 51 | cfg.locale = raw_locale.to_string_lossy().to_string(); | 53 | cfg.locale = raw_locale.to_string_lossy().to_string(); |
| 52 | } | 54 | } |
| 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 | + } | ||
| 53 | 61 | ||
| 54 | Ok(cfg) | 62 | Ok(cfg) |
| 55 | } | 63 | } |
@@ -84,6 +92,12 @@ impl Config { | |||
| 84 | if let Some(locale) = file_cfg.locale { | 92 | if let Some(locale) = file_cfg.locale { |
| 85 | self.locale = locale; | 93 | self.locale = locale; |
| 86 | } | 94 | } |
| 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 | + } | ||
| 87 | 101 | ||
| 88 | Ok(()) | 102 | Ok(()) |
| 89 | } | 103 | } |
@@ -126,6 +140,7 @@ struct FileConfig { | |||
| 126 | agent_backend: Option<String>, | 140 | agent_backend: Option<String>, |
| 127 | polkit_object_path: Option<String>, | 141 | polkit_object_path: Option<String>, |
| 128 | locale: Option<String>, | 142 | locale: Option<String>, |
| 143 | + backend_healthcheck_secs: Option<String>, | ||
| 129 | } | 144 | } |
| 130 | 145 | ||
| 131 | fn config_path() -> Option<PathBuf> { | 146 | fn config_path() -> Option<PathBuf> { |
@@ -143,6 +158,17 @@ fn parse_octal_mode(raw: &str) -> Result<u32> { | |||
| 143 | Ok(mode) | 158 | Ok(mode) |
| 144 | } | 159 | } |
| 145 | 160 | ||
| 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 | + | ||
| 146 | #[cfg(test)] | 172 | #[cfg(test)] |
| 147 | mod tests { | 173 | mod tests { |
| 148 | use super::*; | 174 | use super::*; |
@@ -168,6 +194,7 @@ socket_mode = "640" | |||
| 168 | agent_backend = "stub" | 194 | agent_backend = "stub" |
| 169 | polkit_object_path = "/org/gardesk/Garcard/TestAgent" | 195 | polkit_object_path = "/org/gardesk/Garcard/TestAgent" |
| 170 | locale = "C" | 196 | locale = "C" |
| 197 | +backend_healthcheck_secs = "7" | ||
| 171 | "#, | 198 | "#, |
| 172 | ) | 199 | ) |
| 173 | .expect("parse file config"); | 200 | .expect("parse file config"); |
@@ -183,6 +210,7 @@ locale = "C" | |||
| 183 | Some("/org/gardesk/Garcard/TestAgent") | 210 | Some("/org/gardesk/Garcard/TestAgent") |
| 184 | ); | 211 | ); |
| 185 | assert_eq!(parsed.locale.as_deref(), Some("C")); | 212 | assert_eq!(parsed.locale.as_deref(), Some("C")); |
| 213 | + assert_eq!(parsed.backend_healthcheck_secs.as_deref(), Some("7")); | ||
| 186 | } | 214 | } |
| 187 | 215 | ||
| 188 | #[test] | 216 | #[test] |
@@ -206,4 +234,15 @@ locale = "C" | |||
| 206 | let err = AgentBackendMode::from_str("bad").expect_err("should fail"); | 234 | let err = AgentBackendMode::from_str("bad").expect_err("should fail"); |
| 207 | assert!(err.to_string().contains("unsupported backend mode")); | 235 | assert!(err.to_string().contains("unsupported backend mode")); |
| 208 | } | 236 | } |
| 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 | + } | ||
| 209 | } | 248 | } |
garcard/src/daemon.rsmodified@@ -7,6 +7,7 @@ use serde_json::json; | |||
| 7 | use std::os::unix::fs::PermissionsExt; | 7 | use std::os::unix::fs::PermissionsExt; |
| 8 | use std::path::{Path, PathBuf}; | 8 | use std::path::{Path, PathBuf}; |
| 9 | use std::sync::Arc; | 9 | use std::sync::Arc; |
| 10 | +use std::time::Duration; | ||
| 10 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; | 11 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; |
| 11 | use tokio::net::{UnixListener, UnixStream}; | 12 | use tokio::net::{UnixListener, UnixStream}; |
| 12 | use tokio::sync::mpsc; | 13 | use tokio::sync::mpsc; |
@@ -64,16 +65,37 @@ pub async fn run(config: Config) -> Result<()> { | |||
| 64 | .context("Failed to register SIGTERM handler")?; | 65 | .context("Failed to register SIGTERM handler")?; |
| 65 | let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) | 66 | let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) |
| 66 | .context("Failed to register SIGINT handler")?; | 67 | .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); | ||
| 67 | 73 | ||
| 68 | tracing::info!( | 74 | tracing::info!( |
| 69 | socket = %config.socket_path.display(), | 75 | socket = %config.socket_path.display(), |
| 70 | pid = state.status().pid, | 76 | pid = state.status().pid, |
| 71 | backend = backend.name(), | 77 | backend = backend.name(), |
| 78 | + backend_healthcheck_secs = config.backend_healthcheck_secs, | ||
| 72 | "garcard daemon started" | 79 | "garcard daemon started" |
| 73 | ); | 80 | ); |
| 74 | 81 | ||
| 75 | loop { | 82 | loop { |
| 76 | tokio::select! { | 83 | 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 | + } | ||
| 77 | _ = sigterm.recv() => { | 99 | _ = sigterm.recv() => { |
| 78 | tracing::info!("Received SIGTERM"); | 100 | tracing::info!("Received SIGTERM"); |
| 79 | break; | 101 | break; |
@@ -114,6 +136,12 @@ pub async fn run(config: Config) -> Result<()> { | |||
| 114 | Ok(()) | 136 | Ok(()) |
| 115 | } | 137 | } |
| 116 | 138 | ||
| 139 | +fn reconnect_backend(backend: &mut dyn AuthAgentBackend) -> Result<()> { | ||
| 140 | + backend.unregister()?; | ||
| 141 | + backend.register()?; | ||
| 142 | + Ok(()) | ||
| 143 | +} | ||
| 144 | + | ||
| 117 | fn init_backend(config: &Config, auth_state: Arc<AuthState>) -> Result<Box<dyn AuthAgentBackend>> { | 145 | fn init_backend(config: &Config, auth_state: Arc<AuthState>) -> Result<Box<dyn AuthAgentBackend>> { |
| 118 | match config.agent_backend { | 146 | match config.agent_backend { |
| 119 | AgentBackendMode::Stub => { | 147 | AgentBackendMode::Stub => { |
@@ -241,6 +269,9 @@ fn dispatch( | |||
| 241 | #[cfg(test)] | 269 | #[cfg(test)] |
| 242 | mod tests { | 270 | mod tests { |
| 243 | use super::*; | 271 | use super::*; |
| 272 | + use anyhow::Result as AnyResult; | ||
| 273 | + use std::sync::Arc; | ||
| 274 | + use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| 244 | 275 | ||
| 245 | fn fake_state() -> RuntimeState { | 276 | fn fake_state() -> RuntimeState { |
| 246 | RuntimeState::new("/tmp/garcard-test.sock".to_string(), "test-backend") | 277 | RuntimeState::new("/tmp/garcard-test.sock".to_string(), "test-backend") |
@@ -276,10 +307,46 @@ mod tests { | |||
| 276 | agent_backend: AgentBackendMode::Auto, | 307 | agent_backend: AgentBackendMode::Auto, |
| 277 | polkit_object_path: "invalid path".to_string(), | 308 | polkit_object_path: "invalid path".to_string(), |
| 278 | locale: "C".to_string(), | 309 | locale: "C".to_string(), |
| 310 | + backend_healthcheck_secs: 5, | ||
| 279 | }; | 311 | }; |
| 280 | 312 | ||
| 281 | let backend = init_backend(&config, Arc::new(AuthState::default())) | 313 | let backend = init_backend(&config, Arc::new(AuthState::default())) |
| 282 | .expect("auto mode should fall back"); | 314 | .expect("auto mode should fall back"); |
| 283 | assert_eq!(backend.name(), "stub-polkit-agent"); | 315 | assert_eq!(backend.name(), "stub-polkit-agent"); |
| 284 | } | 316 | } |
| 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(®ister_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 | + } | ||
| 285 | } | 352 | } |