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.
|
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
|
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
|
queue, logs and server settings are in Stalwart's own administration, and it
|
||||||
there when the operator sets `STALWART_ADMIN_URL` — or, for a domain routed to
|
links there. The address is found rather than configured: the public host
|
||||||
another server, that server's `adminUrl` in the servers file — and is plain text
|
Stalwart advertises in its own session — the one people reach it at, even when
|
||||||
otherwise, since the address ihasmail reaches Stalwart on is often not one a
|
ihasmail talks to it on a private address — and the prefix its web interface is
|
||||||
browser can open.
|
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
|
## 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` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
[`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
|
with the rules written in it. The Administration dashboard links each administrator to
|
||||||
where that server's own administration is, for the Administration dashboard's
|
their own server's administration, found from that server; a domain's value may
|
||||||
link — `{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
|
also be an object that overrides it, for an administration that lives elsewhere —
|
||||||
`STALWART_ADMIN_URL` is the same for the default server. A listed domain with no
|
`{"url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test"}`.
|
||||||
`adminUrl` gets no link rather than the default server's.
|
`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
|
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
|
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_ADMIN_URL = "https://admin.default.example/";
|
||||||
process.env.STALWART_SERVERS_FILE = file;
|
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");
|
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", () => {
|
test("a routed domain opens its own server's administration, and never the default's", () => {
|
||||||
assert.equal(adminUrlFor("[email protected]"), "https://admin.linked.test");
|
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);
|
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", () => {
|
test("the shipped example loads through the parser that reads it", () => {
|
||||||
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
||||||
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
||||||
|
|||||||
+1
-1
@@ -898,7 +898,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
edition: info.edition,
|
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. */
|
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
|
||||||
enterpriseNotices: config.showEnterpriseNotices,
|
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}`));
|
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||||
|
|
||||||
/** What the dashboard reads beyond the directory. */
|
/** 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. */
|
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||||
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailUpdate", "jmapMailboxGet", "sysAccountSettingsGet"];
|
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);
|
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. */
|
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
||||||
const tenants: Obj[] = [
|
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" },
|
{ 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 } : {}) };
|
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/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)),
|
"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 { config } from "./config.js";
|
||||||
|
import { grantsAdministration } from "./adminGate.js";
|
||||||
|
|
||||||
export interface UpstreamSession {
|
export interface UpstreamSession {
|
||||||
capabilities: Record<string, unknown>;
|
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
|
* 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
|
* What the operator configured wins -- STALWART_ADMIN_URL for the default
|
||||||
* falls back: a domain routed to another server is not pointed at the default
|
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
|
||||||
* server's administration, where its accounts are not.
|
* 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 at = username.lastIndexOf("@");
|
||||||
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
const domain = at < 0 ? "" : username.slice(at + 1).trim().toLowerCase().replace(/\.$/, "");
|
||||||
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? null;
|
if (domain && domain in config.stalwartServers) return config.stalwartAdminUrls[domain] ?? detected;
|
||||||
return config.stalwartAdminUrl || null;
|
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 {
|
export function wellKnownUrl(base: string = config.stalwartUrl): string {
|
||||||
@@ -157,6 +224,11 @@ export interface AccountInfo {
|
|||||||
* access.
|
* access.
|
||||||
*/
|
*/
|
||||||
permissions: string[];
|
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 }>();
|
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
@@ -309,6 +381,9 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
|
|||||||
try {
|
try {
|
||||||
info = await fetchAccountInfo(authorization, session);
|
info = await fetchAccountInfo(authorization, session);
|
||||||
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
|
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 {
|
} catch {
|
||||||
/* all of this is a nicety - never fail the session over it */
|
/* 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",
|
"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.",
|
"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",
|
"ihasmail's Administration dashboard links to each server's own",
|
||||||
"administration is, for the link on ihasmail's Administration dashboard:",
|
"administration, found from the server. A value may instead be an object",
|
||||||
"{\"url\": ..., \"adminUrl\": ...}. STALWART_ADMIN_URL is the same for the",
|
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
|
||||||
"default server. A listed domain without adminUrl gets no link, never the",
|
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
|
||||||
"default server's.",
|
"never pointed at the default server's administration.",
|
||||||
"",
|
"",
|
||||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user