1018 lines
36 KiB
Rust
1018 lines
36 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
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};
|
|
use crate::crypto::{Keypair, now_ts};
|
|
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
|
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
|
use crate::node;
|
|
use crate::registry::{self, KeyEntry, RegistryDoc, RegistryMember, SignedRegistry};
|
|
use crate::render;
|
|
|
|
pub fn add(
|
|
config_path: &Path,
|
|
path: &Path,
|
|
name: Option<String>,
|
|
shared: bool,
|
|
exposure: &str,
|
|
) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let index = LocalIndex::open(&config.index_dir())?;
|
|
let name = name.unwrap_or_else(|| {
|
|
path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("collection")
|
|
.to_string()
|
|
});
|
|
let exposure = if exposure == EXPOSURE_FULL {
|
|
EXPOSURE_FULL
|
|
} else {
|
|
EXPOSURE_METADATA
|
|
};
|
|
let collection = Collection {
|
|
name: name.clone(),
|
|
path: path
|
|
.canonicalize()
|
|
.with_context(|| format!("resolving {}", path.display()))?
|
|
.display()
|
|
.to_string(),
|
|
shared,
|
|
exposure: exposure.to_string(),
|
|
};
|
|
let added = index.add_collection(&collection)?;
|
|
let manifest = config.collections_path();
|
|
let mut collections = load_collections(&manifest)?;
|
|
collections.retain(|existing| existing.name != name);
|
|
collections.push(collection);
|
|
save_collections(&manifest, &collections)?;
|
|
println!(
|
|
"indexed {added} file(s) from {} as collection '{name}' (shared={shared}, exposure={exposure})",
|
|
path.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn reindex(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let index = LocalIndex::open(&config.index_dir())?;
|
|
let collections = load_collections(&config.collections_path())?;
|
|
if collections.is_empty() {
|
|
println!("no collections registered; use `frxd add <path>`");
|
|
return Ok(());
|
|
}
|
|
for collection in collections {
|
|
let added = index.add_collection(&collection)?;
|
|
println!(
|
|
"collection '{}' ({}): {added} file(s)",
|
|
collection.name, collection.path
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn search(config_path: &Path, text: &str, limit: usize) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let index = LocalIndex::open(&config.index_dir())?;
|
|
let (hits, total) = index.search(text, limit, false)?;
|
|
render::local_hits(&hits, total);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn query(
|
|
config_path: &Path,
|
|
text: &str,
|
|
max_results: Option<usize>,
|
|
timeout_ms: Option<u64>,
|
|
network: bool,
|
|
) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let base = format!("http://{}", config.node.listen);
|
|
let outcome = node::control_query(&base, text, max_results, timeout_ms, network).await?;
|
|
render::query_outcome(&outcome);
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_key(value: &str) -> Result<String> {
|
|
let bytes = hex::decode(value).context("pubkey must be hex")?;
|
|
if bytes.len() != 32 {
|
|
return Err(anyhow!("pubkey must be 32 bytes (64 hex characters)"));
|
|
}
|
|
Ok(value.to_ascii_lowercase())
|
|
}
|
|
|
|
pub fn member_add(
|
|
config_path: &Path,
|
|
name: &str,
|
|
pubkey: &str,
|
|
class: &str,
|
|
previous: &[String],
|
|
) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let pubkey = normalize_key(pubkey)?;
|
|
let previous = previous
|
|
.iter()
|
|
.map(|key| normalize_key(key))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let class = if class == CLASS_ENRICHMENT {
|
|
CLASS_ENRICHMENT
|
|
} else {
|
|
CLASS_SOURCE
|
|
};
|
|
let mut members = load_members(&config.members_path())?;
|
|
members.retain(|member| member.name != name && member.pubkey != pubkey);
|
|
members.push(Member {
|
|
name: name.to_string(),
|
|
pubkey,
|
|
class: class.to_string(),
|
|
previous,
|
|
});
|
|
save_members(&config.members_path(), &members)?;
|
|
println!(
|
|
"listed {name} ({class}) in {}",
|
|
config.members_path().display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn key_show(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
println!("{}", config.load_key()?.public_hex());
|
|
Ok(())
|
|
}
|
|
|
|
pub fn key_show_enc(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let secret = config
|
|
.load_enc_key()?
|
|
.ok_or_else(|| anyhow!("no encryption key at {}", config.enc_key_path().display()))?;
|
|
println!("{}", crate::crypto::enc_public_from_secret(&secret)?);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn key_rotate(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let old = config.load_key()?;
|
|
let backup = config.key_path().with_extension("hex.bak");
|
|
std::fs::copy(config.key_path(), &backup)?;
|
|
let new_key = Keypair::generate();
|
|
config.save_key(&new_key)?;
|
|
let (enc_secret, enc_public) = crate::crypto::generate_enc_keypair();
|
|
config.save_enc_key(&enc_secret)?;
|
|
println!("old pubkey {}", old.public_hex());
|
|
println!("new pubkey {}", new_key.public_hex());
|
|
println!("new enc pubkey {enc_public}");
|
|
println!("old key saved to {}", backup.display());
|
|
println!(
|
|
"peers accept the new key via: frxd member add <name> {} --previous {}",
|
|
new_key.public_hex(),
|
|
old.public_hex()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn member_remove(config_path: &Path, name: &str) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let mut members = load_members(&config.members_path())?;
|
|
let before = members.len();
|
|
members.retain(|member| member.name != name);
|
|
if members.len() == before {
|
|
println!("no member named {name}");
|
|
return Ok(());
|
|
}
|
|
save_members(&config.members_path(), &members)?;
|
|
println!("removed {name}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn member_list(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let members = load_members(&config.members_path())?;
|
|
if members.is_empty() {
|
|
println!(
|
|
"member directory {} is empty (open bootstrap mode: any valid signature is accepted)",
|
|
config.members_path().display()
|
|
);
|
|
return Ok(());
|
|
}
|
|
for member in members {
|
|
println!("{} [{}] {}", member.name, member.class, member.pubkey);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn aggregates(
|
|
config_path: &Path,
|
|
from: Option<&str>,
|
|
period: Option<&str>,
|
|
timeout_ms: Option<u64>,
|
|
) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let base = format!("http://{}", config.node.listen);
|
|
let period = period
|
|
.map(str::to_string)
|
|
.unwrap_or_else(node::current_period);
|
|
match from {
|
|
Some(to) => {
|
|
let value = node::control_aggregate_request(&base, to, &period, timeout_ms).await?;
|
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
|
}
|
|
None => {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()?;
|
|
let response = client
|
|
.get(format!("{base}/v1/local/aggregates?period={period}"))
|
|
.send()
|
|
.await
|
|
.context("calling local node (is `frxd serve` running?)")?;
|
|
let status = response.status();
|
|
let value: serde_json::Value = response.json().await?;
|
|
if !status.is_success() {
|
|
return Err(anyhow!("local node error {status}: {value}"));
|
|
}
|
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn registry_key_path(dir: &Path) -> PathBuf {
|
|
dir.join("ma-key.hex")
|
|
}
|
|
|
|
fn registry_doc_path(dir: &Path) -> PathBuf {
|
|
dir.join("registry.json")
|
|
}
|
|
|
|
fn open_registry(dir: &Path) -> Result<(Keypair, SignedRegistry)> {
|
|
let raw = std::fs::read_to_string(registry_key_path(dir))
|
|
.with_context(|| format!("reading MA key from {}", registry_key_path(dir).display()))?;
|
|
let key = Keypair::from_hex(&raw)?;
|
|
let signed = registry::load_registry(®istry_doc_path(dir))
|
|
.with_context(|| format!("reading registry from {}", registry_doc_path(dir).display()))?;
|
|
Ok((key, signed))
|
|
}
|
|
|
|
fn mutate_registry(dir: &Path, mutate: impl FnOnce(&mut RegistryDoc) -> Result<()>) -> Result<u64> {
|
|
let (ma, signed) = open_registry(dir)?;
|
|
let mut doc = signed.doc;
|
|
mutate(&mut doc)?;
|
|
doc.version += 1;
|
|
doc.issued_at = now_ts();
|
|
let signed = registry::sign_registry(doc, &ma);
|
|
registry::save_registry(®istry_doc_path(dir), &signed)?;
|
|
Ok(signed.doc.version)
|
|
}
|
|
|
|
pub fn registry_init(dir: &Path, zone: &str) -> Result<()> {
|
|
let registry_path = registry_doc_path(dir);
|
|
if registry_path.exists() {
|
|
return Err(anyhow!(
|
|
"registry {} already exists",
|
|
registry_path.display()
|
|
));
|
|
}
|
|
std::fs::create_dir_all(dir)?;
|
|
let ma = Keypair::generate();
|
|
std::fs::write(registry_key_path(dir), ma.to_hex())?;
|
|
crate::config::set_private_permissions(®istry_key_path(dir))?;
|
|
let doc = RegistryDoc {
|
|
version: 1,
|
|
issued_at: now_ts(),
|
|
ma_key: String::new(),
|
|
zone: zone.to_string(),
|
|
members: Vec::new(),
|
|
relays: Vec::new(),
|
|
};
|
|
let signed = registry::sign_registry(doc, &ma);
|
|
registry::save_registry(®istry_path, &signed)?;
|
|
println!("MA key {}", ma.public_hex());
|
|
println!("registry {}", registry_path.display());
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_add(
|
|
dir: &Path,
|
|
id: &str,
|
|
pubkey: &str,
|
|
class: &str,
|
|
not_before: Option<u64>,
|
|
not_after: Option<u64>,
|
|
enc_key: Option<String>,
|
|
) -> Result<()> {
|
|
let pubkey = normalize_key(pubkey)?;
|
|
let enc_key = enc_key.map(|key| normalize_key(&key)).transpose()?;
|
|
let class = if class == CLASS_ENRICHMENT {
|
|
CLASS_ENRICHMENT
|
|
} else {
|
|
CLASS_SOURCE
|
|
};
|
|
mutate_registry(dir, |doc| {
|
|
if doc.members.iter().any(|member| member.id == id) {
|
|
return Err(anyhow!("member {id} already listed"));
|
|
}
|
|
doc.members.push(RegistryMember {
|
|
id: id.to_string(),
|
|
class: class.to_string(),
|
|
keys: vec![KeyEntry {
|
|
key: pubkey.clone(),
|
|
not_before: not_before.unwrap_or_else(now_ts),
|
|
not_after,
|
|
}],
|
|
enc_key: enc_key.clone(),
|
|
});
|
|
Ok(())
|
|
})?;
|
|
println!("added {id} ({class})");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_set_enc_key(dir: &Path, id: &str, enc_key: &str) -> Result<()> {
|
|
let enc_key = normalize_key(enc_key)?;
|
|
mutate_registry(dir, |doc| {
|
|
let Some(member) = doc.members.iter_mut().find(|member| member.id == id) else {
|
|
return Err(anyhow!("no member named {id}"));
|
|
};
|
|
member.enc_key = Some(enc_key.clone());
|
|
Ok(())
|
|
})?;
|
|
println!("set enc key for {id}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_add_key(
|
|
dir: &Path,
|
|
id: &str,
|
|
pubkey: &str,
|
|
not_before: Option<u64>,
|
|
not_after: Option<u64>,
|
|
) -> Result<()> {
|
|
let pubkey = normalize_key(pubkey)?;
|
|
mutate_registry(dir, |doc| {
|
|
let Some(member) = doc.members.iter_mut().find(|member| member.id == id) else {
|
|
return Err(anyhow!("no member named {id}"));
|
|
};
|
|
if member.keys.iter().any(|entry| entry.key == pubkey) {
|
|
return Err(anyhow!("key already authorized for {id}"));
|
|
}
|
|
member.keys.push(KeyEntry {
|
|
key: pubkey.clone(),
|
|
not_before: not_before.unwrap_or_else(now_ts),
|
|
not_after,
|
|
});
|
|
Ok(())
|
|
})?;
|
|
println!("added key for {id}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_revoke_key(dir: &Path, id: &str, pubkey: &str) -> Result<()> {
|
|
let pubkey = normalize_key(pubkey)?;
|
|
mutate_registry(dir, |doc| {
|
|
let Some(member) = doc.members.iter_mut().find(|member| member.id == id) else {
|
|
return Err(anyhow!("no member named {id}"));
|
|
};
|
|
let before = member.keys.len();
|
|
member.keys.retain(|entry| entry.key != pubkey);
|
|
if member.keys.len() == before {
|
|
return Err(anyhow!("key not authorized for {id}"));
|
|
}
|
|
Ok(())
|
|
})?;
|
|
println!("revoked key for {id}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_remove(dir: &Path, id: &str) -> Result<()> {
|
|
mutate_registry(dir, |doc| {
|
|
let before = doc.members.len();
|
|
doc.members.retain(|member| member.id != id);
|
|
if doc.members.len() == before {
|
|
return Err(anyhow!("no member named {id}"));
|
|
}
|
|
Ok(())
|
|
})?;
|
|
println!("removed {id}");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_list(dir: &Path) -> Result<()> {
|
|
let (_, signed) = open_registry(dir)?;
|
|
for member in &signed.doc.members {
|
|
let keys = member.keys.len();
|
|
println!("{} [{}] ({} key(s))", member.id, member.class, keys);
|
|
for entry in &member.keys {
|
|
let window = match entry.not_after {
|
|
Some(end) => format!("valid {}..{}", entry.not_before, end),
|
|
None => format!("valid from {}", entry.not_before),
|
|
};
|
|
println!(" {} ({window})", entry.key);
|
|
}
|
|
if let Some(enc_key) = &member.enc_key {
|
|
println!(" enc {enc_key}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_applications(dir: &Path) -> Result<()> {
|
|
let applications = registry::load_applications(®istry::applications_path(dir))?;
|
|
if applications.is_empty() {
|
|
println!("no applications recorded");
|
|
return Ok(());
|
|
}
|
|
for app in &applications {
|
|
println!("{} [{}] {} <{}> — {}", app.id, app.class, app.org, app.email, app.status);
|
|
println!(" representative: {}", app.representative);
|
|
if !app.address.is_empty() {
|
|
println!(" address: {}", app.address);
|
|
}
|
|
if !app.domain.is_empty() {
|
|
println!(" domain: {}", app.domain);
|
|
}
|
|
if !app.payment.is_empty() {
|
|
println!(" payment: {}", app.payment);
|
|
}
|
|
if !app.privacy_link.is_empty() {
|
|
println!(" privacy: {}", app.privacy_link);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Approves a pending application: creates the member stub, mints its account
|
|
/// credential (member token), and prints the credential block to hand over.
|
|
/// `--class enrichment` is for derived corpora (metadata-only, §6); default is source.
|
|
pub fn registry_approve(
|
|
dir: &Path,
|
|
id: &str,
|
|
registry_url: Option<&str>,
|
|
class: Option<&str>,
|
|
) -> Result<()> {
|
|
let (_, signed) = open_registry(dir)?;
|
|
if signed.doc.members.iter().any(|member| member.id == id) {
|
|
return Err(anyhow!("member {id} already listed"));
|
|
}
|
|
let _application = registry::approve_application(dir, id)?;
|
|
let class = if class == Some(CLASS_ENRICHMENT) {
|
|
CLASS_ENRICHMENT
|
|
} else {
|
|
CLASS_SOURCE
|
|
}
|
|
.to_string();
|
|
mutate_registry(dir, |doc| {
|
|
doc.members.push(RegistryMember {
|
|
id: id.to_string(),
|
|
class: class.clone(),
|
|
keys: Vec::new(),
|
|
enc_key: None,
|
|
});
|
|
Ok(())
|
|
})?;
|
|
let token = registry::create_token(dir, id)?;
|
|
let (_, signed) = open_registry(dir)?;
|
|
println!("approved {id} ({class})");
|
|
println!("member token — reusable for every node the member runs; keep private:");
|
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
|
Ok(())
|
|
}
|
|
|
|
/// Issues a fresh invite for an existing member — one single-use token per node
|
|
/// the member runs (each node binds its own key at enrollment).
|
|
pub fn registry_invite(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<()> {
|
|
let (_, signed) = open_registry(dir)?;
|
|
if !signed.doc.members.iter().any(|member| member.id == id) {
|
|
return Err(anyhow!("no member named {id}"));
|
|
}
|
|
let invite = registry::create_invite(dir, id, 24 * 3600)?;
|
|
println!("single-use handoff invite for {id} (valid 24h):");
|
|
print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
|
|
Ok(())
|
|
}
|
|
|
|
/// Mints an additional member token (account credential) and prints the block.
|
|
/// Returns the raw token for programmatic use.
|
|
pub fn registry_token(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<String> {
|
|
let (_, signed) = open_registry(dir)?;
|
|
if !signed.doc.members.iter().any(|member| member.id == id) {
|
|
return Err(anyhow!("no member named {id}"));
|
|
}
|
|
let token = registry::create_token(dir, id)?;
|
|
println!("member token for {id} — reusable for every node they run; keep private:");
|
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
|
Ok(token)
|
|
}
|
|
|
|
/// Revokes all of a member's tokens (e.g. after a leak); mint fresh with `registry token`.
|
|
pub fn registry_revoke_token(dir: &Path, id: &str) -> Result<()> {
|
|
let revoked = registry::revoke_tokens(dir, id)?;
|
|
println!("revoked {revoked} token(s) for {id}");
|
|
Ok(())
|
|
}
|
|
|
|
fn print_credential_block(id: &str, token: &str, registry_url: Option<&str>, ma_key: &str) {
|
|
let registry_url = registry_url.unwrap_or("<registry-url>");
|
|
println!("id={id} token={token} registry={registry_url} ma_key={ma_key}");
|
|
}
|
|
|
|
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
|
|
mutate_registry(dir, |doc| {
|
|
doc.relays = relays.to_vec();
|
|
Ok(())
|
|
})?;
|
|
println!("relays: {}", relays.join(" "));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn registry_show(dir: &Path) -> Result<()> {
|
|
let (_, signed) = open_registry(dir)?;
|
|
println!("ma_key {}", signed.doc.ma_key);
|
|
println!("version {}", signed.doc.version);
|
|
println!("issued_at {}", signed.doc.issued_at);
|
|
for relay in &signed.doc.relays {
|
|
println!("relay {relay}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct RegistryServer {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
pub fn registry_router(dir: &Path) -> Router {
|
|
let state = std::sync::Arc::new(RegistryServer {
|
|
dir: dir.to_path_buf(),
|
|
});
|
|
Router::new()
|
|
.route("/health", get(registry_health))
|
|
.route("/registry.json", get(registry_snapshot))
|
|
.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) -> Result<()> {
|
|
let app = registry_router(dir);
|
|
let listener = TcpListener::bind(listen).await?;
|
|
println!("registry serving on http://{}", listener.local_addr()?);
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn registry_health() -> &'static str {
|
|
"ok"
|
|
}
|
|
|
|
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,
|
|
Json(serde_json::json!({ "error": "registry not found" })),
|
|
)
|
|
.into_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,
|
|
#[serde(default)]
|
|
org: String,
|
|
#[serde(default)]
|
|
representative: String,
|
|
#[serde(default)]
|
|
email: String,
|
|
#[serde(default)]
|
|
address: String,
|
|
#[serde(default)]
|
|
domain: String,
|
|
#[serde(default)]
|
|
payment: String,
|
|
#[serde(default)]
|
|
privacy_link: String,
|
|
#[serde(default)]
|
|
attestation: bool,
|
|
#[serde(default)]
|
|
privacy_ack: bool,
|
|
}
|
|
|
|
async fn registry_signup(
|
|
State(server): State<std::sync::Arc<RegistryServer>>,
|
|
Json(request): Json<SignupRequest>,
|
|
) -> 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();
|
|
}
|
|
if !request.attestation {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({ "error": "content authorization must be confirmed" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
if !request.privacy_ack {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({ "error": "the privacy notice must be acknowledged" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
if request.org.trim().is_empty()
|
|
|| request.representative.trim().is_empty()
|
|
|| request.email.trim().is_empty()
|
|
{
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({ "error": "organization name, representative, and contact email are required" })),
|
|
)
|
|
.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 applications = match registry::load_applications(®istry::applications_path(&server.dir))
|
|
{
|
|
Ok(applications) => applications,
|
|
Err(error) => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({ "error": error.to_string() })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
if applications.iter().any(|application| application.id == id) {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
Json(serde_json::json!({ "error": "an application for this identifier is already on file" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
let application = registry::Application {
|
|
id: id.clone(),
|
|
org: request.org.clone(),
|
|
representative: request.representative.clone(),
|
|
email: request.email.clone(),
|
|
address: request.address.clone(),
|
|
domain: request.domain.clone(),
|
|
class: CLASS_SOURCE.to_string(),
|
|
payment: request.payment.clone(),
|
|
privacy_link: request.privacy_link.clone(),
|
|
status: "pending".to_string(),
|
|
submitted_at: now_ts(),
|
|
};
|
|
if let Err(error) = registry::record_application(&server.dir, application) {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({ "error": error.to_string() })),
|
|
)
|
|
.into_response();
|
|
}
|
|
(
|
|
StatusCode::ACCEPTED,
|
|
Json(serde_json::json!({
|
|
"status": "pending",
|
|
"id": id,
|
|
"message": "application received — the membership authority reviews it and issues your credential block"
|
|
})),
|
|
)
|
|
.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 registry::redeem_invite(&server.dir, &request.id, &request.token).is_err() {
|
|
let valid = registry::validate_token(&server.dir, &request.id, &request.token)
|
|
.unwrap_or(false);
|
|
if !valid {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({ "error": "unknown invite or member token" })),
|
|
)
|
|
.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 lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>FRX — Federated Retrieval Exchange</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; line-height: 1.55;
|
|
max-width: 44rem; margin: 0 auto; padding: 2.5rem 1rem; color: #1c1c1e; background: #f7f7f9; }
|
|
h1 { font-size: 1.7rem; margin-bottom: 0.2rem; }
|
|
h2 { font-size: 1.05rem; margin-top: 2.2rem; }
|
|
.tag { color: #555; margin-top: 0; }
|
|
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.1rem 1.25rem; }
|
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
|
|
input, select, button { font: inherit; border: 1px solid #bbb; border-radius: 6px; padding: 0.45rem 0.6rem; }
|
|
input, select { width: 100%; margin: 0.2rem 0 0.9rem; }
|
|
.check { display: block; font-size: 0.92rem; margin: 0.5rem 0; }
|
|
.check input { width: auto; margin: 0 0.4rem 0 0; }
|
|
button { background: #174ea6; color: #fff; border: none; cursor: pointer; padding: 0.5rem 1rem; border-radius: 6px; }
|
|
button:hover { background: #0f3d91; }
|
|
#out { display: none; background: #101418; color: #d6f5d6; padding: 0.85rem 1rem;
|
|
border-radius: 8px; white-space: pre-wrap; word-break: break-all; margin-top: 1rem; }
|
|
pre.cmd { background: #101418; color: #d6f5d6; padding: 0.85rem 1rem;
|
|
border-radius: 8px; overflow-x: auto; }
|
|
.muted { color: #666; font-size: 0.92rem; }
|
|
ol { padding-left: 1.3rem; }
|
|
li { margin: 0.35rem 0; }
|
|
a { color: #174ea6; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>FRX</h1>
|
|
<p class="tag">Federated Retrieval Exchange — a membership federation for retrieval.</p>
|
|
<p>Members answer broadcast queries from content they already hold. The protocol is deliberately
|
|
small: signed messages, budgets, honest truncation, aggregate courtesy. No announce stream, no
|
|
scores on the wire, no in-protocol payment.</p>
|
|
<p>Everything else — matching, ranking, retention, trust — is local.</p>
|
|
|
|
<h2>1. Register with the membership authority</h2>
|
|
<div class="card">
|
|
<p class="muted">This form requests membership from the membership authority (MA). Membership has no
|
|
roles or tiers: every member may broadcast queries and every member may answer them. The MA
|
|
reviews your organization details and issues a credential block
|
|
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
|
|
binds your node's keys to the identifier. The public registry publishes only your identifier,
|
|
class, keys, and the federation's relays. The organization details below are kept privately by
|
|
the MA for the membership contract — never published, never on the wire.</p>
|
|
<form id="f">
|
|
<label>Short name — this becomes your identifier<br>
|
|
<input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label>
|
|
<p class="muted">identifier: <code id="preview">(type a short name)</code> — no domain or DNS of your own is needed.</p>
|
|
<label>Legal organization name<br>
|
|
<input name="org" required placeholder="Keswick Research LLC"></label>
|
|
<label>Representative (authorized contact person)<br>
|
|
<input name="representative" required placeholder="Jane Keswick"></label>
|
|
<label>Contact email<br>
|
|
<input name="email" type="email" required placeholder="ops@example.org"></label>
|
|
<label>Registered address<br>
|
|
<input name="address" placeholder="street, city, country"></label>
|
|
<label>Organization domain (optional)<br>
|
|
<input name="domain" placeholder="example.org"></label>
|
|
<label>Payment details (billing / payout — e.g. IBAN or payment handle)<br>
|
|
<input name="payment" placeholder="kept private; the protocol itself carries no payment"></label>
|
|
<label>Your privacy statement URL (optional)<br>
|
|
<input name="privacy_link" placeholder="https://example.org/privacy"></label>
|
|
<label class="check"><input type="checkbox" name="attestation" required> I will only index content I own or that users supply, and only collections I explicitly mark shared will answer queries.</label>
|
|
<label class="check"><input type="checkbox" name="privacy_ack" required> I acknowledge the privacy notice above.</label>
|
|
<button type="submit">Request membership</button>
|
|
</form>
|
|
<pre id="out"></pre>
|
|
</div>
|
|
|
|
<h2>2. Download</h2>
|
|
<div class="card">
|
|
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
|
|
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64
|
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64.sha256
|
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frx-linux-amd64
|
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frx-linux-amd64.sha256</pre>
|
|
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
|
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
|
</div>
|
|
|
|
<h2>3. Install</h2>
|
|
<div class="card">
|
|
<pre class="cmd">sha256sum -c frxd-linux-amd64.sha256
|
|
chmod +x frxd-linux-amd64 frx-linux-amd64
|
|
sudo mv frxd-linux-amd64 /usr/local/bin/frxd
|
|
sudo mv frx-linux-amd64 /usr/local/bin/frx</pre>
|
|
<p class="muted">No sudo? Run them in place — each is a single self-contained binary.</p>
|
|
</div>
|
|
|
|
<h2>4. Onboard</h2>
|
|
<div class="card">
|
|
<pre class="cmd">frxd --onboarding</pre>
|
|
<ol>
|
|
<li>Paste the credential block from step 1.</li>
|
|
<li>The wizard generates your keys, enrolls them with the MA, verifies the signed registry
|
|
against the pinned MA key, and wires the federation relays — no domains, DNS, or open ports
|
|
needed on your side.</li>
|
|
<li>Index a directory and mark what you share:</li>
|
|
</ol>
|
|
<p class="muted">The credential block is reusable: run the wizard on every node you operate —
|
|
each node binds its own key to your identifier. If the token leaks, the MA revokes it and
|
|
issues a fresh one.</p>
|
|
<pre class="cmd">frxd add ~/documents --name docs --shared --exposure metadata
|
|
frxd serve</pre>
|
|
<p class="muted">Search local-first with <code>frx search "..."</code>; broadcast to the federation with
|
|
<code>frx query "..."</code>. Nothing is shared until a collection is explicitly marked
|
|
<code>--shared</code>.</p>
|
|
</div>
|
|
|
|
<h2>Who is in</h2>
|
|
<p class="muted" id="members">…</p>
|
|
<p class="muted"><small>The registry is a signed, versioned, public snapshot: <code>GET /registry.json</code>. Membership is governed by the MA — questions and codes come from there.</small></p>
|
|
|
|
<script>
|
|
const out = document.getElementById("out");
|
|
const f = document.getElementById("f");
|
|
let zone = "frx.federatedsearch.org";
|
|
function sanitizeLabel(v) {
|
|
let label = "", lastDash = true;
|
|
for (const c of v.toLowerCase()) {
|
|
if (/[a-z0-9]/.test(c)) { label += c; lastDash = false; }
|
|
else if (!lastDash && (/\s/.test(c) || c === "-" || c === "_" || c === ".")) { label += "-"; lastDash = true; }
|
|
if (label.length >= 32) break;
|
|
}
|
|
return label.replace(/^-+|-+$/g, "").slice(0, 32);
|
|
}
|
|
const preview = document.getElementById("preview");
|
|
const updatePreview = () => {
|
|
const label = sanitizeLabel(f.label.value);
|
|
preview.textContent = label ? label + "." + zone : "(type a short name)";
|
|
};
|
|
f.label.addEventListener("input", updatePreview);
|
|
f.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const res = await fetch("/v1/signup", {
|
|
method: "POST",
|
|
headers: {"content-type": "application/json"},
|
|
body: JSON.stringify({
|
|
label: f.label.value,
|
|
org: f.org.value, representative: f.representative.value, email: f.email.value,
|
|
address: f.address.value, domain: f.domain.value,
|
|
payment: f.payment.value, privacy_link: f.privacy_link.value,
|
|
attestation: f.attestation.checked, privacy_ack: f.privacy_ack.checked
|
|
})
|
|
});
|
|
const body = await res.json();
|
|
out.style.display = "block";
|
|
out.textContent = body.credentials
|
|
? "Membership approved.\n\nNext: download frxd (step 2), install it (step 3), then run `frxd --onboarding` and paste this block:\n\n" + body.credentials + "\n"
|
|
: res.ok
|
|
? "Application received.\n\n" + (body.message || "The membership authority will review it and issue your credential block.")
|
|
: "Failed: " + (body.error || ("http " + res.status));
|
|
};
|
|
|
|
(async () => {
|
|
try {
|
|
const res = await fetch("/registry.json");
|
|
const doc = await res.json();
|
|
zone = doc.zone || zone;
|
|
updatePreview();
|
|
const ids = doc.members.map((m) => m.id);
|
|
document.getElementById("members").textContent = doc.members.length === 0
|
|
? "The registry is empty — be the first member."
|
|
: doc.members.length + " member(s): " + ids.join(", ") + " · registry v" + doc.version;
|
|
} catch (e) {
|
|
document.getElementById("members").textContent = "registry unavailable";
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##;
|
|
|
|
pub async fn status(config_path: &Path) -> Result<()> {
|
|
let config = Config::load(config_path)?;
|
|
let base = format!("http://{}", config.node.listen);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(2))
|
|
.build()?;
|
|
match client.get(format!("{base}/v1/local/status")).send().await {
|
|
Ok(response) if response.status().is_success() => {
|
|
let value: serde_json::Value = response.json().await?;
|
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
|
}
|
|
Ok(response) => println!("node at {base} responded {}", response.status()),
|
|
Err(_) => {
|
|
println!("node not running at {base}");
|
|
println!("config: {}", config_path.display());
|
|
println!("name: {}", config.node.name);
|
|
println!("data dir: {}", config.data_dir().display());
|
|
println!("relays: {:?}", config.node.relays);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|