Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::fetch_resource};
|
||||
use ahash::AHashMap;
|
||||
use arc_swap::ArcSwap;
|
||||
use registry::schema::{enums::CompressionAlgo, structs::Application};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{self, Cursor, Read},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use store::{
|
||||
registry::{RegistryObject, bootstrap::Bootstrap},
|
||||
write::{BatchBuilder, BlobLink, BlobOp, now},
|
||||
};
|
||||
use trc::{AddContext, Key};
|
||||
use types::{blob_hash::BlobHash, id::Id};
|
||||
|
||||
const APP_BLOB_PREFIX: &str = "STALWART_APP_";
|
||||
const MAX_APP_SIZE: usize = 100 * 1024 * 1024;
|
||||
const BASE_HREF: &str = "<base href=\"/\"";
|
||||
const OAUTH_CLIENT_ID: &str = "<meta name=\"oauth-client-id\" content=\"\"";
|
||||
|
||||
enum IndexEdit<'x> {
|
||||
BaseHref(&'x str),
|
||||
OAuthClientId(&'x str),
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct WebApplications {
|
||||
applications: ArcSwap<Vec<WebApplicationManager>>,
|
||||
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
|
||||
}
|
||||
|
||||
pub struct AppRoutes {
|
||||
resources: AHashMap<String, Resource<PathBuf>>,
|
||||
oauth_client_id_meta: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebApplicationManager {
|
||||
bundle_path: TempDir,
|
||||
prefixes: Vec<String>,
|
||||
description: String,
|
||||
url: String,
|
||||
expiry: u64,
|
||||
blob_key: BlobHash,
|
||||
oauth_client_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Resource<T> {
|
||||
pub content_type: Cow<'static, str>,
|
||||
pub contents: T,
|
||||
}
|
||||
|
||||
impl<T> Resource<T> {
|
||||
pub fn new(content_type: impl Into<Cow<'static, str>>, contents: T) -> Self {
|
||||
Self {
|
||||
content_type: content_type.into(),
|
||||
contents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppResource {
|
||||
pub resource: Resource<Vec<u8>>,
|
||||
pub no_cache: bool,
|
||||
}
|
||||
|
||||
impl WebApplications {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
applications: ArcSwap::new(Arc::new(Vec::new())),
|
||||
routes: ArcSwap::new(Arc::new(AHashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve(&self, prefix: &str, path: &str) -> trc::Result<Option<AppResource>> {
|
||||
if let Some(routes) = self.routes.load().get(prefix)
|
||||
&& let Some((is_index, resource)) = routes
|
||||
.resources
|
||||
.get(path)
|
||||
.map(|res| (path == "index.html", res))
|
||||
.or_else(|| routes.resources.get("index.html").map(|res| (true, res)))
|
||||
{
|
||||
tokio::fs::read(&resource.contents)
|
||||
.await
|
||||
.map(|mut contents| {
|
||||
if is_index && let Ok(html) = std::str::from_utf8(&contents) {
|
||||
contents =
|
||||
rewrite_index(html, prefix, routes.oauth_client_id_meta.as_deref());
|
||||
}
|
||||
|
||||
Some(AppResource {
|
||||
resource: Resource {
|
||||
content_type: resource.content_type.clone(),
|
||||
contents,
|
||||
},
|
||||
no_cache: is_index,
|
||||
})
|
||||
})
|
||||
.map_err(|err| {
|
||||
trc::ResourceEvent::Error
|
||||
.reason(err)
|
||||
.ctx(trc::Key::Path, path.to_string())
|
||||
.caused_by(trc::location!())
|
||||
})
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reload(&self, bp: &mut Bootstrap) {
|
||||
let mut apps = Vec::new();
|
||||
for app in bp.list_infallible::<Application>().await {
|
||||
if app.object.enabled {
|
||||
apps.push(WebApplicationManager::new(app));
|
||||
}
|
||||
}
|
||||
self.applications.store(Arc::new(apps));
|
||||
}
|
||||
|
||||
pub async fn unpack_all(&self, server: &Server, update: bool) {
|
||||
let mut routes = AHashMap::new();
|
||||
for app in self.applications.load().as_ref() {
|
||||
if update && let Err(err) = app.delete(server).await {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Reason = err,
|
||||
Url = app.url.clone(),
|
||||
Details = format!(
|
||||
"Failed to delete application bundle for prefixes: {}",
|
||||
app.prefixes.join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
match app.unpack(server).await {
|
||||
Ok(resources) => {
|
||||
let app_routes = Arc::new(AppRoutes {
|
||||
resources,
|
||||
oauth_client_id_meta: app
|
||||
.oauth_client_id
|
||||
.as_deref()
|
||||
.map(oauth_client_id_meta),
|
||||
});
|
||||
|
||||
for prefix in &app.prefixes {
|
||||
routes.insert(prefix.clone(), app_routes.clone());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::Error),
|
||||
Reason = err,
|
||||
Url = app.url.clone(),
|
||||
Details = format!(
|
||||
"Failed to unpack application for prefixes: {}",
|
||||
app.prefixes.join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.routes.store(Arc::new(routes));
|
||||
}
|
||||
}
|
||||
|
||||
impl WebApplicationManager {
|
||||
pub fn new(app: RegistryObject<Application>) -> Self {
|
||||
let base_path = app
|
||||
.object
|
||||
.unpack_directory
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(app.id.id().to_string());
|
||||
|
||||
Self {
|
||||
bundle_path: TempDir::new(base_path),
|
||||
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
|
||||
url: app.object.resource_url,
|
||||
description: app.object.description,
|
||||
expiry: app.object.auto_update_frequency.as_secs(),
|
||||
oauth_client_id: app.object.oauth_client_id,
|
||||
prefixes: app
|
||||
.object
|
||||
.url_prefix
|
||||
.iter()
|
||||
.map(|prefix| {
|
||||
prefix
|
||||
.trim_end_matches('/')
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> {
|
||||
// Delete any existing bundles
|
||||
self.bundle_path.clean().await.map_err(unpack_error)?;
|
||||
|
||||
// Obtain application bundle
|
||||
let bundle = if let Some(bundle) = server
|
||||
.blob_store()
|
||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||
.await?
|
||||
{
|
||||
bundle
|
||||
} else {
|
||||
// Fetch app bundle
|
||||
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::ResourceEvent::Error
|
||||
.caused_by(trc::location!())
|
||||
.ctx(Key::Url, self.url.clone())
|
||||
.reason(err)
|
||||
.details("Failed to fetch application bundle")
|
||||
})?;
|
||||
|
||||
// Store in blob store for future use
|
||||
server
|
||||
.blob_store()
|
||||
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Schedule expiration
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
hash: self.blob_key.clone(),
|
||||
to: BlobLink::Temporary {
|
||||
until: now() + self.expiry,
|
||||
},
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.set(
|
||||
BlobOp::Commit {
|
||||
hash: self.blob_key.clone(),
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
server
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::ApplicationUpdated),
|
||||
Url = self.url.clone(),
|
||||
Details = self.description.clone(),
|
||||
);
|
||||
|
||||
resource
|
||||
};
|
||||
|
||||
let url = self.url.clone();
|
||||
let bundle_path = self.bundle_path.path.clone();
|
||||
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
||||
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
||||
trc::ResourceEvent::Error
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
.ctx(Key::Url, url.clone())
|
||||
.details("Failed to decompress application bundle")
|
||||
})?;
|
||||
let mut routes = AHashMap::new();
|
||||
for i in 0..bundle.len() {
|
||||
let mut file = bundle.by_index(i).map_err(|err| {
|
||||
trc::ResourceEvent::Error
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
.details("Failed to read file from application bundle")
|
||||
})?;
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents).map_err(unpack_error)?;
|
||||
let file_name = file.name().to_string();
|
||||
drop(file);
|
||||
|
||||
let path = bundle_path.join(format!("{i:02}"));
|
||||
std::fs::write(&path, contents).map_err(unpack_error)?;
|
||||
|
||||
let resource = Resource {
|
||||
content_type: match file_name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, ext)| ext)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"html" => "text/html",
|
||||
"css" => "text/css",
|
||||
"wasm" => "application/wasm",
|
||||
"js" => "application/javascript",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.into(),
|
||||
contents: path,
|
||||
};
|
||||
|
||||
routes.insert(file_name, resource);
|
||||
}
|
||||
Ok(routes)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::ResourceEvent::Error
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
.details("Bundle unpack task panicked")
|
||||
})??;
|
||||
|
||||
trc::event!(
|
||||
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
||||
Url = self.url.clone(),
|
||||
Path = self.bundle_path.path.to_string_lossy().into_owned(),
|
||||
);
|
||||
|
||||
Ok(routes)
|
||||
}
|
||||
|
||||
async fn delete(&self, server: &Server) -> trc::Result<()> {
|
||||
server
|
||||
.blob_store()
|
||||
.delete_blob(self.blob_key.as_slice())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
||||
let blob_key = BlobHash::generate(format!("{APP_BLOB_PREFIX}{app_id}").as_bytes());
|
||||
|
||||
server
|
||||
.blob_store()
|
||||
.delete_blob(blob_key.as_slice())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource<Vec<u8>> {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.content_type.is_empty() && self.contents.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TempDir {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(path: PathBuf) -> TempDir {
|
||||
TempDir { path }
|
||||
}
|
||||
|
||||
pub async fn clean(&self) -> io::Result<()> {
|
||||
if tokio::fs::metadata(&self.path).await.is_ok() {
|
||||
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
||||
}
|
||||
tokio::fs::create_dir(&self.path).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unpack_error(err: std::io::Error) -> trc::Error {
|
||||
trc::ResourceEvent::Error
|
||||
.reason(err)
|
||||
.details("Failed to unpack application bundle")
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebApplications {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_index(html: &str, prefix: &str, oauth_client_id_meta: Option<&str>) -> Vec<u8> {
|
||||
let mut edits = [
|
||||
html.find(BASE_HREF)
|
||||
.map(|at| (at, BASE_HREF.len(), IndexEdit::BaseHref(prefix))),
|
||||
oauth_client_id_meta.and_then(|meta| {
|
||||
html.find(OAUTH_CLIENT_ID)
|
||||
.map(|at| (at, OAUTH_CLIENT_ID.len(), IndexEdit::OAuthClientId(meta)))
|
||||
}),
|
||||
];
|
||||
|
||||
if edits.iter().all(Option::is_none) {
|
||||
return html.as_bytes().to_vec();
|
||||
}
|
||||
edits.sort_unstable_by_key(|edit| edit.as_ref().map_or(usize::MAX, |(at, _, _)| *at));
|
||||
|
||||
let mut out =
|
||||
String::with_capacity(html.len() + prefix.len() + oauth_client_id_meta.map_or(0, str::len));
|
||||
let mut pos = 0;
|
||||
|
||||
for (at, len, edit) in edits.into_iter().flatten() {
|
||||
out.push_str(&html[pos..at]);
|
||||
match edit {
|
||||
IndexEdit::BaseHref(prefix) => {
|
||||
out.push_str("<base href=\"/");
|
||||
out.push_str(prefix);
|
||||
out.push_str("/\"");
|
||||
}
|
||||
IndexEdit::OAuthClientId(meta) => out.push_str(meta),
|
||||
}
|
||||
pos = at + len;
|
||||
}
|
||||
out.push_str(&html[pos..]);
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
fn oauth_client_id_meta(client_id: &str) -> String {
|
||||
let mut meta = String::with_capacity(OAUTH_CLIENT_ID.len() + client_id.len());
|
||||
meta.push_str("<meta name=\"oauth-client-id\" content=\"");
|
||||
for ch in client_id.chars() {
|
||||
match ch {
|
||||
'&' => meta.push_str("&"),
|
||||
'"' => meta.push_str("""),
|
||||
'<' => meta.push_str("<"),
|
||||
'>' => meta.push_str(">"),
|
||||
_ => meta.push(ch),
|
||||
}
|
||||
}
|
||||
meta.push('"');
|
||||
meta
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const INDEX: &str = concat!(
|
||||
"<!doctype html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"UTF-8\" />\n",
|
||||
" <base href=\"/\" />\n <meta name=\"oauth-client-id\" content=\"\" />\n",
|
||||
" <title>Portal</title>\n</head>\n\n<body></body>\n\n</html>\n"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn index_is_rewritten_with_the_prefix_and_client_id() {
|
||||
let meta = oauth_client_id_meta("stalwart-webui");
|
||||
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
|
||||
|
||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("<title>Portal</title>"), "{html}");
|
||||
assert!(html.starts_with("<!doctype html>"), "{html}");
|
||||
assert!(html.ends_with("</html>\n"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_keeps_the_empty_placeholder_when_no_client_id_is_configured() {
|
||||
let html = String::from_utf8(rewrite_index(INDEX, "account", None)).unwrap();
|
||||
|
||||
assert!(html.contains("<base href=\"/account/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"\" />"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_without_a_placeholder_is_left_alone() {
|
||||
let bundle = "<head>\n <base href=\"/\" />\n</head>";
|
||||
let meta = oauth_client_id_meta("stalwart-webui");
|
||||
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
|
||||
|
||||
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_are_applied_in_document_order() {
|
||||
let bundle = concat!(
|
||||
"<head><meta name=\"oauth-client-id\" content=\"\" />",
|
||||
"<base href=\"/\" /></head>"
|
||||
);
|
||||
let meta = oauth_client_id_meta("app");
|
||||
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
concat!(
|
||||
"<head><meta name=\"oauth-client-id\" content=\"app\" />",
|
||||
"<base href=\"/admin/\" /></head>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ids_are_escaped_for_the_attribute() {
|
||||
let meta = oauth_client_id_meta("a\"b&c<d>");
|
||||
|
||||
assert_eq!(
|
||||
meta,
|
||||
"<meta name=\"oauth-client-id\" content=\"a"b&c<d>\""
|
||||
);
|
||||
}
|
||||
|
||||
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
|
||||
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}")));
|
||||
dir.clean().await.unwrap();
|
||||
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.path.join("app.js"), "export const x = 1;\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut resources = AHashMap::new();
|
||||
resources.insert(
|
||||
"index.html".to_string(),
|
||||
Resource::new("text/html", dir.path.join("index.html")),
|
||||
);
|
||||
resources.insert(
|
||||
"app.js".to_string(),
|
||||
Resource::new("text/javascript", dir.path.join("app.js")),
|
||||
);
|
||||
|
||||
let routes = Arc::new(AppRoutes {
|
||||
resources,
|
||||
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
||||
});
|
||||
|
||||
let mut map = AHashMap::new();
|
||||
map.insert("admin".to_string(), routes.clone());
|
||||
map.insert("account".to_string(), routes);
|
||||
|
||||
let apps = WebApplications::new();
|
||||
apps.routes.store(Arc::new(map));
|
||||
|
||||
(apps, dir)
|
||||
}
|
||||
|
||||
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
||||
let served = apps.serve(prefix, path).await.unwrap().unwrap();
|
||||
assert!(served.no_cache, "index responses must not be cached");
|
||||
assert_eq!(served.resource.content_type.as_ref(), "text/html");
|
||||
String::from_utf8(served.resource.contents).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serving_index_injects_the_prefix_and_client_id() {
|
||||
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await;
|
||||
|
||||
let html = serve_html(&apps, "admin", "index.html").await;
|
||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"pocket-id-client\" />"),
|
||||
"{html}"
|
||||
);
|
||||
|
||||
let html = serve_html(&apps, "account", "index.html").await;
|
||||
assert!(html.contains("<base href=\"/account/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"pocket-id-client\" />"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
||||
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await;
|
||||
|
||||
let html = serve_html(&apps, "admin", "settings/directory").await;
|
||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"pocket-id-client\" />"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assets_and_unknown_prefixes_are_untouched() {
|
||||
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await;
|
||||
|
||||
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
||||
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
||||
assert_eq!(served.resource.content_type.as_ref(), "text/javascript");
|
||||
assert!(!served.no_cache);
|
||||
|
||||
assert!(apps.serve("unknown", "index.html").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
||||
let (apps, _dir) = fixture("serve-unconfigured", None).await;
|
||||
|
||||
let html = serve_html(&apps, "admin", "index.html").await;
|
||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||
assert!(
|
||||
html.contains("<meta name=\"oauth-client-id\" content=\"\" />"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmodified_document_is_returned_verbatim() {
|
||||
let bundle = "<head><title>x</title></head>";
|
||||
|
||||
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::Core;
|
||||
use ahash::AHashSet;
|
||||
use lz4_flex::frame::FrameEncoder;
|
||||
use std::{
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc::{self, SyncSender},
|
||||
};
|
||||
use store::{
|
||||
write::{AnyClass, AnyKey, ValueClass},
|
||||
*,
|
||||
};
|
||||
use types::blob_hash::{BLOB_HASH_LEN, BlobHash};
|
||||
use utils::{UnwrapFailure, codec::leb128::Leb128_};
|
||||
|
||||
pub(super) const MAGIC_MARKER: u8 = 123;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
||||
pub(super) enum Family {
|
||||
Data = 0,
|
||||
Blob = 2,
|
||||
Registry = 3,
|
||||
Changelog = 4,
|
||||
Queue = 5,
|
||||
Report = 6,
|
||||
Telemetry = 7,
|
||||
Tasks = 8,
|
||||
}
|
||||
|
||||
type TaskHandle = (tokio::task::JoinHandle<()>, std::thread::JoinHandle<()>);
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct BackupParams {
|
||||
dest: PathBuf,
|
||||
families: AHashSet<Family>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
pub async fn backup(&self, mut params: BackupParams) {
|
||||
if !params.dest.exists() {
|
||||
std::fs::create_dir_all(¶ms.dest).failed("Failed to create backup directory");
|
||||
} else if !params.dest.is_dir() {
|
||||
eprintln!("Backup destination {:?} is not a directory.", params.dest);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut sync_handles = Vec::new();
|
||||
let schema_version = self
|
||||
.storage
|
||||
.data
|
||||
.get_value::<u32>(AnyKey {
|
||||
subspace: SUBSPACE_PROPERTY,
|
||||
key: vec![0u8],
|
||||
})
|
||||
.await
|
||||
.failed("Could not retrieve database schema version.")
|
||||
.failed("Could not retrieve database schema version.");
|
||||
|
||||
if params.families.is_empty() {
|
||||
params.families = [
|
||||
Family::Data,
|
||||
Family::Registry,
|
||||
Family::Blob,
|
||||
Family::Changelog,
|
||||
Family::Queue,
|
||||
Family::Report,
|
||||
Family::Telemetry,
|
||||
Family::Tasks,
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
for subspace in params
|
||||
.families
|
||||
.into_iter()
|
||||
.flat_map(|f| f.subspaces())
|
||||
.copied()
|
||||
{
|
||||
let (async_handle, sync_handle) = if subspace == SUBSPACE_BLOBS {
|
||||
self.backup_blobs(¶ms.dest, subspace, schema_version)
|
||||
} else {
|
||||
self.backup_subspace(¶ms.dest, subspace, schema_version)
|
||||
};
|
||||
async_handle.await.failed("Task failed");
|
||||
sync_handles.push(sync_handle);
|
||||
}
|
||||
|
||||
for handle in sync_handles {
|
||||
handle.join().expect("Failed to join thread");
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_blobs(&self, dest: &Path, subspace: u8, schema_version: u32) -> TaskHandle {
|
||||
let store = self.storage.data.clone();
|
||||
let blob_store = self.storage.blob.clone();
|
||||
let (handle, writer) = spawn_writer(
|
||||
dest.join(format!("subspace_{}", char::from(subspace))),
|
||||
subspace,
|
||||
schema_version,
|
||||
);
|
||||
(
|
||||
tokio::spawn(async move {
|
||||
let mut blobs = Vec::new();
|
||||
let mut last_hash = BlobHash::default();
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
AnyKey {
|
||||
subspace: SUBSPACE_BLOB_LINK,
|
||||
key: vec![0u8],
|
||||
},
|
||||
AnyKey {
|
||||
subspace: SUBSPACE_BLOB_LINK,
|
||||
key: vec![u8::MAX; 32],
|
||||
},
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
let hash = BlobHash::try_from_hash_slice(
|
||||
key.get(0..BLOB_HASH_LEN).ok_or_else(|| {
|
||||
trc::Error::corrupted_key(key, None, trc::location!())
|
||||
})?,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if last_hash != hash {
|
||||
blobs.push(hash.clone());
|
||||
last_hash = hash;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.failed("Failed to iterate over data store");
|
||||
|
||||
for hash in blobs {
|
||||
if let Some(blob) = blob_store
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.failed("Failed to get blob")
|
||||
{
|
||||
writer
|
||||
.send((hash.as_slice().to_vec(), blob))
|
||||
.failed("Failed to send key");
|
||||
}
|
||||
}
|
||||
}),
|
||||
handle,
|
||||
)
|
||||
}
|
||||
|
||||
fn backup_subspace(&self, dest: &Path, subspace: u8, schema_version: u32) -> TaskHandle {
|
||||
let store = self.storage.data.clone();
|
||||
let (handle, writer) = spawn_writer(
|
||||
dest.join(format!("subspace_{}", char::from(subspace))),
|
||||
subspace,
|
||||
schema_version,
|
||||
);
|
||||
(
|
||||
tokio::spawn(async move {
|
||||
if !store.is_sql() || (subspace != SUBSPACE_COUNTER && subspace != SUBSPACE_QUOTA) {
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![0u8],
|
||||
},
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![u8::MAX; 32],
|
||||
},
|
||||
)
|
||||
.set_values(
|
||||
![SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX].contains(&subspace),
|
||||
),
|
||||
|key, value| {
|
||||
writer
|
||||
.send((key.to_vec(), value.to_vec()))
|
||||
.failed("Failed to send key");
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.failed("Failed to iterate over data store");
|
||||
} else {
|
||||
let mut keys = Vec::with_capacity(128);
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![0u8],
|
||||
},
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![u8::MAX; 32],
|
||||
},
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
keys.push(key.to_vec());
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.failed("Failed to iterate over data store");
|
||||
|
||||
for key in keys {
|
||||
let counter = store
|
||||
.get_counter(ValueClass::Any(AnyClass {
|
||||
subspace,
|
||||
key: key.clone(),
|
||||
}))
|
||||
.await
|
||||
.failed("Failed to get counter");
|
||||
writer
|
||||
.send((key.to_vec(), (counter as u64).to_le_bytes().to_vec()))
|
||||
.failed("Failed to send key");
|
||||
}
|
||||
}
|
||||
}),
|
||||
handle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn spawn_writer(
|
||||
path: PathBuf,
|
||||
subspace: u8,
|
||||
version: u32,
|
||||
) -> (std::thread::JoinHandle<()>, SyncSender<(Vec<u8>, Vec<u8>)>) {
|
||||
let (tx, rx) = mpsc::sync_channel::<(Vec<u8>, Vec<u8>)>(10);
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
println!("Exporting database to {}.", path.to_str().unwrap());
|
||||
|
||||
let mut file = FrameEncoder::new(BufWriter::new(
|
||||
std::fs::File::create(path).failed("Failed to create backup file"),
|
||||
));
|
||||
file.write_all(&[MAGIC_MARKER, subspace])
|
||||
.failed("Failed to write version");
|
||||
file.write_all(&version.to_le_bytes())
|
||||
.failed("Failed to write version");
|
||||
|
||||
while let Ok((key, value)) = rx.recv() {
|
||||
key.len()
|
||||
.to_leb128_writer(&mut file)
|
||||
.failed("Failed to write key value");
|
||||
file.write_all(&key).failed("Failed to write key");
|
||||
value
|
||||
.len()
|
||||
.to_leb128_writer(&mut file)
|
||||
.failed("Failed to write key value");
|
||||
if !value.is_empty() {
|
||||
file.write_all(&value).failed("Failed to write key value");
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = file.finish().failed("Failed to finish backup file");
|
||||
file.flush().failed("Failed to flush backup file");
|
||||
});
|
||||
|
||||
(handle, tx)
|
||||
}
|
||||
|
||||
impl BackupParams {
|
||||
pub fn new(dest: PathBuf) -> Self {
|
||||
let mut params = Self {
|
||||
dest,
|
||||
families: AHashSet::new(),
|
||||
};
|
||||
|
||||
if let Ok(families) = std::env::var("EXPORT_TYPES") {
|
||||
params.parse_families(&families);
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
|
||||
fn parse_families(&mut self, families: &str) {
|
||||
for family in families.split(',') {
|
||||
let family = family.trim();
|
||||
match Family::parse(family) {
|
||||
Ok(family) => {
|
||||
self.families.insert(family);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Backup failed: {err}.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Family {
|
||||
pub fn subspaces(&self) -> &'static [u8] {
|
||||
match self {
|
||||
Family::Data => &[
|
||||
SUBSPACE_ACL,
|
||||
SUBSPACE_INDEXES,
|
||||
SUBSPACE_QUOTA,
|
||||
SUBSPACE_COUNTER,
|
||||
SUBSPACE_PROPERTY,
|
||||
],
|
||||
Family::Blob => &[SUBSPACE_BLOBS, SUBSPACE_BLOB_LINK],
|
||||
Family::Registry => &[
|
||||
SUBSPACE_REGISTRY,
|
||||
SUBSPACE_REGISTRY_IDX,
|
||||
SUBSPACE_REGISTRY_PK,
|
||||
SUBSPACE_DIRECTORY,
|
||||
],
|
||||
Family::Changelog => &[SUBSPACE_LOGS],
|
||||
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
||||
Family::Report => &[SUBSPACE_REPORT_OUT, SUBSPACE_REPORT_IN],
|
||||
Family::Telemetry => &[SUBSPACE_TELEMETRY_SPAN, SUBSPACE_TELEMETRY_METRIC],
|
||||
Family::Tasks => &[SUBSPACE_TASK_QUEUE],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(family: &str) -> Result<Self, String> {
|
||||
match family {
|
||||
"data" => Ok(Family::Data),
|
||||
"registry" => Ok(Family::Registry),
|
||||
"blob" => Ok(Family::Blob),
|
||||
"changelog" => Ok(Family::Changelog),
|
||||
"queue" => Ok(Family::Queue),
|
||||
"report" => Ok(Family::Report),
|
||||
"telemetry" => Ok(Family::Telemetry),
|
||||
"tasks" => Ok(Family::Tasks),
|
||||
_ => Err(format!("Unknown family {}", family)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{backup::BackupParams, console::store_console};
|
||||
use crate::{
|
||||
BuildServer, Caches, Core, Data, IPC_CHANNEL_BUFFER, Inner, Ipc,
|
||||
config::{
|
||||
network::AsnGeoLookupConfig, server::Listeners, storage::Storage, telemetry::Telemetry,
|
||||
},
|
||||
ipc::{BroadcastEvent, PushEvent, QueueEvent, ReportingEvent, TrainTaskController},
|
||||
manager::defaults::BootstrapDefaults,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
use store::{RegistryStore, registry::bootstrap::Bootstrap};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use utils::{UnwrapFailure, failed};
|
||||
|
||||
pub struct BootManager {
|
||||
pub bootstrap: Bootstrap,
|
||||
pub inner: Arc<Inner>,
|
||||
pub servers: Listeners,
|
||||
pub ipc_rxs: IpcReceivers,
|
||||
}
|
||||
|
||||
pub struct IpcReceivers {
|
||||
pub push_rx: Option<mpsc::Receiver<PushEvent>>,
|
||||
pub queue_rx: Option<mpsc::Receiver<QueueEvent>>,
|
||||
pub report_rx: Option<mpsc::Receiver<ReportingEvent>>,
|
||||
pub broadcast_rx: Option<mpsc::Receiver<BroadcastEvent>>,
|
||||
}
|
||||
|
||||
const HELP: &str = concat!(
|
||||
"Stalwart Server v",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
r#"
|
||||
|
||||
Usage: stalwart [OPTIONS]
|
||||
|
||||
Options:
|
||||
-c, --config <PATH> Start server with the specified configuration file
|
||||
-e, --export <PATH> Export all store data to a specific path
|
||||
-i, --import <PATH> Import store data from a specific path
|
||||
-o, --console Open the store console
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
"#
|
||||
);
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum StoreOp {
|
||||
Export(BackupParams),
|
||||
Import(PathBuf),
|
||||
Console,
|
||||
None,
|
||||
}
|
||||
|
||||
impl BootManager {
|
||||
pub async fn init() -> Self {
|
||||
let mut config_path = std::env::var("CONFIG_PATH").ok();
|
||||
let mut import_export = StoreOp::None;
|
||||
|
||||
if config_path.is_none() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
||||
while let Some(arg) = args.next().and_then(|arg| {
|
||||
arg.strip_prefix("--")
|
||||
.or_else(|| arg.strip_prefix('-'))
|
||||
.map(|arg| arg.to_string())
|
||||
}) {
|
||||
let (key, value) = if let Some((key, value)) = arg.split_once('=') {
|
||||
(key.to_string(), Some(value.trim().to_string()))
|
||||
} else {
|
||||
(arg, args.next())
|
||||
};
|
||||
|
||||
match (key.as_str(), value) {
|
||||
("help" | "h", _) => {
|
||||
eprintln!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
("version" | "V", _) => {
|
||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
("config" | "c", Some(value)) => {
|
||||
config_path = Some(value);
|
||||
}
|
||||
("export" | "e", Some(value)) => {
|
||||
import_export = StoreOp::Export(BackupParams::new(value.into()));
|
||||
}
|
||||
("import" | "i", Some(value)) => {
|
||||
import_export = StoreOp::Import(value.into());
|
||||
}
|
||||
("console" | "o", None) => {
|
||||
import_export = StoreOp::Console;
|
||||
}
|
||||
(_, None) => {
|
||||
failed(&format!("Unrecognized command '{key}', try '--help'."));
|
||||
}
|
||||
(_, Some(_)) => failed(&format!(
|
||||
"Missing value for argument '{key}', try '--help'."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
if config_path.is_none() {
|
||||
if import_export == StoreOp::None {
|
||||
eprintln!("{HELP}");
|
||||
} else {
|
||||
eprintln!("Missing '--config' argument for import/export.")
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize registry
|
||||
let registry = RegistryStore::init(
|
||||
PathBuf::from(config_path.unwrap()),
|
||||
import_export == StoreOp::None,
|
||||
)
|
||||
.await
|
||||
.failed("⚠️ Startup failed");
|
||||
let mut bootstrap = Bootstrap::new(registry).await;
|
||||
|
||||
// Add safe defaults if missing
|
||||
if import_export == StoreOp::None {
|
||||
bootstrap.insert_safe_defaults().await;
|
||||
}
|
||||
|
||||
// Start listeners
|
||||
let mut servers = Listeners::parse(&mut bootstrap).await;
|
||||
servers.bind_and_drop_priv(&mut bootstrap);
|
||||
|
||||
// Parse storage
|
||||
let storage = Storage::parse(&mut bootstrap).await;
|
||||
|
||||
// Parse telemetry
|
||||
let telemetry = Telemetry::parse(&mut bootstrap, &storage).await;
|
||||
|
||||
match import_export {
|
||||
StoreOp::None => {
|
||||
// Parse components
|
||||
let core: Box<Core> =
|
||||
Box::new(Box::pin(Core::parse(&mut bootstrap, storage)).await);
|
||||
let data = Data::parse(&mut bootstrap).await;
|
||||
let cache = Caches::parse(&mut bootstrap).await;
|
||||
|
||||
// Enable telemetry
|
||||
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
telemetry.enable(false);
|
||||
|
||||
if bootstrap.registry.is_bootstrap_mode() {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::BootstrapMode),
|
||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||
Details =
|
||||
"No configuration file was found. Port 8080 is open for initial setup.",
|
||||
Version = env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
} else if bootstrap.registry.is_recovery_mode() {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::RecoveryMode),
|
||||
Details = "Port 8080 is open for troubleshooting and recovery.",
|
||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||
Version = env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
} else {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::Startup),
|
||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||
Version = env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
}
|
||||
|
||||
if core.storage.coordinator.is_enabled() {
|
||||
trc::event!(
|
||||
Cluster(trc::ClusterEvent::Startup),
|
||||
Id = bootstrap.registry.node_id(),
|
||||
Type = bootstrap
|
||||
.registry
|
||||
.cluster_role()
|
||||
.unwrap_or("[default]")
|
||||
.to_string(),
|
||||
Details = bootstrap.registry.cluster_push_shard()
|
||||
);
|
||||
}
|
||||
|
||||
// Build shared inner
|
||||
let has_remote_asn = matches!(
|
||||
core.network.asn_geo_lookup,
|
||||
AsnGeoLookupConfig::Resource { .. }
|
||||
);
|
||||
let (ipc, ipc_rxs) = build_ipc(!core.storage.coordinator.is_none());
|
||||
let inner = Arc::new(Inner {
|
||||
shared_core: ArcSwap::new(Arc::from(core)),
|
||||
data,
|
||||
ipc,
|
||||
cache,
|
||||
});
|
||||
|
||||
if !bootstrap.registry.is_recovery_mode() {
|
||||
// Load spam model
|
||||
if let Err(err) = inner.build_server().spam_model_reload().await {
|
||||
trc::error!(
|
||||
err.details("Failed to load spam filter model")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch ASN database
|
||||
if has_remote_asn {
|
||||
inner
|
||||
.build_server()
|
||||
.lookup_asn_country(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TCP acceptors
|
||||
servers
|
||||
.parse_tcp_acceptors(&mut bootstrap, inner.clone())
|
||||
.await;
|
||||
|
||||
BootManager {
|
||||
inner,
|
||||
bootstrap,
|
||||
servers,
|
||||
ipc_rxs,
|
||||
}
|
||||
}
|
||||
StoreOp::Export(path) => {
|
||||
// Enable telemetry
|
||||
telemetry.enable(false);
|
||||
|
||||
// Parse settings and backup
|
||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
||||
.await
|
||||
.backup(path)
|
||||
.await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
StoreOp::Import(path) => {
|
||||
// Enable telemetry
|
||||
telemetry.enable(false);
|
||||
|
||||
// Parse settings and restore
|
||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
||||
.await
|
||||
.restore(path)
|
||||
.await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
StoreOp::Console => {
|
||||
// Store console
|
||||
store_console(
|
||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
||||
.await
|
||||
.storage
|
||||
.data,
|
||||
)
|
||||
.await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) {
|
||||
// Build ipc receivers
|
||||
let (push_tx, push_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
|
||||
let (queue_tx, queue_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
|
||||
let (report_tx, report_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
|
||||
let (broadcast_tx, broadcast_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
|
||||
(
|
||||
Ipc {
|
||||
push_tx,
|
||||
queue_tx,
|
||||
report_tx,
|
||||
broadcast_tx: has_pubsub.then_some(broadcast_tx),
|
||||
task_tx: Arc::new(Notify::new()),
|
||||
train_task_controller: Arc::new(TrainTaskController::default()),
|
||||
},
|
||||
IpcReceivers {
|
||||
push_rx: Some(push_rx),
|
||||
queue_rx: Some(queue_rx),
|
||||
report_rx: Some(report_rx),
|
||||
broadcast_rx: has_pubsub.then_some(broadcast_rx),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose;
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use store::write::{AnyClass, AnyKey, BatchBuilder, ValueClass};
|
||||
use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store};
|
||||
|
||||
const HELP: &str = concat!(
|
||||
"Stalwart Server v",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
r#" Data Store CLI
|
||||
|
||||
Enter commands (type 'help' for available commands).
|
||||
"#
|
||||
);
|
||||
|
||||
pub async fn store_console(store: Store) {
|
||||
print!("{HELP}");
|
||||
|
||||
if matches!(store, Store::None) {
|
||||
println!("No store available. Verify your configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
print!("> ");
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).unwrap();
|
||||
let input = input.trim();
|
||||
|
||||
let parts: Vec<&str> = input.split_whitespace().collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parts[0] {
|
||||
"scan" => {
|
||||
if parts.len() != 3 {
|
||||
println!("Usage: scan <from_key> <to_key>");
|
||||
} else if let (Some(from_key), Some(to_key)) =
|
||||
(parse_key(parts[1]), parse_key(parts[2]))
|
||||
{
|
||||
println!("Scanning from {:?} to {:?}", from_key, to_key);
|
||||
let mut from_key = from_key.into_iter();
|
||||
let mut to_key = to_key.into_iter();
|
||||
let from_subspace = from_key.next().unwrap();
|
||||
let to_subspace = to_key.next().unwrap();
|
||||
|
||||
if from_subspace != to_subspace {
|
||||
println!("Keys must be in the same subspace.");
|
||||
return;
|
||||
}
|
||||
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
AnyKey {
|
||||
subspace: from_subspace,
|
||||
key: from_key.collect::<Vec<_>>(),
|
||||
},
|
||||
AnyKey {
|
||||
subspace: to_subspace,
|
||||
key: to_key.collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.set_values(
|
||||
![SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX].contains(&from_subspace),
|
||||
),
|
||||
|key, value| {
|
||||
print!("{}", char::from(from_subspace));
|
||||
print_escaped(key);
|
||||
print!(" : ");
|
||||
print_escaped(value);
|
||||
println!();
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to scan keys");
|
||||
}
|
||||
}
|
||||
"delete" => match (parts.get(1), parts.get(2)) {
|
||||
(Some(from_key), Some(to_key)) => {
|
||||
if let (Some(from_key), Some(to_key)) = (parse_key(from_key), parse_key(to_key))
|
||||
{
|
||||
let mut from_key = from_key.into_iter();
|
||||
let mut to_key = to_key.into_iter();
|
||||
|
||||
let from_key = AnyKey {
|
||||
subspace: from_key.next().unwrap(),
|
||||
key: from_key.collect::<Vec<_>>(),
|
||||
};
|
||||
let to_key = AnyKey {
|
||||
subspace: to_key.next().unwrap(),
|
||||
key: to_key.collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
if from_key.subspace != to_key.subspace {
|
||||
println!("Keys must be in the same subspace.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(from_key.clone(), to_key.clone()).no_values(),
|
||||
|_, _| {
|
||||
total += 1;
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to scan keys");
|
||||
|
||||
if total > 0 {
|
||||
print!("Are you sure you want to delete {total} keys? (y/N): ");
|
||||
io::stdout().flush().unwrap();
|
||||
let mut response = String::new();
|
||||
io::stdin().read_line(&mut response).unwrap();
|
||||
if !response.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
store
|
||||
.delete_range(from_key, to_key)
|
||||
.await
|
||||
.expect("Failed to delete keys");
|
||||
println!("Deleted {total} keys.");
|
||||
} else {
|
||||
println!("No keys found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(key), None) => {
|
||||
if let Some(key) = parse_key(key) {
|
||||
println!("Deleting key: {:?}", key);
|
||||
let mut key = key.into_iter();
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(ValueClass::Any(AnyClass {
|
||||
subspace: key.next().unwrap(),
|
||||
key: key.collect(),
|
||||
}));
|
||||
if let Err(err) = store.write(batch.build_all()).await {
|
||||
println!("Failed to delete key: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!("Usage: delete <from_key> [<to_key>]");
|
||||
}
|
||||
},
|
||||
"get" => {
|
||||
if parts.len() != 2 {
|
||||
println!("Usage: get <key>");
|
||||
} else if let Some(key) = parse_key(parts[1]) {
|
||||
let mut key = key.into_iter();
|
||||
match store
|
||||
.get_value::<RawValue>(AnyKey {
|
||||
subspace: key.next().unwrap(),
|
||||
key: key.collect::<Vec<_>>(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => {
|
||||
print_escaped(&data.0);
|
||||
println!();
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("Key not found.");
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Failed to retrieve key: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"put" => {
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: put <key> [<value>]");
|
||||
} else if let Some(key) = parse_key(parts[1]) {
|
||||
let value = parts.get(2).map(|v| parse_value(v)).unwrap_or_default();
|
||||
println!("Putting key: {key:?}");
|
||||
|
||||
let mut key = key.into_iter();
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: key.next().unwrap(),
|
||||
key: key.collect(),
|
||||
}),
|
||||
value,
|
||||
);
|
||||
if let Err(err) = store.write(batch.build_all()).await {
|
||||
println!("Failed to insert key: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
"help" => {
|
||||
print_help();
|
||||
}
|
||||
"exit" | "quit" => {
|
||||
println!("Exiting...");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Unknown command. Type 'help' for available commands.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(input: &str) -> Option<Vec<u8>> {
|
||||
let result = if let Some(key) = input.strip_prefix("base64:") {
|
||||
base64_decode(key)
|
||||
} else {
|
||||
parse_binary(input)
|
||||
};
|
||||
if matches!(result.first(), Some(ch) if ch.is_ascii_alphabetic() && ch.is_ascii_lowercase()) {
|
||||
Some(result)
|
||||
} else {
|
||||
println!("Invalid key: {result:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(input: &str) -> Vec<u8> {
|
||||
if let Some(key) = input.strip_prefix("base64:") {
|
||||
base64_decode(key)
|
||||
} else {
|
||||
parse_binary(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_decode(input: &str) -> Vec<u8> {
|
||||
general_purpose::STANDARD
|
||||
.decode(input)
|
||||
.expect("Failed to decode base64")
|
||||
}
|
||||
|
||||
fn parse_binary(input: &str) -> Vec<u8> {
|
||||
let mut result = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
match chars.next() {
|
||||
Some('x') => {
|
||||
let hex: String = chars.by_ref().take(2).collect();
|
||||
if hex.len() == 2 {
|
||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||
result.push(byte);
|
||||
} else {
|
||||
result.extend_from_slice(b"\\x");
|
||||
result.extend_from_slice(hex.as_bytes());
|
||||
}
|
||||
} else {
|
||||
result.push(b'\\');
|
||||
result.push(b'x');
|
||||
result.extend_from_slice(hex.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
result.push(b'\\');
|
||||
result.push(other as u8);
|
||||
}
|
||||
None => {
|
||||
result.push(b'\\');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c as u8);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn print_escaped(bytes: &[u8]) {
|
||||
for ch in bytes {
|
||||
if ch.is_ascii() && !ch.is_ascii_control() && *ch != b'\\' {
|
||||
print!("{}", *ch as char);
|
||||
} else {
|
||||
print!("\\x{:02x}", ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("Available commands:");
|
||||
println!(" scan <from_key> <to_key>");
|
||||
println!(" delete <from_key> [<to_key>]");
|
||||
println!(" get <key>");
|
||||
println!(" put <key> [<value>]");
|
||||
println!(" help");
|
||||
println!(" exit/quit");
|
||||
println!("Note: Keys and values can be prefixed with 'base64:' for base64 encoding");
|
||||
println!(" or use escaped hex values (e.g., \\x41 for 'A')");
|
||||
}
|
||||
|
||||
struct RawValue(Vec<u8>);
|
||||
|
||||
impl Deserialize for RawValue {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
Ok(RawValue(bytes.to_vec()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::auth::permissions::DefaultPermissions;
|
||||
use aws_lc_rs::{
|
||||
rand::SystemRandom,
|
||||
signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::*,
|
||||
prelude::{ObjectType, SocketAddr},
|
||||
structs::*,
|
||||
},
|
||||
types::{duration::Duration, error::Error, list::List, map::Map},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
rand::{RngExt, distr::Alphanumeric, rng},
|
||||
registry::{
|
||||
bootstrap::Bootstrap,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
};
|
||||
|
||||
pub const ASN_IPV4: &str =
|
||||
"https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn-ipv4.csv";
|
||||
pub const ASN_IPV6: &str =
|
||||
"https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn-ipv6.csv";
|
||||
pub const GEO_IPV4: &str =
|
||||
"https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv";
|
||||
pub const GEO_IPV6: &str =
|
||||
"https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv";
|
||||
|
||||
pub trait BootstrapDefaults {
|
||||
fn insert_safe_defaults(&mut self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl BootstrapDefaults for Bootstrap {
|
||||
async fn insert_safe_defaults(&mut self) {
|
||||
if let Err(error) = insert_safe_defaults(self).await {
|
||||
self.errors.push(Error::Internal {
|
||||
object_id: None,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
let is_recovery_mode = bp.registry.is_recovery_mode();
|
||||
let is_bootstrap_mode = bp.registry.is_bootstrap_mode();
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
if bp.registry.count_object(ObjectType::Application).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Application {
|
||||
auto_update_frequency: Duration::from_millis(30 * 24 * 60 * 60 * 1000),
|
||||
description: "Stalwart Web Interface".to_string(),
|
||||
enabled: true,
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
resource_url:
|
||||
"https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
|
||||
.into(),
|
||||
#[cfg(feature = "dev_mode")]
|
||||
resource_url: "file:///Users/me/code/webui/.ignore/webui.zip".into(),
|
||||
unpack_directory: None,
|
||||
oauth_client_id: None,
|
||||
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if is_bootstrap_mode {
|
||||
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
|
||||
if bp.registry.count_object(ObjectType::SystemSettings).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&SystemSettings {
|
||||
default_hostname: bp.registry.local_hostname().to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_recovery_mode {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&MtaQueueQuota {
|
||||
description: "Global queue quota".to_string().into(),
|
||||
enable: true,
|
||||
messages: 100000.into(),
|
||||
size: 10737418240.into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if bp
|
||||
.registry
|
||||
.count_object(ObjectType::MtaInboundThrottle)
|
||||
.await?
|
||||
== 0
|
||||
{
|
||||
for object in [
|
||||
MtaInboundThrottle {
|
||||
description: "Sender IP throttle".to_string(),
|
||||
enable: true,
|
||||
key: Map::new(vec![MtaInboundThrottleKey::RemoteIp]),
|
||||
rate: Rate {
|
||||
count: 5,
|
||||
period: Duration::from_millis(1000),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
MtaInboundThrottle {
|
||||
description: "Sender address to recipient throttle".to_string(),
|
||||
enable: true,
|
||||
key: Map::new(vec![
|
||||
MtaInboundThrottleKey::SenderDomain,
|
||||
MtaInboundThrottleKey::Rcpt,
|
||||
]),
|
||||
rate: Rate {
|
||||
count: 25,
|
||||
period: Duration::from_millis(60 * 60 * 1000),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(&object.into()))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp
|
||||
.registry
|
||||
.count_object(ObjectType::MtaVirtualQueue)
|
||||
.await?
|
||||
== 0
|
||||
&& bp
|
||||
.registry
|
||||
.count_object(ObjectType::MtaDeliverySchedule)
|
||||
.await?
|
||||
== 0
|
||||
{
|
||||
for (id, object) in [
|
||||
MtaVirtualQueue {
|
||||
description: "Local delivery queue".to_string().into(),
|
||||
name: "local".into(),
|
||||
threads_per_node: 25,
|
||||
},
|
||||
MtaVirtualQueue {
|
||||
description: "Remote delivery queue".to_string().into(),
|
||||
name: "remote".into(),
|
||||
threads_per_node: 50,
|
||||
},
|
||||
MtaVirtualQueue {
|
||||
description: "Delivery Status Notification delivery queue"
|
||||
.to_string()
|
||||
.into(),
|
||||
name: "dsn".into(),
|
||||
threads_per_node: 5,
|
||||
},
|
||||
MtaVirtualQueue {
|
||||
description: "DMARC and TLS report delivery queue".to_string().into(),
|
||||
name: "report".into(),
|
||||
threads_per_node: 5,
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert_with_id(
|
||||
(id as u64).into(),
|
||||
&object.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (id, object) in [
|
||||
MtaDeliverySchedule {
|
||||
name: "local".into(),
|
||||
description: "Local delivery schedule".to_string().into(),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: Duration::from_millis(3 * 24 * 60 * 60 * 1000),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
queue_id: 0u64.into(),
|
||||
},
|
||||
MtaDeliverySchedule {
|
||||
name: "remote".into(),
|
||||
description: "Remote delivery schedule".to_string().into(),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: Duration::from_millis(3 * 24 * 60 * 60 * 1000),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
queue_id: 1u64.into(),
|
||||
},
|
||||
MtaDeliverySchedule {
|
||||
name: "dsn".into(),
|
||||
description: "Delivery Status Notification delivery schedule"
|
||||
.to_string()
|
||||
.into(),
|
||||
expiry: MtaDeliveryExpiration::Attempts(MtaDeliveryExpirationAttempts {
|
||||
max_attempts: 10,
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(
|
||||
MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(15 * 60 * 1000),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(30 * 60 * 1000),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(60 * 60 * 1000),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(2 * 60 * 60 * 1000),
|
||||
},
|
||||
]),
|
||||
},
|
||||
),
|
||||
queue_id: 2u64.into(),
|
||||
},
|
||||
MtaDeliverySchedule {
|
||||
name: "report".into(),
|
||||
description: "DMARC and TLS report delivery schedule".to_string().into(),
|
||||
expiry: MtaDeliveryExpiration::Attempts(MtaDeliveryExpirationAttempts {
|
||||
max_attempts: 8,
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(
|
||||
MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(30 * 60 * 1000),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(60 * 60 * 1000),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: Duration::from_millis(2 * 60 * 60 * 1000),
|
||||
},
|
||||
]),
|
||||
},
|
||||
),
|
||||
queue_id: 3u64.into(),
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert_with_id(
|
||||
(id as u64).into(),
|
||||
&object.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::MtaTlsStrategy).await? == 0 {
|
||||
for object in [
|
||||
MtaTlsStrategy {
|
||||
name: "invalid-tls".into(),
|
||||
description: "Allow invalid TLS certificates".to_string().into(),
|
||||
allow_invalid_certs: true,
|
||||
..Default::default()
|
||||
},
|
||||
MtaTlsStrategy {
|
||||
name: "default".into(),
|
||||
description: "Default TLS settings".to_string().into(),
|
||||
allow_invalid_certs: false,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(&object.into()))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::MtaRoute).await? == 0 {
|
||||
for object in [
|
||||
MtaRoute::Mx(MtaRouteMx {
|
||||
description: "MX delivery route".to_string().into(),
|
||||
ip_lookup_strategy: MtaIpStrategy::V4ThenV6,
|
||||
max_multihomed: 2,
|
||||
max_mx_hosts: 2,
|
||||
name: "mx".into(),
|
||||
}),
|
||||
MtaRoute::Local(MtaRouteCommon {
|
||||
description: "Local delivery route".to_string().into(),
|
||||
name: "local".into(),
|
||||
}),
|
||||
] {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(&object.into()))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp
|
||||
.registry
|
||||
.count_object(ObjectType::MtaConnectionStrategy)
|
||||
.await?
|
||||
== 0
|
||||
{
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&MtaConnectionStrategy {
|
||||
name: "default".into(),
|
||||
description: "Default connection strategy".to_string().into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::OidcProvider).await? == 0 {
|
||||
let pkcs8_doc =
|
||||
EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &SystemRandom::new())
|
||||
.map_err(|err| {
|
||||
trc::EventType::Server(trc::ServerEvent::Startup)
|
||||
.into_err()
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
let signature_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
|
||||
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&OidcProvider {
|
||||
encryption_key: SecretKey::Value(SecretKeyValue {
|
||||
secret: rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect::<String>(),
|
||||
}),
|
||||
signature_key: SecretText::Text(SecretTextValue {
|
||||
secret: signature_pem,
|
||||
}),
|
||||
signature_algorithm: JwtSignatureAlgorithm::Es256,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Generate a Web Push VAPID signing key (RFC 9749)
|
||||
if bp.registry.count_object(ObjectType::Jmap).await? == 0 {
|
||||
match crate::network::webpush::generate_pkcs8_pem() {
|
||||
Ok(web_push_pem) => {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Jmap {
|
||||
web_push_key: SecretTextOptional::Text(SecretTextValue {
|
||||
secret: web_push_pem,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::Startup),
|
||||
Details = "Failed to generate Web Push VAPID key",
|
||||
Reason = err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||
let permissions = DefaultPermissions::default();
|
||||
let mut role_ids = Vec::with_capacity(4);
|
||||
|
||||
for role in [
|
||||
Role {
|
||||
description: "User".into(),
|
||||
enabled_permissions: Map::new(permissions.user),
|
||||
..Default::default()
|
||||
},
|
||||
Role {
|
||||
description: "Group".into(),
|
||||
enabled_permissions: Map::new(permissions.group),
|
||||
..Default::default()
|
||||
},
|
||||
Role {
|
||||
description: "Tenant Administrator".into(),
|
||||
enabled_permissions: Map::new(permissions.tenant),
|
||||
..Default::default()
|
||||
},
|
||||
Role {
|
||||
description: "System Administrator".into(),
|
||||
enabled_permissions: Map::new(permissions.superuser),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
match bp
|
||||
.registry
|
||||
.write(RegistryWrite::insert(&role.into()))
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(id) => role_ids.push(id),
|
||||
err => {
|
||||
bp.build_error(
|
||||
ObjectType::Role.singleton(),
|
||||
format!("Failed to insert default role: {err}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::Authentication).await? == 0 && role_ids.len() == 4 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Authentication {
|
||||
default_user_role_ids: Map::new(vec![role_ids[0]]),
|
||||
default_group_role_ids: Map::new(vec![role_ids[1]]),
|
||||
default_tenant_role_ids: Map::new(vec![role_ids[2], role_ids[0]]),
|
||||
default_admin_role_ids: Map::new(vec![role_ids[3], role_ids[0]]),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp
|
||||
.registry
|
||||
.count_object(ObjectType::NetworkListener)
|
||||
.await?
|
||||
== 0
|
||||
{
|
||||
for (protocol, name, port, tls_implicit) in [
|
||||
(NetworkListenerProtocol::Smtp, "smtp", 25, false),
|
||||
(NetworkListenerProtocol::Smtp, "submissions", 465, true),
|
||||
(NetworkListenerProtocol::Imap, "imaps", 993, true),
|
||||
(NetworkListenerProtocol::Pop3, "pop3s", 995, true),
|
||||
(NetworkListenerProtocol::ManageSieve, "sieve", 4190, false),
|
||||
(NetworkListenerProtocol::Http, "https", 443, true),
|
||||
(NetworkListenerProtocol::Http, "http", 8080, false),
|
||||
] {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&NetworkListener {
|
||||
bind: Map::new(vec![
|
||||
SocketAddr::from_str(&format!("[::]:{port}")).unwrap(),
|
||||
]),
|
||||
name: name.to_string(),
|
||||
protocol,
|
||||
use_tls: true,
|
||||
tls_implicit,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
|
||||
if bp.registry.count_object(ObjectType::Asn).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Asn::Resource(AsnResource {
|
||||
asn_urls: Map::new(vec![ASN_IPV4.into(), ASN_IPV6.into()]),
|
||||
geo_urls: Map::new(vec![GEO_IPV4.into(), GEO_IPV6.into()]),
|
||||
max_size: 104857600,
|
||||
expires: Duration::from_millis(24 * 60 * 60 * 1000),
|
||||
timeout: Duration::from_millis(5 * 60 * 1000),
|
||||
..Default::default()
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
if bp.registry.count_object(ObjectType::TracingStore).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(&TracingStore::Default.into()))
|
||||
.await?;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
if bp.registry.count_object(ObjectType::MetricsStore).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(&MetricsStore::Default.into()))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::Tracer).await? == 0 {
|
||||
bp.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Tracer::Log(TracerLog {
|
||||
enable: true,
|
||||
ansi: false,
|
||||
prefix: "stalwart.log".into(),
|
||||
rotate: LogRotateFrequency::Daily,
|
||||
path: "/var/log/stalwart".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
use store::write::BatchBuilder;
|
||||
use types::id::Id;
|
||||
|
||||
if bp.registry.count_object(ObjectType::SpamRule).await? == 0
|
||||
&& bp
|
||||
.registry
|
||||
.object::<SpamSettings>(Id::singleton())
|
||||
.await?
|
||||
.is_none_or(|spam| spam.spam_filter_rules_url.is_some())
|
||||
{
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
||||
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
|
||||
status: TaskStatus::now(),
|
||||
}));
|
||||
bp.data_store.write(batch.build_all()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::USER_AGENT;
|
||||
use hyper::HeaderMap;
|
||||
use mail_auth::flate2;
|
||||
use std::{
|
||||
io::{BufReader, Read},
|
||||
time::Duration,
|
||||
};
|
||||
use utils::HttpLimitResponse;
|
||||
|
||||
pub mod application;
|
||||
pub mod backup;
|
||||
pub mod boot;
|
||||
pub mod console;
|
||||
pub mod defaults;
|
||||
pub mod restore;
|
||||
|
||||
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes();
|
||||
pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
|
||||
|
||||
pub async fn fetch_resource(
|
||||
url: &str,
|
||||
headers: Option<HeaderMap>,
|
||||
timeout: Duration,
|
||||
max_size: usize,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if let Some(path) = url.strip_prefix("file://") {
|
||||
tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read {path}: {err}"))
|
||||
} else {
|
||||
let response = utils::http::http_client_builder(is_localhost_url(url))
|
||||
.timeout(timeout)
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
.get(url)
|
||||
.headers(headers.unwrap_or_default())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to fetch {url}: {err}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.bytes_with_limit(max_size)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to fetch {url}: {err}"))
|
||||
.and_then(|bytes| bytes.ok_or_else(|| format!("Resource too large: {url}")))
|
||||
} else {
|
||||
let code = response.status().canonical_reason().unwrap_or_default();
|
||||
let reason = response.text().await.unwrap_or_default();
|
||||
|
||||
Err(format!(
|
||||
"Failed to fetch {url}: Code: {code}, Details: {reason}",
|
||||
))
|
||||
}
|
||||
}
|
||||
.and_then(|bytes| {
|
||||
if url.ends_with(".gz") || url.ends_with(".gzip") {
|
||||
BufReader::new(flate2::read::GzDecoder::new(&bytes[..]))
|
||||
.bytes()
|
||||
.collect::<Result<Vec<u8>, _>>()
|
||||
.map_err(|err| format!("Failed to decompress {url}: {err}"))
|
||||
} else {
|
||||
Ok(bytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_localhost_url(url: &str) -> bool {
|
||||
url.split_once("://")
|
||||
.map(|(_, url)| url.split_once('/').map_or(url, |(host, _)| host))
|
||||
.is_some_and(|host| {
|
||||
let host = host.rsplit_once(':').map_or(host, |(host, _)| host);
|
||||
host == "localhost" || host == "127.0.0.1" || host == "[::1]"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::backup::MAGIC_MARKER;
|
||||
use crate::{Core, DATABASE_SCHEMA_VERSION};
|
||||
use lz4_flex::frame::FrameDecoder;
|
||||
use registry::schema::enums::CompressionAlgo;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufReader, ErrorKind, Read},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use store::{
|
||||
BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES, SUBSPACE_QUOTA,
|
||||
SUBSPACE_REGISTRY_PK, Store, U32_LEN,
|
||||
write::{
|
||||
AnyClass, AnyKey, BatchBuilder, ValueClass,
|
||||
key::{DeserializeBigEndian, is_node_id_key},
|
||||
},
|
||||
};
|
||||
use types::{collection::Collection, field::Field};
|
||||
use utils::{UnwrapFailure, failed};
|
||||
|
||||
impl Core {
|
||||
pub async fn restore(&self, src: PathBuf) {
|
||||
// Backup the core
|
||||
let paths = if src.is_dir() {
|
||||
let mut paths = Vec::new();
|
||||
for entry in std::fs::read_dir(&src).failed("Failed to read directory") {
|
||||
let entry = entry.failed("Failed to read entry");
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
paths
|
||||
} else {
|
||||
vec![src]
|
||||
};
|
||||
|
||||
let mut conflicts = Vec::new();
|
||||
for path in &paths {
|
||||
let subspace = KeyValueReader::new(path).subspace;
|
||||
if subspace_has_data(&self.storage.data, subspace).await {
|
||||
conflicts.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !conflicts.is_empty() {
|
||||
eprintln!(
|
||||
"Cannot import: the target database already contains data in the key ranges being \
|
||||
imported. This usually means Stalwart was started before the import ran, which \
|
||||
can create duplicate entries. Import into a fresh, empty database and do not \
|
||||
start Stalwart before importing. Conflicting dumps:"
|
||||
);
|
||||
for path in conflicts {
|
||||
eprintln!(" {}", path.display());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for path in paths {
|
||||
let storage = self.storage.clone();
|
||||
let blob_store = self.storage.blob.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
restore_file(storage.data, blob_store, &path).await;
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
task.await.failed("Failed to wait for task");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn subspace_has_data(store: &Store, subspace: u8) -> bool {
|
||||
let mut has_data = false;
|
||||
store
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![0u8],
|
||||
},
|
||||
AnyKey {
|
||||
subspace,
|
||||
key: vec![u8::MAX; 32],
|
||||
},
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if subspace == SUBSPACE_REGISTRY_PK && is_node_id_key(key) {
|
||||
Ok(true)
|
||||
} else {
|
||||
has_data = true;
|
||||
Ok(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.failed("Failed to inspect target database");
|
||||
has_data
|
||||
}
|
||||
|
||||
async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) {
|
||||
println!("Importing database dump from {}.", path.to_str().unwrap());
|
||||
|
||||
let mut reader = KeyValueReader::new(path);
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
match reader.subspace {
|
||||
SUBSPACE_BLOBS => {
|
||||
while let Some((key, value)) = reader.next() {
|
||||
blob_store
|
||||
.put_blob(&key, &value, CompressionAlgo::Lz4)
|
||||
.await
|
||||
.failed("Failed to write blob");
|
||||
}
|
||||
}
|
||||
SUBSPACE_COUNTER | SUBSPACE_QUOTA => {
|
||||
while let Some((key, value)) = reader.next() {
|
||||
batch.add(
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: reader.subspace,
|
||||
key,
|
||||
}),
|
||||
u64::from_le_bytes(
|
||||
value
|
||||
.try_into()
|
||||
.expect("Failed to deserialize counter/quota"),
|
||||
) as i64,
|
||||
);
|
||||
if batch.is_large_batch() {
|
||||
store
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.failed("Failed to write batch");
|
||||
batch = BatchBuilder::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
SUBSPACE_INDEXES => {
|
||||
while let Some((key, _)) = reader.next() {
|
||||
let account_id = key
|
||||
.as_slice()
|
||||
.deserialize_be_u32(0)
|
||||
.failed("Failed to deserialize account ID");
|
||||
let collection = *key.get(U32_LEN).failed("Missing collection byte");
|
||||
let field = *key.get(U32_LEN + 1).failed("Missing field byte");
|
||||
let value = key
|
||||
.get(U32_LEN + 2..key.len() - U32_LEN)
|
||||
.failed("Missing index key")
|
||||
.to_vec();
|
||||
let document_id = key
|
||||
.as_slice()
|
||||
.deserialize_be_u32(key.len() - U32_LEN)
|
||||
.failed("Failed to deserialize document ID");
|
||||
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::from(collection))
|
||||
.with_document(document_id)
|
||||
.index(Field::new(field), value);
|
||||
|
||||
if batch.is_large_batch() {
|
||||
store
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.failed("Failed to write batch");
|
||||
batch = BatchBuilder::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
while let Some((key, value)) = reader.next() {
|
||||
batch.set(
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: reader.subspace,
|
||||
key,
|
||||
}),
|
||||
value,
|
||||
);
|
||||
if batch.is_large_batch() {
|
||||
store
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.failed("Failed to write batch");
|
||||
batch = BatchBuilder::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
store
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.failed("Failed to write batch");
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyValueReader {
|
||||
subspace: u8,
|
||||
file: FrameDecoder<BufReader<File>>,
|
||||
}
|
||||
|
||||
impl KeyValueReader {
|
||||
fn new(path: &Path) -> Self {
|
||||
let mut file = FrameDecoder::new(BufReader::new(
|
||||
File::open(path).failed("Failed to open file"),
|
||||
));
|
||||
let mut buf = [0u8; 1];
|
||||
file.read_exact(&mut buf)
|
||||
.failed(&format!("Failed to read magic marker from {path:?}"));
|
||||
|
||||
if buf[0] != MAGIC_MARKER {
|
||||
failed(&format!("Invalid magic marker in {path:?}"));
|
||||
}
|
||||
|
||||
file.read_exact(&mut buf)
|
||||
.failed(&format!("Failed to read subspace from {path:?}"));
|
||||
let subspace = buf[0];
|
||||
|
||||
let mut buf = [0u8; 4];
|
||||
file.read_exact(&mut buf)
|
||||
.failed(&format!("Failed to read version from {path:?}"));
|
||||
let version = u32::from_le_bytes(buf);
|
||||
|
||||
if version != DATABASE_SCHEMA_VERSION {
|
||||
failed(&format!(
|
||||
"Invalid database schema version in {path:?}: Expected {DATABASE_SCHEMA_VERSION}, found {version}"
|
||||
));
|
||||
}
|
||||
|
||||
Self { file, subspace }
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let size = self.read_size()?;
|
||||
|
||||
let mut key = vec![0; size as usize];
|
||||
self.file
|
||||
.read_exact(&mut key)
|
||||
.failed("Failed to read bytes");
|
||||
let value = self.expect_sized_bytes();
|
||||
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
fn read_size(&mut self) -> Option<u32> {
|
||||
let mut result = 0;
|
||||
let mut buf = [0u8; 1];
|
||||
|
||||
for shift in [0, 7, 14, 21, 28] {
|
||||
if let Err(err) = self.file.read_exact(&mut buf) {
|
||||
if err.kind() == ErrorKind::UnexpectedEof {
|
||||
return None;
|
||||
} else {
|
||||
failed(&format!("Failed to read file: {err:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
let byte = buf[0];
|
||||
if (byte & 0x80) == 0 {
|
||||
result |= (byte as u32) << shift;
|
||||
return Some(result);
|
||||
} else {
|
||||
result |= ((byte & 0x7F) as u32) << shift;
|
||||
}
|
||||
}
|
||||
|
||||
failed("Invalid leb128 sequence")
|
||||
}
|
||||
|
||||
fn expect_sized_bytes(&mut self) -> Vec<u8> {
|
||||
let len = self.read_size().failed("Missing leb128 value sequence") as usize;
|
||||
let mut bytes = vec![0; len];
|
||||
self.file
|
||||
.read_exact(&mut bytes)
|
||||
.failed("Failed to read bytes");
|
||||
bytes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user