-
Notifications
You must be signed in to change notification settings - Fork 0
metrics-plugin: add authentication #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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> = | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are already StringConfigOption and DefaultStringConfigOption types declared in cln_plugin::options |
||
| 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< | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are already BoolConfigOption and DefaultBoolConfigOption declared in cln_plugin::options |
||
| '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<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()); | ||
|
|
||
|
|
@@ -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,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> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure about storing token in file... i think devopses may prefer envs cause it may be easier for them to setup secrets securely. With env approach, we can just leave the disable auth option, and if it wasnt set require the existence of env. What fo you think? |
||
| 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!({ | ||
|
|
@@ -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")); | ||
| } | ||
| } | ||
| 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, | ||
| }; | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we work with bytes? token is already stored as a string, so we can just compare strings |
||
| 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, not sure, but usually i think it may be preferred to fix the token encoding, like URL-compatible Base64. I understand that you may want to leave the decision to the plugin user, but we should be sure that it cant be a potential attack vector |
||
| 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like you're using it only in tests, then I'd rather move it into [dev-dependencies]