Skip to main content

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}/stream`            | GET    | Server-Sent Events                   |
45//! | `/api/scan/{id}/retry`             | POST   | retry one site in a scan             |
46//! | `/api/scan/{id}/refilter`          | POST   | cancel and restart with new filters  |
47//! | `/api/scans`                       | GET    | recent scan history                  |
48//! | `/api/access`                      | GET    | read-only access-engine summary      |
49//! | `/`                                | GET    | embedded `SolidJS` SPA               |
50//!
51//! ## Threading and shutdown
52//!
53//! [`serve`] binds the TCP listener, installs a `SIGINT` / `SIGTERM`
54//! graceful-shutdown signal, and runs until the listener closes. All
55//! state (registry, client, in-flight scans) lives in an [`AppState`]
56//! cloned into each handler — no global mutables.
57
58#![warn(missing_docs)]
59
60use std::net::SocketAddr;
61use std::path::PathBuf;
62
63use adler_core::{Client, Site};
64use tokio::net::TcpListener;
65use tokio::signal;
66
67mod api;
68mod assets;
69mod error;
70mod persist;
71mod scan;
72mod state;
73
74pub use api::router;
75pub use error::{Error, Result};
76pub use persist::{PersistedScan, default_dir as default_scans_dir};
77pub use scan::{FinishedScan, ScanHandle, ScanId, Summary};
78pub use state::AppState;
79
80/// Server configuration.
81///
82/// `bind` is the TCP socket the server listens on; defaults are
83/// imposed by the caller (the CLI binds `127.0.0.1:8765` and refuses
84/// to bind a non-loopback address unless explicitly told to — there
85/// is no authentication on the API).
86#[derive(Debug, Clone)]
87pub struct AppConfig {
88    /// Address to bind the HTTP listener.
89    pub bind: SocketAddr,
90    /// Maximum number of recent scans retained in memory.
91    pub scan_capacity: usize,
92    /// Directory for on-disk scan history. `None` disables persistence.
93    /// The CLI defaults to [`default_scans_dir`].
94    pub scans_dir: Option<PathBuf>,
95}
96
97impl Default for AppConfig {
98    fn default() -> Self {
99        Self {
100            bind: SocketAddr::from(([127, 0, 0, 1], 8765)),
101            scan_capacity: 32,
102            scans_dir: None,
103        }
104    }
105}
106
107/// Run the server until the listener closes or a shutdown signal arrives.
108///
109/// `sites` is the pre-filtered enabled site list every scan dispatched
110/// through this server runs against. `catalog` is the same startup filter
111/// including disabled/parked entries so API/UI surfaces can explain why a
112/// site is unavailable. `client` is the pre-built HTTP client (so
113/// configuration like proxy, throttle, and browser backend flows from the
114/// CLI flags through here unchanged).
115pub async fn serve(
116    sites: Vec<Site>,
117    catalog: Vec<Site>,
118    client: Client,
119    config: AppConfig,
120) -> Result<()> {
121    let mut state = AppState::with_catalog(sites, catalog, client, config.scan_capacity);
122    if let Some(dir) = config.scans_dir.clone() {
123        state = state.with_scans_dir(dir);
124    }
125    let app = assets::attach(router(state));
126
127    let listener = TcpListener::bind(config.bind)
128        .await
129        .map_err(|source| Error::Bind {
130            addr: config.bind.to_string(),
131            source,
132        })?;
133    tracing::debug!(bind = %config.bind, "adler-server listening");
134
135    axum::serve(listener, app)
136        .with_graceful_shutdown(shutdown_signal())
137        .await
138        .map_err(Error::Server)?;
139    Ok(())
140}
141
142async fn shutdown_signal() {
143    let ctrl_c = async {
144        if let Err(err) = signal::ctrl_c().await {
145            tracing::warn!(error = %err, "failed to install Ctrl-C handler");
146        }
147    };
148
149    #[cfg(unix)]
150    let terminate = async {
151        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
152            Ok(mut sig) => {
153                sig.recv().await;
154            }
155            Err(err) => tracing::warn!(error = %err, "failed to install SIGTERM handler"),
156        }
157    };
158
159    #[cfg(not(unix))]
160    let terminate = std::future::pending::<()>();
161
162    tokio::select! {
163        () = ctrl_c => {}
164        () = terminate => {}
165    }
166    tracing::info!("shutdown signal received");
167}