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	// Tie spawned children (pg_basebackup, kopia) to this process, so a daemon
85	// restart can't leave a backup running to collide with the next one.
86	crate::child_confinement::confine_children();
87
88	metrics::record_activity();
89
90	let pool = daemon_config.pg_pool.clone();
91
92	let canopy_client = match CanopyClient::new(
93		daemon_config.device_key_pem.as_ref().map(|r| r.0.as_str()),
94		crate::http_builder,
95	)
96	.await
97	{
98		Ok(Some(client)) => {
99			if client.is_tailscale().await {
100				info!("canopy client ready via tailscale");
101			} else {
102				info!("canopy client ready via mTLS");
103			}
104			let client = Arc::new(client);
105			let renew = client.clone();
106			tokio::spawn(async move {
107				let mut interval = tokio::time::interval(crate::canopy::CERT_RENEW_AFTER);
108				interval.tick().await; // skip the immediate first tick
109				loop {
110					interval.tick().await;
111					if !renew.is_tailscale().await {
112						info!("renewing canopy mTLS certificate");
113						if let Err(err) = renew.renew().await {
114							error!("failed to renew canopy cert: {}", LogError(&err));
115						}
116					}
117				}
118			});
119			Some(client)
120		}
121		Ok(None) => {
122			info!(
123				"no canopy auth path available (no tailscale, no device key); canopy posting will be skipped"
124			);
125			None
126		}
127		Err(err) => {
128			error!("failed to build canopy client: {}", LogError(&err));
129			None
130		}
131	};
132
133	// Reload channel: the SIGHUP/SIGUSR1 handler (and the `/reload` HTTP control)
134	// bump it; tasks watch it to refresh without a restart.
135	let (reload_tx, reload_rx) = tokio::sync::watch::channel(0u64);
136	let reload_tx = Arc::new(reload_tx);
137
138	let (event_tx, mut event_rx) = mpsc::channel(100);
139
140	let ctx = Arc::new(InternalContext {
141		pg_pool: pool,
142		http_client: crate::http_client(),
143		canopy_client,
144		reload: reload_rx,
145		restart: Some(RestartTrigger {
146			events: event_tx.clone(),
147		}),
148	});
149
150	// Control handle for the HTTP server's `/reload` and `/restart` endpoints.
151	let control = DaemonControl {
152		reload: reload_tx.clone(),
153		events: event_tx.clone(),
154	};
155
156	// Start HTTP server
157	if !daemon_config.no_server {
158		let ctx_for_server = ctx.clone();
159		let background_tasks_for_server = daemon_config.background_tasks.clone();
160		let server_addrs = daemon_config.server_addrs.clone();
161		let watchdog_timeout = daemon_config.watchdog_timeout;
162		let backups = daemon_config.backups.clone();
163		let metrics = daemon_config.metrics.clone();
164		let binary_version = daemon_config.binary_version.clone();
165		tokio::spawn(async move {
166			http_server::start_server(
167				ctx_for_server,
168				server_addrs,
169				watchdog_timeout,
170				&background_tasks_for_server,
171				control,
172				backups,
173				metrics,
174				binary_version,
175			)
176			.await;
177		});
178	}
179
180	// SIGINT handler
181	let signal_tx = event_tx.clone();
182	tokio::spawn(async move {
183		match tokio::signal::ctrl_c().await {
184			Ok(()) => {
185				info!("received SIGINT, shutting down");
186				let _ = signal_tx.send(DaemonEvent::Shutdown).await;
187			}
188			Err(err) => {
189				error!("unable to listen for shutdown signal: {}", err);
190			}
191		}
192	});
193
194	// External shutdown signal (for Windows service)
195	let external_signal_tx = event_tx.clone();
196	tokio::spawn(async move {
197		let _ = external_shutdown.await;
198		info!("received external shutdown signal");
199		let _ = external_signal_tx.send(DaemonEvent::Shutdown).await;
200	});
201
202	#[cfg(unix)]
203	{
204		use tokio::signal::unix::{SignalKind, signal};
205		let signal_tx_term = event_tx.clone();
206		tokio::spawn(async move {
207			let mut sigterm =
208				signal(SignalKind::terminate()).expect("failed to setup SIGTERM handler");
209			sigterm.recv().await;
210			info!("received SIGTERM, shutting down");
211			let _ = signal_tx_term.send(DaemonEvent::Shutdown).await;
212		});
213
214		// Reload on SIGHUP (sent by the unit's ExecReload) or SIGUSR1:
215		// notify systemd we're reloading, bump the reload channel so tasks
216		// refresh, then notify ready again. The reload work itself is async and
217		// best-effort, so READY is sent once the refresh is dispatched.
218		tokio::spawn(async move {
219			let mut sighup = signal(SignalKind::hangup()).expect("failed to setup SIGHUP handler");
220			let mut sigusr1 =
221				signal(SignalKind::user_defined1()).expect("failed to setup SIGUSR1 handler");
222			loop {
223				tokio::select! {
224					_ = sighup.recv() => {}
225					_ = sigusr1.recv() => {}
226				}
227				info!("received reload signal; refreshing");
228				let mut reloading = vec![sd_notify::NotifyState::Reloading];
229				if let Ok(stamp) = sd_notify::NotifyState::monotonic_usec_now() {
230					reloading.push(stamp);
231				}
232				let _ = sd_notify::notify(&reloading);
233				reload_tx.send_modify(|n| *n = n.wrapping_add(1));
234				let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]);
235			}
236		});
237	}
238	#[cfg(not(unix))]
239	let _ = reload_tx; // no reload signals off Unix; tasks keep their other triggers
240
241	// Registered background tasks (e.g. the doctor sweep). Each ticks at its
242	// own interval; errors are logged but don't tear down the daemon.
243	for task in &daemon_config.background_tasks {
244		let task = task.clone();
245		let task_ctx = TaskContext::from_internal(&ctx);
246		info!(name = task.name(), interval = ?task.interval(), "registering background task");
247		tokio::spawn(async move {
248			let mut tick = tokio::time::interval(task.interval());
249			tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
250			loop {
251				tick.tick().await;
252				metrics::record_activity();
253				if let Err(err) = task.run(&task_ctx).await {
254					error!(
255						name = task.name(),
256						"background task failed: {}",
257						LogError(&err)
258					);
259				}
260			}
261		});
262	}
263
264	// Watchdog: if no task has ticked within the timeout, shut down so the
265	// service manager (Windows SCM / systemd / etc.) can restart us.
266	if let Some(watchdog_timeout) = daemon_config.watchdog_timeout {
267		let watchdog_tx = event_tx.clone();
268		tokio::spawn(async move {
269			// Give the daemon time to start up and run its first tick
270			let grace = watchdog_timeout.max(Duration::from_secs(60));
271			tokio::time::sleep(grace).await;
272
273			let mut check_interval = tokio::time::interval(Duration::from_secs(30));
274			check_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
275			loop {
276				check_interval.tick().await;
277				let last = metrics::last_activity_timestamp();
278				let now = jiff::Timestamp::now().as_second();
279				let elapsed = Duration::from_secs(now.saturating_sub(last) as u64);
280				if elapsed > watchdog_timeout {
281					error!(
282						?elapsed,
283						?watchdog_timeout,
284						"watchdog: no task activity detected within timeout, shutting down"
285					);
286					let _ = watchdog_tx.send(DaemonEvent::WatchdogTimeout).await;
287					break;
288				}
289			}
290		});
291	}
292
293	info!("daemon started successfully");
294	// Tell systemd (Type=notify[-reload]) we're up; no-op when not under systemd.
295	// The status line surfaces the running version and the canopy transport
296	// (which is fixed at startup), so `systemctl status` shows them at a glance.
297	#[cfg(unix)]
298	{
299		let canopy = match &ctx.canopy_client {
300			Some(client) if client.is_tailscale().await => "canopy via tailscale",
301			Some(_) => "canopy via mTLS",
302			None => "canopy not connected",
303		};
304		let status = format!(
305			"monitoring; bestool {}; {canopy}",
306			daemon_config.binary_version
307		);
308		let _ = sd_notify::notify(&[
309			sd_notify::NotifyState::Ready,
310			sd_notify::NotifyState::Status(&status),
311		]);
312	}
313
314	// Block until the first lifecycle event arrives: a shutdown signal, or the
315	// watchdog firing. `None` means every sender was dropped, which we treat as
316	// a shutdown too.
317	let event = event_rx.recv().await;
318	#[cfg(unix)]
319	let _ = sd_notify::notify(&[sd_notify::NotifyState::Stopping]);
320	match event {
321		Some(DaemonEvent::Shutdown) | None => {
322			info!("daemon stopped");
323			Ok(())
324		}
325		Some(DaemonEvent::Restart) => {
326			// Exit non-zero so the service manager restarts us (systemd
327			// `Restart=`, Windows SCM recovery).
328			info!("restart requested; exiting for the service manager to restart");
329			Err(miette!("restart requested"))
330		}
331		Some(DaemonEvent::WatchdogTimeout) => {
332			error!("daemon exiting due to watchdog timeout");
333			Err(miette!("watchdog timeout: no task activity detected"))
334		}
335	}
336}