adler_server/lib.rs
1//! HTTP server for the [Adler](https://github.com/commit3296/adler)
2//! OSINT username-search engine.
3//!
4//! This crate hosts the JSON API and embedded `SolidJS` web UI for
5//! Adler. It is a thin shell around [`adler_core`]: scans run through
6//! the same [`adler_core::executor`] the CLI uses, and the same
7//! [`adler_core::Client`] is shared across all in-process scans.
8//!
9//! ## Quick start
10//!
11//! ```no_run
12//! use std::net::SocketAddr;
13//! use adler_core::{Client, Registry};
14//! use adler_server::{AppConfig, serve};
15//!
16//! # #[tokio::main]
17//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! let registry = Registry::default_embedded()?;
19//! // Use the caller's filtering rules — the CLI already exposes
20//! // --only/--tag/--exclude, so the server just runs whatever site
21//! // list it's handed.
22//! let filter = adler_core::SiteFilter::default();
23//! let sites = registry.filter_with(&filter);
24//! let catalog = registry.matches_with(&filter);
25//! let client = Client::builder().build()?;
26//! let config = AppConfig {
27//! bind: "127.0.0.1:8765".parse::<SocketAddr>()?,
28//! scan_capacity: 32,
29//! scans_dir: None, // or Some(adler_server::default_scans_dir())
30//! };
31//! serve(sites, catalog, client, config).await?;
32//! # Ok(())
33//! # }
34//! ```
35//!
36//! ## Routes
37//!
38//! | Route | Method | Purpose |
39//! |------------------------------------|--------|--------------------------------------|
40//! | `/api/health` | GET | liveness |
41//! | `/api/sites` | GET | site catalogue |
42//! | `/api/scan` | POST | start a scan, returns a `scan_id` |
43//! | `/api/scan/{id}` | GET | poll status / final aggregate |
44//! | `/api/scan/{id}/report` | GET | investigation report export |
45//! | `/api/scan/{id}/stream` | GET | Server-Sent Events |
46//! | `/api/scan/{id}/retry` | POST | retry one site in a scan |
47//! | `/api/scan/{id}/refilter` | POST | cancel and restart with new filters |
48//! | `/api/scans` | GET | recent scan history |
49//! | `/api/access` | GET | read-only access-engine summary |
50//! | `/` | GET | embedded `SolidJS` SPA |
51//!
52//! ## Threading and shutdown
53//!
54//! [`serve`] binds the TCP listener, installs a `SIGINT` / `SIGTERM`
55//! graceful-shutdown signal, and runs until the listener closes. All
56//! state (registry, client, in-flight scans) lives in an [`AppState`]
57//! cloned into each handler — no global mutables.
58
59#![warn(missing_docs)]
60
61use std::net::SocketAddr;
62use std::path::PathBuf;
63
64use adler_core::{Client, Site};
65use tokio::net::TcpListener;
66use tokio::signal;
67
68mod api;
69mod assets;
70mod error;
71mod persist;
72mod scan;
73mod state;
74
75pub use api::router;
76pub use error::{Error, Result};
77pub use persist::{
78 EvidenceChange, PersistedScan, ScanDiff, ScanTimeline, TimelineEvent, TimelineEventKind,
79 TimelineProfile, VerdictChange, apply_historical_confidence_overlay,
80 build_investigation_report, build_scan_timeline, default_dir as default_scans_dir, diff_scans,
81};
82pub use scan::{FinishedScan, ScanHandle, ScanId, Summary};
83pub use state::AppState;
84
85/// Server configuration.
86///
87/// `bind` is the TCP socket the server listens on; defaults are
88/// imposed by the caller (the CLI binds `127.0.0.1:8765` and refuses
89/// to bind a non-loopback address unless explicitly told to — there
90/// is no authentication on the API).
91#[derive(Debug, Clone)]
92pub struct AppConfig {
93 /// Address to bind the HTTP listener.
94 pub bind: SocketAddr,
95 /// Maximum number of recent scans retained in memory.
96 pub scan_capacity: usize,
97 /// Directory for on-disk scan history. `None` disables persistence.
98 /// The CLI defaults to [`default_scans_dir`].
99 pub scans_dir: Option<PathBuf>,
100}
101
102impl Default for AppConfig {
103 fn default() -> Self {
104 Self {
105 bind: SocketAddr::from(([127, 0, 0, 1], 8765)),
106 scan_capacity: 32,
107 scans_dir: None,
108 }
109 }
110}
111
112/// Run the server until the listener closes or a shutdown signal arrives.
113///
114/// `sites` is the pre-filtered enabled site list every scan dispatched
115/// through this server runs against. `catalog` is the same startup filter
116/// including disabled/parked entries so API/UI surfaces can explain why a
117/// site is unavailable. `client` is the pre-built HTTP client (so
118/// configuration like proxy, throttle, and browser backend flows from the
119/// CLI flags through here unchanged).
120pub async fn serve(
121 sites: Vec<Site>,
122 catalog: Vec<Site>,
123 client: Client,
124 config: AppConfig,
125) -> Result<()> {
126 let mut state = AppState::with_catalog(sites, catalog, client, config.scan_capacity);
127 if let Some(dir) = config.scans_dir.clone() {
128 state = state.with_scans_dir(dir);
129 }
130 let app = assets::attach(router(state));
131
132 let listener = TcpListener::bind(config.bind)
133 .await
134 .map_err(|source| Error::Bind {
135 addr: config.bind.to_string(),
136 source,
137 })?;
138 tracing::debug!(bind = %config.bind, "adler-server listening");
139
140 axum::serve(listener, app)
141 .with_graceful_shutdown(shutdown_signal())
142 .await
143 .map_err(Error::Server)?;
144 Ok(())
145}
146
147async fn shutdown_signal() {
148 let ctrl_c = async {
149 if let Err(err) = signal::ctrl_c().await {
150 tracing::warn!(error = %err, "failed to install Ctrl-C handler");
151 }
152 };
153
154 #[cfg(unix)]
155 let terminate = async {
156 match signal::unix::signal(signal::unix::SignalKind::terminate()) {
157 Ok(mut sig) => {
158 sig.recv().await;
159 }
160 Err(err) => tracing::warn!(error = %err, "failed to install SIGTERM handler"),
161 }
162 };
163
164 #[cfg(not(unix))]
165 let terminate = std::future::pending::<()>();
166
167 tokio::select! {
168 () = ctrl_c => {}
169 () = terminate => {}
170 }
171 tracing::info!("shutdown signal received");
172}