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);