Skip to content
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
1 change: 1 addition & 0 deletions Cargo.lock

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

16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,42 @@ a Lightning node while exposing a robust, language-agnostic API via [Protocol Bu
- Built on top of LDK-Node, leveraging the modular, reliable, and high-performance architecture of LDK.

- **Effortless Integration**:
- Ideal for embedding Lightning functionality into payment processors, self-hosted nodes, custodial wallets, or other Lightning-enabled
- Ideal for embedding Lightning functionality into payment processors, self-hosted nodes, custodial wallets, or
other Lightning-enabled
applications.

### Project Status

🚧 **Work in Progress**:

- **APIs Under Development**: Expect breaking changes as the project evolves.
- **Potential Bugs and Inconsistencies**: While progress is being made toward stability, unexpected behavior may occur.
- **Improved Logging and Error Handling Coming Soon**: Current error handling is rudimentary (specially for CLI), and usability improvements are actively being worked on.
- **Improved Logging and Error Handling Coming Soon**: Current error handling is rudimentary (specially for CLI), and
usability improvements are actively being worked on.
- **Pending Testing**: Not tested, hence don't use it for production!

We welcome your feedback and contributions to help shape the future of LDK Server!


### Configuration

Refer `./ldk-server/ldk-server-config.toml` to see available configuration options.

### Building

```
git clone https://github.com/lightningdevkit/ldk-server.git
cargo build
```

### Running

```
cargo run --bin ldk-server ./ldk-server/ldk-server-config.toml
```

Interact with the node using CLI:

```
./target/debug/ldk-server-cli -b localhost:3002 onchain-receive # To generate onchain-receive address.
./target/debug/ldk-server-cli -b localhost:3002 help # To print help/available commands.
./target/debug/ldk-server-cli -b localhost:3002 -u user -p pass onchain-receive # To generate onchain-receive address.
./target/debug/ldk-server-cli -b localhost:3002 -u user -p pass help # To print help/available commands.
```
8 changes: 7 additions & 1 deletion ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ struct Cli {
#[arg(short, long, default_value = "localhost:3000")]
base_url: String,

#[arg(short, long)]
username: String,

#[arg(short, long)]
password: String,

#[command(subcommand)]
command: Commands,
}
Expand Down Expand Up @@ -208,7 +214,7 @@ enum Commands {
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let client = LdkServerClient::new(cli.base_url);
let client = LdkServerClient::new(cli.base_url, cli.username, cli.password);

match cli.command {
Commands::GetNodeInfo => {
Expand Down
8 changes: 6 additions & 2 deletions ldk-server-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
pub struct LdkServerClient {
base_url: String,
client: Client,
auth_credentials: (String, String),
}

impl LdkServerClient {
/// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint.
pub fn new(base_url: String) -> Self {
Self { base_url, client: Client::new() }
/// `username` and `password` are used for basic authentication.
pub fn new(base_url: String, username: String, password: String) -> Self {
Self { base_url, client: Client::new(), auth_credentials: (username, password) }
}

/// Retrieve the latest node info like `node_id`, `current_best_block` etc.
Expand Down Expand Up @@ -196,10 +198,12 @@ impl LdkServerClient {
&self, request: &Rq, url: &str,
) -> Result<Rs, LdkServerError> {
let request_body = request.encode_to_vec();
let (username, password) = &self.auth_credentials;
let response_raw = self
.client
.post(url)
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
.basic_auth(username, Some(password))
.body(request_body)
.send()
.await
Expand Down
1 change: 1 addition & 0 deletions ldk-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async-trait = { version = "0.1.85", default-features = false }
toml = { version = "0.8.9", default-features = false, features = ["parse"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
log = "0.4.28"
base64 = { version = "0.21", default-features = false, features = ["std"] }

# Required for RabittMQ based EventPublisher. Only enabled for `events-rabbitmq` feature.
lapin = { version = "2.4.0", features = ["rustls"], default-features = false, optional = true }
Expand Down
5 changes: 5 additions & 0 deletions ldk-server/ldk-server-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ dir_path = "/tmp/ldk-server/" # Path for LDK and BDK data persis
level = "Debug" # Log level (Error, Warn, Info, Debug, Trace)
file_path = "/tmp/ldk-server/ldk-server.log" # Log file path

# HTTP Basic Authentication (REQUIRED)
[auth]
username = "your-username"
password = "your-password"

# Must set either bitcoind or esplora settings, but not both

# Bitcoin Core settings
Expand Down
2 changes: 1 addition & 1 deletion ldk-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ fn main() {
match res {
Ok((stream, _)) => {
let io_stream = TokioIo::new(stream);
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store));
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), config_file.auth_config.clone());
runtime.spawn(async move {
if let Err(err) = http1::Builder::new().serve_connection(io_stream, node_service).await {
error!("Failed to serve connection: {}", err);
Expand Down
158 changes: 155 additions & 3 deletions ldk-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use ldk_node::bitcoin::base64::{self, Engine};
use ldk_node::Node;

use http_body_util::{BodyExt, Full, Limited};
use hyper::body::{Bytes, Incoming};
use hyper::header::AUTHORIZATION;
use hyper::service::Service;
use hyper::{Request, Response, StatusCode};

Expand All @@ -30,7 +32,7 @@ use crate::api::bolt12_receive::handle_bolt12_receive_request;
use crate::api::bolt12_send::handle_bolt12_send_request;
use crate::api::close_channel::{handle_close_channel_request, handle_force_close_channel_request};
use crate::api::error::LdkServerError;
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError};
use crate::api::get_balances::handle_get_balances_request;
use crate::api::get_node_info::handle_get_node_info_request;
use crate::api::get_payment_details::handle_get_payment_details_request;
Expand All @@ -43,6 +45,7 @@ use crate::api::open_channel::handle_open_channel;
use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request};
use crate::api::update_channel_config::handle_update_channel_config_request;
use crate::io::persist::paginated_kv_store::PaginatedKVStore;
use crate::util::config::BasicAuthConfig;
use crate::util::proto_adapter::to_error_response;
use std::future::Future;
use std::pin::Pin;
Expand All @@ -56,11 +59,44 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
pub struct NodeService {
node: Arc<Node>,
paginated_kv_store: Arc<dyn PaginatedKVStore>,
auth_config: BasicAuthConfig,
}

impl NodeService {
pub(crate) fn new(node: Arc<Node>, paginated_kv_store: Arc<dyn PaginatedKVStore>) -> Self {
Self { node, paginated_kv_store }
pub(crate) fn new(
node: Arc<Node>, paginated_kv_store: Arc<dyn PaginatedKVStore>,
auth_config: BasicAuthConfig,
) -> Self {
Self { node, paginated_kv_store, auth_config }
}
}

fn validate_auth<B>(req: &Request<B>, auth_config: &BasicAuthConfig) -> Result<(), LdkServerError> {
let auth_header = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| LdkServerError::new(AuthError, "Missing Authorization header"))?;

let encoded = auth_header
.strip_prefix("Basic ")
.ok_or_else(|| LdkServerError::new(AuthError, "Invalid Authorization header format"))?;

let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| LdkServerError::new(AuthError, "Invalid base64 encoding"))?;

let credentials = std::str::from_utf8(&decoded)
.map_err(|_| LdkServerError::new(AuthError, "Invalid credentials format"))?;

let (username, password) = credentials
.split_once(':')
.ok_or_else(|| LdkServerError::new(AuthError, "Invalid credentials format"))?;

if username == auth_config.username && password == auth_config.password {
Ok(())
} else {
Err(LdkServerError::new(AuthError, "Invalid credentials"))
}
}

Expand All @@ -75,6 +111,18 @@ impl Service<Request<Incoming>> for NodeService {
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

fn call(&self, req: Request<Incoming>) -> Self::Future {
// Validate authentication
if let Err(e) = validate_auth(&req, &self.auth_config) {
let (error_response, status_code) = to_error_response(e);
return Box::pin(async move {
Ok(Response::builder()
.status(status_code)
.body(Full::new(Bytes::from(error_response.encode_to_vec())))
// unwrap safety: body only errors when previous chained calls failed.
.unwrap())
});
}

let context = Context {
node: Arc::clone(&self.node),
paginated_kv_store: Arc::clone(&self.paginated_kv_store),
Expand Down Expand Up @@ -189,3 +237,107 @@ async fn handle_request<
},
}
}

#[cfg(test)]
mod tests {
use super::*;
use hyper::header::AUTHORIZATION;

fn create_test_request(auth_header: Option<String>) -> Request<()> {
let mut builder = Request::builder();
if let Some(header) = auth_header {
builder = builder.header(AUTHORIZATION, header);
}
builder.body(()).unwrap()
}

#[test]
fn test_validate_auth_success() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

// Create a valid Basic Auth header
let credentials = format!("{}:{}", auth_config.username, auth_config.password);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
let auth_header = format!("Basic {encoded}");

let req = create_test_request(Some(auth_header));

let result = validate_auth(&req, &auth_config);
assert!(result.is_ok());
}

#[test]
fn test_validate_auth_wrong_password() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

// Wrong password
let credentials = format!("{}:wrongpass", auth_config.username);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
let auth_header = format!("Basic {encoded}");

let req = create_test_request(Some(auth_header));

let result = validate_auth(&req, &auth_config);
assert!(result.is_err());
assert_eq!(result.unwrap_err().error_code, AuthError);
}

#[test]
fn test_validate_auth_wrong_username() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

// Wrong username
let credentials = format!("wronguser:{}", auth_config.password);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
let auth_header = format!("Basic {encoded}");

let req = create_test_request(Some(auth_header));

let result = validate_auth(&req, &auth_config);
assert!(result.is_err());
assert_eq!(result.unwrap_err().error_code, AuthError);
}

#[test]
fn test_validate_auth_missing_header() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

let req = create_test_request(None);

let result = validate_auth(&req, &auth_config);
assert!(result.is_err());
assert_eq!(result.unwrap_err().error_code, AuthError);
}

#[test]
fn test_validate_auth_invalid_format() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

let credentials = format!("{}:{}", auth_config.username, auth_config.password);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
// Missing "Basic " prefix
let req = create_test_request(Some(encoded));

let result = validate_auth(&req, &auth_config);
assert!(result.is_err());
assert_eq!(result.unwrap_err().error_code, AuthError);
}

#[test]
fn test_validate_auth_invalid_base64() {
let auth_config =
BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() };

// Invalid base64
let req = create_test_request(Some("Basic not-valid-base64!".to_string()));

let result = validate_auth(&req, &auth_config);
assert!(result.is_err());
assert_eq!(result.unwrap_err().error_code, AuthError);
}
}
Loading