285 lines
9.2 KiB
Rust
285 lines
9.2 KiB
Rust
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<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 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<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 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<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()
|
|
})
|
|
}
|