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 that the upcoming `SolidJS` web UI
5//! talks to. It is a thin shell around [`adler_core`]: scans run
6//! through the same [`adler_core::executor`] the CLI uses, and the
7//! same [`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 sites = registry.filter(&[], &[], &[], &[], false);
23//! let client = Client::builder().build()?;
24//! let config = AppConfig {
25//! bind: "127.0.0.1:8765".parse::<SocketAddr>()?,
26//! scan_capacity: 32,
27//! scans_dir: None, // or Some(adler_server::default_scans_dir())
28//! };
29//! serve(sites, client, config).await?;
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Routes
35//!
36//! | Route | Method | Purpose |
37//! |------------------------------------|--------|--------------------------------------|
38//! | `/api/health` | GET | liveness |
39//! | `/api/sites` | GET | site catalogue |
40//! | `/api/scan` | POST | start a scan, returns a `scan_id` |
41//! | `/api/scan/{id}` | GET | poll status / final aggregate |
42//! | `/api/scan/{id}/stream` | GET | Server-Sent Events |
43//! | `/` | GET | placeholder HTML (SPA TBD) |
44//!
45//! ## Threading and shutdown
46//!
47//! [`serve`] binds the TCP listener, installs a `SIGINT` / `SIGTERM`
48//! graceful-shutdown signal, and runs until the listener closes. All
49//! state (registry, client, in-flight scans) lives in an [`AppState`]
50//! cloned into each handler — no global mutables.
51
52#![warn(missing_docs)]
53
54use std::net::SocketAddr;
55use std::path::PathBuf;
56
57use adler_core::{Client, Site};
58use tokio::net::TcpListener;
59use tokio::signal;
60
61mod api;
62mod assets;
63mod error;
64mod persist;
65mod scan;
66mod state;
67
68pub use api::router;
69pub use error::{Error, Result};
70pub use persist::{PersistedScan, default_dir as default_scans_dir};
71pub use scan::{FinishedScan, ScanHandle, ScanId, Summary};
72pub use state::AppState;
73
74/// Server configuration.
75///
76/// `bind` is the TCP socket the server listens on; defaults are
77/// imposed by the caller (the CLI binds `127.0.0.1:8765` and refuses
78/// to bind a non-loopback address unless explicitly told to — there
79/// is no authentication on the API).
80#[derive(Debug, Clone)]
81pub struct AppConfig {
82 /// Address to bind the HTTP listener.
83 pub bind: SocketAddr,
84 /// Maximum number of recent scans retained in memory.
85 pub scan_capacity: usize,
86 /// Directory for on-disk scan history. `None` disables persistence.
87 /// The CLI defaults to [`default_scans_dir`].
88 pub scans_dir: Option<PathBuf>,
89}
90
91impl Default for AppConfig {
92 fn default() -> Self {
93 Self {
94 bind: SocketAddr::from(([127, 0, 0, 1], 8765)),
95 scan_capacity: 32,
96 scans_dir: None,
97 }
98 }
99}
100
101/// Run the server until the listener closes or a shutdown signal arrives.
102///
103/// `sites` is the pre-filtered site list every scan dispatched through
104/// this server runs against. `client` is the pre-built HTTP client (so
105/// configuration like proxy, throttle, and browser backend flows from
106/// the CLI flags through here unchanged).
107pub async fn serve(sites: Vec<Site>, client: Client, config: AppConfig) -> Result<()> {
108 let mut state = AppState::new(sites, client, config.scan_capacity);
109 if let Some(dir) = config.scans_dir.clone() {
110 state = state.with_scans_dir(dir);
111 }
112 let app = assets::attach(router(state));
113
114 let listener = TcpListener::bind(config.bind)
115 .await
116 .map_err(|source| Error::Bind {
117 addr: config.bind.to_string(),
118 source,
119 })?;
120 tracing::debug!(bind = %config.bind, "adler-server listening");
121
122 axum::serve(listener, app)
123 .with_graceful_shutdown(shutdown_signal())
124 .await
125 .map_err(Error::Server)?;
126 Ok(())
127}
128
129async fn shutdown_signal() {
130 let ctrl_c = async {
131 if let Err(err) = signal::ctrl_c().await {
132 tracing::warn!(error = %err, "failed to install Ctrl-C handler");
133 }
134 };
135
136 #[cfg(unix)]
137 let terminate = async {
138 match signal::unix::signal(signal::unix::SignalKind::terminate()) {
139 Ok(mut sig) => {
140 sig.recv().await;
141 }
142 Err(err) => tracing::warn!(error = %err, "failed to install SIGTERM handler"),
143 }
144 };
145
146 #[cfg(not(unix))]
147 let terminate = std::future::pending::<()>();
148
149 tokio::select! {
150 () = ctrl_c => {}
151 () = terminate => {}
152 }
153 tracing::info!("shutdown signal received");
154}