secrets manager
git clone https://git.lucas.co/cce-secrets.git
src/bin/cce-keyring-sync/daemon.rs (4K)
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 //! A prompt nobody answered includes the app's own unlock: it starts locked
16 //! at login, and until someone types the account password a call ends in
17 //! `authorization timeout` (measured 2026-10-01).
18 //!
19 //! At login this unit also starts before the app, so a call that finds no
20 //! app retries every [`APP_DOWN_RETRY`] for a few minutes instead of
21 //! waiting out a whole tick.
22 //!
23 //! The same pid serves one-time codes to cce-secrets (serve.rs): its
24 //! authorization is the reason a code needs no dialog.
25
26 use std::time::Duration;
27
28 use tokio::signal::unix::{signal, SignalKind};
29
30 use crate::op::{is_app_down, is_dismissed, OnePassword};
31 use crate::sync::sync_remote;
32 use crate::{now_unix, State};
33
34 /// Inside the ~10-minute idle window with margin.
35 pub const TICK: Duration = Duration::from_secs(5 * 60);
36 const BACKOFF: [Duration; 3] = [Duration::from_secs(15 * 60), Duration::from_secs(30 * 60), Duration::from_secs(60 * 60)];
37 /// While the app is not up yet; [`APP_DOWN_TRIES`] of these, then ticks.
38 const APP_DOWN_RETRY: Duration = Duration::from_secs(30);
39 const APP_DOWN_TRIES: usize = 10;
40
41 pub async fn daemon(state_path: &std::path::Path) {
42 let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
43 let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
44 let mut dismissed = 0usize;
45 let mut app_down = 0usize;
46 println!("cce-keyring-sync daemon: tick every {}s, SIGUSR1 syncs now", TICK.as_secs());
47 tokio::spawn(crate::serve::serve(state_path.to_path_buf()));
48
49 loop {
50 // Re-read every tick: adopt or a manual sync may have moved the base.
51 let mut state: State = std::fs::read_to_string(state_path)
52 .ok()
53 .and_then(|s| serde_json::from_str(&s).ok())
54 .unwrap_or_default();
55 let wait = if state.backend != "onepassword" {
56 eprintln!("{}: base is not 1Password's; idling until `adopt` runs", now_unix());
57 TICK
58 } else {
59 let mut remote = OnePassword::new(&state.vault);
60 match sync_remote(&mut remote, state_path, &mut state, false, false).await {
61 Ok(_) => {
62 dismissed = 0;
63 app_down = 0;
64 TICK
65 }
66 Err(e) if is_app_down(&e) && app_down < APP_DOWN_TRIES => {
67 app_down += 1;
68 eprintln!("1Password app not running; retrying in {}s", APP_DOWN_RETRY.as_secs());
69 APP_DOWN_RETRY
70 }
71 Err(e) if is_dismissed(&e) => {
72 let w = BACKOFF[dismissed.min(BACKOFF.len() - 1)];
73 dismissed += 1;
74 eprintln!("authorization dialog unanswered; next try in {}m (or SIGUSR1)", w.as_secs() / 60);
75 w
76 }
77 Err(e) => {
78 eprintln!("sync: {e}");
79 TICK
80 }
81 }
82 };
83 tokio::select! {
84 _ = tokio::time::sleep(wait) => {}
85 _ = usr1.recv() => { dismissed = 0; }
86 _ = term.recv() => { println!("cce-keyring-sync daemon: stopping"); return; }
87 }
88 }
89 }