radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 6820aa55412b4d6822ebdd72a902a067e9e5865f
parent 31216709a03aa95680e4af582d35184a61a5fc52
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 16:39:25 +0000

refactor: restore minimal runtime and transports

- reintroduce app runtime with config loading and logging
- rebuild jsonrpc transport with nip46 methods
- wire nostr listener to handle nip46 requests
- add core state for keys and session store

Diffstat:
MCargo.toml | 3++-
Asrc/app/cli.rs | 35+++++++++++++++++++++++++++++++++++
Asrc/app/config.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/mod.rs | 7+++++++
Asrc/app/runtime.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/core/mod.rs | 3+++
Asrc/core/nip46/mod.rs | 3+++
Asrc/core/nip46/session.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/core/state.rs | 7++++++-
Msrc/main.rs | 22+++++++++++++++++++++-
Asrc/transport/jsonrpc/context.rs | 17+++++++++++++++++
Asrc/transport/jsonrpc/error.rs | 30++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/mod.rs | 17+++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/connect.rs | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/get_public_key.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/mod.rs | 24++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/ping.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/session_close.rs | 29+++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/session_status.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/status.rs | 18++++++++++++++++++
Msrc/transport/jsonrpc/mod.rs | 39+++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/nip46/client.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/nip46/connection.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/nip46/mod.rs | 2++
Asrc/transport/jsonrpc/params.rs | 1+
Asrc/transport/jsonrpc/registry.rs | 23+++++++++++++++++++++++
Asrc/transport/jsonrpc/server.rs | 30++++++++++++++++++++++++++++++
Msrc/transport/nostr/listener.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
28 files changed, 1454 insertions(+), 4 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -21,8 +21,9 @@ anyhow = { version = "1" } clap = { version = "4", features = ["derive"] } jsonrpsee = { version = "0.26", features = ["server"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -serde = { version = "1", default-features = false } +serde = { version = "1", default-features = false, features = ["derive"] } serde_json = { version = "1", default-features = false } +serde_qs = { version = "0.12" } tokio = { version = "1", features = ["full"] } thiserror = { version = "1" } tracing = { version = "0.1" } diff --git a/src/app/cli.rs b/src/app/cli.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use clap::{command, Parser, ValueHint}; + +#[derive(Parser, Debug, Clone)] +#[command( + about = env!("CARGO_PKG_DESCRIPTION"), + author = env!("CARGO_PKG_AUTHORS"), + version = env!("CARGO_PKG_VERSION") +)] +pub struct Args { + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::FilePath, + default_value = "config.toml", + help = "Path to the daemon configuration file (defaults to config.toml)" + )] + pub config: PathBuf, + + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::FilePath, + help = "Path to the daemon identity file (json, txt, or raw 32-byte key; defaults to identity.json)", + )] + pub identity: Option<PathBuf>, + + #[arg( + long, + action = clap::ArgAction::SetTrue, + help = "Allow generating a new identity file if missing; if not set and identity file is absent, the daemon will fail" + )] + pub allow_generate_identity: bool, +} diff --git a/src/app/config.rs b/src/app/config.rs @@ -0,0 +1,83 @@ +use radroots_nostr::prelude::RadrootsNostrMetadata; +use serde::{Deserialize, Serialize}; + +fn default_rpc_addr() -> String { + "127.0.0.1:7070".to_string() +} + +fn default_max_request_body_size() -> u32 { + 10 * 1024 * 1024 +} + +fn default_max_response_body_size() -> u32 { + 10 * 1024 * 1024 +} + +fn default_max_connections() -> u32 { + 100 +} + +fn default_max_subscriptions_per_connection() -> u32 { + 1024 +} + +fn default_message_buffer_capacity() -> u32 { + 1024 +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RpcConfig { + #[serde(default = "default_rpc_addr")] + pub addr: String, + #[serde(default = "default_max_request_body_size")] + pub max_request_body_size: u32, + #[serde(default = "default_max_response_body_size")] + pub max_response_body_size: u32, + #[serde(default = "default_max_connections")] + pub max_connections: u32, + #[serde(default = "default_max_subscriptions_per_connection")] + pub max_subscriptions_per_connection: u32, + #[serde(default = "default_message_buffer_capacity")] + pub message_buffer_capacity: u32, + #[serde(default)] + pub batch_request_limit: Option<u32>, +} + +impl Default for RpcConfig { + fn default() -> Self { + Self { + addr: default_rpc_addr(), + max_request_body_size: default_max_request_body_size(), + max_response_body_size: default_max_response_body_size(), + max_connections: default_max_connections(), + max_subscriptions_per_connection: default_max_subscriptions_per_connection(), + message_buffer_capacity: default_message_buffer_capacity(), + batch_request_limit: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Configuration { + pub logs_dir: String, + #[serde(default)] + pub rpc: RpcConfig, + #[serde(default)] + pub rpc_addr: Option<String>, + #[serde(default)] + pub relays: Vec<String>, +} + +impl Configuration { + pub fn rpc_addr(&self) -> &str { + self.rpc_addr + .as_deref() + .unwrap_or(self.rpc.addr.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub metadata: RadrootsNostrMetadata, + pub config: Configuration, +} diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -0,0 +1,7 @@ +pub mod cli; +pub mod config; +mod runtime; + +pub use cli::Args; +pub use config::Settings; +pub use runtime::run; diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -0,0 +1,51 @@ +use anyhow::{Context, Result}; +use radroots_identity::RadrootsIdentity; +use tracing::info; + +use crate::app::{cli, config}; +use crate::core::Radrootsd; +use crate::transport::jsonrpc; +use crate::transport::nostr::listener::spawn_nip46_listener; + +pub async fn run() -> Result<()> { + let (args, settings): (cli::Args, config::Settings) = + radroots_runtime::parse_and_load_path_with_init( + |a: &cli::Args| Some(a.config.as_path()), + |cfg: &config::Settings| cfg.config.logs_dir.as_str(), + None, + ) + .context("load configuration")?; + + info!("Starting radrootsd"); + + let identity = RadrootsIdentity::load_or_generate( + args.identity.as_ref(), + args.allow_generate_identity, + )?; + let keys = identity.keys().clone(); + let radrootsd = Radrootsd::new(keys, settings.metadata.clone()); + + for relay in settings.config.relays.iter() { + radrootsd.client.add_relay(relay).await?; + } + + if !settings.config.relays.is_empty() { + spawn_nip46_listener(radrootsd.clone()); + } + + let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; + let handle = jsonrpc::start_rpc(radrootsd.clone(), addr, &settings.config.rpc).await?; + info!("JSON-RPC listening on {addr}"); + + let stop_handle = handle.clone(); + + tokio::select! { + _ = radroots_runtime::shutdown_signal() => { + info!("Shutting down…"); + let _ = stop_handle.stop(); + } + _ = handle.stopped() => {} + } + + Ok(()) +} diff --git a/src/core/mod.rs b/src/core/mod.rs @@ -1 +1,4 @@ +pub mod nip46; pub mod state; + +pub use state::Radrootsd; diff --git a/src/core/nip46/mod.rs b/src/core/nip46/mod.rs @@ -0,0 +1,3 @@ +pub mod session; + +pub use session::{Nip46Session, Nip46SessionStore}; diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -0,0 +1,70 @@ +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use radroots_nostr::prelude::{ + RadrootsNostrClient, + RadrootsNostrKeys, + RadrootsNostrPublicKey, +}; + +#[derive(Clone)] +pub struct Nip46SessionStore { + inner: Arc<Mutex<HashMap<String, Nip46Session>>>, +} + +#[derive(Clone)] +pub struct Nip46Session { + pub id: String, + pub client: RadrootsNostrClient, + pub client_keys: RadrootsNostrKeys, + pub client_pubkey: RadrootsNostrPublicKey, + pub remote_signer_pubkey: RadrootsNostrPublicKey, + pub user_pubkey: Option<RadrootsNostrPublicKey>, + pub relays: Vec<String>, + pub perms: Vec<String>, + pub name: Option<String>, + pub url: Option<String>, + pub image: Option<String>, +} + +impl Nip46SessionStore { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn insert(&self, session: Nip46Session) { + let mut sessions = self.inner.lock().await; + sessions.insert(session.id.clone(), session); + } + + pub async fn get(&self, session_id: &str) -> Option<Nip46Session> { + let sessions = self.inner.lock().await; + sessions.get(session_id).cloned() + } + + pub async fn remove(&self, session_id: &str) -> bool { + let mut sessions = self.inner.lock().await; + sessions.remove(session_id).is_some() + } + + pub async fn set_user_pubkey( + &self, + session_id: &str, + pubkey: RadrootsNostrPublicKey, + ) -> bool { + let mut sessions = self.inner.lock().await; + match sessions.get_mut(session_id) { + Some(session) => { + session.user_pubkey = Some(pubkey); + true + } + None => false, + } + } +} diff --git a/src/core/state.rs b/src/core/state.rs @@ -11,26 +11,31 @@ use radroots_nostr::prelude::{ pub struct Radrootsd { pub(crate) started: Instant, pub client: RadrootsNostrClient, + pub keys: RadrootsNostrKeys, pub pubkey: RadrootsNostrPublicKey, pub metadata: RadrootsNostrMetadata, pub info: serde_json::Value, + pub(crate) nip46_sessions: crate::core::nip46::session::Nip46SessionStore, } impl Radrootsd { pub fn new(keys: RadrootsNostrKeys, metadata: RadrootsNostrMetadata) -> Self { let pubkey = keys.public_key(); - let client = RadrootsNostrClient::new(keys); + let client = RadrootsNostrClient::new(keys.clone()); let info = serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "build": option_env!("GIT_HASH").unwrap_or("unknown"), }); + let nip46_sessions = crate::core::nip46::session::Nip46SessionStore::new(); Self { started: Instant::now(), client, + keys, pubkey, metadata, info, + nip46_sessions, } } } diff --git a/src/main.rs b/src/main.rs @@ -1 +1,21 @@ -fn main() {} +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +use anyhow::Result; + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + tracing::error!(error = ?err, "Fatal error"); + eprintln!("Fatal error: {err:#}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<()> { + radrootsd::app::run().await +} diff --git a/src/transport/jsonrpc/context.rs b/src/transport/jsonrpc/context.rs @@ -0,0 +1,17 @@ +#![forbid(unsafe_code)] + +use crate::core::Radrootsd; + +use super::registry::MethodRegistry; + +#[derive(Clone)] +pub struct RpcContext { + pub state: Radrootsd, + pub methods: MethodRegistry, +} + +impl RpcContext { + pub fn new(state: Radrootsd, methods: MethodRegistry) -> Self { + Self { state, methods } + } +} diff --git a/src/transport/jsonrpc/error.rs b/src/transport/jsonrpc/error.rs @@ -0,0 +1,30 @@ +#![forbid(unsafe_code)] + +use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RpcError { + #[error("failed to add relay {0}: {1}")] + AddRelay(String, String), + #[error("no relays configured; call relays.add first")] + NoRelays, + #[error("invalid params: {0}")] + InvalidParams(String), + #[error("method not found: {0}")] + MethodNotFound(String), + #[error("{0}")] + Other(String), +} + +impl From<RpcError> for ErrorObjectOwned { + fn from(err: RpcError) -> Self { + match err { + RpcError::InvalidParams(msg) => ErrorObject::owned(-32602, msg, None::<()>), + RpcError::MethodNotFound(name) => { + ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>) + } + other => ErrorObject::owned(-32000, other.to_string(), None::<()>), + } + } +} diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -0,0 +1,17 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod nip46; + +pub fn register_all( + root: &mut RpcModule<RpcContext>, + ctx: RpcContext, + registry: MethodRegistry, +) -> Result<()> { + root.merge(nip46::module(ctx, registry)?)?; + Ok(()) +} diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -0,0 +1,437 @@ +use std::time::Duration; + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use tokio::time::sleep; +use uuid::Uuid; + +use crate::core::nip46::session::Nip46Session; +use crate::transport::jsonrpc::nip46::connection::{ + parse_connect_url, + Nip46ConnectInfo, + Nip46ConnectMode, +}; +use crate::transport::jsonrpc::params::DEFAULT_TIMEOUT_SECS; +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_filter_tag, + radroots_nostr_parse_pubkey, + RadrootsNostrClient, + RadrootsNostrEventBuilder, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrKeys, + RadrootsNostrPublicKey, + RadrootsNostrSecretKey, + RadrootsNostrRelayPoolNotification, + RadrootsNostrTimestamp, +}; +use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest}; +use nostr::JsonUtil; + +#[derive(Debug, Deserialize)] +struct Nip46ConnectParams { + url: String, + client_secret_key: Option<String>, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46ConnectResponse { + session_id: String, + mode: Nip46ConnectMode, + remote_signer_pubkey: String, + client_pubkey: String, + relays: Vec<String>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.connect"); + m.register_async_method("nip46.connect", |params, ctx, _| async move { + let Nip46ConnectParams { + url, + client_secret_key, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let response = connect_nip46(ctx.as_ref().clone(), url, client_secret_key).await?; + Ok::<Nip46ConnectResponse, RpcError>(response) + })?; + Ok(()) +} + +async fn connect_nip46( + ctx: RpcContext, + url: String, + client_secret_key: Option<String>, +) -> Result<Nip46ConnectResponse, RpcError> { + let info = parse_connect_url(&url)?; + match info.mode { + Nip46ConnectMode::Bunker => connect_bunker(ctx, info).await, + Nip46ConnectMode::Nostrconnect => { + connect_nostrconnect(ctx, info, client_secret_key).await + } + } +} + +async fn connect_bunker( + ctx: RpcContext, + info: Nip46ConnectInfo, +) -> Result<Nip46ConnectResponse, RpcError> { + if info.relays.is_empty() { + return Err(RpcError::InvalidParams("missing relay".to_string())); + } + + let remote_signer_raw = info.remote_signer_pubkey.as_ref().ok_or_else(|| { + RpcError::InvalidParams("missing remote signer pubkey".to_string()) + })?; + let remote_signer_pubkey = radroots_nostr_parse_pubkey(remote_signer_raw) + .map_err(|e| RpcError::InvalidParams(format!("invalid remote signer: {e}")))?; + + let client_keys = RadrootsNostrKeys::generate(); + let client_pubkey = client_keys.public_key(); + let client = RadrootsNostrClient::new(client_keys.clone()); + + add_relays(&client, &info.relays).await?; + client.connect().await; + client + .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .await; + + let request_id = send_connect_request( + &client, + &client_keys, + &remote_signer_pubkey, + info.secret.as_deref(), + ) + .await?; + + let response = wait_for_connect_response( + &client, + &client_keys, + &remote_signer_pubkey, + &client_pubkey, + &request_id, + ) + .await?; + + validate_connect_response(&response, info.secret.as_deref())?; + + let session_id = Uuid::new_v4().to_string(); + let session = Nip46Session { + id: session_id.clone(), + client, + client_keys, + client_pubkey, + remote_signer_pubkey, + user_pubkey: None, + relays: info.relays.clone(), + perms: info.perms.clone(), + name: info.name.clone(), + url: info.url.clone(), + image: info.image.clone(), + }; + ctx.state.nip46_sessions.insert(session).await; + + Ok(Nip46ConnectResponse { + session_id, + mode: info.mode, + remote_signer_pubkey: remote_signer_raw.to_string(), + client_pubkey: client_pubkey.to_hex(), + relays: info.relays, + }) +} + +async fn connect_nostrconnect( + ctx: RpcContext, + info: Nip46ConnectInfo, + client_secret_key: Option<String>, +) -> Result<Nip46ConnectResponse, RpcError> { + if info.relays.is_empty() { + return Err(RpcError::InvalidParams("missing relay".to_string())); + } + let secret = info + .secret + .as_deref() + .ok_or_else(|| RpcError::InvalidParams("missing secret".to_string()))?; + let client_secret_key = client_secret_key + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| RpcError::InvalidParams("missing client_secret_key".to_string()))?; + let client_secret_key = RadrootsNostrSecretKey::parse(&client_secret_key) + .map_err(|e| RpcError::InvalidParams(format!("invalid client_secret_key: {e}")))?; + let client_keys = RadrootsNostrKeys::new(client_secret_key); + let client_pubkey = client_keys.public_key(); + let client_pubkey_raw = info.client_pubkey.as_ref().ok_or_else(|| { + RpcError::InvalidParams("missing client pubkey".to_string()) + })?; + let expected_pubkey = radroots_nostr_parse_pubkey(client_pubkey_raw) + .map_err(|e| RpcError::InvalidParams(format!("invalid client pubkey: {e}")))?; + if expected_pubkey != client_pubkey { + return Err(RpcError::InvalidParams( + "client_secret_key does not match client pubkey".to_string(), + )); + } + + let client = RadrootsNostrClient::new(client_keys.clone()); + add_relays(&client, &info.relays).await?; + client.connect().await; + client + .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .await; + + let (remote_signer_pubkey, response) = wait_for_nostrconnect_response( + &client, + &client_keys, + &client_pubkey, + secret, + ) + .await?; + validate_nostrconnect_response(&response, secret)?; + + let session_id = Uuid::new_v4().to_string(); + let session = Nip46Session { + id: session_id.clone(), + client, + client_keys, + client_pubkey, + remote_signer_pubkey, + user_pubkey: None, + relays: info.relays.clone(), + perms: info.perms.clone(), + name: info.name.clone(), + url: info.url.clone(), + image: info.image.clone(), + }; + ctx.state.nip46_sessions.insert(session).await; + + Ok(Nip46ConnectResponse { + session_id, + mode: info.mode, + remote_signer_pubkey: remote_signer_pubkey.to_hex(), + client_pubkey: client_pubkey.to_hex(), + relays: info.relays, + }) +} + +async fn add_relays(client: &RadrootsNostrClient, relays: &[String]) -> Result<(), RpcError> { + for relay in relays.iter() { + client + .add_relay(relay) + .await + .map_err(|e| RpcError::Other(format!("nip46 relay add failed: {e}")))?; + } + Ok(()) +} + +async fn send_connect_request( + client: &RadrootsNostrClient, + client_keys: &RadrootsNostrKeys, + remote_signer_pubkey: &RadrootsNostrPublicKey, + secret: Option<&str>, +) -> Result<String, RpcError> { + let req = NostrConnectRequest::Connect { + remote_signer_public_key: remote_signer_pubkey.clone(), + secret: secret.map(|value| value.to_string()), + }; + let message = NostrConnectMessage::request(&req); + let request_id = message.id().to_string(); + let event = RadrootsNostrEventBuilder::nostr_connect( + client_keys, + remote_signer_pubkey.clone(), + message, + ) + .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; + client + .send_event_builder(event) + .await + .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; + Ok(request_id) +} + +async fn wait_for_connect_response( + client: &RadrootsNostrClient, + client_keys: &RadrootsNostrKeys, + remote_signer_pubkey: &RadrootsNostrPublicKey, + client_pubkey: &RadrootsNostrPublicKey, + request_id: &str, +) -> Result<NostrConnectMessage, RpcError> { + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::NostrConnect) + .author(remote_signer_pubkey.clone()) + .since(RadrootsNostrTimestamp::now()); + let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()]) + .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?; + let mut notifications = client.notifications(); + let subscription = client + .subscribe(filter, None) + .await + .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?; + let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + client.unsubscribe(&subscription.val).await; + return Err(RpcError::Other("nip46 connect response not found".to_string())); + } + msg = notifications.recv() => { + let notification = match msg { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(RpcError::Other("nip46 connect notification closed".to_string())); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = (*event).clone(); + if event.kind != RadrootsNostrKind::NostrConnect + || event.pubkey != *remote_signer_pubkey + { + continue; + } + let decrypted = nip44::decrypt( + client_keys.secret_key(), + remote_signer_pubkey, + &event.content, + ) + .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?; + let message = NostrConnectMessage::from_json(&decrypted) + .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?; + if message.is_response() && message.id() == request_id { + client.unsubscribe(&subscription.val).await; + return Ok(message); + } + } + } + } +} + +fn validate_connect_response( + response: &NostrConnectMessage, + secret: Option<&str>, +) -> Result<(), RpcError> { + let (result, error) = match response { + NostrConnectMessage::Response { result, error, .. } => (result, error), + _ => { + return Err(RpcError::Other( + "nip46 connect response invalid".to_string(), + )) + } + }; + + if let Some(error) = error { + return Err(RpcError::Other(format!("nip46 connect error: {error}"))); + } + + let result = result + .as_deref() + .ok_or_else(|| RpcError::Other("nip46 connect missing result".to_string()))?; + + if result == "ack" { + return Ok(()); + } + + if secret.is_some_and(|expected| expected == result) { + return Ok(()); + } + + Err(RpcError::Other(format!( + "nip46 connect unexpected result: {result}" + ))) +} + +fn validate_nostrconnect_response( + response: &NostrConnectMessage, + secret: &str, +) -> Result<(), RpcError> { + let (result, error) = match response { + NostrConnectMessage::Response { result, error, .. } => (result, error), + _ => { + return Err(RpcError::Other( + "nip46 connect response invalid".to_string(), + )) + } + }; + + if let Some(error) = error { + return Err(RpcError::Other(format!("nip46 connect error: {error}"))); + } + + let Some(value) = result.as_deref() else { + return Err(RpcError::Other( + "nip46 connect missing result".to_string(), + )); + }; + + if value == secret { + return Ok(()); + } + + Err(RpcError::Other(format!( + "nip46 connect unexpected result: {value}" + ))) +} + +async fn wait_for_nostrconnect_response( + client: &RadrootsNostrClient, + client_keys: &RadrootsNostrKeys, + client_pubkey: &RadrootsNostrPublicKey, + secret: &str, +) -> Result<(RadrootsNostrPublicKey, NostrConnectMessage), RpcError> { + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::NostrConnect) + .since(RadrootsNostrTimestamp::now()); + let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()]) + .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?; + let mut notifications = client.notifications(); + let subscription = client + .subscribe(filter, None) + .await + .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?; + let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + client.unsubscribe(&subscription.val).await; + return Err(RpcError::Other("nip46 connect response not found".to_string())); + } + msg = notifications.recv() => { + let notification = match msg { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(RpcError::Other("nip46 connect notification closed".to_string())); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = (*event).clone(); + if event.kind != RadrootsNostrKind::NostrConnect { + continue; + } + let decrypted = nip44::decrypt( + client_keys.secret_key(), + &event.pubkey, + &event.content, + ) + .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?; + let message = NostrConnectMessage::from_json(&decrypted) + .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?; + if !message.is_response() || message.id().is_empty() { + continue; + } + validate_nostrconnect_response(&message, secret)?; + client.unsubscribe(&subscription.val).await; + return Ok((event.pubkey, message)); + } + } + } +} diff --git a/src/transport/jsonrpc/methods/nip46/get_public_key.rs b/src/transport/jsonrpc/methods/nip46/get_public_key.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::core::nip46::session::Nip46Session; +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use crate::transport::jsonrpc::nip46::client; +use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; + +#[derive(Debug, Deserialize)] +struct Nip46GetPublicKeyParams { + session_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46GetPublicKeyResponse { + pubkey: String, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.get_public_key"); + m.register_async_method("nip46.get_public_key", |params, ctx, _| async move { + let Nip46GetPublicKeyParams { session_id } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let session = ctx + .state + .nip46_sessions + .get(&session_id) + .await + .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; + let (pubkey, updated) = request_get_public_key(&session).await?; + if updated { + if !ctx + .state + .nip46_sessions + .set_user_pubkey(&session_id, pubkey.clone()) + .await + { + return Err(RpcError::Other("nip46 session update failed".to_string())); + } + } + Ok::<Nip46GetPublicKeyResponse, RpcError>(Nip46GetPublicKeyResponse { + pubkey: pubkey.to_hex(), + }) + })?; + Ok(()) +} + +async fn request_get_public_key( + session: &Nip46Session, +) -> Result<(radroots_nostr::prelude::RadrootsNostrPublicKey, bool), RpcError> { + let req = NostrConnectRequest::GetPublicKey; + let response = client::request(session, req, "get_public_key").await?; + let response = response + .to_response(NostrConnectMethod::GetPublicKey) + .map_err(|e| RpcError::Other(format!("nip46 get_public_key failed: {e}")))?; + + if let Some(error) = response.error { + return Err(RpcError::Other(format!( + "nip46 get_public_key error: {error}" + ))); + } + + let pubkey = match response.result { + Some(ResponseResult::GetPublicKey(pubkey)) => pubkey, + Some(_) => { + return Err(RpcError::Other( + "nip46 get_public_key unexpected response".to_string(), + )) + } + None => { + return Err(RpcError::Other( + "nip46 get_public_key missing response".to_string(), + )) + } + }; + + let updated = session.user_pubkey.map(|existing| existing != pubkey).unwrap_or(true); + Ok((pubkey, updated)) +} diff --git a/src/transport/jsonrpc/methods/nip46/mod.rs b/src/transport/jsonrpc/methods/nip46/mod.rs @@ -0,0 +1,24 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod connect; +pub mod get_public_key; +pub mod ping; +pub mod session_close; +pub mod session_status; +pub mod status; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + status::register(&mut m, &registry)?; + connect::register(&mut m, &registry)?; + ping::register(&mut m, &registry)?; + get_public_key::register(&mut m, &registry)?; + session_status::register(&mut m, &registry)?; + session_close::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/transport/jsonrpc/methods/nip46/ping.rs b/src/transport/jsonrpc/methods/nip46/ping.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::core::nip46::session::Nip46Session; +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use crate::transport::jsonrpc::nip46::client; +use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; + +#[derive(Debug, Deserialize)] +struct Nip46PingParams { + session_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46PingResponse { + result: String, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.ping"); + m.register_async_method("nip46.ping", |params, ctx, _| async move { + let Nip46PingParams { session_id } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let session = ctx + .state + .nip46_sessions + .get(&session_id) + .await + .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; + Ok::<Nip46PingResponse, RpcError>(Nip46PingResponse { + result: request_ping(&session).await?, + }) + })?; + Ok(()) +} + +async fn request_ping(session: &Nip46Session) -> Result<String, RpcError> { + let req = NostrConnectRequest::Ping; + let response = client::request(session, req, "ping").await?; + let response = response + .to_response(NostrConnectMethod::Ping) + .map_err(|e| RpcError::Other(format!("nip46 ping failed: {e}")))?; + + if let Some(error) = response.error { + return Err(RpcError::Other(format!("nip46 ping error: {error}"))); + } + + match response.result { + Some(ResponseResult::Pong) => Ok("pong".to_string()), + Some(_) => Err(RpcError::Other( + "nip46 ping unexpected response".to_string(), + )), + None => Err(RpcError::Other("nip46 ping missing response".to_string())), + } +} diff --git a/src/transport/jsonrpc/methods/nip46/session_close.rs b/src/transport/jsonrpc/methods/nip46/session_close.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct Nip46SessionCloseParams { + session_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46SessionCloseResponse { + closed: bool, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.session.close"); + m.register_async_method("nip46.session.close", |params, ctx, _| async move { + let Nip46SessionCloseParams { session_id } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let closed = ctx.state.nip46_sessions.remove(&session_id).await; + Ok::<Nip46SessionCloseResponse, RpcError>(Nip46SessionCloseResponse { + closed, + }) + })?; + Ok(()) +} diff --git a/src/transport/jsonrpc/methods/nip46/session_status.rs b/src/transport/jsonrpc/methods/nip46/session_status.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct Nip46SessionStatusParams { + session_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46SessionStatusResponse { + session_id: String, + client_pubkey: String, + remote_signer_pubkey: String, + user_pubkey: Option<String>, + relays: Vec<String>, + perms: Vec<String>, + name: Option<String>, + url: Option<String>, + image: Option<String>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.session.status"); + m.register_async_method("nip46.session.status", |params, ctx, _| async move { + let Nip46SessionStatusParams { session_id } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let session = ctx + .state + .nip46_sessions + .get(&session_id) + .await + .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; + Ok::<Nip46SessionStatusResponse, RpcError>(Nip46SessionStatusResponse { + session_id, + client_pubkey: session.client_pubkey.to_hex(), + remote_signer_pubkey: session.remote_signer_pubkey.to_hex(), + user_pubkey: session.user_pubkey.map(|pubkey| pubkey.to_hex()), + relays: session.relays, + perms: session.perms, + name: session.name, + url: session.url, + image: session.image, + }) + })?; + Ok(()) +} diff --git a/src/transport/jsonrpc/methods/nip46/status.rs b/src/transport/jsonrpc/methods/nip46/status.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Serialize; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Clone, Debug, Serialize)] +struct Nip46StatusResponse { + ready: bool, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.status"); + m.register_method("nip46.status", |_p, _ctx, _| { + Ok::<Nip46StatusResponse, RpcError>(Nip46StatusResponse { ready: true }) + })?; + Ok(()) +} diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs @@ -0,0 +1,39 @@ +#![forbid(unsafe_code)] + +use std::net::SocketAddr; + +use anyhow::Result; +use jsonrpsee::server::{RpcModule, ServerHandle}; + +use crate::app::config::RpcConfig; +use crate::core::Radrootsd; + +mod context; +mod error; +mod params; +mod registry; +mod server; + +pub mod methods; +pub mod nip46; + +pub use context::RpcContext; +pub use error::RpcError; +pub use registry::MethodRegistry; +pub(crate) use params::DEFAULT_TIMEOUT_SECS; + +pub async fn start_rpc( + state: Radrootsd, + addr: SocketAddr, + rpc_cfg: &RpcConfig, +) -> Result<ServerHandle> { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state, registry.clone()); + let server = server::build_server(addr, rpc_cfg).await?; + + let mut root = RpcModule::new(ctx.clone()); + methods::register_all(&mut root, ctx, registry)?; + + let handle = server.start(root); + Ok(handle) +} diff --git a/src/transport/jsonrpc/nip46/client.rs b/src/transport/jsonrpc/nip46/client.rs @@ -0,0 +1,156 @@ +#![forbid(unsafe_code)] + +use std::time::Duration; + +use crate::core::nip46::session::Nip46Session; +use crate::transport::jsonrpc::{params::DEFAULT_TIMEOUT_SECS, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_filter_tag, + RadrootsNostrEventBuilder, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrRelayPoolNotification, + RadrootsNostrTimestamp, +}; +use nostr::nips::{ + nip44, + nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult}, +}; +use nostr::JsonUtil; +use nostr::UnsignedEvent; +use tokio::sync::broadcast; +use tokio::time::sleep; + +pub async fn sign_event( + session: &Nip46Session, + unsigned: UnsignedEvent, + label: &str, +) -> Result<nostr::Event, RpcError> { + let req = NostrConnectRequest::SignEvent(unsigned); + let response = request(session, req, label).await?; + let response = response + .to_response(NostrConnectMethod::SignEvent) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + + if let Some(error) = response.error { + return Err(RpcError::Other(format!("nip46 {label} error: {error}"))); + } + + let event = match response.result { + Some(ResponseResult::SignEvent(event)) => *event, + Some(_) => { + return Err(RpcError::Other(format!( + "nip46 {label} unexpected response" + ))) + } + None => { + return Err(RpcError::Other(format!( + "nip46 {label} missing response" + ))) + } + }; + + event + .verify() + .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?; + + Ok(event) +} + +pub async fn request( + session: &Nip46Session, + request: NostrConnectRequest, + label: &str, +) -> Result<NostrConnectMessage, RpcError> { + let request_id = send_request(session, request, label).await?; + wait_for_response(session, &request_id, label).await +} + +async fn send_request( + session: &Nip46Session, + request: NostrConnectRequest, + label: &str, +) -> Result<String, RpcError> { + session.client.connect().await; + session + .client + .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .await; + + let message = NostrConnectMessage::request(&request); + let request_id = message.id().to_string(); + let event = RadrootsNostrEventBuilder::nostr_connect( + &session.client_keys, + session.remote_signer_pubkey.clone(), + message, + ) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + + session + .client + .send_event_builder(event) + .await + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + + Ok(request_id) +} + +async fn wait_for_response( + session: &Nip46Session, + request_id: &str, + label: &str, +) -> Result<NostrConnectMessage, RpcError> { + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::NostrConnect) + .author(session.remote_signer_pubkey.clone()) + .since(RadrootsNostrTimestamp::now()); + let filter = radroots_nostr_filter_tag(filter, "p", vec![session.client_pubkey.to_hex()]) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + let mut notifications = session.client.notifications(); + let subscription = session + .client + .subscribe(filter, None) + .await + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + session.client.unsubscribe(&subscription.val).await; + return Err(RpcError::Other(format!("nip46 {label} response not found"))); + } + msg = notifications.recv() => { + let notification = match msg { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(RpcError::Other(format!("nip46 {label} notification closed"))); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = (*event).clone(); + if event.kind != RadrootsNostrKind::NostrConnect + || event.pubkey != session.remote_signer_pubkey + { + continue; + } + let decrypted = nip44::decrypt( + session.client_keys.secret_key(), + &session.remote_signer_pubkey, + &event.content, + ) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + let message = NostrConnectMessage::from_json(&decrypted) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + if message.is_response() && message.id() == request_id { + session.client.unsubscribe(&subscription.val).await; + return Ok(message); + } + } + } + } +} diff --git a/src/transport/jsonrpc/nip46/connection.rs b/src/transport/jsonrpc/nip46/connection.rs @@ -0,0 +1,99 @@ +use radroots_nostr::prelude::radroots_nostr_parse_pubkey; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::transport::jsonrpc::RpcError; + +#[derive(Clone, Debug, Serialize)] +pub enum Nip46ConnectMode { + Bunker, + Nostrconnect, +} + +#[derive(Clone, Debug)] +pub struct Nip46ConnectInfo { + pub mode: Nip46ConnectMode, + pub relays: Vec<String>, + pub remote_signer_pubkey: Option<String>, + pub client_pubkey: Option<String>, + pub secret: Option<String>, + pub perms: Vec<String>, + pub name: Option<String>, + pub url: Option<String>, + pub image: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct Nip46ConnectQuery { + relay: Option<Vec<String>>, + secret: Option<String>, + perms: Option<String>, + name: Option<String>, + url: Option<String>, + image: Option<String>, +} + +pub fn parse_connect_url(raw: &str) -> Result<Nip46ConnectInfo, RpcError> { + let url = Url::parse(raw).map_err(|e| RpcError::InvalidParams(e.to_string()))?; + match url.scheme() { + "bunker" => parse_bunker_url(&url), + "nostrconnect" => parse_nostrconnect_url(&url), + _ => Err(RpcError::InvalidParams("unsupported scheme".to_string())), + } +} + +fn parse_bunker_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> { + let remote_signer_pubkey = url.host_str().map(|host| host.to_string()); + let query: Nip46ConnectQuery = + serde_qs::from_str(url.query().unwrap_or_default()) + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let relays = query.relay.unwrap_or_default(); + let perms = parse_perms(query.perms); + + Ok(Nip46ConnectInfo { + mode: Nip46ConnectMode::Bunker, + relays, + remote_signer_pubkey, + client_pubkey: None, + secret: query.secret, + perms, + name: query.name, + url: query.url, + image: query.image, + }) +} + +fn parse_nostrconnect_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> { + let client_pubkey = url + .host_str() + .map(|host| host.to_string()) + .ok_or_else(|| RpcError::InvalidParams("missing client pubkey".to_string()))?; + radroots_nostr_parse_pubkey(&client_pubkey) + .map_err(|e| RpcError::InvalidParams(format!("invalid client pubkey: {e}")))?; + let query: Nip46ConnectQuery = + serde_qs::from_str(url.query().unwrap_or_default()) + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let relays = query.relay.unwrap_or_default(); + let perms = parse_perms(query.perms); + + Ok(Nip46ConnectInfo { + mode: Nip46ConnectMode::Nostrconnect, + relays, + remote_signer_pubkey: None, + client_pubkey: Some(client_pubkey), + secret: query.secret, + perms, + name: query.name, + url: query.url, + image: query.image, + }) +} + +fn parse_perms(perms: Option<String>) -> Vec<String> { + perms + .unwrap_or_default() + .split(',') + .map(|entry| entry.trim().to_string()) + .filter(|entry| !entry.is_empty()) + .collect() +} diff --git a/src/transport/jsonrpc/nip46/mod.rs b/src/transport/jsonrpc/nip46/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod connection; diff --git a/src/transport/jsonrpc/params.rs b/src/transport/jsonrpc/params.rs @@ -0,0 +1 @@ +pub const DEFAULT_TIMEOUT_SECS: u64 = 10; diff --git a/src/transport/jsonrpc/registry.rs b/src/transport/jsonrpc/registry.rs @@ -0,0 +1,23 @@ +#![forbid(unsafe_code)] + +use std::sync::{Arc, RwLock}; + +#[derive(Clone, Default)] +pub struct MethodRegistry { + inner: Arc<RwLock<Vec<String>>>, +} + +impl MethodRegistry { + pub fn track(&self, name: &'static str) { + let mut methods = self.inner.write().unwrap_or_else(|e| e.into_inner()); + if methods.iter().any(|entry| entry == name) { + return; + } + methods.push(name.to_string()); + methods.sort(); + } + + pub fn list(&self) -> Vec<String> { + self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() + } +} diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -0,0 +1,30 @@ +#![forbid(unsafe_code)] + +use std::net::SocketAddr; + +use anyhow::Result; +use jsonrpsee::server::{BatchRequestConfig, Server, ServerBuilder, ServerConfigBuilder}; + +use crate::app::config::RpcConfig; + +pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Server> { + let mut builder = ServerConfigBuilder::new() + .max_request_body_size(rpc_cfg.max_request_body_size) + .max_response_body_size(rpc_cfg.max_response_body_size) + .max_connections(rpc_cfg.max_connections) + .max_subscriptions_per_connection(rpc_cfg.max_subscriptions_per_connection) + .set_message_buffer_capacity(rpc_cfg.message_buffer_capacity); + + if let Some(limit) = rpc_cfg.batch_request_limit { + let cfg = if limit == 0 { + BatchRequestConfig::Disabled + } else { + BatchRequestConfig::Limit(limit) + }; + builder = builder.set_batch_request_config(cfg); + } + + let server_cfg = builder.build(); + let server = ServerBuilder::with_config(server_cfg).build(addr).await?; + Ok(server) +} diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -1,12 +1,16 @@ use std::time::Duration; use anyhow::{anyhow, Result}; +use nostr::nips::nip44; +use nostr::nips::nip46::{NostrConnectMessage, NostrConnectRequest, NostrConnectResponse, ResponseResult}; +use nostr::JsonUtil; use tokio::sync::broadcast; use tracing::{info, warn}; use crate::core::state::Radrootsd; use radroots_nostr::prelude::{ radroots_nostr_filter_tag, + RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrRelayPoolNotification, @@ -55,6 +59,64 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { if event.kind != RadrootsNostrKind::NostrConnect { continue; } - info!("NIP-46 request received: {}", event.id); + + let decrypted = match nip44::decrypt( + radrootsd.keys.secret_key(), + &event.pubkey, + &event.content, + ) { + Ok(value) => value, + Err(err) => { + warn!("NIP-46 decrypt failed: {err}"); + continue; + } + }; + let message = match NostrConnectMessage::from_json(&decrypted) { + Ok(value) => value, + Err(err) => { + warn!("NIP-46 parse failed: {err}"); + continue; + } + }; + if !message.is_request() { + continue; + } + + let request_id = message.id().to_string(); + let request = match message.to_request() { + Ok(value) => value, + Err(err) => { + warn!("NIP-46 request invalid: {err}"); + continue; + } + }; + let response = handle_request(&radrootsd, request); + let response_message = NostrConnectMessage::response(request_id, response); + let response_event = RadrootsNostrEventBuilder::nostr_connect( + &radrootsd.keys, + event.pubkey, + response_message, + ) + .map_err(|err| anyhow!("nip46 response build failed: {err}"))?; + let _ = radrootsd.client.send_event_builder(response_event).await; + } +} + +fn handle_request(radrootsd: &Radrootsd, request: NostrConnectRequest) -> NostrConnectResponse { + match request { + NostrConnectRequest::Connect { + remote_signer_public_key, + .. + } => { + if remote_signer_public_key != radrootsd.pubkey { + return NostrConnectResponse::with_error("remote signer pubkey mismatch"); + } + NostrConnectResponse::with_result(ResponseResult::Ack) + } + NostrConnectRequest::GetPublicKey => { + NostrConnectResponse::with_result(ResponseResult::GetPublicKey(radrootsd.pubkey)) + } + NostrConnectRequest::Ping => NostrConnectResponse::with_result(ResponseResult::Pong), + _ => NostrConnectResponse::with_error("unsupported request"), } }