#![allow(clippy::cast_precision_loss, clippy::doc_markdown)]
mod auth;
mod collector;
mod config;
mod docker;
mod model;
mod notify;
mod store;
mod trend;
mod web;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use tracing::info;
use tracing_subscriber::EnvFilter;
use crate::collector::Config;
use crate::docker::{DockerClient, DockerHandle};
use crate::notify::Notifier;
use crate::store::Store;
#[derive(Parser)]
#[command(name = "dockdoe", version, about, long_about = None)]
struct Cli {
#[arg(long, env = "DOCKDOE_CONFIG")]
config: Option<PathBuf>,
#[arg(long, env = "DOCKDOE_BIND", default_value = "127.0.0.1:8080")]
bind: String,
#[arg(long, env = "DOCKDOE_DB_PATH", default_value = "dockdoe.sqlite")]
db_path: PathBuf,
#[arg(long, env = "DOCKDOE_INTERVAL_SECS", default_value_t = 3,
value_parser = clap::value_parser!(u64).range(1..))]
interval_secs: u64,
#[arg(long, env = "DOCKDOE_RAW_RETENTION_SECS", default_value_t = 3600)]
raw_retention_secs: u64,
#[arg(long, env = "DOCKDOE_TREND_BUCKET_SECS", default_value_t = 60)]
trend_bucket_secs: u64,
#[arg(long, env = "DOCKDOE_TREND_RETENTION_SECS", default_value_t = 30 * 24 * 3600)]
trend_retention_secs: u64,
#[arg(long, env = "DOCKDOE_PRUNE_INTERVAL_SECS", default_value_t = 3600)]
prune_interval_secs: u64,
#[arg(long, env = "DOCKDOE_ALLOWED_HOSTS", value_delimiter = ',')]
allowed_hosts: Vec<String>,
#[arg(long, env = "DOCKDOE_APPRISE_URL")]
apprise_url: Option<String>,
#[arg(long, env = "DOCKDOE_NOTIFY_DELAY", default_value_t = 30)]
notify_delay_secs: u64,
#[arg(long, env = "DOCKDOE_AUTH_USER")]
auth_user: Option<String>,
#[arg(long, env = "DOCKDOE_AUTH_PASSWORD")]
auth_password: Option<String>,
#[arg(long, env = "DOCKDOE_COOKIE_SECURE", default_value_t = false)]
cookie_secure: bool,
#[arg(long, env = "DOCKDOE_PORT_HOST")]
port_host: Option<String>,
#[arg(long, env = "DOCKDOE_LOG", default_value = "info")]
log: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let cfg = config::load(cli.config.as_deref(), &cli)?;
init_tracing(&cfg.log);
let store = Store::open(&cfg.db_path)
.with_context(|| format!("opening store at {}", cfg.db_path.display()))?;
info!(db = %cfg.db_path.display(), "store ready");
let seed_window = Duration::from_secs(cfg.raw_retention_secs);
let allowed_hosts = web::normalize_allowed_hosts(&cfg.allowed_hosts);
if !allowed_hosts.is_empty() {
info!(hosts = ?allowed_hosts, "Host allowlist enabled (plus localhost forms)");
}
let auth = match (cfg.auth_user.clone(), cfg.auth_password.clone()) {
(Some(user), Some(password)) => {
let secret = store.session_secret().context("loading session secret")?;
info!(
secure_cookie = cfg.cookie_secure,
"web UI authentication enabled"
);
Some(auth::Auth::new(user, password, secret, cfg.cookie_secure))
}
(None, None) => None,
_ => anyhow::bail!(
"set both DOCKDOE_AUTH_USER and DOCKDOE_AUTH_PASSWORD to enable authentication, or neither"
),
};
if cfg.apprise_url.is_some() {
info!(
delay_secs = cfg.notify_delay_secs,
"Apprise notifications enabled"
);
}
let mut hosts = HashMap::with_capacity(cfg.hosts.len());
let mut host_order = Vec::with_capacity(cfg.hosts.len());
for host_cfg in &cfg.hosts {
let name = host_cfg.name.clone();
let docker = DockerClient::connect_from(host_cfg)
.with_context(|| format!("connecting to host {name:?}"))?;
let docker_handle = DockerHandle::connect_from(host_cfg)
.with_context(|| format!("connecting to host {name:?}"))?;
let cpu_count = docker.cpu_count().await;
let shared = Arc::new(RwLock::new(None));
let interval_secs = host_cfg.effective_interval_secs(cfg.interval_secs);
let collector_config = Config {
interval: Duration::from_secs(interval_secs),
raw_retention: Duration::from_secs(cfg.raw_retention_secs),
trend_bucket_secs: cfg.trend_bucket_secs,
trend_retention: Duration::from_secs(cfg.trend_retention_secs),
prune_interval: Duration::from_secs(cfg.prune_interval_secs),
};
let notifier = cfg.apprise_url.as_ref().map(|url| {
Notifier::new(
name.clone(),
url.clone(),
Duration::from_secs(cfg.notify_delay_secs),
)
});
tokio::spawn(collector::run(
name.clone(),
docker,
cpu_count,
store.clone(),
collector_config,
Arc::clone(&shared),
notifier,
));
let links = web::PortLinks::from_config(host_cfg.public_host.as_deref(), &host_cfg.docker);
hosts.insert(
name.clone(),
web::HostRuntime {
shared,
docker: docker_handle,
links,
read_only: Arc::new(AtomicBool::new(false)),
},
);
host_order.push(name);
}
info!(hosts = ?host_order, "monitoring hosts");
let app = web::router(web::AppState {
hosts: Arc::new(hosts),
host_order: host_order.into(),
store,
seed_window,
allowed_hosts,
auth,
});
let listener = tokio::net::TcpListener::bind(&cfg.bind)
.await
.with_context(|| format!("binding to {}", cfg.bind))?;
info!(bind = %cfg.bind, "DockDoe listening");
tokio::select! {
result = axum::serve(listener, app) => {
result.context("running the web server")?;
}
() = shutdown_signal() => {
info!("shutdown signal received; exiting");
}
}
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
fn init_tracing(filter: &str) {
let filter = EnvFilter::try_new(filter)
.or_else(|_| EnvFilter::try_new("info"))
.unwrap_or_default();
tracing_subscriber::fmt().with_env_filter(filter).init();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_interval_is_rejected_at_parse_time() {
assert!(Cli::try_parse_from(["dockdoe", "--interval-secs", "0"]).is_err());
assert!(Cli::try_parse_from(["dockdoe", "--interval-secs", "1"]).is_ok());
}
}