mod config;
mod setup;
mod setup_recipe;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use clap::{Parser, Subcommand};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, debug, error, info, warn};
use did_hosting_common::server::config::init_tracing;
use did_hosting_common::server::error::AppError;
use did_hosting_common::server::identity::ServiceIdentity;
use did_hosting_common::server::init;
use did_hosting_common::server::secret_store::ServerSecrets;
use did_hosting_common::server::stats_collector::StatsCollector;
use did_hosting_common::server::store::{KeyspaceHandle, Store};
use config::DaemonConfig;
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES, KS_WITNESSES,
};
#[derive(Parser)]
#[command(
name = "did-hosting-daemon",
about = "DID Hosting Daemon — Unified Service",
version
)]
struct Cli {
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Setup {
#[arg(long, conflicts_with = "setup_key_file")]
setup_key_out: Option<PathBuf>,
#[arg(long, conflicts_with = "setup_key_out")]
setup_key_file: Option<PathBuf>,
#[arg(long, default_value = "webvh", requires = "setup_key_out")]
context: String,
#[arg(long, value_name = "FILE")]
from: Option<PathBuf>,
#[arg(long)]
force_reprovision: bool,
#[arg(long, requires = "from")]
non_interactive: bool,
},
Uninstall {
#[arg(long)]
yes: bool,
},
SetupOfflinePrepare {
#[arg(long, default_value = "bootstrap-request.json")]
request: PathBuf,
#[arg(long, default_value = "setup-offline-state.toml")]
state: PathBuf,
},
SetupOfflineComplete {
#[arg(long)]
bundle: PathBuf,
#[arg(long)]
expect_digest: String,
#[arg(long, default_value = "setup-offline-state.toml")]
state: PathBuf,
},
Health,
AddAcl {
#[arg(long)]
did: String,
#[arg(long, default_value = "owner")]
role: String,
#[arg(long)]
label: Option<String>,
},
ListAcl,
IdentityList,
IdentityRotateKeys {
#[arg(long, default_value = "ka")]
keys: String,
#[arg(long)]
ka_key: Option<String>,
#[arg(long)]
signing_key: Option<String>,
#[arg(long)]
grace: Option<String>,
},
IdentityRetireNow {
#[arg(long)]
generation: u64,
},
RemoveAcl {
#[arg(long)]
did: String,
},
Invite {
#[arg(long)]
did: String,
#[arg(long, default_value = "owner")]
role: String,
#[arg(long)]
ttl_hours: Option<u64>,
},
BootstrapDid {
#[arg(long, default_value = ".well-known")]
path: String,
#[arg(long)]
did_log: Option<PathBuf>,
#[arg(long)]
did_witness: Option<PathBuf>,
#[arg(long)]
witness_url: Option<String>,
#[arg(long)]
witness_id: Option<String>,
},
RecreateDid {
#[arg(long)]
path: String,
},
RecoverDid {
#[arg(long)]
path: String,
},
ListDids,
RemoveDid {
#[arg(long)]
path: String,
},
LoadDid {
#[arg(long)]
path: String,
#[arg(long)]
did_log: PathBuf,
#[arg(long)]
did_witness: Option<PathBuf>,
},
ImportSecrets {
#[arg(long, group = "source")]
vta_bundle: Option<String>,
#[arg(long, group = "source")]
signing_key: Option<String>,
#[arg(long)]
ka_key: Option<String>,
#[arg(long)]
jwt_key: Option<String>,
#[arg(long)]
vta_credential: Option<String>,
#[arg(long)]
force: bool,
},
Backup {
#[arg(short, long, default_value = "webvh-backup.json")]
output: String,
},
Restore {
#[arg(short, long)]
input: String,
},
MigrateFromWebvhConfig {
#[arg(long, value_name = "FILE")]
input: PathBuf,
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
print_banner();
match cli.command {
Some(Command::Setup {
setup_key_out,
setup_key_file,
context,
from,
force_reprovision,
non_interactive: _,
}) => {
if let Some(path) = setup_key_out {
if let Err(e) = setup::run_setup_phase1(&path, &context).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
} else if let Some(recipe_path) = from {
match setup_recipe::run_from_recipe(&recipe_path, setup_key_file, force_reprovision)
.await
{
Ok(()) => {}
Err(e) => {
eprintln!("Setup error: {e}");
std::process::exit(setup_recipe::map_exit_code(&e));
}
}
} else if let Err(e) = setup::run_wizard(cli.config, setup_key_file).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::Uninstall { yes }) => {
let config_path = cli
.config
.clone()
.unwrap_or_else(|| PathBuf::from("config.toml"));
if let Err(e) = setup_recipe::run_uninstall(&config_path, yes).await {
eprintln!("Uninstall error: {e}");
std::process::exit(1);
}
}
Some(Command::SetupOfflinePrepare { request, state }) => {
if let Err(e) = setup::run_setup_offline_prepare(cli.config, request, state).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::SetupOfflineComplete {
bundle,
expect_digest,
state,
}) => {
if let Err(e) = setup::run_setup_offline_complete(bundle, expect_digest, state).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::Health) => {
if let Err(e) = run_health(cli.config).await {
eprintln!("Health check error: {e}");
std::process::exit(1);
}
}
Some(Command::AddAcl { did, role, label }) => {
if let Err(e) = run_add_acl(cli.config, did, role, label).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ListAcl) => {
if let Err(e) = run_list_acl(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityRotateKeys {
keys,
ka_key,
signing_key,
grace,
}) => {
if let Err(e) =
run_identity_rotate_keys(cli.config, keys, ka_key, signing_key, grace).await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityList) => {
if let Err(e) = run_identity_list(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityRetireNow { generation }) => {
if let Err(e) = run_identity_retire_now(cli.config, generation).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RemoveAcl { did }) => {
if let Err(e) = run_remove_acl(cli.config, did).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::Invite {
did,
role,
ttl_hours,
}) => {
if let Err(e) = run_invite(cli.config, did, role, ttl_hours).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::BootstrapDid {
path,
did_log,
did_witness,
witness_url,
witness_id,
}) => {
if let Err(e) = run_bootstrap_did(
cli.config,
path,
did_log,
did_witness,
witness_url,
witness_id,
)
.await
{
eprintln!("Bootstrap error: {e}");
std::process::exit(1);
}
}
Some(Command::RecreateDid { path }) => {
if let Err(e) = run_recreate_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RecoverDid { path }) => {
if let Err(e) = run_recover_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ListDids) => {
if let Err(e) = run_list_dids(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RemoveDid { path }) => {
if let Err(e) = run_remove_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::LoadDid {
path,
did_log,
did_witness,
}) => {
if let Err(e) = run_load_did(cli.config, path, did_log, did_witness).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ImportSecrets {
vta_bundle,
signing_key,
ka_key,
jwt_key,
vta_credential,
force,
}) => {
if let Err(e) = run_import_secrets(
cli.config,
vta_bundle,
signing_key,
ka_key,
jwt_key,
vta_credential,
force,
)
.await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::Backup { output }) => {
if let Err(e) = did_hosting_server::backup::run_backup(cli.config, output).await {
eprintln!("Backup error: {e}");
std::process::exit(1);
}
}
Some(Command::Restore { input }) => {
if let Err(e) = did_hosting_server::backup::run_restore(cli.config, input).await {
eprintln!("Restore error: {e}");
std::process::exit(1);
}
}
Some(Command::MigrateFromWebvhConfig {
input,
output,
force,
}) => {
if let Err(e) = run_migrate_from_webvh_config(&input, output.as_deref(), force) {
eprintln!("Config migration error: {e}");
std::process::exit(1);
}
}
None => run_daemon(cli.config).await,
}
}
fn run_migrate_from_webvh_config(
input: &std::path::Path,
output: Option<&std::path::Path>,
_force: bool,
) -> Result<(), String> {
if !input.exists() {
return Err(format!("input config not found: {}", input.display()));
}
let default_out;
let out: &std::path::Path = match output {
Some(p) => p,
None => {
let mut s = input.as_os_str().to_owned();
s.push(".migrated");
default_out = std::path::PathBuf::from(s);
default_out.as_path()
}
};
eprintln!(
"migrate-from-webvh-config: stub in v0.7.0\n \
input: {}\n \
output: {} (would be written)\n\n\
The rewriter is implemented in a follow-up release (see \
tasks/did-hosting-rollout-plan.md WS-7). For now, rename the \
crate refs / env vars manually:\n \
- WEBVH_* env vars → DID_HOSTING_*\n \
- webvh-server binary → did-hosting-server\n \
- webvh-control binary → did-hosting-control\n \
- webvh-daemon binary → did-hosting-daemon\n \
- (webvh-witness / webvh-watcher keep their names)",
input.display(),
out.display(),
);
Ok(())
}
async fn run_daemon(config_path: Option<PathBuf>) {
let mut config = match DaemonConfig::load(config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {e}");
eprintln!();
eprintln!("Create a config.toml or specify one:");
eprintln!(" did-hosting-daemon --config <path>");
std::process::exit(1);
}
};
init_tracing(&config.log);
let secrets = load_secrets(&config).await;
let main_store = Store::open(&config.store).await.unwrap_or_else(|e| {
error!("failed to open main store: {e}");
std::process::exit(1);
});
let witness_store = Store::open(&config.witness_store)
.await
.unwrap_or_else(|e| {
error!("failed to open witness store: {e}");
std::process::exit(1);
});
let dids_ks = main_store.keyspace(KS_DIDS).unwrap_or_else(|e| {
error!("failed to open dids keyspace: {e}");
std::process::exit(1);
});
let stats_ks = main_store.keyspace(KS_STATS).unwrap_or_else(|e| {
error!("failed to open stats keyspace: {e}");
std::process::exit(1);
});
let timeseries_ks = main_store.keyspace(KS_TIMESERIES).unwrap_or_else(|e| {
error!("failed to open timeseries keyspace: {e}");
std::process::exit(1);
});
match did_hosting_common::server::domain::seed_domains_first_boot(
&main_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) => {
error!("first-boot domain seed failed: {e}");
std::process::exit(1);
}
}
let assignment_now = did_hosting_common::server::auth::session::now_epoch();
match did_hosting_common::server::assignment_seed::seed_assignments_first_boot(
&main_store,
&config.hosting.bootstrap_domains,
config.public_url.as_deref(),
assignment_now,
)
.await
{
Ok(outcome) => {
info!(
tier = ?outcome.tier,
count = outcome.final_count,
"first-boot assignment seed"
);
}
Err(e) => {
error!("first-boot assignment seed failed: {e}");
std::process::exit(1);
}
}
{
let runner = did_hosting_common::server::migrations::MigrationRunner::new(
did_hosting_common::server::migrations::registry(),
);
match runner.run_pending(&main_store).await {
Ok(summary) => {
info!(
applied = ?summary.applied,
skipped = ?summary.skipped,
"migration runner complete"
);
}
Err(e) => {
error!("migration runner failed: {e}");
std::process::exit(1);
}
}
}
if config.enable.server {
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"),
}
}
if config.enable.server {
let server_config = config.server_config();
let bootstrapped = did_hosting_server::server::auto_bootstrap_dids(
server_config,
&main_store,
&dids_ks,
&secrets,
)
.await;
if config.server_did.is_none() && bootstrapped.server_did.is_some() {
config.server_did = bootstrapped.server_did;
}
}
let stats_collector = {
use did_hosting_common::server::stats_collector::StatsAggregate;
let collector = StatsCollector::new();
let mut total_resolves = 0u64;
let mut total_updates = 0u64;
let mut last_resolved_at: Option<u64> = None;
let mut last_updated_at: Option<u64> = None;
if let Ok(raw) = stats_ks.prefix_iter_raw("stats:").await {
for (_key, value) in raw {
if let Ok(s) = serde_json::from_slice::<did_hosting_common::DidStats>(&value) {
total_resolves += s.total_resolves;
total_updates += s.total_updates;
last_resolved_at = match (last_resolved_at, s.last_resolved_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
last_updated_at = match (last_updated_at, s.last_updated_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
}
}
}
let total_dids = dids_ks
.prefix_iter_raw("did:")
.await
.map(|v| v.len())
.unwrap_or(0) as u64;
collector.seed_aggregate(&StatsAggregate {
total_dids,
total_resolves,
total_updates,
last_resolved_at,
last_updated_at,
});
info!(
total_dids,
total_resolves, total_updates, "stats collector seeded from store"
);
Arc::new(collector)
};
let identity = did_hosting_common::server::identity::load_identity(
config.server_did.as_deref(),
config.mediator_did.as_deref(),
did_hosting_common::server::identity::ProtocolSet {
didcomm: config.features.didcomm,
tsp: config.features.tsp,
},
&secrets,
&main_store,
)
.await;
let mut combined: Router = Router::new();
let mut server_state: Option<did_hosting_server::server::AppState> = None;
let mut enabled_services = Vec::new();
let 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");
if config.enable.server {
match build_server(
&config,
&secrets,
&main_store,
&stats_collector,
&http_client,
identity.clone(),
)
.await
{
Ok((router, state)) => {
combined = combined.merge(router);
server_state = Some(state);
enabled_services.push("server (/)");
}
Err(e) => {
error!("failed to initialize server: {e}");
std::process::exit(1);
}
}
}
if config.enable.witness {
match build_witness(&config, &secrets, &witness_store, identity.clone()).await {
Ok(router) => {
combined = combined.nest("/witness", router);
enabled_services.push("witness (/witness)");
}
Err(e) => {
error!("failed to initialize witness: {e}");
std::process::exit(1);
}
}
}
if config.enable.watcher {
match build_watcher(&config, &main_store).await {
Ok(router) => {
combined = combined.nest("/watcher", router);
enabled_services.push("watcher (/watcher)");
}
Err(e) => {
error!("failed to initialize watcher: {e}");
std::process::exit(1);
}
}
}
let mut control_state: Option<did_hosting_control::server::AppState> = None;
if config.enable.control {
match build_control(
&config,
&secrets,
&main_store,
&stats_collector,
&http_client,
identity.clone(),
)
.await
{
Ok((router, state)) => {
combined = combined.merge(router);
control_state = Some(state);
enabled_services.push("control (/)");
}
Err(e) => {
error!("failed to initialize control plane: {e}");
std::process::exit(1);
}
}
}
combined = match server_state {
Some(ref state) => combined.fallback({
let state = state.clone();
move |request: axum::extract::Request| {
let state = state.clone();
async move { daemon_fallback(state, request).await }
}
}),
None => {
#[cfg(feature = "ui")]
{
combined.fallback(did_hosting_control::frontend::static_handler)
}
#[cfg(not(feature = "ui"))]
{
combined
}
}
};
let app = combined
.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(did_hosting_common::server::public_resolution_cors())
.route("/health", get(daemon_health));
info!("--- daemon services ---");
for svc in &enabled_services {
info!(" {svc}");
}
let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
let sessions_ks = main_store.keyspace(KS_SESSIONS).unwrap_or_else(|e| {
error!("failed to open sessions keyspace: {e}");
std::process::exit(1);
});
let storage_handle = tokio::spawn(run_daemon_storage_task(
DaemonStorageParams {
store: main_store.clone(),
sessions_ks,
dids_ks: dids_ks.clone(),
stats_ks,
timeseries_ks: timeseries_ks.clone(),
auth_config: config.auth.clone(),
has_auth: config.server_did.is_some(),
collector: stats_collector.clone(),
control_state: control_state.clone(),
},
storage_shutdown_rx,
));
let addr = format!("{}:{}", config.server.host, config.server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| {
error!("failed to bind {addr}: {e}");
std::process::exit(1);
});
info!("daemon listening on {addr}");
let (http_ready_tx, http_ready_rx) = tokio::sync::oneshot::channel::<()>();
let http_handle = tokio::spawn(async move {
let _ = http_ready_tx.send(());
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(init::shutdown_signal())
.await
.expect("axum serve failed");
});
let _ = http_ready_rx.await;
if let Some(ref state) = control_state
&& let Err(e) = did_hosting_control::identity_rotation::reload_now(state).await
{
warn!("failed to establish the service identity from its DID document: {e}");
}
let didcomm_shutdown = CancellationToken::new();
if config.features.didcomm || config.features.tsp {
if let Some(ref mut state) = control_state {
info!(
server_did = ?state.config.server_did,
mediator_did = ?state.config.mediator_did,
"starting control plane DIDComm service"
);
match did_hosting_control::server::start_didcomm_service(
state,
didcomm_shutdown.clone(),
)
.await
{
Ok(Some(svc)) => {
info!("DIDComm service started successfully");
let _ = state.didcomm_service.set(svc);
}
Ok(None) => {
warn!(
"DIDComm service returned None — check server_did and mediator_did config"
);
}
Err(e) => {
warn!("failed to start DIDComm service: {e}");
}
}
} else {
warn!("DIDComm enabled but control plane not enabled — skipping");
}
} else {
info!("DIDComm disabled in config");
}
let (outbox_shutdown_tx, outbox_shutdown_rx) = watch::channel(false);
let outbox_handle = if let Some(state) = control_state.as_ref() {
let outbox_state = state.clone();
let outbox_notify = state.outbox_notify.clone();
Some(tokio::spawn(async move {
did_hosting_control::outbox::run_outbox_loop(
outbox_state,
outbox_notify,
outbox_shutdown_rx,
)
.await;
}))
} else {
None
};
let _ = http_handle.await;
didcomm_shutdown.cancel();
info!("DIDComm service stopped");
let _ = outbox_shutdown_tx.send(true);
if let Some(handle) = outbox_handle {
match handle.await {
Ok(()) => info!("outbox worker stopped"),
Err(e) => warn!("outbox worker didn't shut down cleanly: {e}"),
}
}
let _ = storage_shutdown_tx.send(true);
match storage_handle.await {
Ok(()) => info!("storage task stopped"),
Err(e) => error!("storage task panicked: {e}"),
}
if let Err(e) = witness_store.persist().await {
error!("failed to persist witness store: {e}");
}
info!("daemon shut down");
}
struct DaemonStorageParams {
store: Store,
sessions_ks: KeyspaceHandle,
dids_ks: KeyspaceHandle,
stats_ks: KeyspaceHandle,
timeseries_ks: KeyspaceHandle,
auth_config: did_hosting_common::server::config::AuthConfig,
has_auth: bool,
collector: Arc<StatsCollector>,
control_state: Option<did_hosting_control::server::AppState>,
}
async fn run_daemon_storage_task(
params: DaemonStorageParams,
mut shutdown_rx: watch::Receiver<bool>,
) {
info!("storage task started");
let session_interval = Duration::from_secs(params.auth_config.session_cleanup_interval);
let did_ttl_seconds = params.auth_config.cleanup_ttl_minutes * 60;
let did_interval = Duration::from_secs(did_ttl_seconds.max(60));
let flush_interval = Duration::from_secs(10);
let mut session_timer = tokio::time::interval(session_interval);
let mut did_timer = tokio::time::interval(did_interval);
let mut flush_timer = tokio::time::interval(flush_interval);
let mut purge_timer =
tokio::time::interval(did_hosting_server::purge_sweep::DEFAULT_SWEEP_INTERVAL);
let mut identity_expiry_timer =
tokio::time::interval(did_hosting_control::identity_rotation::SWEEP_INTERVAL);
let mut identity_reload_timer =
tokio::time::interval(did_hosting_control::identity_rotation::RELOAD_INTERVAL);
session_timer.tick().await;
did_timer.tick().await;
flush_timer.tick().await;
purge_timer.tick().await;
identity_expiry_timer.tick().await;
identity_reload_timer.tick().await;
loop {
tokio::select! {
_ = session_timer.tick(), if params.has_auth => {
if let Err(e) = did_hosting_common::server::auth::session::cleanup_expired_sessions(
¶ms.sessions_ks,
params.auth_config.challenge_ttl,
).await {
warn!("session cleanup error: {e}");
}
}
_ = did_timer.tick() => {
match did_hosting_server::did_ops::cleanup_empty_dids(
¶ms.dids_ks,
did_ttl_seconds,
).await {
Ok(0) => {}
Ok(n) => {
info!(count = n, "cleaned up empty DID records");
for _ in 0..n {
params.collector.decrement_total_dids();
}
}
Err(e) => warn!("DID cleanup error: {e}"),
}
}
_ = flush_timer.tick() => {
if let Err(e) = did_hosting_control::server::flush_stats_to_store(
¶ms.collector,
¶ms.stats_ks,
¶ms.timeseries_ks,
¶ms.dids_ks,
¶ms.store,
).await {
warn!("stats flush error: {e}");
}
}
_ = purge_timer.tick() => {
let purged = did_hosting_server::purge_sweep::run_sweep_once(¶ms.store).await;
if purged > 0 {
info!(count = purged, "purge sweep tick completed");
}
}
_ = identity_expiry_timer.tick() => {
if let Some(state) = params.control_state.as_ref() {
did_hosting_control::identity_rotation::expire_due(state).await;
}
}
_ = identity_reload_timer.tick() => {
if let Some(state) = params.control_state.as_ref()
&& let Err(e) = did_hosting_control::identity_rotation::reload_now(state).await
{
debug!("identity backstop reload failed: {e}");
}
}
_ = shutdown_rx.changed() => {
info!("storage task shutting down");
break;
}
}
}
let _ = did_hosting_control::server::flush_stats_to_store(
¶ms.collector,
¶ms.stats_ks,
¶ms.timeseries_ks,
¶ms.dids_ks,
¶ms.store,
)
.await;
if let Err(e) = params.store.persist().await {
error!("failed to persist main store on shutdown: {e}");
} else {
info!("main store persisted");
}
}
type ServiceResult = Result<Router, AppError>;
async fn build_server(
config: &DaemonConfig,
secrets: &ServerSecrets,
store: &Store,
stats_collector: &Arc<StatsCollector>,
http_client: &reqwest::Client,
identity: Option<Arc<ServiceIdentity>>,
) -> Result<(Router, did_hosting_server::server::AppState), AppError> {
use did_hosting_server::server::AppState;
let server_config = config.server_config();
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let dids_ks = store.keyspace(KS_DIDS)?;
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 (parsed_cidrs, bad_cidrs) = did_hosting_common::server::domain::parse_trusted_cidrs(
&server_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(server_config),
did_resolver,
secrets_resolver,
identity,
didcomm_service: Arc::new(std::sync::OnceLock::new()),
jwt_keys,
signing_key_bytes,
http_client: http_client.clone(),
stats_collector: Some(stats_collector.clone()),
did_cache: Arc::new(did_hosting_server::cache::ContentCache::new(
Duration::from_secs(300),
)),
trusted_proxy_cidrs: Arc::new(parsed_cidrs),
};
let router = did_hosting_server::routes::router_public_only().with_state(state.clone());
info!("server service initialized (public-only, daemon mode)");
Ok((router, state))
}
async fn build_witness(
config: &DaemonConfig,
secrets: &ServerSecrets,
store: &Store,
identity: Option<Arc<ServiceIdentity>>,
) -> ServiceResult {
use webvh_witness::server::AppState;
use webvh_witness::signing::LocalSigner;
let witness_config = config.witness_config();
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let witnesses_ks = store.keyspace(KS_WITNESSES)?;
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 state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
witnesses_ks,
config: Arc::new(witness_config),
did_resolver,
secrets_resolver,
identity,
didcomm_service: Arc::new(std::sync::OnceLock::new()),
jwt_keys,
signer: Arc::new(LocalSigner),
};
let router = webvh_witness::routes::router().with_state(state);
info!("witness service initialized");
Ok(router)
}
async fn build_watcher(config: &DaemonConfig, store: &Store) -> ServiceResult {
use webvh_watcher::server::AppState;
let watcher_config = config.watcher_config();
let dids_ks = store.keyspace(KS_DIDS)?;
let state = AppState {
store: store.clone(),
dids_ks,
config: Arc::new(watcher_config),
};
let router = webvh_watcher::routes::router().with_state(state);
info!("watcher service initialized");
Ok(router)
}
async fn build_control(
config: &DaemonConfig,
secrets: &ServerSecrets,
store: &Store,
stats_collector: &Arc<StatsCollector>,
http_client: &reqwest::Client,
identity: Option<Arc<ServiceIdentity>>,
) -> Result<(Router, did_hosting_control::server::AppState), AppError> {
use did_hosting_control::server::AppState;
let control_config = config.control_config();
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let registry_ks = store.keyspace(KS_REGISTRY)?;
let dids_ks = store.keyspace(KS_DIDS)?;
let stats_ks = store.keyspace(KS_STATS)?;
let timeseries_ks = store.keyspace(KS_TIMESERIES)?;
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 webauthn = control_config.public_url.as_ref().and_then(|url| {
match did_hosting_common::server::passkey::build_webauthn(url) {
Ok(w) => {
info!("WebAuthn (passkey) auth enabled for control plane");
Some(Arc::new(w))
}
Err(e) => {
warn!("WebAuthn initialization failed: {e} — passkey auth disabled");
None
}
}
});
let trust_tasks_verifier = did_resolver.clone().map(|client| {
let resolver = Arc::new(trust_tasks_proof::affinidi::CachedDidResolver::new(
Arc::new(client),
));
Arc::new(
did_hosting_common::server::trust_tasks::TransportBoundVerifier::with_resolver(
resolver,
),
)
});
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(control_config),
did_resolver,
secrets_resolver,
identity,
trust_tasks_verifier,
jwt_keys,
webauthn,
http_client: http_client.clone(),
didcomm_service: Arc::new(std::sync::OnceLock::new()),
stats_collector: stats_collector.clone(),
stats_ks,
timeseries_ks,
signing_key_bytes: init::decode_multibase_ed25519_key(&secrets.signing_key).ok(),
replay_cache: Arc::new(did_hosting_control::replay::ReplayCache::new()),
path_locks: did_hosting_control::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(
did_hosting_control::pending_challenges::PendingChallengeTracker::new(),
),
pending_confirms: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
ip_rate_limiter: Arc::new(did_hosting_control::rate_limit::IpRateLimiter::new()),
};
did_hosting_control::server::seed_registry(&state).await;
let router = did_hosting_control::routes::router_without_fallback().with_state(state.clone());
info!("control plane service initialized");
Ok((router, state))
}
async fn daemon_fallback(
state: did_hosting_server::server::AppState,
request: axum::extract::Request,
) -> Response {
let uri = request.uri().clone();
let did_resp =
did_hosting_server::routes::did_public::serve_public(axum::extract::State(state), request)
.await;
if did_resp.status() != StatusCode::NOT_FOUND {
return did_resp;
}
#[cfg(feature = "ui")]
{
did_hosting_control::frontend::static_handler(uri).await
}
#[cfg(not(feature = "ui"))]
{
let _ = uri;
StatusCode::NOT_FOUND.into_response()
}
}
async fn load_secrets(config: &DaemonConfig) -> ServerSecrets {
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)
.unwrap_or_else(|e| {
eprintln!("Error creating secret store: {e}");
std::process::exit(1);
});
match secret_store.get().await {
Ok(Some(s)) => {
info!("secrets loaded from secret store");
s
}
Ok(None) => {
eprintln!("Error: no secrets found — run service setup first");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error loading secrets: {e}");
std::process::exit(1);
}
}
}
async fn run_add_acl(
config_path: Option<PathBuf>,
did: String,
role_str: String,
label: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_add_acl(
&config.store,
did,
role_str,
label,
None,
None,
)
.await
}
async fn run_list_acl(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_list_acl(&config.store).await
}
async fn run_remove_acl(
config_path: Option<PathBuf>,
did: String,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_remove_acl(&config.store, did).await
}
async fn run_invite(
config_path: Option<PathBuf>,
did: String,
role: String,
ttl_hours: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::passkey::routes::create_enrollment_invite;
let config = DaemonConfig::load(config_path)?;
let control_config = config.control_config();
let base_url = control_config
.public_url
.as_deref()
.ok_or("public_url must be set in config for enrollment invites")?;
let enrollment_ttl = match ttl_hours {
Some(hours) => hours * 3600,
None => control_config.auth.passkey_enrollment_ttl,
};
let store = Store::open(&control_config.store).await?;
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let resp =
create_enrollment_invite(&sessions_ks, base_url, enrollment_ttl, &did, &role).await?;
eprintln!();
eprintln!(" Enrollment invite created!");
eprintln!();
eprintln!(" DID: {did}");
eprintln!(" Role: {role}");
let ttl_hours_display = enrollment_ttl / 3600;
eprintln!(
" Expires: in {ttl_hours_display}h (epoch {})",
resp.expires_at
);
eprintln!();
eprintln!(" Enrollment URL:");
eprintln!(" {}", resp.enrollment_url);
eprintln!();
Ok(())
}
async fn run_bootstrap_did(
config_path: Option<PathBuf>,
mnemonic: String,
did_log: Option<PathBuf>,
did_witness: Option<PathBuf>,
witness_url: Option<String>,
witness_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_server::bootstrap;
if did_witness.is_some() && did_log.is_none() {
return Err("--did-witness requires --did-log".into());
}
let config = DaemonConfig::load(config_path)?;
let server_config = config.server_config();
let public_url = server_config
.public_url
.as_deref()
.ok_or("public_url must be set in config for bootstrap")?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_server::did_ops::did_key(&mnemonic);
if dids_ks.contains_key(did_key).await? {
eprintln!();
eprintln!(" DID at path '{mnemonic}' already exists.");
eprintln!(" No action taken.");
eprintln!();
return Ok(());
}
let result = if let Some(log_path) = did_log {
let jsonl = std::fs::read_to_string(&log_path)
.map_err(|e| format!("failed to read {}: {e}", log_path.display()))?;
let witness_content = match &did_witness {
Some(path) => Some(
std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?,
),
None => None,
};
bootstrap::import_did_at_path(
&store,
&dids_ks,
&mnemonic,
&jsonl,
witness_content.as_deref(),
)
.await?
} else {
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run setup first")?;
let signing_secret = Secret::from_multibase(&secrets.signing_key, None)
.map_err(|e| format!("invalid signing_key: {e}"))?;
let ka_secret = Secret::from_multibase(&secrets.key_agreement_key, None).ok();
let mediator_uri = if let Some(ref vta_did) = config.mediator_did {
use did_hosting_common::server::didcomm_profile::resolve_mediator_did;
resolve_mediator_did(vta_did, None).await
} else {
None
};
let result = bootstrap::bootstrap_did(
&store,
&dids_ks,
&signing_secret,
ka_secret.as_ref(),
mediator_uri.as_deref(),
config.features.didcomm,
config.features.tsp,
public_url,
&mnemonic,
)
.await?;
if let (Some(w_url), Some(w_id)) = (witness_url, witness_id) {
use did_hosting_common::WitnessClient;
eprintln!(" Requesting witness proof...");
let mut witness_client = WitnessClient::new(&w_url);
if let Err(e) = witness_client
.authenticate(&result.did_id, &signing_secret)
.await
{
eprintln!(" Warning: witness authentication failed: {e}");
} else {
let version_id = result
.jsonl
.lines()
.last()
.and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.and_then(|v| {
v.get("versionId")
.and_then(|id| id.as_str())
.map(String::from)
});
if let Some(vid) = version_id {
match witness_client.request_proof(&w_id, &vid).await {
Ok(proof) => {
let proof_json = serde_json::to_string(&proof)?;
dids_ks
.insert_raw(
did_hosting_server::did_ops::content_witness_key(&mnemonic),
proof_json.into_bytes(),
)
.await?;
eprintln!(" Witness proof stored.");
}
Err(e) => {
eprintln!(" Warning: witness proof request failed: {e}");
}
}
}
}
}
result
};
store.persist().await?;
let url_path = if mnemonic == ".well-known" {
".well-known/did.jsonl".to_string()
} else {
format!("{mnemonic}/did.jsonl")
};
eprintln!();
if mnemonic == ".well-known" {
eprintln!(" Root DID bootstrapped!");
} else {
eprintln!(" DID bootstrapped at path '{mnemonic}'!");
}
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" JSONL: {public_url}/{url_path}");
eprintln!();
Ok(())
}
async fn run_recreate_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_server::bootstrap;
let config = DaemonConfig::load(config_path)?;
let config_file = config.config_path.clone();
let server_config = config.server_config();
let public_url = server_config
.public_url
.as_deref()
.ok_or("public_url must be set in config")?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_server::did_ops::did_key(&mnemonic);
if let Some(existing) = dids_ks
.get::<did_hosting_common::did_ops::DidRecord>(did_key.clone())
.await?
{
dids_ks.remove(did_key).await?;
dids_ks
.remove(did_hosting_server::did_ops::content_log_key(&mnemonic))
.await?;
dids_ks
.remove(did_hosting_server::did_ops::content_witness_key(&mnemonic))
.await?;
dids_ks
.remove(did_hosting_server::did_ops::owner_key(
&existing.owner,
&mnemonic,
))
.await?;
eprintln!(" Removed existing DID at path '{mnemonic}'");
}
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run setup first")?;
let signing_secret = Secret::from_multibase(&secrets.signing_key, None)
.map_err(|e| format!("invalid signing_key: {e}"))?;
let ka_secret = Secret::from_multibase(&secrets.key_agreement_key, None).ok();
let mediator_uri = if let Some(ref vta_did) = config.mediator_did {
use did_hosting_common::server::didcomm_profile::resolve_mediator_did;
resolve_mediator_did(vta_did, None).await
} else {
None
};
let result = bootstrap::bootstrap_did(
&store,
&dids_ks,
&signing_secret,
ka_secret.as_ref(),
mediator_uri.as_deref(),
config.features.didcomm,
config.features.tsp,
public_url,
&mnemonic,
)
.await?;
store.persist().await?;
did_hosting_server::setup::update_server_did_in_config(&config_file, &result.did_id)?;
eprintln!();
eprintln!(" DID recreated at path '{mnemonic}'!");
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" config.toml updated with new server_did.");
eprintln!();
Ok(())
}
async fn run_recover_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::did_ops::DidRecord;
let config = DaemonConfig::load(config_path)?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_common::did_ops::did_key(&mnemonic);
let mut record: DidRecord = dids_ks
.get(did_key.as_str())
.await?
.ok_or(format!("DID not found at path '{mnemonic}'"))?;
if record.deleted_at.is_none() {
eprintln!(" DID at path '{mnemonic}' is not deleted.");
return Ok(());
}
record.deleted_at = None;
dids_ks.insert(did_key.as_str(), &record).await?;
store.persist().await?;
eprintln!();
eprintln!(" DID recovered at path '{mnemonic}'!");
if let Some(ref did_id) = record.did_id {
eprintln!(" DID: {did_id}");
}
eprintln!();
Ok(())
}
async fn run_load_did(
config_path: Option<PathBuf>,
path: String,
did_log: PathBuf,
did_witness: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let jsonl = std::fs::read_to_string(&did_log)
.map_err(|e| format!("failed to read {}: {e}", did_log.display()))?;
let witness_content = match &did_witness {
Some(wp) => Some(
std::fs::read_to_string(wp)
.map_err(|e| format!("failed to read {}: {e}", wp.display()))?,
),
None => None,
};
let result = did_hosting_server::bootstrap::import_did_at_path(
&store,
&dids_ks,
&path,
&jsonl,
witness_content.as_deref(),
)
.await?;
store.persist().await?;
eprintln!();
eprintln!(" DID loaded at path '{path}'!");
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" Path: {path}/did.jsonl");
eprintln!();
Ok(())
}
async fn run_import_secrets(
config_path: Option<PathBuf>,
vta_bundle: Option<String>,
signing_key: Option<String>,
ka_key: Option<String>,
jwt_key: Option<String>,
vta_credential: Option<String>,
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::server::vta_setup::generate_ed25519_multibase;
use vta_sdk::did_secrets::DidSecretsBundle;
use vta_sdk::keys::KeyType;
let config = DaemonConfig::load(config_path)?;
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
if !force && let Ok(Some(_)) = secret_store.get().await {
return Err("secrets already exist — use --force to overwrite".into());
}
let (resolved_signing, resolved_ka, resolved_vta_cred) =
if let Some(ref bundle_str) = vta_bundle {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
let bundle_json = BASE64
.decode(bundle_str.as_bytes())
.map_err(|e| format!("failed to decode VTA secrets bundle base64: {e}"))?;
let bundle: DidSecretsBundle = serde_json::from_slice(&bundle_json)
.map_err(|e| format!("failed to decode VTA secrets bundle: {e}"))?;
let mut signing = None;
let mut ka = None;
for entry in &bundle.secrets {
match entry.key_type {
KeyType::Ed25519 if signing.is_none() => {
signing = Some(entry.private_key_multibase.clone());
}
KeyType::X25519 if ka.is_none() => {
ka = Some(entry.private_key_multibase.clone());
}
_ => {}
}
}
let signing = signing.ok_or("VTA bundle contains no Ed25519 signing key")?;
let ka = ka.ok_or("VTA bundle contains no X25519 key agreement key")?;
eprintln!(" VTA bundle decoded for DID: {}", bundle.did);
eprintln!(" Found {} secret(s)", bundle.secrets.len());
(signing, ka, vta_credential)
} else if let Some(signing) = signing_key {
let ka = ka_key.ok_or("--ka-key is required when using --signing-key")?;
(signing, ka, vta_credential)
} else {
return Err("provide either --vta-bundle or --signing-key + --ka-key".into());
};
Secret::from_multibase(&resolved_signing, None)
.map_err(|e| format!("invalid signing key: {e}"))?;
Secret::from_multibase(&resolved_ka, None)
.map_err(|e| format!("invalid key agreement key: {e}"))?;
let resolved_jwt = match jwt_key {
Some(key) => {
Secret::from_multibase(&key, None)
.map_err(|e| format!("invalid JWT signing key: {e}"))?;
key
}
None => {
eprintln!(" Generated JWT signing key.");
generate_ed25519_multibase()
}
};
let retired = match secret_store.get().await {
Ok(Some(existing)) => existing.retired,
_ => Vec::new(),
};
if !retired.is_empty() {
eprintln!(
" Preserving {} retired key(s) — superseded generations still inside their grace period.",
retired.len()
);
}
let server_secrets = ServerSecrets {
signing_key: resolved_signing,
key_agreement_key: resolved_ka,
jwt_signing_key: resolved_jwt,
vta_credential: resolved_vta_cred,
retired,
};
secret_store.set(&server_secrets).await?;
eprintln!();
eprintln!(" Secrets imported successfully!");
eprintln!();
Ok(())
}
async fn run_health(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::health;
health::header("did-hosting-daemon", env!("CARGO_PKG_VERSION"));
let config = match DaemonConfig::load(config_path) {
Ok(c) => {
health::section("Configuration");
health::check_config_loaded(&c.config_path);
health::check_value("server_did", &c.server_did);
health::check_value("public_url", &c.public_url);
health::check_value("did_hosting_url", &c.did_hosting_url);
health::check_value("mediator_did", &c.mediator_did);
Some(c)
}
Err(e) => {
health::section("Configuration");
health::fail(&format!("Config load failed: {e}"));
None
}
};
health::section("Compile Features");
health::print_feature("store-fjall", cfg!(feature = "store-fjall"));
health::print_feature("keyring", cfg!(feature = "keyring"));
health::print_feature("ui", cfg!(feature = "ui"));
let config = match config {
Some(c) => c,
None => {
eprintln!();
return Ok(());
}
};
health::section("Enabled Services");
health::print_feature("server", config.enable.server);
health::print_feature("witness", config.enable.witness);
health::print_feature("watcher", config.enable.watcher);
health::print_feature("control", config.enable.control);
health::print_feature("didcomm", config.features.didcomm);
health::print_feature("tsp", config.features.tsp);
health::section("Secrets");
health::check_secrets(&config.secrets, &config.config_path).await;
if config.enable.server {
health::section("Store (server)");
let store = health::check_store(&config.store).await;
if let Some(ref store) = store
&& let Ok(dids_ks) = store.keyspace(KS_DIDS)
{
health::section("Root DID (.well-known)");
match did_hosting_server::bootstrap::root_did_exists(&dids_ks).await {
Ok(true) => {
health::pass("Root DID exists");
match dids_ks
.get::<did_hosting_server::did_ops::DidRecord>(
did_hosting_server::did_ops::did_key(".well-known"),
)
.await
{
Ok(Some(record)) => {
if let Some(ref did_id) = record.did_id {
health::info_msg(&format!("DID: {did_id}"));
}
health::info_msg(&format!("Version count: {}", record.version_count));
}
Ok(None) => {}
Err(e) => health::warn_msg(&format!("Could not read DID record: {e}")),
}
}
Ok(false) => health::skip("Root DID not yet bootstrapped"),
Err(e) => health::fail(&format!("Root DID check failed: {e}")),
}
}
}
if config.enable.witness {
health::section("Store (witness)");
health::check_store(&config.witness_store).await;
}
if config.enable.watcher {
health::section("Store (watcher — shared with server)");
health::check_store(&config.store).await;
}
if config.enable.control {
health::section("Store (control — shared with server)");
health::check_store(&config.store).await;
}
if let Some(ref did) = config.server_did {
health::section("DID Resolution");
health::check_did_resolution("Server DID resolves", did).await;
}
if let Some(ref did) = config.mediator_did {
health::section("Mediator DID Resolution");
health::check_did_resolution("Mediator DID resolves", did).await;
}
eprintln!();
Ok(())
}
async fn daemon_health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"status": "ok",
"service": "did-hosting-daemon",
}))
}
fn print_banner() {
let cyan = "\x1b[36m";
let magenta = "\x1b[35m";
let yellow = "\x1b[33m";
let dim = "\x1b[2m";
let reset = "\x1b[0m";
eprintln!(
r#"
{cyan}██████╗ {magenta} █████╗ {yellow}███████╗{cyan}███╗ ███╗{magenta} ██████╗ {yellow}███╗ ██╗{reset}
{cyan}██╔══██╗{magenta}██╔══██╗{yellow}██╔════╝{cyan}████╗ ████║{magenta}██╔═══██╗{yellow}████╗ ██║{reset}
{cyan}██║ ██║{magenta}███████║{yellow}█████╗ {cyan}██╔████╔██║{magenta}██║ ██║{yellow}██╔██╗ ██║{reset}
{cyan}██║ ██║{magenta}██╔══██║{yellow}██╔══╝ {cyan}██║╚██╔╝██║{magenta}██║ ██║{yellow}██║╚██╗██║{reset}
{cyan}██████╔╝{magenta}██║ ██║{yellow}███████╗{cyan}██║ ╚═╝ ██║{magenta}╚██████╔╝{yellow}██║ ╚████║{reset}
{cyan}╚═════╝ {magenta}╚═╝ ╚═╝{yellow}╚══════╝{cyan}╚═╝ ╚═╝{magenta} ╚═════╝ {yellow}╚═╝ ╚═══╝{reset}
{dim} DID Hosting Daemon v{version}{reset}
"#,
version = env!("CARGO_PKG_VERSION"),
);
}
async fn run_list_dids(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use did_hosting_common::did_ops::DidRecord;
let config = DaemonConfig::load(config_path)?;
let store = did_hosting_common::server::store::Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let raw = dids_ks.prefix_iter_raw("did:").await?;
if raw.is_empty() {
eprintln!(" No DIDs in store.");
return Ok(());
}
eprintln!(" {:<25} {:<15} {:<60}", "PATH", "VERSIONS", "DID ID");
eprintln!(" {}", "-".repeat(100));
for (_key, value) in &raw {
if let Ok(record) = serde_json::from_slice::<DidRecord>(value) {
let did_id = record.did_id.as_deref().unwrap_or("(unpublished)");
let deleted = if record.deleted_at.is_some() {
" [deleted]"
} else {
""
};
let disabled = if record.disabled { " [disabled]" } else { "" };
eprintln!(
" {:<25} {:<15} {}{}{}",
record.mnemonic, record.version_count, did_id, deleted, disabled
);
}
}
eprintln!();
eprintln!(" Total: {} DIDs", raw.len());
Ok(())
}
async fn run_remove_did(
config_path: Option<PathBuf>,
path: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use did_hosting_common::did_ops::{
DidRecord, content_log_key, content_witness_key, did_key, owner_key,
};
let config = DaemonConfig::load(config_path)?;
let store = did_hosting_common::server::store::Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let record: Option<DidRecord> = dids_ks.get(did_key(&path)).await?;
let record = match record {
Some(r) => r,
None => {
eprintln!(" DID not found at path '{path}'");
std::process::exit(1);
}
};
let did_id = record.did_id.as_deref().unwrap_or("(unpublished)");
eprintln!(" Removing DID at path '{path}'");
eprintln!(" DID ID: {did_id}");
eprintln!(" Owner: {}", record.owner);
let mut batch = store.batch();
batch.remove(&dids_ks, did_key(&path));
batch.remove(&dids_ks, content_log_key(&path));
batch.remove(&dids_ks, content_witness_key(&path));
batch.remove(&dids_ks, owner_key(&record.owner, &path));
batch.commit().await?;
eprintln!(" DID removed.");
Ok(())
}
async fn run_identity_list(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_identity::run_list_generations(&config.store).await
}
async fn run_identity_retire_now(
config_path: Option<PathBuf>,
generation: u64,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_identity::run_retire_generation(
&config.store,
&config.secrets,
&config.config_path,
generation,
)
.await
}
async fn run_identity_rotate_keys(
config_path: Option<PathBuf>,
keys: String,
ka_key: Option<String>,
signing_key: Option<String>,
grace: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::identity_rotate::{RotateKeys, rotate_keys};
use did_hosting_common::server::secret_store::create_secret_store;
use did_hosting_common::server::store::Store;
let config = DaemonConfig::load(config_path)?;
let server_did = config
.server_did
.as_deref()
.ok_or("server_did is not set — this service has no identity to rotate")?;
let which = RotateKeys::parse(&keys)?;
let grace_secs = match grace.as_deref() {
Some(s) => did_hosting_common::server::pending_purge::parse_grace_string(s)
.map_err(|e| format!("invalid --grace: {e}"))?,
None => config.identity.rotation_grace_secs(),
};
let store = Store::open(&config.store).await?;
let secret_store = create_secret_store(&config.secrets, &config.config_path)?;
let report = rotate_keys(
&store,
secret_store.as_ref(),
server_did,
which,
ka_key.as_deref(),
signing_key.as_deref(),
grace_secs,
)
.await?;
eprintln!();
eprintln!(" Rotated keys for {}", report.did);
if report.which != RotateKeys::Signing {
eprintln!(" key agreement (new) : {}", report.new_ka_kid);
if grace_secs > 0 {
eprintln!(
" key agreement (old) : {} — honoured for {}m",
report.retired_ka_kid,
grace_secs / 60
);
} else {
eprintln!(
" key agreement (old) : {} — RETIRED IMMEDIATELY, no grace period",
report.retired_ka_kid
);
}
}
if report.which != RotateKeys::KeyAgreement {
eprintln!(" signing (new) : {}", report.new_signing_kid);
eprintln!(" signing (old) : {}", report.retired_signing_kid);
eprintln!();
eprintln!(" The old signing key's authority to publish DID updates is REVOKED as of");
eprintln!(" this log entry — it can no longer sign an update the chain will accept.");
eprintln!(" There is no grace period for a signing key and there cannot be: peers");
eprintln!(" holding a cached DID document will reject signatures made with the new");
eprintln!(" key until they re-resolve.");
}
eprintln!();
eprintln!(
" generation {} -> {}",
report.retired_generation, report.new_generation
);
eprintln!(" did.jsonl now has {} entries", report.version_count);
eprintln!();
if grace_secs > 0 && report.which != RotateKeys::Signing {
eprintln!(" Start the service. It will honour BOTH key-agreement keys, so peers");
eprintln!(" holding a cached DID document can still reach it until the old one expires.");
} else {
eprintln!(" Start the service.");
}
eprintln!();
Ok(())
}