Switch off upstream's web interface when a Stalwart store is first opened #251
No files matched your search
@@ -88,6 +88,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// inbuxa: a store brought over from Stalwart doesn't serve upstream's web
|
||||
// interface (docs/spec/import-0.16.md), before the applications load
|
||||
super::first_party::disable_imported_web_interface(bp).await?;
|
||||
|
||||
// inbuxa: registration is required (contract C-5), so the first-party
|
||||
// front ends are registered on every start (C-6)
|
||||
super::first_party::ensure_first_party_clients(bp).await?;
|
||||
|
||||
@@ -38,9 +38,13 @@ use registry::{
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use store::registry::{
|
||||
bootstrap::Bootstrap,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
use store::{
|
||||
SUBSPACE_INBUXA,
|
||||
registry::{
|
||||
bootstrap::Bootstrap,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::AnyKey,
|
||||
};
|
||||
|
||||
/// The client id the upstream web interface uses when its application names none.
|
||||
@@ -50,6 +54,9 @@ pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
|
||||
/// The web interface's client id before the fork renamed it (SPEC §2.4).
|
||||
/// Only ever read to retire it.
|
||||
const LEGACY_WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
|
||||
/// Where upstream's web interface is downloaded from, whatever the release.
|
||||
/// Only ever read to switch it off in a store brought over from Stalwart.
|
||||
const UPSTREAM_WEB_INTERFACE_URL: &str = "https://github.com/stalwartlabs/webui/";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirstPartyClient {
|
||||
@@ -272,6 +279,62 @@ pub async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A store brought over from Stalwart still lists upstream's web interface,
|
||||
/// which would be downloaded and served at `/admin` (docs/spec/import-0.16.md).
|
||||
/// The first time inbuxa opens a store, before it has inbuxa's store version,
|
||||
/// that application is switched off, and the record is kept so an operator can
|
||||
/// switch it back on. The version is written later in the same start, so an
|
||||
/// application switched back on stays on.
|
||||
pub async fn disable_imported_web_interface(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
let marked = bp
|
||||
.data_store
|
||||
.get_value::<u32>(AnyKey {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: crate::INBUXA_STORE_VERSION_KEY.to_vec(),
|
||||
})
|
||||
.await?
|
||||
.is_some();
|
||||
if marked {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for app in bp.list_infallible::<Application>().await {
|
||||
if !app.object.enabled || !is_upstream_web_interface(&app.object.resource_url) {
|
||||
continue;
|
||||
}
|
||||
let mut updated = app.object.clone();
|
||||
updated.enabled = false;
|
||||
// The old object carries its revision: the write asserts on it.
|
||||
let current = Object::with_revision(ObjectInner::from(app.object), app.revision);
|
||||
let result = bp
|
||||
.registry
|
||||
.write(RegistryWrite::update(
|
||||
app.id.id(),
|
||||
&updated.clone().into(),
|
||||
¤t,
|
||||
))
|
||||
.await?;
|
||||
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to switch off the imported web interface.")
|
||||
.reason(result.to_string())
|
||||
.caused_by(trc::location!()));
|
||||
}
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::Startup),
|
||||
Details = "Switched off the upstream web interface an imported store listed; switch it back on in its Application if it's wanted.",
|
||||
Url = updated.resource_url,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_upstream_web_interface(resource_url: &str) -> bool {
|
||||
resource_url.starts_with(UPSTREAM_WEB_INTERFACE_URL)
|
||||
}
|
||||
|
||||
/// An install from before the rename, upstream's or this fork's, has the web
|
||||
/// interface registered as `stalwart-webui`, and may
|
||||
/// have an application naming it. The application is moved to the current id
|
||||
@@ -422,6 +485,24 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upstream_web_interface_is_recognised_by_its_download() {
|
||||
for url in [
|
||||
"https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip",
|
||||
"https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip",
|
||||
] {
|
||||
assert!(is_upstream_web_interface(url), "{url}");
|
||||
}
|
||||
for url in [
|
||||
"https://127.0.0.1/webui.zip",
|
||||
"https://github.com/stalwartlabs/webui-fork/releases/download/v1/webui.zip",
|
||||
"https://example.org/github.com/stalwartlabs/webui/webui.zip",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_upstream_web_interface(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
fn web_interface() -> Application {
|
||||
Application {
|
||||
description: "inbuxa Web Interface".to_string(),
|
||||
|
||||
+4
-2
@@ -492,8 +492,10 @@ that downloads a release of `stalwartlabs/webui` from GitHub. The fork:
|
||||
Done 2026-09-18. A first boot of the debug build answered 404 on `/admin` and
|
||||
`/account` in bootstrap mode and after setup, held no web application and no
|
||||
OAuth client, and logged no download. Setup worked over JMAP. An install
|
||||
upgraded from Stalwart keeps any web application it has, and contract C-6
|
||||
still registers its OAuth client.
|
||||
upgraded from Stalwart keeps its web application records, and contract C-6
|
||||
still registers its OAuth client. Upstream's own web interface is switched
|
||||
off the first time inbuxa opens the store, with its record kept, so the
|
||||
operator can switch it back on (`docs/spec/import-0.16.md`).
|
||||
|
||||
Where each of the web interface's jobs goes:
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ Each of these is done in place on first start, and each is listed in
|
||||
`tests/resources/import-0.16.27/known-differences.txt` or tested on its own:
|
||||
|
||||
- **Store version:** written as 1 (`crates/migration`).
|
||||
- **Upstream's web interface** is switched off, record kept (see below).
|
||||
- **Roles:** inbuxa adds its Compliance Officer role. The built-in System
|
||||
Administrator and Tenant Administrator roles gain inbuxa's own permissions
|
||||
(account locks, audit, legal holds, journaling, DLP, mail rules,
|
||||
@@ -79,10 +80,12 @@ Each of these is done in place on first start, and each is listed in
|
||||
restores passwords on JMAP for an operator who needs it while clients move
|
||||
to OAuth.
|
||||
- **The 0.16 web interface.** Its application record carries over and points
|
||||
at upstream's `webui` release on GitHub. inbuxa keeps an imported
|
||||
application and fetches it (SPEC.md §5.3), so it serves that interface at
|
||||
`/admin` unless the operator removes it. Open: whether an import should
|
||||
disable it (see below).
|
||||
at upstream's `webui` release on GitHub. inbuxa switches it off the first
|
||||
time it opens the store (`first_party.rs`,
|
||||
`disable_imported_web_interface`, test `imported_web_interface`), keeps the
|
||||
record, and logs that it did. An operator who still wants that interface at
|
||||
`/admin` switches the application back on, and it stays on. Applications of
|
||||
the operator's own aren't touched.
|
||||
|
||||
## Not covered
|
||||
|
||||
@@ -99,7 +102,8 @@ Each of these is done in place on first start, and each is listed in
|
||||
needs docker and about seven minutes, most of it waiting for the reindex.
|
||||
It fails on any difference that isn't in `known-differences.txt`, and on
|
||||
any of the after-import checks: delivery through the imported Sieve filter,
|
||||
search over imported mail, and re-saving a pre-rename filter. A known
|
||||
search over imported mail, re-saving a pre-rename filter, and the 0.16 web
|
||||
interface switched off with its record kept. A known
|
||||
difference that stops happening is reported as `GONE`, so the list can't go
|
||||
stale without anyone noticing.
|
||||
|
||||
@@ -112,9 +116,3 @@ it. The server it builds has no internet access, so upstream's web
|
||||
interface bundle and the data files it downloads at first start never reach
|
||||
the fixture. The fixture holds only what `populate.py` creates, plus what
|
||||
0.16.27 creates at setup.
|
||||
|
||||
## Open
|
||||
|
||||
- Whether importing a 0.16 store should disable the leftover web interface
|
||||
application (keeping the record, so the operator can switch it back on),
|
||||
rather than serving upstream's interface at `/admin`.
|
||||
@@ -6,17 +6,29 @@
|
||||
|
||||
//! inbuxa: the store version mark. A store without it gets it on start, a
|
||||
//! marked store is left alone, and a store marked by a newer release is
|
||||
//! refused before anything is written to it.
|
||||
//! refused before anything is written to it. A store opened for the first
|
||||
//! time has upstream's web interface switched off (docs/spec/import-0.16.md).
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use common::{INBUXA_STORE_VERSION, INBUXA_STORE_VERSION_KEY, Server};
|
||||
use common::{
|
||||
INBUXA_STORE_VERSION, INBUXA_STORE_VERSION_KEY, Server,
|
||||
manager::first_party::disable_imported_web_interface,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
use registry::{
|
||||
schema::{prelude::ObjectInner, structs::Application},
|
||||
types::map::Map,
|
||||
};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use store::{
|
||||
SUBSPACE_INBUXA, SerializeInfallible,
|
||||
registry::{bootstrap::Bootstrap, write::RegistryWrite},
|
||||
write::{AnyClass, AnyKey, BatchBuilder, ValueClass},
|
||||
};
|
||||
|
||||
const UPSTREAM_WEBUI: &str =
|
||||
"https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip";
|
||||
|
||||
async fn read_mark(server: &Server) -> Option<u32> {
|
||||
server
|
||||
.store()
|
||||
@@ -74,3 +86,85 @@ async fn store_version() {
|
||||
write_mark(server, Some(INBUXA_STORE_VERSION)).await;
|
||||
migration::try_migrate(server).await.unwrap();
|
||||
}
|
||||
|
||||
async fn applications(server: &Server) -> Vec<(String, bool)> {
|
||||
let mut apps = server
|
||||
.registry()
|
||||
.list::<Application>()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|app| (app.object.resource_url, app.object.enabled))
|
||||
.collect::<Vec<_>>();
|
||||
apps.sort();
|
||||
apps
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn imported_web_interface() {
|
||||
// A store brought over from Stalwart: no mark yet, and upstream's web
|
||||
// interface listed and on, beside an application of the operator's own.
|
||||
let app = |url: &str| Application {
|
||||
enabled: true,
|
||||
description: "Web interface".to_string(),
|
||||
resource_url: url.to_string(),
|
||||
url_prefix: Map::new(vec!["/admin".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let test = TestServerBuilder::new("imported_web_interface")
|
||||
.await
|
||||
.with_object(app(UPSTREAM_WEBUI))
|
||||
.await
|
||||
.with_object(app("https://127.0.0.1/own-app.zip"))
|
||||
.await
|
||||
.disable_services()
|
||||
.build()
|
||||
.await;
|
||||
let server = &test.server;
|
||||
|
||||
println!("Running imported web interface tests...");
|
||||
|
||||
// Starting switched upstream's off and kept the record; the other is on.
|
||||
assert_eq!(
|
||||
applications(server).await,
|
||||
vec![
|
||||
("https://127.0.0.1/own-app.zip".to_string(), true),
|
||||
(UPSTREAM_WEBUI.to_string(), false),
|
||||
]
|
||||
);
|
||||
|
||||
// Once the store has its version, an operator who switches it back on
|
||||
// keeps it on.
|
||||
migration::try_migrate(server).await.unwrap();
|
||||
let upstream = server
|
||||
.registry()
|
||||
.list::<Application>()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|app| app.object.resource_url == UPSTREAM_WEBUI)
|
||||
.unwrap();
|
||||
let mut on = upstream.object.clone();
|
||||
on.enabled = true;
|
||||
let current = registry::schema::prelude::Object::with_revision(
|
||||
ObjectInner::from(upstream.object),
|
||||
upstream.revision,
|
||||
);
|
||||
server
|
||||
.registry()
|
||||
.write(RegistryWrite::update(
|
||||
upstream.id.id(),
|
||||
&on.into(),
|
||||
¤t,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut bp = Bootstrap::new(server.registry().clone()).await;
|
||||
disable_imported_web_interface(&mut bp).await.unwrap();
|
||||
assert!(
|
||||
applications(server)
|
||||
.await
|
||||
.contains(&(UPSTREAM_WEBUI.to_string(), true)),
|
||||
"a marked store's web interface is left as the operator set it"
|
||||
);
|
||||
}
|
||||
@@ -39,3 +39,10 @@ crates/common/src/manager/spam_rules.rs "https://github.com/stalwartlabs/spam-fi
|
||||
# bundles this server line can serve.
|
||||
crates/common/src/manager/defaults.rs "https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip"
|
||||
crates/common/src/manager/defaults.rs "https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
|
||||
# Recognising that web application in a store brought over from Stalwart, to
|
||||
# switch it off on first open (docs/spec/import-0.16.md).
|
||||
crates/common/src/manager/first_party.rs "https://github.com/stalwartlabs/webui/"
|
||||
crates/common/src/manager/first_party.rs "https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip"
|
||||
crates/common/src/manager/first_party.rs "https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
|
||||
crates/common/src/manager/first_party.rs "https://github.com/stalwartlabs/webui-fork/releases/download/v1/webui.zip"
|
||||
crates/common/src/manager/first_party.rs "https://example.org/github.com/stalwartlabs/webui/webui.zip"
|
||||
@@ -2,8 +2,8 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
"""What happens on the imported store after the move: delivery through the
|
||||
imported Sieve filter, search over imported mail, and re-saving a filter that
|
||||
uses a pre-rename extension name."""
|
||||
imported Sieve filter, search over imported mail, re-saving a filter that uses
|
||||
a pre-rename extension name, and the 0.16 web interface switched off."""
|
||||
import json, smtplib, sys, time, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from jmap import Jmap
|
||||
@@ -58,9 +58,11 @@ blob = alice.upload(b'require ["fileinto", "vnd.stalwart.expressions"];\r\nlet "
|
||||
r = alice.call(("SieveScript/set", {"accountId": acct, "update": {script["id"]: {"blobId": blob}}}))[0]
|
||||
ok("re-saving a pre-rename filter", not r.get("notUpdated"), r.get("notUpdated"))
|
||||
|
||||
# 4. The 0.16 web interface left in the registry.
|
||||
# 4. The 0.16 web interface: switched off on first open, record kept.
|
||||
A = json.load(open(os.path.join(os.environ["FIXTURE"], "admin.json")))
|
||||
adm = Jmap(BASE, A["username"], A["secret"], ["urn:inbuxa:jmap:registry"])
|
||||
apps = adm.call(("x:Application/get", {"ids": None}))[0]["list"]
|
||||
print("INFO applications:", [(a.get("description"), a.get("enabled"), a.get("resourceUrl")) for a in apps])
|
||||
webui = [a for a in apps if (a.get("resourceUrl") or "").startswith("https://github.com/stalwartlabs/webui/")]
|
||||
ok("0.16 web interface switched off, record kept", webui and not any(a.get("enabled") for a in webui),
|
||||
[(a.get("enabled"), a.get("resourceUrl")) for a in apps])
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -11,7 +11,8 @@
|
||||
# what 0.16.27 served (expected.json, less known-differences.txt). Then it
|
||||
# checks what happens after the move: mail delivered through the imported
|
||||
# Sieve filter, search over imported mail once the reindex has run (up to
|
||||
# five minutes), and re-saving a filter that uses a pre-rename extension name.
|
||||
# five minutes), re-saving a filter that uses a pre-rename extension name, and
|
||||
# the 0.16 web interface switched off with its record kept.
|
||||
# Run it on every release candidate; see docs/spec/import-0.16.md.
|
||||
#
|
||||
# Needs docker, python3, loopback ports 18091 and 18025, and DNS (delivery
|
||||
@@ -30,8 +31,8 @@ echo '{"@type":"RocksDb","path":"/var/lib/inbuxa/"}' > "$WORK/etc/config.json" &
|
||||
docker run --rm -v "$WORK/etc:/etc/inbuxa" -v "$WORK/data:/var/lib/inbuxa" -v "$FIXTURE/export:/import:ro" \
|
||||
$IMAGE --config /etc/inbuxa/config.json --import /import | tail -1
|
||||
# Basic auth on JMAP is refused by default (contract C-23); the check signs in
|
||||
# with the fixture's passwords. GitHub stays unreachable, so the 0.16 web
|
||||
# interface the store still lists is never fetched.
|
||||
# with the fixture's passwords. GitHub stays unreachable, so an image from
|
||||
# before the 0.16 web interface was switched off on import never fetches it.
|
||||
docker run -d --name $NAME -e INBUXA_HTTP_BASIC_AUTH=all --add-host github.com:127.0.0.1 \
|
||||
-p 127.0.0.1:18091:8080 -p 127.0.0.1:18025:25 \
|
||||
-v "$WORK/etc:/etc/inbuxa" -v "$WORK/data:/var/lib/inbuxa" $IMAGE >/dev/null
|
||||
|
||||
Reference in new issue
Block a user