Onboarding wizard and MA signup/enroll site

This commit is contained in:
George Coles
2026-09-15 09:35:24 -04:00
parent 1297766e8a
commit 15e5c6cbd7
12 changed files with 794 additions and 17 deletions
+2 -1
View File
@@ -5,7 +5,7 @@
- `DESIGN.md` is the partner-facing architecture and decision log (with rationale for the spec cuts); keep it in sync when architecture decisions change. - `DESIGN.md` is the partner-facing architecture and decision log (with rationale for the spec cuts); keep it in sync when architecture decisions change.
- `frxd` is the member node (init/add/index/serve/relay/query/status); `frx` is the thin client (search/query/status). Relay and node roles are separate subcommands. - `frxd` is the member node (init/add/index/serve/relay/query/status); `frx` is the thin client (search/query/status). Relay and node roles are separate subcommands.
- `DEPLOY.md` documents the TLS/deployment story: members need no TLS (outbound HTTPS), relays terminate TLS with Caddy or a tunnel, `ca_cert` adds private CAs, `allow_insecure` opts into plain http on private networks, and non-loopback `http://` is refused by default. - `DEPLOY.md` documents the TLS/deployment story: members need no TLS (outbound HTTPS), relays terminate TLS with Caddy or a tunnel, `ca_cert` adds private CAs, `allow_insecure` opts into plain http on private networks, and non-loopback `http://` is refused by default.
- Commands: `cargo build`, `cargo test` (102 tests: unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config. unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config. - Commands: `cargo build`, `cargo test` (104 tests: unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config. unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config.
- E2E pattern: relay + nodes in-process on ephemeral ports with tempdir corpora; use `tests/common/mod.rs` helpers (`spawn_relay*`, `query_envelope`, `poll_messages`, `register`) for new coverage. Raw relay polls return envelopes (payload under `body`), not response bodies. - E2E pattern: relay + nodes in-process on ephemeral ports with tempdir corpora; use `tests/common/mod.rs` helpers (`spawn_relay*`, `query_envelope`, `poll_messages`, `register`) for new coverage. Raw relay polls return envelopes (payload under `body`), not response bodies.
## Editing the spec ## Editing the spec
@@ -50,5 +50,6 @@
- Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role. - Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role.
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 12 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch). - Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 12 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search``EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine). - Engine seam: `src/engine.rs` `SearchEngine` trait (`search``EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine).
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Invites live in `<registry dir>/invites.json`. Prompts accept empty input as the default; scripted stdin works for tests.
- Next matching steps: eval harness with a small golden set (precision@k + false-silence rate), then a dense recall leg (model2vec-rs 0.2.1 exists but needs `default-features = false, features = ["fancy-regex", "local-only"]` for musl/airgapped; verify crate + model licenses before bundling), then an optional cross-encoder reranker. Embeddings are for recall; reranking is the precision tier. - Next matching steps: eval harness with a small golden set (precision@k + false-silence rate), then a dense recall leg (model2vec-rs 0.2.1 exists but needs `default-features = false, features = ["fancy-regex", "local-only"]` for musl/airgapped; verify crate + model licenses before bundling), then an optional cross-encoder reranker. Embeddings are for recall; reranking is the precision tier.
- Identity/registry (RFC Draft 0.5 §4/§6): MA-hosted FQDN identifiers first (`<label>.frx.<ma-domain>`, no DNS needed by users), signed versioned registry snapshot with the MA key pinned; envelope `from` = identifier, `key` = pubkey; registry outage fails static. Member-hosted identities, MA anchor rollover, and unicast confidentiality are §10 open. Implementation phases: A (signed registry snapshot) and B (identifier + `key` + JCS on the wire) are built and tested. Prioritize frictionless onboarding (users may be department-level and cannot create DNS). - Identity/registry (RFC Draft 0.5 §4/§6): MA-hosted FQDN identifiers first (`<label>.frx.<ma-domain>`, no DNS needed by users), signed versioned registry snapshot with the MA key pinned; envelope `from` = identifier, `key` = pubkey; registry outage fails static. Member-hosted identities, MA anchor rollover, and unicast confidentiality are §10 open. Implementation phases: A (signed registry snapshot) and B (identifier + `key` + JCS on the wire) are built and tested. Prioritize frictionless onboarding (users may be department-level and cannot create DNS).
+21
View File
@@ -16,6 +16,27 @@ frxd serve
The node connects outbound over HTTPS, verifies the registry with the pinned MA key, and holds an SSE stream per relay. Nothing inbound, no DNS, no certificates. Members at departmental level can start here. The node connects outbound over HTTPS, verifies the registry with the pinned MA key, and holds an SSE stream per relay. Nothing inbound, no DNS, no certificates. Members at departmental level can start here.
## Joining the network (membership site + wizard)
Identity registration stays with the MA; the wizard only consumes the credentials it issues.
MA operator — run the signup site:
```
frxd registry --dir ./ma init --zone frx.example
frxd registry --dir ./ma serve --listen 127.0.0.1:7800 --signup-code <code> --registry-url https://ma.example.com/registry.json
```
(put Caddy in front for a real domain). The page at `/` accepts a label and the signup code and returns a credential block: `id=... token=... registry=... ma_key=...`.
New member:
```
frxd --onboarding
```
The wizard asks for a config path, accepts the pasted credential block (or field-by-field entry), generates keypairs, enrolls the new key with the MA (redeeming the single-use invite token), verifies the signed registry snapshot, takes relay defaults from the registry, optionally shares a directory, writes the config, and offers to start serving. If enrollment is unavailable (file-path registry), it prints the exact `registry add` command the operator must run.
## Relay with TLS (one line) ## Relay with TLS (one line)
Run `frxd` on loopback and terminate TLS with Caddy: Run `frxd` on loopback and terminate TLS with Caddy:
+2
View File
@@ -88,6 +88,7 @@ Decisions taken during design review, with rationale.
| 17 | Lexical coverage gate before any rerank | Precision is project health; a demo false positive showed raw OR matching is too weak; embeddings later, local and replaceable | Implemented (`[match] min_coverage`) | | 17 | Lexical coverage gate before any rerank | Precision is project health; a demo false positive showed raw OR matching is too weak; embeddings later, local and replaceable | Implemented (`[match] min_coverage`) |
| 18 | Default engine: boundary tokenizer + fold + stopwords + stemmer, title/phrase boosts, query-time snippets | The floor must be high out of the box; model-number and morphology matching are cheap wins with no model | Implemented (`src/tokenizer.rs`) | | 18 | Default engine: boundary tokenizer + fold + stopwords + stemmer, title/phrase boosts, query-time snippets | The floor must be high out of the box; model-number and morphology matching are cheap wins with no model | Implemented (`src/tokenizer.rs`) |
| 19 | Engine seam: `SearchEngine` trait with the responder path as the conformance wrapper | Plugins can change quality, never conformance; engines return `Option<total>` so an external engine can't fake the truncation bit | Implemented (`src/engine.rs`) | | 19 | Engine seam: `SearchEngine` trait with the responder path as the conformance wrapper | Plugins can change quality, never conformance; engines return `Option<total>` so an external engine can't fake the truncation bit | Implemented (`src/engine.rs`) |
| 20 | Onboarding is a wizard consuming MA-issued credentials; signup lives on the MA's site | Users may be department-level and cannot create domains or DNS records; the wizard never creates identities, only binds locally generated keys | Implemented (`frxd --onboarding`, `/v1/signup`+`/v1/enroll`) |
## 8. Implementation status ## 8. Implementation status
@@ -102,6 +103,7 @@ Built and tested (99 tests):
- Registry CLI (init/add/add-key/revoke-key/set-enc-key/set-relays/show/serve), key rotation - Registry CLI (init/add/add-key/revoke-key/set-enc-key/set-relays/show/serve), key rotation
- Aggregates (sent/passed, monthly floor, yearly rollup) - Aggregates (sent/passed, monthly floor, yearly rollup)
- Encrypted unicast, TLS guardrails and custom CA support, static-build release profile - Encrypted unicast, TLS guardrails and custom CA support, static-build release profile
- Onboarding wizard (`frxd --onboarding`) and MA signup/enroll site (`/v1/signup`, `/v1/enroll`, HTML at `/`)
Not built (see §9): dashboard UI, directory watching, user-supplied URL ingestion, node-side rate limiting, member-hosted identities, delegation, document lineage, MA anchor rollover, embedding rerank, invite-based self-enrollment, relay-to-relay unicast routing. Not built (see §9): dashboard UI, directory watching, user-supplied URL ingestion, node-side rate limiting, member-hosted identities, delegation, document lineage, MA anchor rollover, embedding rerank, invite-based self-enrollment, relay-to-relay unicast routing.
+255 -8
View File
@@ -5,8 +5,9 @@ use anyhow::{Context, Result, anyhow};
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Config, Member, load_members, save_members}; 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) 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); let registry_path = registry_doc_path(dir);
if registry_path.exists() { if registry_path.exists() {
return Err(anyhow!( return Err(anyhow!(
@@ -288,6 +289,7 @@ pub fn registry_init(dir: &Path) -> Result<()> {
version: 1, version: 1,
issued_at: now_ts(), issued_at: now_ts(),
ma_key: String::new(), ma_key: String::new(),
zone: zone.to_string(),
members: Vec::new(), members: Vec::new(),
relays: Vec::new(), relays: Vec::new(),
}; };
@@ -442,12 +444,38 @@ pub fn registry_show(dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> { struct RegistryServer {
let state = dir.to_path_buf(); dir: PathBuf,
let app = Router::new() 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("/health", get(registry_health))
.route("/registry.json", get(registry_snapshot)) .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?; let listener = TcpListener::bind(listen).await?;
println!("registry serving on http://{}", listener.local_addr()?); println!("registry serving on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
@@ -458,8 +486,8 @@ async fn registry_health() -> &'static str {
"ok" "ok"
} }
async fn registry_snapshot(State(dir): State<PathBuf>) -> Response { async fn registry_snapshot(State(server): State<std::sync::Arc<RegistryServer>>) -> Response {
match registry::load_registry(&registry_doc_path(&dir)) { match registry::load_registry(&registry_doc_path(&server.dir)) {
Ok(signed) => Json(signed).into_response(), Ok(signed) => Json(signed).into_response(),
Err(_) => ( Err(_) => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -469,6 +497,225 @@ async fn registry_snapshot(State(dir): State<PathBuf>) -> Response {
} }
} }
fn sanitize_label(input: &str) -> String {
let mut label = String::new();
let mut last_dash = true;
for c in input.to_ascii_lowercase().chars() {
if c.is_ascii_alphanumeric() {
label.push(c);
last_dash = false;
} else if !last_dash && (c.is_whitespace() || c == '-' || c == '_' || c == '.') {
label.push('-');
last_dash = true;
}
if label.len() >= 32 {
break;
}
}
label.trim_matches('-').chars().take(32).collect()
}
#[derive(Deserialize)]
struct SignupRequest {
label: String,
code: Option<String>,
}
async fn registry_signup(
State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<SignupRequest>,
) -> Response {
let Some(expected) = &server.signup_code else {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "signup is not enabled" })),
)
.into_response();
};
if request.code.as_deref() != Some(expected.as_str()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "wrong signup code" })),
)
.into_response();
}
let label = sanitize_label(&request.label);
if label.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
)
.into_response();
}
let signed = match registry::load_registry(&registry_doc_path(&server.dir)) {
Ok(signed) => signed,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
let zone = signed.doc.zone.clone();
let id = format!("{label}.{zone}");
if signed.doc.members.iter().any(|member| member.id == id) {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "member id already taken" })),
)
.into_response();
}
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) {
Ok(invite) => invite,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
if let Err(error) = mutate_registry(&server.dir, |doc| {
doc.members.push(RegistryMember {
id: id.clone(),
class: crate::config::CLASS_SOURCE.to_string(),
keys: Vec::new(),
enc_key: None,
});
Ok(())
}) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let registry_url = server.registry_url.clone().unwrap_or_default();
let ma_key = signed.doc.ma_key.clone();
(
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"token": invite.token,
"registry": registry_url,
"ma_key": ma_key,
"credentials": format!(
"id={id} token={} registry={registry_url} ma_key={ma_key}",
invite.token
),
})),
)
.into_response()
}
#[derive(Deserialize)]
struct EnrollRequest {
id: String,
token: String,
pubkey: String,
enc_key: Option<String>,
}
async fn registry_enroll(
State(server): State<std::sync::Arc<RegistryServer>>,
Json(request): Json<EnrollRequest>,
) -> Response {
let pubkey = match normalize_key(&request.pubkey) {
Ok(pubkey) => pubkey,
Err(error) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
let enc_key = match request.enc_key.as_deref().map(normalize_key).transpose() {
Ok(enc_key) => enc_key,
Err(error) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
};
if let Err(error) = registry::redeem_invite(&server.dir, &request.id, &request.token) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response();
}
let result = mutate_registry(&server.dir, |doc| {
let Some(member) = doc
.members
.iter_mut()
.find(|member| member.id == request.id)
else {
return Err(anyhow!("unknown member id {}", request.id));
};
if member.keys.iter().any(|entry| entry.key == pubkey) {
return Err(anyhow!("key already authorized"));
}
member.keys.push(KeyEntry {
key: pubkey.clone(),
not_before: now_ts(),
not_after: None,
});
if let Some(enc_key) = enc_key.clone() {
member.enc_key = Some(enc_key);
}
Ok(())
});
match result {
Ok(version) => (
StatusCode::OK,
Json(serde_json::json!({ "id": request.id, "version": version })),
)
.into_response(),
Err(error) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response(),
}
}
async fn registry_page() -> Response {
(
StatusCode::OK,
[("content-type", "text/html; charset=utf-8")],
REGISTRY_PAGE,
)
.into_response()
}
const REGISTRY_PAGE: &str = r#"<!doctype html>
<html><head><meta charset="utf-8"><title>FRX membership</title></head>
<body>
<h1>Request FRX membership</h1>
<form id="f">
<label>Organization or handle <input name="label" required pattern="[A-Za-z0-9-]+"></label><br>
<label>Signup code <input name="code" type="password"></label><br>
<button type="submit">Request membership</button>
</form>
<pre id="out" style="white-space:pre-wrap"></pre>
<script>
document.getElementById("f").onsubmit = async (e) => {
e.preventDefault();
const label = e.target.label.value, code = e.target.code.value;
const res = await fetch("/v1/signup", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({label, code})});
const body = await res.json();
document.getElementById("out").textContent = res.ok
? "Membership approved. Run `frxd --onboarding` and paste:\n\n" + body.credentials + "\n"
: "Failed: " + (body.error || res.status);
};
</script>
</body></html>
"#;
pub async fn status(config_path: &Path) -> Result<()> { pub async fn status(config_path: &Path) -> Result<()> {
let config = Config::load(config_path)?; let config = Config::load(config_path)?;
let base = format!("http://{}", config.node.listen); let base = format!("http://{}", config.node.listen);
+1
View File
@@ -7,6 +7,7 @@ pub mod index;
pub mod message; pub mod message;
pub mod net; pub mod net;
pub mod node; pub mod node;
pub mod onboard;
pub mod registry; pub mod registry;
pub mod relay; pub mod relay;
pub mod render; pub mod render;
+28 -6
View File
@@ -5,7 +5,7 @@ use clap::{Parser, Subcommand, ValueEnum};
use frxd::config::Config; use frxd::config::Config;
use frxd::crypto::Keypair; use frxd::crypto::Keypair;
use frxd::message::{EXPOSURE_FULL, EXPOSURE_METADATA}; use frxd::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
use frxd::{commands, node, relay}; use frxd::{commands, node, onboard, relay};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -16,8 +16,10 @@ use frxd::{commands, node, relay};
struct Cli { struct Cli {
#[arg(long, global = true, default_value = "frxd.toml")] #[arg(long, global = true, default_value = "frxd.toml")]
config: PathBuf, config: PathBuf,
#[arg(long)]
onboarding: bool,
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Option<Command>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -139,7 +141,10 @@ enum KeyCommand {
#[derive(Subcommand)] #[derive(Subcommand)]
enum RegistryCommand { enum RegistryCommand {
Init, Init {
#[arg(long, default_value = "frx.invalid")]
zone: String,
},
Add { Add {
id: String, id: String,
pubkey: String, pubkey: String,
@@ -180,6 +185,10 @@ enum RegistryCommand {
Serve { Serve {
#[arg(long, default_value = "127.0.0.1:7800")] #[arg(long, default_value = "127.0.0.1:7800")]
listen: String, listen: String,
#[arg(long)]
signup_code: Option<String>,
#[arg(long)]
registry_url: Option<String>,
}, },
} }
@@ -192,7 +201,16 @@ enum Exposure {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); 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 { Command::Init {
name, name,
listen, listen,
@@ -309,7 +327,7 @@ async fn main() -> Result<()> {
KeyCommand::Rotate => commands::key_rotate(&cli.config)?, KeyCommand::Rotate => commands::key_rotate(&cli.config)?,
}, },
Command::Registry { dir, command } => match command { Command::Registry { dir, command } => match command {
RegistryCommand::Init => commands::registry_init(&dir)?, RegistryCommand::Init { zone } => commands::registry_init(&dir, &zone)?,
RegistryCommand::Add { RegistryCommand::Add {
id, id,
pubkey, pubkey,
@@ -336,7 +354,11 @@ async fn main() -> Result<()> {
RegistryCommand::List => commands::registry_list(&dir)?, RegistryCommand::List => commands::registry_list(&dir)?,
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?, RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
RegistryCommand::Show => commands::registry_show(&dir)?, 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 { Command::Aggregates {
from, from,
+271
View File
@@ -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, &registry).await?
} else {
load_registry(Path::new(&registry))?
};
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(&registry));
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, &registry).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()
})
}
+68
View File
@@ -35,12 +35,79 @@ pub struct RegistryDoc {
pub version: u64, pub version: u64,
pub issued_at: u64, pub issued_at: u64,
pub ma_key: String, pub ma_key: String,
#[serde(default = "default_zone")]
pub zone: String,
#[serde(default)] #[serde(default)]
pub members: Vec<RegistryMember>, pub members: Vec<RegistryMember>,
#[serde(default)] #[serde(default)]
pub relays: Vec<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRegistry { pub struct SignedRegistry {
#[serde(flatten)] #[serde(flatten)]
@@ -254,6 +321,7 @@ mod tests {
version: 1, version: 1,
issued_at: now_ts(), issued_at: now_ts(),
ma_key: String::new(), ma_key: String::new(),
zone: "frx.invalid".to_string(),
members, members,
relays: Vec::new(), relays: Vec::new(),
}, },
+1
View File
@@ -38,6 +38,7 @@ fn save_registry(dir: &Path, ma: &Keypair, members: Vec<RegistryMember>) -> Path
version: 1, version: 1,
issued_at: now_ts(), issued_at: now_ts(),
ma_key: String::new(), ma_key: String::new(),
zone: "frx.invalid".to_string(),
members, members,
relays: Vec::new(), relays: Vec::new(),
}, },
+2 -2
View File
@@ -1,11 +1,10 @@
mod common; mod common;
use std::fs; use std::fs;
use std::path::Path;
use std::time::Duration; use std::time::Duration;
use common::{ use common::{
client, collection, config_for, messages_of_type, poll, poll_messages, publish, query_envelope, client, collection, config_for, messages_of_type, poll_messages, publish, query_envelope,
register, test_envelope, register, test_envelope,
}; };
use frxd::crypto::{Keypair, now_ts}; use frxd::crypto::{Keypair, now_ts};
@@ -99,6 +98,7 @@ fn registry_doc(
version, version,
issued_at: now_ts(), issued_at: now_ts(),
ma_key: String::new(), ma_key: String::new(),
zone: "frx.invalid".to_string(),
members, members,
relays, relays,
}, },
+142
View File
@@ -0,0 +1,142 @@
use std::path::Path;
use std::time::Duration;
use frxd::commands;
use frxd::config::Config;
use frxd::crypto::{Keypair, now_ts};
use frxd::onboard;
use frxd::registry::{self};
use serde_json::Value;
use tokio::net::TcpListener;
async fn spawn_registry_server(dir: &Path, signup_code: Option<&str>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}");
let router = commands::registry_router(
dir,
signup_code.map(str::to_string),
Some(format!("{base}/registry.json")),
);
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
base
}
fn setup_ma(root: &Path) -> std::path::PathBuf {
let dir = root.join("ma");
commands::registry_init(&dir, "frx.invalid").unwrap();
dir
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn signup_issues_invite_and_enroll_binds_key() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
let base = spawn_registry_server(&dir, Some("sesame")).await;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let rejected = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "alice", "code": "wrong"}))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
let response = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame"}))
.send()
.await
.unwrap();
assert!(response.status().is_success());
let body: Value = response.json().await.unwrap();
let id = body.get("id").and_then(Value::as_str).unwrap().to_string();
assert_eq!(id, "alice-dev.frx.invalid");
let token = body
.get("token")
.and_then(Value::as_str)
.unwrap()
.to_string();
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(
registry::authorized_keys(&signed, now_ts()).is_empty(),
"signup must not authorize a key before enrollment"
);
let key = Keypair::generate();
let response = http
.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id,
"token": token,
"pubkey": key.public_hex(),
}))
.send()
.await
.unwrap();
assert!(response.status().is_success());
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
let replayed = http
.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({
"id": id,
"token": token,
"pubkey": Keypair::generate().public_hex(),
}))
.send()
.await
.unwrap();
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wizard_enrolls_and_writes_config() {
let root = tempfile::tempdir().unwrap();
let dir = setup_ma(root.path());
let base = spawn_registry_server(&dir, Some("sesame")).await;
let http = reqwest::Client::new();
let body: Value = http
.post(format!("{base}/v1/signup"))
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let credentials = body.get("credentials").and_then(Value::as_str).unwrap();
let config_path = root.path().join("wizard.toml");
let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display());
let mut reader = input.as_bytes();
let mut output = Vec::new();
onboard::run(&mut reader, &mut output, &config_path)
.await
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("enrolled"), "{text}");
let config = Config::load(&config_path).unwrap();
assert_eq!(config.node.id.as_deref(), Some("wizard-test.frx.invalid"));
assert_eq!(
config.node.registry.as_deref(),
Some(format!("{base}/registry.json").as_str())
);
let key = config.load_key().unwrap();
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
assert!(
registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()),
"wizard did not bind the key"
);
}
+1
View File
@@ -41,6 +41,7 @@ fn ma_registry(ma: &Keypair, members: Vec<RegistryMember>, version: u64) -> Sign
version, version,
issued_at: now_ts(), issued_at: now_ts(),
ma_key: String::new(), ma_key: String::new(),
zone: "frx.invalid".to_string(),
members, members,
relays: Vec::new(), relays: Vec::new(),
}, },