use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use serde_json::Value; use crate::config::Config; use crate::crypto::{Keypair, generate_enc_keypair, now_ts}; use crate::registry::{SignedRegistry, authorized_keys, load_registry, verify_registry}; pub async fn run( reader: &mut impl BufRead, writer: &mut impl Write, config_path: &Path, listen_override: Option<&str>, ) -> Result<()> { writeln!(writer, "FRX onboarding")?; writeln!( writer, "You need credentials from your FRX membership authority (issued by the membership site or your operator)." )?; let path_answer = prompt( writer, reader, "config file", &config_path.display().to_string(), )?; let config_path = PathBuf::from(path_answer); if config_path.exists() { bail!( "config {} already exists; choose another path or remove it", config_path.display() ); } let block = prompt( writer, reader, "paste credential block (id=.. token=.. registry=.. ma_key=..), or press enter to enter fields", "", )?; let Credentials { id, token, registry, ma_key, } = if block.trim().is_empty() { Credentials { id: prompt(writer, reader, "member id (e.g. alice.frx.example)", "")?, token: prompt( writer, reader, "invite token (leave empty if the MA added your key out of band)", "", )?, registry: prompt(writer, reader, "registry URL or file path", "")?, ma_key: prompt(writer, reader, "MA key (pubkey hex)", "")?, } } else { parse_block(&block)? }; if id.is_empty() || registry.is_empty() || ma_key.is_empty() { bail!("member id, registry, and MA key are required"); } let (relays_from_registry, authorized) = { let signed = if registry.starts_with("http://") || registry.starts_with("https://") { let client = crate::net::build_client(None, Duration::from_secs(10))?; fetch_registry(&client, ®istry).await? } else { load_registry(Path::new(®istry))? }; verify_registry(&signed, &ma_key)?; ( signed.doc.relays.clone(), authorized_keys(&signed, now_ts()), ) }; let key = Keypair::generate(); let (enc_secret, enc_public) = generate_enc_keypair(); if registry.starts_with("http://") || registry.starts_with("https://") { if token.is_empty() { if !authorized.contains_key(&key.public_hex()) { writeln!( writer, "note: your key {} is not yet authorized; the MA must run: frxd registry add {id} {} --enc-key {enc_public}", key.public_hex(), key.public_hex() )?; } } else { let client = crate::net::build_client(None, Duration::from_secs(10))?; let enroll_url = format!("{}/v1/enroll", registry_base(®istry)); let response = client .post(&enroll_url) .json(&serde_json::json!({ "id": id, "token": token, "pubkey": key.public_hex(), "enc_key": enc_public, })) .send() .await .context("calling enrollment endpoint")?; let status = response.status(); let body: Value = response.json().await.unwrap_or_default(); if !status.is_success() { let message = body .get("error") .and_then(Value::as_str) .unwrap_or("unknown error"); bail!("enrollment failed: {message}"); } let signed = fetch_registry(&client, ®istry).await?; verify_registry(&signed, &ma_key)?; if !authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()) { bail!("enrollment accepted but the registry does not yet authorize the key"); } writeln!(writer, "enrolled {id} with the MA")?; } } else if !authorized.contains_key(&key.public_hex()) { writeln!( writer, "your pubkey is {}; the MA must run: frxd registry add {id} {} --enc-key {enc_public}", key.public_hex(), key.public_hex() )?; } let relay_default = if relays_from_registry.is_empty() { "http://127.0.0.1:7700".to_string() } else { relays_from_registry.join(",") }; let relays_answer = prompt( writer, reader, "relay URLs (comma separated)", &relay_default, )?; let relays: Vec = relays_answer .split(',') .map(|relay| relay.trim().to_string()) .filter(|relay| !relay.is_empty()) .collect(); if relays.is_empty() { bail!("at least one relay is required"); } let data_dir = prompt(writer, reader, "data directory", "./frx-data")?; let listen = listen_override .map(str::to_string) .unwrap_or_else(|| pick_listen("127.0.0.1:7701")); let mut config = Config::new(&id, &listen, relays.clone(), &data_dir); config.node.id = Some(id.clone()); config.node.registry = Some(registry.clone()); config.node.ma_key = Some(ma_key.clone()); config.save_key(&key)?; config.save_enc_key(&enc_secret)?; config.save(&config_path)?; let share_dir = prompt( writer, reader, "directory to share (empty to skip; you can add collections later with `frxd add`)", "", )?; if !share_dir.trim().is_empty() { let exposure = prompt(writer, reader, "exposure for the collection", "metadata")?; crate::commands::add( &config_path, Path::new(share_dir.trim()), None, true, &exposure, )?; } writeln!(writer)?; writeln!(writer, "Onboarded as {id}")?; writeln!(writer, " config: {}", config_path.display())?; writeln!(writer, " registry: {registry}")?; writeln!(writer, " relays: {}", relays.join(", "))?; writeln!(writer, " listening: {listen}")?; writeln!( writer, "Next: frxd serve, then frx query \"...\" to broadcast." )?; let start = prompt(writer, reader, "start serving now?", "n")?; if start == "y" || start.eq_ignore_ascii_case("yes") { let config = Config::load(&config_path)?; let handle = crate::node::Node::start(config).await?; writeln!(writer, "frxd listening on http://{}", handle.addr)?; writeln!(writer, "ctrl-c to stop")?; tokio::signal::ctrl_c().await?; } Ok(()) } struct Credentials { id: String, token: String, registry: String, ma_key: String, } fn parse_block(block: &str) -> Result { let mut id = None; let mut token = None; let mut registry = None; let mut ma_key = None; for part in block.split_whitespace() { let Some((key, value)) = part.split_once('=') else { continue; }; match key { "id" => id = Some(value.to_string()), "token" => token = Some(value.to_string()), "registry" => registry = Some(value.to_string()), "ma_key" => ma_key = Some(value.to_string()), _ => {} } } Ok(Credentials { id: id.ok_or_else(|| anyhow!("credential block is missing id"))?, token: token.unwrap_or_default(), registry: registry.ok_or_else(|| anyhow!("credential block is missing registry"))?, ma_key: ma_key.ok_or_else(|| anyhow!("credential block is missing ma_key"))?, }) } fn pick_listen(preferred: &str) -> String { if std::net::TcpListener::bind(preferred).is_ok() { return preferred.to_string(); } let fallback = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); fallback.local_addr().expect("local addr").to_string() } fn registry_base(registry_url: &str) -> String { match registry_url.rfind('/') { Some(index) => registry_url[..index].to_string(), None => registry_url.to_string(), } } async fn fetch_registry(client: &reqwest::Client, url: &str) -> Result { let response = client .get(url) .send() .await .with_context(|| format!("fetching registry {url}"))?; if !response.status().is_success() { bail!("registry fetch failed: {}", response.status()); } let signed: SignedRegistry = response.json().await.context("parsing registry")?; Ok(signed) } fn prompt( writer: &mut impl Write, reader: &mut impl BufRead, label: &str, default: &str, ) -> Result { if default.is_empty() { write!(writer, "{label}: ")?; } else { write!(writer, "{label} [{default}]: ")?; } writer.flush()?; let mut line = String::new(); reader.read_line(&mut line)?; let line = line.trim(); Ok(if line.is_empty() { default.to_string() } else { line.to_string() }) }