Skip to content

Commit d4e5ea2

Browse files
committed
Add basic auth support for the metrics endpoint
1 parent 788c253 commit d4e5ea2

File tree

7 files changed

+240
-18
lines changed

7 files changed

+240
-18
lines changed

e2e-tests/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,24 @@ pub struct LdkServerHandle {
9797
client: LdkServerClient,
9898
}
9999

100+
pub struct LdkServerConfig {
101+
pub metrics_auth: Option<(String, String)>,
102+
}
103+
104+
impl Default for LdkServerConfig {
105+
fn default() -> Self {
106+
Self { metrics_auth: None }
107+
}
108+
}
109+
100110
impl LdkServerHandle {
101111
/// Starts a new ldk-server instance against the given bitcoind.
102112
/// Waits until the server is ready to accept requests.
103113
pub async fn start(bitcoind: &TestBitcoind) -> Self {
114+
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
115+
}
116+
117+
pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
104118
#[allow(deprecated)]
105119
let storage_dir = tempfile::tempdir().unwrap().into_path();
106120
let rest_port = find_available_port();
@@ -111,6 +125,12 @@ impl LdkServerHandle {
111125

112126
let exchange_name = format!("e2e_test_exchange_{rest_port}");
113127

128+
let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
129+
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
130+
} else {
131+
String::new()
132+
};
133+
114134
let config_content = format!(
115135
r#"[node]
116136
network = "regtest"
@@ -144,7 +164,9 @@ client_trusts_lsp = true
144164
[metrics]
145165
enabled = true
146166
poll_metrics_interval = 1
167+
{}
147168
"#,
169+
metrics_auth_config,
148170
storage_dir = storage_dir.display(),
149171
);
150172

e2e-tests/tests/e2e.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use std::time::Duration;
1212

1313
use e2e_tests::{
1414
find_available_port, mine_and_sync, run_cli, run_cli_raw, setup_funded_channel,
15-
wait_for_onchain_balance, LdkServerHandle, RabbitMqEventConsumer, TestBitcoind,
15+
wait_for_onchain_balance, LdkServerConfig, LdkServerHandle, RabbitMqEventConsumer,
16+
TestBitcoind,
1617
};
1718
use hex_conservative::{DisplayHex, FromHex};
1819
use ldk_node::bitcoin::hashes::{sha256, Hash};
@@ -1087,3 +1088,40 @@ async fn test_metrics_endpoint() {
10871088
tokio::time::sleep(Duration::from_millis(500)).await;
10881089
}
10891090
}
1091+
1092+
#[tokio::test]
1093+
async fn test_metrics_endpoint_with_auth() {
1094+
let bitcoind = TestBitcoind::new();
1095+
1096+
let username = "admin";
1097+
let password = "password123";
1098+
1099+
let config =
1100+
LdkServerConfig { metrics_auth: Some((username.to_string(), password.to_string())) };
1101+
1102+
let server = LdkServerHandle::start_with_config(&bitcoind, config).await;
1103+
let client = server.client();
1104+
1105+
// Should fail because auth is provided in the config
1106+
let result = client.get_metrics().await;
1107+
assert!(result.is_err(), "Expected failure without credentials");
1108+
1109+
// Request has the correct auth, so it should succeed
1110+
let result = client.get_metrics_with_auth(Some(username), Some(password)).await;
1111+
1112+
assert!(result.is_ok(), "Expected success with correct credentials");
1113+
let metrics = result.unwrap();
1114+
1115+
assert!(metrics.contains("ldk_server_total_peers_count 0"));
1116+
assert!(metrics.contains("ldk_server_total_payments_count 0"));
1117+
assert!(metrics.contains("ldk_server_total_successful_payments_count 0"));
1118+
assert!(metrics.contains("ldk_server_total_pending_payments_count 0"));
1119+
assert!(metrics.contains("ldk_server_total_failed_payments_count 0"));
1120+
assert!(metrics.contains("ldk_server_total_channels_count 0"));
1121+
assert!(metrics.contains("ldk_server_total_public_channels_count 0"));
1122+
assert!(metrics.contains("ldk_server_total_private_channels_count 0"));
1123+
assert!(metrics.contains("ldk_server_total_onchain_balance_sats 0"));
1124+
assert!(metrics.contains("ldk_server_spendable_onchain_balance_sats 0"));
1125+
assert!(metrics.contains("ldk_server_total_anchor_channels_reserve_sats 0"));
1126+
assert!(metrics.contains("ldk_server_total_lightning_balance_sats 0"));
1127+
}

ldk-server-client/src/client.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ use ldk_server_protos::endpoints::{
4040
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
4141
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH,
4242
DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH,
43-
GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH,
44-
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
45-
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
46-
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
47-
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
43+
GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
44+
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
45+
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
46+
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
47+
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
48+
VERIFY_SIGNATURE_PATH,
4849
};
4950
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
5051
use prost::bytes::Bytes;
@@ -123,8 +124,17 @@ impl LdkServerClient {
123124

124125
/// Retrieve the node metrics in Prometheus format.
125126
pub async fn get_metrics(&self) -> Result<String, LdkServerError> {
127+
self.get_metrics_with_auth(None, None).await
128+
}
129+
130+
/// Retrieve the node metrics in Prometheus format using Basic Auth.
131+
pub async fn get_metrics_with_auth(
132+
&self, username: Option<&str>, password: Option<&str>,
133+
) -> Result<String, LdkServerError> {
126134
let url = format!("https://{}/{GET_METRICS_PATH}", self.base_url);
127-
let payload = self.make_request(&url, RequestType::Get, None, false).await?;
135+
let payload =
136+
self.make_request(&url, RequestType::Get, None, false, username, password).await?;
137+
128138
String::from_utf8(payload.to_vec()).map_err(|e| {
129139
LdkServerError::new(
130140
InternalError,
@@ -468,21 +478,23 @@ impl LdkServerClient {
468478
&self, request: &Rq, url: &str,
469479
) -> Result<Rs, LdkServerError> {
470480
let request_body = request.encode_to_vec();
471-
let payload = self.make_request(url, RequestType::Post, Some(request_body), true).await?;
481+
let payload =
482+
self.make_request(url, RequestType::Post, Some(request_body), true, None, None).await?;
472483
Rs::decode(&payload[..]).map_err(|e| {
473484
LdkServerError::new(InternalError, format!("Failed to decode success response: {}", e))
474485
})
475486
}
476487

477488
async fn make_request(
478-
&self, url: &str, request_type: RequestType, body: Option<Vec<u8>>, authenticated: bool,
489+
&self, url: &str, request_type: RequestType, body: Option<Vec<u8>>,
490+
hmac_authenticated: bool, metrics_username: Option<&str>, metrics_password: Option<&str>,
479491
) -> Result<Bytes, LdkServerError> {
480492
let builder = match request_type {
481493
RequestType::Get => self.client.get(url),
482494
RequestType::Post => self.client.post(url),
483495
};
484496

485-
let builder = if authenticated {
497+
let builder = if hmac_authenticated {
486498
let body_for_auth = body.as_deref().unwrap_or(&[]);
487499
let auth_header = self.compute_auth_header(body_for_auth);
488500
builder.header("X-Auth", auth_header)
@@ -496,10 +508,23 @@ impl LdkServerClient {
496508
builder
497509
};
498510

511+
let builder = if let (Some(username), Some(password)) = (metrics_username, metrics_password)
512+
{
513+
builder.basic_auth(username, Some(password))
514+
} else {
515+
builder
516+
};
517+
499518
let response_raw = builder.send().await.map_err(|e| {
500519
LdkServerError::new(InternalError, format!("HTTP request failed: {}", e))
501520
})?;
502521

522+
self.handle_response(response_raw).await
523+
}
524+
525+
async fn handle_response(
526+
&self, response_raw: reqwest::Response,
527+
) -> Result<Bytes, LdkServerError> {
503528
let status = response_raw.status();
504529
let payload = response_raw.bytes().await.map_err(|e| {
505530
LdkServerError::new(InternalError, format!("Failed to read response body: {}", e))

ldk-server/ldk-server-config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ client_trusts_lsp = false
9393
[metrics]
9494
enabled = false
9595
poll_metrics_interval = 60 # The polling interval for metrics in seconds. Defaults to 60secs if unset and metrics enabled.
96+
# The auth details below are optional, but uncommenting the fields means enabling basic auth, so valid fields must be supplied.
97+
#username = "" # The username required to access the metrics endpoint (Basic Auth).
98+
#password = "" # The password required to access the metrics endpoint (Basic Auth).

ldk-server/src/main.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use std::path::{Path, PathBuf};
1818
use std::sync::Arc;
1919
use std::time::{Duration, SystemTime, UNIX_EPOCH};
2020

21+
use base64::prelude::BASE64_STANDARD;
22+
use base64::Engine;
2123
use clap::Parser;
2224
use hex::DisplayHex;
2325
use hyper::server::conn::http1;
@@ -296,6 +298,15 @@ fn main() {
296298
None
297299
};
298300

301+
let metrics_auth_header = if let (Some(username), Some(password)) =
302+
(config_file.metrics_username.as_ref(), config_file.metrics_password.as_ref())
303+
{
304+
let auth = format!("{}:{}", username, password);
305+
Some(format!("Basic {}", BASE64_STANDARD.encode(auth)))
306+
} else {
307+
None
308+
};
309+
299310
let rest_svc_listener = TcpListener::bind(config_file.rest_service_addr)
300311
.await
301312
.expect("Failed to bind listening port");
@@ -488,7 +499,13 @@ fn main() {
488499
res = rest_svc_listener.accept() => {
489500
match res {
490501
Ok((stream, _)) => {
491-
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone(), metrics.clone());
502+
let node_service = NodeService::new(
503+
Arc::clone(&node),
504+
Arc::clone(&paginated_store),
505+
api_key.clone(),
506+
metrics.clone(),
507+
metrics_auth_header.clone(),
508+
);
492509
let acceptor = tls_acceptor.clone();
493510
runtime.spawn(async move {
494511
match acceptor.accept(stream).await {

ldk-server/src/service.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ use ldk_server_protos::endpoints::{
2424
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
2525
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH,
2626
DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH,
27-
GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH,
28-
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
29-
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
30-
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
31-
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
27+
GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
28+
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
29+
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
30+
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
31+
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
32+
VERIFY_SIGNATURE_PATH,
3233
};
3334
use prost::Message;
3435

@@ -85,14 +86,15 @@ pub struct NodeService {
8586
paginated_kv_store: Arc<dyn PaginatedKVStore>,
8687
api_key: String,
8788
metrics: Option<Arc<Metrics>>,
89+
metrics_auth_header: Option<String>,
8890
}
8991

9092
impl NodeService {
9193
pub(crate) fn new(
9294
node: Arc<Node>, paginated_kv_store: Arc<dyn PaginatedKVStore>, api_key: String,
93-
metrics: Option<Arc<Metrics>>,
95+
metrics: Option<Arc<Metrics>>, metrics_auth_header: Option<String>,
9496
) -> Self {
95-
Self { node, paginated_kv_store, api_key, metrics }
97+
Self { node, paginated_kv_store, api_key, metrics, metrics_auth_header }
9698
}
9799
}
98100

@@ -181,6 +183,19 @@ impl Service<Request<Incoming>> for NodeService {
181183
&& req.uri().path().len() > 1
182184
&& &req.uri().path()[1..] == GET_METRICS_PATH
183185
{
186+
if let Some(expected_header) = &self.metrics_auth_header {
187+
let auth_header = req.headers().get("Authorization").and_then(|h| h.to_str().ok());
188+
if auth_header != Some(expected_header) {
189+
return Box::pin(async move {
190+
Ok(Response::builder()
191+
.status(StatusCode::UNAUTHORIZED)
192+
.header("WWW-Authenticate", "Basic realm=\"metrics\"")
193+
.body(Full::new(Bytes::from("Unauthorized")))
194+
.unwrap())
195+
});
196+
}
197+
}
198+
184199
if let Some(metrics) = &self.metrics {
185200
let metrics = Arc::clone(metrics);
186201
return Box::pin(async move {

0 commit comments

Comments
 (0)