Skip to main content

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}