crates/signald/src/main.rs
190 lines · 7701 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//! ```
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 // The audited repo-path tag never leaves the watched roots.
97 if !signald::tag_within_roots(&sig, &repos) {
98 continue;
99 }
100 producer.publish(sig);
101 }
102 }
103 for sig in terminal.collect(&spool) {
104 producer.publish(sig);
105 }
106 thread::sleep(interval);
107 }
108 });
109
110 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
111 if let Err(e) = publish::serve(&cfg.socket, hub) {
112 eprintln!("signald: fatal: {e}");
113 std::process::exit(1);
114 }
115}
116
117fn parse_args() -> Config {
118 let mut socket: Option<PathBuf> = None;
119 let mut db: Option<PathBuf> = None;
120 let mut spool: Option<PathBuf> = None;
121 let mut collector: Option<PathBuf> = None;
122 let mut interval_ms: u64 = 2000;
123 let mut retention_days: u64 = 7;
124 let mut repos: Vec<PathBuf> = Vec::new();
125
126 let mut args = std::env::args().skip(1);
127 while let Some(arg) = args.next() {
128 match arg.as_str() {
129 "--socket" => socket = args.next().map(PathBuf::from),
130 "--db" => db = args.next().map(PathBuf::from),
131 "--spool" => spool = args.next().map(PathBuf::from),
132 "--collector" => collector = args.next().map(PathBuf::from),
133 "--interval-ms" => {
134 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
135 }
136 "--retention-days" => {
137 retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
138 }
139 _ => repos.push(PathBuf::from(arg)),
140 }
141 }
142
143 if repos.is_empty() {
144 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
145 }
146 let socket = socket.unwrap_or_else(default_socket_path);
147 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
148 Config {
149 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
150 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
151 collector: collector.or_else(collectors::hardware::find_on_path),
152 interval: Duration::from_millis(interval_ms),
153 retention: Duration::from_secs(retention_days * 86_400),
154 repos,
155 socket,
156 }
157}
158
159/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
160fn default_socket_path() -> PathBuf {
161 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
162 return PathBuf::from(dir).join("signald.sock");
163 }
164 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
165 PathBuf::from(home).join(".local/state/signald/sock")
166}
167
168/// Log enabled collectors and assert none holds an input-tap capability. A real
169/// keylogger would need one of the forbidden APIs; their absence is the point,
170/// and this is the runtime half of that guarantee.
171fn print_self_attestation(cfg: &Config) {
172 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
173 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
174 eprintln!(" history: sqlite (WAL), aggregate scalars only");
175 let hardware = match &cfg.collector {
176 Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
177 None => format!(
178 "DISABLED ({} not on PATH; pass --collector <path>)",
179 collectors::hardware::PROGRAM
180 ),
181 };
182 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
183 let state = match source {
184 Source::Git => "ENABLED (aggregate scalars only)",
185 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
186 Source::Macos | Source::Hardware => hardware.as_str(),
187 };
188 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
189 }
190}