357 lines
11 KiB
Rust
357 lines
11 KiB
Rust
mod common;
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use common::{
|
|
client, collection, config_for, poll_messages, publish, query_envelope, register, spawn_relay,
|
|
};
|
|
use frxd::crypto::{Keypair, now_ts};
|
|
use frxd::index::LocalIndex;
|
|
use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE};
|
|
use frxd::node::{Node, NodeHandle};
|
|
use frxd::registry::{self, KeyEntry, RegistryDoc, RegistryMember, SignedRegistry};
|
|
use serde_json::Value;
|
|
|
|
fn member_entry(
|
|
id: &str,
|
|
key: &Keypair,
|
|
not_before: u64,
|
|
not_after: Option<u64>,
|
|
) -> RegistryMember {
|
|
RegistryMember {
|
|
id: id.to_string(),
|
|
keys: vec![KeyEntry {
|
|
key: key.public_hex(),
|
|
not_before,
|
|
not_after,
|
|
}],
|
|
enc_key: None,
|
|
}
|
|
}
|
|
|
|
fn ma_registry(ma: &Keypair, members: Vec<RegistryMember>, version: u64) -> SignedRegistry {
|
|
registry::sign_registry(
|
|
RegistryDoc {
|
|
version,
|
|
issued_at: now_ts(),
|
|
ma_key: String::new(),
|
|
zone: "frx.invalid".to_string(),
|
|
members,
|
|
relays: Vec::new(),
|
|
},
|
|
ma,
|
|
)
|
|
}
|
|
|
|
fn config_with_registry(
|
|
dir: &Path,
|
|
name: &str,
|
|
relay_url: &str,
|
|
registry: &str,
|
|
ma_key: &str,
|
|
) -> frxd::config::Config {
|
|
let mut config = config_for(dir, name, relay_url);
|
|
config.node.registry = Some(registry.to_string());
|
|
config.node.ma_key = Some(ma_key.to_string());
|
|
config.node.dev_bootstrap = false;
|
|
config
|
|
}
|
|
|
|
async fn bob_with_docs(root: &Path, relay_url: &str, registry: &str, ma_key: &str) -> NodeHandle {
|
|
let docs = root.join("bob-docs");
|
|
fs::create_dir_all(&docs).unwrap();
|
|
fs::write(docs.join("doc.txt"), "registry rust document").unwrap();
|
|
let config = config_with_registry(&root.join("bob"), "bob", relay_url, registry, ma_key);
|
|
{
|
|
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
|
index
|
|
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
|
.unwrap();
|
|
}
|
|
Node::start(config).await.unwrap()
|
|
}
|
|
|
|
async fn responses_for(
|
|
http: &reqwest::Client,
|
|
relay_url: &str,
|
|
asker: &Keypair,
|
|
identifier: &str,
|
|
text: &str,
|
|
timeout_ms: u64,
|
|
) -> Vec<Value> {
|
|
register(http, relay_url, asker).await;
|
|
let envelope = query_envelope(asker, identifier, text, 5);
|
|
assert!(
|
|
publish(http, relay_url, &envelope)
|
|
.await
|
|
.status()
|
|
.is_success()
|
|
);
|
|
let start = Instant::now();
|
|
let deadline = Duration::from_millis(timeout_ms);
|
|
let mut responses = Vec::new();
|
|
while start.elapsed() < deadline {
|
|
let messages = poll_messages(http, relay_url, asker, 200).await;
|
|
responses.extend(
|
|
messages
|
|
.iter()
|
|
.filter(|m| m.get("type").and_then(Value::as_str) == Some(TYPE_RESPONSE))
|
|
.cloned(),
|
|
);
|
|
if !responses.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
responses
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn registry_gates_membership_and_revocation_propagates() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relay_url = spawn_relay().await;
|
|
let ma = Keypair::generate();
|
|
let alice = Keypair::generate();
|
|
let registry_path = root.path().join("registry.json");
|
|
registry::save_registry(
|
|
®istry_path,
|
|
&ma_registry(
|
|
&ma,
|
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
|
1,
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let _bob = bob_with_docs(
|
|
root.path(),
|
|
&relay_url,
|
|
®istry_path.display().to_string(),
|
|
&ma.public_hex(),
|
|
)
|
|
.await;
|
|
let http = client();
|
|
|
|
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 700).await;
|
|
assert_eq!(accepted.len(), 1, "listed member got no response");
|
|
|
|
let stranger = Keypair::generate();
|
|
let denied = responses_for(
|
|
&http,
|
|
&relay_url,
|
|
&stranger,
|
|
&stranger.public_hex(),
|
|
"rust",
|
|
400,
|
|
)
|
|
.await;
|
|
assert!(denied.is_empty(), "unlisted key was answered");
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
registry::save_registry(®istry_path, &ma_registry(&ma, Vec::new(), 2)).unwrap();
|
|
let revoked = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await;
|
|
assert!(revoked.is_empty(), "revoked member still answered");
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
registry::save_registry(
|
|
®istry_path,
|
|
&ma_registry(
|
|
&ma,
|
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
|
1,
|
|
),
|
|
)
|
|
.unwrap();
|
|
let rolled_back =
|
|
responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await;
|
|
assert!(
|
|
rolled_back.is_empty(),
|
|
"registry rollback re-admitted a revoked member"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn forged_or_wrong_key_snapshot_closes_the_registry() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relay_url = spawn_relay().await;
|
|
let ma = Keypair::generate();
|
|
let attacker = Keypair::generate();
|
|
let alice = Keypair::generate();
|
|
let registry_path = root.path().join("registry.json");
|
|
registry::save_registry(
|
|
®istry_path,
|
|
&ma_registry(
|
|
&attacker,
|
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
|
1,
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let _bob = bob_with_docs(
|
|
root.path(),
|
|
&relay_url,
|
|
®istry_path.display().to_string(),
|
|
&ma.public_hex(),
|
|
)
|
|
.await;
|
|
let http = client();
|
|
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
|
|
assert!(
|
|
denied.is_empty(),
|
|
"snapshot signed by wrong key was trusted"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn expired_key_is_not_authorized() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relay_url = spawn_relay().await;
|
|
let ma = Keypair::generate();
|
|
let alice = Keypair::generate();
|
|
let registry_path = root.path().join("registry.json");
|
|
registry::save_registry(
|
|
®istry_path,
|
|
&ma_registry(
|
|
&ma,
|
|
vec![member_entry(
|
|
"alice.frx.example",
|
|
&alice,
|
|
0,
|
|
Some(now_ts().saturating_sub(1)),
|
|
)],
|
|
1,
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let _bob = bob_with_docs(
|
|
root.path(),
|
|
&relay_url,
|
|
®istry_path.display().to_string(),
|
|
&ma.public_hex(),
|
|
)
|
|
.await;
|
|
let http = client();
|
|
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
|
|
assert!(denied.is_empty(), "expired key was answered");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn fail_static_uses_last_validated_snapshot() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relay_url = spawn_relay().await;
|
|
let ma = Keypair::generate();
|
|
let alice = Keypair::generate();
|
|
let snapshot = ma_registry(
|
|
&ma,
|
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
|
1,
|
|
);
|
|
|
|
let cached_dir = root.path().join("bob-cached");
|
|
let config = config_with_registry(
|
|
&cached_dir,
|
|
"bob",
|
|
&relay_url,
|
|
"http://127.0.0.1:1/registry.json",
|
|
&ma.public_hex(),
|
|
);
|
|
fs::create_dir_all(config.data_dir()).unwrap();
|
|
registry::save_registry(&config.registry_cache_path(), &snapshot).unwrap();
|
|
let docs = root.path().join("bob-docs");
|
|
fs::create_dir_all(&docs).unwrap();
|
|
fs::write(docs.join("doc.txt"), "registry rust document").unwrap();
|
|
{
|
|
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
|
index
|
|
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
|
.unwrap();
|
|
}
|
|
let _bob = Node::start(config.clone()).await.unwrap();
|
|
|
|
let http = client();
|
|
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 700).await;
|
|
assert_eq!(
|
|
accepted.len(),
|
|
1,
|
|
"cached snapshot should keep last-known-good membership"
|
|
);
|
|
drop(_bob);
|
|
|
|
let fresh_dir = root.path().join("bob-fresh");
|
|
let fresh = config_with_registry(
|
|
&fresh_dir,
|
|
"bob2",
|
|
&relay_url,
|
|
"http://127.0.0.1:1/registry.json",
|
|
&ma.public_hex(),
|
|
);
|
|
{
|
|
let index = LocalIndex::open(&fresh.index_dir()).unwrap();
|
|
index
|
|
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
|
.unwrap();
|
|
}
|
|
let _bob2 = Node::start(fresh).await.unwrap();
|
|
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
|
|
assert!(
|
|
denied.is_empty(),
|
|
"unreachable registry without cache must close, not open"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn registry_can_be_served_over_http() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let relay_url = spawn_relay().await;
|
|
let ma = Keypair::generate();
|
|
let alice = Keypair::generate();
|
|
let registry_dir = root.path().join("ma");
|
|
fs::create_dir_all(®istry_dir).unwrap();
|
|
registry::save_registry(
|
|
®istry_dir.join("registry.json"),
|
|
&ma_registry(
|
|
&ma,
|
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
|
1,
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let state: PathBuf = registry_dir.clone();
|
|
let handler_state = state.clone();
|
|
let app = Router::new()
|
|
.route(
|
|
"/registry.json",
|
|
get(move || {
|
|
let state = handler_state.clone();
|
|
async move {
|
|
Json(
|
|
registry::load_registry(&state.join("registry.json"))
|
|
.expect("registry file"),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.with_state(state);
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
let registry_url = format!("http://{addr}/registry.json");
|
|
|
|
let _bob = bob_with_docs(root.path(), &relay_url, ®istry_url, &ma.public_hex()).await;
|
|
let http = client();
|
|
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 900).await;
|
|
assert_eq!(
|
|
accepted.len(),
|
|
1,
|
|
"http-served registry did not authorize member"
|
|
);
|
|
}
|