Skip to main content

bark_rest/
lib.rs

1
2#[macro_use]
3extern crate anyhow;
4
5pub mod api;
6pub mod auth;
7pub mod config;
8pub mod error;
9
10use crate::auth::AuthToken;
11pub use crate::config::Config;
12pub use axum::http;
13use chrono::{DateTime, Utc};
14
15
16use std::collections::{HashMap};
17use std::pin::Pin;
18use std::sync::Arc;
19
20use anyhow::Context;
21use axum::routing::get;
22use log::{error, warn, info};
23use tokio::sync::RwLock;
24use tokio::task::JoinHandle;
25use tokio_util::sync::CancellationToken;
26use axum::http::{header, Method, HeaderValue};
27use tower_http::cors::CorsLayer;
28use utoipa::{Modify, OpenApi};
29use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
30use utoipa_axum::router::OpenApiRouter;
31use utoipa_swagger_ui::SwaggerUi;
32
33use bark::Wallet;
34use bark::onchain::OnchainWallet;
35use bark_json::web::CreateWalletRequest;
36
37type BoxFuture<T> =
38	Pin<Box<dyn Future<Output = T> + Send + 'static>>;
39
40pub type OnWalletCreate = dyn Fn(CreateWalletRequest)
41	-> BoxFuture<anyhow::Result<ServerWallet>> + Send + Sync;
42
43pub type OnWalletDelete = dyn Fn()
44	-> BoxFuture<anyhow::Result<()>> + Send + Sync;
45
46const CRATE_VERSION : &'static str = env!("CARGO_PKG_VERSION");
47
48// NB please keep below 1000 chars for crates.io publish
49const API_DESCRIPTION: &str = "\
50A simple REST API for barkd, a wallet daemon for integrating bitcoin payments into your app over HTTP. Supports self-custodial Lightning, Ark, and on-chain out of the box.
51
52barkd is a long-running daemon best suited for always-on or high-connectivity environments like nodes, servers, desktops, and point-of-sale terminals.
53
54All endpoints return JSON. Amounts are denominated in satoshis.";
55
56#[derive(OpenApi)]
57#[openapi(
58	paths(
59		ping,
60	),
61	nest(
62		(path = "/api/v1/boards", api = api::v1::boards::BoardsApiDoc),
63		(path = "/api/v1/exits", api = api::v1::exits::ExitsApiDoc),
64		(path = "/api/v1/fees", api = api::v1::fees::FeesApiDoc),
65		(path = "/api/v1/history", api = api::v1::history::HistoryApiDoc),
66		(path = "/api/v1/lightning", api = api::v1::lightning::LightningApiDoc),
67		(path = "/api/v1/onchain", api = api::v1::onchain::OnchainApiDoc),
68		(path = "/api/v1/wallet", api = api::v1::wallet::WalletApiDoc),
69		(path = "/api/v1/bitcoin", api = api::v1::bitcoin::BitcoinApiDoc),
70		(path = "/api/v1/notifications", api = api::v1::notifications::NotificationApiDoc),
71	),
72	info(
73		title = "barkd REST API",
74		version = CRATE_VERSION,
75		description = API_DESCRIPTION,
76	),
77	security(
78		("bearer" = []),
79	),
80	modifiers(&BearerSecurity),
81)]
82pub struct ApiDoc;
83
84struct BearerSecurity;
85
86impl Modify for BearerSecurity {
87	fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
88		let components = openapi.components.get_or_insert_with(Default::default);
89		components.add_security_scheme(
90			"bearer",
91			SecurityScheme::Http(
92				HttpBuilder::new()
93					.scheme(HttpAuthScheme::Bearer)
94					.bearer_format("AuthToken")
95					.description(Some("Base64url-encoded auth token"))
96					.build(),
97			),
98		);
99	}
100}
101
102fn cors_layer(config: &Config) -> CorsLayer {
103	if config.allowed_origins.is_empty() {
104		return CorsLayer::new(); // deny all cross-origin
105	}
106
107	// Origins are validated at config construction time.
108	let origins: Vec<HeaderValue> = config.allowed_origins.iter()
109		.map(|o| o.parse().expect("pre-validated"))
110		.collect();
111
112	CorsLayer::new()
113		.allow_origin(origins)
114		.allow_methods([Method::GET, Method::POST, Method::DELETE])
115		.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
116}
117
118async fn shutdown_signal(shutdown: CancellationToken) {
119	shutdown.cancelled().await;
120}
121
122/// A server that serves a REST API for the bark [Wallet]
123pub struct RestServer {
124	shutdown: CancellationToken,
125	jh: JoinHandle<()>,
126}
127
128/// A simple wrapper around a [Wallet] and an [OnchainWallet] hold by
129/// the [RestServer]
130pub struct ServerWallet {
131	pub wallet: Wallet,
132	pub onchain: Arc<RwLock<OnchainWallet>>,
133}
134
135impl ServerWallet {
136	pub fn new(wallet: Wallet, onchain: Arc<RwLock<OnchainWallet>>) -> Self {
137		Self { wallet, onchain }
138	}
139}
140
141#[derive(Clone)]
142pub struct ServerState {
143	wallet: Arc<parking_lot::RwLock<Option<ServerWallet>>>,
144	auth_token: Option<AuthToken>,
145
146	/// A hook to be called when a wallet is created, returning a
147	/// [ServerWallet] to be added to the server state
148	on_wallet_create: Option<Arc<OnWalletCreate>>,
149	/// A hook to be called when a wallet is deleted,
150	///in addition to removing the wallet from the server state
151	on_wallet_delete: Option<Arc<OnWalletDelete>>,
152
153	/// A map of websocket tickets to their expiration time
154	///
155	/// Note: this map is only stored in memory and not persisted
156	/// to the database, any server restart will clear the map.
157	websocket_tickets: Arc<RwLock<HashMap<String, DateTime<Utc>>>>,
158}
159
160impl ServerState {
161	pub fn new(
162		wallet: Option<ServerWallet>,
163		auth_token: Option<AuthToken>,
164		on_wallet_create: Option<Arc<OnWalletCreate>>,
165		on_wallet_delete: Option<Arc<OnWalletDelete>>,
166	) -> Self {
167		ServerState {
168			wallet: Arc::new(parking_lot::RwLock::new(wallet)),
169			on_wallet_create,
170			auth_token,
171			on_wallet_delete,
172
173			websocket_tickets: Arc::new(RwLock::new(HashMap::new())),
174		}
175	}
176
177	pub fn require_wallet(&self) -> anyhow::Result<Wallet> {
178		let wallet = self.wallet.read().as_ref()
179			.ok_or_else(|| anyhow!("No wallet set"))?.wallet.clone();
180		Ok(wallet)
181	}
182
183	pub fn require_onchain(&self) -> anyhow::Result<Arc<RwLock<OnchainWallet>>> {
184		let onchain = self.wallet.read().as_ref()
185			.ok_or_else(|| anyhow!("No onchain set"))?.onchain.clone();
186		Ok(onchain)
187	}
188
189	pub fn auth_token(&self) -> Option<&AuthToken> {
190		self.auth_token.as_ref()
191	}
192}
193
194impl RestServer {
195	/// Start a new [RestServer] with the given config and an optional [ServerWallet]
196	///
197	/// If no wallet is provided, the server will reject any action.
198	/// If `auth_secrets` is non-empty, token-based authentication is
199	/// enforced on all `/api/v1` routes.
200	pub async fn start(
201		config: &Config,
202		auth_token: Option<AuthToken>,
203		wallet: Option<ServerWallet>,
204		on_wallet_create: Option<Arc<OnWalletCreate>>,
205		on_wallet_delete: Option<Arc<OnWalletDelete>>,
206	) -> anyhow::Result<Self> {
207		let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
208			.split_for_parts();
209
210		let socket_addr = config.socket_addr();
211
212		if auth_token.is_none() {
213			warn!("No auth token configured — all authentication is disabled");
214		}
215
216		let state = ServerState::new(wallet, auth_token, on_wallet_create, on_wallet_delete);
217
218		let router = router
219			.route("/ping", get(ping))
220			.nest("/api/v1", api::v1::router(&state))
221			.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api.clone()))
222			.layer(cors_layer(config))
223			.with_state(state)
224			.fallback(error::route_not_found);
225
226		// Run the server
227		log::info!("Server starting on http://{}", socket_addr);
228
229		let listener = tokio::net::TcpListener::bind(socket_addr).await
230			.context("Failed to bind to address")?;
231
232		let shutdown = CancellationToken::new();
233
234		let shutdown2 = shutdown.clone();
235		let jh = tokio::spawn(async move {
236			if let Err(e) = axum::serve(listener, router.into_make_service())
237				.with_graceful_shutdown(shutdown_signal(shutdown2)).await
238			{
239				error!("Error running server: {:#}", e);
240			} else {
241				info!("Server stopped running");
242			}
243		});
244
245		Ok(RestServer { shutdown, jh })
246	}
247
248	/// Stop the REST server
249	pub fn stop(&self) {
250		self.shutdown.cancel();
251	}
252
253	/// Stop the REST server and wait for it to shut down
254	pub async fn stop_wait(self) -> anyhow::Result<()> {
255		self.stop();
256		self.jh.await?;
257		Ok(())
258	}
259}
260
261#[utoipa::path(
262	get,
263	path = "/ping",
264	summary = "Ping",
265	security(()),
266	extensions(
267		("x-hidden" = json!(true))
268	),
269	responses(
270		(status = 200, description = "Returns pong")
271	)
272)]
273pub async fn ping() -> &'static str { "pong" }