git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

src/bin/cce-keyring-sync/daemon.rs (3K)

 1 //! `daemon` — the resident parent that keeps the `op` authorization alive.
 2 //!
 3 //! Phase 0 (KEYRING-SYNC.md) measured the rule this loop lives by: the
 4 //! app's authorization is keyed to the calling process's parent and lapses
 5 //! after ~10 idle minutes, but use extends it indefinitely. So this process
 6 //! stays up for the session, ticks every [`TICK`], and every tick is one
 7 //! `op item list` under its own pid. One Authorize dialog per login, then
 8 //! none, as long as nothing (suspend, the app locking) opens a gap.
 9 //!
10 //! A dialog nobody answers costs a 60-second hang and comes back as
11 //! `authorization prompt dismissed`; re-offering one every five minutes to an
12 //! empty chair is the annoyance the timer design was rejected for, so after a
13 //! dismissal the tick backs off (15 → 30 → 60 minutes) until something asks:
14 //! `SIGUSR1`, which cce-secrets sends from its Sync button and after a save.
15 
16 use std::time::Duration;
17 
18 use tokio::signal::unix::{signal, SignalKind};
19 
20 use crate::op::{is_dismissed, OnePassword};
21 use crate::sync::sync_remote;
22 use crate::{now_unix, State};
23 
24 /// Inside the ~10-minute idle window with margin.
25 pub const TICK: Duration = Duration::from_secs(5 * 60);
26 const BACKOFF: [Duration; 3] = [Duration::from_secs(15 * 60), Duration::from_secs(30 * 60), Duration::from_secs(60 * 60)];
27 
28 pub async fn daemon(state_path: &std::path::Path) {
29     let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
30     let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
31     let mut dismissed = 0usize;
32     println!("cce-keyring-sync daemon: tick every {}s, SIGUSR1 syncs now", TICK.as_secs());
33 
34     loop {
35         // Re-read every tick: adopt or a manual sync may have moved the base.
36         let mut state: State = std::fs::read_to_string(state_path)
37             .ok()
38             .and_then(|s| serde_json::from_str(&s).ok())
39             .unwrap_or_default();
40         let wait = if state.backend != "onepassword" {
41             eprintln!("{}: base is not 1Password's; idling until `adopt` runs", now_unix());
42             TICK
43         } else {
44             let mut remote = OnePassword::new(&state.vault);
45             match sync_remote(&mut remote, state_path, &mut state, false).await {
46                 Ok(_) => {
47                     dismissed = 0;
48                     TICK
49                 }
50                 Err(e) if is_dismissed(&e) => {
51                     let w = BACKOFF[dismissed.min(BACKOFF.len() - 1)];
52                     dismissed += 1;
53                     eprintln!("authorization dialog unanswered; next try in {}m (or SIGUSR1)", w.as_secs() / 60);
54                     w
55                 }
56                 Err(e) => {
57                     eprintln!("sync: {e}");
58                     TICK
59                 }
60             }
61         };
62         tokio::select! {
63             _ = tokio::time::sleep(wait) => {}
64             _ = usr1.recv() => { dismissed = 0; }
65             _ = term.recv() => { println!("cce-keyring-sync daemon: stopping"); return; }
66         }
67     }
68 }