v0.25.5
This commit is contained in:
1 parent
1dea7f55c8
commit
c819f0a335
151 files changed
+7610
-4264
No files matched your search
@@ -107,9 +107,13 @@ async fn run(
|
||||
let initialize = directory.clone();
|
||||
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
|
||||
rust_proxy::secret_store::initialize(&data_dir)?;
|
||||
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
|
||||
let migrated =
|
||||
rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
|
||||
if migrated > 0 {
|
||||
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
|
||||
tracing::info!(
|
||||
migrated,
|
||||
"legacy ACME credentials migrated to encrypted Secret Store"
|
||||
);
|
||||
}
|
||||
let cert_dir = Some(directory.clone());
|
||||
let policy = ValidationPolicy {
|
||||
|
||||
+54
-25
@@ -8,6 +8,12 @@ use hyper_util::{
|
||||
client::legacy::{connect::HttpConnector, Client},
|
||||
rt::TokioExecutor,
|
||||
};
|
||||
use rust_proxy::{
|
||||
auth,
|
||||
config::{self, Config, Envelope, ValidationPolicy},
|
||||
db::Store,
|
||||
runtime::App,
|
||||
};
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
@@ -16,12 +22,6 @@ use rustls::{
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
};
|
||||
use rust_proxy::{
|
||||
auth,
|
||||
config::{self, Config, Envelope, ValidationPolicy},
|
||||
db::Store,
|
||||
runtime::App,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{self, BufRead, IsTerminal, Read, Write};
|
||||
use std::{
|
||||
@@ -34,14 +34,14 @@ use tokio::{
|
||||
net::{TcpListener, UdpSocket},
|
||||
task::JoinSet,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.4"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.5"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
@@ -142,9 +142,7 @@ enum BackupCommand {
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
|
||||
Restore {
|
||||
archive: PathBuf,
|
||||
},
|
||||
Restore { archive: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -241,11 +239,21 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn installed_data_dir_candidates() -> &'static [&'static str] {
|
||||
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
|
||||
#[cfg(any(
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
{
|
||||
return &["/var/db/rustproxy", "/var/lib/rustproxy"];
|
||||
}
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))]
|
||||
#[cfg(not(any(
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
)))]
|
||||
{
|
||||
&["/var/lib/rustproxy", "/var/db/rustproxy"]
|
||||
}
|
||||
@@ -302,11 +310,16 @@ async fn application() -> Result<()> {
|
||||
let cli = Cli::from_arg_matches(&matches)?;
|
||||
let data_dir_explicit = explicit(&matches, "data_dir");
|
||||
if cli.livelogs {
|
||||
ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand");
|
||||
ensure!(
|
||||
cli.command.is_none(),
|
||||
"--livelogs cannot be combined with a subcommand"
|
||||
);
|
||||
let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit);
|
||||
return live_logs_command(data_dir, cli.admin_listen).await;
|
||||
}
|
||||
let command = cli.command.context("a subcommand is required (or use --livelogs)")?;
|
||||
let command = cli
|
||||
.command
|
||||
.context("a subcommand is required (or use --livelogs)")?;
|
||||
match command {
|
||||
Command::Token => {
|
||||
admin_command(
|
||||
@@ -316,7 +329,11 @@ async fn application() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
Command::Admin { action } => {
|
||||
admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await
|
||||
admin_command(
|
||||
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
|
||||
action,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Tls { action } => {
|
||||
tls_command(
|
||||
@@ -368,15 +385,19 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
});
|
||||
let mut generated = false;
|
||||
let password = if io::stdin().is_terminal() {
|
||||
let value = Zeroizing::new(
|
||||
rpassword::prompt_password("Backup password (leave empty to generate): ")?
|
||||
);
|
||||
let value = Zeroizing::new(rpassword::prompt_password(
|
||||
"Backup password (leave empty to generate): ",
|
||||
)?);
|
||||
if value.is_empty() {
|
||||
generated = true;
|
||||
Zeroizing::new(rust_proxy::backup::generated_password())
|
||||
} else {
|
||||
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
|
||||
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
|
||||
let confirmation =
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
|
||||
ensure!(
|
||||
value.as_str() == confirmation.as_str(),
|
||||
"backup passwords do not match"
|
||||
);
|
||||
value
|
||||
}
|
||||
} else {
|
||||
@@ -386,10 +407,15 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
|
||||
println!(
|
||||
"Encrypted backup created: {} ({} files, configuration revision {})",
|
||||
summary.path.display(), summary.files, summary.configuration_revision
|
||||
summary.path.display(),
|
||||
summary.files,
|
||||
summary.configuration_revision
|
||||
);
|
||||
if generated {
|
||||
println!("Generated backup password (shown once): {}", password.as_str());
|
||||
println!(
|
||||
"Generated backup password (shown once): {}",
|
||||
password.as_str()
|
||||
);
|
||||
println!("Store this password securely; restore is impossible without it.");
|
||||
}
|
||||
Ok(())
|
||||
@@ -401,10 +427,13 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
);
|
||||
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
|
||||
ensure!(!password.is_empty(), "backup password cannot be empty");
|
||||
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
|
||||
let summary =
|
||||
rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
|
||||
println!(
|
||||
"Backup restored to {} ({} files, configuration revision {})",
|
||||
data_dir.display(), summary.files, summary.configuration_revision
|
||||
data_dir.display(),
|
||||
summary.files,
|
||||
summary.configuration_revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+99
-24
@@ -169,7 +169,10 @@ struct LiveLogFilters {
|
||||
}
|
||||
|
||||
fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str {
|
||||
entry.get(key).and_then(|value| value.as_str()).unwrap_or("-")
|
||||
entry
|
||||
.get(key)
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("-")
|
||||
}
|
||||
|
||||
fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
@@ -178,7 +181,10 @@ fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'
|
||||
|
||||
fn live_status_matches(filter: &str, status: u64) -> bool {
|
||||
if let Some(prefix) = filter.strip_suffix("xx") {
|
||||
return prefix.parse::<u64>().ok().is_some_and(|class| status / 100 == class);
|
||||
return prefix
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|class| status / 100 == class);
|
||||
}
|
||||
filter.parse::<u64>().ok() == Some(status)
|
||||
}
|
||||
@@ -205,7 +211,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let status = entry
|
||||
.get("status")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
if filters
|
||||
.status
|
||||
.as_ref()
|
||||
@@ -234,7 +243,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
|
||||
}
|
||||
|
||||
fn live_time(entry: &serde_json::Value) -> String {
|
||||
let ms = entry.get("time_ms").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let ms = entry
|
||||
.get("time_ms")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let seconds = (ms / 1000) % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}Z",
|
||||
@@ -246,8 +258,14 @@ fn live_time(entry: &serde_json::Value) -> String {
|
||||
}
|
||||
|
||||
fn print_live_entry(entry: &serde_json::Value, detail: bool) {
|
||||
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let status = entry
|
||||
.get("status")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let duration = entry
|
||||
.get("duration_ms")
|
||||
.and_then(|value| value.as_u64())
|
||||
@@ -304,7 +322,9 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
|
||||
let mut values = Vec::new();
|
||||
macro_rules! add {
|
||||
($name:literal, $value:expr) => {
|
||||
if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); }
|
||||
if let Some(value) = $value.as_ref() {
|
||||
values.push(format!("{}={}", $name, value));
|
||||
}
|
||||
};
|
||||
}
|
||||
add!("host", filters.host);
|
||||
@@ -317,11 +337,23 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
|
||||
add!("cache", filters.cache);
|
||||
add!("status", filters.status);
|
||||
add!("path", filters.path_prefix);
|
||||
if filters.errors_only { values.push("errors=on".into()); }
|
||||
if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); }
|
||||
if filters.detail { values.push("detail=on".into()); }
|
||||
if filters.paused { values.push("paused".into()); }
|
||||
if values.is_empty() { "none".into() } else { values.join(" ") }
|
||||
if filters.errors_only {
|
||||
values.push("errors=on".into());
|
||||
}
|
||||
if let Some(ms) = filters.slow_ms {
|
||||
values.push(format!("slow>={ms}ms"));
|
||||
}
|
||||
if filters.detail {
|
||||
values.push("detail=on".into());
|
||||
}
|
||||
if filters.paused {
|
||||
values.push("paused".into());
|
||||
}
|
||||
if values.is_empty() {
|
||||
"none".into()
|
||||
} else {
|
||||
values.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn live_help() {
|
||||
@@ -356,20 +388,44 @@ fn handle_live_command(
|
||||
match command.as_str() {
|
||||
"help" | "?" => live_help(),
|
||||
"filters" => println!("filters: {}", live_filters_summary(filters)),
|
||||
"reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); }
|
||||
"pause" => { filters.paused = true; println!("paused"); }
|
||||
"resume" => { filters.paused = false; println!("resumed"); }
|
||||
"clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; }
|
||||
"reset" => {
|
||||
*filters = LiveLogFilters::default();
|
||||
println!("filters cleared");
|
||||
}
|
||||
"pause" => {
|
||||
filters.paused = true;
|
||||
println!("paused");
|
||||
}
|
||||
"resume" => {
|
||||
filters.paused = false;
|
||||
println!("resumed");
|
||||
}
|
||||
"clear" => {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
"quit" | "exit" | "q" => return Ok(false),
|
||||
"detail" => filters.detail = matches!(value, "on" | "1" | "true"),
|
||||
"errors" => filters.errors_only = matches!(value, "on" | "1" | "true"),
|
||||
"slow" => {
|
||||
filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) };
|
||||
filters.slow_ms = if matches!(value, "" | "off" | "*") {
|
||||
None
|
||||
} else {
|
||||
Some(value.parse().context("slow expects milliseconds or off")?)
|
||||
};
|
||||
}
|
||||
"status" => {
|
||||
if matches!(value, "" | "*" | "off") { filters.status = None; }
|
||||
else {
|
||||
ensure!(matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value.parse::<u16>().ok().is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx");
|
||||
if matches!(value, "" | "*" | "off") {
|
||||
filters.status = None;
|
||||
} else {
|
||||
ensure!(
|
||||
matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx")
|
||||
|| value
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.is_some_and(|v| (100..=599).contains(&v)),
|
||||
"status expects 100..599 or 1xx..5xx"
|
||||
);
|
||||
filters.status = Some(value.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
@@ -378,9 +434,19 @@ fn handle_live_command(
|
||||
"backend" => set_live_filter(&mut filters.backend, value),
|
||||
"route" => set_live_filter(&mut filters.route, value),
|
||||
"client" => set_live_filter(&mut filters.client, value),
|
||||
"method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } },
|
||||
"method" => {
|
||||
set_live_filter(&mut filters.method, value);
|
||||
if let Some(v) = filters.method.as_mut() {
|
||||
*v = v.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
"transport" => set_live_filter(&mut filters.transport, value),
|
||||
"cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } },
|
||||
"cache" => {
|
||||
set_live_filter(&mut filters.cache, value);
|
||||
if let Some(v) = filters.cache.as_mut() {
|
||||
*v = v.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
"path" => set_live_filter(&mut filters.path_prefix, value),
|
||||
"show" => {
|
||||
let id: u64 = value.parse().context("show expects a request id")?;
|
||||
@@ -391,7 +457,10 @@ fn handle_live_command(
|
||||
}
|
||||
_ => bail!("unknown live-log command '{command}' (type help)"),
|
||||
}
|
||||
if !matches!(command.as_str(), "help" | "?" | "filters" | "show" | "clear") {
|
||||
if !matches!(
|
||||
command.as_str(),
|
||||
"help" | "?" | "filters" | "show" | "clear"
|
||||
) {
|
||||
println!("filters: {}", live_filters_summary(filters));
|
||||
}
|
||||
Ok(true)
|
||||
@@ -406,7 +475,13 @@ fn take_sse_event(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let lf = find(b"\n\n").map(|index| (index, 2));
|
||||
let crlf = find(b"\r\n\r\n").map(|index| (index, 4));
|
||||
let (index, delimiter) = match (lf, crlf) {
|
||||
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
|
||||
(Some(a), Some(b)) => {
|
||||
if a.0 <= b.0 {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => return None,
|
||||
|
||||
+10
-2
@@ -113,7 +113,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
|
||||
if matches!(&action, TlsCommand::Reload) {
|
||||
auth::prepare_directory(&dir)?;
|
||||
let path = dir.join("config.sqlite3");
|
||||
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
|
||||
ensure!(
|
||||
path.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
path.display()
|
||||
);
|
||||
auth::prepare_database(&path)?;
|
||||
let store = Store::open(&path, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
@@ -150,7 +154,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
|
||||
|
||||
auth::prepare_directory(&dir)?;
|
||||
let database = dir.join("config.sqlite3");
|
||||
ensure!(database.is_file(), "database does not exist at {}; start rust-proxy serve first", database.display());
|
||||
ensure!(
|
||||
database.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
database.display()
|
||||
);
|
||||
auth::prepare_database(&database)?;
|
||||
let store = Store::open(&database, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
|
||||
@@ -55,7 +55,10 @@ mod cli_regressions {
|
||||
#[test]
|
||||
fn sse_parser_accepts_lf_and_crlf_frames() {
|
||||
let mut lf = b"event: log\ndata: {\"id\":1}\n\nrest".to_vec();
|
||||
assert_eq!(take_sse_event(&mut lf).unwrap(), b"event: log\ndata: {\"id\":1}".to_vec());
|
||||
assert_eq!(
|
||||
take_sse_event(&mut lf).unwrap(),
|
||||
b"event: log\ndata: {\"id\":1}".to_vec()
|
||||
);
|
||||
assert_eq!(lf, b"rest".to_vec());
|
||||
let mut crlf = b"event: gap\r\ndata: {\"dropped\":2}\r\n\r\n".to_vec();
|
||||
let event = take_sse_event(&mut crlf).unwrap();
|
||||
|
||||
+20
-7
@@ -72,9 +72,12 @@ fn local_admin_public_roots() -> RootCertStore {
|
||||
fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
|
||||
let directory = data_dir.join("certs");
|
||||
let mut pins = Vec::new();
|
||||
for entry in std::fs::read_dir(&directory)
|
||||
.with_context(|| format!("cannot read local certificate directory {}", directory.display()))?
|
||||
{
|
||||
for entry in std::fs::read_dir(&directory).with_context(|| {
|
||||
format!(
|
||||
"cannot read local certificate directory {}",
|
||||
directory.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("pem") {
|
||||
@@ -105,7 +108,10 @@ fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
|
||||
Ok(pins)
|
||||
}
|
||||
|
||||
fn local_admin_scheme_from_probe(status: StatusCode, location: Option<&str>) -> Result<&'static str> {
|
||||
fn local_admin_scheme_from_probe(
|
||||
status: StatusCode,
|
||||
location: Option<&str>,
|
||||
) -> Result<&'static str> {
|
||||
if status == StatusCode::PERMANENT_REDIRECT {
|
||||
ensure!(
|
||||
location.is_some_and(|value| value.starts_with("https://")),
|
||||
@@ -198,10 +204,17 @@ impl LocalAdminClient {
|
||||
}
|
||||
|
||||
async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
ensure!(addr.ip().is_loopback(), "--livelogs requires the local loopback administration listener");
|
||||
ensure!(
|
||||
addr.ip().is_loopback(),
|
||||
"--livelogs requires the local loopback administration listener"
|
||||
);
|
||||
auth::prepare_directory(&dir)?;
|
||||
let path = dir.join("config.sqlite3");
|
||||
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
|
||||
ensure!(
|
||||
path.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
path.display()
|
||||
);
|
||||
auth::prepare_database(&path)?;
|
||||
let store = Store::open(&path, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
@@ -226,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.25.4 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.25.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
Reference in new issue
Block a user