Skip to content

Commit 138e4eb

Browse files
committed
spaced rpc user/password protection
1 parent 646c312 commit 138e4eb

File tree

9 files changed

+152
-5
lines changed

9 files changed

+152
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ tabled = "0.17.0"
4040
colored = "3.0.0"
4141
domain = {version = "0.10.3", default-features = false, features = ["zonefile"]}
4242
tower = "0.4.13"
43+
hyper = "0.14.28"
4344

4445
[dev-dependencies]
4546
assert_cmd = "2.0.16"

client/src/app.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ impl App {
6969
let rpc_server = RpcServerImpl::new(async_chain_state.clone(), wallet_manager);
7070

7171
let bind = spaced.bind.clone();
72+
let auth_token = spaced.auth_token.clone();
7273
let shutdown = self.shutdown.clone();
7374

7475
self.services.spawn(async move {
7576
rpc_server
76-
.listen(bind, shutdown)
77+
.listen(bind, auth_token, shutdown)
7778
.await
7879
.map_err(|e| anyhow!("RPC Server error: {}", e))
7980
});

client/src/auth.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use std::{
2+
error::Error,
3+
future::Future,
4+
pin::Pin,
5+
sync::Arc,
6+
task::{Context, Poll},
7+
};
8+
use base64::Engine;
9+
use hyper::{Body, Request, Response, StatusCode, HeaderMap};
10+
use tower::{Layer, Service};
11+
12+
#[derive(Debug, Clone)]
13+
pub(crate) struct BasicAuthLayer {
14+
token: Option<String>,
15+
}
16+
17+
impl BasicAuthLayer {
18+
pub fn new(token: Option<String>) -> Self {
19+
Self { token }
20+
}
21+
}
22+
23+
impl<S> Layer<S> for BasicAuthLayer {
24+
type Service = BasicAuth<S>;
25+
26+
fn layer(&self, inner: S) -> Self::Service {
27+
BasicAuth::new(inner, self.token.clone())
28+
}
29+
}
30+
31+
#[derive(Debug, Clone)]
32+
pub(crate) struct BasicAuth<S> {
33+
inner: S,
34+
token: Option<Arc<str>>,
35+
}
36+
37+
impl<S> BasicAuth<S> {
38+
pub fn new(inner: S, token: Option<String>) -> Self {
39+
Self {
40+
inner,
41+
token: token.map(|t| Arc::from(t.as_str())),
42+
}
43+
}
44+
45+
fn check_auth(&self, headers: &HeaderMap) -> bool {
46+
let Some(expected_token) = &self.token else {
47+
return true;
48+
};
49+
50+
let auth_header = match headers.get("authorization") {
51+
Some(header) => header,
52+
None => return false,
53+
};
54+
55+
let auth_str = match auth_header.to_str() {
56+
Ok(s) => s,
57+
Err(_) => return false,
58+
};
59+
60+
if let Some(token_part) = auth_str.strip_prefix("Basic ") {
61+
token_part == expected_token.as_ref()
62+
} else {
63+
false
64+
}
65+
}
66+
67+
fn unauthorized_response() -> Response<Body> {
68+
Response::builder()
69+
.status(StatusCode::UNAUTHORIZED)
70+
.header("WWW-Authenticate", "Basic realm=\"Protected\"")
71+
.body(Body::from("Unauthorized"))
72+
.expect("Failed to build unauthorized response")
73+
}
74+
}
75+
76+
impl<S> Service<Request<Body>> for BasicAuth<S>
77+
where
78+
S: Service<Request<Body>, Response = Response<Body>>,
79+
S::Response: 'static,
80+
S::Error: Into<Box<dyn Error + Send + Sync>> + 'static,
81+
S::Future: Send + 'static,
82+
{
83+
type Response = S::Response;
84+
type Error = Box<dyn Error + Send + Sync + 'static>;
85+
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
86+
87+
#[inline]
88+
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
89+
self.inner.poll_ready(cx).map_err(Into::into)
90+
}
91+
92+
fn call(&mut self, req: Request<Body>) -> Self::Future {
93+
if !self.check_auth(req.headers()) {
94+
let response = Self::unauthorized_response();
95+
return Box::pin(async move { Ok(response) });
96+
}
97+
98+
let fut = self.inner.call(req);
99+
let res_fut = async move {
100+
fut.await.map_err(|err| err.into())
101+
};
102+
Box::pin(res_fut)
103+
}
104+
}
105+
106+
pub fn basic_auth_token(user: &str, password: &str) -> String {
107+
base64::prelude::BASE64_STANDARD.encode(format!("{user}:{password}"))
108+
}

client/src/bin/space-cli.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use jsonrpsee::{
2020
};
2121
use serde::{Deserialize, Serialize};
2222
use spaces_client::{
23+
auth::basic_auth_token,
2324
config::{default_spaces_rpc_port, ExtendedNetwork},
2425
deserialize_base64,
2526
format::{
@@ -54,6 +55,12 @@ pub struct Args {
5455
/// Spaced RPC URL [default: based on specified chain]
5556
#[arg(long)]
5657
spaced_rpc_url: Option<String>,
58+
/// Spaced RPC user
59+
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
60+
rpc_user: Option<String>,
61+
/// Spaced RPC password
62+
#[arg(long, env = "SPACED_RPC_PASSWORD")]
63+
rpc_password: Option<String>,
5764
/// Specify wallet to use
5865
#[arg(long, short, global = true, default_value = "default")]
5966
wallet: String,
@@ -387,7 +394,17 @@ impl SpaceCli {
387394
args.spaced_rpc_url = Some(default_spaced_rpc_url(&args.chain));
388395
}
389396

390-
let client = HttpClientBuilder::default().build(args.spaced_rpc_url.clone().unwrap())?;
397+
let client = HttpClientBuilder::default();
398+
let client = if args.rpc_user.is_some() {
399+
let token = basic_auth_token(args.rpc_user.as_ref().unwrap(), args.rpc_password.as_ref().unwrap());
400+
let mut headers = hyper::http::HeaderMap::new();
401+
headers.insert("Authorization", hyper::http::HeaderValue::from_str(&format!("Basic {token}")).unwrap());
402+
client.set_headers(headers)
403+
} else {
404+
client
405+
};
406+
let client = client.build(args.spaced_rpc_url.clone().unwrap())?;
407+
391408
Ok((
392409
Self {
393410
wallet: args.wallet.clone(),

client/src/config.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use serde::Deserialize;
1515
use spaces_protocol::bitcoin::Network;
1616

1717
use crate::{
18+
auth::basic_auth_token,
1819
source::{BitcoinRpc, BitcoinRpcAuth},
1920
store::{LiveStore, Store},
2021
spaces::Spaced,
@@ -58,6 +59,12 @@ pub struct Args {
5859
/// Bitcoin RPC password
5960
#[arg(long, env = "SPACED_BITCOIN_RPC_PASSWORD")]
6061
bitcoin_rpc_password: Option<String>,
62+
/// Spaced RPC user
63+
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
64+
rpc_user: Option<String>,
65+
/// Spaced RPC password
66+
#[arg(long, env = "SPACED_RPC_PASSWORD")]
67+
rpc_password: Option<String>,
6168
/// Bind to given address to listen for JSON-RPC connections.
6269
/// This option can be specified multiple times (default: 127.0.0.1 and ::1 i.e., localhost)
6370
#[arg(long, help_heading = Some(RPC_OPTIONS), default_values = ["127.0.0.1", "::1"], env = "SPACED_RPC_BIND")]
@@ -102,7 +109,7 @@ impl Args {
102109
/// Configures spaced node by processing command line arguments
103110
/// and configuration files
104111
pub async fn configure(args: Vec<String>) -> anyhow::Result<Spaced> {
105-
let mut args = Args::try_parse_from(args)?;
112+
let mut args = Args::try_parse_from(args)?;
106113
let default_dirs = get_default_node_dirs();
107114

108115
if args.bitcoin_rpc_url.is_none() {
@@ -132,6 +139,12 @@ impl Args {
132139
})
133140
.collect();
134141

142+
let auth_token = if args.rpc_user.is_some() {
143+
Some(basic_auth_token(args.rpc_user.as_ref().unwrap(), args.rpc_password.as_ref().unwrap()))
144+
} else {
145+
None
146+
};
147+
135148
let bitcoin_rpc_auth = if let Some(cookie) = args.bitcoin_rpc_cookie {
136149
let cookie = std::fs::read_to_string(cookie)?;
137150
BitcoinRpcAuth::Cookie(cookie)
@@ -144,7 +157,7 @@ impl Args {
144157
let rpc = BitcoinRpc::new(
145158
&args.bitcoin_rpc_url.expect("bitcoin rpc url"),
146159
bitcoin_rpc_auth,
147-
!args.bitcoin_rpc_light
160+
!args.bitcoin_rpc_light,
148161
);
149162

150163
let genesis = Spaced::genesis(args.chain);
@@ -196,13 +209,14 @@ impl Args {
196209
rpc,
197210
data_dir,
198211
bind: rpc_bind_addresses,
212+
auth_token,
199213
chain,
200214
block_index,
201215
block_index_full: args.block_index_full,
202216
num_workers: args.jobs as usize,
203217
anchors_path,
204218
synced: false,
205-
cbf: args.bitcoin_rpc_light
219+
cbf: args.bitcoin_rpc_light,
206220
})
207221
}
208222
}

client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::time::{Duration, Instant};
99
use base64::Engine;
1010
use serde::{Deserialize, Deserializer, Serializer};
1111

12+
pub mod auth;
1213
mod checker;
1314
pub mod client;
1415
pub mod config;

client/src/rpc.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ use crate::{calc_progress, checker::TxChecker, client::{BlockMeta, TxEntry}, con
5353
WalletResponse,
5454
}};
5555
use crate::wallets::WalletInfoWithProgress;
56+
use crate::auth::BasicAuthLayer;
5657

5758
pub(crate) type Responder<T> = oneshot::Sender<T>;
5859

@@ -689,12 +690,14 @@ impl RpcServerImpl {
689690
pub async fn listen(
690691
self,
691692
addrs: Vec<SocketAddr>,
693+
auth_token: Option<String>,
692694
signal: broadcast::Sender<()>,
693695
) -> anyhow::Result<()> {
694696
let mut listeners: Vec<_> = Vec::with_capacity(addrs.len());
695697

696698
for addr in addrs.iter() {
697699
let service_builder = tower::ServiceBuilder::new()
700+
.layer(BasicAuthLayer::new(auth_token.clone()))
698701
.layer(ProxyGetRequestLayer::new(
699702
"/root-anchors.json",
700703
"getrootanchors",

client/src/spaces.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub struct Spaced {
4242
pub rpc: BitcoinRpc,
4343
pub data_dir: PathBuf,
4444
pub bind: Vec<SocketAddr>,
45+
pub auth_token: Option<String>,
4546
pub num_workers: usize,
4647
pub anchors_path: Option<PathBuf>,
4748
pub synced: bool,

0 commit comments

Comments
 (0)