Skip to main content

bestool_alertd/
daemon.rs

1use std::{sync::Arc, time::Duration};
2
3use miette::{Result, miette};
4use tokio::sync::{mpsc, oneshot};
5use tracing::{error, info};
6
7use crate::{
8	DaemonConfig, LogError, canopy::CanopyClient, context::InternalContext, http_server, metrics,
9	tasks::TaskContext,
10};
11
12enum DaemonEvent {
13	/// Clean stop (SIGINT/SIGTERM, or the service manager): exit 0, no restart.
14	Shutdown,
15	/// Exit non-zero so the service manager (systemd `Restart=`, Windows SCM
16	/// recovery) brings the daemon back — how `bestool alertd restart` works.
17	Restart,
18	WatchdogTimeout,
19}
20
21/// Handle the HTTP control endpoints use to drive the daemon.
22///
23/// `pub` only so it can appear in the (also-internal) `ServerState` /
24/// `start_server` signatures without tripping `private_interfaces`; the
25/// enclosing `daemon` module is private, so it isn't part of the public API.
26#[derive(Clone)]
27pub struct DaemonControl {
28	reload: Arc<tokio::sync::watch::Sender<u64>>,
29	events: mpsc::Sender<DaemonEvent>,
30}
31
32impl DaemonControl {
33	/// Bump the reload channel so tasks refresh (HTTP `/reload`).
34	pub(crate) fn reload(&self) {
35		self.reload.send_modify(|n| *n = n.wrapping_add(1));
36	}
37
38	/// Ask the daemon to exit so the service manager restarts it (HTTP `/restart`).
39	pub(crate) async fn request_restart(&self) {
40		let _ = self.events.send(DaemonEvent::Restart).await;
41	}
42
43	/// A detached control whose channels go nowhere, for tests.
44	#[cfg(test)]
45	pub(crate) fn detached() -> Self {
46		let (reload, _) = tokio::sync::watch::channel(0);
47		let (events, _) = mpsc::channel(1);
48		Self {
49			reload: Arc::new(reload),
50			events,
51		}
52	}
53}
54
55/// A handle a background task can use to ask the daemon to restart itself.
56///
57/// Held by [`TaskContext`](crate::tasks::TaskContext) so a task that has
58/// replaced the running binary (self-update) can have the daemon exit for the
59/// service manager to relaunch the new binary, via the same path as the
60/// `/restart` control.
61#[derive(Clone, Debug)]
62pub struct RestartTrigger {
63	events: mpsc::Sender<DaemonEvent>,
64}
65
66impl RestartTrigger {
67	/// Ask the daemon to exit so the service manager restarts it.
68	pub async fn request_restart(&self) {
69		let _ = self.events.send(DaemonEvent::Restart).await;
70	}
71}
72
73pub async fn run(daemon_config: DaemonConfig) -> Result<()> {
74	let (_shutdown_tx, shutdown_rx) = oneshot::channel();
75	run_with_shutdown(daemon_config, shutdown_rx).await
76}
77
78pub async fn run_with_shutdown(
79	daemon_config: DaemonConfig,
80	external_shutdown: oneshot::Receiver<()>,
81) -> Result<()> {
82	info!("starting alertd daemon");
83
84	metrics::init_metrics();
85	metrics::record_activity();
86
87	let pool = daemon_config.pg_pool.clone();
88
89	let canopy_client = match CanopyClient::new(
90		daemon_config.device_key_pem.as_ref().map(|r| r.0.as_str()),
91		crate::http_builder,
92	)
93	.await
94	{
95		Ok(Some(client)) => {
96			if client.is_tailscale().await {
97				info!("canopy client ready via tailscale");
98			} else {
99				info!("canopy client ready via mTLS");
100			}
101			let client = Arc::new(client);
102			let renew = client.clone();
103			tokio::spawn(async move {
104				let mut interval = tokio::time::interval(crate::canopy::CERT_RENEW_AFTER);
105				interval.tick().await; // skip the immediate first tick
106				loop {
107					interval.tick().await;
108					if !renew.is_tailscale().await {
109						info!("renewing canopy mTLS certificate");
110						if let Err(err) = renew.renew().await {
111							error!("failed to renew canopy cert: {}", LogError(&err));
112						}
113					}
114				}
115			});
116			Some(client)
117		}
118		Ok(None) => {
119			info!(
120				"no canopy auth path available (no tailscale, no device key); canopy posting will be skipped"
121			);
122			None
123		}
124		Err(err) => {
125			error!("failed to build canopy client: {}", LogError(&err));
126			None
127		}
128	};
129
130	// Reload channel: the SIGHUP/SIGUSR1 handler (and the `/reload` HTTP control)
131	// bump it; tasks watch it to refresh without a restart.
132	let (reload_tx, reload_rx) = tokio::sync::watch::channel(0u64);
133	let reload_tx = Arc::new(reload_tx);
134
135	let (event_tx, mut event_rx) = mpsc::channel(100);
136
137	let ctx = Arc::new(InternalContext {
138		pg_pool: pool,
139		http_client: crate::http_client(),
140		canopy_client,
141		reload: reload_rx,
142		restart: Some(RestartTrigger {
143			events: event_tx.clone(),
144		}),
145	});
146
147	// Control handle for the HTTP server's `/reload` and `/restart` endpoints.
148	let control = DaemonControl {
149		reload: reload_tx.clone(),
150		events: event_tx.clone(),
151	};
152
153	// Start HTTP server
154	if !daemon_config.no_server {
155		let ctx_for_server = ctx.clone();
156		let background_tasks_for_server = daemon_config.background_tasks.clone();
157		let server_addrs = daemon_config.server_addrs.clone();
158		let watchdog_timeout = daemon_config.watchdog_timeout;
159		let backups = daemon_config.backups.clone();
160		tokio::spawn(async move {
161			http_server::start_server(
162				ctx_for_server,
163				server_addrs,
164				watchdog_timeout,
165				&background_tasks_for_server,
166				control,
167				backups,
168			)
169			.await;
170		});
171	}
172
173	// SIGINT handler
174	let signal_tx = event_tx.clone();
175	tokio::spawn(async move {
176		match tokio::signal::ctrl_c().await {
177			Ok(()) => {
178				info!("received SIGINT, shutting down");
179				let _ = signal_tx.send(DaemonEvent::Shutdown).await;
180			}
181			Err(err) => {
182				error!("unable to listen for shutdown signal: {}", err);
183			}
184		}
185	});
186
187	// External shutdown signal (for Windows service)
188	let external_signal_tx = event_tx.clone();
189	tokio::spawn(async move {
190		let _ = external_shutdown.await;
191		info!("received external shutdown signal");
192		let _ = external_signal_tx.send(DaemonEvent::Shutdown).await;
193	});
194
195	#[cfg(unix)]
196	{
197		use tokio::signal::unix::{SignalKind, signal};
198		let signal_tx_term = event_tx.clone();
199		tokio::spawn(async move {
200			let mut sigterm =
201				signal(SignalKind::terminate()).expect("failed to setup SIGTERM handler");
202			sigterm.recv().await;
203			info!("received SIGTERM, shutting down");
204			let _ = signal_tx_term.send(DaemonEvent::Shutdown).await;
205		});
206
207		// Reload on SIGHUP (systemd's notify-reload `ReloadSignal`) or SIGUSR1:
208		// notify systemd we're reloading, bump the reload channel so tasks
209		// refresh, then notify ready again. The reload work itself is async and
210		// best-effort, so READY is sent once the refresh is dispatched.
211		tokio::spawn(async move {
212			let mut sighup = signal(SignalKind::hangup()).expect("failed to setup SIGHUP handler");
213			let mut sigusr1 =
214				signal(SignalKind::user_defined1()).expect("failed to setup SIGUSR1 handler");
215			loop {
216				tokio::select! {
217					_ = sighup.recv() => {}
218					_ = sigusr1.recv() => {}
219				}
220				info!("received reload signal; refreshing");
221				let mut reloading = vec![sd_notify::NotifyState::Reloading];
222				if let Ok(stamp) = sd_notify::NotifyState::monotonic_usec_now() {
223					reloading.push(stamp);
224				}
225				let _ = sd_notify::notify(&reloading);
226				reload_tx.send_modify(|n| *n = n.wrapping_add(1));
227				let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]);
228			}
229		});
230	}
231	#[cfg(not(unix))]
232	let _ = reload_tx; // no reload signals off Unix; tasks keep their other triggers
233
234	// Registered background tasks (e.g. the doctor sweep). Each ticks at its
235	// own interval; errors are logged but don't tear down the daemon.
236	for task in &daemon_config.background_tasks {
237		let task = task.clone();
238		let task_ctx = TaskContext::from_internal(&ctx);
239		info!(name = task.name(), interval = ?task.interval(), "registering background task");
240		tokio::spawn(async move {
241			let mut tick = tokio::time::interval(task.interval());
242			tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
243			loop {
244				tick.tick().await;
245				metrics::record_activity();
246				if let Err(err) = task.run(&task_ctx).await {
247					error!(
248						name = task.name(),
249						"background task failed: {}",
250						LogError(&err)
251					);
252				}
253			}
254		});
255	}
256
257	// Watchdog: if no task has ticked within the timeout, shut down so the
258	// service manager (Windows SCM / systemd / etc.) can restart us.
259	if let Some(watchdog_timeout) = daemon_config.watchdog_timeout {
260		let watchdog_tx = event_tx.clone();
261		tokio::spawn(async move {
262			// Give the daemon time to start up and run its first tick
263			let grace = watchdog_timeout.max(Duration::from_secs(60));
264			tokio::time::sleep(grace).await;
265
266			let mut check_interval = tokio::time::interval(Duration::from_secs(30));
267			check_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
268			loop {
269				check_interval.tick().await;
270				let last = metrics::last_activity_timestamp();
271				let now = jiff::Timestamp::now().as_second();
272				let elapsed = Duration::from_secs(now.saturating_sub(last) as u64);
273				if elapsed > watchdog_timeout {
274					error!(
275						?elapsed,
276						?watchdog_timeout,
277						"watchdog: no task activity detected within timeout, shutting down"
278					);
279					let _ = watchdog_tx.send(DaemonEvent::WatchdogTimeout).await;
280					break;
281				}
282			}
283		});
284	}
285
286	info!("daemon started successfully");
287	// Tell systemd (Type=notify[-reload]) we're up; no-op when not under systemd.
288	// The status line surfaces the running version and the canopy transport
289	// (which is fixed at startup), so `systemctl status` shows them at a glance.
290	#[cfg(unix)]
291	{
292		let canopy = match &ctx.canopy_client {
293			Some(client) if client.is_tailscale().await => "canopy via tailscale",
294			Some(_) => "canopy via mTLS",
295			None => "canopy not connected",
296		};
297		let status = format!(
298			"monitoring; bestool {}; {canopy}",
299			daemon_config.binary_version
300		);
301		let _ = sd_notify::notify(&[
302			sd_notify::NotifyState::Ready,
303			sd_notify::NotifyState::Status(&status),
304		]);
305	}
306
307	// Block until the first lifecycle event arrives: a shutdown signal, or the
308	// watchdog firing. `None` means every sender was dropped, which we treat as
309	// a shutdown too.
310	let event = event_rx.recv().await;
311	#[cfg(unix)]
312	let _ = sd_notify::notify(&[sd_notify::NotifyState::Stopping]);
313	match event {
314		Some(DaemonEvent::Shutdown) | None => {
315			info!("daemon stopped");
316			Ok(())
317		}
318		Some(DaemonEvent::Restart) => {
319			// Exit non-zero so the service manager restarts us (systemd
320			// `Restart=`, Windows SCM recovery).
321			info!("restart requested; exiting for the service manager to restart");
322			Err(miette!("restart requested"))
323		}
324		Some(DaemonEvent::WatchdogTimeout) => {
325			error!("daemon exiting due to watchdog timeout");
326			Err(miette!("watchdog timeout: no task activity detected"))
327		}
328	}
329}