mirror of
https://github.com/MAGICGrants/cuprate-for-explorer.git
synced 2026-01-09 19:47:59 -05:00
cuprated: integrate RpcServer (#423)
* add rpc server * add init fn * add layers * docs * comments * move * warn * split config * split * fix toml * impl p2p port * fix tests * docs * doc * remove (de)compression * `advertise` * Update binaries/cuprated/src/config/rpc.rs Co-authored-by: Boog900 <boog900@tutanota.com> * update tracing * `tracing::field::display` * fix * typo * docs * clippy * remove comment_out * add test for `cuprate_helper::net::ip_is_local` * add `FIXME` --------- Co-authored-by: Boog900 <boog900@tutanota.com>
This commit is contained in:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -1115,6 +1115,7 @@ version = "0.0.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bitflags 2.9.0",
|
||||
"borsh",
|
||||
"bytemuck",
|
||||
@@ -1164,6 +1165,7 @@ dependencies = [
|
||||
"nu-ansi-term",
|
||||
"paste",
|
||||
"pin-project",
|
||||
"pretty_assertions",
|
||||
"rand",
|
||||
"rand_distr",
|
||||
"randomx-rs",
|
||||
@@ -1181,6 +1183,7 @@ dependencies = [
|
||||
"toml",
|
||||
"toml_edit",
|
||||
"tower 0.5.1",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -3382,6 +3385,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower-layer 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tower-service 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
|
||||
@@ -163,6 +163,7 @@ cuprate-rpc-interface = { path = "rpc/interface", default-featur
|
||||
cuprate-zmq-types = { path = "zmq/types", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
axum = { version = "0.7", default-features = false }
|
||||
anyhow = { version = "1", default-features = false }
|
||||
arc-swap = { version = "1", default-features = false }
|
||||
arrayvec = { version = "0.7", default-features = false }
|
||||
@@ -204,6 +205,7 @@ tokio-util = { version = "0.7", default-features = false }
|
||||
tokio-stream = { version = "0.1", default-features = false }
|
||||
tokio = { version = "1", default-features = false }
|
||||
tower = { git = "https://github.com/Cuprate/tower.git", rev = "6c7faf0", default-features = false } # <https://github.com/tower-rs/tower/pull/796>
|
||||
tower-http = { version = "0.6", default-features = false }
|
||||
toml = { version = "0.8", default-features = false }
|
||||
toml_edit = { version = "0.22", default-features = false }
|
||||
tracing-appender = { version = "0.2", default-features = false }
|
||||
|
||||
@@ -23,14 +23,14 @@ cuprate-database = { workspace = true, features = ["serde"] }
|
||||
cuprate-epee-encoding = { workspace = true }
|
||||
cuprate-fast-sync = { workspace = true }
|
||||
cuprate-fixed-bytes = { workspace = true }
|
||||
cuprate-helper = { workspace = true, features = ["std", "serde", "time"] }
|
||||
cuprate-helper = { workspace = true, features = ["std", "serde", "time", "net"] }
|
||||
cuprate-hex = { workspace = true }
|
||||
cuprate-json-rpc = { workspace = true }
|
||||
cuprate-levin = { workspace = true }
|
||||
cuprate-p2p-core = { workspace = true }
|
||||
cuprate-p2p = { workspace = true }
|
||||
cuprate-pruning = { workspace = true }
|
||||
cuprate-rpc-interface = { workspace = true }
|
||||
cuprate-rpc-interface = { workspace = true, features = ["dummy"] }
|
||||
cuprate-rpc-types = { workspace = true, features = ["from"] }
|
||||
cuprate-test-utils = { workspace = true }
|
||||
cuprate-txpool = { workspace = true }
|
||||
@@ -39,6 +39,7 @@ cuprate-wire = { workspace = true }
|
||||
|
||||
|
||||
# TODO: after v1.0.0, remove unneeded dependencies.
|
||||
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
@@ -78,13 +79,15 @@ tokio-stream = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true, features = ["parse", "display"]}
|
||||
toml_edit = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower = { workspace = true, features = ["limit"] }
|
||||
tower-http = { workspace = true, features = ["limit"] }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["std", "fmt", "default"] }
|
||||
tracing = { workspace = true, features = ["default"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
cuprate-hex = { workspace = true }
|
||||
|
||||
@@ -28,6 +28,7 @@ mod args;
|
||||
mod fs;
|
||||
mod p2p;
|
||||
mod rayon;
|
||||
mod rpc;
|
||||
mod storage;
|
||||
mod tokio;
|
||||
mod tracing_config;
|
||||
@@ -38,6 +39,7 @@ mod macros;
|
||||
use fs::FileSystemConfig;
|
||||
use p2p::P2PConfig;
|
||||
use rayon::RayonConfig;
|
||||
pub use rpc::{RpcConfig, SharedRpcConfig};
|
||||
use storage::StorageConfig;
|
||||
use tokio::TokioConfig;
|
||||
use tracing_config::TracingConfig;
|
||||
@@ -142,6 +144,10 @@ config_struct! {
|
||||
/// Configuration for cuprated's P2P system.
|
||||
pub p2p: P2PConfig,
|
||||
|
||||
#[child = true]
|
||||
/// Configuration for cuprated's RPC system.
|
||||
pub rpc: RpcConfig,
|
||||
|
||||
#[child = true]
|
||||
/// Configuration for persistent data storage.
|
||||
pub storage: StorageConfig,
|
||||
@@ -161,6 +167,7 @@ impl Default for Config {
|
||||
tokio: Default::default(),
|
||||
rayon: Default::default(),
|
||||
p2p: Default::default(),
|
||||
rpc: Default::default(),
|
||||
storage: Default::default(),
|
||||
fs: Default::default(),
|
||||
}
|
||||
@@ -211,8 +218,7 @@ impl Config {
|
||||
max_inbound_connections: self.p2p.clear_net.general.max_inbound_connections,
|
||||
gray_peers_percent: self.p2p.clear_net.general.gray_peers_percent,
|
||||
p2p_port: self.p2p.clear_net.general.p2p_port,
|
||||
// TODO: set this if a public RPC server is set.
|
||||
rpc_port: 0,
|
||||
rpc_port: self.rpc.restricted.port_for_p2p(),
|
||||
address_book_config: self
|
||||
.p2p
|
||||
.clear_net
|
||||
@@ -271,6 +277,7 @@ impl fmt::Display for Config {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use pretty_assertions::assert_eq;
|
||||
use toml::from_str;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -9,6 +9,12 @@ use toml_edit::TableLike;
|
||||
/// - `#[inline = true]`: inlines the struct into `{}` instead of having a separate `[]` header.
|
||||
/// - `#[comment_out = true]`: comments out the field.
|
||||
///
|
||||
/// # Invariants
|
||||
/// Required for this macro to work:
|
||||
///
|
||||
/// - struct must implement [`Default`] and `serde`
|
||||
/// - None of the fields can be [`Option`]
|
||||
///
|
||||
/// # Documentation
|
||||
/// Consider using the following style when adding documentation:
|
||||
///
|
||||
|
||||
143
binaries/cuprated/src/config/rpc.rs
Normal file
143
binaries/cuprated/src/config/rpc.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::macros::config_struct;
|
||||
|
||||
config_struct! {
|
||||
/// RPC config.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct RpcConfig {
|
||||
#[child = true]
|
||||
/// Configuration for the unrestricted RPC server.
|
||||
pub unrestricted: UnrestrictedRpcConfig,
|
||||
|
||||
#[child = true]
|
||||
/// Configuration for the restricted RPC server.
|
||||
pub restricted: RestrictedRpcConfig,
|
||||
}
|
||||
}
|
||||
|
||||
config_struct! {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct UnrestrictedRpcConfig {
|
||||
/// Allow the unrestricted RPC server to be public.
|
||||
///
|
||||
/// ⚠️ WARNING ⚠️
|
||||
/// -------------
|
||||
/// Unrestricted RPC should almost never be made available
|
||||
/// to the wider internet. If the unrestricted address
|
||||
/// is a non-local address, `cuprated` will crash,
|
||||
/// unless this setting is set to `true`.
|
||||
///
|
||||
/// Type | boolean
|
||||
/// Valid values | true, false
|
||||
pub i_know_what_im_doing_allow_public_unrestricted_rpc: bool,
|
||||
|
||||
#[flatten = true]
|
||||
/// Shared config.
|
||||
##[serde(flatten)]
|
||||
pub shared: SharedRpcConfig,
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UnrestrictedRpcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
i_know_what_im_doing_allow_public_unrestricted_rpc: false,
|
||||
shared: SharedRpcConfig {
|
||||
address: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18081)),
|
||||
enable: true,
|
||||
request_byte_limit: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config_struct! {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct RestrictedRpcConfig {
|
||||
#[flatten = true]
|
||||
/// Shared config.
|
||||
##[serde(flatten)]
|
||||
pub shared: SharedRpcConfig,
|
||||
|
||||
/// Advertise the restricted RPC port.
|
||||
///
|
||||
/// Setting this to `true` will make `cuprated`
|
||||
/// share the restricted RPC server's port
|
||||
/// publicly to the P2P network.
|
||||
///
|
||||
/// Type | boolean
|
||||
/// Valid values | true, false
|
||||
pub advertise: bool,
|
||||
}
|
||||
}
|
||||
|
||||
impl RestrictedRpcConfig {
|
||||
/// Return the restricted RPC port for P2P if available and public.
|
||||
pub const fn port_for_p2p(&self) -> u16 {
|
||||
if self.advertise && self.shared.enable {
|
||||
self.shared.address.port()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config_struct! {
|
||||
/// Shared RPC configuration options.
|
||||
///
|
||||
/// Both RPC servers uses this struct.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct SharedRpcConfig {
|
||||
/// The address and port the RPC server will listen on.
|
||||
///
|
||||
/// Type | IPv4/IPv6 address + port
|
||||
/// Examples | "", "127.0.0.1:18081", "192.168.1.50:18085"
|
||||
pub address: SocketAddr,
|
||||
|
||||
/// Toggle the RPC server.
|
||||
///
|
||||
/// If `true` the RPC server will be enabled.
|
||||
/// If `false` the RPC server will be disabled.
|
||||
///
|
||||
/// Type | boolean
|
||||
/// Examples | true, false
|
||||
pub enable: bool,
|
||||
|
||||
// FIXME: <https://github.com/Cuprate/cuprate/issues/492>
|
||||
// Below should be `#[comment_out = true]` but is prevented by above issue.
|
||||
|
||||
/// If a request is above this byte limit, it will be rejected.
|
||||
///
|
||||
/// Setting this to `0` will disable the limit.
|
||||
///
|
||||
/// Type | Number
|
||||
/// Valid values | >= 0
|
||||
/// Examples | 0 (no limit), 5242880 (5MB), 10485760 (10MB)
|
||||
pub request_byte_limit: usize,
|
||||
|
||||
// TODO: <https://github.com/Cuprate/cuprate/issues/445>
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedRpcConfig {
|
||||
/// This returns the default for [`RestrictedRpcConfig`].
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 18089)),
|
||||
enable: false,
|
||||
// 1 megabyte.
|
||||
// <https://github.com/monero-project/monero/blob/3b01c490953fe92f3c6628fa31d280a4f0490d28/src/cryptonote_config.h#L134>
|
||||
request_byte_limit: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,9 @@ fn main() {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Initialize the RPC server(s).
|
||||
rpc::init_rpc_servers(config.rpc);
|
||||
|
||||
// Start the command listener.
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
let (command_tx, command_rx) = mpsc::channel(1);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
mod constants;
|
||||
mod handlers;
|
||||
mod rpc_handler;
|
||||
mod server;
|
||||
mod service;
|
||||
|
||||
pub use rpc_handler::CupratedRpcHandler;
|
||||
pub use server::init_rpc_servers;
|
||||
|
||||
99
binaries/cuprated/src/rpc/server.rs
Normal file
99
binaries/cuprated/src/rpc/server.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! RPC server initialization and main loop.
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Error;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::limit::rate::RateLimitLayer;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tracing::{field::display, info, warn};
|
||||
|
||||
use cuprate_rpc_interface::{RouterBuilder, RpcHandlerDummy};
|
||||
|
||||
use crate::{
|
||||
config::{RpcConfig, SharedRpcConfig},
|
||||
rpc::CupratedRpcHandler,
|
||||
};
|
||||
|
||||
/// Initialize the RPC server(s).
|
||||
///
|
||||
/// # Panics
|
||||
/// This function will panic if:
|
||||
/// - the server(s) could not be started
|
||||
/// - unrestricted RPC is started on non-local
|
||||
/// address without override option
|
||||
pub fn init_rpc_servers(config: RpcConfig) {
|
||||
for (c, restricted) in [
|
||||
(config.unrestricted.shared, false),
|
||||
(config.restricted.shared, true),
|
||||
] {
|
||||
if !c.enable {
|
||||
info!(restricted, "Skipping RPC server");
|
||||
continue;
|
||||
}
|
||||
|
||||
let addr = c.address;
|
||||
|
||||
if !restricted && !cuprate_helper::net::ip_is_local(addr.ip()) {
|
||||
if config
|
||||
.unrestricted
|
||||
.i_know_what_im_doing_allow_public_unrestricted_rpc
|
||||
{
|
||||
warn!(
|
||||
address = display(addr),
|
||||
"Starting unrestricted RPC on non-local address, this is dangerous!"
|
||||
);
|
||||
} else {
|
||||
panic!("Refusing to start unrestricted RPC on a non-local address ({addr})");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
run_rpc_server(restricted, c).await.unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// This initializes and runs an RPC server.
|
||||
///
|
||||
/// The function will only return when the server itself returns or an error occurs.
|
||||
async fn run_rpc_server(restricted: bool, config: SharedRpcConfig) -> Result<(), Error> {
|
||||
info!(
|
||||
restricted,
|
||||
address = display(&config.address),
|
||||
"Starting RPC server"
|
||||
);
|
||||
|
||||
// Create the router.
|
||||
//
|
||||
// TODO: impl more layers, rate-limiting, configuration, etc.
|
||||
let state = RpcHandlerDummy { restricted };
|
||||
// TODO:
|
||||
// - add functions that are `all()` but for restricted RPC
|
||||
// - enable aliases automatically `other_get_height` + `other_getheight`?
|
||||
//
|
||||
// FIXME:
|
||||
// - `json_rpc` is 1 endpoint; `RouterBuilder` operates at the
|
||||
// level endpoint; we can't selectively enable certain `json_rpc` methods
|
||||
let router = RouterBuilder::new().fallback().build().with_state(state);
|
||||
|
||||
// Add restrictive layers if restricted RPC.
|
||||
//
|
||||
// TODO: <https://github.com/Cuprate/cuprate/issues/445>
|
||||
let router = if config.request_byte_limit != 0 {
|
||||
router.layer(RequestBodyLimitLayer::new(config.request_byte_limit))
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
// Start the server.
|
||||
//
|
||||
// TODO: impl custom server code, don't use axum.
|
||||
let listener = TcpListener::bind(config.address).await?;
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,6 +24,7 @@ time = ["dep:chrono", "std"]
|
||||
thread = ["std", "dep:target_os_lib"]
|
||||
tx = ["dep:monero-serai"]
|
||||
fmt = ["map", "std"]
|
||||
net = []
|
||||
|
||||
[dependencies]
|
||||
cuprate-constants = { workspace = true, optional = true, features = ["block"] }
|
||||
|
||||
@@ -36,3 +36,6 @@ pub mod crypto;
|
||||
|
||||
#[cfg(feature = "fmt")]
|
||||
pub mod fmt;
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
pub mod net;
|
||||
|
||||
76
helper/src/net.rs
Normal file
76
helper/src/net.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Networking utilities.
|
||||
//!
|
||||
//! `#[no_std]` compatible.
|
||||
|
||||
use core::net::IpAddr;
|
||||
|
||||
/// Returns [`true`] if the address is a local address
|
||||
/// (non-reachable via the broader internet).
|
||||
///
|
||||
/// # FIXME
|
||||
/// This is only mostly accurate.
|
||||
///
|
||||
/// It should be replaced when `std` stabilizes things:
|
||||
/// <https://github.com/rust-lang/rust/issues/27709>
|
||||
pub const fn ip_is_local(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(),
|
||||
IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use core::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ip_local() {
|
||||
for ipv4 in [
|
||||
Ipv4Addr::LOCALHOST,
|
||||
Ipv4Addr::new(10, 0, 0, 0),
|
||||
Ipv4Addr::new(10, 0, 255, 255),
|
||||
Ipv4Addr::new(172, 16, 0, 0),
|
||||
Ipv4Addr::new(172, 16, 255, 255),
|
||||
Ipv4Addr::new(192, 168, 0, 0),
|
||||
Ipv4Addr::new(192, 168, 255, 255),
|
||||
] {
|
||||
assert!(ip_is_local(ipv4.into()));
|
||||
}
|
||||
|
||||
for ipv4 in [
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
Ipv4Addr::new(1, 1, 1, 1),
|
||||
Ipv4Addr::new(176, 9, 0, 187),
|
||||
Ipv4Addr::new(88, 198, 163, 90),
|
||||
Ipv4Addr::new(66, 85, 74, 134),
|
||||
Ipv4Addr::new(51, 79, 173, 165),
|
||||
Ipv4Addr::new(192, 99, 8, 110),
|
||||
Ipv4Addr::new(37, 187, 74, 171),
|
||||
Ipv4Addr::new(77, 172, 183, 193),
|
||||
] {
|
||||
assert!(!ip_is_local(ipv4.into()));
|
||||
}
|
||||
|
||||
for ipv6 in [
|
||||
Ipv6Addr::LOCALHOST,
|
||||
Ipv6Addr::new(0xfc02, 0, 0, 0, 0, 0, 0, 0),
|
||||
Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0),
|
||||
Ipv6Addr::new(0xfe80, 0, 0, 1, 0, 0, 0, 0),
|
||||
Ipv6Addr::new(0xfe81, 0, 0, 0, 0, 0, 0, 0),
|
||||
] {
|
||||
assert!(ip_is_local(ipv6.into()));
|
||||
}
|
||||
|
||||
for ipv6 in [
|
||||
Ipv6Addr::UNSPECIFIED,
|
||||
Ipv6Addr::new(1, 1, 1, 1, 1, 1, 1, 1),
|
||||
Ipv6Addr::new(
|
||||
0x1020, 0x3040, 0x5060, 0x7080, 0x90A0, 0xB0C0, 0xD0E0, 0xF00D,
|
||||
),
|
||||
] {
|
||||
assert!(!ip_is_local(ipv6.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ cuprate-rpc-types = { workspace = true, features = ["serde", "epee", "from"]
|
||||
cuprate-helper = { workspace = true, features = ["asynch"], default-features = false, optional = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
axum = { version = "0.7.5", features = ["json"], default-features = false }
|
||||
axum = { workspace = true, features = ["json"], default-features = false }
|
||||
serde = { workspace = true, optional = true }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
paste = { workspace = true }
|
||||
|
||||
Reference in New Issue
Block a user