From 84d5bfaad05a7c213aef60ca830b8f211091435a Mon Sep 17 00:00:00 2001 From: nazarevsky Date: Wed, 16 Sep 2026 17:40:16 +0200 Subject: [PATCH 1/3] [metrics] require bearer token auth for /metrics endpoint Adds authorization layer for /metrics endpoint which is a bearer token. When a query is performed, under the hood the token gets stripped and compared with the one that is provided to the plugin. Adds --metrics-token-file (path to a file holding the bearer token) and --metrics-disable-auth. The plugin refuses to start unless one of the two is set, so /metrics can't be exposed unauthenticated by accident; the disable flag logs a warning. Reading the token from a file keeps it out of the process list, CLN config, and shell history. --- Cargo.lock | 1 + metrics-plugin/Cargo.toml | 1 + metrics-plugin/README.md | 45 +++++++++++-- metrics-plugin/src/main.rs | 49 +++++++++++++- metrics-plugin/src/metrics.rs | 116 +++++++++++++++++++++++++++++++++- 5 files changed, 202 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2cd2b65..302c05f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2193,6 +2193,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower", "tracing", ] diff --git a/metrics-plugin/Cargo.toml b/metrics-plugin/Cargo.toml index 2ba1cc4..e11dbe7 100644 --- a/metrics-plugin/Cargo.toml +++ b/metrics-plugin/Cargo.toml @@ -19,3 +19,4 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } anyhow = { version = "1" } tracing = { version = "0.1" } +tower = { version = "0.5", features = ["util"] } diff --git a/metrics-plugin/README.md b/metrics-plugin/README.md index 7557b0b..d0444ac 100644 --- a/metrics-plugin/README.md +++ b/metrics-plugin/README.md @@ -6,12 +6,28 @@ Core Lightning plugin that exposes node liquidity metrics via a Prometheus `/met ## Configuration -Both options are set as CLN plugin options (e.g. in `config` or passed via `--plugin`): +These are set as CLN plugin options (e.g. in `config` or passed via `--plugin`): -| Option | Required | Default | Description | -|---------------------|----------|---------|------------------------------------------------------------------------| -| `--metrics-addr` | yes | - | Address and port to expose `/metrics` on (e.g. `127.0.0.1:9750`) | -| `--metrics-refresh` | no | `30` | How often (seconds) to refresh node data for gauge metrics. Minimum 5. | +| Option | Required | Default | Description | +|---------------------------|---------------------------------------------------|---------|--------------------------------------------------------------------------------| +| `--metrics-addr` | yes | - | Address and port to expose `/metrics` on (e.g. `127.0.0.1:9750`) | +| `--metrics-refresh` | no | `30` | How often (seconds) to refresh node data for gauge metrics. Minimum 5. | +| `--metrics-token-file` | yes, unless `--metrics-disable-auth` is set | - | Path to a file containing the bearer token required to access `/metrics`. | +| `--metrics-disable-auth` | no | `false` | Serve `/metrics` without authentication. **Insecure** - local/dev use only. | + +The plugin refuses to start unless either `--metrics-token-file` or `--metrics-disable-auth` is +set, so a deployment can't accidentally expose `/metrics` without making an explicit choice. When +`--metrics-disable-auth` is used, the plugin logs a warning on startup. + +The token is read once at startup from the given file (surrounding whitespace is trimmed), so it +never appears in the process list, CLN config file, or shell history the way a plain +`--metrics-token=...` value would. Keep the file's permissions restricted to the user CLN runs as, +e.g.: + +``` +install -m 600 /dev/null /path/to/metrics-token +echo -n "changeme" > /path/to/metrics-token +``` Example CLN config: @@ -19,6 +35,25 @@ Example CLN config: plugin=/path/to/metrics-plugin metrics-addr=127.0.0.1:9750 metrics-refresh=15 +metrics-token-file=/path/to/metrics-token +``` + +Scrape it with: + +``` +curl -H "Authorization: Bearer changeme" http://127.0.0.1:9750/metrics +``` + +Prometheus scrape config equivalent (Prometheus itself supports reading the token from a file +directly, so it also never has to hold the secret in its own config): + +```yaml +scrape_configs: + - job_name: cln-metrics + authorization: + credentials_file: /path/to/metrics-token + static_configs: + - targets: ["127.0.0.1:9750"] ``` --- diff --git a/metrics-plugin/src/main.rs b/metrics-plugin/src/main.rs index 98d126f..a61e9ee 100644 --- a/metrics-plugin/src/main.rs +++ b/metrics-plugin/src/main.rs @@ -15,7 +15,7 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tokio::net::TcpListener; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::cache::CachedData; use crate::metrics::RefreshHealth; @@ -36,6 +36,21 @@ const OPT_REFRESH_SECS: ConfigOption<'static, cln_plugin::options::config_type:: "How often (seconds) to refresh CLN node data for gauge metrics", ); +const OPT_METRICS_TOKEN_FILE: ConfigOption<'static, cln_plugin::options::config_type::String> = + ConfigOption::new_str_no_default( + "metrics-token-file", + "Path to a file containing the bearer token required to access the /metrics endpoint", + ); + +const OPT_METRICS_DISABLE_AUTH: ConfigOption< + 'static, + cln_plugin::options::config_type::DefaultBoolean, +> = ConfigOption::new_bool_with_default( + "metrics-disable-auth", + false, + "Serve /metrics without authentication (INSECURE - local/dev use only)", +); + /// Shared plugin state - event counters and a read-cache of the last CLN poll. /// Event counters are mutated atomically from subscription/hook handlers; the cache /// is rebuilt by the refresh loop and read by both 'rpc_status' and the metrics @@ -51,6 +66,8 @@ async fn main() -> Result<()> { let configured = Builder::new(tokio::io::stdin(), tokio::io::stdout()) .option(OPT_METRICS_ADDR) .option(OPT_REFRESH_SECS) + .option(OPT_METRICS_TOKEN_FILE) + .option(OPT_METRICS_DISABLE_AUTH) .subscribe("channel_opened", events::on_channel_opened) .subscribe("forward_event", events::on_forward_event) .subscribe("channel_state_changed", events::on_channel_state_changed) @@ -74,6 +91,21 @@ async fn main() -> Result<()> { .unwrap_or(DEFAULT_REFRESH_SECS as i64) .max(5) as u64; // floor at 5s to avoid hammering CLN RPC + let metrics_token_file = configured.option(&OPT_METRICS_TOKEN_FILE)?; + let disable_auth = configured.option(&OPT_METRICS_DISABLE_AUTH)?; + let auth_token: Option> = match (metrics_token_file, disable_auth) { + (Some(path), _) => Some(read_token_file(&path).await?), + (None, true) => { + warn!("metrics-disable-auth is set: /metrics is being served WITHOUT authentication"); + None + } + (None, false) => { + return Err(anyhow::anyhow!( + "metrics-token-file is required to protect /metrics; set --metrics-disable-auth to run without authentication" + )); + } + }; + let socket_path = PathBuf::from(configured.configuration().lightning_dir) .join(configured.configuration().rpc_file.clone()); @@ -99,7 +131,7 @@ async fn main() -> Result<()> { let listener = TcpListener::bind(addr).await?; let plugin = configured.start(state).await?; - metrics::start_metrics_server(listener).await?; + metrics::start_metrics_server(listener, auth_token).await?; spawn_refresh_loop(cache, socket_path, refresh_secs, health); @@ -141,6 +173,19 @@ fn spawn_refresh_loop( }); } +/// Reads the bearer token from `path`, trimming surrounding whitespace so a trailing newline +/// left by `echo` or an editor doesn't become part of the expected token. +async fn read_token_file(path: &str) -> Result> { + let contents = tokio::fs::read_to_string(path) + .await + .map_err(|e| anyhow::anyhow!("failed to read metrics-token-file {path}: {e}"))?; + let token = contents.trim(); + if token.is_empty() { + return Err(anyhow::anyhow!("metrics-token-file {path} is empty")); + } + Ok(Arc::from(token)) +} + async fn rpc_status(plugin: Plugin, _args: Value) -> Result { let cache = plugin.state().cache.read().expect("cache lock poisoned"); Ok(serde_json::json!({ diff --git a/metrics-plugin/src/metrics.rs b/metrics-plugin/src/metrics.rs index ba1b50f..2b87a31 100644 --- a/metrics-plugin/src/metrics.rs +++ b/metrics-plugin/src/metrics.rs @@ -1,7 +1,9 @@ use crate::cache::CachedData; use axum::{ Router, - http::StatusCode, + extract::{Request, State}, + http::{StatusCode, header}, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, }; @@ -521,8 +523,60 @@ async fn metrics_handler() -> Response { .into_response() } -pub async fn start_metrics_server(listener: TcpListener) -> anyhow::Result<()> { - let app = Router::new().route("/metrics", get(metrics_handler)); +/// Constant-time comparison to avoid leaking the configured token via response-timing side +/// channels. +fn tokens_match(provided: &[u8], expected: &[u8]) -> bool { + if provided.len() != expected.len() { + return false; + } + + provided + .iter() + .zip(expected.iter()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +fn is_authorized(request: &Request, token: &str) -> bool { + let Some(header) = request.headers().get(header::AUTHORIZATION) else { + return false; + }; + + let Ok(header) = header.to_str() else { + return false; + }; + + let Some(provided) = header.strip_prefix("Bearer ") else { + return false; + }; + + tokens_match(provided.as_bytes(), token.as_bytes()) +} + +async fn require_bearer_token( + State(token): State>, + request: Request, + next: Next, +) -> Response { + if is_authorized(&request, &token) { + return next.run(request).await; + } + + (StatusCode::UNAUTHORIZED, "Unauthorized\n").into_response() +} + +/// `auth_token` gates the `/metrics` route with bearer-token auth when set; `None` serves it +/// unauthenticated (only reachable via the explicit `metrics-disable-auth` opt-out). +pub async fn start_metrics_server( + listener: TcpListener, + auth_token: Option>, +) -> anyhow::Result<()> { + let mut app = Router::new().route("/metrics", get(metrics_handler)); + + if let Some(token) = auth_token { + app = app.route_layer(middleware::from_fn_with_state(token, require_bearer_token)); + } + tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { error!("metrics server error: {}", e); @@ -530,3 +584,59 @@ pub async fn start_metrics_server(listener: TcpListener) -> anyhow::Result<()> { }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn protected_app() -> Router { + Router::new() + .route("/metrics", get(metrics_handler)) + .route_layer(middleware::from_fn_with_state( + Arc::::from("secret"), + require_bearer_token, + )) + } + + async fn get_metrics(app: Router, auth_header: Option<&str>) -> StatusCode { + let mut req = Request::builder().uri("/metrics"); + if let Some(value) = auth_header { + req = req.header(header::AUTHORIZATION, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + response.status() + } + + #[tokio::test] + async fn rejects_missing_authorization_header() { + assert_eq!( + get_metrics(protected_app(), None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn rejects_wrong_token() { + assert_eq!( + get_metrics(protected_app(), Some("Bearer wrong")).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn accepts_correct_token() { + assert_eq!( + get_metrics(protected_app(), Some("Bearer secret")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn unprotected_app_serves_without_authorization() { + let app = Router::new().route("/metrics", get(metrics_handler)); + assert_eq!(get_metrics(app, None).await, StatusCode::OK); + } +} From c0113e4d0de81186dccd1fb36f9a2d1f0bae12bb Mon Sep 17 00:00:00 2001 From: nazarevsky Date: Wed, 16 Sep 2026 17:53:54 +0200 Subject: [PATCH 2/3] [metrics] validate minimum length of bearer token Reject tokens shorter than 16 characters (and empty ones) when reading --metrics-token-file at startup, to catch weak or placeholder values before /metrics is exposed. Updates the README example to generate a random token with openssl instead of a short placeholder. --- metrics-plugin/README.md | 8 +++-- metrics-plugin/src/main.rs | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/metrics-plugin/README.md b/metrics-plugin/README.md index d0444ac..fc3117c 100644 --- a/metrics-plugin/README.md +++ b/metrics-plugin/README.md @@ -26,9 +26,13 @@ e.g.: ``` install -m 600 /dev/null /path/to/metrics-token -echo -n "changeme" > /path/to/metrics-token +openssl rand -hex 32 > /path/to/metrics-token ``` +The plugin rejects tokens shorter than 16 characters at startup (and an empty file), to catch +weak or placeholder values before `/metrics` is ever exposed. Generate a long, random token rather +than a memorable password - e.g. `openssl rand -hex 32` above. + Example CLN config: ``` @@ -41,7 +45,7 @@ metrics-token-file=/path/to/metrics-token Scrape it with: ``` -curl -H "Authorization: Bearer changeme" http://127.0.0.1:9750/metrics +curl -H "Authorization: Bearer $(cat /path/to/metrics-token)" http://127.0.0.1:9750/metrics ``` Prometheus scrape config equivalent (Prometheus itself supports reading the token from a file diff --git a/metrics-plugin/src/main.rs b/metrics-plugin/src/main.rs index a61e9ee..22009bc 100644 --- a/metrics-plugin/src/main.rs +++ b/metrics-plugin/src/main.rs @@ -173,6 +173,10 @@ fn spawn_refresh_loop( }); } +/// Tokens shorter than this are rejected at startup - long enough to rule out trivially +/// guessable values without dictating a specific format. +const MIN_TOKEN_LEN: usize = 16; + /// Reads the bearer token from `path`, trimming surrounding whitespace so a trailing newline /// left by `echo` or an editor doesn't become part of the expected token. async fn read_token_file(path: &str) -> Result> { @@ -183,6 +187,11 @@ async fn read_token_file(path: &str) -> Result> { if token.is_empty() { return Err(anyhow::anyhow!("metrics-token-file {path} is empty")); } + if token.len() < MIN_TOKEN_LEN { + return Err(anyhow::anyhow!( + "metrics-token-file {path} holds a token shorter than {MIN_TOKEN_LEN} characters; use a longer, random value" + )); + } Ok(Arc::from(token)) } @@ -194,3 +203,61 @@ async fn rpc_status(plugin: Plugin, _args: Value) -> Result PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "metrics-plugin-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, contents).unwrap(); + path + } + + #[tokio::test] + async fn trims_whitespace_and_trailing_newline() { + let file = tempfile_with_contents(" a-long-enough-token\n"); + let token = read_token_file(file.to_str().unwrap()).await.unwrap(); + assert_eq!(&*token, "a-long-enough-token"); + std::fs::remove_file(&file).unwrap(); + } + + #[tokio::test] + async fn rejects_empty_file() { + let file = tempfile_with_contents(" \n"); + let err = read_token_file(file.to_str().unwrap()).await.unwrap_err(); + assert!(err.to_string().contains("is empty")); + std::fs::remove_file(&file).unwrap(); + } + + #[tokio::test] + async fn rejects_token_shorter_than_minimum() { + let file = tempfile_with_contents("short\n"); + let err = read_token_file(file.to_str().unwrap()).await.unwrap_err(); + assert!(err.to_string().contains("shorter than")); + std::fs::remove_file(&file).unwrap(); + } + + #[tokio::test] + async fn accepts_token_at_exactly_the_minimum_length() { + let token = "a".repeat(MIN_TOKEN_LEN); + let file = tempfile_with_contents(&token); + let read = read_token_file(file.to_str().unwrap()).await.unwrap(); + assert_eq!(&*read, token.as_str()); + std::fs::remove_file(&file).unwrap(); + } + + #[tokio::test] + async fn rejects_missing_file() { + let err = read_token_file("/nonexistent/metrics-token") + .await + .unwrap_err(); + assert!(err.to_string().contains("failed to read")); + } +} From 3a7c77bac50528d33736e7809a76c6e3d52447e1 Mon Sep 17 00:00:00 2001 From: nazarevsky Date: Wed, 16 Sep 2026 18:02:07 +0200 Subject: [PATCH 3/3] [metrics] use String for bearer token, fix test build Switches the token plumbing from Arc to String throughout, and fixes the auth test helper in metrics.rs. --- metrics-plugin/src/main.rs | 9 ++++++--- metrics-plugin/src/metrics.rs | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/metrics-plugin/src/main.rs b/metrics-plugin/src/main.rs index 22009bc..af9aa6e 100644 --- a/metrics-plugin/src/main.rs +++ b/metrics-plugin/src/main.rs @@ -93,7 +93,7 @@ async fn main() -> Result<()> { let metrics_token_file = configured.option(&OPT_METRICS_TOKEN_FILE)?; let disable_auth = configured.option(&OPT_METRICS_DISABLE_AUTH)?; - let auth_token: Option> = match (metrics_token_file, disable_auth) { + let auth_token: Option = match (metrics_token_file, disable_auth) { (Some(path), _) => Some(read_token_file(&path).await?), (None, true) => { warn!("metrics-disable-auth is set: /metrics is being served WITHOUT authentication"); @@ -179,20 +179,23 @@ const MIN_TOKEN_LEN: usize = 16; /// Reads the bearer token from `path`, trimming surrounding whitespace so a trailing newline /// left by `echo` or an editor doesn't become part of the expected token. -async fn read_token_file(path: &str) -> Result> { +async fn read_token_file(path: &str) -> Result { let contents = tokio::fs::read_to_string(path) .await .map_err(|e| anyhow::anyhow!("failed to read metrics-token-file {path}: {e}"))?; let token = contents.trim(); + if token.is_empty() { return Err(anyhow::anyhow!("metrics-token-file {path} is empty")); } + if token.len() < MIN_TOKEN_LEN { return Err(anyhow::anyhow!( "metrics-token-file {path} holds a token shorter than {MIN_TOKEN_LEN} characters; use a longer, random value" )); } - Ok(Arc::from(token)) + + Ok(token.to_string()) } async fn rpc_status(plugin: Plugin, _args: Value) -> Result { diff --git a/metrics-plugin/src/metrics.rs b/metrics-plugin/src/metrics.rs index 2b87a31..fd8d744 100644 --- a/metrics-plugin/src/metrics.rs +++ b/metrics-plugin/src/metrics.rs @@ -554,7 +554,7 @@ fn is_authorized(request: &Request, token: &str) -> bool { } async fn require_bearer_token( - State(token): State>, + State(token): State, request: Request, next: Next, ) -> Response { @@ -569,7 +569,7 @@ async fn require_bearer_token( /// unauthenticated (only reachable via the explicit `metrics-disable-auth` opt-out). pub async fn start_metrics_server( listener: TcpListener, - auth_token: Option>, + auth_token: Option, ) -> anyhow::Result<()> { let mut app = Router::new().route("/metrics", get(metrics_handler)); @@ -596,7 +596,7 @@ mod tests { Router::new() .route("/metrics", get(metrics_handler)) .route_layer(middleware::from_fn_with_state( - Arc::::from("secret"), + "secret".to_string(), require_bearer_token, )) }