floonet-rs: hardened nostr-rs-relay for the Grin community
Test and build / test_floonet-rs (push) Has been cancelled
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:
@@ -0,0 +1,10 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use floonet_rs::cli::CLIArgs;
|
||||
|
||||
#[test]
|
||||
fn cli_tests() {
|
||||
use clap::CommandFactory;
|
||||
CLIArgs::command().debug_assert();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user