Onboarding wizard and MA signup/enroll site

This commit is contained in:
George Coles
2026-09-15 09:35:24 -04:00
parent 1297766e8a
commit 15e5c6cbd7
12 changed files with 794 additions and 17 deletions
+271
View File
@@ -0,0 +1,271 @@
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,
) -> 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, &registry).await?
} else {
load_registry(Path::new(&registry))?
};
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(&registry));
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, &registry).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<String> = 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 mut config = Config::new(&id, "127.0.0.1:7701", 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,
"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<Credentials> {
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 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<SignedRegistry> {
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<String> {
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()
})
}