Onboarding wizard and MA signup/enroll site
This commit is contained in:
+255
-8
@@ -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(®istry_doc_path(&dir)) {
|
||||
async fn registry_snapshot(State(server): State<std::sync::Arc<RegistryServer>>) -> Response {
|
||||
match registry::load_registry(®istry_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(®istry_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);
|
||||
|
||||
Reference in New Issue
Block a user