@wtfalch/foundry 0.16.9 → 0.16.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -260,7 +260,9 @@ export interface PlatformMailAdminPort {
|
|
|
260
260
|
* Sets the instance's default hostname and its OIDC directory
|
|
261
261
|
* (`x:SystemSettings`/`x:Directory`/`x:Domain`/`x:Authentication`) --
|
|
262
262
|
* idempotent, safe to call on every apply once the instance is up. A fresh
|
|
263
|
-
* instance is first
|
|
263
|
+
* instance is first given its `Bootstrap` config; Stalwart 0.16 loads that
|
|
264
|
+
* only on its next start, so the call then returns `'restart'` having
|
|
265
|
+
* written nothing else. The caller restarts the app and calls again.
|
|
264
266
|
*/
|
|
265
267
|
applySettings(input: {
|
|
266
268
|
mailHost: string;
|
|
@@ -270,8 +272,12 @@ export interface PlatformMailAdminPort {
|
|
|
270
272
|
issuerUrl: string;
|
|
271
273
|
audience: string;
|
|
272
274
|
};
|
|
273
|
-
}): Promise<
|
|
274
|
-
/**
|
|
275
|
+
}): Promise<'restart' | undefined>;
|
|
276
|
+
/**
|
|
277
|
+
* `StalwartClient.createApiKey`'s own signature. The key belongs to a real
|
|
278
|
+
* admin account, not to the recovery admin (see
|
|
279
|
+
* {@link defaultStalwartForInstance}).
|
|
280
|
+
*/
|
|
275
281
|
createApiKey(description: string, permissions: readonly string[] | 'inherit'): Promise<string>;
|
|
276
282
|
}
|
|
277
283
|
/**
|
|
@@ -293,6 +299,8 @@ export declare function defaultStalwartForInstance(input: {
|
|
|
293
299
|
url: string;
|
|
294
300
|
user: string;
|
|
295
301
|
password: string;
|
|
302
|
+
/** The platform's apex: the domain of the admin account the API key is minted as. */
|
|
303
|
+
apex: string;
|
|
296
304
|
}): PlatformMailAdminPort;
|
|
297
305
|
/**
|
|
298
306
|
* #169: everything {@link provisionPlatformMail} needs beyond
|
|
@@ -322,6 +330,7 @@ export interface PlatformMailDeps {
|
|
|
322
330
|
url: string;
|
|
323
331
|
user: string;
|
|
324
332
|
password: string;
|
|
333
|
+
apex: string;
|
|
325
334
|
}): PlatformMailAdminPort;
|
|
326
335
|
deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
|
|
327
336
|
/** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
|
|
@@ -1488,10 +1488,11 @@ export function defaultStalwartForInstance(input) {
|
|
|
1488
1488
|
return {
|
|
1489
1489
|
async applySettings({ mailHost, apex, oidc }) {
|
|
1490
1490
|
// A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
|
|
1491
|
-
// but the `Bootstrap` object,
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1491
|
+
// but the `Bootstrap` object, and loads that object only on its next
|
|
1492
|
+
// start, so write it and hand the restart to the caller. A server
|
|
1493
|
+
// already past it skips this. `update` drops the response, which
|
|
1494
|
+
// carries the generated `admin@<apex>` secret: this step signs in with
|
|
1495
|
+
// the recovery admin and never needs it.
|
|
1495
1496
|
if ((await instance.client.probe()) === 'bootstrap') {
|
|
1496
1497
|
await instance.client.update('Bootstrap', 'singleton', {
|
|
1497
1498
|
serverHostname: mailHost,
|
|
@@ -1499,13 +1500,45 @@ export function defaultStalwartForInstance(input) {
|
|
|
1499
1500
|
// The server sits behind a tunnel; no ACME challenge can reach it.
|
|
1500
1501
|
requestTlsCertificate: false,
|
|
1501
1502
|
});
|
|
1502
|
-
|
|
1503
|
+
return 'restart';
|
|
1503
1504
|
}
|
|
1504
1505
|
await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
|
|
1505
1506
|
},
|
|
1506
|
-
createApiKey
|
|
1507
|
+
async createApiKey(description, permissions) {
|
|
1508
|
+
// The recovery admin is not a directory account (Stalwart's
|
|
1509
|
+
// `account_set` in `jmap/src/registry/mapping/account.rs` finds no
|
|
1510
|
+
// Account for its id and answers notFound), so it cannot own a key. Give an internal admin account (no credential, so
|
|
1511
|
+
// nothing to sign in with, and the OIDC directory is never asked) and
|
|
1512
|
+
// act as it: the recovery admin may impersonate any account as
|
|
1513
|
+
// `<account>%<recovery user>`.
|
|
1514
|
+
const domains = await instance.client.list('Domain');
|
|
1515
|
+
const domain = domains.find((d) => d.name === input.apex);
|
|
1516
|
+
if (!domain) {
|
|
1517
|
+
throw new Error(`platform:mail: Stalwart has no domain ${input.apex} to hold the admin account`);
|
|
1518
|
+
}
|
|
1519
|
+
await instance.client.upsert('Account', (a) => a.name === PLATFORM_MAIL_ADMIN_ACCOUNT && a.domainId === domain.id, {
|
|
1520
|
+
'@type': 'User',
|
|
1521
|
+
name: PLATFORM_MAIL_ADMIN_ACCOUNT,
|
|
1522
|
+
domainId: domain.id,
|
|
1523
|
+
description: 'foundry platform admin (API key owner)',
|
|
1524
|
+
roles: { '@type': 'Admin' },
|
|
1525
|
+
permissions: { '@type': 'Inherit' },
|
|
1526
|
+
encryptionAtRest: { '@type': 'Disabled' },
|
|
1527
|
+
quotas: {},
|
|
1528
|
+
aliases: {},
|
|
1529
|
+
memberGroupIds: {},
|
|
1530
|
+
}, {});
|
|
1531
|
+
const asAdmin = openInstance({
|
|
1532
|
+
url: input.url,
|
|
1533
|
+
secret: input.password,
|
|
1534
|
+
user: `${PLATFORM_MAIL_ADMIN_ACCOUNT}@${input.apex}%${input.user}`,
|
|
1535
|
+
});
|
|
1536
|
+
return asAdmin.client.createApiKey(description, permissions);
|
|
1537
|
+
},
|
|
1507
1538
|
};
|
|
1508
1539
|
}
|
|
1540
|
+
/** The internal account the admin API key belongs to, in the platform's apex domain. */
|
|
1541
|
+
const PLATFORM_MAIL_ADMIN_ACCOUNT = 'foundry-admin';
|
|
1509
1542
|
/** The ZITADEL project and resource api Stalwart's own OIDC directory checks access tokens' `aud` claim against (#346). */
|
|
1510
1543
|
const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
1511
1544
|
/**
|
|
@@ -1783,11 +1816,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1783
1816
|
url: `https://${hostname}`,
|
|
1784
1817
|
user: STALWART_RECOVERY_USER,
|
|
1785
1818
|
password,
|
|
1786
|
-
});
|
|
1787
|
-
await admin.applySettings({
|
|
1788
|
-
mailHost,
|
|
1789
1819
|
apex: platform.apex,
|
|
1790
|
-
|
|
1820
|
+
});
|
|
1821
|
+
await applyMailSettings(admin, {
|
|
1822
|
+
coolify: deps.coolify,
|
|
1823
|
+
appId,
|
|
1824
|
+
runtime,
|
|
1825
|
+
sleep,
|
|
1826
|
+
hostname,
|
|
1827
|
+
settings: {
|
|
1828
|
+
mailHost,
|
|
1829
|
+
apex: platform.apex,
|
|
1830
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1831
|
+
},
|
|
1791
1832
|
});
|
|
1792
1833
|
await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
1793
1834
|
}
|
|
@@ -1815,11 +1856,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1815
1856
|
url: `https://${hostname}`,
|
|
1816
1857
|
user: STALWART_RECOVERY_USER,
|
|
1817
1858
|
password,
|
|
1818
|
-
});
|
|
1819
|
-
await admin.applySettings({
|
|
1820
|
-
mailHost,
|
|
1821
1859
|
apex: platform.apex,
|
|
1822
|
-
|
|
1860
|
+
});
|
|
1861
|
+
await applyMailSettings(admin, {
|
|
1862
|
+
coolify: deps.coolify,
|
|
1863
|
+
appId,
|
|
1864
|
+
runtime,
|
|
1865
|
+
sleep,
|
|
1866
|
+
hostname,
|
|
1867
|
+
settings: {
|
|
1868
|
+
mailHost,
|
|
1869
|
+
apex: platform.apex,
|
|
1870
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1871
|
+
},
|
|
1823
1872
|
});
|
|
1824
1873
|
// Recorded and persisted before the secret leaves this function: if
|
|
1825
1874
|
// delivery fails for good, the next plan reports the lost key rather
|
|
@@ -2017,6 +2066,37 @@ async function waitForMailInstance(url, runtime, sleep) {
|
|
|
2017
2066
|
await sleep(interval);
|
|
2018
2067
|
}
|
|
2019
2068
|
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Applies the settings; when the instance was fresh, restarts the app (the
|
|
2071
|
+
* `Bootstrap` config loads only on Stalwart's next start) and applies again
|
|
2072
|
+
* once it answers ready. Rejects if it is still in bootstrap after the wait.
|
|
2073
|
+
*/
|
|
2074
|
+
async function applyMailSettings(admin, input) {
|
|
2075
|
+
const { runtime, sleep } = input;
|
|
2076
|
+
if ((await admin.applySettings(input.settings)) !== 'restart')
|
|
2077
|
+
return;
|
|
2078
|
+
await input.coolify.raw('POST', `/applications/${input.appId}/restart`);
|
|
2079
|
+
const now = runtime.now ?? Date.now;
|
|
2080
|
+
const deadline = now() + (runtime.readyTimeoutMs ?? 300_000);
|
|
2081
|
+
for (;;) {
|
|
2082
|
+
await sleep(runtime.readyIntervalMs ?? 5_000);
|
|
2083
|
+
await waitForMailInstance(`https://${input.hostname}${STALWART_READY_PATH}`, runtime, sleep);
|
|
2084
|
+
// The old container may still answer, or drop the connection, for a
|
|
2085
|
+
// moment after the restart call.
|
|
2086
|
+
try {
|
|
2087
|
+
if ((await admin.applySettings(input.settings)) !== 'restart')
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
catch (error) {
|
|
2091
|
+
if (now() >= deadline)
|
|
2092
|
+
throw error;
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (now() >= deadline) {
|
|
2096
|
+
throw new Error(`platform:mail: Stalwart at https://${input.hostname} is still in bootstrap mode after a restart; apply again`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2020
2100
|
function redactedMailError(error, secrets) {
|
|
2021
2101
|
const original = error instanceof Error ? error : new Error(String(error));
|
|
2022
2102
|
const message = redactSecrets(original.message, secrets);
|
|
@@ -2964,6 +3044,9 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2964
3044
|
envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
|
|
2965
3045
|
key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
|
|
2966
3046
|
value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
|
|
3047
|
+
// A PEM spans lines; unflagged, Coolify writes it into `.env`
|
|
3048
|
+
// unquoted and the container never starts.
|
|
3049
|
+
multiline: true,
|
|
2967
3050
|
}, {
|
|
2968
3051
|
key: 'INTEGRATIONS_MANAGEMENT_ISSUER',
|
|
2969
3052
|
value: INTEGRATIONS_MANAGEMENT_ISSUER,
|