Merge pull request #366 from Coffey-Labs/feat/detect-stalwart-admin-url
Find Stalwart's administration instead of asking for it
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