Skip to content

Spaced RPC user/password protection #96

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion client/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
});
Expand Down
114 changes: 114 additions & 0 deletions client/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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<S> Layer<S> for BasicAuthLayer {
type Service = BasicAuth<S>;

fn layer(&self, inner: S) -> Self::Service {
BasicAuth::new(inner, self.token.clone())
}
}

#[derive(Debug, Clone)]
pub(crate) struct BasicAuth<S> {
inner: S,
token: Arc<str>,
}

impl<S> BasicAuth<S> {
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<Body> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"Protected\"")
.body(Body::from("Unauthorized"))
.expect("Failed to build unauthorized response")
}
}

impl<S> Service<Request<Body>> for BasicAuth<S>
where
S: Service<Request<Body>, Response = Response<Body>>,
S::Response: 'static,
S::Error: Into<Box<dyn Error + Send + Sync>> + 'static,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = Box<dyn Error + Send + Sync + 'static>;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;

#[inline]
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(Into::into)
}

fn call(&mut self, req: Request<Body>) -> 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<HttpClient, ClientError> {
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)
}
41 changes: 35 additions & 6 deletions client/src/bin/space-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,6 +54,15 @@ pub struct Args {
/// Spaced RPC URL [default: based on specified chain]
#[arg(long)]
spaced_rpc_url: Option<String>,
/// Spaced RPC cookie file path
#[arg(long, env = "SPACED_RPC_COOKIE")]
rpc_cookie: Option<PathBuf>,
/// Spaced RPC user
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
rpc_user: Option<String>,
/// Spaced RPC password
#[arg(long, env = "SPACED_RPC_PASSWORD")]
rpc_password: Option<String>,
/// Specify wallet to use
#[arg(long, short, global = true, default_value = "default")]
wallet: String,
Expand Down Expand Up @@ -387,7 +396,27 @@ impl SpaceCli {
args.spaced_rpc_url = Some(default_spaced_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.spaced_rpc_url.as_ref().unwrap(), &auth_token)?;

Ok((
Self {
wallet: args.wallet.clone(),
Expand Down
60 changes: 51 additions & 9 deletions client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,6 +62,12 @@ pub struct Args {
/// Bitcoin RPC password
#[arg(long, env = "SPACED_BITCOIN_RPC_PASSWORD")]
bitcoin_rpc_password: Option<String>,
/// Spaced RPC user
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
rpc_user: Option<String>,
/// Spaced RPC password
#[arg(long, env = "SPACED_RPC_PASSWORD")]
rpc_password: Option<String>,
/// 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")]
Expand Down Expand Up @@ -102,7 +112,7 @@ impl Args {
/// Configures spaced node by processing command line arguments
/// and configuration files
pub async fn configure(args: Vec<String>) -> anyhow::Result<Spaced> {
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() {
Expand All @@ -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<SocketAddr> = args
Expand All @@ -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::<String>()
);
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)
Expand All @@ -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();

Expand Down Expand Up @@ -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,
})
}
}
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading