floonet-rs: hardened nostr-rs-relay for the Grin community
Test and build / test_floonet-rs (push) Has been cancelled

nostr-rs-relay + a default-deny admission pipeline (kinds 0,3,5,13,1059,
10002,10050,27235 only), NIP-42 auth, neutral NIP-11, a built-in name
authority (paid names via GoblinPay), and a config-toggled co-located
mixnet exit supervisor. Single binary + installer + hardened systemd, or
Docker Compose. Relay core untouched (additive admission + authority).
This commit is contained in:
Goblin
2026-07-02 08:22:18 -04:00
commit 9fa97ebb5c
74 changed files with 30110 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
#[cfg(test)]
mod tests {
use floonet_rs::cli::CLIArgs;
#[test]
fn cli_tests() {
use clap::CommandFactory;
CLIArgs::command().debug_assert();
}
}
+115
View File
@@ -0,0 +1,115 @@
use anyhow::{anyhow, Result};
use floonet_rs::config;
use floonet_rs::server::start_server;
//use http::{Request, Response};
use hyper::{Client, StatusCode, Uri};
use std::net::TcpListener;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::mpsc as syncmpsc;
use std::sync::mpsc::{Receiver as MpscReceiver, Sender as MpscSender};
use std::thread;
use std::thread::JoinHandle;
use std::time::Duration;
use tracing::{debug, info};
pub struct Relay {
pub port: u16,
pub handle: JoinHandle<()>,
pub shutdown_tx: MpscSender<()>,
}
pub fn start_relay() -> Result<Relay> {
start_relay_with(|_| {})
}
/// Start a relay, letting the caller adjust settings after the defaults
/// (port and loopback bind are already set; the closure may read
/// `settings.network.port`).
pub fn start_relay_with(configure: impl FnOnce(&mut config::Settings)) -> Result<Relay> {
// setup tracing
let _trace_sub = tracing_subscriber::fmt::try_init();
info!("Starting a new relay");
// replace default settings
let mut settings = config::Settings::default();
// identify open port
info!("Checking for address...");
let port = get_available_port().unwrap();
info!("Found open port: {}", port);
// bind to local interface only
settings.network.address = "127.0.0.1".to_owned();
settings.network.port = port;
// create an in-memory DB with multiple readers
settings.database.in_memory = true;
settings.database.min_conn = 4;
settings.database.max_conn = 8;
configure(&mut settings);
let (shutdown_tx, shutdown_rx): (MpscSender<()>, MpscReceiver<()>) = syncmpsc::channel();
let handle = thread::spawn(move || {
// server will block the thread it is run on.
let _ = start_server(&settings, shutdown_rx);
});
// how do we know the relay has finished starting up?
Ok(Relay {
port,
handle,
shutdown_tx,
})
}
// check if the server is healthy via HTTP request
async fn server_ready(relay: &Relay) -> Result<bool> {
let uri: String = format!("http://127.0.0.1:{}/", relay.port);
let client = Client::new();
let uri: Uri = uri.parse().unwrap();
let res = client.get(uri).await?;
Ok(res.status() == StatusCode::OK)
}
pub async fn wait_for_healthy_relay(relay: &Relay) -> Result<()> {
// TODO: maximum time to wait for server to become healthy.
// give it a little time to start up before we start polling
tokio::time::sleep(Duration::from_millis(10)).await;
loop {
let server_check = server_ready(relay).await;
match server_check {
Ok(true) => {
// server responded with 200-OK.
break;
}
Ok(false) => {
// server responded with an error, we're done.
return Err(anyhow!("Got non-200-OK from relay"));
}
Err(_) => {
// server is not yet ready, probably connection refused...
debug!("Relay not ready, will try again...");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
info!("relay is ready");
Ok(())
// simple message sent to web browsers
//let mut request = Request::builder()
// .uri("https://www.rust-lang.org/")
// .header("User-Agent", "my-awesome-agent/1.0");
}
// from https://elliotekj.com/posts/2017/07/25/find-available-tcp-port-rust/
// This needed some modification; if multiple tasks all ask for open ports, they will tend to get the same one.
// instead we should try to try these incrementally/globally.
static PORT_COUNTER: AtomicU16 = AtomicU16::new(4030);
fn get_available_port() -> Option<u16> {
let startsearch = PORT_COUNTER.fetch_add(10, Ordering::SeqCst);
if startsearch >= 20000 {
// wrap around
PORT_COUNTER.store(4030, Ordering::Relaxed);
}
(startsearch..20000).find(|port| port_is_available(*port))
}
pub fn port_is_available(port: u16) -> bool {
info!("checking on port {}", port);
TcpListener::bind(("127.0.0.1", port)).is_ok()
}
+356
View File
@@ -0,0 +1,356 @@
#[cfg(test)]
mod tests {
use bitcoin_hashes::hex::ToHex;
use bitcoin_hashes::sha256;
use bitcoin_hashes::Hash;
use secp256k1::rand;
use secp256k1::{KeyPair, Secp256k1, XOnlyPublicKey};
use floonet_rs::conn::ClientConn;
use floonet_rs::error::Error;
use floonet_rs::event::Event;
use floonet_rs::utils::unix_time;
const RELAY: &str = "wss://nostr.example.com/";
#[test]
fn test_generate_auth_challenge() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let last_auth_challenge = client_conn.auth_challenge().cloned();
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_ne!(
client_conn.auth_challenge().unwrap(),
&last_auth_challenge.unwrap()
);
assert_eq!(client_conn.auth_pubkey(), None);
}
#[test]
fn test_authenticate_with_valid_event() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event(challenge);
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Ok(())));
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey));
}
#[test]
fn test_fail_to_authenticate_in_invalid_state() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let event = auth_event(&"challenge".into());
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_authenticate_when_already_authenticated() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap().clone();
let event = auth_event(&challenge);
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Ok(())));
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey));
let event1 = auth_event(&challenge);
let result1 = client_conn.authenticate(&event1, RELAY);
assert!(matches!(result1, Ok(())));
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey));
assert_ne!(client_conn.auth_pubkey(), Some(&event1.pubkey));
}
#[test]
fn test_fail_to_authenticate_with_invalid_event() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let mut event = auth_event(challenge);
event.sig = event.sig.chars().rev().collect::<String>();
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_with_invalid_event_kind() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event_with_kind(challenge, 9999999999999999);
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_with_expired_timestamp() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event_with_created_at(challenge, unix_time() - 1200); // 20 minutes
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_with_future_timestamp() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event_with_created_at(challenge, unix_time() + 1200); // 20 minutes
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_without_tags() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let event = auth_event_without_tags();
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_without_challenge() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let event = auth_event_without_challenge();
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_without_relay() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event_without_relay(challenge);
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_with_invalid_challenge() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let event = auth_event(&"invalid challenge".into());
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
#[test]
fn test_fail_to_authenticate_with_invalid_relay() {
let mut client_conn = ClientConn::new("127.0.0.1".into());
assert_eq!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
client_conn.generate_auth_challenge();
assert_ne!(client_conn.auth_challenge(), None);
assert_eq!(client_conn.auth_pubkey(), None);
let challenge = client_conn.auth_challenge().unwrap();
let event = auth_event_with_relay(challenge, &"xyz".into());
let result = client_conn.authenticate(&event, RELAY);
assert!(matches!(result, Err(Error::AuthFailure)));
}
fn auth_event(challenge: &String) -> Event {
create_auth_event(Some(challenge), Some(&RELAY.into()), 22242, unix_time())
}
fn auth_event_with_kind(challenge: &String, kind: u64) -> Event {
create_auth_event(Some(challenge), Some(&RELAY.into()), kind, unix_time())
}
fn auth_event_with_created_at(challenge: &String, created_at: u64) -> Event {
create_auth_event(Some(challenge), Some(&RELAY.into()), 22242, created_at)
}
fn auth_event_without_challenge() -> Event {
create_auth_event(None, Some(&RELAY.into()), 22242, unix_time())
}
fn auth_event_without_relay(challenge: &String) -> Event {
create_auth_event(Some(challenge), None, 22242, unix_time())
}
fn auth_event_without_tags() -> Event {
create_auth_event(None, None, 22242, unix_time())
}
fn auth_event_with_relay(challenge: &String, relay: &String) -> Event {
create_auth_event(Some(challenge), Some(relay), 22242, unix_time())
}
fn create_auth_event(
challenge: Option<&String>,
relay: Option<&String>,
kind: u64,
created_at: u64,
) -> Event {
let secp = Secp256k1::new();
let key_pair = KeyPair::new(&secp, &mut rand::thread_rng());
let public_key = XOnlyPublicKey::from_keypair(&key_pair);
let mut tags: Vec<Vec<String>> = vec![];
if let Some(c) = challenge {
let tag = vec!["challenge".into(), c.into()];
tags.push(tag);
}
if let Some(r) = relay {
let tag = vec!["relay".into(), r.into()];
tags.push(tag);
}
let mut event = Event {
id: "0".to_owned(),
pubkey: public_key.to_hex(),
delegated_by: None,
created_at,
kind,
tags,
content: "".to_owned(),
sig: "0".to_owned(),
tagidx: None,
};
let c = event.to_canonical().unwrap();
let digest: sha256::Hash = sha256::Hash::hash(c.as_bytes());
let msg = secp256k1::Message::from_slice(digest.as_ref()).unwrap();
let sig = secp.sign_schnorr(&msg, &key_pair);
event.id = format!("{digest:x}");
event.sig = sig.to_hex();
event
}
}
+80
View File
@@ -0,0 +1,80 @@
use anyhow::Result;
use futures::SinkExt;
use futures::StreamExt;
use std::thread;
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tracing::info;
mod common;
#[tokio::test]
async fn start_and_stop() -> Result<()> {
// this will be the common pattern for acquiring a new relay:
// start a fresh relay, on a port to-be-provided back to us:
let relay = common::start_relay()?;
// wait for the relay's webserver to start up and deliver a page:
common::wait_for_healthy_relay(&relay).await?;
let port = relay.port;
// just make sure we can startup and shut down.
// if we send a shutdown message before the server is listening,
// we will get a SendError. Keep sending until someone is
// listening.
loop {
let shutdown_res = relay.shutdown_tx.send(());
match shutdown_res {
Ok(()) => {
break;
}
Err(_) => {
thread::sleep(Duration::from_millis(100));
}
}
}
// wait for relay to shutdown
let thread_join = relay.handle.join();
assert!(thread_join.is_ok());
// assert that port is now available.
assert!(common::port_is_available(port));
Ok(())
}
#[tokio::test]
async fn relay_home_page() -> Result<()> {
// get a relay and wait for startup...
let relay = common::start_relay()?;
common::wait_for_healthy_relay(&relay).await?;
// tell relay to shutdown
let _res = relay.shutdown_tx.send(());
Ok(())
}
//#[tokio::test]
// Still inwork
#[allow(dead_code)]
async fn publish_test() -> Result<()> {
// get a relay and wait for startup
let relay = common::start_relay()?;
common::wait_for_healthy_relay(&relay).await?;
// open a non-secure websocket connection.
let (mut ws, _res) = connect_async(format!("ws://localhost:{}", relay.port)).await?;
// send a simple pre-made message
let simple_event = r#"["EVENT", {"content": "hello world","created_at": 1691239763,
"id":"f3ce6798d70e358213ebbeba4886bbdfacf1ecfd4f65ee5323ef5f404de32b86",
"kind": 1,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"sig": "30ca29e8581eeee75bf838171dec818af5e6de2b74f5337de940f5cc91186534c0b20d6cf7ad1043a2c51dbd60b979447720a471d346322103c83f6cb66e4e98",
"tags": []}]"#;
ws.send(simple_event.into()).await?;
// get response from server, confirm it is an array with first element "OK"
let event_confirm = ws.next().await;
ws.close(None).await?;
info!("event confirmed: {:?}", event_confirm);
// open a new connection, and wait for some time to get the event.
let (mut sub_ws, _res) = connect_async(format!("ws://localhost:{}", relay.port)).await?;
let event_sub = r#"["REQ", "simple", {}]"#;
sub_ws.send(event_sub.into()).await?;
// read from subscription
let _ws_next = sub_ws.next().await;
let _res = relay.shutdown_tx.send(());
Ok(())
}
+377
View File
@@ -0,0 +1,377 @@
//! End-to-end tests for the built-in name authority: NIP-98 registration
//! round trip, NIP-05 resolution, reverse lookup, one-name-per-key,
//! reserved names, release + cooldown, plus the paid-name flow against a
//! fake GoblinPay server (402 until the invoice reports paid). Also
//! verifies the NIP-11 document stays payment-free.
use anyhow::Result;
use base64::Engine;
use bitcoin_hashes::{sha256, Hash};
use floonet_rs::event::Event;
use floonet_rs::utils::unix_time;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Client, Request, Server, StatusCode};
use secp256k1::{KeyPair, Secp256k1, XOnlyPublicKey};
use serde_json::{json, Value};
use std::convert::Infallible;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
mod common;
/// A test identity that can sign NIP-98 auth events.
struct Signer {
secp: Secp256k1<secp256k1::All>,
keypair: KeyPair,
pub pubkey_hex: String,
}
impl Signer {
fn new(seed: u8) -> Signer {
let secp = Secp256k1::new();
let keypair = KeyPair::from_seckey_slice(&secp, &[seed; 32]).unwrap();
let pubkey_hex = XOnlyPublicKey::from_keypair(&keypair).to_string();
Signer {
secp,
keypair,
pubkey_hex,
}
}
/// `Authorization: Nostr <b64>` header for method+url over body.
fn nip98(&self, url: &str, method: &str, body: &[u8]) -> String {
let mut tags: Vec<Vec<String>> = vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), method.to_string()],
// A nonce keeps every auth event id unique, so back-to-back
// requests in the same second are not misread as replays.
vec!["nonce".to_string(), format!("{:x}", rand_u64())],
];
if !body.is_empty() {
let digest: sha256::Hash = sha256::Hash::hash(body);
tags.push(vec!["payload".to_string(), format!("{digest:x}")]);
}
let mut event = Event {
id: "0".to_owned(),
pubkey: self.pubkey_hex.clone(),
delegated_by: None,
created_at: unix_time(),
kind: 27235,
tags,
content: String::new(),
sig: "0".to_owned(),
tagidx: None,
};
let canonical = event.to_canonical().unwrap();
let digest: sha256::Hash = sha256::Hash::hash(canonical.as_bytes());
let msg = secp256k1::Message::from_slice(digest.as_ref()).unwrap();
event.id = format!("{digest:x}");
event.sig = self.secp.sign_schnorr(&msg, &self.keypair).to_string();
let json = serde_json::to_string(&event).unwrap();
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(json)
)
}
}
fn rand_u64() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
// Cheap uniqueness for test nonces.
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as u64
}
async fn get_json(url: &str) -> Result<(StatusCode, Value)> {
let client = Client::new();
let res = client.get(url.parse()?).await?;
let status = res.status();
let bytes = hyper::body::to_bytes(res.into_body()).await?;
Ok((status, serde_json::from_slice(&bytes)?))
}
async fn register(
base: &str,
signer: Option<&Signer>,
name: &str,
pubkey: &str,
) -> Result<(StatusCode, Value)> {
let body = json!({"name": name, "pubkey": pubkey}).to_string();
let mut builder = Request::builder()
.method("POST")
.uri(format!("{base}/api/v1/register"))
.header("Content-Type", "application/json");
if let Some(signer) = signer {
builder = builder.header(
"Authorization",
signer.nip98(&format!("{base}/api/v1/register"), "POST", body.as_bytes()),
);
}
let res = Client::new()
.request(builder.body(Body::from(body))?)
.await?;
let status = res.status();
let bytes = hyper::body::to_bytes(res.into_body()).await?;
Ok((status, serde_json::from_slice(&bytes)?))
}
async fn unregister(base: &str, signer: &Signer, name: &str) -> Result<(StatusCode, Value)> {
let url = format!("{base}/api/v1/register/{name}");
let req = Request::builder()
.method("DELETE")
.uri(&url)
.header("Authorization", signer.nip98(&url, "DELETE", &[]))
.body(Body::empty())?;
let res = Client::new().request(req).await?;
let status = res.status();
let bytes = hyper::body::to_bytes(res.into_body()).await?;
Ok((status, serde_json::from_slice(&bytes)?))
}
/// Fresh file-backed data directory (exercises the v19 migration).
fn temp_data_dir(tag: &str) -> String {
let dir = std::env::temp_dir().join(format!("floonet-rs-test-{tag}-{}", rand_u64()));
std::fs::create_dir_all(&dir).unwrap();
dir.to_string_lossy().into_owned()
}
fn authority_relay(data_dir: &str) -> Result<common::Relay> {
let data_dir = data_dir.to_owned();
common::start_relay_with(move |settings| {
settings.database.in_memory = false;
settings.database.data_directory = data_dir;
settings.name_authority.enabled = true;
settings.name_authority.domain = "names.example".to_owned();
settings.name_authority.base_url = format!("http://127.0.0.1:{}", settings.network.port);
settings.name_authority.name_change_cooldown_secs = 600;
})
}
#[tokio::test]
async fn name_authority_round_trip() -> Result<()> {
let data_dir = temp_data_dir("authority");
let relay = authority_relay(&data_dir)?;
common::wait_for_healthy_relay(&relay).await?;
let base = format!("http://127.0.0.1:{}", relay.port);
let alice = Signer::new(11);
let bob = Signer::new(22);
// Health.
let res = Client::new()
.get(format!("{base}/api/v1/health").parse()?)
.await?;
assert_eq!(res.status(), StatusCode::OK);
// Availability before any claim.
let (status, body) = get_json(&format!("{base}/api/v1/name/ada")).await?;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["available"], json!(true));
// Unauthenticated register is refused.
let (status, _) = register(&base, None, "ada", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::UNAUTHORIZED);
// NIP-98 authenticated register succeeds.
let (status, body) = register(&base, Some(&alice), "ada", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["nip05"], json!("ada@names.example"));
// NIP-05 resolution.
let (status, body) =
get_json(&format!("{base}/.well-known/nostr.json?name=ada")).await?;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["names"]["ada"], json!(alice.pubkey_hex));
// Reverse lookup.
let (status, body) =
get_json(&format!("{base}/api/v1/by-pubkey/{}", alice.pubkey_hex)).await?;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["name"], json!("ada"));
// Same name, different key: conflict.
let (status, _) = register(&base, Some(&bob), "ada", &bob.pubkey_hex).await?;
assert_eq!(status, StatusCode::CONFLICT);
// One active name per key.
let (status, body) = register(&base, Some(&alice), "ada2", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
// Reserved and look-alike names are refused.
let (status, _) = register(&base, Some(&bob), "admin", &bob.pubkey_hex).await?;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _) = register(&base, Some(&bob), "supp0rt", &bob.pubkey_hex).await?;
assert_eq!(status, StatusCode::FORBIDDEN);
// The operator's own domain label is reserved too.
let (status, _) = register(&base, Some(&bob), "names", &bob.pubkey_hex).await?;
assert_eq!(status, StatusCode::FORBIDDEN);
// Release, then the release-armed cooldown blocks a fresh claim.
let (status, body) = unregister(&base, &alice, "ada").await?;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = register(&base, Some(&alice), "lovelace", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{body}");
assert_eq!(body["error"], json!("name_change_cooldown"));
// The released name resolves to nobody.
let (_, body) = get_json(&format!("{base}/.well-known/nostr.json?name=ada")).await?;
assert_eq!(body["names"], json!({}));
let _res = relay.shutdown_tx.send(());
std::fs::remove_dir_all(&data_dir).ok();
Ok(())
}
#[tokio::test]
async fn nip11_and_landing_are_payment_free() -> Result<()> {
let relay = common::start_relay()?;
common::wait_for_healthy_relay(&relay).await?;
let base = format!("http://127.0.0.1:{}", relay.port);
// NIP-11 document: neutral Floonet identity, zero payment wording.
let req = Request::builder()
.method("GET")
.uri(&base)
.header("Accept", "application/nostr+json")
.body(Body::empty())?;
let res = Client::new().request(req).await?;
assert_eq!(res.status(), StatusCode::OK);
let bytes = hyper::body::to_bytes(res.into_body()).await?;
let text = String::from_utf8(bytes.to_vec())?;
let info: Value = serde_json::from_str(&text)?;
assert_eq!(info["name"], json!("floonet-rs-relay"));
for banned in ["payment", "fees", "sats", "msats", "invoice", "slatepack"] {
assert!(
!text.to_lowercase().contains(banned),
"NIP-11 must not mention `{banned}`: {text}"
);
}
// Landing page shows the Floonet branding and references the logo.
let res = Client::new().get(base.parse()?).await?;
let bytes = hyper::body::to_bytes(res.into_body()).await?;
let html = String::from_utf8(bytes.to_vec())?;
assert!(html.contains("/logo.svg"), "landing must show the logo");
assert!(
!html.to_lowercase().contains("payment"),
"landing must not mention payments"
);
// The logo itself is served.
let res = Client::new().get(format!("{base}/logo.svg").parse()?).await?;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get("content-type").unwrap(),
"image/svg+xml"
);
let _res = relay.shutdown_tx.send(());
Ok(())
}
/// Minimal fake GoblinPay: POST /invoice and GET /invoice/{id}, with a
/// shared "paid" flag the test flips.
async fn fake_goblinpay(paid: Arc<AtomicBool>) -> Result<String> {
let make_svc = make_service_fn(move |_conn| {
let paid = paid.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let paid = paid.clone();
async move {
let authed = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
== Some("Bearer test-gp-token");
let status = if paid.load(Ordering::SeqCst) {
"paid"
} else {
"open"
};
let response = if !authed {
hyper::Response::builder()
.status(401)
.body(Body::from(r#"{"error":"unauthorized"}"#))
.unwrap()
} else {
hyper::Response::builder()
.status(200)
.header("Content-Type", "application/json")
.body(Body::from(
json!({
"invoice_id": "inv-test-1",
"token": "tok1",
"pay_url": "http://pay.invalid/pay/tok1",
"status": status,
})
.to_string(),
))
.unwrap()
};
Ok::<_, Infallible>(response)
}
}))
}
});
let server = Server::bind(&"127.0.0.1:0".parse()?).serve(make_svc);
let addr = server.local_addr();
tokio::spawn(async move {
let _ = server.await;
});
Ok(format!("http://{addr}"))
}
#[tokio::test]
async fn paid_names_require_confirmed_goblinpay_payment() -> Result<()> {
let paid = Arc::new(AtomicBool::new(false));
let gp_url = fake_goblinpay(paid.clone()).await?;
let data_dir = temp_data_dir("paid");
let relay = {
let data_dir = data_dir.clone();
common::start_relay_with(move |settings| {
settings.database.in_memory = false;
settings.database.data_directory = data_dir;
settings.name_authority.enabled = true;
settings.name_authority.domain = "names.example".to_owned();
settings.name_authority.base_url =
format!("http://127.0.0.1:{}", settings.network.port);
settings.goblinpay.pay_mode = "name".to_owned();
settings.goblinpay.url = gp_url;
settings.goblinpay.api_token = "test-gp-token".to_owned();
settings.goblinpay.name_price_grin = 2.5;
})?
};
common::wait_for_healthy_relay(&relay).await?;
let base = format!("http://127.0.0.1:{}", relay.port);
let alice = Signer::new(33);
// Unpaid: register answers 402 with the GoblinPay pay page.
let (status, body) = register(&base, Some(&alice), "ada", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED, "{body}");
assert_eq!(body["error"], json!("payment_required"));
assert_eq!(body["pay_url"], json!("http://pay.invalid/pay/tok1"));
assert_eq!(body["invoice_id"], json!("inv-test-1"));
assert_eq!(body["price_grin"], json!(2.5));
assert_eq!(body["price_nanogrin"], json!(2_500_000_000u64));
// Still unpaid on retry: the same outstanding invoice comes back.
let (status, body) = register(&base, Some(&alice), "ada", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED, "{body}");
assert_eq!(body["invoice_id"], json!("inv-test-1"));
// Payment confirms on chain (GoblinPay now reports paid): claim works.
paid.store(true, Ordering::SeqCst);
let (status, body) = register(&base, Some(&alice), "ada", &alice.pubkey_hex).await?;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["nip05"], json!("ada@names.example"));
// And the name resolves.
let (_, body) = get_json(&format!("{base}/.well-known/nostr.json?name=ada")).await?;
assert_eq!(body["names"]["ada"], json!(alice.pubkey_hex));
let _res = relay.shutdown_tx.send(());
std::fs::remove_dir_all(&data_dir).ok();
Ok(())
}
+103
View File
@@ -0,0 +1,103 @@
//! End-to-end tests for the Floonet default-deny kind whitelist: a real
//! relay, a real websocket, real signed events. An allowed kind gets
//! OK=true; a disallowed kind gets OK=false with a `blocked:` reason.
use anyhow::Result;
use bitcoin_hashes::{sha256, Hash};
use floonet_rs::event::Event;
use floonet_rs::utils::unix_time;
use futures::SinkExt;
use futures::StreamExt;
use secp256k1::{rand, KeyPair, Secp256k1, XOnlyPublicKey};
use serde_json::Value;
mod common;
/// Build a signed event of `kind` and return (event_json, event_id).
fn signed_event(kind: u64, content: &str) -> (String, String) {
let secp = Secp256k1::new();
let key_pair = KeyPair::new(&secp, &mut rand::thread_rng());
let public_key = XOnlyPublicKey::from_keypair(&key_pair);
let mut event = Event {
id: "0".to_owned(),
pubkey: public_key.to_string(),
delegated_by: None,
created_at: unix_time(),
kind,
tags: vec![],
content: content.to_owned(),
sig: "0".to_owned(),
tagidx: None,
};
let canonical = event.to_canonical().unwrap();
let digest: sha256::Hash = sha256::Hash::hash(canonical.as_bytes());
let msg = secp256k1::Message::from_slice(digest.as_ref()).unwrap();
let sig = secp.sign_schnorr(&msg, &key_pair);
event.id = format!("{digest:x}");
event.sig = sig.to_string();
let json = serde_json::to_string(&event).unwrap();
(format!(r#"["EVENT",{json}]"#), event.id)
}
/// Publish a message and return the relay's OK frame for that event id.
async fn publish_and_get_ok(port: u16, msg: &str, event_id: &str) -> Result<Value> {
let (mut ws, _res) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}")).await?;
ws.send(msg.into()).await?;
// Read frames until the OK for our event id shows up.
while let Some(frame) = ws.next().await {
let frame = frame?;
if let Ok(text) = frame.into_text() {
if let Ok(value) = serde_json::from_str::<Value>(&text) {
if value.get(0).and_then(Value::as_str) == Some("OK")
&& value.get(1).and_then(Value::as_str) == Some(event_id)
{
ws.close(None).await.ok();
return Ok(value);
}
}
}
}
anyhow::bail!("no OK frame received for event {event_id}");
}
#[tokio::test]
async fn whitelist_accepts_allowed_kind_and_rejects_disallowed() -> Result<()> {
let relay = common::start_relay()?;
common::wait_for_healthy_relay(&relay).await?;
// Kind 1 (short text note) is NOT in the Floonet whitelist: rejected.
let (msg, id) = signed_event(1, "hello world");
let ok = publish_and_get_ok(relay.port, &msg, &id).await?;
assert_eq!(
ok.get(2).and_then(Value::as_bool),
Some(false),
"kind 1 must be rejected: {ok}"
);
let reason = ok.get(3).and_then(Value::as_str).unwrap_or_default();
assert!(
reason.starts_with("blocked:"),
"rejection must be a blocked: OK message, got {reason}"
);
// Kind 0 (profile metadata) IS in the whitelist: accepted.
let (msg, id) = signed_event(0, r#"{"name":"floonet-test"}"#);
let ok = publish_and_get_ok(relay.port, &msg, &id).await?;
assert_eq!(
ok.get(2).and_then(Value::as_bool),
Some(true),
"kind 0 must be accepted: {ok}"
);
// Kind 1059 (gift wrap) IS in the whitelist: accepted.
let (msg, id) = signed_event(1059, "opaque ciphertext");
let ok = publish_and_get_ok(relay.port, &msg, &id).await?;
assert_eq!(
ok.get(2).and_then(Value::as_bool),
Some(true),
"kind 1059 must be accepted: {ok}"
);
let _res = relay.shutdown_tx.send(());
Ok(())
}