clnrest: add clnrest-register-path method for dynamic paths

Changelog-Added: clnrest: add clnrest-register-path rpc method to register dynamic paths
This commit is contained in:
daywalker90
2026-01-07 13:34:06 +01:00
committed by Rusty Russell
parent 8bc2e76f44
commit d03cf820a8
25 changed files with 1627 additions and 334 deletions

View File

@@ -13,23 +13,39 @@ bytes = "1"
log = { version = "0.4", features = ['std'] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yml = "0.0.12"
quick-xml = { version = "0.37", features = ["serialize"] }
serde_yaml_ng = "0.10.0"
quick-xml = { version = "0.38", features = ["serialize"] }
roxmltree_to_serde = "0.6"
serde_qs = "0.15"
tokio-util = { version = "0.7", features = ["codec"] }
tokio = { version="1", features = ['io-std', 'rt-multi-thread', 'sync', 'macros', 'io-util'] }
tokio = { version = "1", features = [
'io-std',
'rt-multi-thread',
'sync',
'macros',
'io-util',
] }
axum = "0.8"
axum-server = { version = "0.6", features = ["tls-rustls"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] }
rustls = { version = "0.23", default-features = false, features = [
"logging",
"tls12",
"std",
"ring",
] }
matchit = "0.9"
futures-util = { version = "0.3", default-features = false, features = [
"sink",
"std",
] }
rcgen = "0.13"
hyper = "1"
tower= "0.5"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "set-header"] }
utoipa = { version = "5", features = ['axum_extras'] }
log-panics = "2"
socketioxide = "0.15"
socketioxide = { version = "0.16", features = ["state"] }
cln-plugin = { workspace = true }
cln-rpc = { workspace = true }

View File

@@ -3,8 +3,8 @@ use std::{collections::HashMap, process};
use anyhow::anyhow;
use axum::{
body::{to_bytes, Body},
extract::{Extension, Json, Path, State},
http::{Request, StatusCode},
extract::{Extension, Json, Path},
http::{self, Request, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
};
@@ -15,36 +15,14 @@ use cln_rpc::{
};
use serde_json::json;
use socketioxide::extract::{Data, SocketRef};
use std::fmt::Write;
use crate::{
shared::{call_rpc, filter_json, verify_rune},
PluginState, SWAGGER_FALLBACK,
shared::{call_rpc, filter_json, path_to_rest_map_and_params, verify_rune},
structs::{AppError, CheckRuneParams, ClnrestMap, PluginState},
SWAGGER_FALLBACK,
};
#[derive(Debug)]
pub enum AppError {
Unauthorized(RpcError),
Forbidden(RpcError),
NotFound(RpcError),
InternalServerError(RpcError),
NotAcceptable(RpcError),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err),
AppError::Forbidden(err) => (StatusCode::FORBIDDEN, err),
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err),
AppError::InternalServerError(err) => (StatusCode::INTERNAL_SERVER_ERROR, err),
AppError::NotAcceptable(err) => (StatusCode::NOT_ACCEPTABLE, err),
};
let body = Json(json!(error_message));
(status, body).into_response()
}
}
/* Handler for list-methods */
#[utoipa::path(
get,
@@ -57,7 +35,7 @@ impl IntoResponse for AppError {
pub async fn list_methods(
Extension(plugin): Extension<Plugin<PluginState>>,
) -> Result<Html<String>, AppError> {
match call_rpc(plugin, "help", json!(HelpRequest { command: None })).await {
match call_rpc(&plugin, "help", json!(HelpRequest { command: None })).await {
Ok(help_response) => {
let html_content = process_help_response(help_response);
Ok(Html(html_content))
@@ -72,14 +50,20 @@ pub async fn list_methods(
fn process_help_response(help_response: serde_json::Value) -> String {
/* Parse the "help" field as an array of HelpCommand */
let processed_res: HelpResponse = serde_json::from_value(help_response).unwrap();
let processed_res: HelpResponse = match serde_json::from_value(help_response) {
Ok(res) => res,
Err(e) => {
log::error!("Failed to parse help response: {e}");
return format!("Failed to parse help response: {e}");
}
};
let line = "\n---------------------------------------------------------------------------------------------------------------------------------------------------------------------------\n\n";
let line = "\n---------------------------------------------------------------------------------------------------------------------------------------------------------------------------\n";
let mut processed_html_res = String::new();
for row in processed_res.help {
processed_html_res.push_str(&format!("Command: {}\n", row.command));
processed_html_res.push_str(line);
writeln!(processed_html_res, "Command: {}", row.command).unwrap();
writeln!(processed_html_res, "{line}").unwrap();
}
processed_html_res
@@ -120,7 +104,8 @@ struct DynamicForm(HashMap<String, String>);
security(("api_key" = []))
)]
pub async fn call_rpc_method(
Path(rpc_method): Path<String>,
http_method: http::Method,
Path(path): Path<String>,
headers: axum::http::HeaderMap,
Extension(plugin): Extension<Plugin<PluginState>>,
body: Request<Body>,
@@ -141,13 +126,26 @@ pub async fn call_rpc_method(
}
};
let mut rpc_params = convert_request_to_json(&headers, &rpc_method, request_bytes)?;
let (mut rest_map, mut rpc_params) = path_to_rest_map_and_params(&plugin, &path, &http_method)?;
filter_json(&mut rpc_params);
request_body_to_rpc_params(
&mut rpc_params,
&headers,
&rest_map.rpc_method,
request_bytes,
)?;
verify_rune(plugin.clone(), rune, &rpc_method, &rpc_params).await?;
fill_rune_restrictions(&mut rest_map, &rpc_params);
let cln_result = match call_rpc(plugin, &rpc_method, rpc_params).await {
let mut rpc_params_value = json!(rpc_params);
filter_json(&mut rpc_params_value);
if rest_map.rune_required || http_method != http::Method::GET {
verify_rune(&plugin, rune, &rest_map.rune_restrictions.unwrap()).await?;
}
let cln_result = match call_rpc(&plugin, &rest_map.rpc_method, rpc_params_value).await {
Ok(result) => result,
Err(err) => {
if let Some(code) = err.code {
@@ -159,14 +157,35 @@ pub async fn call_rpc_method(
}
};
convert_json_to_response(headers, &rpc_method, cln_result)
convert_json_to_response(headers, &rest_map.rpc_method, cln_result)
}
fn convert_request_to_json(
fn fill_rune_restrictions(
rest_map: &mut ClnrestMap,
rpc_params: &serde_json::Map<String, serde_json::Value>,
) {
if let Some(r) = &mut rest_map.rune_restrictions {
if r.params.is_none() {
r.params = Some(rpc_params.clone());
}
if r.method.is_none() {
r.method = Some(rest_map.rpc_method.clone());
}
} else {
rest_map.rune_restrictions = Some(CheckRuneParams {
nodeid: None,
method: Some(rest_map.rpc_method.clone()),
params: Some(rpc_params.clone()),
});
}
}
fn request_body_to_rpc_params(
rpc_params: &mut serde_json::Map<String, serde_json::Value>,
headers: &axum::http::HeaderMap,
rpc_method: &str,
request_bytes: axum::body::Bytes,
) -> Result<serde_json::Value, AppError> {
) -> Result<(), AppError> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
@@ -188,11 +207,11 @@ fn convert_request_to_json(
};
if request_bytes.is_empty() {
return Ok(json!({}));
return Ok(());
}
match format {
"yaml" => serde_yml::from_slice(&request_bytes).map_err(|e| {
let body_rpc_params: serde_json::Map<String, serde_json::Value> = match format {
"yaml" => serde_yaml_ng::from_slice(&request_bytes).map_err(|e| {
AppError::InternalServerError(RpcError {
code: None,
data: None,
@@ -202,7 +221,7 @@ fn convert_request_to_json(
e
),
})
}),
})?,
"xml" => {
let req_str = std::str::from_utf8(&request_bytes).map_err(|e| {
AppError::InternalServerError(RpcError {
@@ -237,23 +256,19 @@ fn convert_request_to_json(
message: format!("Use rpc method name as root element: `{}`", rpc_method),
})
})?;
Ok(json!(json_without_root))
}
"form" => {
let form_map: HashMap<String, serde_json::Value> = serde_qs::from_bytes(&request_bytes)
.map_err(|e| {
AppError::InternalServerError(RpcError {
code: None,
data: None,
message: format!(
"Could not parse `{}` FORM-URLENCODED request: {}",
String::from_utf8_lossy(&request_bytes),
e
),
})
})?;
Ok(json!(form_map))
serde_json::from_value(json_without_root.to_owned()).unwrap()
}
"form" => serde_qs::from_bytes(&request_bytes).map_err(|e| {
AppError::InternalServerError(RpcError {
code: None,
data: None,
message: format!(
"Could not parse `{}` FORM-URLENCODED request: {}",
String::from_utf8_lossy(&request_bytes),
e
),
})
})?,
_ => serde_json::from_slice(&request_bytes).map_err(|e| {
AppError::InternalServerError(RpcError {
code: None,
@@ -264,8 +279,29 @@ fn convert_request_to_json(
e
),
})
}),
})?,
};
merge_maps_disjoint(rpc_params, body_rpc_params)?;
Ok(())
}
fn merge_maps_disjoint(
base: &mut serde_json::Map<String, serde_json::Value>,
other: serde_json::Map<String, serde_json::Value>,
) -> Result<(), AppError> {
for (key, value) in other {
if base.contains_key(&key) {
return Err(AppError::NotAcceptable(RpcError {
code: None,
message: format!("Duplicate key: {key}"),
data: None,
}));
}
base.insert(key, value);
}
Ok(())
}
fn convert_json_to_response(
@@ -294,7 +330,7 @@ fn convert_json_to_response(
};
match format {
"yaml" => match serde_yml::to_string(&cln_result) {
"yaml" => match serde_yaml_ng::to_string(&cln_result) {
Ok(yaml) => Ok((
StatusCode::CREATED,
[("Content-Type", "application/yaml")],
@@ -366,36 +402,35 @@ pub async fn handle_notification(
}
}
pub async fn header_inspection_middleware(
State(plugin): State<Plugin<PluginState>>,
pub async fn swagger_redirect_middleware(
Extension(swagger_path): Extension<String>,
req: Request<Body>,
next: Next,
) -> Result<Response, AppError> {
let root_path = req.uri().path();
if !root_path.eq("/") && !root_path.eq("/socket.io/") {
return Ok(StatusCode::NOT_FOUND.into_response());
if root_path.eq("/") && swagger_path.eq("/") {
return Ok(Redirect::permanent(SWAGGER_FALLBACK).into_response());
}
let rune = req
.headers()
Ok(next.run(req).await)
}
pub async fn auth_socket_io_middleware(
socket: SocketRef,
socketioxide::extract::State(plugin): socketioxide::extract::State<Plugin<PluginState>>,
) -> Result<(), AppError> {
let rune = socket
.req_parts()
.headers
.get("rune")
.and_then(|v| v.to_str().ok())
.map(String::from);
let upgrade = req
.headers()
.get("upgrade")
.and_then(|v| v.to_str().ok())
.map(String::from);
if upgrade.is_some() {
match verify_rune(plugin, rune, "listclnrest-notifications", &json!({})).await {
Ok(()) => Ok(next.run(req).await),
Err(e) => Err(e),
}
} else if swagger_path.eq("/") {
Ok(Redirect::permanent(SWAGGER_FALLBACK).into_response())
} else {
Ok(StatusCode::NOT_FOUND.into_response())
}
let checkrune_params = CheckRuneParams {
nodeid: None,
method: Some("listclnrest-notifications".to_owned()),
params: None,
};
verify_rune(&plugin, rune, &checkrune_params).await
}

View File

@@ -1,67 +1,50 @@
use std::{net::SocketAddr, str::FromStr, time::Duration};
use std::{
collections::HashMap,
net::SocketAddr,
str::FromStr,
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::anyhow;
use axum::{
http::{HeaderName, HeaderValue},
middleware,
routing::{get, post},
routing::{any, get},
Extension, Router,
};
use axum_server::tls_rustls::RustlsConfig;
use certs::{do_certificates_exist, generate_certificates};
use cln_plugin::{Builder, Plugin};
use cln_plugin::{Builder, Plugin, RpcMethodBuilder};
use handlers::{
call_rpc_method, handle_notification, header_inspection_middleware, list_methods,
socketio_on_connect,
call_rpc_method, handle_notification, list_methods, socketio_on_connect,
swagger_redirect_middleware,
};
use options::*;
use socketioxide::SocketIo;
use serde_json::json;
use socketioxide::{handler::ConnectHandler, SocketIo, SocketIoBuilder};
use tokio::{
sync::mpsc::{self, Receiver, Sender},
sync::mpsc::{self, Receiver},
time,
};
use tower::ServiceBuilder;
use tower_http::set_header::SetResponseHeaderLayer;
use utoipa::{
openapi::{
security::{ApiKey, ApiKeyValue, SecurityScheme},
Components,
},
Modify, OpenApi,
};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::{
handlers::auth_socket_io_middleware,
parse::parse_register_path_args,
shared::filter_json,
structs::{ApiDoc, CheckRuneParams, ClnrestMap, ClnrestOptions, ClnrestProtocol, PluginState},
};
mod certs;
mod handlers;
mod options;
mod parse;
mod shared;
#[derive(Clone, Debug)]
struct PluginState {
notification_sender: Sender<serde_json::Value>,
}
#[derive(OpenApi)]
#[openapi(
paths(
handlers::list_methods,
handlers::call_rpc_method,
),
modifiers(&SecurityAddon),
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Components::new);
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("rune"))),
);
openapi.components = Some(components.clone())
}
}
mod structs;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
@@ -79,6 +62,11 @@ async fn main() -> Result<(), anyhow::Error> {
.option(OPT_CLNREST_CORS)
.option(OPT_CLNREST_CSP)
.option(OPT_CLNREST_SWAGGER)
.rpcmethod_from_builder(
RpcMethodBuilder::new("clnrest-register-path", register_path)
.description("Register a dynamic REST path for clnrest")
.usage("path rpc_method [http_method] [rune_required] [rune_restrictions]"),
)
.subscribe("*", handle_notification)
.dynamic()
.configure()
@@ -88,7 +76,7 @@ async fn main() -> Result<(), anyhow::Error> {
None => return Ok(()),
};
let clnrest_options = match parse_options(&plugin).await {
let clnrest_options = match parse_options(&plugin) {
Ok(opts) => opts,
Err(e) => return plugin.disable(&e.to_string()).await,
};
@@ -97,6 +85,7 @@ async fn main() -> Result<(), anyhow::Error> {
let state = PluginState {
notification_sender: notify_tx,
dyn_router: Arc::new(Mutex::new(matchit::Router::new())),
};
let plugin = plugin.start(state.clone()).await?;
@@ -121,9 +110,11 @@ async fn run_rest_server(
clnrest_options: ClnrestOptions,
notify_rx: Receiver<serde_json::Value>,
) -> Result<(), anyhow::Error> {
let (socket_layer, socket_io) = SocketIo::new_layer();
let (socket_layer, socket_io) = SocketIoBuilder::new()
.with_state(plugin.clone())
.build_layer();
socket_io.ns("/", socketio_on_connect);
socket_io.ns("/", socketio_on_connect.with(auth_socket_io_middleware));
tokio::spawn(notification_background_task(socket_io.clone(), notify_rx));
@@ -132,36 +123,31 @@ async fn run_rest_server(
} else {
clnrest_options.swagger.clone()
};
let swagger_router =
Router::new().merge(SwaggerUi::new(swagger_path).url("/swagger.json", ApiDoc::openapi()));
let rpc_router = Router::new()
let root_router = Router::new()
.route("/", get(|| async { "Hello, World!" }))
.layer(ServiceBuilder::new().layer(middleware::from_fn(swagger_redirect_middleware)))
.layer(Extension(clnrest_options.swagger));
let rpc_router = Router::new()
.route("/v1/list-methods", get(list_methods))
.route("/{*path}", any(call_rpc_method))
.layer(clnrest_options.cors)
.layer(Extension(plugin.clone()))
.layer(
ServiceBuilder::new()
.layer(middleware::from_fn_with_state(
plugin.clone(),
header_inspection_middleware,
))
.layer(socket_layer),
)
.layer(Extension(clnrest_options.swagger))
.nest(
"/v1",
Router::new()
.route("/list-methods", get(list_methods))
.route("/{rpc_method}", post(call_rpc_method))
.layer(clnrest_options.cors)
.layer(Extension(plugin.clone()))
.layer(
ServiceBuilder::new().layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_str("Content-Security-Policy")?,
HeaderValue::from_str(&clnrest_options.csp)?,
)),
),
ServiceBuilder::new().layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_str("Content-Security-Policy")?,
HeaderValue::from_str(&clnrest_options.csp)?,
)),
);
let app = swagger_router.merge(rpc_router);
let app = swagger_router
.merge(root_router)
.merge(rpc_router)
.layer(socket_layer);
match clnrest_options.protocol {
ClnrestProtocol::Https => {
@@ -176,6 +162,7 @@ async fn run_rest_server(
if !do_certificates_exist(&clnrest_options.certs) {
log::debug!("Certificates still not existing after retries. Generating...");
generate_certificates(&clnrest_options.certs, &plugin.option(&OPT_CLNREST_HOST)?)?;
log::debug!("Certificates generated.");
}
let config = RustlsConfig::from_pem_file(
@@ -207,10 +194,65 @@ async fn run_rest_server(
}
}
async fn register_path(
plugin: Plugin<PluginState>,
mut args: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
filter_json(&mut args);
let (path_input, http_method, clnrest_map) = parse_register_path_args(args)?;
if path_input.eq("/") {
return Err(anyhow!("Path must not be root"));
}
let path = path_input.trim_matches('/');
if path.is_empty() {
return Err(anyhow!("Path must not be empty"));
}
if path.contains("{*") {
return Err(anyhow!("Wildcards not supported"));
}
let mut dyn_router = plugin.state().dyn_router.lock().unwrap();
if let Ok(p) = dyn_router.at_mut(path) {
if p.value.contains_key(&http_method) {
return Err(anyhow!(
"Conflicting path '{}' already exists with http_method: {}",
path,
http_method,
));
}
p.value.insert(http_method.clone(), clnrest_map.clone());
} else {
let mut new_map = HashMap::new();
new_map.insert(http_method.clone(), clnrest_map.clone());
dyn_router.insert(path, new_map)?;
}
log::debug!(
"Registered path: {} with http_method: {} to rpc_method: {} with rune_required:{} \
and rune_restrictions:{}",
path,
http_method,
clnrest_map.rpc_method,
clnrest_map.rune_required,
if let Some(restr) = clnrest_map.rune_restrictions {
restr.to_string()
} else {
"{}".to_owned()
},
);
Ok(json!({}))
}
async fn notification_background_task(io: SocketIo, mut receiver: Receiver<serde_json::Value>) {
log::debug!("Background task spawned");
while let Some(notification) = receiver.recv().await {
match io.emit("message", &notification) {
match io.emit("message", &notification).await {
Ok(_) => (),
Err(e) => log::info!("Could not emit notification from background task: {}", e),
}

View File

@@ -15,7 +15,10 @@ use cln_plugin::{
};
use tower_http::cors::{Any, CorsLayer};
use crate::PluginState;
use crate::{
structs::{ClnrestOptions, ClnrestProtocol},
PluginState,
};
pub const OPT_CLNREST_PORT: IntegerConfigOption =
ConfigOption::new_i64_no_default("clnrest-port", "REST server port to listen");
@@ -40,21 +43,7 @@ pub const OPT_CLNREST_SWAGGER: DefaultStringConfigOption =
ConfigOption::new_str_with_default("clnrest-swagger-root", "/", "Root path for Swagger UI");
pub const SWAGGER_FALLBACK: &str = "/swagger-ui";
pub enum ClnrestProtocol {
Https,
Http,
}
pub struct ClnrestOptions {
pub certs: PathBuf,
pub protocol: ClnrestProtocol,
pub address_str: String,
pub address: SocketAddr,
pub cors: CorsLayer,
pub csp: String,
pub swagger: String,
}
pub async fn parse_options(
pub fn parse_options(
plugin: &ConfiguredPlugin<PluginState, tokio::io::Stdin, tokio::io::Stdout>,
) -> Result<ClnrestOptions, anyhow::Error> {
let port = if let Some(p) = plugin.option(&OPT_CLNREST_PORT)? {

View File

@@ -0,0 +1,114 @@
use std::str::FromStr;
use anyhow::anyhow;
use axum::http;
use crate::structs::{CheckRuneParams, ClnrestMap};
pub fn parse_register_path_args(
args: serde_json::Value,
) -> Result<(String, http::Method, ClnrestMap), anyhow::Error> {
let (path_input, http_method, clnrest_map) = match args {
serde_json::Value::Array(args_arr) => {
let path_input = args_arr
.first()
.ok_or_else(|| anyhow!("path is required"))?
.as_str()
.ok_or_else(|| anyhow!("path must be a string"))?
.to_owned();
let rpc_method = args_arr
.get(1)
.ok_or_else(|| anyhow!("rpc_method is required"))?
.as_str()
.ok_or_else(|| anyhow!("rpc_method must be a string"))?
.to_owned();
let http_method = if let Some(h) = args_arr.get(2) {
http::Method::from_str(
&h.as_str()
.ok_or_else(|| anyhow!("http_method must be a string"))?
.to_ascii_uppercase(),
)?
} else {
http::Method::POST
};
let rune_required = if let Some(r) = args_arr.get(3) {
r.as_bool()
.ok_or_else(|| anyhow!("rune_required must be a boolean"))?
} else {
true
};
let rune_restrictions: Option<CheckRuneParams> = if let Some(r) = args_arr.get(4) {
Some(serde_json::from_value(r.clone())?)
} else {
None
};
let clnrest_map = ClnrestMap {
rpc_method,
rune_required,
rune_restrictions,
};
(path_input, http_method, clnrest_map)
}
serde_json::Value::Object(map) => {
let path_input = map
.get("path")
.ok_or_else(|| anyhow!("path is required"))?
.as_str()
.ok_or_else(|| anyhow!("path must be a string"))?
.to_owned();
let rpc_method = map
.get("rpc_method")
.ok_or_else(|| anyhow!("rpc_method is required"))?
.as_str()
.ok_or_else(|| anyhow!("rpc_method must be a string"))?
.to_owned();
let http_method = if let Some(h) = map.get("http_method") {
http::Method::from_str(
&h.as_str()
.ok_or_else(|| anyhow!("http_method must be a string"))?
.to_ascii_uppercase(),
)?
} else {
http::Method::POST
};
let rune_required = if let Some(r) = map.get("rune_required") {
r.as_bool()
.ok_or_else(|| anyhow!("rune_required must be a boolean"))?
} else {
true
};
let rune_restrictions: Option<CheckRuneParams> =
if let Some(r) = map.get("rune_restrictions") {
Some(serde_json::from_value(r.clone())?)
} else {
None
};
let clnrest_map = ClnrestMap {
rpc_method,
rune_required,
rune_restrictions,
};
(path_input, http_method, clnrest_map)
}
_ => return Err(anyhow!("Input arguments must be an array or object")),
};
if !matches!(
http_method,
http::Method::GET
| http::Method::POST
| http::Method::PUT
| http::Method::PATCH
| http::Method::DELETE
) {
return Err(anyhow!("{} is not a supported http method!", http_method));
}
if http_method != http::Method::GET && !clnrest_map.rune_required {
return Err(anyhow!(
"rune_required must be true for anything but GET requests"
));
}
Ok((path_input, http_method, clnrest_map))
}

View File

@@ -1,3 +1,4 @@
use axum::http;
use cln_plugin::Plugin;
use cln_rpc::{
model::responses::{CheckruneResponse, ShowrunesResponse},
@@ -5,13 +6,12 @@ use cln_rpc::{
};
use serde_json::json;
use crate::{handlers::AppError, PluginState};
use crate::{structs::AppError, CheckRuneParams, ClnrestMap, PluginState};
pub async fn verify_rune(
plugin: Plugin<PluginState>,
plugin: &Plugin<PluginState>,
rune_header: Option<String>,
rpc_method: &str,
rpc_params: &serde_json::Value,
checkrune_params: &CheckRuneParams,
) -> Result<(), AppError> {
let rune = match rune_header {
Some(rune) => rune,
@@ -21,21 +21,28 @@ pub async fn verify_rune(
data: None,
message: "Not authorized: Missing rune".to_string(),
};
log::info!("verify_rune failed: method:`{}` {}", rpc_method, err);
log::info!("verify_rune failed: {checkrune_params} {err}");
return Err(AppError::Forbidden(err));
}
};
let checkrune_result = match call_rpc(
plugin.clone(),
"checkrune",
json!({"rune": rune, "method": rpc_method, "params": rpc_params}),
)
.await
{
let mut rpc_params = serde_json::Map::new();
rpc_params.insert("rune".to_owned(), json!(rune));
if let Some(nodeid) = &checkrune_params.nodeid {
rpc_params.insert("nodeid".to_owned(), json!(nodeid));
}
if let Some(method) = &checkrune_params.method {
rpc_params.insert("method".to_owned(), json!(method));
}
if let Some(params) = &checkrune_params.params {
rpc_params.insert("params".to_owned(), json!(params));
}
let rpc_params_value = serde_json::Value::Object(rpc_params);
let checkrune_result = match call_rpc(plugin, "checkrune", rpc_params_value).await {
Ok(o) => serde_json::from_value::<CheckruneResponse>(o).unwrap(),
Err(e) => {
log::info!("verify_rune failed: method:`{}` {}", rpc_method, e);
log::info!("verify_rune failed: {checkrune_params} {e}");
return Err(AppError::Unauthorized(e));
}
};
@@ -46,7 +53,7 @@ pub async fn verify_rune(
message: "Rune is not valid".to_string(),
data: None,
};
log::info!("verify_rune failed: method:`{}` {}", rpc_method, err);
log::info!("verify_rune failed: {checkrune_params} {err}");
return Err(AppError::Unauthorized(err));
}
@@ -56,17 +63,16 @@ pub async fn verify_rune(
};
log::info!(
"Authorized rune_id:`{}` access to method:`{}` with params:`{}`",
"Authorized rune_id:`{}` access to {}",
showrunes_result.runes.first().unwrap().unique_id,
rpc_method,
rpc_params
checkrune_params,
);
Ok(())
}
pub async fn call_rpc(
plugin: Plugin<PluginState>,
plugin: &Plugin<PluginState>,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, RpcError> {
@@ -79,6 +85,55 @@ pub async fn call_rpc(
rpc.call_raw(method, &params).await
}
pub fn path_to_rest_map_and_params(
plugin: &Plugin<PluginState>,
path: &str,
http_method: &http::Method,
) -> Result<(ClnrestMap, serde_json::Map<String, serde_json::Value>), AppError> {
let mut rpc_params = serde_json::Map::new();
let dynamic_paths = plugin.state().dyn_router.lock().unwrap();
if let Ok(dyn_path) = dynamic_paths.at(path) {
for (name, value) in dyn_path.params.iter() {
rpc_params.insert(name.to_owned(), serde_json::Value::String(value.to_owned()));
}
if let Some(clnrest_map) = dyn_path.value.get(http_method) {
return Ok((clnrest_map.to_owned(), rpc_params));
}
return Err(AppError::MethodNotAllowed(RpcError {
code: Some(-32601),
message: format!("Dynamic path: {path} has no http_method:{http_method} registered"),
data: None,
}));
}
if let Some((prefix, suffix)) = path.split_once("v1/") {
if !prefix.is_empty() {
return Err(AppError::NotFound(RpcError {
code: Some(-32601),
message: "Path invalid, version missing for CLN methods".to_owned(),
data: None,
}));
}
if http_method != http::Method::POST {
return Err(AppError::MethodNotAllowed(RpcError {
code: Some(-32601),
message: "Path invalid, http_method must be POST for CLN methods".to_owned(),
data: None,
}));
}
let clnrest_map = ClnrestMap {
rpc_method: suffix.to_owned(),
rune_required: true,
rune_restrictions: None,
};
return Ok((clnrest_map, rpc_params));
}
Err(AppError::NotFound(RpcError {
code: Some(-32601),
message: "Path not found".to_owned(),
data: None,
}))
}
pub fn filter_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(arr) => {

View File

@@ -0,0 +1,147 @@
use std::{
collections::HashMap,
net::SocketAddr,
path::PathBuf,
sync::{Arc, Mutex},
};
use axum::{
extract::Json,
http::{self, StatusCode},
response::{IntoResponse, Response},
};
use cln_rpc::RpcError;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::mpsc::Sender;
use tower_http::cors::CorsLayer;
use utoipa::{
openapi::{
security::{ApiKey, ApiKeyValue, SecurityScheme},
Components,
},
Modify, OpenApi,
};
#[derive(Debug)]
pub enum AppError {
Unauthorized(RpcError),
Forbidden(RpcError),
NotFound(RpcError),
MethodNotAllowed(RpcError),
InternalServerError(RpcError),
NotAcceptable(RpcError),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::Unauthorized(err) => (StatusCode::UNAUTHORIZED, err),
AppError::Forbidden(err) => (StatusCode::FORBIDDEN, err),
AppError::NotFound(err) => (StatusCode::NOT_FOUND, err),
AppError::MethodNotAllowed(err) => (StatusCode::METHOD_NOT_ALLOWED, err),
AppError::InternalServerError(err) => (StatusCode::INTERNAL_SERVER_ERROR, err),
AppError::NotAcceptable(err) => (StatusCode::NOT_ACCEPTABLE, err),
};
let body = Json(json!(error_message));
(status, body).into_response()
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Unauthorized(err) => write!(f, "Unauthorized: {err}"),
AppError::Forbidden(err) => write!(f, "Forbidden: {err}"),
AppError::NotFound(err) => write!(f, "Not Found: {err}"),
AppError::MethodNotAllowed(err) => write!(f, "Method not allowed: {err}"),
AppError::InternalServerError(err) => write!(f, "Internal Server Error: {err}"),
AppError::NotAcceptable(err) => write!(f, "Not Acceptable: {err}"),
}
}
}
#[derive(Clone, Debug)]
pub struct PluginState {
pub notification_sender: Sender<serde_json::Value>,
pub dyn_router: Arc<Mutex<matchit::Router<HashMap<http::Method, ClnrestMap>>>>,
}
#[derive(Debug, Clone)]
pub struct ClnrestMap {
pub rpc_method: String,
pub rune_required: bool,
pub rune_restrictions: Option<CheckRuneParams>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CheckRuneParams {
pub nodeid: Option<String>,
pub method: Option<String>,
pub params: Option<serde_json::Map<String, serde_json::Value>>,
}
impl std::fmt::Display for CheckRuneParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new();
if let Some(nodeid) = &self.nodeid {
parts.push(format!("nodeid: `{nodeid}`"));
}
if let Some(method) = &self.method {
parts.push(format!("method: `{method}`"));
}
if let Some(params) = &self.params {
parts.push(format!(
"params: `{}`",
serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string())
));
}
if parts.is_empty() {
write!(f, "{{}}")
} else {
write!(f, "{}", parts.join(", "))
}
}
}
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::list_methods,
crate::handlers::call_rpc_method,
),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Components::new);
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("rune"))),
);
openapi.components = Some(components.clone());
}
}
pub enum ClnrestProtocol {
Https,
Http,
}
pub struct ClnrestOptions {
pub certs: PathBuf,
pub protocol: ClnrestProtocol,
pub address_str: String,
pub address: SocketAddr,
pub cors: CorsLayer,
pub csp: String,
pub swagger: String,
}