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:
committed by
Rusty Russell
parent
8bc2e76f44
commit
d03cf820a8
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", ¬ification) {
|
||||
match io.emit("message", ¬ification).await {
|
||||
Ok(_) => (),
|
||||
Err(e) => log::info!("Could not emit notification from background task: {}", e),
|
||||
}
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
114
plugins/rest-plugin/src/parse.rs
Normal file
114
plugins/rest-plugin/src/parse.rs
Normal 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))
|
||||
}
|
||||
@@ -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, ¶ms).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) => {
|
||||
|
||||
147
plugins/rest-plugin/src/structs.rs
Normal file
147
plugins/rest-plugin/src/structs.rs
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user