Find Stalwart's administration instead of asking for it
The dashboard's link to Stalwart's own administration needed
STALWART_ADMIN_URL, which an operator had to know to set. Everything it
holds can be read from the server:
- the origin is the host Stalwart advertises in its own session URLs, the
one people reach it at even when ihasmail talks to it on a private
address;
- the prefix is where its web interface application is installed. The
production server's x:Application reads "Stalwart Web Interface",
enabled, urlPrefix {"/admin", "/account"}, and /admin is also what
Stalwart writes at first boot.
So, for an account that administers, the account info fetch now asks the
account's own server for its applications and links to origin + /admin/.
An installation whose web interface is disabled or moved gets no link; an
administrator who may not read applications gets Stalwart's default
/admin. It is cached with the rest of the account info.
STALWART_ADMIN_URL and a servers file entry's adminUrl still win, for an
administration that lives somewhere else. A routed domain without one now
takes what its own server said, never the default server's.
This commit is contained in:
+10
-6
@@ -1153,11 +1153,15 @@ so rows come out even: six are three over three, and fall to two and then one
|
||||
as the space narrows. **Refresh** reads everything again; nothing is polled.
|
||||
|
||||
Below the cards, a line says where the rest is: detailed metrics, the delivery
|
||||
queue, logs and server settings are in Stalwart's own administration. It links
|
||||
there when the operator sets `STALWART_ADMIN_URL` — or, for a domain routed to
|
||||
another server, that server's `adminUrl` in the servers file — and is plain text
|
||||
otherwise, since the address ihasmail reaches Stalwart on is often not one a
|
||||
browser can open.
|
||||
queue, logs and server settings are in Stalwart's own administration, and it
|
||||
links there. The address is found rather than configured: the public host
|
||||
Stalwart advertises in its own session — the one people reach it at, even when
|
||||
ihasmail talks to it on a private address — and the prefix its web interface is
|
||||
installed under, read from its `x:Application` objects (`/admin` unless it was
|
||||
moved). A server whose web interface is disabled or moved away gets no link, and
|
||||
an administrator who may not read applications gets Stalwart's default `/admin`.
|
||||
`STALWART_ADMIN_URL`, or a servers file entry's `adminUrl`, overrides it for an
|
||||
administration that lives somewhere else.
|
||||
|
||||
## Accounts
|
||||
|
||||
@@ -1715,7 +1719,7 @@ wizard, because either would be state.
|
||||
| --- | --- | --- |
|
||||
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
|
||||
| `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off |
|
||||
| `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link |
|
||||
| `STALWART_ADMIN_URL` | found | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Unset, it is found: the host Stalwart advertises and its web interface's prefix. Set it only when the administration lives somewhere else |
|
||||
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
|
||||
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
|
||||
| `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below |
|
||||
|
||||
@@ -215,11 +215,12 @@ installation that sets nothing else behaves exactly as it always has.
|
||||
```
|
||||
|
||||
[`stalwart-servers.example.json`](stalwart-servers.example.json) is that file
|
||||
with the rules written in it. A domain's value may also be an object that names
|
||||
where that server's own administration is, for the Administration dashboard's
|
||||
link — `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
|
||||
`STALWART_ADMIN_URL` is the same for the default server. A listed domain with no
|
||||
`adminUrl` gets no link rather than the default server's.
|
||||
with the rules written in it. The Administration dashboard links each administrator to
|
||||
their own server's administration, found from that server; a domain's value may
|
||||
also be an object that overrides it, for an administration that lives elsewhere —
|
||||
`{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
|
||||
`STALWART_ADMIN_URL` is the same for the default server. A listed domain is never
|
||||
pointed at the default server's administration.
|
||||
|
||||
A domain nobody listed — and a bare username, which Stalwart accepts and which
|
||||
has no domain at all — goes to `STALWART_URL`. **A listed domain never falls
|
||||
|
||||
@@ -18,7 +18,7 @@ process.env.STALWART_URL = "https://default.example";
|
||||
process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
|
||||
process.env.STALWART_SERVERS_FILE = file;
|
||||
|
||||
const { adminUrlFor, upstreamFor } = await import("./upstream.js");
|
||||
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
|
||||
const { config, parseStalwartServers } = await import("./config.js");
|
||||
|
||||
/**
|
||||
@@ -39,10 +39,33 @@ test("an unmapped domain and a bare username open the default administration", (
|
||||
|
||||
test("a routed domain opens its own server's administration, and never the default's", () => {
|
||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
|
||||
// Routed away, with no adminUrl of its own: no link rather than the wrong server.
|
||||
// Routed away, with no adminUrl of its own and nothing found: no link rather than the wrong server.
|
||||
assert.equal(adminUrlFor("[email protected]"), null);
|
||||
});
|
||||
|
||||
test("what the operator configured wins over what was found, and what was found fills the gap", () => {
|
||||
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.default.example");
|
||||
assert.equal(adminUrlFor("[email protected]", "https://found.example/admin/"), "https://admin.linked.test");
|
||||
// The routed domain without an adminUrl takes what its own server said.
|
||||
assert.equal(adminUrlFor("[email protected]", "https://mail.plain.test/admin/"), "https://mail.plain.test/admin/");
|
||||
});
|
||||
|
||||
/** Finding the administration on the server itself, as production's answered on 2026-09-15. */
|
||||
test("the web interface's prefix is read from the applications Stalwart has installed", () => {
|
||||
const got = (list: unknown[]) => adminPrefixFrom([["x:Application/query", { ids: ["a"] }, "q"], ["x:Application/get", { list }, "g"]]);
|
||||
assert.equal(got([{ enabled: true, description: "Stalwart Web Interface", urlPrefix: { "/admin": true, "/account": true } }]), "/admin");
|
||||
assert.equal(got([{ enabled: false, urlPrefix: { "/admin": true } }]), null);
|
||||
assert.equal(got([{ enabled: true, urlPrefix: { "/console": true } }]), null);
|
||||
assert.equal(got([]), null);
|
||||
// May not read applications: not an answer, so Stalwart's own default.
|
||||
assert.equal(adminPrefixFrom([["error", { type: "forbidden" }, "q"], ["error", { type: "forbidden" }, "g"]]), "/admin");
|
||||
});
|
||||
|
||||
test("the origin is the one Stalwart advertises, even when it is reached on a private address", () => {
|
||||
assert.equal(advertisedOrigin({ apiUrl: "https://mail.example.com/jmap/", baseUrl: "http://127.0.0.1:8080" }), "https://mail.example.com");
|
||||
assert.equal(advertisedOrigin({ apiUrl: "/jmap/", baseUrl: "https://mail.example.com" }), "https://mail.example.com");
|
||||
});
|
||||
|
||||
test("the shipped example loads through the parser that reads it", () => {
|
||||
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
||||
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
||||
|
||||
+1
-1
@@ -898,7 +898,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
*/
|
||||
server: {
|
||||
edition: info.edition,
|
||||
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null,
|
||||
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username, info.adminUrl ?? null) : null,
|
||||
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
|
||||
enterpriseNotices: config.showEnterpriseNotices,
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||
|
||||
/** What the dashboard reads beyond the directory. */
|
||||
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"];
|
||||
const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery", "sysApplicationGet", "sysApplicationQuery"];
|
||||
|
||||
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||
@@ -226,6 +226,8 @@ export function createDirectory(opts: Options) {
|
||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||
}
|
||||
}
|
||||
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
|
||||
|
||||
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
||||
const tenants: Obj[] = [
|
||||
{ id: "t1", name: "Acme Corp", logo: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: { maxAccounts: 25, maxDomains: 2, maxDiskQuota: 50 * GIB }, createdAt: "2026-07-01T09:00:00Z" },
|
||||
@@ -693,6 +695,10 @@ export function createDirectory(opts: Options) {
|
||||
}
|
||||
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||
},
|
||||
// Stalwart's web interface is an installed application; ihasmail reads its
|
||||
// prefix to link the dashboard to it.
|
||||
"x:Application/query": query(() => applications, "sysApplicationQuery", ["text"], () => true),
|
||||
"x:Application/get": get(applications, "sysApplicationGet"),
|
||||
"x:Role/get": get(roles, "sysRoleGet"),
|
||||
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => (f.memberTenantId === undefined || (o.memberTenantId ?? null) === f.memberTenantId) && matchText(o, f.description)),
|
||||
};
|
||||
|
||||
+82
-7
@@ -1,4 +1,5 @@
|
||||
import { config } from "./config.js";
|
||||
import { grantsAdministration } from "./adminGate.js";
|
||||
|
||||
export interface UpstreamSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
@@ -56,17 +57,83 @@ export function upstreamFor(username: string): string {
|
||||
|
||||
/**
|
||||
* Where the administrator signed in as `username` opens Stalwart's own
|
||||
* administration, or null when the operator has not said.
|
||||
* administration.
|
||||
*
|
||||
* Follows the same routing as `upstreamFor`, and for the same reason never
|
||||
* falls back: a domain routed to another server is not pointed at the default
|
||||
* server's administration, where its accounts are not.
|
||||
* What the operator configured wins -- STALWART_ADMIN_URL for the default
|
||||
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
|
||||
* found on the account's own server (`detected`) is used otherwise. Routing is
|
||||
* the same as `upstreamFor`: a routed domain is never pointed at the default
|
||||
* server's administration, and `detected` already came from its own server.
|
||||
*/
|
||||
export function adminUrlFor(username: string): string | null {
|
||||
export function adminUrlFor(username: string, detected: string | null = null): string | null {
|
||||
const at = username.lastIndexOf("@");
|
||||
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? null;
|
||||
return config.stalwartAdminUrl || null;
|
||||
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? detected;
|
||||
return config.stalwartAdminUrl || detected;
|
||||
}
|
||||
|
||||
/** Stalwart's own default for its web interface, written at first boot (`manager/defaults.rs`). */
|
||||
const DEFAULT_ADMIN_PREFIX = "/admin";
|
||||
|
||||
/**
|
||||
* The prefix Stalwart's administration is served under, from the `x:Application`
|
||||
* answers: "/admin" if an enabled application claims it, null if the server
|
||||
* says there is none (disabled, removed, or moved to another prefix). A refusal
|
||||
* -- the account may not read applications -- is not an answer, and gets
|
||||
* Stalwart's default.
|
||||
*/
|
||||
export function adminPrefixFrom(responses: [string, Record<string, unknown>, string][]): string | null {
|
||||
const get = responses.find(([name]) => name === "x:Application/get" || name === "error");
|
||||
if (!get || get[0] === "error") return DEFAULT_ADMIN_PREFIX;
|
||||
const list = (get[1].list as Array<{ enabled?: unknown; urlPrefix?: unknown }> | undefined) ?? [];
|
||||
const claims = list.some((app) => app.enabled !== false && app.urlPrefix && typeof app.urlPrefix === "object" && DEFAULT_ADMIN_PREFIX in (app.urlPrefix as object));
|
||||
return claims ? DEFAULT_ADMIN_PREFIX : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public origin a Stalwart session belongs to: the host it advertises in
|
||||
* its own URLs, which is the address people reach it at even when this server
|
||||
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative
|
||||
* URL falls back to the configured base.
|
||||
*/
|
||||
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
|
||||
try {
|
||||
return new URL(session.apiUrl, session.baseUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this session's server serves its own administration, found from the
|
||||
* server itself: its advertised origin, and the prefix its web interface
|
||||
* application is installed under. Null when the server says it has none.
|
||||
*/
|
||||
async function detectAdminUrl(authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
const origin = advertisedOrigin(session);
|
||||
const accountId = session.primaryAccounts?.[STALWART_CAP];
|
||||
if (!origin) return null;
|
||||
let prefix: string | null = DEFAULT_ADMIN_PREFIX;
|
||||
if (accountId) {
|
||||
try {
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: [JMAP_CORE, STALWART_CAP],
|
||||
methodCalls: [
|
||||
["x:Application/query", { accountId }, "q"],
|
||||
["x:Application/get", { accountId, "#ids": { resultOf: "q", name: "x:Application/query", path: "/ids" }, properties: ["enabled", "urlPrefix"] }, "g"],
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.ok) prefix = adminPrefixFrom(((await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] }).methodResponses ?? []);
|
||||
} catch {
|
||||
/* unreachable is not "none": keep the default */
|
||||
}
|
||||
}
|
||||
return prefix ? `${origin}${prefix}/` : null;
|
||||
}
|
||||
|
||||
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||
@@ -157,6 +224,11 @@ export interface AccountInfo {
|
||||
* access.
|
||||
*/
|
||||
permissions: string[];
|
||||
/**
|
||||
* Where this server's own administration is, found rather than configured:
|
||||
* see `detectAdminUrl`. Only looked for when the account administers.
|
||||
*/
|
||||
adminUrl?: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
@@ -309,6 +381,9 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
|
||||
// Only an administrator is shown the link, so only an administrator's
|
||||
// server is asked where it is.
|
||||
if (grantsAdministration(info.permissions)) info = { ...info, adminUrl: await detectAdminUrl(authorization, session) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
"JSON, a duplicate domain, or a value that is not an http(s) URL stops the",
|
||||
"server at startup rather than failing quietly at somebody's sign-in.",
|
||||
"",
|
||||
"A value may instead be an object that also says where that server's own",
|
||||
"administration is, for the link on ihasmail's Administration dashboard:",
|
||||
"{\"url\": ..., \"adminUrl\": ...}. STALWART_ADMIN_URL is the same for the",
|
||||
"default server. A listed domain without adminUrl gets no link, never the",
|
||||
"default server's.",
|
||||
"ihasmail's Administration dashboard links to each server's own",
|
||||
"administration, found from the server. A value may instead be an object",
|
||||
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
|
||||
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
|
||||
"never pointed at the default server's administration.",
|
||||
"",
|
||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user