aranet_service/lib.rs
1#![deny(unsafe_code)]
2
3//! Background collector and HTTP REST API for Aranet sensors.
4//!
5//! This crate provides a service that:
6//! - Polls configured Aranet devices on a schedule
7//! - Stores readings in the local database
8//! - Exposes a REST API for querying data
9//! - Provides WebSocket connections for real-time updates
10//! - Optional API key authentication and rate limiting
11//!
12//! # REST API Endpoints
13//!
14//! - `GET /api/health` - Lightweight service health check (no auth required)
15//! - `GET /api/health/detailed` - Database, collector, and platform diagnostics
16//! - `GET /api/status` - Collector status plus per-device polling statistics
17//! - `GET /api/devices` - List all known devices
18//! - `GET /api/devices/current` - Latest reading for every known device
19//! - `GET /api/devices/:id` - Get device info
20//! - `GET /api/devices/:id/current` - Latest reading wrapped in `CurrentReadingResponse`
21//! - `GET /api/devices/:id/readings` - Query readings with filters
22//! - `GET /api/devices/:id/history` - Query cached history
23//! - `GET /api/readings` - All readings across devices
24//! - `GET /api/config`, `PUT /api/config` - Read or update runtime configuration
25//! - `POST /api/config/devices`, `PUT/DELETE /api/config/devices/:id` - Manage monitored devices
26//! - `POST /api/collector/start`, `POST /api/collector/stop` - Control the background collector
27//! - `GET /metrics` - Prometheus metrics export
28//! - `WS /api/ws` - Real-time readings stream
29//! - `GET /`, `GET /dashboard` - Embedded dashboard shell
30//!
31//! # Configuration
32//!
33//! The service reads configuration from `~/.config/aranet/server.toml`:
34//!
35//! ```toml
36//! [server]
37//! bind = "127.0.0.1:8080"
38//!
39//! [storage]
40//! path = "~/.local/share/aranet/data.db"
41//!
42//! [[devices]]
43//! address = "Aranet4 17C3C"
44//! alias = "office"
45//! poll_interval = 60
46//! ```
47//!
48//! # Security
49//!
50//! Optional security features can be enabled:
51//!
52//! ```toml
53//! [security]
54//! # Require X-API-Key header for protected API, WebSocket, and metrics requests
55//! api_key_enabled = true
56//! api_key = "your-secure-random-key-at-least-32-chars"
57//!
58//! # Rate limit requests per IP address
59//! rate_limit_enabled = true
60//! rate_limit_requests = 100 # max requests per window
61//! rate_limit_window_secs = 60 # window duration
62//! ```
63//!
64//! The dashboard shell routes (`/` and `/dashboard`) remain public so browsers can
65//! load the UI. Protected API, metrics, and WebSocket requests still honor the
66//! configured security settings.
67//!
68//! For WebSocket connections, browsers cannot set custom headers. Use the `token`
69//! query parameter only on `/api/ws` instead:
70//! `ws://localhost:8080/api/ws?token=your-api-key`
71//!
72//! **Note**: Query parameters may be logged by proxies or appear in browser history.
73//! For sensitive deployments, consider using a short-lived token exchange endpoint
74//! rather than passing the API key directly in the query string.
75//!
76//! # Platform Setup
77//!
78//! ## macOS
79//!
80//! ### Bluetooth Permissions
81//!
82//! The Aranet devices use Bluetooth Low Energy. On macOS, you need to grant
83//! Bluetooth permissions:
84//!
85//! 1. **Terminal App**: When running from Terminal, the Terminal app must have
86//! Bluetooth permission in System Preferences > Privacy & Security > Bluetooth.
87//!
88//! 2. **VS Code Terminal**: Add VS Code to the Bluetooth permissions list.
89//!
90//! 3. **LaunchAgent**: For background services, add `aranet-service` to the
91//! Bluetooth permission list. You may need to use a signed binary or run
92//! with appropriate entitlements.
93//!
94//! ### User-Level Service (Recommended)
95//!
96//! Install as a user LaunchAgent (no root required):
97//!
98//! ```bash
99//! # Install the service
100//! aranet-service service install --user
101//!
102//! # Start the service
103//! aranet-service service start --user
104//!
105//! # Check status
106//! aranet-service service status --user
107//!
108//! # Stop and uninstall
109//! aranet-service service stop --user
110//! aranet-service service uninstall --user
111//! ```
112//!
113//! The LaunchAgent plist is created at `~/Library/LaunchAgents/dev.rye.aranet.plist`.
114//!
115//! ## Linux
116//!
117//! ### BlueZ D-Bus Access
118//!
119//! The service needs access to the BlueZ D-Bus interface. For user-level services:
120//!
121//! 1. **Ensure your user is in the bluetooth group:**
122//! ```bash
123//! sudo usermod -a -G bluetooth $USER
124//! # Log out and back in for group changes to take effect
125//! ```
126//!
127//! 2. **D-Bus session access**: User-level systemd services need the D-Bus session.
128//! Create a drop-in config if needed:
129//! ```bash
130//! mkdir -p ~/.config/systemd/user/aranet.service.d
131//! cat > ~/.config/systemd/user/aranet.service.d/dbus.conf << EOF
132//! [Service]
133//! Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus"
134//! EOF
135//! systemctl --user daemon-reload
136//! ```
137//!
138//! ### User-Level Service
139//!
140//! ```bash
141//! # Install the user service
142//! aranet-service service install --user
143//!
144//! # Enable and start
145//! systemctl --user enable --now dev.rye.aranet
146//!
147//! # Check status
148//! systemctl --user status dev.rye.aranet
149//!
150//! # View logs
151//! journalctl --user -u dev.rye.aranet -f
152//!
153//! # Stop and uninstall
154//! systemctl --user stop dev.rye.aranet
155//! aranet-service service uninstall --user
156//! ```
157//!
158//! ### System-Level Service
159//!
160//! For system services, you need to create a dedicated user:
161//!
162//! ```bash
163//! # Create aranet user (with bluetooth group membership)
164//! sudo useradd -r -s /sbin/nologin -G bluetooth aranet
165//!
166//! # Install as system service
167//! sudo aranet-service service install
168//!
169//! # Start the service
170//! sudo systemctl enable --now dev.rye.aranet
171//! ```
172//!
173//! ## Windows
174//!
175//! ### Bluetooth Permissions
176//!
177//! Windows requires the app to be granted Bluetooth access through Settings:
178//! - Settings > Privacy & Security > Bluetooth > Allow apps to access your Bluetooth
179//!
180//! ### Running as a Service
181//!
182//! On Windows, the service runs as a Windows Service. Install and manage via:
183//!
184//! ```powershell
185//! # Run as Administrator
186//! aranet-service service install
187//! aranet-service service start
188//!
189//! # Check status (in Services panel or via)
190//! aranet-service service status
191//!
192//! # Stop and uninstall
193//! aranet-service service stop
194//! aranet-service service uninstall
195//! ```
196//!
197//! **Note**: Windows Services run in session 0 without a desktop, which may affect
198//! Bluetooth access. Consider using Task Scheduler to run the service at logon
199//! if you encounter Bluetooth issues:
200//!
201//! ```powershell
202//! # Create a scheduled task to run at logon
203//! schtasks /create /tn "AranetService" /tr "aranet-service run" /sc onlogon /rl highest
204//! ```
205
206use std::net::SocketAddr;
207use std::path::PathBuf;
208use std::sync::Arc;
209
210use axum::Router;
211use tower_http::trace::TraceLayer;
212
213pub mod api;
214pub mod collector;
215pub mod config;
216pub mod dashboard;
217pub mod middleware;
218pub mod state;
219pub mod ws;
220
221pub use collector::Collector;
222pub use config::{
223 Config, ConfigError, DeviceConfig, InfluxDbConfig, MqttConfig, NotificationConfig,
224 PrometheusConfig, SecurityConfig, ServerConfig, StorageConfig, WebhookConfig, WebhookEndpoint,
225};
226pub use state::{AppState, ReadingEvent};
227
228#[cfg(feature = "mqtt")]
229pub mod mqtt;
230
231#[cfg(feature = "prometheus")]
232pub mod prometheus;
233
234pub mod influxdb;
235pub mod mdns;
236pub mod webhook;
237
238/// Runtime options for starting the HTTP service.
239#[derive(Debug, Clone, Default)]
240pub struct RunOptions {
241 /// Optional path to a `server.toml` file.
242 pub config: Option<PathBuf>,
243 /// Optional bind address override.
244 pub bind: Option<String>,
245 /// Optional database path override.
246 pub database: Option<PathBuf>,
247 /// Disable the background collector.
248 pub no_collector: bool,
249}
250
251/// Initialize the default tracing subscriber used by the service binaries.
252pub fn init_tracing() -> anyhow::Result<()> {
253 let filter = tracing_subscriber::EnvFilter::from_default_env()
254 .add_directive("aranet_service=info".parse()?)
255 .add_directive("tower_http=debug".parse()?);
256
257 let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
258 Ok(())
259}
260
261/// Build the fully layered HTTP application used in production and end-to-end tests.
262pub fn app(
263 state: Arc<AppState>,
264 security_config: Arc<SecurityConfig>,
265 rate_limit_state: Arc<middleware::RateLimitState>,
266) -> Router {
267 Router::new()
268 .merge(api::router())
269 .merge(ws::router())
270 .merge(dashboard::router())
271 .layer(axum::middleware::from_fn_with_state(
272 Arc::clone(&security_config),
273 middleware::api_key_auth,
274 ))
275 .layer(axum::middleware::from_fn_with_state(
276 (security_config, rate_limit_state),
277 middleware::rate_limit,
278 ))
279 .layer(TraceLayer::new_for_http())
280 .with_state(state)
281}
282
283/// Run the HTTP service until shutdown.
284pub async fn run(options: RunOptions) -> anyhow::Result<()> {
285 let config_path = options
286 .config
287 .clone()
288 .unwrap_or_else(config::default_config_path);
289
290 let mut config = if config_path.exists() {
291 Config::load(&config_path)?
292 } else {
293 Config::default()
294 };
295
296 if let Some(bind) = options.bind {
297 config.server.bind = bind;
298 }
299 if let Some(db_path) = options.database {
300 config.storage.path = db_path;
301 }
302
303 config.validate()?;
304
305 tracing::info!("Opening database at {:?}", config.storage.path);
306 let store = aranet_store::Store::open(&config.storage.path)?;
307 let state = AppState::with_config_path(store, config.clone(), config_path);
308
309 let security_config = Arc::new(config.security.clone());
310 let rate_limit_state = Arc::new(middleware::RateLimitState::new());
311
312 {
313 let rate_limit_state = Arc::clone(&rate_limit_state);
314 let window_secs = config.security.rate_limit_window_secs;
315 let max_entries = config.security.rate_limit_max_entries;
316 tokio::spawn(async move {
317 let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
318 loop {
319 interval.tick().await;
320 rate_limit_state.cleanup(window_secs, max_entries).await;
321 }
322 });
323 }
324
325 let collector = if !options.no_collector {
326 let collector = Collector::new(Arc::clone(&state));
327 collector.start().await;
328 Some(collector)
329 } else {
330 tracing::info!("Background collector disabled");
331 None
332 };
333
334 #[cfg(feature = "mqtt")]
335 {
336 use crate::mqtt::MqttPublisher;
337 let mqtt_publisher = MqttPublisher::new(Arc::clone(&state));
338 mqtt_publisher.start().await;
339 }
340
341 #[cfg(feature = "prometheus")]
342 {
343 use crate::prometheus::PrometheusPusher;
344 let prometheus_pusher = PrometheusPusher::new(Arc::clone(&state));
345 prometheus_pusher.start().await;
346 }
347
348 {
349 use crate::webhook::WebhookDispatcher;
350 let webhook_dispatcher = WebhookDispatcher::new(Arc::clone(&state));
351 webhook_dispatcher.start().await;
352 }
353
354 {
355 use crate::influxdb::InfluxDbWriter;
356 let influxdb_writer = InfluxDbWriter::new(Arc::clone(&state));
357 influxdb_writer.start().await;
358 }
359
360 let _mdns_handle = {
361 use crate::mdns::MdnsAdvertiser;
362 let advertiser = MdnsAdvertiser::new(Arc::clone(&state));
363 advertiser.start().await
364 };
365
366 let app = app(
367 Arc::clone(&state),
368 Arc::clone(&security_config),
369 Arc::clone(&rate_limit_state),
370 )
371 .layer(middleware::cors_layer(&config.security));
372
373 let addr: SocketAddr = config.server.bind.parse()?;
374 tracing::info!("Starting server on {}", addr);
375
376 let listener = tokio::net::TcpListener::bind(addr).await?;
377 axum::serve(
378 listener,
379 app.into_make_service_with_connect_info::<SocketAddr>(),
380 )
381 .with_graceful_shutdown(shutdown_signal(collector, state))
382 .await?;
383
384 Ok(())
385}
386
387/// Wait for shutdown signal and perform cleanup.
388async fn shutdown_signal(mut collector: Option<Collector>, state: Arc<AppState>) {
389 let ctrl_c = async {
390 if let Err(e) = tokio::signal::ctrl_c().await {
391 tracing::error!("Failed to install Ctrl+C handler: {}", e);
392 std::future::pending::<()>().await;
393 }
394 };
395
396 #[cfg(unix)]
397 let terminate = async {
398 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
399 Ok(mut signal) => {
400 signal.recv().await;
401 }
402 Err(e) => {
403 tracing::error!("Failed to install SIGTERM handler: {}", e);
404 std::future::pending::<()>().await;
405 }
406 }
407 };
408
409 #[cfg(not(unix))]
410 let terminate = std::future::pending::<()>();
411
412 tokio::select! {
413 _ = ctrl_c => {},
414 _ = terminate => {},
415 }
416
417 tracing::info!("Shutdown signal received, stopping services...");
418
419 if let Some(ref mut collector) = collector {
420 collector.stop().await;
421 }
422
423 state.signal_shutdown();
424 state.collector.signal_stop();
425
426 tracing::info!("Graceful shutdown complete");
427}