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.

1 change: 1 addition & 0 deletions metrics-plugin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
49 changes: 44 additions & 5 deletions metrics-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,58 @@ 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
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:

```
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 $(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
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"]
```

---
Expand Down
119 changes: 117 additions & 2 deletions metrics-plugin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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<String> = 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());

Expand All @@ -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);

Expand Down Expand Up @@ -141,6 +173,31 @@ 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<String> {
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(token.to_string())
}

async fn rpc_status(plugin: Plugin<PluginState>, _args: Value) -> Result<Value, anyhow::Error> {
let cache = plugin.state().cache.read().expect("cache lock poisoned");
Ok(serde_json::json!({
Expand All @@ -149,3 +206,61 @@ async fn rpc_status(plugin: Plugin<PluginState>, _args: Value) -> Result<Value,
"blockheight": cache.node.blockheight,
}))
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};

fn tempfile_with_contents(contents: &str) -> 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"));
}
}
116 changes: 113 additions & 3 deletions metrics-plugin/src/metrics.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -521,12 +523,120 @@ 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<String>,
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<String>,
) -> 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);
}
});
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(
"secret".to_string(),
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);
}
}
Loading