mod acl;
mod attribute;
mod config;
mod daemon;
mod db;
mod db_log;
mod error;
mod handlers;
mod maintenance;
mod persistence;
mod sighting_reader;
mod sighting_writer;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use actix_web::{App, HttpServer, web};
use anyhow::{Context, Result};
use chrono::Utc;
use clap::Parser;
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
use crate::acl::Acl;
use crate::config::{Settings, TlsSettings};
use crate::db::{DEFAULT_APIKEY, Database};
use crate::handlers::SharedState;
use crate::maintenance::Shutdown;
#[derive(Debug, Parser)]
#[command(
name = "sightingdb",
version,
about = "Sightings Database",
author = "Sebastien Tricaud <sebastien.tricaud@devo.com>"
)]
struct Cli {
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
#[arg(
short = 'l',
long,
value_name = "FILE",
default_value = "etc/log4rs.yml"
)]
logging_config: PathBuf,
#[arg(short = 'k', long, value_name = "APIKEY")]
apikey: Option<String>,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
fn main() -> Result<()> {
let cli = Cli::parse();
log4rs::init_file(&cli.logging_config, Default::default()).with_context(|| {
format!(
"loading logging configuration {}",
cli.logging_config.display()
)
})?;
create_home_config();
let config_path = match cli.config {
Some(path) => path,
None => config::locate()?,
};
log::info!("Using configuration file: {}", config_path.display());
let settings = Settings::load(&config_path)?;
if settings.daemonize && !daemon::is_child() {
let pid = daemon::detach(&settings.log_out, &settings.log_err)?;
log::info!("Running in the background as pid {pid}");
return Ok(());
}
let snapshot = usable_snapshot_path(&settings);
let db = open_database(&settings, snapshot.as_deref())?;
let acl = build_acl(&settings, &db, cli.apikey.as_deref());
log::info!("Starting Sighting Daemon");
if !settings.authenticate {
log::warn!("No authentication used for the database; the ACL is not consulted.");
}
if settings.tls.is_none() {
log::warn!("TLS is disabled; serving plain HTTP.");
}
if snapshot.is_none() {
log::warn!("Persistence is disabled; data will be lost when the process stops.");
}
if !settings.daemonize {
log::info!(
"Running in the foreground. Set 'daemonize = true' in {} to detach, or run under \
a service manager (see etc/sightingdb.service).",
config_path.display()
);
}
let state = Arc::new(SharedState {
db,
authenticate: settings.authenticate,
acl,
});
let shutdown = Shutdown::new();
let workers = spawn_workers(&state, &settings, snapshot.as_deref(), &shutdown)?;
let result = actix_web::rt::System::new().block_on(serve(Arc::clone(&state), &settings));
shutdown.stop();
for worker in workers {
let _ = worker.join();
}
if let Some(path) = snapshot.as_deref() {
log::info!("Saving the database to {}", path.display());
if let Err(e) = persistence::save(&state.db, path) {
log::error!("Could not save the database: {e:#}");
}
}
daemon::remove_pid_file();
result
}
fn build_acl(settings: &Settings, db: &Database, cli_apikey: Option<&str>) -> Acl {
let legacy = db.legacy_apikeys();
let mut acl = match &settings.acl {
Some(acl) => {
if !legacy.is_empty() {
log::warn!(
"Ignoring {} API key(s) stored in the snapshot: the configuration has an \
[acl] section, which takes precedence",
legacy.len()
);
}
log::info!("Loaded {} API key(s) from the [acl] section", acl.len());
acl.clone()
}
None => {
let mut acl = Acl::new();
for key in &legacy {
acl.grant_full(key);
}
if !legacy.is_empty() {
log::warn!(
"Granting full access to {} API key(s) restored from the snapshot. Declare \
an [acl] section to scope them to particular namespaces.",
legacy.len()
);
}
acl
}
};
if let Some(apikey) = cli_apikey {
acl.remove(DEFAULT_APIKEY);
acl.grant_full(apikey);
log::info!("API key from -k granted full access");
}
if acl.is_empty() {
log::warn!(
"No API keys configured; seeding '{DEFAULT_APIKEY}' with full access. Add an [acl] \
section or pass -k."
);
acl.grant_full(DEFAULT_APIKEY);
}
acl
}
fn open_database(settings: &Settings, snapshot: Option<&Path>) -> Result<Database> {
let policy = settings.database_policy();
let Some(path) = snapshot else {
return Ok(Database::with_policy(policy));
};
match persistence::load(path)? {
Some(data) => {
let db = Database::from_snapshot(data, policy);
log::info!(
"Restored {} namespaces from {}",
db.namespace_count(),
path.display()
);
Ok(db)
}
None => {
log::info!("No snapshot at {}, starting empty", path.display());
Ok(Database::with_policy(policy))
}
}
}
fn usable_snapshot_path(settings: &Settings) -> Option<PathBuf> {
let dbdir = settings.dbdir.as_ref()?;
if let Err(e) = fs::create_dir_all(dbdir) {
log::error!(
"Cannot use dbdir {}: {e}. Continuing without persistence.",
dbdir.display()
);
return None;
}
Some(persistence::snapshot_path(dbdir))
}
fn spawn_workers(
state: &Arc<SharedState>,
settings: &Settings,
snapshot: Option<&Path>,
shutdown: &Arc<Shutdown>,
) -> Result<Vec<JoinHandle<()>>> {
let mut workers = Vec::new();
if settings.sweep_interval > 0 {
let state = Arc::clone(state);
workers.push(
maintenance::spawn_interval(
"sightingdb-sweeper",
Duration::from_secs(settings.sweep_interval),
Arc::clone(shutdown),
move || {
let report = state.db.sweep(Utc::now());
if !report.is_empty() {
log::info!(
"Swept {} expired values and {} empty namespaces",
report.values_removed,
report.namespaces_removed
);
}
},
)
.context("starting the sweeper thread")?,
);
}
if let Some(path) = snapshot
&& settings.snapshot_interval > 0
{
let state = Arc::clone(state);
let path = path.to_path_buf();
workers.push(
maintenance::spawn_interval(
"sightingdb-snapshot",
Duration::from_secs(settings.snapshot_interval),
Arc::clone(shutdown),
move || {
if let Err(e) = persistence::save(&state.db, &path) {
log::error!("Snapshot failed: {e:#}");
}
},
)
.context("starting the snapshot thread")?,
);
}
Ok(workers)
}
async fn serve(state: Arc<SharedState>, settings: &Settings) -> Result<()> {
let state = web::Data::from(state);
let post_limit = settings.post_limit;
let server = HttpServer::new(move || {
App::new()
.app_data(state.clone())
.app_data(handlers::json_config(post_limit))
.app_data(handlers::query_config())
.configure(handlers::routes)
});
let server = match &settings.tls {
Some(tls) => {
let builder = tls_acceptor(tls)?;
server
.bind_openssl(&settings.listen, builder)
.with_context(|| format!("binding https://{}", settings.listen))?
}
None => server
.bind(&settings.listen)
.with_context(|| format!("binding http://{}", settings.listen))?,
};
let scheme = if settings.tls.is_some() {
"https"
} else {
"http"
};
log::info!("Listening on {scheme}://{}", settings.listen);
server.run().await.context("running the HTTP server")
}
fn tls_acceptor(tls: &TlsSettings) -> Result<SslAcceptorBuilder> {
let mut builder =
SslAcceptor::mozilla_intermediate(SslMethod::tls()).context("creating the TLS acceptor")?;
builder
.set_private_key_file(&tls.key, SslFiletype::PEM)
.with_context(|| format!("reading TLS key {}", tls.key.display()))?;
builder
.set_certificate_chain_file(&tls.cert)
.with_context(|| format!("reading TLS certificate {}", tls.cert.display()))?;
Ok(builder)
}
fn create_home_config() {
let Some(mut home_config) = dirs::home_dir() else {
log::warn!("Cannot determine the home directory; skipping ~/.sightingdb");
return;
};
home_config.push(".sightingdb");
if let Err(e) = fs::create_dir_all(&home_config) {
log::error!(
"Error creating home configuration {}: {e}",
home_config.display()
);
}
}