use std::sync::{Arc, OnceLock};
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_messaging_didcomm_service::{
DIDCommService, DIDCommServiceConfig, ListenerConfig, Protocols, RestartPolicy, RetryConfig,
};
use affinidi_tdk::secrets_resolver::ThreadedSecretsResolver;
use axum::routing::get;
use did_hosting_common::server::domain::parse_trusted_cidrs;
use did_hosting_common::server::store::{KS_ACL, KS_DIDS, KS_SESSIONS};
use ipnetwork::IpNetwork;
use did_hosting_common::server::auth::extractor::AuthState;
use did_hosting_common::server::didcomm_profile::{
advertised_protocols, build_tdk_profile_for_identity, reconcile_listener_protocols,
wait_for_did_resolution,
};
use did_hosting_common::server::identity::{self, ServiceIdentity};
use did_hosting_common::server::init;
use tokio_util::sync::CancellationToken;
use crate::auth::jwt::JwtKeys;
use crate::auth::session::cleanup_expired_sessions;
use crate::config::{AppConfig, AuthConfig};
use crate::control_register;
use crate::did_ops::cleanup_empty_dids;
use crate::error::AppError;
use crate::messaging;
use crate::routes;
use crate::secret_store::ServerSecrets;
use crate::stats;
use crate::store::{KeyspaceHandle, Store};
use tokio::sync::{oneshot, watch};
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, debug, error, info, warn};
#[derive(Clone)]
pub struct AppState {
pub store: Store,
pub sessions_ks: KeyspaceHandle,
pub acl_ks: KeyspaceHandle,
pub dids_ks: KeyspaceHandle,
pub config: Arc<AppConfig>,
pub did_resolver: Option<DIDCacheClient>,
pub secrets_resolver: Option<Arc<ThreadedSecretsResolver>>,
pub identity: Option<Arc<ServiceIdentity>>,
pub didcomm_service: Arc<OnceLock<DIDCommService>>,
pub jwt_keys: Option<Arc<JwtKeys>>,
pub signing_key_bytes: Option<[u8; 32]>,
pub http_client: reqwest::Client,
pub stats_collector: Option<Arc<stats::StatsCollector>>,
pub did_cache: Arc<crate::cache::ContentCache>,
pub trusted_proxy_cidrs: Arc<Vec<IpNetwork>>,
}
impl AppState {
pub fn require_didcomm_auth(
&self,
) -> Result<(&DIDCacheClient, &ThreadedSecretsResolver, &JwtKeys), AppError> {
let did_resolver = self
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Authentication("DID resolver not configured".into()))?;
let secrets_resolver = self
.secrets_resolver
.as_ref()
.ok_or_else(|| AppError::Authentication("secrets resolver not configured".into()))?;
let jwt_keys = self
.jwt_keys
.as_ref()
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
Ok((did_resolver, secrets_resolver.as_ref(), jwt_keys.as_ref()))
}
}
impl AuthState for AppState {
fn jwt_keys(&self) -> Option<&Arc<JwtKeys>> {
self.jwt_keys.as_ref()
}
fn sessions_ks(&self) -> &KeyspaceHandle {
&self.sessions_ks
}
}
pub async fn run(config: AppConfig, store: Store, secrets: ServerSecrets) -> Result<(), AppError> {
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let dids_ks = store.keyspace(KS_DIDS)?;
match dids_ks.verify_integrity().await {
Ok(0) => debug!("store integrity check passed"),
Ok(n) => warn!(
corrupted = n,
"store integrity check found corrupted entries"
),
Err(e) => warn!(error = %e, "store integrity check failed"),
}
run_first_boot_init(&store, &config).await;
let config = auto_bootstrap_dids(config, &store, &dids_ks, &secrets).await;
let identity = identity::load_identity(
config.server_did.as_deref(),
config.mediator_did.as_deref(),
identity::ProtocolSet {
didcomm: config.features.didcomm,
tsp: config.features.tsp,
},
&secrets,
&store,
)
.await;
let did_resolver = identity.as_ref().map(|i| i.did_resolver.clone());
let secrets_resolver = identity.as_ref().map(|i| i.secrets_resolver.clone());
let jwt_keys = init::init_jwt_keys(&secrets);
let signing_key_bytes = init::decode_multibase_ed25519_key(&secrets.signing_key).ok();
let addr = format!("{}:{}", config.server.host, config.server.port);
let std_listener = {
let listener = std::net::TcpListener::bind(&addr).map_err(AppError::Io)?;
listener.set_nonblocking(true).map_err(AppError::Io)?;
info!("server listening addr={addr}");
listener
};
let storage_sessions_ks = sessions_ks.clone();
let storage_dids_ks = dids_ks.clone();
let storage_auth_config = config.auth.clone();
let has_auth = jwt_keys.is_some();
let upload_body_limit = config.limits.upload_body_limit;
let stats_config = config.stats.clone();
let stats_collector = {
let collector = stats::StatsCollector::new();
let total_dids = storage_dids_ks
.prefix_iter_raw("did:")
.await
.map(|v| v.len())
.unwrap_or(0) as u64;
collector.set_total_dids(total_dids);
info!(total_dids, "stats collector initialized");
Arc::new(collector)
};
let (parsed_cidrs, bad_cidrs) = parse_trusted_cidrs(&config.server.trusted_proxy_cidrs);
if !bad_cidrs.is_empty() {
warn!(
bad_cidrs = ?bad_cidrs,
"server.trusted_proxy_cidrs contains unparseable entries; ignoring them"
);
}
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
dids_ks,
config: Arc::new(config),
did_resolver,
secrets_resolver,
identity,
didcomm_service: Arc::new(OnceLock::new()),
jwt_keys,
signing_key_bytes,
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build HTTP client"),
stats_collector: Some(stats_collector.clone()),
did_cache: Arc::new(crate::cache::ContentCache::new(Duration::from_secs(300))),
trusted_proxy_cidrs: Arc::new(parsed_cidrs),
};
info!("--- enabled services ---");
info!(
" REST API : {}",
if state.config.features.rest_api {
"enabled"
} else {
"disabled"
}
);
info!(
" DIDComm : {}",
if state.config.features.didcomm {
"enabled"
} else {
"disabled"
}
);
if let Some(ref did) = state.config.server_did {
info!(" server DID : {did}");
}
if let Some(ref did) = state.config.mediator_did {
info!(" mediator DID : {did}");
}
let (rest_shutdown_tx, rest_shutdown_rx) = watch::channel(false);
let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
let (rest_ready_tx, rest_ready_rx) = oneshot::channel::<()>();
let mut rest_shutdown = rest_shutdown_rx.clone();
let rest_state = state.clone();
let rest_handle = std::thread::Builder::new()
.name("webvh-rest".into())
.spawn(move || {
run_rest_thread(
std_listener,
rest_state,
upload_body_limit,
&mut rest_shutdown,
rest_ready_tx,
)
})
.map_err(|e| AppError::Internal(format!("failed to spawn HTTP thread: {e}")))?;
let mut storage_shutdown = storage_shutdown_rx.clone();
let storage_collector = stats_collector.clone();
let storage_http = state.http_client.clone();
let storage_control_url = state.config.control_url.clone();
let storage_server_did = state.config.server_did.clone();
let storage_stats_config = stats_config;
let storage_handle = std::thread::Builder::new()
.name("webvh-storage".into())
.spawn(move || {
run_storage_thread(
StorageThreadParams {
store,
sessions_ks: storage_sessions_ks,
dids_ks: storage_dids_ks,
auth_config: storage_auth_config,
has_auth,
collector: storage_collector,
stats_config: storage_stats_config,
http: storage_http,
control_url: storage_control_url,
server_did: storage_server_did,
},
&mut storage_shutdown,
)
})
.map_err(|e| AppError::Internal(format!("failed to spawn storage thread: {e}")))?;
let _ = rest_ready_rx.await;
if let Err(e) = crate::identity_rotation::reload_now(&state).await {
warn!("failed to establish the service identity from its DID document: {e}");
}
let didcomm_shutdown = CancellationToken::new();
if state.config.features.didcomm || state.config.features.tsp {
match start_didcomm_service(&state, didcomm_shutdown.clone()).await {
Ok(Some(svc)) => {
let _ = state.didcomm_service.set(svc);
}
Ok(None) => {}
Err(e) => warn!("failed to start DIDComm service: {e}"),
}
}
let didcomm_service = state.didcomm_service.get();
if let Some(svc) = didcomm_service
&& state.config.control_did.is_some()
{
let reg_state = state.clone();
let reg_svc = svc.clone();
tokio::spawn(async move {
control_register::register_via_didcomm(®_state, ®_svc).await;
});
}
let stats_sync_shutdown = CancellationToken::new();
let didcomm_sync_interval = state.config.stats.sync_interval_secs;
if let (Some(svc), Some(control_did), Some(server_did)) = (
didcomm_service,
state.config.control_did.as_ref(),
state.config.server_did.as_ref(),
) && didcomm_sync_interval > 0
{
let token = stats_sync_shutdown.clone();
let svc = svc.clone();
let control_did = control_did.clone();
let server_did = server_did.clone();
let collector = stats_collector.clone();
tokio::spawn(async move {
let mut timer =
tokio::time::interval(Duration::from_secs(didcomm_sync_interval.max(1)));
timer.tick().await; loop {
tokio::select! {
_ = timer.tick() => {
stats::sync_to_control_didcomm(
&svc,
&server_did,
&control_did,
&collector,
).await;
}
_ = token.cancelled() => break,
}
}
});
}
crate::identity_rotation::resume_mediator_drains(&state);
let (identity_shutdown_tx, identity_shutdown_rx) = watch::channel(false);
let identity_state = state.clone();
let identity_handle = tokio::spawn(async move {
crate::identity_rotation::run_identity_sweep_loop(identity_state, identity_shutdown_rx)
.await;
});
init::shutdown_signal().await;
let mut any_panic = false;
let _ = identity_shutdown_tx.send(true);
if let Err(e) = identity_handle.await {
warn!("identity sweep task didn't shut down cleanly: {e}");
}
stats_sync_shutdown.cancel();
didcomm_shutdown.cancel();
if let Some(svc) = didcomm_service {
svc.shutdown().await;
info!("DIDComm service stopped");
}
let _ = rest_shutdown_tx.send(true);
{
let handle = rest_handle;
match tokio::time::timeout(
Duration::from_secs(30),
tokio::task::spawn_blocking(move || handle.join()),
)
.await
{
Ok(Ok(Ok(()))) => info!("REST thread stopped"),
Ok(Ok(Err(_panic))) => {
error!("REST thread panicked");
any_panic = true;
}
Ok(Err(e)) => {
error!("failed to join REST thread: {e}");
any_panic = true;
}
Err(_) => {
error!("REST thread shutdown timed out (30s)");
any_panic = true;
}
}
}
let _ = storage_shutdown_tx.send(true);
match tokio::time::timeout(
Duration::from_secs(30),
tokio::task::spawn_blocking(move || storage_handle.join()),
)
.await
{
Ok(Ok(Ok(()))) => info!("storage thread stopped"),
Ok(Ok(Err(_panic))) => {
error!("storage thread panicked");
any_panic = true;
}
Ok(Err(e)) => {
error!("failed to join storage thread: {e}");
any_panic = true;
}
Err(_) => {
error!("storage thread shutdown timed out (30s)");
any_panic = true;
}
}
if any_panic {
return Err(AppError::Internal("one or more threads panicked".into()));
}
info!("server shut down");
Ok(())
}
pub async fn start_didcomm_service(
state: &AppState,
shutdown: CancellationToken,
) -> Result<Option<DIDCommService>, AppError> {
let identity = match state.identity.as_ref() {
Some(identity) => identity,
None => {
info!("DIDComm not configured — server_did not set");
return Ok(None);
}
};
let mediator_did = match identity.mediator_did() {
Some(did) => did,
None => {
info!("mediator_did not configured — DIDComm messaging disabled");
return Ok(None);
}
};
wait_for_did_resolution(&mediator_did, "mediator", &identity.did_resolver, &shutdown).await?;
let profile = build_tdk_profile_for_identity("server", identity, Some(&mediator_did)).await?;
let advertised = advertised_protocols(&identity.did, Some(&identity.did_resolver)).await;
let transports = reconcile_listener_protocols(identity.protocols(), advertised, &identity.did);
let didcomm_enabled = transports.didcomm;
let tsp_enabled = transports.tsp;
let protocols = match (didcomm_enabled, tsp_enabled) {
(true, true) => Protocols::BOTH,
(false, true) => Protocols::TSP_ONLY,
_ => Protocols::DIDCOMM_ONLY,
};
let listener = ListenerConfig {
id: "server".into(),
profile,
restart_policy: RestartPolicy::Always {
backoff: RetryConfig::default(),
},
auto_delete: true,
protocols,
..Default::default()
};
let router = messaging::build_server_router(state.clone())
.map_err(|e| AppError::Internal(format!("failed to build DIDComm router: {e}")))?;
let config = DIDCommServiceConfig {
listeners: vec![listener],
};
let svc = if tsp_enabled {
info!("starting messaging service with DIDComm + TSP on the mediator connection");
DIDCommService::start_with_tsp(
config,
router,
crate::tsp::ServerTspHandler::new(state.clone()),
shutdown,
)
.await
} else {
info!("starting DIDComm service with mediator connection");
DIDCommService::start(config, router, shutdown).await
}
.map_err(|e| AppError::Internal(format!("failed to start messaging service: {e}")))?;
info!(
tsp = tsp_enabled,
server_did = %identity.did,
"messaging service started"
);
Ok(Some(svc))
}
fn run_rest_thread(
std_listener: std::net::TcpListener,
state: AppState,
upload_body_limit: usize,
shutdown_rx: &mut watch::Receiver<bool>,
ready_tx: oneshot::Sender<()>,
) {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("failed to build REST runtime");
rt.block_on(async {
let listener = tokio::net::TcpListener::from_std(std_listener)
.expect("failed to convert std TcpListener to tokio TcpListener");
let base_router = if state.config.features.rest_api {
info!("HTTP thread started (REST API + public DID serving)");
routes::router(upload_body_limit)
} else {
info!("HTTP thread started (public DID serving only, REST API disabled)");
routes::router_public_only().fallback(routes::did_public::serve_public)
};
let app = base_router
.with_state(state)
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
.on_response(
DefaultOnResponse::new()
.level(Level::DEBUG)
.latency_unit(tower_http::LatencyUnit::Millis),
),
)
.layer(axum::middleware::from_fn(
did_hosting_common::server::security_headers,
))
.layer(did_hosting_common::server::public_resolution_cors())
.route("/api/health", get(routes::health::health));
let _ = ready_tx.send(());
let shutdown_rx = shutdown_rx.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let mut rx = shutdown_rx;
let _ = rx.changed().await;
})
.await
.expect("axum serve failed");
info!("REST thread shutting down");
});
}
struct StorageThreadParams {
store: Store,
sessions_ks: KeyspaceHandle,
dids_ks: KeyspaceHandle,
auth_config: AuthConfig,
has_auth: bool,
collector: Arc<stats::StatsCollector>,
stats_config: crate::config::StatsConfig,
http: reqwest::Client,
control_url: Option<String>,
server_did: Option<String>,
}
fn run_storage_thread(params: StorageThreadParams, shutdown_rx: &mut watch::Receiver<bool>) {
let StorageThreadParams {
store,
sessions_ks,
dids_ks,
auth_config,
has_auth,
collector,
stats_config,
http,
control_url,
server_did,
} = params;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build storage runtime");
rt.block_on(async {
info!(
sync_interval = stats_config.sync_interval_secs,
"storage thread started"
);
let session_interval = Duration::from_secs(auth_config.session_cleanup_interval);
let did_ttl_seconds = auth_config.cleanup_ttl_minutes * 60;
let did_interval = Duration::from_secs(did_ttl_seconds.max(60));
let sync_enabled = stats_config.sync_interval_secs > 0 && control_url.is_some();
let sync_interval = Duration::from_secs(stats_config.sync_interval_secs.max(1));
let mut session_timer = tokio::time::interval(session_interval);
let mut did_timer = tokio::time::interval(did_interval);
let mut sync_timer = tokio::time::interval(sync_interval);
session_timer.tick().await;
did_timer.tick().await;
sync_timer.tick().await;
loop {
tokio::select! {
_ = session_timer.tick(), if has_auth => {
if let Err(e) = cleanup_expired_sessions(&sessions_ks, auth_config.challenge_ttl).await {
warn!("session cleanup error: {e}");
}
}
_ = did_timer.tick() => {
match cleanup_empty_dids(&dids_ks, did_ttl_seconds).await {
Ok(0) => {}
Ok(n) => {
info!(count = n, "cleaned up empty DID records");
for _ in 0..n {
collector.decrement_total_dids();
}
}
Err(e) => warn!("DID cleanup error: {e}"),
}
}
_ = sync_timer.tick(), if sync_enabled => {
if let (Some(url), Some(did)) = (&control_url, &server_did) {
stats::sync_to_control(&http, url, did, &collector).await;
}
}
_ = shutdown_rx.changed() => {
info!("storage thread shutting down");
break;
}
}
}
if let Err(e) = store.persist().await {
error!("failed to persist store on shutdown: {e}");
} else {
info!("store persisted");
}
});
}
use did_hosting_common::server::identity::mnemonic_from_did;
pub async fn run_first_boot_init(store: &Store, config: &AppConfig) {
match did_hosting_common::server::domain::seed_domains_first_boot(
store,
&config.hosting.bootstrap_domains,
config.public_url.as_deref(),
)
.await
{
Ok(outcome) => info!(
tier = ?outcome.tier,
count = outcome.final_count,
default = ?outcome.default,
"first-boot domain seed"
),
Err(e) => warn!(error = %e, "first-boot domain seed failed"),
}
let now = did_hosting_common::server::auth::session::now_epoch();
match did_hosting_common::server::assignment_seed::seed_assignments_first_boot(
store,
&config.hosting.bootstrap_domains,
config.public_url.as_deref(),
now,
)
.await
{
Ok(outcome) => info!(
tier = ?outcome.tier,
count = outcome.final_count,
"first-boot assignment seed"
),
Err(e) => warn!(error = %e, "first-boot assignment seed failed"),
}
let runner = did_hosting_common::server::migrations::MigrationRunner::new(
did_hosting_common::server::migrations::registry(),
);
match runner.run_pending(store).await {
Ok(summary) => info!(
applied = ?summary.applied,
skipped = ?summary.skipped,
"migration runner complete"
),
Err(e) => warn!(error = %e, "migration runner failed"),
}
}
pub async fn auto_bootstrap_dids(
config: AppConfig,
_store: &Store,
dids_ks: &KeyspaceHandle,
_secrets: &ServerSecrets,
) -> AppConfig {
if let Some(ref server_did) = config.server_did
&& let Some(mnemonic) = mnemonic_from_did(server_did)
{
let exists = dids_ks
.contains_key(format!("did:{mnemonic}"))
.await
.unwrap_or(false);
if exists {
info!(path = %mnemonic, "server DID loaded from store");
} else {
error!(
did = %server_did,
path = %mnemonic,
"server DID not found in store — DID resolution will fail"
);
error!(" To create it, run: did-hosting-server bootstrap-did --path {mnemonic}");
error!(" Then update server_did in config.toml with the new DID");
}
}
match dids_ks.prefix_iter_raw("did:").await {
Ok(entries) => {
let count = entries.len();
if count == 0 {
warn!("no DIDs in store — run `did-hosting-server bootstrap-did` to create one");
} else {
info!(count, "DIDs in store:");
for (key, _) in &entries {
let mnemonic = String::from_utf8_lossy(key)
.strip_prefix("did:")
.unwrap_or("?")
.to_string();
info!(path = %mnemonic, " DID loaded");
}
}
}
Err(e) => warn!("failed to list DIDs: {e}"),
}
config
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mnemonic_from_did_simple() {
let did =
"did:webvh:QmaErmPvnHUDaaiM4phkDgrK58T49cxgmCUtKon9gwyWtJ:webvh.storm.ws:webvh:server1";
assert_eq!(mnemonic_from_did(did).unwrap(), "webvh/server1");
}
#[test]
fn mnemonic_from_did_single_path() {
let did = "did:webvh:QmABC:example.com:my-did";
assert_eq!(mnemonic_from_did(did).unwrap(), "my-did");
}
#[test]
fn mnemonic_from_did_deep_path() {
let did = "did:webvh:QmABC:example.com:people:staff:glenn";
assert_eq!(mnemonic_from_did(did).unwrap(), "people/staff/glenn");
}
#[test]
fn mnemonic_from_did_invalid() {
assert!(mnemonic_from_did("did:web:example.com").is_none());
assert!(mnemonic_from_did("not-a-did").is_none());
}
}