Skip to main content

did_hosting_server/
setup_recipe.rs

1//! Non-interactive setup driven by a `SetupRecipe` TOML file.
2//!
3//! Sibling to [`crate::setup`]'s interactive wizard. The recipe captures
4//! every prompt the wizard would ask; this module applies it without a
5//! TTY. Pairs with `--from <recipe.toml>` on the `setup` subcommand.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use did_hosting_common::server::operator_messages::WebvhServerMessages;
11use did_hosting_common::server::setup_recipe::{
12    EXIT_RECIPE_INVALID, ServiceKind, SetupRecipe, VtaMode, VtaSetupOutcome, active_backend,
13    apply_env_overrides, back_up_config, derive_did_path, inspect_existing, load_recipe,
14    print_recipe_banner, refuse_overwrite, require_service, resolve_admin_did,
15    resolve_secrets_config, run_uninstall_unchecked, run_vta_for_recipe, split_origin_and_did_path,
16    to_log_format,
17};
18use did_hosting_common::server::store::{KS_ACL, KS_DIDS};
19use did_hosting_common::server::vta_setup;
20use vta_sdk::provision_client::{EphemeralSetupKey, OperatorMessages};
21
22use crate::acl::{AclEntry, Role, store_acl_entry};
23use crate::auth::session::now_epoch;
24use crate::config::{
25    AppConfig, AuthConfig, FeaturesConfig, LimitsConfig, LogConfig, LogFormat, ServerConfig,
26    StatsConfig, StoreConfig, VtaConfig,
27};
28use crate::error::AppError;
29use crate::secret_store::{ServerSecrets, create_secret_store};
30use crate::setup::update_server_did_in_config;
31use crate::store::Store;
32
33/// Entry point: load + validate + apply.
34pub async fn run_from_recipe(
35    recipe_path: &Path,
36    setup_key_file: Option<PathBuf>,
37    force_reprovision: bool,
38) -> Result<(), AppError> {
39    let recipe = load_recipe(recipe_path).map_err(|e| {
40        eprintln!("  [setup-recipe] {e}");
41        AppError::Config(format!("recipe load failed: {e}"))
42    })?;
43    require_service(&recipe, ServiceKind::Server).map_err(|e| AppError::Config(format!("{e}")))?;
44    apply_recipe(recipe, setup_key_file, force_reprovision).await
45}
46
47/// Apply an already-loaded recipe. Split out so the future
48/// `--non-interactive` shortcut (which builds a recipe in-memory) can
49/// reuse this path.
50pub async fn apply_recipe(
51    mut recipe: SetupRecipe,
52    setup_key_file: Option<PathBuf>,
53    force_reprovision: bool,
54) -> Result<(), AppError> {
55    apply_env_overrides(&mut recipe);
56    print_recipe_banner("did-hosting-server", &recipe);
57
58    // Reprovision scan happens BEFORE any VTA round-trip so we don't
59    // burn an enrolled setup DID on an install that's going to refuse.
60    let secrets_config = resolve_secrets_config(&recipe, "did-hosting-server-secrets", "webvh");
61    let scan = inspect_existing(&secrets_config, &recipe.output.config_path).await;
62    if scan.is_provisioned() && !force_reprovision && !recipe.reprovision.force {
63        let err = refuse_overwrite(&recipe.output.config_path, &scan);
64        return Err(err);
65    }
66
67    // Back up an existing config.toml before we overwrite it. The
68    // backend secrets get rotated below — the backup gives the operator
69    // a recoverable view of the previous wiring.
70    if recipe.output.config_path.exists() {
71        back_up_config(&recipe.output.config_path)
72            .map_err(|e| AppError::Config(format!("config backup failed: {e}")))?;
73    }
74
75    // Online needs --setup-key-file; phase 1 is a separate subcommand.
76    let setup_key = match (recipe.deployment.vta_mode, setup_key_file.as_deref()) {
77        (VtaMode::Online, Some(path)) => Some(
78            EphemeralSetupKey::load_from(path)
79                .map_err(|e| AppError::Config(format!("load setup key: {e}")))?,
80        ),
81        (VtaMode::Online, None) => {
82            return Err(AppError::Config(
83                "online vta_mode requires --setup-key-file <path>. Run \
84                 `did-hosting-server setup --setup-key-out <path>` first to mint and \
85                 enrol an ephemeral did:key."
86                    .into(),
87            ));
88        }
89        _ => None,
90    };
91
92    // Resolve the offline-complete bootstrap seed up-front so we can
93    // fail fast if it's missing.
94    let offline_complete_seed = if recipe.deployment.vta_mode == VtaMode::OfflineComplete {
95        let store = did_hosting_common::server::secret_store::create_secret_store(
96            &secrets_config,
97            &recipe.output.config_path,
98        )?;
99        store.get_bootstrap_seed().await?
100    } else {
101        None
102    };
103
104    let messages: Arc<dyn OperatorMessages> = Arc::new(WebvhServerMessages);
105
106    // One ask packages the server's own DID for both online and
107    // offline-prepare. The server hosts its own DID documents via HTTP and
108    // doesn't mint DIDComm into its DID document (sync uses a separate
109    // `mediator_did` at runtime), so it's always HTTP-only — the builder
110    // selects `did-hosting-daemon`. `public_url` carries the DID path in its
111    // path component, so split it back out and let the builder fold it in.
112    let public_url_owned = recipe
113        .identity
114        .public_url
115        .clone()
116        .unwrap_or_default()
117        .trim_end_matches('/')
118        .to_string();
119    let context_id = recipe
120        .vta
121        .context_id
122        .clone()
123        .unwrap_or_else(|| "webvh".to_string());
124    let (origin, did_path) = split_origin_and_did_path(&public_url_owned);
125    // The server's own DID advertises its messaging transport(s) when the
126    // recipe configures a mediator — matching the control / daemon recipes —
127    // and stays HTTP-only otherwise.
128    let transport = recipe.identity.transport_selection()?;
129    let ask = matches!(
130        recipe.deployment.vta_mode,
131        VtaMode::Online | VtaMode::OfflinePrepare
132    )
133    .then(|| {
134        let shape = vta_setup::WebvhDidShape::Hosted {
135            origin: &origin,
136            did_path: &did_path,
137            mediator_did: recipe.identity.mediator_did.as_deref(),
138            transport,
139            remote: None,
140        };
141        vta_setup::build_webvh_provision_ask(
142            &context_id,
143            &shape,
144            Some(&format!("did-hosting-server setup — {context_id}")),
145        )
146    });
147
148    let outcome =
149        run_vta_for_recipe(&recipe, ask, messages, setup_key, offline_complete_seed).await?;
150
151    // Three terminal shapes: online, offline-complete, or offline-prepare.
152    // Server has no self-managed mode (rejected by recipe validation).
153    let (
154        server_did,
155        signing_priv,
156        ka_priv,
157        vta_did_persisted,
158        vta_url,
159        vta_credential_b64,
160        log_entry,
161    ) = match outcome {
162        VtaSetupOutcome::Online(o) => (
163            o.integration_did,
164            o.integration_signing_key_mb,
165            o.integration_ka_key_mb,
166            Some(o.vta_did),
167            o.vta_url,
168            Some(o.vta_credential_b64),
169            o.did_log_entry,
170        ),
171        VtaSetupOutcome::Offline(o) => (
172            o.did,
173            o.signing_key_multibase,
174            o.key_agreement_multibase,
175            Some(o.vta_did),
176            o.vta_url,
177            None,
178            o.log_entry,
179        ),
180        VtaSetupOutcome::OfflinePreparedOnly(info) => {
181            persist_offline_prepare(&recipe, &info, &secrets_config).await?;
182            return Ok(());
183        }
184        VtaSetupOutcome::SelfManaged(_) => {
185            // Recipe validation rejects this for non-daemon services,
186            // but be loud if a future code change accidentally allows it.
187            return Err(AppError::Config(
188                "self-managed mode is not supported for did-hosting-server".into(),
189            ));
190        }
191    };
192
193    let jwt_signing_key = did_hosting_common::server::vta_setup::generate_ed25519_multibase();
194
195    let host = recipe
196        .server
197        .host
198        .clone()
199        .unwrap_or_else(|| "0.0.0.0".to_string());
200    let port = recipe
201        .server
202        .port
203        .unwrap_or_else(|| SetupRecipe::default_port(ServiceKind::Server));
204    let log_level = recipe
205        .server
206        .log_level
207        .clone()
208        .unwrap_or_else(|| "info".to_string());
209    let log_format = recipe
210        .server
211        .log_format
212        .map(to_log_format)
213        .map(|f| match f {
214            did_hosting_common::server::config::LogFormat::Text => LogFormat::Text,
215            did_hosting_common::server::config::LogFormat::Json => LogFormat::Json,
216        })
217        .unwrap_or(LogFormat::Text);
218    let data_dir = recipe
219        .server
220        .data_dir
221        .clone()
222        .unwrap_or_else(|| SetupRecipe::default_data_dir(ServiceKind::Server));
223
224    let (didcomm, tsp) = recipe.identity.transport_flags()?;
225    let config = AppConfig {
226        features: FeaturesConfig {
227            didcomm,
228            tsp,
229            rest_api: false,
230            ..Default::default()
231        },
232        server_did: Some(server_did.clone()),
233        mediator_did: recipe.identity.mediator_did.clone(),
234        public_url: Some(public_url_owned.clone()),
235        server: ServerConfig {
236            host,
237            port,
238            trusted_proxies: Vec::new(),
239            trusted_proxy_cidrs: Vec::new(),
240        },
241        log: LogConfig {
242            level: log_level,
243            format: log_format,
244        },
245        store: StoreConfig {
246            data_dir,
247            ..StoreConfig::default()
248        },
249        auth: AuthConfig::default(),
250        hosting: crate::config::HostingConfig::default(),
251        secrets: secrets_config.clone(),
252        limits: LimitsConfig::default(),
253        watchers: Vec::new(),
254        control_url: recipe.identity.control_url.clone(),
255        control_did: recipe.identity.control_did.clone(),
256        vta: VtaConfig {
257            url: vta_url,
258            did: vta_did_persisted,
259            context_id: None,
260        },
261        stats: StatsConfig::default(),
262        identity: Default::default(),
263        config_path: recipe.output.config_path.clone(),
264    };
265
266    if let Some(parent) = recipe.output.config_path.parent()
267        && !parent.as_os_str().is_empty()
268    {
269        std::fs::create_dir_all(parent)?;
270    }
271    std::fs::write(
272        &recipe.output.config_path,
273        toml::to_string_pretty(&config)
274            .map_err(|e| AppError::Config(format!("toml encode: {e}")))?,
275    )?;
276    eprintln!(
277        "  [setup-recipe] config written to {}",
278        recipe.output.config_path.display()
279    );
280
281    let server_secrets = ServerSecrets {
282        signing_key: signing_priv,
283        key_agreement_key: ka_priv,
284        jwt_signing_key,
285        vta_credential: vta_credential_b64,
286        retired: Vec::new(),
287    };
288    let secret_store = create_secret_store(&config)?;
289    secret_store.set(&server_secrets).await?;
290    eprintln!(
291        "  [setup-recipe] secrets stored in {:?} backend",
292        active_backend(&recipe)
293    );
294
295    // Drop the now-spent offline seed (if any) — phase 2 is done.
296    if recipe.deployment.vta_mode == VtaMode::OfflineComplete {
297        let post = did_hosting_common::server::secret_store::create_secret_store(
298            &secrets_config,
299            &recipe.output.config_path,
300        )?;
301        if let Err(e) = post.clear_bootstrap_seed().await {
302            eprintln!("  Warning: failed to clear bootstrap seed: {e}");
303        }
304    }
305
306    // Import the server's own DID into the local store.
307    if let Some(log_entry) = log_entry.as_deref() {
308        let did_path = derive_did_path(&public_url_owned);
309        let store = Store::open(&config.store).await?;
310        let dids_ks = store.keyspace(KS_DIDS)?;
311        match crate::bootstrap::import_did_at_path(&store, &dids_ks, &did_path, log_entry, None)
312            .await
313        {
314            Ok(result) => {
315                eprintln!(
316                    "  [setup-recipe] server DID imported at '{did_path}' (scid={})",
317                    result.scid
318                );
319                update_server_did_in_config(&recipe.output.config_path, &result.did_id)
320                    .map_err(|e| AppError::Config(format!("update server_did: {e}")))?;
321            }
322            Err(e) => {
323                eprintln!(
324                    "  [setup-recipe] WARNING failed to import server DID: {e}\n             retry: did-hosting-server bootstrap-did --path {did_path}"
325                );
326            }
327        }
328    }
329
330    // Admin ACL seeding (if requested by the recipe).
331    if let Some(admin_did) = resolve_admin_did(&recipe) {
332        let store = Store::open(&config.store).await?;
333        let acl_ks = store.keyspace(KS_ACL)?;
334        let entry = AclEntry {
335            did: admin_did.clone(),
336            role: Role::Admin,
337            label: Some("Setup recipe admin".into()),
338            created_at: now_epoch(),
339            max_total_size: None,
340            max_did_count: None,
341
342            domains: did_hosting_common::server::domain::DomainScope::All,
343        };
344        store_acl_entry(&acl_ks, &entry).await?;
345        store.persist().await?;
346        eprintln!("  [setup-recipe] admin ACL entry added for {admin_did}");
347    }
348
349    eprintln!();
350    eprintln!("  [setup-recipe] setup complete");
351    eprintln!();
352    eprintln!("  Server DID:        {server_did}");
353    eprintln!(
354        "  Next: did-hosting-server --config {}",
355        recipe.output.config_path.display()
356    );
357    eprintln!();
358
359    Ok(())
360}
361
362/// Phase 1 of the offline flow: the bootstrap request was written by
363/// `run_vta_for_recipe`; we persist the ephemeral seed in the configured
364/// secret backend so phase 2 can open the sealed response, then print
365/// operator-facing next-steps.
366///
367/// Phase 2 is driven by re-running the wizard with the SAME recipe file
368/// edited to `vta_mode = "offline-complete"` and `[vta].bundle_path` /
369/// `[vta].expect_digest` filled in. The seed comes back out of the same
370/// secret backend automatically.
371async fn persist_offline_prepare(
372    recipe: &SetupRecipe,
373    info: &did_hosting_common::server::setup_recipe::OfflinePreparedInfo,
374    secrets_config: &did_hosting_common::server::config::SecretsConfig,
375) -> Result<(), AppError> {
376    let store = did_hosting_common::server::secret_store::create_secret_store(
377        secrets_config,
378        &recipe.output.config_path,
379    )?;
380    store.set_bootstrap_seed(&info.seed).await?;
381    print_offline_prepare_recap("did-hosting-server", recipe, info);
382    Ok(())
383}
384
385/// Stable, scriptable next-steps recap for offline-prepare. CI scripts
386/// can grep for `[setup-recipe:offline-prepare]` to confirm phase 1
387/// succeeded and pick up the printed values.
388fn print_offline_prepare_recap(
389    binary: &str,
390    recipe: &SetupRecipe,
391    info: &did_hosting_common::server::setup_recipe::OfflinePreparedInfo,
392) {
393    eprintln!();
394    eprintln!("  [setup-recipe:offline-prepare] phase 1 complete");
395    eprintln!(
396        "  [setup-recipe:offline-prepare] request_path = {}",
397        info.request_path.display()
398    );
399    eprintln!(
400        "  [setup-recipe:offline-prepare] client_did   = {}",
401        info.client_did
402    );
403    eprintln!(
404        "  [setup-recipe:offline-prepare] nonce        = {}",
405        info.nonce
406    );
407    eprintln!("  [setup-recipe:offline-prepare] seed stored in configured secret backend");
408    eprintln!();
409    eprintln!("  Next steps:");
410    eprintln!(
411        "    1. Ferry {} to your VTA admin.",
412        info.request_path.display()
413    );
414    eprintln!("    2. Ask them to seal the response:");
415    eprintln!(
416        "         vta bootstrap provision-integration --request <request-file> \\\n           --out <bundle-file>"
417    );
418    eprintln!("       and to communicate the SHA-256 digest out-of-band.");
419    eprintln!(
420        "    3. Edit your recipe ({}):",
421        recipe.output.config_path.display()
422    );
423    eprintln!("         - set [deployment].vta_mode = \"offline-complete\"");
424    eprintln!("         - set [vta].bundle_path    = \"<bundle-path>\"");
425    eprintln!("         - set [vta].expect_digest  = \"<hex-digest>\"");
426    eprintln!("    4. Re-run phase 2 (no TTY required):");
427    eprintln!("         {binary} setup --from <recipe>");
428    eprintln!();
429}
430
431/// Uninstall: list did-hosting-server-managed entries, prompt for typed DELETE,
432/// remove them. `yes` skips the prompt (CI). Returns true on completion.
433pub async fn run_uninstall(config_path: &Path, yes: bool) -> Result<(), AppError> {
434    // Load just enough config to find the secret backend.
435    let config = AppConfig::load(Some(config_path.to_path_buf())).map_err(|e| {
436        AppError::Config(format!(
437            "failed to load {} for uninstall: {e}",
438            config_path.display()
439        ))
440    })?;
441
442    if !yes {
443        let confirmed =
444            did_hosting_common::server::setup_recipe::prompt_uninstall_confirmation(config_path)?;
445        if !confirmed {
446            eprintln!("  Aborted (DELETE not entered).");
447            return Ok(());
448        }
449    }
450
451    let report = run_uninstall_unchecked(&config.secrets, config_path, &[]).await?;
452
453    eprintln!();
454    eprintln!("  Uninstall complete:");
455    eprintln!("    secrets cleared:        {}", report.secrets_cleared);
456    eprintln!("    bootstrap seed cleared: {}", report.seed_cleared);
457    for f in &report.files_removed {
458        eprintln!("    removed file:           {}", f.display());
459    }
460    eprintln!();
461    Ok(())
462}
463
464/// Map an [`AppError`] from any recipe / uninstall flow to the documented
465/// exit code. Generic errors map to 1.
466pub fn map_exit_code(err: &AppError) -> i32 {
467    let msg = err.to_string();
468    if msg.contains("refusing to overwrite") {
469        did_hosting_common::server::setup_recipe::EXIT_REPROVISION_REFUSED
470    } else if msg.contains("VTA provision") || msg.contains("post-auth") {
471        did_hosting_common::server::setup_recipe::EXIT_VTA_POST_AUTH
472    } else if msg.contains("recipe") && (msg.contains("invalid") || msg.contains("missing")) {
473        EXIT_RECIPE_INVALID
474    } else {
475        1
476    }
477}