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")]
mashlib: bool,
#[arg(long, env = "JSS_MASHLIB_CDN", default_value = "2.0.0")]
mashlib_cdn: Option<String>,
#[arg(long, env = "JSS_MASHLIB_MODULE")]
mashlib_module: Option<String>,
#[arg(long, env = "JSS_LIVE_RELOAD")]
live_reload: bool,
#[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>,
#[arg(long, env = "SOLID_ALLOWED_ORIGINS", value_delimiter = ',')]
allowed_origins: Vec<String>,
#[arg(long, env = "SOLID_ADMIN_KEY")]
admin_key: Option<String>,
#[arg(long, env = "JSS_OPEN_REGISTRATION")]
open_registration: bool,
#[arg(long, env = "JSS_MCP")]
mcp: bool,
#[arg(long)]
no_mcp: bool,
#[arg(long, env = "DEPOSIT_TXO_STANDIN_ENABLED")]
deposit_txo_standin: bool,
#[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()))
}
}
}
fn bind_available(host: &str, requested: u16) -> anyhow::Result<std::net::TcpListener> {
let attempts = if requested == 0 { 1 } else { 11 };
for offset in 0..attempts {
let port = requested.saturating_add(offset);
match std::net::TcpListener::bind((host, port)) {
Ok(listener) => {
listener.set_nonblocking(true)?;
if offset > 0 {
warn!(
requested_port = requested,
selected_port = port,
"port busy; shifted listener"
);
}
return Ok(listener);
}
Err(error)
if error.kind() == std::io::ErrorKind::AddrInUse && offset + 1 < attempts => {}
Err(error) => return Err(error).with_context(|| format!("bind {host}:{port}")),
}
}
unreachable!("the final bind attempt always returns")
}
#[cfg(test)]
mod port_tests {
use super::*;
#[test]
fn busy_port_shifts_and_zero_uses_ephemeral_port() {
let occupied = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
let busy = occupied.local_addr().unwrap().port();
if busy < u16::MAX - 10 {
let shifted = bind_available("127.0.0.1", busy).unwrap();
assert!(shifted.local_addr().unwrap().port() > busy);
}
let ephemeral = bind_available("127.0.0.1", 0).unwrap();
assert_ne!(ephemeral.local_addr().unwrap().port(), 0);
}
}
#[cfg(feature = "tls")]
fn load_rustls_config(cert_path: &str, key_path: &str) -> anyhow::Result<rustls::ServerConfig> {
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
let cert_pem =
std::fs::read(cert_path).with_context(|| format!("open SSL cert {cert_path}"))?;
let certs: Vec<_> = CertificateDer::pem_slice_iter(&cert_pem)
.collect::<Result<Vec<_>, _>>()
.context("parse SSL cert chain")?;
let key_pem = std::fs::read(key_path).with_context(|| format!("open SSL key {key_path}"))?;
let key = PrivateKeyDer::from_pem_slice(&key_pem).context("parse SSL private key")?;
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 requested_port = cfg.server.port;
let listener = bind_available(&host, requested_port)?;
let port = listener.local_addr()?.port();
let bind_addr = format!("{host}:{port}");
let data_root = match &cfg.storage {
StorageBackendConfig::Fs { root } => Some(std::path::PathBuf::from(root.as_str())),
_ => None,
};
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.data_root = data_root;
solid_pod_rs_server::mempool::log_mempool_selection_once(
&solid_pod_rs_server::mempool::select_mempool_endpoint(state.mempool_url.as_deref()),
);
#[cfg(feature = "quota")]
if let (Some(root), limit) = (&state.data_root, cfg.security.default_quota_bytes) {
if limit > 0 {
state.quota = Some(std::sync::Arc::new(solid_pod_rs::quota::FsQuotaStore::new(
root.clone(),
limit,
)));
info!(limit_bytes = limit, "per-pod quota enforcement enabled");
}
}
state.allowed_origins = cli.allowed_origins.clone();
state.admin_key = cli.admin_key.clone();
state.mcp_enabled = cli.mcp && !cli.no_mcp;
if state.mcp_enabled {
info!("MCP server enabled — POST /mcp is live (agent tool surface, #490)");
}
state.deposit_txo_standin_enabled = cli.deposit_txo_standin;
if state.deposit_txo_standin_enabled {
warn!(
"DEPOSIT_TXO_STANDIN_ENABLED is set — POST /pay/.deposit credits UNVERIFIED TXO \
vouchers with no chain/UTXO check. This is a free-money oracle unless backed by a \
real UTXO existence/value/ownership check. Do not run this in production."
);
}
state.nodeinfo = NodeInfoMeta {
software_name: "solid-pod-rs-server".into(),
software_version: env!("CARGO_PKG_VERSION").into(),
open_registrations: cli.open_registration || cfg.extras.invite_only == Some(false),
total_users: 0,
base_url,
};
let mashlib_enabled = cli.mashlib || cli.mashlib_module.is_some();
if mashlib_enabled {
state.mashlib.enabled = true;
if let Some(ref module_url) = cli.mashlib_module {
state.mashlib.mode = solid_pod_rs::MashlibMode::Module {
url: module_url.clone(),
};
} else if let Some(ref version) = cli.mashlib_cdn {
state.mashlib.mode = solid_pod_rs::MashlibMode::Cdn {
version: version.clone(),
};
}
}
state.mashlib_cdn = cli.mashlib_cdn.clone();
state.live_reload = cli.live_reload;
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
.listen_rustls_0_23(listener, rustls_cfg)
.with_context(|| format!("listen_rustls {bind_addr}"))?
}
_ => server_builder
.listen(listener)
.with_context(|| format!("listen {bind_addr}"))?,
}
};
#[cfg(not(feature = "tls"))]
let server = server_builder
.listen(listener)
.with_context(|| format!("listen {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;
}