use std::sync::Arc;
use actix_web::HttpServer;
use anyhow::Context;
use clap::Parser;
use solid_pod_rs::{
config::{ConfigLoader, ServerConfig, StorageBackendConfig},
storage::{fs::FsBackend, memory::MemoryBackend, Storage},
};
use solid_pod_rs_server::{
build_app,
cli::{dispatch as dispatch_operator_cmd, OperatorCommand},
AppState, NodeInfoMeta,
};
use tracing::{info, warn};
#[derive(Debug, Parser)]
#[command(
name = "solid-pod-rs-server",
version,
about = "Drop-in JSS replacement — Solid Pod server binary",
long_about = None,
)]
struct Cli {
#[arg(long, short = 'c', env = "JSS_CONFIG")]
config: Option<String>,
#[arg(long)]
host: Option<String>,
#[arg(long, short = 'p')]
port: Option<u16>,
#[arg(long, env = "RUST_LOG")]
log: Option<String>,
#[arg(long, env = "JSS_MASHLIB_CDN")]
mashlib_cdn: Option<String>,
#[cfg(feature = "tls")]
#[arg(long, env = "JSS_SSL_KEY")]
ssl_key: Option<String>,
#[cfg(feature = "tls")]
#[arg(long, env = "JSS_SSL_CERT")]
ssl_cert: Option<String>,
#[command(subcommand)]
op: Option<OperatorCommand>,
}
async fn build_storage(cfg: &StorageBackendConfig) -> anyhow::Result<Arc<dyn Storage>> {
match cfg {
StorageBackendConfig::Fs { root } => {
info!(backend = "fs", root = %root, "initialising storage");
let fs = FsBackend::new(root.as_str())
.await
.with_context(|| format!("initialise FS backend at {root}"))?;
Ok(Arc::new(fs))
}
StorageBackendConfig::Memory => {
info!(backend = "memory", "initialising storage (ephemeral)");
Ok(Arc::new(MemoryBackend::new()))
}
StorageBackendConfig::S3 { bucket, region, .. } => {
anyhow::bail!(
"storage.type=s3 requested (bucket={bucket}, region={region}) but this \
binary was built without the `s3-backend` feature. Rebuild with \
`--features solid-pod-rs/s3-backend` or use fs/memory storage."
);
}
}
}
#[cfg(feature = "tls")]
fn load_rustls_config(
cert_path: &str,
key_path: &str,
) -> anyhow::Result<rustls::ServerConfig> {
use std::fs::File;
use std::io::BufReader;
let cert_file = File::open(cert_path)
.with_context(|| format!("open SSL cert {cert_path}"))?;
let mut cert_reader = BufReader::new(cert_file);
let certs: Vec<_> = rustls_pemfile::certs(&mut cert_reader)
.collect::<Result<Vec<_>, _>>()
.context("parse SSL cert chain")?;
let key_file = File::open(key_path)
.with_context(|| format!("open SSL key {key_path}"))?;
let mut key_reader = BufReader::new(key_file);
let key = rustls_pemfile::private_key(&mut key_reader)
.context("parse SSL private key")?
.ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.context("build rustls server config")
}
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let filter = cli
.log
.clone()
.or_else(|| std::env::var("RUST_LOG").ok())
.unwrap_or_else(|| "info".to_string());
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
.with_target(true)
.init();
if let Some(op) = cli.op {
return dispatch_operator_cmd(op).await;
}
let mut loader = ConfigLoader::new().with_defaults();
if let Some(path) = cli.config.as_deref() {
loader = loader.with_file(path);
}
let mut cfg: ServerConfig = loader
.with_env()
.load()
.await
.context("load server config")?;
if let Some(host) = cli.host.clone() {
cfg.server.host = host;
}
if let Some(port) = cli.port {
cfg.server.port = port;
}
cfg.validate().map_err(anyhow::Error::msg)?;
let host = cfg.server.host.clone();
let port = cfg.server.port;
let bind_addr = format!("{host}:{port}");
let storage = build_storage(&cfg.storage).await?;
let base_url = cfg
.server
.base_url
.clone()
.unwrap_or_else(|| format!("http://{bind_addr}"));
let mut state = AppState::new(storage);
state.nodeinfo = NodeInfoMeta {
software_name: "solid-pod-rs-server".into(),
software_version: env!("CARGO_PKG_VERSION").into(),
open_registrations: false,
total_users: 0,
base_url,
};
state.mashlib_cdn = cli
.mashlib_cdn
.clone()
.or_else(|| std::env::var("JSS_MASHLIB_CDN").ok());
if !cfg.auth.oidc_enabled {
warn!("auth.oidc_enabled=false — DPoP / OIDC routes disabled");
}
info!(%bind_addr, "solid-pod-rs-server starting");
let state_factory = state.clone();
let server_builder = HttpServer::new(move || build_app(state_factory.clone()));
#[cfg(feature = "tls")]
let server = {
match (cli.ssl_key.as_deref(), cli.ssl_cert.as_deref()) {
(Some(key), Some(cert)) => {
let rustls_cfg = load_rustls_config(cert, key)?;
server_builder
.bind_rustls_0_23(&bind_addr, rustls_cfg)
.with_context(|| format!("bind_rustls {bind_addr}"))?
}
_ => server_builder
.bind(&bind_addr)
.with_context(|| format!("bind {bind_addr}"))?,
}
};
#[cfg(not(feature = "tls"))]
let server = server_builder
.bind(&bind_addr)
.with_context(|| format!("bind {bind_addr}"))?;
let server = server.shutdown_timeout(30).run();
let server_handle = server.handle();
let shutdown = tokio::spawn(async move {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
info!("SIGINT received — initiating graceful shutdown");
}
_ = terminate_signal() => {
info!("SIGTERM received — initiating graceful shutdown");
}
}
server_handle.stop(true).await;
});
server.await.context("HTTP server exited with error")?;
let _ = shutdown.await;
info!("solid-pod-rs-server stopped cleanly");
Ok(())
}
#[cfg(unix)]
async fn terminate_signal() {
use tokio::signal::unix::{signal, SignalKind};
if let Ok(mut stream) = signal(SignalKind::terminate()) {
stream.recv().await;
} else {
std::future::pending::<()>().await;
}
}
#[cfg(not(unix))]
async fn terminate_signal() {
std::future::pending::<()>().await;
}