crates/signald/src/main.rs
287 lines · 11263 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.
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//! signald --help | --version
29//! ```
30//! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else
31//! `~/.config/signald/repos`) is read — one path per line, `#` comments
32//! ignored — and failing that the current directory is watched. The socket defaults to
33//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
34//! history db and terminal spool default alongside it. `--collector` names the
35//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
36//! with a log line, when absent. History older than `--retention-days`
37//! (default 7) is pruned.
38
39use std::path::PathBuf;
40use std::thread;
41use std::time::Duration;
42
43use signal_schema::Source;
44use signald::collectors;
45use signald::history::History;
46use signald::supervisor;
47use signald::hub::Hub;
48use signald::publish;
49
50struct Config {
51 socket: PathBuf,
52 db: PathBuf,
53 spool: PathBuf,
54 collector: Option<PathBuf>,
55 interval: Duration,
56 retention: Duration,
57 repos: Vec<PathBuf>,
58}
59
60fn main() {
61 let cfg = parse_args();
62 print_self_attestation(&cfg);
63
64 let history = match History::open_with_retention(&cfg.db, cfg.retention) {
65 Ok(h) => {
66 eprintln!(
67 "signald: history at {} (retention {} days)",
68 cfg.db.display(),
69 cfg.retention.as_secs() / 86_400
70 );
71 h
72 }
73 Err(e) => {
74 eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
75 std::process::exit(1);
76 }
77 };
78 let hub = Hub::with_history(history);
79
80 // Every collector reports through one Health, so a state change reaches the
81 // bus once and only when it is a change.
82 let health = supervisor::Health::new();
83
84 // Hardware: keep the out-of-process collector running. Not having one
85 // (Linux, or a dev build not on PATH) is not fatal and is not retried.
86 match &cfg.collector {
87 Some(collector) => {
88 eprintln!("signald: hardware collector {}", collector.display());
89 let (hub, health) = (hub.clone(), health.clone());
90 let collector = collector.clone();
91 let interval_ms = cfg.interval.as_millis() as u64;
92 thread::spawn(move || supervisor::supervise_hardware(hub, health, collector, interval_ms));
93 }
94 None => supervisor::report_unconfigured(&hub, &health, collectors::hardware::PROGRAM),
95 }
96
97 // Producer: poll the git and terminal collectors on a tick and publish into
98 // the hub. Runs for the life of the daemon, independent of any subscriber.
99 {
100 let (hub, health) = (hub.clone(), health.clone());
101 let repos = cfg.repos.clone();
102 let spool = cfg.spool.clone();
103 let interval = cfg.interval;
104 thread::spawn(move || supervisor::run_producer(hub, health, repos, spool, interval));
105 }
106
107 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
108 if let Err(e) = publish::serve(&cfg.socket, hub) {
109 eprintln!("signald: fatal: {e}");
110 std::process::exit(1);
111 }
112}
113
114const USAGE: &str = "\
115Usage: signald [options] [<repo-path> ...]
116
117 --socket <path> listen here (default $XDG_RUNTIME_DIR/signald.sock,
118 else ~/.local/state/signald/sock)
119 --db <path> sqlite history (default: beside the socket)
120 --spool <path> terminal spool (default: beside the socket)
121 --collector <path> macos-collector binary (default: found on PATH)
122 --interval-ms <n> collector tick (default 2000)
123 --retention-days <n> history retention (default 7)
124 -h, --help print this and exit
125 -V, --version print the version and exit
126
127With no <repo-path>, $XDG_CONFIG_HOME/signald/repos (else
128~/.config/signald/repos) is read, one path per line, # comments ignored.
129Failing that, the working directory is watched.
130";
131
132fn parse_args() -> Config {
133 let mut socket: Option<PathBuf> = None;
134 let mut db: Option<PathBuf> = None;
135 let mut spool: Option<PathBuf> = None;
136 let mut collector: Option<PathBuf> = None;
137 let mut interval_ms: u64 = 2000;
138 let mut retention_days: u64 = 7;
139 let mut repos: Vec<PathBuf> = Vec::new();
140
141 let mut args = std::env::args().skip(1);
142 while let Some(arg) = args.next() {
143 match arg.as_str() {
144 "--socket" => socket = args.next().map(PathBuf::from),
145 "--db" => db = args.next().map(PathBuf::from),
146 "--spool" => spool = args.next().map(PathBuf::from),
147 "--collector" => collector = args.next().map(PathBuf::from),
148 "--interval-ms" => {
149 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
150 }
151 "--retention-days" => {
152 retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
153 }
154 "--help" | "-h" => {
155 print!("{USAGE}");
156 std::process::exit(0);
157 }
158 "--version" | "-V" => {
159 println!("signald {}", env!("CARGO_PKG_VERSION"));
160 std::process::exit(0);
161 }
162 // Without this an unknown flag becomes a repository path and the
163 // daemon starts anyway, watching a directory that does not exist.
164 _ if arg.starts_with('-') => {
165 eprintln!("signald: unknown option {arg}\n");
166 eprint!("{USAGE}");
167 std::process::exit(2);
168 }
169 _ => repos.push(PathBuf::from(arg)),
170 }
171 }
172
173 if repos.is_empty() {
174 repos = repos_from_config_file();
175 }
176 if repos.is_empty() {
177 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
178 }
179 let socket = socket.unwrap_or_else(signal_client::default_socket_path);
180 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
181 Config {
182 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
183 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
184 collector: collector.or_else(collectors::hardware::find_on_path),
185 interval: Duration::from_millis(interval_ms),
186 retention: Duration::from_secs(retention_days * 86_400),
187 repos,
188 socket,
189 }
190}
191
192/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
193fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
194 match xdg_config_home {
195 Some(dir) => PathBuf::from(dir).join("signald/repos"),
196 None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
197 }
198}
199
200/// One repository path per line. Blank lines and `#` comments are ignored.
201fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
202 contents
203 .lines()
204 .map(|l| l.trim())
205 .filter(|l| !l.is_empty() && !l.starts_with('#'))
206 .map(PathBuf::from)
207 .collect()
208}
209
210/// Repositories to watch when none were given on the command line.
211///
212/// A login agent has no useful working directory — launchd starts it in `/` —
213/// so falling straight through to the cwd would leave `brew services start
214/// signald` collecting no git signals at all. A missing file is not an error;
215/// the cwd fallback still applies.
216fn repos_from_config_file() -> Vec<PathBuf> {
217 let path = repos_config_path(
218 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
219 std::env::var("HOME").ok().as_deref(),
220 );
221 match std::fs::read_to_string(&path) {
222 Ok(contents) => {
223 let repos = parse_repos_file(&contents);
224 if !repos.is_empty() {
225 eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
226 }
227 repos
228 }
229 Err(_) => Vec::new(),
230 }
231}
232
233/// Log enabled collectors and assert none holds an input-tap capability. A real
234/// keylogger would need one of the forbidden APIs; their absence is the point,
235/// and this is the runtime half of that guarantee.
236fn print_self_attestation(cfg: &Config) {
237 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
238 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
239 eprintln!(" history: sqlite (WAL), aggregate scalars only");
240 let hardware = match &cfg.collector {
241 Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
242 None => format!(
243 "DISABLED ({} not on PATH; pass --collector <path>)",
244 collectors::hardware::PROGRAM
245 ),
246 };
247 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
248 let state = match source {
249 Source::Git => "ENABLED (aggregate scalars only)",
250 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
251 Source::Macos | Source::Hardware => hardware.as_str(),
252 };
253 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
254 }
255}
256
257#[cfg(test)]
258mod path_tests {
259 use super::*;
260
261 #[test]
262 fn repos_file_skips_blanks_and_comments() {
263 let repos = parse_repos_file(
264 "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n",
265 );
266 assert_eq!(
267 repos,
268 vec![
269 PathBuf::from("/Users/x/git/one"),
270 PathBuf::from("/Users/x/git/two")
271 ]
272 );
273 }
274
275 #[test]
276 fn repos_config_follows_xdg_then_home() {
277 assert_eq!(
278 repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
279 PathBuf::from("/Users/x/.config/signald/repos")
280 );
281 assert_eq!(
282 repos_config_path(None, Some("/Users/x")),
283 PathBuf::from("/Users/x/.config/signald/repos")
284 );
285 }
286
287}