From 9e150ba620366104a77973a46019f66cd9444a86 Mon Sep 17 00:00:00 2001 From: Jonathan Zernik Date: Tue, 12 Jul 2022 17:17:45 -0700 Subject: [PATCH 1/3] Use openssl (#1) * Update library to use openssl instead of rustls * Include docstring in lib module --- Cargo.toml | 25 +++-- build.rs | 1 - examples/getinfo.rs | 26 ++++- src/error.rs | 21 +--- src/lib.rs | 265 +++++++++++++++++++++++--------------------- 5 files changed, 177 insertions(+), 161 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e7c0f42..bd14f5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,18 +11,21 @@ keywords = ["LND", "rpc", "grpc", "tonic", "async"] categories = ["api-bindings", "asynchronous", "cryptography::cryptocurrencies", "network-programming"] license = "MITNFA" +[lib] +doctest = false + [dependencies] -tonic = { version = "0.6.2", features = ["transport", "tls"] } -prost = "0.9.0" -rustls = { version = "0.19.0", features = ["dangerous_configuration"] } -webpki = "0.21.3" -rustls-pemfile = "1.0.0" +tonic = "0.7" +tonic-openssl = { version = "0.2" } +hyper = "0.14" +hyper-openssl = "0.9" +prost = "0.10" +tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } +openssl = "0.10" +tower = "0.4" +pretty_env_logger = "*" hex = "0.4.3" -tokio = { version = "1.7.1", features = ["fs"] } -tracing = { version = "0.1", features = ["log"], optional = true } [build-dependencies] -tonic-build = "0.5.2" - -[dev-dependencies] -tokio = { version = "1.7.1", features = ["rt-multi-thread"] } +tonic-build = "0.7" diff --git a/build.rs b/build.rs index 9757251..f075b21 100644 --- a/build.rs +++ b/build.rs @@ -31,7 +31,6 @@ fn main() -> std::io::Result<()> { tonic_build::configure() .build_client(true) .build_server(false) - .format(false) .compile(&proto_paths, &[dir])?; Ok(()) } diff --git a/examples/getinfo.rs b/examples/getinfo.rs index 2728e34..ef64e3f 100644 --- a/examples/getinfo.rs +++ b/examples/getinfo.rs @@ -8,13 +8,29 @@ async fn main() { let mut args = std::env::args_os(); args.next().expect("not even zeroth arg given"); - let address = args.next().expect("missing arguments: address, cert file, macaroon file"); - let cert_file = args.next().expect("missing arguments: cert file, macaroon file"); + let host = args + .next() + .expect("missing arguments: host, port, cert file, macaroon file"); + let port = args + .next() + .expect("missing arguments: port, cert file, macaroon file"); + let cert_file = args + .next() + .expect("missing arguments: cert file, macaroon file"); let macaroon_file = args.next().expect("missing argument: macaroon file"); - let address = address.into_string().expect("address is not UTF-8"); + let host: String = host.into_string().expect("host is not UTF-8"); + let port: u32 = port + .into_string() + .expect("port is not UTF-8") + .parse() + .expect("port is not u32"); + let cert_file: String = cert_file.into_string().expect("cert_file is not UTF-8"); + let macaroon_file: String = macaroon_file + .into_string() + .expect("macaroon_file is not UTF-8"); - // Connecting to LND requires only address, cert file, and macaroon file - let mut client = tonic_lnd::connect(address, cert_file, macaroon_file) + // Connecting to LND requires only host, port, cert file, macaroon file + let mut client = tonic_lnd::connect(host, port, cert_file, macaroon_file) .await .expect("failed to connect"); diff --git a/src/error.rs b/src/error.rs index 62f5386..bec0daa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,19 +13,16 @@ pub struct ConnectError { impl From for ConnectError { fn from(value: InternalConnectError) -> Self { - ConnectError { - internal: value, - } + ConnectError { internal: value } } } #[derive(Debug)] pub(crate) enum InternalConnectError { - ReadFile { file: PathBuf, error: std::io::Error, }, - ParseCert { file: PathBuf, error: std::io::Error, }, - InvalidAddress { address: String, error: Box, }, - TlsConfig(tonic::transport::Error), - Connect { address: String, error: tonic::transport::Error, } + ReadFile { + file: PathBuf, + error: std::io::Error, + }, } impl fmt::Display for ConnectError { @@ -34,10 +31,6 @@ impl fmt::Display for ConnectError { match &self.internal { ReadFile { file, .. } => write!(f, "failed to read file {}", file.display()), - ParseCert { file, .. } => write!(f, "failed to parse certificate {}", file.display()), - InvalidAddress { address, .. } => write!(f, "invalid address {}", address), - TlsConfig(_) => write!(f, "failed to configure TLS"), - Connect { address, .. } => write!(f, "failed to connect to {}", address), } } } @@ -48,10 +41,6 @@ impl std::error::Error for ConnectError { match &self.internal { ReadFile { error, .. } => Some(error), - ParseCert { error, .. } => Some(error), - InvalidAddress { error, .. } => Some(&**error), - TlsConfig(error) => Some(error), - Connect { error, .. } => Some(error), } } } diff --git a/src/lib.rs b/src/lib.rs index be6cf3e..18ab6d1 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,42 +1,51 @@ // include_str! is not supported in attributes yet #![doc = r###" -Rust implementation of LND RPC client using async GRPC library `tonic`. - +Rust implementation of LND RPC client using async GRPC library `tonic-openssl`. ## About - **Warning: this crate is in early development and may have unknown problems! Review it before using with mainnet funds!** - This crate implements LND GRPC using [`tonic`](https://docs.rs/tonic/) and [`prost`](https://docs.rs/prost/). Apart from being up-to-date at the time of writing (:D) it also allows `aync` usage. It contains vendored `rpc.proto` file so LND source code is not *required* but accepts an environment variable `LND_REPO_DIR` which overrides the vendored `rpc.proto` file. This can be used to test new features in non-released `lnd`. (Actually, the motivating project using this library is that case. :)) - ## Usage - There's no setup needed beyond adding the crate to your `Cargo.toml`. If you need to change the `rpc.proto` input set the environment variable `LND_REPO_DIR` to the directory with cloned `lnd` during build. - Here's an example of retrieving information from LND (`getinfo` call). You can find the same example in crate root for your convenience. ```no_run -// This program accepts three arguments: address, cert file, macaroon file +// This program accepts three arguments: host, port, cert file, macaroon file // The address must start with `https://`! - #[tokio::main] async fn main() { let mut args = std::env::args_os(); args.next().expect("not even zeroth arg given"); - let address = args.next().expect("missing arguments: address, cert file, macaroon file"); - let cert_file = args.next().expect("missing arguments: cert file, macaroon file"); + let host = args + .next() + .expect("missing arguments: host, port, cert file, macaroon file"); + let port = args + .next() + .expect("missing arguments: port, cert file, macaroon file"); + let cert_file = args + .next() + .expect("missing arguments: cert file, macaroon file"); let macaroon_file = args.next().expect("missing argument: macaroon file"); - let address = address.into_string().expect("address is not UTF-8"); + let host: String = host.into_string().expect("host is not UTF-8"); + let port: u32 = port + .into_string() + .expect("port is not UTF-8") + .parse() + .expect("port is not u32"); + let cert_file: String = cert_file.into_string().expect("cert_file is not UTF-8"); + let macaroon_file: String = macaroon_file + .into_string() + .expect("macaroon_file is not UTF-8"); // Connecting to LND requires only address, cert file, and macaroon file - let mut client = tonic_lnd::connect(address, cert_file, macaroon_file) + let mut client = tonic_lnd::connect(host, port, cert_file, macaroon_file) .await .expect("failed to connect"); @@ -52,44 +61,48 @@ async fn main() { println!("{:#?}", info); } ``` - ## MSRV - Undetermined yet, please make suggestions. - ## License - MITNFA "###] -/// This is part of public interface so it's re-exported. -pub extern crate tonic; - -use std::path::{Path, PathBuf}; -use std::convert::TryInto; -pub use error::ConnectError; use error::InternalConnectError; +use hyper::client::connect::HttpConnector; +use hyper::{client::ResponseFuture, Body, Client, Request, Response, Uri}; +use hyper_openssl::HttpsConnector; +use openssl::{ + ssl::{SslConnector, SslMethod}, + x509::X509, +}; +use std::path::{Path, PathBuf}; +use std::{error::Error, task::Poll}; +use tonic::body::BoxBody; use tonic::codegen::InterceptedService; -use tonic::transport::Channel; +use tonic_openssl::ALPN_H2_WIRE; +use tower::Service; #[cfg(feature = "tracing")] use tracing; /// Convenience type alias for lightning client. -pub type LightningClient = lnrpc::lightning_client::LightningClient>; +pub type LightningClient = + lnrpc::lightning_client::LightningClient>; /// Convenience type alias for wallet client. -pub type WalletKitClient = walletrpc::wallet_kit_client::WalletKitClient>; +pub type WalletKitClient = walletrpc::wallet_kit_client::WalletKitClient< + InterceptedService, +>; /// The client returned by `connect` function /// /// This is a convenience type which you most likely want to use instead of raw client. -pub struct Client { +pub struct LndClient { lightning: LightningClient, wallet: WalletKitClient, } -impl Client { +impl LndClient { /// Returns the lightning client. pub fn lightning(&mut self) -> &mut LightningClient { &mut self.lightning @@ -101,24 +114,11 @@ impl Client { } } -/// [`tonic::Status`] is re-exported as `Error` for convenience. -pub type Error = tonic::Status; - mod error; -macro_rules! try_map_err { - ($result:expr, $mapfn:expr) => { - match $result { - Ok(value) => value, - Err(error) => return Err($mapfn(error).into()), - } - } -} +/// [`tonic::Status`] is re-exported as `Error` for convenience. +pub type LndClientError = tonic::Status; -/// Messages and other types generated by `tonic`/`prost` -/// -/// This is the go-to module you will need to look in to find documentation on various message -/// types. However it may be better to start from methods on the [`LightningClient`](lnrpc::lightning_client::LightningClient) type. pub mod lnrpc { tonic::include_proto!("lnrpc"); } @@ -138,109 +138,118 @@ pub struct MacaroonInterceptor { } impl tonic::service::Interceptor for MacaroonInterceptor { - fn call(&mut self, mut request: tonic::Request<()>) -> Result, Error> { - request - .metadata_mut() - .insert("macaroon", tonic::metadata::MetadataValue::from_str(&self.macaroon).expect("hex produced non-ascii")); + fn call( + &mut self, + mut request: tonic::Request<()>, + ) -> Result, LndClientError> { + request.metadata_mut().insert( + "macaroon", + #[allow(deprecated)] + tonic::metadata::MetadataValue::from_str(&self.macaroon) + .expect("hex produced non-ascii"), + ); Ok(request) } } -async fn load_macaroon(path: impl AsRef + Into) -> Result { - let macaroon = tokio::fs::read(&path) - .await - .map_err(|error| InternalConnectError::ReadFile { file: path.into(), error, })?; +async fn load_macaroon( + path: impl AsRef + Into, +) -> Result { + let macaroon = + tokio::fs::read(&path) + .await + .map_err(|error| InternalConnectError::ReadFile { + file: path.into(), + error, + })?; Ok(hex::encode(&macaroon)) } -/// Connects to LND using given address and credentials -/// -/// This function does all required processing of the cert file and macaroon file, so that you -/// don't have to. The address must begin with "https://", though. -/// -/// This is considered the recommended way to connect to LND. An alternative function to use -/// already-read certificate or macaroon data is currently **not** provided to discourage such use. -/// LND occasionally changes that data which would lead to errors and in turn in worse application. -/// -/// If you have a motivating use case for use of direct data feel free to open an issue and -/// explain. -#[cfg_attr(feature = "tracing", tracing::instrument(name = "Connecting to LND"))] -pub async fn connect(address: A, cert_file: CP, macaroon_file: MP) -> Result where A: TryInto + std::fmt::Debug + ToString, >::Error: std::error::Error + Send + Sync + 'static, CP: AsRef + Into + std::fmt::Debug, MP: AsRef + Into + std::fmt::Debug { - let address_str = address.to_string(); - let conn = try_map_err!(address - .try_into(), |error| InternalConnectError::InvalidAddress { address: address_str.clone(), error: Box::new(error), }) - .tls_config(tls::config(cert_file).await?) - .map_err(InternalConnectError::TlsConfig)? - .connect() - .await - .map_err(|error| InternalConnectError::Connect { address: address_str, error, })?; - - let macaroon = load_macaroon(macaroon_file).await?; - - let interceptor = MacaroonInterceptor { macaroon, }; - - let client = Client { - lightning: lnrpc::lightning_client::LightningClient::with_interceptor(conn.clone(), interceptor.clone()), - wallet: walletrpc::wallet_kit_client::WalletKitClient::with_interceptor(conn, interceptor) +pub async fn connect( + lnd_host: String, + lnd_port: u32, + lnd_tls_cert_path: String, + lnd_macaroon_path: String, +) -> Result> { + let lnd_address = format!("https://{}:{}", lnd_host, lnd_port).to_string(); + + let pem = tokio::fs::read(lnd_tls_cert_path).await.ok(); + let uri = lnd_address.parse::().unwrap(); + let channel = MyChannel::new(pem, uri).await?; + + let macaroon = load_macaroon(lnd_macaroon_path).await.unwrap(); + let interceptor = MacaroonInterceptor { macaroon }; + + let client = LndClient { + lightning: lnrpc::lightning_client::LightningClient::with_interceptor( + channel.clone(), + interceptor.clone(), + ), + wallet: walletrpc::wallet_kit_client::WalletKitClient::with_interceptor( + channel.clone(), + interceptor, + ), }; + Ok(client) } -mod tls { - use std::path::{Path, PathBuf}; - use rustls::{RootCertStore, Certificate, TLSError, ServerCertVerified}; - use webpki::DNSNameRef; - use crate::error::{ConnectError, InternalConnectError}; - - pub(crate) async fn config(path: impl AsRef + Into) -> Result { - let mut tls_config = rustls::ClientConfig::new(); - tls_config.dangerous().set_certificate_verifier(std::sync::Arc::new(CertVerifier::load(path).await?)); - tls_config.set_protocols(&["h2".into()]); - Ok(tonic::transport::ClientTlsConfig::new() - .rustls_client_config(tls_config)) - } - - pub(crate) struct CertVerifier { - certs: Vec> - } - - impl CertVerifier { - pub(crate) async fn load(path: impl AsRef + Into) -> Result { - let contents = try_map_err!(tokio::fs::read(&path).await, - |error| InternalConnectError::ReadFile { file: path.into(), error }); - let mut reader = &*contents; +#[derive(Clone)] +pub struct MyChannel { + uri: Uri, + client: MyClient, +} - let certs = try_map_err!(rustls_pemfile::certs(&mut reader), - |error| InternalConnectError::ParseCert { file: path.into(), error }); +#[derive(Clone)] +enum MyClient { + ClearText(Client), + Tls(Client, BoxBody>), +} - #[cfg(feature = "tracing")] { - tracing::debug!("Certificates loaded (Count: {})", certs.len()); +impl MyChannel { + pub async fn new(certificate: Option>, uri: Uri) -> Result> { + let mut http = HttpConnector::new(); + http.enforce_http(false); + let client = match certificate { + None => MyClient::ClearText(Client::builder().http2_only(true).build(http)), + Some(pem) => { + let ca = X509::from_pem(&pem[..])?; + let mut connector = SslConnector::builder(SslMethod::tls())?; + connector.cert_store_mut().add_cert(ca)?; + connector.set_alpn_protos(ALPN_H2_WIRE)?; + let mut https = HttpsConnector::with_connector(http, connector)?; + https.set_callback(|c, _| { + c.set_verify_hostname(false); + Ok(()) + }); + MyClient::Tls(Client::builder().http2_only(true).build(https)) } + }; - Ok(CertVerifier { - certs: certs, - }) - } + Ok(Self { client, uri }) } +} - impl rustls::ServerCertVerifier for CertVerifier { - fn verify_server_cert(&self, _roots: &RootCertStore, presented_certs: &[Certificate], _dns_name: DNSNameRef<'_>, _ocsp_response: &[u8]) -> Result { - - if self.certs.len() != presented_certs.len() { - return Err(TLSError::General(format!("Mismatched number of certificates (Expected: {}, Presented: {})", self.certs.len(), presented_certs.len()))); - } - - for (c, p) in self.certs.iter().zip(presented_certs.iter()) { - if *p.0 != **c { - return Err(TLSError::General(format!("Server certificates do not match ours"))); - } else { - #[cfg(feature = "tracing")] { - tracing::trace!("Confirmed certificate match"); - } - } - } +impl Service> for MyChannel { + type Response = Response; + type Error = hyper::Error; + type Future = ResponseFuture; + + fn poll_ready(&mut self, _: &mut std::task::Context<'_>) -> Poll> { + Ok(()).into() + } - Ok(ServerCertVerified::assertion()) + fn call(&mut self, mut req: Request) -> Self::Future { + let uri = Uri::builder() + .scheme(self.uri.scheme().unwrap().clone()) + .authority(self.uri.authority().unwrap().clone()) + .path_and_query(req.uri().path_and_query().unwrap().clone()) + .build() + .unwrap(); + *req.uri_mut() = uri; + match &self.client { + MyClient::ClearText(client) => client.request(req), + MyClient::Tls(client) => client.request(req), } } } From 6ca4f5c9b5a6f80fb7c64ea279d10bc7429de79f Mon Sep 17 00:00:00 2001 From: okjodom Date: Wed, 14 Dec 2022 12:12:07 +0000 Subject: [PATCH 2/3] rename MyChannel and MyClient to indicate Ssl association --- src/lib.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 18ab6d1..b0c4b7d 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,11 +87,11 @@ use tracing; /// Convenience type alias for lightning client. pub type LightningClient = - lnrpc::lightning_client::LightningClient>; + lnrpc::lightning_client::LightningClient>; /// Convenience type alias for wallet client. pub type WalletKitClient = walletrpc::wallet_kit_client::WalletKitClient< - InterceptedService, + InterceptedService, >; /// The client returned by `connect` function @@ -175,7 +175,7 @@ pub async fn connect( let pem = tokio::fs::read(lnd_tls_cert_path).await.ok(); let uri = lnd_address.parse::().unwrap(); - let channel = MyChannel::new(pem, uri).await?; + let channel = SslChannel::new(pem, uri).await?; let macaroon = load_macaroon(lnd_macaroon_path).await.unwrap(); let interceptor = MacaroonInterceptor { macaroon }; @@ -195,23 +195,23 @@ pub async fn connect( } #[derive(Clone)] -pub struct MyChannel { +pub struct SslChannel { uri: Uri, - client: MyClient, + client: SslClient, } #[derive(Clone)] -enum MyClient { +enum SslClient { ClearText(Client), Tls(Client, BoxBody>), } -impl MyChannel { +impl SslChannel { pub async fn new(certificate: Option>, uri: Uri) -> Result> { let mut http = HttpConnector::new(); http.enforce_http(false); let client = match certificate { - None => MyClient::ClearText(Client::builder().http2_only(true).build(http)), + None => SslClient::ClearText(Client::builder().http2_only(true).build(http)), Some(pem) => { let ca = X509::from_pem(&pem[..])?; let mut connector = SslConnector::builder(SslMethod::tls())?; @@ -222,7 +222,7 @@ impl MyChannel { c.set_verify_hostname(false); Ok(()) }); - MyClient::Tls(Client::builder().http2_only(true).build(https)) + SslClient::Tls(Client::builder().http2_only(true).build(https)) } }; @@ -230,7 +230,7 @@ impl MyChannel { } } -impl Service> for MyChannel { +impl Service> for SslChannel { type Response = Response; type Error = hyper::Error; type Future = ResponseFuture; @@ -248,8 +248,8 @@ impl Service> for MyChannel { .unwrap(); *req.uri_mut() = uri; match &self.client { - MyClient::ClearText(client) => client.request(req), - MyClient::Tls(client) => client.request(req), + SslClient::ClearText(client) => client.request(req), + SslClient::Tls(client) => client.request(req), } } } From db1fbba8126ab3e9ca26f46e31d8c9cd1c8aae3d Mon Sep 17 00:00:00 2001 From: okjodom Date: Wed, 14 Dec 2022 12:26:56 +0000 Subject: [PATCH 3/3] refactor config parsing in examples --- examples/getinfo.rs | 24 +++++++++++++----------- examples/subscribe_invoices.rs | 28 +++++++++++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/examples/getinfo.rs b/examples/getinfo.rs index ef64e3f..ed0f02f 100644 --- a/examples/getinfo.rs +++ b/examples/getinfo.rs @@ -10,22 +10,24 @@ async fn main() { args.next().expect("not even zeroth arg given"); let host = args .next() - .expect("missing arguments: host, port, cert file, macaroon file"); - let port = args - .next() - .expect("missing arguments: port, cert file, macaroon file"); - let cert_file = args + .expect("missing arguments: host, port, cert file, macaroon file") + .into_string() + .expect("host is not UTF-8"); + let port: u32 = args .next() - .expect("missing arguments: cert file, macaroon file"); - let macaroon_file = args.next().expect("missing argument: macaroon file"); - let host: String = host.into_string().expect("host is not UTF-8"); - let port: u32 = port + .expect("missing arguments: port, cert file, macaroon file") .into_string() .expect("port is not UTF-8") .parse() .expect("port is not u32"); - let cert_file: String = cert_file.into_string().expect("cert_file is not UTF-8"); - let macaroon_file: String = macaroon_file + let cert_file: String = args + .next() + .expect("missing arguments: cert file, macaroon file") + .into_string() + .expect("cert_file is not UTF-8"); + let macaroon_file: String = args + .next() + .expect("missing argument: macaroon file") .into_string() .expect("macaroon_file is not UTF-8"); diff --git a/examples/subscribe_invoices.rs b/examples/subscribe_invoices.rs index 40ce97e..81b7ee9 100644 --- a/examples/subscribe_invoices.rs +++ b/examples/subscribe_invoices.rs @@ -6,17 +6,31 @@ async fn main() { let mut args = std::env::args_os(); args.next().expect("not even zeroth arg given"); - let address = args + let host = args .next() - .expect("missing arguments: address, cert file, macaroon file"); - let cert_file = args + .expect("missing arguments: host, port, cert file, macaroon file") + .into_string() + .expect("host is not UTF-8"); + let port: u32 = args .next() - .expect("missing arguments: cert file, macaroon file"); - let macaroon_file = args.next().expect("missing argument: macaroon file"); - let address = address.into_string().expect("address is not UTF-8"); + .expect("missing arguments: port, cert file, macaroon file") + .into_string() + .expect("port is not UTF-8") + .parse() + .expect("port is not u32"); + let cert_file: String = args + .next() + .expect("missing arguments: cert file, macaroon file") + .into_string() + .expect("cert_file is not UTF-8"); + let macaroon_file: String = args + .next() + .expect("missing argument: macaroon file") + .into_string() + .expect("macaroon_file is not UTF-8"); // Connecting to LND requires only address, cert file, and macaroon file - let mut client = tonic_lnd::connect(address, cert_file, macaroon_file) + let mut client = tonic_lnd::connect(host, port, cert_file, macaroon_file) .await .expect("failed to connect");