crates/signald/src/main.rs
186 lines · 7524 bytes
1//! # signald
2//!
3//! The one long-lived, user-level daemon that owns every collector and
4//! publishes a signal stream. Renderers are thin subscribers over a local Unix
5//! socket — no renderer ever touches a sensor (spec §1.3).
6//!
7//! ```text
8//! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool)
9//! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out
10//! IOKit/AppKit ▶ system+hw collector ┘ (macos-collector child, frames on stdout)
11//! ├─▶ publish: Unix socket (live)
12//! └─▶ SQLite WAL history
13//! ```
14//!
15//! The git and terminal collectors run in-process on a tick; the hardware
16//! collector is the sibling `macos-collector` binary, spawned as a child whose
17//! stdout frames are ingested into the same hub. The hub caches the latest
18//! value of every metric and streams updates to subscribers; every signal is
19//! persisted to a SQLite (WAL) history store. Collector internals live in the
20//! `signald` library crate; this binary is argument wiring, the producer loop,
21//! and self-attestation.
22//!
23//! Usage:
24//! ```text
25//! signald [--socket <path>] [--db <path>] [--spool <path>]
26//! [--collector <path>] [--interval-ms <n>] [--retention-days <n>]
27//! [<repo-path> ...]
28//! ```
29//! With no repo paths, the current directory is watched. The socket defaults to
30//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
31//! history db and terminal spool default alongside it. `--collector` names the
32//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
33//! with a log line, when absent. History older than `--retention-days`
34//! (default 7) is pruned.
35
36use std::path::PathBuf;
37use std::thread;
38use std::time::Duration;
39
40use signal_schema::Source;
41use signald::collectors;
42use signald::history::History;
43use signald::hub::Hub;
44use signald::publish;
45
46struct Config {
47 socket: PathBuf,
48 db: PathBuf,
49 spool: PathBuf,
50 collector: Option<PathBuf>,
51 interval: Duration,
52 retention: Duration,
53 repos: Vec<PathBuf>,
54}
55
56fn main() {
57 let cfg = parse_args();
58 print_self_attestation(&cfg);
59
60 let history = match History::open_with_retention(&cfg.db, cfg.retention) {
61 Ok(h) => {
62 eprintln!(
63 "signald: history at {} (retention {} days)",
64 cfg.db.display(),
65 cfg.retention.as_secs() / 86_400
66 );
67 h
68 }
69 Err(e) => {
70 eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
71 std::process::exit(1);
72 }
73 };
74 let hub = Hub::with_history(history);
75
76 // Hardware: spawn the out-of-process collector and ingest its frames. Not
77 // having one (Linux, or a dev build not on PATH) is not fatal.
78 if let Some(collector) = &cfg.collector {
79 match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) {
80 Ok(_) => eprintln!("signald: hardware collector {}", collector.display()),
81 Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()),
82 }
83 }
84
85 // Producer: poll the collectors on a tick and publish into the hub. Runs
86 // for the life of the daemon, independent of any subscriber.
87 let producer = hub.clone();
88 let repos = cfg.repos.clone();
89 let spool = cfg.spool.clone();
90 let interval = cfg.interval;
91 thread::spawn(move || {
92 let mut terminal = collectors::terminal::Collector::new();
93 loop {
94 for repo in &repos {
95 for sig in collectors::git::collect(repo) {
96 producer.publish(sig);
97 }
98 }
99 for sig in terminal.collect(&spool) {
100 producer.publish(sig);
101 }
102 thread::sleep(interval);
103 }
104 });
105
106 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
107 if let Err(e) = publish::serve(&cfg.socket, hub) {
108 eprintln!("signald: fatal: {e}");
109 std::process::exit(1);
110 }
111}
112
113fn parse_args() -> Config {
114 let mut socket: Option<PathBuf> = None;
115 let mut db: Option<PathBuf> = None;
116 let mut spool: Option<PathBuf> = None;
117 let mut collector: Option<PathBuf> = None;
118 let mut interval_ms: u64 = 2000;
119 let mut retention_days: u64 = 7;
120 let mut repos: Vec<PathBuf> = Vec::new();
121
122 let mut args = std::env::args().skip(1);
123 while let Some(arg) = args.next() {
124 match arg.as_str() {
125 "--socket" => socket = args.next().map(PathBuf::from),
126 "--db" => db = args.next().map(PathBuf::from),
127 "--spool" => spool = args.next().map(PathBuf::from),
128 "--collector" => collector = args.next().map(PathBuf::from),
129 "--interval-ms" => {
130 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
131 }
132 "--retention-days" => {
133 retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
134 }
135 _ => repos.push(PathBuf::from(arg)),
136 }
137 }
138
139 if repos.is_empty() {
140 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
141 }
142 let socket = socket.unwrap_or_else(default_socket_path);
143 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
144 Config {
145 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
146 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
147 collector: collector.or_else(collectors::hardware::find_on_path),
148 interval: Duration::from_millis(interval_ms),
149 retention: Duration::from_secs(retention_days * 86_400),
150 repos,
151 socket,
152 }
153}
154
155/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
156fn default_socket_path() -> PathBuf {
157 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
158 return PathBuf::from(dir).join("signald.sock");
159 }
160 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
161 PathBuf::from(home).join(".local/state/signald/sock")
162}
163
164/// Log enabled collectors and assert none holds an input-tap capability. A real
165/// keylogger would need one of the forbidden APIs; their absence is the point,
166/// and this is the runtime half of that guarantee (spec §1.5).
167fn print_self_attestation(cfg: &Config) {
168 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
169 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
170 eprintln!(" history: sqlite (WAL), aggregate scalars only");
171 let hardware = match &cfg.collector {
172 Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
173 None => format!(
174 "DISABLED ({} not on PATH; pass --collector <path>)",
175 collectors::hardware::PROGRAM
176 ),
177 };
178 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
179 let state = match source {
180 Source::Git => "ENABLED (aggregate scalars only)",
181 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
182 Source::Macos | Source::Hardware => hardware.as_str(),
183 };
184 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
185 }
186}