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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ®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 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()
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user