Switch off upstream's web interface when a Stalwart store is first opened #251

Merged
jcoffey-dev merged 1 commits from feat/disable-imported-webui into main 2026-10-30 05:03:26 +00:00
8 changed files with 214 additions and 25 deletions

No files matched your search

+4
View File
@@ -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?;
+84 -3
View File
@@ -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(),
&current,
))
.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
View File
@@ -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:
+9 -11
View File
@@ -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`.
+96 -2
View File
@@ -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(),
&current,
))
.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"
);
}
+7
View File
@@ -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"
+6 -4
View File
@@ -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)
+4 -3
View File
@@ -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