use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::{
middleware,
routing::{delete, get, head, patch, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use crate::{
auth::require_api_key,
handlers::{
audit_events, create_api_key, create_secret, create_webhook, delete_api_key, delete_secret,
delete_webhook, get_secret, head_secret, health, list_api_keys, list_secrets,
list_webhooks, patch_secret, prune_secrets,
},
license, AppState,
};
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub api_key: Option<String>,
pub license_key: Option<String>,
pub data_dir: Option<PathBuf>,
pub sweep_interval: Duration,
pub cors_origins: Option<String>,
pub audit_retention_days: u64,
pub validation_url: String,
pub validation_cache_secs: u64,
pub heartbeat: bool,
pub webhook_secret: Option<String>,
pub instance_id: Option<String>,
pub log_level: String,
pub no_banner: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: std::env::var("SIRR_HOST").unwrap_or_else(|_| "0.0.0.0".into()),
port: std::env::var("SIRR_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(39999),
api_key: std::env::var("SIRR_API_KEY").ok(),
license_key: std::env::var("SIRR_LICENSE_KEY").ok(),
data_dir: std::env::var("SIRR_DATA_DIR").ok().map(PathBuf::from),
sweep_interval: Duration::from_secs(300),
cors_origins: std::env::var("SIRR_CORS_ORIGINS").ok(),
audit_retention_days: std::env::var("SIRR_AUDIT_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30),
validation_url: std::env::var("SIRR_VALIDATION_URL")
.unwrap_or_else(|_| "https://secretdrop.app/api/validate".into()),
validation_cache_secs: std::env::var("SIRR_VALIDATION_CACHE_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3600),
heartbeat: std::env::var("SIRR_HEARTBEAT")
.map(|v| v != "false" && v != "0")
.unwrap_or(true),
webhook_secret: std::env::var("SIRR_WEBHOOK_SECRET").ok(),
instance_id: std::env::var("SIRR_INSTANCE_ID").ok(),
log_level: std::env::var("SIRR_LOG_LEVEL").unwrap_or_else(|_| "warn".into()),
no_banner: std::env::var("NO_BANNER")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false),
}
}
}
pub fn read_key_file(path: &std::path::Path) -> Result<String> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("read key file: {}", path.display()))?;
let key = content.trim().to_string();
if key.is_empty() {
anyhow::bail!("key file is empty: {}", path.display());
}
Ok(key)
}
pub fn resolve_master_key() -> Result<String> {
if let Ok(path) = std::env::var("SIRR_MASTER_KEY_FILE") {
let key = read_key_file(std::path::Path::new(&path))?;
if std::env::var("SIRR_MASTER_KEY").is_ok() {
tracing::warn!("both SIRR_MASTER_KEY and SIRR_MASTER_KEY_FILE are set; using file");
}
return Ok(key);
}
std::env::var("SIRR_MASTER_KEY")
.context("SIRR_MASTER_KEY or SIRR_MASTER_KEY_FILE environment variable is required")
}
pub fn resolve_data_dir(data_dir: Option<&PathBuf>) -> Result<PathBuf> {
match data_dir {
Some(d) => {
std::fs::create_dir_all(d).context("create data dir")?;
Ok(d.clone())
}
None => {
let d = std::env::var("SIRR_DATA_DIR").ok().map(PathBuf::from);
match d {
Some(d) => {
std::fs::create_dir_all(&d).context("create data dir")?;
Ok(d)
}
None => crate::dirs::data_dir(),
}
}
}
}
pub async fn run(cfg: ServerConfig) -> Result<()> {
let data_dir = resolve_data_dir(cfg.data_dir.as_ref())?;
info!(data_dir = %data_dir.display(), "using data directory");
let key_path = data_dir.join("sirr.key");
let enc_key = load_or_create_key(&data_dir)?;
let key_bytes_for_id = std::fs::read(&key_path).ok();
let db_path = data_dir.join("sirr.db");
let store = crate::store::Store::open(&db_path, enc_key).context("open store")?;
let webhook_instance_id = cfg
.instance_id
.clone()
.unwrap_or_else(|| gethostname().unwrap_or_else(|| "unknown".into()));
let webhook_sender = crate::webhooks::WebhookSender::new(
store.clone(),
webhook_instance_id,
cfg.webhook_secret.clone(),
);
store
.clone()
.spawn_sweep(cfg.sweep_interval, Some(webhook_sender.clone()));
let retention_secs = (cfg.audit_retention_days * 86400) as i64;
store
.clone()
.spawn_audit_sweep(cfg.sweep_interval, retention_secs);
let lic_status = license::effective_status(cfg.license_key.as_deref());
match &lic_status {
license::LicenseStatus::Free => {
info!(
"running on free tier (≤{} secrets)",
license::FREE_TIER_LIMIT
);
}
license::LicenseStatus::Licensed => {
info!("license key accepted — unlimited secrets");
}
license::LicenseStatus::Invalid(reason) => {
anyhow::bail!("invalid SIRR_LICENSE_KEY: {reason}");
}
}
print_banner(&cfg, &data_dir, &lic_status);
let heartbeat_url = cfg
.validation_url
.replace("/api/validate", "/api/instances/heartbeat");
let validator = if lic_status == license::LicenseStatus::Licensed {
if let Some(ref key) = cfg.license_key {
let v = crate::validator::OnlineValidator::new(
key.clone(),
cfg.validation_url,
cfg.validation_cache_secs,
259200, );
let valid = v.validate_startup(&store).await;
if !valid {
warn!("license rejected online — server will enforce free-tier limits above 100 secrets");
}
Some(v)
} else {
None
}
} else {
None
};
if cfg.heartbeat {
if let (Some(ref license_key), Some(ref raw_bytes)) = (&cfg.license_key, &key_bytes_for_id)
{
let instance_id = crate::heartbeat::instance_id_from_key(raw_bytes);
info!(instance_id = %instance_id, "starting instance heartbeat");
crate::heartbeat::spawn_heartbeat(crate::heartbeat::HeartbeatConfig {
endpoint: heartbeat_url,
license_key: license_key.clone(),
instance_id,
store: store.clone(),
});
}
}
let state = AppState {
store,
api_key: cfg.api_key,
license: lic_status,
validator,
webhook_sender: Some(webhook_sender),
};
let cors = build_cors(cfg.cors_origins.as_deref());
let public = Router::new()
.route("/health", get(health))
.route("/secrets/{key}", get(get_secret))
.route("/secrets/{key}", head(head_secret));
let protected = Router::new()
.route("/secrets", get(list_secrets))
.route("/secrets", post(create_secret))
.route("/secrets/{key}", patch(patch_secret))
.route("/secrets/{key}", delete(delete_secret))
.route("/prune", post(prune_secrets))
.route("/audit", get(audit_events))
.route("/webhooks", post(create_webhook))
.route("/webhooks", get(list_webhooks))
.route("/webhooks/{id}", delete(delete_webhook))
.route("/keys", post(create_api_key))
.route("/keys", get(list_api_keys))
.route("/keys/{id}", delete(delete_api_key))
.layer(middleware::from_fn_with_state(
state.clone(),
require_api_key,
));
let app = Router::new()
.merge(public)
.merge(protected)
.with_state(state)
.layer(cors)
.layer(TraceLayer::new_for_http());
let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
.parse()
.context("invalid host/port")?;
info!(%addr, "sirr server listening");
let listener = tokio::net::TcpListener::bind(addr)
.await
.context("bind listener")?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("server error")
}
fn load_or_create_key(data_dir: &std::path::Path) -> Result<crate::store::crypto::EncryptionKey> {
let key_path = data_dir.join("sirr.key");
if key_path.exists() {
let bytes = std::fs::read(&key_path).context("read sirr.key")?;
crate::store::crypto::load_key(&bytes).ok_or_else(|| {
anyhow::anyhow!(
"sirr.key is corrupt (expected 32 bytes, got {})",
bytes.len()
)
})
} else {
let key = crate::store::crypto::generate_key();
std::fs::write(&key_path, key.as_bytes()).context("write sirr.key")?;
info!("generated new encryption key");
Ok(key)
}
}
fn gethostname() -> Option<String> {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
fn print_banner(
cfg: &ServerConfig,
data_dir: &std::path::Path,
lic_status: &license::LicenseStatus,
) {
if cfg.no_banner {
return;
}
let version = env!("CARGO_PKG_VERSION");
let addr = format!("http://{}:{}", cfg.host, cfg.port);
let token_source = if std::env::var("SIRR_MASTER_KEY_FILE").is_ok() {
"SIRR_MASTER_KEY_FILE"
} else {
"SIRR_MASTER_KEY"
};
let tier = match lic_status {
license::LicenseStatus::Free => {
format!("free (≤{} active secrets)", license::FREE_TIER_LIMIT)
}
license::LicenseStatus::Licensed => "licensed (unlimited secrets)".to_string(),
license::LicenseStatus::Invalid(_) => return,
};
eprintln!();
eprintln!(" ___ _ ");
eprintln!(" / __(_)_ _ _ _ ");
eprintln!(" \\__ \\ | '_| '_| ");
eprintln!(" |___/_|_| |_| ");
eprintln!();
eprintln!(" sirr v{version} · ephemeral secret vault");
eprintln!();
eprintln!(" address {addr}");
eprintln!(" data {}", data_dir.display());
eprintln!(" log {}", cfg.log_level);
eprintln!(" token {token_source}");
eprintln!(" tier {tier}");
eprintln!();
}
fn build_cors(origins: Option<&str>) -> CorsLayer {
let cors = CorsLayer::new()
.allow_methods([
http::Method::GET,
http::Method::HEAD,
http::Method::POST,
http::Method::PATCH,
http::Method::DELETE,
http::Method::OPTIONS,
])
.allow_headers(Any);
match origins {
Some(o) => {
let origins: Vec<_> = o.split(',').filter_map(|s| s.trim().parse().ok()).collect();
cors.allow_origin(origins)
}
None => cors.allow_origin(Any),
}
}