diff --git a/Cargo.lock b/Cargo.lock index 96df5ab..44d0ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2346,9 +2346,11 @@ dependencies = [ "env_logger", "futures", "hex", + "hyper 0.14.28", "jsonrpsee", "log", "predicates", + "rand", "reqwest", "serde", "serde_json", @@ -2381,6 +2383,7 @@ dependencies = [ "anyhow", "assert_cmd", "bitcoind", + "hyper 0.14.28", "spaces_client", "zip", ] diff --git a/client/Cargo.toml b/client/Cargo.toml index 62ec2c5..dc13ed5 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -27,6 +27,7 @@ clap = { version = "4.5.6", features = ["derive", "env"] } log = "0.4.21" serde = { version = "1.0.200", features = ["derive"] } hex = "0.4.3" +rand = "0.8" jsonrpsee = { version = "0.22.5", features = ["server", "http-client", "macros"] } directories = "5.0.1" env_logger = "0.11.3" @@ -40,6 +41,7 @@ tabled = "0.17.0" colored = "3.0.0" domain = {version = "0.10.3", default-features = false, features = ["zonefile"]} tower = "0.4.13" +hyper = "0.14.28" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/client/src/app.rs b/client/src/app.rs index d392c68..3f0697a 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -69,11 +69,12 @@ impl App { let rpc_server = RpcServerImpl::new(async_chain_state.clone(), wallet_manager); let bind = spaced.bind.clone(); + let auth_token = spaced.auth_token.clone(); let shutdown = self.shutdown.clone(); self.services.spawn(async move { rpc_server - .listen(bind, shutdown) + .listen(bind, auth_token, shutdown) .await .map_err(|e| anyhow!("RPC Server error: {}", e)) }); diff --git a/client/src/auth.rs b/client/src/auth.rs new file mode 100644 index 0000000..fce5833 --- /dev/null +++ b/client/src/auth.rs @@ -0,0 +1,114 @@ +use base64::Engine; +use hyper::{http::HeaderValue, Body, HeaderMap, Request, Response, StatusCode}; +use jsonrpsee::{ + core::ClientError, + http_client::{HttpClient, HttpClientBuilder}, +}; +use std::{ + error::Error, + future::Future, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tower::{Layer, Service}; + +#[derive(Debug, Clone)] +pub(crate) struct BasicAuthLayer { + token: String, +} + +impl BasicAuthLayer { + pub fn new(token: String) -> Self { + Self { token } + } +} + +impl Layer for BasicAuthLayer { + type Service = BasicAuth; + + fn layer(&self, inner: S) -> Self::Service { + BasicAuth::new(inner, self.token.clone()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct BasicAuth { + inner: S, + token: Arc, +} + +impl BasicAuth { + pub fn new(inner: S, token: String) -> Self { + Self { + inner, + token: Arc::from(token.as_str()), + } + } + + fn check_auth(&self, headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Basic ")) + .map_or(false, |token| token == self.token.as_ref()) + } + + fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("WWW-Authenticate", "Basic realm=\"Protected\"") + .body(Body::from("Unauthorized")) + .expect("Failed to build unauthorized response") + } +} + +impl Service> for BasicAuth +where + S: Service, Response = Response>, + S::Response: 'static, + S::Error: Into> + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = Box; + type Future = + Pin> + Send + 'static>>; + + #[inline] + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx).map_err(Into::into) + } + + fn call(&mut self, req: Request) -> Self::Future { + if !self.check_auth(req.headers()) { + let response = Self::unauthorized_response(); + return Box::pin(async move { Ok(response) }); + } + + let fut = self.inner.call(req); + let res_fut = async move { fut.await.map_err(|err| err.into()) }; + Box::pin(res_fut) + } +} + +pub fn auth_cookie(user: &str, password: &str) -> String { + format!("{user}:{password}") +} + +pub fn auth_token_from_cookie(cookie: &str) -> String { + base64::prelude::BASE64_STANDARD.encode(cookie) +} + +pub fn auth_token_from_creds(user: &str, password: &str) -> String { + base64::prelude::BASE64_STANDARD.encode(auth_cookie(user, password)) +} + +pub fn http_client_with_auth(url: &str, auth_token: &str) -> Result { + let mut headers = hyper::http::HeaderMap::new(); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Basic {auth_token}")).unwrap(), + ); + HttpClientBuilder::default().set_headers(headers).build(url) +} diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 5322df5..b7cfe03 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -16,17 +16,17 @@ use domain::{ }; use jsonrpsee::{ core::{client::Error, ClientError}, - http_client::{HttpClient, HttpClientBuilder}, + http_client::HttpClient, }; use serde::{Deserialize, Serialize}; use spaces_client::{ - config::{default_spaces_rpc_port, ExtendedNetwork}, + auth::{auth_token_from_cookie, auth_token_from_creds, http_client_with_auth}, + config::{default_cookie_path, default_spaces_rpc_port, ExtendedNetwork}, deserialize_base64, format::{ print_error_rpc_response, print_list_bidouts, print_list_spaces_response, - print_list_transactions, print_list_unspent, print_server_info, - print_list_wallets, print_wallet_balance_response, print_wallet_info, print_wallet_response, - Format, + print_list_transactions, print_list_unspent, print_list_wallets, print_server_info, + print_wallet_balance_response, print_wallet_info, print_wallet_response, Format, }, rpc::{ BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, @@ -53,7 +53,16 @@ pub struct Args { output_format: Format, /// Spaced RPC URL [default: based on specified chain] #[arg(long)] - spaced_rpc_url: Option, + rpc_url: Option, + /// Spaced RPC cookie file path + #[arg(long, env = "SPACED_RPC_COOKIE")] + rpc_cookie: Option, + /// Spaced RPC user + #[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")] + rpc_user: Option, + /// Spaced RPC password + #[arg(long, env = "SPACED_RPC_PASSWORD")] + rpc_password: Option, /// Specify wallet to use #[arg(long, short, global = true, default_value = "default")] wallet: String, @@ -383,11 +392,31 @@ struct Base64Bytes( impl SpaceCli { async fn configure() -> anyhow::Result<(Self, Args)> { let mut args = Args::parse(); - if args.spaced_rpc_url.is_none() { - args.spaced_rpc_url = Some(default_spaced_rpc_url(&args.chain)); + if args.rpc_url.is_none() { + args.rpc_url = Some(default_rpc_url(&args.chain)); } - let client = HttpClientBuilder::default().build(args.spaced_rpc_url.clone().unwrap())?; + let auth_token = if args.rpc_user.is_some() { + auth_token_from_creds( + args.rpc_user.as_ref().unwrap(), + args.rpc_password.as_ref().unwrap(), + ) + } else { + let cookie_path = match &args.rpc_cookie { + Some(path) => path, + None => &default_cookie_path(&args.chain), + }; + let cookie = fs::read_to_string(cookie_path).map_err(|e| { + anyhow!( + "Failed to read cookie file '{}': {}", + cookie_path.display(), + e + ) + })?; + auth_token_from_cookie(&cookie) + }; + let client = http_client_with_auth(args.rpc_url.as_ref().unwrap(), &auth_token)?; + Ok(( Self { wallet: args.wallet.clone(), @@ -396,7 +425,7 @@ impl SpaceCli { force: args.force, skip_tx_check: args.skip_tx_check, network: args.chain, - rpc_url: args.spaced_rpc_url.clone().unwrap(), + rpc_url: args.rpc_url.clone().unwrap(), client, }, args, @@ -930,7 +959,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Ok(()) } -fn default_spaced_rpc_url(chain: &ExtendedNetwork) -> String { +fn default_rpc_url(chain: &ExtendedNetwork) -> String { format!("http://127.0.0.1:{}", default_spaces_rpc_port(chain)) } diff --git a/client/src/config.rs b/client/src/config.rs index 57cb405..e501a14 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -5,19 +5,23 @@ use std::{ path::PathBuf, }; -use clap::{ - ArgGroup, Parser, ValueEnum, -}; +use anyhow::anyhow; +use clap::{ArgGroup, Parser, ValueEnum}; use directories::ProjectDirs; use jsonrpsee::core::Serialize; use log::error; +use rand::{ + distributions::Alphanumeric, + {thread_rng, Rng}, +}; use serde::Deserialize; use spaces_protocol::bitcoin::Network; use crate::{ + auth::{auth_token_from_cookie, auth_token_from_creds}, source::{BitcoinRpc, BitcoinRpcAuth}, - store::{LiveStore, Store}, spaces::Spaced, + store::{LiveStore, Store}, }; const RPC_OPTIONS: &str = "RPC Server Options"; @@ -58,6 +62,12 @@ pub struct Args { /// Bitcoin RPC password #[arg(long, env = "SPACED_BITCOIN_RPC_PASSWORD")] bitcoin_rpc_password: Option, + /// Spaced RPC user + #[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")] + rpc_user: Option, + /// Spaced RPC password + #[arg(long, env = "SPACED_RPC_PASSWORD")] + rpc_password: Option, /// Bind to given address to listen for JSON-RPC connections. /// This option can be specified multiple times (default: 127.0.0.1 and ::1 i.e., localhost) #[arg(long, help_heading = Some(RPC_OPTIONS), default_values = ["127.0.0.1", "::1"], env = "SPACED_RPC_BIND")] @@ -102,7 +112,7 @@ impl Args { /// Configures spaced node by processing command line arguments /// and configuration files pub async fn configure(args: Vec) -> anyhow::Result { - let mut args = Args::try_parse_from(args)?; + let mut args = Args::try_parse_from(args)?; let default_dirs = get_default_node_dirs(); if args.bitcoin_rpc_url.is_none() { @@ -117,6 +127,7 @@ impl Args { Some(data_dir) => data_dir, } .join(args.chain.to_string()); + fs::create_dir_all(data_dir.clone())?; let default_port = args.rpc_port.unwrap(); let rpc_bind_addresses: Vec = args @@ -132,6 +143,31 @@ impl Args { }) .collect(); + let auth_token = if args.rpc_user.is_some() { + auth_token_from_creds( + args.rpc_user.as_ref().unwrap(), + args.rpc_password.as_ref().unwrap(), + ) + } else { + let cookie = format!( + "__cookie__:{}", + thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect::() + ); + let cookie_path = data_dir.join(".cookie"); + fs::write(&cookie_path, &cookie).map_err(|e| { + anyhow!( + "Failed to write cookie file '{}': {}", + cookie_path.display(), + e + ) + })?; + auth_token_from_cookie(&cookie) + }; + let bitcoin_rpc_auth = if let Some(cookie) = args.bitcoin_rpc_cookie { let cookie = std::fs::read_to_string(cookie)?; BitcoinRpcAuth::Cookie(cookie) @@ -144,13 +180,11 @@ impl Args { let rpc = BitcoinRpc::new( &args.bitcoin_rpc_url.expect("bitcoin rpc url"), bitcoin_rpc_auth, - !args.bitcoin_rpc_light + !args.bitcoin_rpc_light, ); let genesis = Spaced::genesis(args.chain); - fs::create_dir_all(data_dir.clone())?; - let proto_db_path = data_dir.join("protocol.sdb"); let initial_sync = !proto_db_path.exists(); @@ -196,13 +230,14 @@ impl Args { rpc, data_dir, bind: rpc_bind_addresses, + auth_token, chain, block_index, block_index_full: args.block_index_full, num_workers: args.jobs as usize, anchors_path, synced: false, - cbf: args.bitcoin_rpc_light + cbf: args.bitcoin_rpc_light, }) } } @@ -214,6 +249,13 @@ fn get_default_node_dirs() -> ProjectDirs { }) } +pub fn default_cookie_path(network: &ExtendedNetwork) -> PathBuf { + get_default_node_dirs() + .data_dir() + .join(network.to_string()) + .join(".cookie") +} + // from clap utilities pub fn safe_exit(code: i32) -> ! { use std::io::Write; diff --git a/client/src/lib.rs b/client/src/lib.rs index 7093588..13e491c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use base64::Engine; use serde::{Deserialize, Deserializer, Serializer}; +pub mod auth; mod checker; pub mod client; pub mod config; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index f91611b..3fe73c7 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -48,11 +48,22 @@ use tokio::{ task::JoinSet, }; -use crate::{calc_progress, checker::TxChecker, client::{BlockMeta, TxEntry}, config::ExtendedNetwork, deserialize_base64, serialize_base64, source::BitcoinRpc, store::{ChainState, LiveSnapshot, RolloutEntry, Sha256}, spaces::{COMMIT_BLOCK_INTERVAL, ROOT_ANCHORS_COUNT}, wallets::{ - AddressKind, ListSpacesResponse, RpcWallet, TxInfo, TxResponse, WalletCommand, - WalletResponse, -}}; +use crate::auth::BasicAuthLayer; use crate::wallets::WalletInfoWithProgress; +use crate::{ + calc_progress, + checker::TxChecker, + client::{BlockMeta, TxEntry}, + config::ExtendedNetwork, + deserialize_base64, serialize_base64, + source::BitcoinRpc, + spaces::{COMMIT_BLOCK_INTERVAL, ROOT_ANCHORS_COUNT}, + store::{ChainState, LiveSnapshot, RolloutEntry, Sha256}, + wallets::{ + AddressKind, ListSpacesResponse, RpcWallet, TxInfo, TxResponse, WalletCommand, + WalletResponse, + }, +}; pub(crate) type Responder = oneshot::Sender; @@ -224,7 +235,8 @@ pub trait Rpc { ) -> Result; #[method(name = "walletgetinfo")] - async fn wallet_get_info(&self, name: &str) -> Result; + async fn wallet_get_info(&self, name: &str) + -> Result; #[method(name = "walletexport")] async fn wallet_export(&self, name: &str) -> Result; @@ -586,7 +598,8 @@ impl WalletManager { .filter_map(Result::ok) .filter(|entry| entry.path().is_dir()) .filter_map(|entry| { - entry.path() + entry + .path() .file_name() .and_then(|name| name.to_str()) .map(String::from) @@ -689,12 +702,14 @@ impl RpcServerImpl { pub async fn listen( self, addrs: Vec, + auth_token: String, signal: broadcast::Sender<()>, ) -> anyhow::Result<()> { let mut listeners: Vec<_> = Vec::with_capacity(addrs.len()); for addr in addrs.iter() { let service_builder = tower::ServiceBuilder::new() + .layer(BasicAuthLayer::new(auth_token.clone())) .layer(ProxyGetRequestLayer::new( "/root-anchors.json", "getrootanchors", @@ -841,13 +856,11 @@ impl RpcServer for RpcServerImpl { Ok(data) } - async fn list_wallets(&self) -> Result, ErrorObjectOwned> { + async fn list_wallets(&self) -> Result, ErrorObjectOwned> { self.wallet_manager .list_wallets() .await - .map_err(|error| { - ErrorObjectOwned::owned(-1, error.to_string(), None::) - }) + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } async fn wallet_load(&self, name: &str) -> Result<(), ErrorObjectOwned> { @@ -892,7 +905,10 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_get_info(&self, wallet: &str) -> Result { + async fn wallet_get_info( + &self, + wallet: &str, + ) -> Result { self.wallet(&wallet) .await? .send_get_info() @@ -1112,7 +1128,7 @@ impl AsyncChainState { rpc, chain_state, ) - .await?; + .await?; Ok(block .block_meta @@ -1121,7 +1137,6 @@ impl AsyncChainState { .find(|tx| &tx.changeset.txid == txid)) } - async fn get_indexed_block( index: &mut Option, height_or_hash: HeightOrHash, @@ -1144,10 +1159,7 @@ impl AsyncChainState { .get(BaseHash::from_slice(hash.as_ref())) .context("Could not fetch block from index")? { - return Ok(BlockMetaWithHash { - hash, - block_meta, - }); + return Ok(BlockMetaWithHash { hash, block_meta }); } let info: serde_json::Value = rpc @@ -1227,14 +1239,9 @@ impl AsyncChainState { height_or_hash, resp, } => { - let res = Self::get_indexed_block( - block_index, - height_or_hash, - client, - rpc, - chain_state, - ) - .await; + let res = + Self::get_indexed_block(block_index, height_or_hash, client, rpc, chain_state) + .await; let _ = resp.send(res); } ChainStateCommand::GetTxMeta { txid, resp } => { @@ -1296,7 +1303,7 @@ impl AsyncChainState { File::open(anchors_path) .or_else(|e| Err(anyhow!("Could not open anchors file: {}", e)))?, ) - .or_else(|e| Err(anyhow!("Could not read anchors file: {}", e)))?; + .or_else(|e| Err(anyhow!("Could not read anchors file: {}", e)))?; return Ok(anchors); } @@ -1371,13 +1378,14 @@ impl AsyncChainState { let key = OutpointKey::from_outpoint::(outpoint); let proof = if !prefer_recent { - let spaceout = - match state.get_spaceout(&outpoint)? { - Some(spaceot) => spaceot, - None => return Err(anyhow!( + let spaceout = match state.get_spaceout(&outpoint)? { + Some(spaceot) => spaceot, + None => { + return Err(anyhow!( "Cannot find older proofs for a non-existent utxo (try with oldest: false)" - )), - }; + )) + } + }; let target_snapshot = match spaceout.space.as_ref() { None => return Ok(ProofResult { proof: vec![], root: Bytes::new(vec![]) }), Some(space) => match space.covenant { @@ -1530,7 +1538,9 @@ impl AsyncChainState { pub async fn get_server_info(&self) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); - self.sender.send(ChainStateCommand::GetServerInfo { resp }).await?; + self.sender + .send(ChainStateCommand::GetServerInfo { resp }) + .await?; resp_rx.await? } @@ -1592,8 +1602,11 @@ fn get_space_key(space_or_hash: &str) -> Result { Ok(SpaceKey::from(hash)) } - -async fn get_server_info(client: &reqwest::Client, rpc: &BitcoinRpc, tip: ChainAnchor) -> anyhow::Result { +async fn get_server_info( + client: &reqwest::Client, + rpc: &BitcoinRpc, + tip: ChainAnchor, +) -> anyhow::Result { #[derive(Deserialize)] struct Info { pub chain: String, diff --git a/client/src/spaces.rs b/client/src/spaces.rs index a16ea76..ed0cfee 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -42,6 +42,7 @@ pub struct Spaced { pub rpc: BitcoinRpc, pub data_dir: PathBuf, pub bind: Vec, + pub auth_token: String, pub num_workers: usize, pub anchors_path: Option, pub synced: bool, @@ -255,9 +256,7 @@ impl Spaced { Ok(()) } - pub fn genesis( - network: ExtendedNetwork, - ) -> ChainAnchor { + pub fn genesis(network: ExtendedNetwork) -> ChainAnchor { match network { ExtendedNetwork::Testnet => ChainAnchor::TESTNET(), ExtendedNetwork::Testnet4 => ChainAnchor::TESTNET4(), diff --git a/testutil/Cargo.toml b/testutil/Cargo.toml index da6823b..947fb80 100644 --- a/testutil/Cargo.toml +++ b/testutil/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] bitcoind = { version = "0.36.0", features = ["26_0"] } spaces_client = { path = "../client" } +hyper = "0.14.28" assert_cmd = "2.0.16" [build-dependencies] diff --git a/testutil/src/spaced.rs b/testutil/src/spaced.rs index 2057bbf..eb4c5fd 100644 --- a/testutil/src/spaced.rs +++ b/testutil/src/spaced.rs @@ -8,10 +8,8 @@ use anyhow::Result; use assert_cmd::cargo::CommandCargoExt; use bitcoind::{anyhow, anyhow::anyhow, get_available_port, tempfile::tempdir}; use spaces_client::{ - jsonrpsee::{ - http_client::{HttpClient, HttpClientBuilder}, - tokio, - }, + auth::{auth_token_from_creds, http_client_with_auth}, + jsonrpsee::{http_client::HttpClient, tokio}, log::{debug, error}, rpc::RpcClient, }; @@ -53,13 +51,18 @@ impl SpaceD { .arg(rpc_port.to_string()) .arg("--data-dir") .arg(tempdir()?.path()) + .arg("--rpc-user") + .arg("user") + .arg("--rpc-password") + .arg("pass") .stdout(stdout) .spawn()?) }) .await .expect("spawn blocking task")?; - let client = HttpClientBuilder::default().build(rpc_url(rpc_port))?; + let client = + http_client_with_auth(&rpc_url(rpc_port), &auth_token_from_creds("user", "pass"))?; let mut spaced = Self { process,