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
+255 -8
View File
@@ -5,8 +5,9 @@ use anyhow::{Context, Result, anyhow};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use tokio::net::TcpListener;
use crate::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Config, Member, load_members, save_members};
@@ -272,7 +273,7 @@ fn mutate_registry(dir: &Path, mutate: impl FnOnce(&mut RegistryDoc) -> Result<(
Ok(signed.doc.version)
}
pub fn registry_init(dir: &Path) -> Result<()> {
pub fn registry_init(dir: &Path, zone: &str) -> Result<()> {
let registry_path = registry_doc_path(dir);
if registry_path.exists() {
return Err(anyhow!(
@@ -288,6 +289,7 @@ pub fn registry_init(dir: &Path) -> Result<()> {
version: 1,
issued_at: now_ts(),
ma_key: String::new(),
zone: zone.to_string(),
members: Vec::new(),
relays: Vec::new(),
};
@@ -442,12 +444,38 @@ pub fn registry_show(dir: &Path) -> Result<()> {
Ok(())
}
pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> {
let state = dir.to_path_buf();
let app = Router::new()
struct RegistryServer {
dir: PathBuf,
signup_code: Option<String>,
registry_url: Option<String>,
}
pub fn registry_router(
dir: &Path,
signup_code: Option<String>,
registry_url: Option<String>,
) -> Router {
let state = std::sync::Arc::new(RegistryServer {
dir: dir.to_path_buf(),
signup_code,
registry_url,
});
Router::new()
.route("/health", get(registry_health))
.route("/registry.json", get(registry_snapshot))
.with_state(state);
.route("/", get(registry_page))
.route("/v1/signup", post(registry_signup))
.route("/v1/enroll", post(registry_enroll))
.with_state(state)
}
pub async fn registry_serve(
dir: &Path,
listen: &str,
signup_code: Option<String>,
registry_url: Option<String>,
) -> Result<()> {
let app = registry_router(dir, signup_code, registry_url);
let listener = TcpListener::bind(listen).await?;
println!("registry serving on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
@@ -458,8 +486,8 @@ async fn registry_health() -> &'static str {
"ok"
}
async fn registry_snapshot(State(dir): State<PathBuf>) -> Response {
match registry::load_registry(&registry_doc_path(&dir)) {
async fn registry_snapshot(State(server): State<std::sync::Arc<RegistryServer>>) -> Response {
match registry::load_registry(&registry_doc_path(&server.dir)) {
Ok(signed) => Json(signed).into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
@@ -469,6 +497,225 @@ async fn registry_snapshot(State(dir): State<PathBuf>) -> Response {
}
}
fn sanitize_label(input: &str) -> String {
let mut label = String::new();
let mut last_dash = true;
for c in input.to_ascii_lowercase().chars() {
if c.is_ascii_alphanumeric() {
label.push(c);
last_dash = false;
} else if !last_dash && (c.is_whitespace() || c == '-' || c == '_' || c == '.') {
label.push('-');
last_dash = true;
}
if label.len() >= 32 {
break;
}
}
label.trim_matches('-').chars().take(32).collect()
}
#[derive(Deserialize)]
struct SignupRequest {
label: String,
code: Option<String>,
}
async fn registry_signup(
State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<SignupRequest>,
) -> Response {
let Some(expected) = &server.signup_code else {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "signup is not enabled" })),
)
.into_response();
};
if request.code.as_deref() != Some(expected.as_str()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })),
)
.into_response();
}
let label = sanitize_label(&request.label);
if label.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
)
.into_response();
}
let signed = match registry::load_registry(&registry_doc_path(&server.dir)) {
Ok(signed) => signed,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
let zone = signed.doc.zone.clone();
let id = format!("{label}.{zone}");
if signed.doc.members.iter().any(|member| member.id == id) {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "member id already taken" })),
)
.into_response();
}
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) {
Ok(invite) => invite,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
if let Err(error) = mutate_registry(&server.dir, |doc| {
doc.members.push(RegistryMember {
id: id.clone(),
class: crate::config::CLASS_SOURCE.to_string(),
keys: Vec::new(),
enc_key: None,
});
Ok(())
}) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let registry_url = server.registry_url.clone().unwrap_or_default();
let ma_key = signed.doc.ma_key.clone();
(
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"token": invite.token,
"registry": registry_url,
"ma_key": ma_key,
"credentials": format!(
"id={id} token={} registry={registry_url} ma_key={ma_key}",
invite.token
),
})),
)
.into_response()
}
#[derive(Deserialize)]
struct EnrollRequest {
id: String,
token: String,
pubkey: String,
enc_key: Option<String>,
}
async fn registry_enroll(
State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<EnrollRequest>,
) -> Response {
let pubkey = match normalize_key(&request.pubkey) {
Ok(pubkey) => pubkey,
Err(error) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
let enc_key = match request.enc_key.as_deref().map(normalize_key).transpose() {
Ok(enc_key) => enc_key,
Err(error) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
if let Err(error) = registry::redeem_invite(&server.dir, &request.id, &request.token) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let result = mutate_registry(&server.dir, |doc| {
let Some(member) = doc
.members
.iter_mut()
.find(|member| member.id == request.id)
else {
return Err(anyhow!("unknown member id {}", request.id));
};
if member.keys.iter().any(|entry| entry.key == pubkey) {
return Err(anyhow!("key already authorized"));
}
member.keys.push(KeyEntry {
key: pubkey.clone(),
not_before: now_ts(),
not_after: None,
});
if let Some(enc_key) = enc_key.clone() {
member.enc_key = Some(enc_key);
}
Ok(())
});
match result {
Ok(version) => (
StatusCode::OK,
Json(serde_json::json!({ "id": request.id, "version": version })),
)
.into_response(),
Err(error) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response(),
}
}
async fn registry_page() -> Response {
(
StatusCode::OK,
[("content-type", "text/html; charset=utf-8")],
REGISTRY_PAGE,
)
.into_response()
}
const REGISTRY_PAGE: &str = r#"<!doctype html>
<html><head><meta charset="utf-8"><title>FRX membership</title></head>
<body>
<h1>Request FRX membership</h1>
<form id="f">
<label>Organization or handle <input name="label" required pattern="[A-Za-z0-9-]+"></label><br>
<label>Signup code <input name="code" type="password"></label><br>
<button type="submit">Request membership</button>
</form>
<pre id="out" style="white-space:pre-wrap"></pre>
<script>
document.getElementById("f").onsubmit = async (e) => {
e.preventDefault();
const label = e.target.label.value, code = e.target.code.value;
const res = await fetch("/v1/signup", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({label, code})});
const body = await res.json();
document.getElementById("out").textContent = res.ok
? "Membership approved. Run `frxd --onboarding` and paste:\n\n" + body.credentials + "\n"
: "Failed: " + (body.error || res.status);
};
</script>
</body></html>
"#;
pub async fn status(config_path: &Path) -> Result<()> {
let config = Config::load(config_path)?;
let base = format!("http://{}", config.node.listen);
+1
View File
@@ -7,6 +7,7 @@ pub mod index;
pub mod message;
pub mod net;
pub mod node;
pub mod onboard;
pub mod registry;
pub mod relay;
pub mod render;
+28 -6
View File
@@ -5,7 +5,7 @@ use clap::{Parser, Subcommand, ValueEnum};
use frxd::config::Config;
use frxd::crypto::Keypair;
use frxd::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
use frxd::{commands, node, relay};
use frxd::{commands, node, onboard, relay};
#[derive(Parser)]
#[command(
@@ -16,8 +16,10 @@ use frxd::{commands, node, relay};
struct Cli {
#[arg(long, global = true, default_value = "frxd.toml")]
config: PathBuf,
#[arg(long)]
onboarding: bool,
#[command(subcommand)]
command: Command,
command: Option<Command>,
}
#[derive(Subcommand)]
@@ -139,7 +141,10 @@ enum KeyCommand {
#[derive(Subcommand)]
enum RegistryCommand {
Init,
Init {
#[arg(long, default_value = "frx.invalid")]
zone: String,
},
Add {
id: String,
pubkey: String,
@@ -180,6 +185,10 @@ enum RegistryCommand {
Serve {
#[arg(long, default_value = "127.0.0.1:7800")]
listen: String,
#[arg(long)]
signup_code: Option<String>,
#[arg(long)]
registry_url: Option<String>,
},
}
@@ -192,7 +201,16 @@ enum Exposure {
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
if cli.onboarding {
let mut input = std::io::stdin().lock();
let mut output = std::io::stdout().lock();
onboard::run(&mut input, &mut output, &cli.config).await?;
return Ok(());
}
let Some(command) = cli.command else {
bail!("no subcommand given (try --onboarding or --help)");
};
match command {
Command::Init {
name,
listen,
@@ -309,7 +327,7 @@ async fn main() -> Result<()> {
KeyCommand::Rotate => commands::key_rotate(&cli.config)?,
},
Command::Registry { dir, command } => match command {
RegistryCommand::Init => commands::registry_init(&dir)?,
RegistryCommand::Init { zone } => commands::registry_init(&dir, &zone)?,
RegistryCommand::Add {
id,
pubkey,
@@ -336,7 +354,11 @@ async fn main() -> Result<()> {
RegistryCommand::List => commands::registry_list(&dir)?,
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
RegistryCommand::Show => commands::registry_show(&dir)?,
RegistryCommand::Serve { listen } => commands::registry_serve(&dir, &listen).await?,
RegistryCommand::Serve {
listen,
signup_code,
registry_url,
} => commands::registry_serve(&dir, &listen, signup_code, registry_url).await?,
},
Command::Aggregates {
from,
+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()
})
}
+68
View File
@@ -35,12 +35,79 @@ pub struct RegistryDoc {
pub version: u64,
pub issued_at: u64,
pub ma_key: String,
#[serde(default = "default_zone")]
pub zone: String,
#[serde(default)]
pub members: Vec<RegistryMember>,
#[serde(default)]
pub relays: Vec<String>,
}
fn default_zone() -> String {
"frx.invalid".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invite {
pub id: String,
pub token: String,
#[serde(default)]
pub used: bool,
#[serde(default)]
pub expires: u64,
}
pub fn invites_path(dir: &Path) -> PathBuf {
dir.join("invites.json")
}
pub fn load_invites(path: &Path) -> Result<Vec<Invite>> {
if !path.exists() {
return Ok(Vec::new());
}
let raw = fs::read_to_string(path).context("reading invites")?;
serde_json::from_str(&raw).context("parsing invites")
}
pub fn save_invites(path: &Path, invites: &[Invite]) -> Result<()> {
fs::write(path, serde_json::to_string_pretty(invites)?)?;
Ok(())
}
pub fn create_invite(dir: &Path, id: &str, ttl_secs: u64) -> Result<Invite> {
let path = invites_path(dir);
let mut invites = load_invites(&path)?;
let invite = Invite {
id: id.to_string(),
token: crate::crypto::random_nonce(),
used: false,
expires: now_ts() + ttl_secs,
};
invites.push(invite.clone());
save_invites(&path, &invites)?;
Ok(invite)
}
pub fn redeem_invite(dir: &Path, id: &str, token: &str) -> Result<()> {
let path = invites_path(dir);
let mut invites = load_invites(&path)?;
let Some(invite) = invites
.iter_mut()
.find(|invite| invite.id == id && invite.token == token)
else {
return Err(anyhow!("unknown invite"));
};
if invite.used {
return Err(anyhow!("invite already redeemed"));
}
if invite.expires < now_ts() {
return Err(anyhow!("invite expired"));
}
invite.used = true;
save_invites(&path, &invites)?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRegistry {
#[serde(flatten)]
@@ -254,6 +321,7 @@ mod tests {
version: 1,
issued_at: now_ts(),
ma_key: String::new(),
zone: "frx.invalid".to_string(),
members,
relays: Vec::new(),
},