Skip to content

Commit

Permalink
Authenticate to bitcoin using a username and password (ordinals#1527)
Browse files Browse the repository at this point in the history
Add the ability to authenticate bitcoind RPC calls using a username
and password, as an alternative to cookie file authentication.
  • Loading branch information
raphjaph authored May 1, 2023
1 parent 5b1e89b commit d82091d
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 72 deletions.
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use super::*;
#[derive(Deserialize, Default, PartialEq, Debug)]
pub(crate) struct Config {
pub(crate) hidden: HashSet<InscriptionId>,
pub(crate) rpc_pass: Option<String>,
pub(crate) rpc_user: Option<String>,
}

impl Config {
Expand All @@ -27,6 +29,7 @@ mod tests {

let config = Config {
hidden: iter::once(a).collect(),
..Default::default()
};

assert!(config.is_hidden(a));
Expand Down
20 changes: 4 additions & 16 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use {
super::*,
crate::wallet::Wallet,
bitcoin::BlockHeader,
bitcoincore_rpc::{json::GetBlockHeaderResult, Auth, Client},
bitcoincore_rpc::{json::GetBlockHeaderResult, Client},
chrono::SubsecRound,
indicatif::{ProgressBar, ProgressStyle},
log::log_enabled,
Expand Down Expand Up @@ -44,16 +44,15 @@ define_table! { STATISTIC_TO_COUNT, u64, u64 }
define_table! { WRITE_TRANSACTION_STARTING_BLOCK_COUNT_TO_TIMESTAMP, u64, u128 }

pub(crate) struct Index {
auth: Auth,
client: Client,
database: Database,
path: PathBuf,
first_inscription_height: u64,
genesis_block_coinbase_transaction: Transaction,
genesis_block_coinbase_txid: Txid,
height_limit: Option<u64>,
options: Options,
reorged: AtomicBool,
rpc_url: String,
}

#[derive(Debug, PartialEq)]
Expand Down Expand Up @@ -133,17 +132,7 @@ impl<T> BitcoinCoreRpcResultExt<T> for Result<T, bitcoincore_rpc::Error> {

impl Index {
pub(crate) fn open(options: &Options) -> Result<Self> {
let rpc_url = options.rpc_url();
let cookie_file = options.cookie_file()?;

log::info!(
"Connecting to Bitcoin Core RPC server at {rpc_url} using credentials from `{}`",
cookie_file.display()
);

let auth = Auth::CookieFile(cookie_file);

let client = Client::new(&rpc_url, auth.clone()).context("failed to connect to RPC URL")?;
let client = options.bitcoin_rpc_client()?;

let data_dir = options.data_dir()?;

Expand Down Expand Up @@ -232,15 +221,14 @@ impl Index {

Ok(Self {
genesis_block_coinbase_txid: genesis_block_coinbase_transaction.txid(),
auth,
client,
database,
path,
first_inscription_height: options.first_inscription_height(),
genesis_block_coinbase_transaction,
height_limit: options.height_limit,
reorged: AtomicBool::new(false),
rpc_url,
options: options.clone(),
})
}

Expand Down
20 changes: 8 additions & 12 deletions src/index/fetcher.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
use {
crate::Options,
anyhow::{anyhow, Result},
base64::Engine,
bitcoin::{Transaction, Txid},
bitcoincore_rpc::Auth,
hyper::{client::HttpConnector, Body, Client, Method, Request, Uri},
serde::Deserialize,
serde_json::{json, Value},
};

pub(crate) struct Fetcher {
auth: String,
client: Client<HttpConnector>,
url: Uri,
auth: String,
}

#[derive(Deserialize, Debug)]
struct JsonResponse<T> {
result: Option<T>,
error: Option<JsonError>,
id: usize,
result: Option<T>,
}

#[derive(Deserialize, Debug)]
Expand All @@ -28,22 +28,18 @@ struct JsonError {
}

impl Fetcher {
pub(crate) fn new(url: &str, auth: Auth) -> Result<Self> {
if auth == Auth::None {
return Err(anyhow!("No rpc authentication provided"));
}

pub(crate) fn new(options: &Options) -> Result<Self> {
let client = Client::new();

let url = if url.starts_with("http://") {
url.to_string()
let url = if options.rpc_url().starts_with("http://") {
options.rpc_url()
} else {
"http://".to_string() + url
"http://".to_string() + &options.rpc_url()
};

let url = Uri::try_from(&url).map_err(|e| anyhow!("Invalid rpc url {url}: {e}"))?;

let (user, password) = auth.get_user_pass()?;
let (user, password) = options.auth()?.get_user_pass()?;
let auth = format!("{}:{}", user.unwrap(), password.unwrap());
let auth = format!(
"Basic {}",
Expand Down
5 changes: 2 additions & 3 deletions src/index/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ impl Updater {

let height_limit = index.height_limit;

let client =
Client::new(&index.rpc_url, index.auth.clone()).context("failed to connect to RPC URL")?;
let client = index.options.bitcoin_rpc_client()?;

let first_inscription_height = index.first_inscription_height;

Expand Down Expand Up @@ -262,7 +261,7 @@ impl Updater {
}

fn spawn_fetcher(index: &Index) -> Result<(Sender<OutPoint>, Receiver<u64>)> {
let fetcher = Fetcher::new(&index.rpc_url, index.auth.clone())?;
let fetcher = Fetcher::new(&index.options)?;

// Not sure if any block has more than 20k inputs, but none so far after first inscription block
const CHANNEL_BUFFER_SIZE: usize = 20_000;
Expand Down
180 changes: 163 additions & 17 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ use {super::*, bitcoincore_rpc::Auth};
pub(crate) struct Options {
#[clap(long, help = "Load Bitcoin Core data dir from <BITCOIN_DATA_DIR>.")]
pub(crate) bitcoin_data_dir: Option<PathBuf>,
#[clap(long, help = "Authenticate to Bitcoin Core RPC with <RPC_PASS>.")]
pub(crate) bitcoin_rpc_pass: Option<String>,
#[clap(long, help = "Authenticate to Bitcoin Core RPC as <RPC_USER>.")]
pub(crate) bitcoin_rpc_user: Option<String>,
#[clap(
long = "chain",
arg_enum,
Expand Down Expand Up @@ -91,11 +95,11 @@ impl Options {
bitcoin_data_dir.clone()
} else if cfg!(target_os = "linux") {
dirs::home_dir()
.ok_or_else(|| anyhow!("failed to retrieve home dir"))?
.ok_or_else(|| anyhow!("failed to get cookie file path: could not get home dir"))?
.join(".bitcoin")
} else {
dirs::data_dir()
.ok_or_else(|| anyhow!("failed to retrieve data dir"))?
.ok_or_else(|| anyhow!("failed to get cookie file path: could not get data dir"))?
.join("Bitcoin")
};

Expand Down Expand Up @@ -136,25 +140,71 @@ impl Options {
)
}

pub(crate) fn bitcoin_rpc_client(&self) -> Result<Client> {
let cookie_file = self
.cookie_file()
.map_err(|err| anyhow!("failed to get cookie file path: {err}"))?;
fn derive_var(
arg_value: Option<&str>,
env_key: Option<&str>,
config_value: Option<&str>,
default_value: Option<&str>,
) -> Result<Option<String>> {
let env_value = match env_key {
Some(env_key) => match env::var(format!("ORD_{env_key}")) {
Ok(env_value) => Some(env_value),
Err(err @ env::VarError::NotUnicode(_)) => return Err(err.into()),
Err(env::VarError::NotPresent) => None,
},
None => None,
};

Ok(
arg_value
.or(env_value.as_deref())
.or(config_value)
.or(default_value)
.map(str::to_string),
)
}

pub(crate) fn auth(&self) -> Result<Auth> {
let config = self.load_config()?;

let rpc_user = Options::derive_var(
self.bitcoin_rpc_user.as_deref(),
Some("BITCOIN_RPC_USER"),
config.rpc_user.as_deref(),
None,
)?;

let rpc_pass = Options::derive_var(
self.bitcoin_rpc_pass.as_deref(),
Some("BITCOIN_RPC_PASS"),
config.rpc_pass.as_deref(),
None,
)?;

match (rpc_user, rpc_pass) {
(Some(rpc_user), Some(rpc_pass)) => Ok(Auth::UserPass(rpc_user, rpc_pass)),
(None, Some(_rpc_pass)) => Err(anyhow!("no bitcoind rpc user specified")),
(Some(_rpc_user), None) => Err(anyhow!("no bitcoind rpc password specified")),
_ => Ok(Auth::CookieFile(self.cookie_file()?)),
}
}

pub(crate) fn bitcoin_rpc_client(&self) -> Result<Client> {
let rpc_url = self.rpc_url();

log::info!(
"Connecting to Bitcoin Core RPC server at {rpc_url} using credentials from `{}`",
cookie_file.display()
);
let auth = self.auth()?;

let client =
Client::new(&rpc_url, Auth::CookieFile(cookie_file.clone())).with_context(|| {
format!(
"failed to connect to Bitcoin Core RPC at {rpc_url} using cookie file {}",
cookie_file.display()
)
})?;
log::info!("Connecting to Bitcoin Core at {}", self.rpc_url());

if let Auth::CookieFile(cookie_file) = &auth {
log::info!(
"Using credentials from cookie file at `{}`",
cookie_file.display()
);
}

let client = Client::new(&rpc_url, auth)
.with_context(|| format!("failed to connect to Bitcoin Core RPC at {rpc_url}"))?;

let rpc_chain = match client.get_blockchain_info()?.chain.as_str() {
"main" => Chain::Mainnet,
Expand Down Expand Up @@ -561,6 +611,27 @@ mod tests {
.unwrap(),
Config {
hidden: iter::once(id).collect(),
..Default::default()
}
);
}

#[test]
fn config_with_rpc_user_pass() {
let tempdir = TempDir::new().unwrap();
let path = tempdir.path().join("ord.yaml");
fs::write(&path, "hidden:\nrpc_user: foo\nrpc_pass: bar").unwrap();

assert_eq!(
Arguments::try_parse_from(["ord", "--config", path.to_str().unwrap(), "index",])
.unwrap()
.options
.load_config()
.unwrap(),
Config {
rpc_user: Some("foo".into()),
rpc_pass: Some("bar".into()),
..Default::default()
}
);
}
Expand Down Expand Up @@ -592,7 +663,82 @@ mod tests {
.unwrap(),
Config {
hidden: iter::once(id).collect(),
..Default::default()
}
);
}

#[test]
fn test_derive_var() {
assert_eq!(Options::derive_var(None, None, None, None).unwrap(), None);

assert_eq!(
Options::derive_var(None, None, None, Some("foo")).unwrap(),
Some("foo".into())
);

assert_eq!(
Options::derive_var(None, None, Some("bar"), Some("foo")).unwrap(),
Some("bar".into())
);

assert_eq!(
Options::derive_var(Some("qux"), None, Some("bar"), Some("foo")).unwrap(),
Some("qux".into())
);

assert_eq!(
Options::derive_var(Some("qux"), None, None, Some("foo")).unwrap(),
Some("qux".into()),
);
}

#[test]
fn auth_missing_rpc_pass_is_an_error() {
let options = Options {
bitcoin_rpc_user: Some("foo".into()),
..Default::default()
};
assert_eq!(
options.auth().unwrap_err().to_string(),
"no bitcoind rpc password specified"
);
}

#[test]
fn auth_missing_rpc_user_is_an_error() {
let options = Options {
bitcoin_rpc_pass: Some("bar".into()),
..Default::default()
};
assert_eq!(
options.auth().unwrap_err().to_string(),
"no bitcoind rpc user specified"
);
}

#[test]
fn auth_with_user_and_pass() {
let options = Options {
bitcoin_rpc_user: Some("foo".into()),
bitcoin_rpc_pass: Some("bar".into()),
..Default::default()
};
assert_eq!(
options.auth().unwrap(),
Auth::UserPass("foo".into(), "bar".into())
);
}

#[test]
fn auth_with_cookie_file() {
let options = Options {
cookie_file: Some("/var/lib/Bitcoin/.cookie".into()),
..Default::default()
};
assert_eq!(
options.auth().unwrap(),
Auth::CookieFile("/var/lib/Bitcoin/.cookie".into())
);
}
}
Loading

0 comments on commit d82091d

Please sign in to comment.