diff --git a/FEATURES.md b/FEATURES.md index 46f8f03..c250e5a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 | diff --git a/README.md b/README.md index 3258ac2..abfaf50 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/src/adminurl.test.ts b/server/src/adminurl.test.ts index 44e2fc4..546298d 100644 --- a/server/src/adminurl.test.ts +++ b/server/src/adminurl.test.ts @@ -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("a@linked.test"), "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("a@plain.test"), null); }); +test("what the operator configured wins over what was found, and what was found fills the gap", () => { + assert.equal(adminUrlFor("a@anything.test", "https://found.example/admin/"), "https://admin.default.example"); + assert.equal(adminUrlFor("a@linked.test", "https://found.example/admin/"), "https://admin.linked.test"); + // The routed domain without an adminUrl takes what its own server said. + assert.equal(adminUrlFor("a@plain.test", "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"); diff --git a/server/src/app.ts b/server/src/app.ts index a566947..a231224 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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, }, diff --git a/server/src/mock/directory.ts b/server/src/mock/directory.ts index 03e2b5c..ae327ff 100644 --- a/server/src/mock/directory.ts +++ b/server/src/mock/directory.ts @@ -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)), }; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index aee042e..4df450f 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -1,4 +1,5 @@ import { config } from "./config.js"; +import { grantsAdministration } from "./adminGate.js"; export interface UpstreamSession { capabilities: Record; @@ -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][]): 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): 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 { + 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][] }).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(); @@ -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 */ } diff --git a/stalwart-servers.example.json b/stalwart-servers.example.json index ffcb9e6..54d4897 100644 --- a/stalwart-servers.example.json +++ b/stalwart-servers.example.json @@ -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" ],