From 878aa2e6825bc22a64da0c295eea8f2ae7017b44 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 21:55:02 -0700 Subject: [PATCH] Switch off upstream's web interface when a Stalwart store is first opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A store brought over from Stalwart 0.16 lists upstream's web interface as an Application, so inbuxa fetched it and served it at /admin. The first time inbuxa opens a store without its version mark, it now switches that Application off and keeps the record, logging how to switch it back on. The mark written on the same start keeps this to once, so an operator who wants the interface back keeps it. Other applications aren't touched. Settles the open question in docs/spec/import-0.16.md; SPEC.md §5.3 and the import check follow. --- crates/common/src/manager/defaults.rs | 4 + crates/common/src/manager/first_party.rs | 87 ++++++++++++++++++++- docs/spec/SPEC.md | 6 +- docs/spec/import-0.16.md | 20 +++-- tests/src/store_version.rs | 98 +++++++++++++++++++++++- tools/fork/name-allowlist.txt | 7 ++ tools/import-check/after.py | 10 ++- tools/import-check/check.sh | 7 +- 8 files changed, 214 insertions(+), 25 deletions(-) diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index 26211b2d..1e68083e 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -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?; diff --git a/crates/common/src/manager/first_party.rs b/crates/common/src/manager/first_party.rs index ad22e0ba..7d954246 100644 --- a/crates/common/src/manager/first_party.rs +++ b/crates/common/src/manager/first_party.rs @@ -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::(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::().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(), diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index f772d569..c352705b 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -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: diff --git a/docs/spec/import-0.16.md b/docs/spec/import-0.16.md index 1d1a99e0..21c489f2 100644 --- a/docs/spec/import-0.16.md +++ b/docs/spec/import-0.16.md @@ -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`. diff --git a/tests/src/store_version.rs b/tests/src/store_version.rs index 5874b5be..06dd6aab 100644 --- a/tests/src/store_version.rs +++ b/tests/src/store_version.rs @@ -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 { 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::() + .await + .unwrap() + .into_iter() + .map(|app| (app.object.resource_url, app.object.enabled)) + .collect::>(); + 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::() + .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" + ); +} diff --git a/tools/fork/name-allowlist.txt b/tools/fork/name-allowlist.txt index ccabf55f..3b599d42 100644 --- a/tools/fork/name-allowlist.txt +++ b/tools/fork/name-allowlist.txt @@ -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" diff --git a/tools/import-check/after.py b/tools/import-check/after.py index 39a4320c..4c4bae2b 100755 --- a/tools/import-check/after.py +++ b/tools/import-check/after.py @@ -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) diff --git a/tools/import-check/check.sh b/tools/import-check/check.sh index e4864178..ac319bca 100755 --- a/tools/import-check/check.sh +++ b/tools/import-check/check.sh @@ -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 -- 2.54.0