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, 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 `"); 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, timeout_ms: Option, 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 { 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::>>()?; 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 {} --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, ) -> 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 { 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, not_after: Option, enc_key: Option, ) -> 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, not_after: Option, ) -> 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 { 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(""); 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>) -> 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>, Json(request): Json, ) -> 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, } async fn registry_enroll( State(server): State>, Json(request): Json, ) -> 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##" FRX — Federated Retrieval Exchange

FRX

Federated Retrieval Exchange — a membership federation for retrieval.

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.

Everything else — matching, ranking, retention, trust — is local.

1. Register with the membership authority

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 (id=... token=... registry=... ma_key=...); 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.

identifier: (type a short name) — no domain or DNS of your own is needed.


2. Download

Static Linux x86_64 binaries (musl — no runtime dependencies):

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

All releases: git.federatedsearch.org/frx/frxd/releases. Source and spec (rfc.txt): git.federatedsearch.org/frx/frxd.

3. Install

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

No sudo? Run them in place — each is a single self-contained binary.

4. Onboard

frxd --onboarding
  1. Paste the credential block from step 1.
  2. 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.
  3. Index a directory and mark what you share:

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.

frxd add ~/documents --name docs --shared --exposure metadata
frxd serve

Search local-first with frx search "..."; broadcast to the federation with frx query "...". Nothing is shared until a collection is explicitly marked --shared.

Who is in

The registry is a signed, versioned, public snapshot: GET /registry.json. Membership is governed by the MA — questions and codes come from there.

"##; 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(()) }