@wtfalch/foundry 0.33.1 → 0.35.1
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.
- package/dist/bootstrap/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
|
@@ -1,25 +1,46 @@
|
|
|
1
|
+
import { ownsServices } from '../org.js';
|
|
1
2
|
import { agoraHostnameFor } from './agora.js';
|
|
2
3
|
import { bouleHostnameFor } from './boule.js';
|
|
4
|
+
import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
|
|
3
5
|
import { loginHostnameFor } from './login-host.js';
|
|
6
|
+
import { ownHostnames } from './own-hostnames.js';
|
|
7
|
+
/** The labels an own org's services answer on, so no hosting entry may take them. */
|
|
8
|
+
export const OWN_SERVICE_LABELS = ['auth', 'mail', 'files', 'sfu'];
|
|
4
9
|
/**
|
|
5
10
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
6
11
|
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
|
7
|
-
* domain, the sign-in host when the issuer does,
|
|
8
|
-
*
|
|
12
|
+
* domain, the sign-in host when the issuer does, `auth.`, `mail.`, `files.` and
|
|
13
|
+
* `sfu.` for an org that runs its own services, and every platform service
|
|
14
|
+
* hostname that falls under the domain. An own org with no domain holds its
|
|
15
|
+
* oikos.host service names; any other org with no domain holds none.
|
|
9
16
|
*/
|
|
10
17
|
export function lockedHostnames(org, platformHostnames) {
|
|
11
18
|
const { domain } = org;
|
|
12
19
|
const locked = new Set();
|
|
13
|
-
if (!domain)
|
|
20
|
+
if (!domain) {
|
|
21
|
+
if (ownsServices(org)) {
|
|
22
|
+
const own = ownHostnames(org);
|
|
23
|
+
for (const hostname of [own.auth, own.mail, own.files, own.sfu])
|
|
24
|
+
locked.add(hostname);
|
|
25
|
+
}
|
|
26
|
+
if (org.site.enabled) {
|
|
27
|
+
locked.add(siteHostnameFor(org));
|
|
28
|
+
locked.add(cmsStudioHostnameFor(org));
|
|
29
|
+
}
|
|
14
30
|
return locked;
|
|
31
|
+
}
|
|
15
32
|
locked.add(agoraHostnameFor(org));
|
|
16
33
|
locked.add(bouleHostnameFor(org));
|
|
17
34
|
if (org.site.enabled)
|
|
18
|
-
locked.add(
|
|
35
|
+
locked.add(cmsStudioHostnameFor(org));
|
|
19
36
|
if (org.calls.enabled && org.calls.usesDomain)
|
|
20
37
|
locked.add(`calls.${domain}`);
|
|
21
38
|
if (org.issuer.usesDomain)
|
|
22
39
|
locked.add(loginHostnameFor(org));
|
|
40
|
+
if (ownsServices(org)) {
|
|
41
|
+
for (const label of OWN_SERVICE_LABELS)
|
|
42
|
+
locked.add(`${label}.${domain}`);
|
|
43
|
+
}
|
|
23
44
|
for (const hostname of platformHostnames) {
|
|
24
45
|
if (hostname.endsWith(`.${domain}`))
|
|
25
46
|
locked.add(hostname);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
2
|
/**
|
|
3
3
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
4
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -22,16 +22,65 @@ import type { DeclaredOrg } from '../org.js';
|
|
|
22
22
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
23
23
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* that host itself. That host must never get a trusted
|
|
29
|
-
* domain or a login binding of its own:
|
|
30
|
-
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
31
|
-
* `company-host.ts` denies every request through one, the shared one
|
|
32
|
-
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
25
|
+
* Every company, the platform owner included, signs in on its own sign-in
|
|
26
|
+
* server at this host (`ownHostnames(org).auth` is this function): a domain is
|
|
27
|
+
* never required.
|
|
33
28
|
*/
|
|
34
|
-
export declare function loginHostnameFor(org: DeclaredOrg): string;
|
|
29
|
+
export declare function loginHostnameFor(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'issuer'>): string;
|
|
30
|
+
/**
|
|
31
|
+
* The login host Agora may take as its issuer, and whether the org's pair is
|
|
32
|
+
* one Agora refuses. Agora accepts an issuer host on its own address's
|
|
33
|
+
* registrable domain, or `<slug>-auth.oikos.host` for `<slug>-agora.oikos.host`.
|
|
34
|
+
* `host` is null for a domain with `issuer.usesDomain` off, where the login host
|
|
35
|
+
* `<slug>-auth.oikos.host` and the Agora address `agora.<domain>` are
|
|
36
|
+
* refused together (`mismatch`).
|
|
37
|
+
*/
|
|
38
|
+
export declare function agoraIssuerHostFor(org: DeclaredOrg): {
|
|
39
|
+
host: string | null;
|
|
40
|
+
mismatch: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** Milliseconds the discovery read may take before the host counts as not serving. */
|
|
43
|
+
export declare const LOGIN_HOST_DISCOVERY_TIMEOUT_MS = 5000;
|
|
44
|
+
/**
|
|
45
|
+
* Host-supplied read of `GET https://<host>/.well-known/openid-configuration`:
|
|
46
|
+
* the status and the body text. Optional on `FoundryPlanApplyOptions`; absent
|
|
47
|
+
* means "not read", so Agora keeps the platform issuer. The port may throw.
|
|
48
|
+
*/
|
|
49
|
+
export interface LoginHostDiscoveryPort {
|
|
50
|
+
discovery(host: string): Promise<{
|
|
51
|
+
status: number;
|
|
52
|
+
body: string;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
/** True when the answer is a 200 whose JSON `issuer` is exactly `https://<host>`. */
|
|
56
|
+
export declare function discoveryNamesHost(host: string, answer: {
|
|
57
|
+
status: number;
|
|
58
|
+
body: string;
|
|
59
|
+
}): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Reads the port once, with a {@link LOGIN_HOST_DISCOVERY_TIMEOUT_MS} limit.
|
|
62
|
+
* An absent port, a throw, a timeout or any answer that is not a discovery
|
|
63
|
+
* document for `host` is false. Never throws; nothing is logged, so no value
|
|
64
|
+
* leaves.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readLoginHostDiscovery(port: LoginHostDiscoveryPort | undefined, host: string, timeoutMs?: number): Promise<boolean>;
|
|
67
|
+
/** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
|
|
68
|
+
export declare const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = "own_auth_applied_hostname";
|
|
69
|
+
/**
|
|
70
|
+
* True once the org's own sign-in step has applied (`coolify:own-auth:<org.id>`
|
|
71
|
+
* in its recorded ids). An own org has no login-host step: its issuer host is
|
|
72
|
+
* the sign-in service's own, so this marker is what Agora and every ZITADEL
|
|
73
|
+
* step of the org wait for.
|
|
74
|
+
*/
|
|
75
|
+
export declare function ownAuthApplied(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* True once the login-host step has applied `host` for the org, and, when
|
|
78
|
+
* Agora's client is already made, the binding it wrote names that client.
|
|
79
|
+
* The binding admits client ids only, so a host whose binding lacks Agora's
|
|
80
|
+
* client cannot sign Agora in. An org with no Agora client yet has nothing
|
|
81
|
+
* to wait for beyond the host itself.
|
|
82
|
+
*/
|
|
83
|
+
export declare function loginHostServesAgora(host: string, org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
35
84
|
/**
|
|
36
85
|
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
37
86
|
* shared instance host: that host's trusted domain belongs to the shared
|
|
@@ -113,6 +162,6 @@ export declare function pendingRemovalState(hosts: readonly string[]): string;
|
|
|
113
162
|
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
114
163
|
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
115
164
|
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
116
|
-
* `null
|
|
165
|
+
* `null`, and always `null` for an own org. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
117
166
|
*/
|
|
118
|
-
export declare function companyLoginTunnelHost(org: DeclaredOrg
|
|
167
|
+
export declare function companyLoginTunnelHost(org: DeclaredOrg): string | null;
|
package/dist/plan/login-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ownsServices } from '../org.js';
|
|
1
2
|
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
|
|
2
3
|
import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
|
|
3
4
|
/**
|
|
@@ -23,14 +24,9 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
|
|
|
23
24
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
24
25
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
25
26
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* that host itself. That host must never get a trusted
|
|
30
|
-
* domain or a login binding of its own:
|
|
31
|
-
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
32
|
-
* `company-host.ts` denies every request through one, the shared one
|
|
33
|
-
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
27
|
+
* Every company, the platform owner included, signs in on its own sign-in
|
|
28
|
+
* server at this host (`ownHostnames(org).auth` is this function): a domain is
|
|
29
|
+
* never required.
|
|
34
30
|
*/
|
|
35
31
|
export function loginHostnameFor(org) {
|
|
36
32
|
// `issuer.usesDomain` is the issuer's opt-in into the org's domain
|
|
@@ -41,6 +37,95 @@ export function loginHostnameFor(org) {
|
|
|
41
37
|
}
|
|
42
38
|
return `${org.slug}-auth.oikos.host`;
|
|
43
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The login host Agora may take as its issuer, and whether the org's pair is
|
|
42
|
+
* one Agora refuses. Agora accepts an issuer host on its own address's
|
|
43
|
+
* registrable domain, or `<slug>-auth.oikos.host` for `<slug>-agora.oikos.host`.
|
|
44
|
+
* `host` is null for a domain with `issuer.usesDomain` off, where the login host
|
|
45
|
+
* `<slug>-auth.oikos.host` and the Agora address `agora.<domain>` are
|
|
46
|
+
* refused together (`mismatch`).
|
|
47
|
+
*/
|
|
48
|
+
export function agoraIssuerHostFor(org) {
|
|
49
|
+
if (org.domain && !org.issuer.usesDomain)
|
|
50
|
+
return { host: null, mismatch: true };
|
|
51
|
+
return { host: loginHostnameFor(org), mismatch: false };
|
|
52
|
+
}
|
|
53
|
+
/** Milliseconds the discovery read may take before the host counts as not serving. */
|
|
54
|
+
export const LOGIN_HOST_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
55
|
+
/** True when the answer is a 200 whose JSON `issuer` is exactly `https://<host>`. */
|
|
56
|
+
export function discoveryNamesHost(host, answer) {
|
|
57
|
+
if (answer.status !== 200)
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(answer.body);
|
|
61
|
+
return (typeof parsed === 'object' &&
|
|
62
|
+
parsed !== null &&
|
|
63
|
+
parsed.issuer === `https://${host}`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads the port once, with a {@link LOGIN_HOST_DISCOVERY_TIMEOUT_MS} limit.
|
|
71
|
+
* An absent port, a throw, a timeout or any answer that is not a discovery
|
|
72
|
+
* document for `host` is false. Never throws; nothing is logged, so no value
|
|
73
|
+
* leaves.
|
|
74
|
+
*/
|
|
75
|
+
export async function readLoginHostDiscovery(port, host, timeoutMs = LOGIN_HOST_DISCOVERY_TIMEOUT_MS) {
|
|
76
|
+
if (!port)
|
|
77
|
+
return false;
|
|
78
|
+
let timer;
|
|
79
|
+
try {
|
|
80
|
+
const answer = await Promise.race([
|
|
81
|
+
port.discovery(host),
|
|
82
|
+
new Promise((_, reject) => {
|
|
83
|
+
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
return discoveryNamesHost(host, answer);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Any failure is "not serving": the plan keeps the platform issuer and says so in a fixed line.
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
|
|
97
|
+
export const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = 'own_auth_applied_hostname';
|
|
98
|
+
/**
|
|
99
|
+
* True once the org's own sign-in step has applied (`coolify:own-auth:<org.id>`
|
|
100
|
+
* in its recorded ids). An own org has no login-host step: its issuer host is
|
|
101
|
+
* the sign-in service's own, so this marker is what Agora and every ZITADEL
|
|
102
|
+
* step of the org wait for.
|
|
103
|
+
*/
|
|
104
|
+
export function ownAuthApplied(org, recorded) {
|
|
105
|
+
return Boolean(recorded[`coolify:own-auth:${org.id}`]);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* True once the login-host step has applied `host` for the org, and, when
|
|
109
|
+
* Agora's client is already made, the binding it wrote names that client.
|
|
110
|
+
* The binding admits client ids only, so a host whose binding lacks Agora's
|
|
111
|
+
* client cannot sign Agora in. An org with no Agora client yet has nothing
|
|
112
|
+
* to wait for beyond the host itself.
|
|
113
|
+
*/
|
|
114
|
+
export function loginHostServesAgora(host, org, recorded) {
|
|
115
|
+
if (ownsServices(org))
|
|
116
|
+
return ownAuthApplied(org, recorded);
|
|
117
|
+
if (recorded[`zitadel:login-host:${org.id}`] !== host)
|
|
118
|
+
return false;
|
|
119
|
+
const bound = recorded[LOGIN_BINDING_SCOPE_KEY];
|
|
120
|
+
if (!bound?.startsWith(`${host} `))
|
|
121
|
+
return false;
|
|
122
|
+
const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
|
|
123
|
+
return (!clientId ||
|
|
124
|
+
bound
|
|
125
|
+
.slice(host.length + 1)
|
|
126
|
+
.split(',')
|
|
127
|
+
.includes(clientId));
|
|
128
|
+
}
|
|
44
129
|
/**
|
|
45
130
|
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
46
131
|
* shared instance host: that host's trusted domain belongs to the shared
|
|
@@ -109,8 +194,11 @@ export function pendingRemovalState(hosts) {
|
|
|
109
194
|
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
110
195
|
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
111
196
|
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
112
|
-
* `null
|
|
197
|
+
* `null`, and always `null` for an own org. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
113
198
|
*/
|
|
114
|
-
export function companyLoginTunnelHost(org
|
|
115
|
-
|
|
199
|
+
export function companyLoginTunnelHost(org) {
|
|
200
|
+
// An own org's sign-in stack publishes its host through its own tunnel.
|
|
201
|
+
if (ownsServices(org))
|
|
202
|
+
return null;
|
|
203
|
+
return org.domain && org.issuer.usesDomain ? loginHostnameFor(org) : null;
|
|
116
204
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { DeclaredPlatform } from '../platform.js';
|
|
2
|
+
import type { DeployHealthRuntime } from './deploy-health.js';
|
|
3
|
+
import type { DraftStep } from './diff.js';
|
|
4
|
+
import type { CoolifyPort } from './executor.js';
|
|
5
|
+
/**
|
|
6
|
+
* The shared mail relay: one Postfix container that owns host port 25 and
|
|
7
|
+
* hands each message to the mail server that serves its recipient's domain,
|
|
8
|
+
* on the Docker network. One server address has one inbound mail port, so a
|
|
9
|
+
* company's own mail server cannot bind it; this container does, for all.
|
|
10
|
+
*
|
|
11
|
+
* It is a platform step (`coolify:app` keyed {@link PLATFORM_MAIL_RELAY_KEY}),
|
|
12
|
+
* planned only when `platform.mail.relay` is true. It mints no secret.
|
|
13
|
+
* Everything it knows reaches the container as env the step writes.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PLATFORM_MAIL_RELAY_KEY = "platform:mail-relay";
|
|
16
|
+
/** Fingerprint of the sorted routes the last successful apply put live. */
|
|
17
|
+
export declare const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = "mail_relay_routes_applied";
|
|
18
|
+
/**
|
|
19
|
+
* Fingerprint of what forces an image rebuild (the Dockerfile text, which holds
|
|
20
|
+
* the base image pin) and the spool volume the last successful apply put live.
|
|
21
|
+
* A routes change leaves it alone, so it restarts the container and rebuilds nothing.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY = "mail_relay_build_applied";
|
|
24
|
+
/** Postfix's queue. On a volume, so mail not yet handed on survives a restart and a rebuild. */
|
|
25
|
+
export declare const MAIL_RELAY_SPOOL_DIR = "/var/spool/postfix";
|
|
26
|
+
export declare const MAIL_RELAY_SPOOL_VOLUME = "platform-mail-relay-spool";
|
|
27
|
+
/**
|
|
28
|
+
* The org-applied-state key the company mail step records its server's SMTP address under: `<server address>:<port>`,
|
|
29
|
+
* the port the company's Stalwart publishes on the server for the relay ({@link OWN_MAIL_SMTP_PORT_SCOPE_KEY}).
|
|
30
|
+
* The relay is on the platform's network and a company's mail server on the company's own, so the relay
|
|
31
|
+
* reaches it through the server, not by its container name.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY = "mail_smtp_internal";
|
|
34
|
+
/** The host port a company's Stalwart publishes (mapped to its port 25) for the relay. */
|
|
35
|
+
export declare const OWN_MAIL_SMTP_PORT_SCOPE_KEY = "mail_smtp_port";
|
|
36
|
+
/** The host ports companies' mail servers take, one each: the lowest free one in this range. */
|
|
37
|
+
export declare const OWN_MAIL_SMTP_PORT_BASE = 25000;
|
|
38
|
+
export declare const OWN_MAIL_SMTP_PORT_MAX = 25999;
|
|
39
|
+
/** Whether `port` is one of the ports a company's mail server may publish. */
|
|
40
|
+
export declare function isOwnMailSmtpPort(port: unknown): port is number;
|
|
41
|
+
export declare const MAIL_RELAY_APP_NAME = "platform-mail-relay";
|
|
42
|
+
/** One recipient domain and the mail server that receives it: `target` is `<host>:<port>`. */
|
|
43
|
+
export interface MailRoute {
|
|
44
|
+
domain: string;
|
|
45
|
+
target: string;
|
|
46
|
+
}
|
|
47
|
+
/** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
|
|
48
|
+
export declare function normalizeMailDomain(domain: string): string | null;
|
|
49
|
+
/** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
|
|
50
|
+
export declare function normalizeMailTarget(target: string): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* The routes, checked and sorted by domain. `null` when any entry is invalid or
|
|
53
|
+
* one domain has two targets (a message could not know where to go).
|
|
54
|
+
*/
|
|
55
|
+
export declare function normalizeMailRoutes(routes: readonly MailRoute[]): MailRoute[] | null;
|
|
56
|
+
/**
|
|
57
|
+
* What the relay was last built to do, as one value. Order of the inputs does
|
|
58
|
+
* not matter; a changed target or a new route does.
|
|
59
|
+
* Never empty, so a platform with no company route still records one.
|
|
60
|
+
*/
|
|
61
|
+
export declare function mailRelayFingerprint(routes: readonly MailRoute[]): string;
|
|
62
|
+
/**
|
|
63
|
+
* The relay's container start script. It renders Postfix's config from env
|
|
64
|
+
* only and refuses to start on a value that is not a plain domain or host, so
|
|
65
|
+
* nothing from env reaches a config line unchecked. The relay accepts mail for
|
|
66
|
+
* the listed domains and nothing else (`reject_unauth_destination`), so it
|
|
67
|
+
* cannot be used to send to a third party.
|
|
68
|
+
*
|
|
69
|
+
* Mail for a domain no company routes is refused: there is no platform mail
|
|
70
|
+
* server to fall back on, so no default target exists.
|
|
71
|
+
*
|
|
72
|
+
* Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_ROUTES` (`domain=host:port`, comma
|
|
73
|
+
* separated). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
|
|
74
|
+
* `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
|
|
75
|
+
*/
|
|
76
|
+
export declare const MAIL_RELAY_ENTRYPOINT = "#!/bin/sh\nset -eu\nset -f\nconf=\"${MAIL_RELAY_CONF_DIR:-/etc/postfix}\"\nfail() { echo \"mail relay: $1\" >&2; exit 1; }\nok_name() {\n case \"$1\" in\n ''|*[!a-z0-9.-]*|.*|*.|*..*|-*|*.-*|*-.*) return 1 ;;\n esac\n return 0\n}\nok_domain() { ok_name \"$1\" && case \"$1\" in *.*) return 0 ;; esac; return 1; }\nok_target() {\n h=\"${1%:*}\"\n p=\"${1##*:}\"\n [ \"$h\" != \"$1\" ] || return 1\n case \"$p\" in ''|0*|*[!0-9]*) return 1 ;; esac\n [ \"${#p}\" -le 5 ] || return 1\n [ \"$p\" -le 65535 ] || return 1\n ok_name \"$h\"\n}\nok_name \"${MAIL_RELAY_HOSTNAME:-}\" || fail \"MAIL_RELAY_HOSTNAME is not a host name\"\n: > \"$conf/relay_domains\"\n: > \"$conf/transport\"\nIFS=','\nfor entry in ${MAIL_RELAY_ROUTES:-}; do\n d=\"${entry%%=*}\"\n t=\"${entry#*=}\"\n ok_domain \"$d\" || fail \"a route domain is not a plain domain\"\n ok_target \"$t\" || fail \"a route target is not <host>:<port>\"\n printf '%s OK\\n' \"$d\" >> \"$conf/relay_domains\"\n printf '%s smtp:[%s]:%s\\n' \"$d\" \"${t%:*}\" \"${t##*:}\" >> \"$conf/transport\"\ndone\nunset IFS\ncat > \"$conf/main.cf\" <<CONF\nmyhostname = $MAIL_RELAY_HOSTNAME\nmyorigin = \\$myhostname\ninet_interfaces = all\ninet_protocols = ipv4\nmydestination =\nmynetworks = 127.0.0.0/8\nlocal_recipient_maps =\nalias_maps =\nalias_database =\nrelay_domains = texthash:$conf/relay_domains\nparent_domain_matches_subdomains =\ntransport_maps = texthash:$conf/transport\nrelay_recipient_maps =\nsmtpd_relay_restrictions = reject_unauth_destination\nsmtpd_recipient_restrictions = reject_unauth_destination\ndisable_vrfy_command = yes\nmaillog_file = /dev/stdout\nCONF\n[ \"${MAIL_RELAY_RENDER_ONLY:-}\" = \"1\" ] && exit 0\nexec postfix start-fg\n";
|
|
77
|
+
/** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
|
|
78
|
+
export declare function mailRelayDockerfile(): string;
|
|
79
|
+
/** What a rebuild depends on: the Dockerfile and the spool mount. Routes, domains and hostname are not in it. */
|
|
80
|
+
export declare function mailRelayBuildFingerprint(): string;
|
|
81
|
+
/** The env the step writes; the only input the container reads. */
|
|
82
|
+
export declare function mailRelayEnv(input: {
|
|
83
|
+
hostname: string;
|
|
84
|
+
routes: readonly MailRoute[];
|
|
85
|
+
}): Array<{
|
|
86
|
+
key: string;
|
|
87
|
+
value: string;
|
|
88
|
+
literal: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
export declare const MAIL_RELAY_BLOCKED: {
|
|
91
|
+
readonly executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies";
|
|
92
|
+
readonly invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route is not valid (a domain must be a plain DNS name, a target must be <host>:25, one domain needs one target)";
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Plans the relay: nothing unless `mail.relay` is true. Planned when it was
|
|
96
|
+
* never applied, when the fingerprint of its routes differs from the recorded
|
|
97
|
+
* one, or when the base image pin changed. Blocked (reported, not planned) while
|
|
98
|
+
* the platform mail server is not applied, the executor cannot run it, or an
|
|
99
|
+
* input is invalid.
|
|
100
|
+
*/
|
|
101
|
+
export declare function planMailRelay(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
|
|
102
|
+
routes: readonly MailRoute[];
|
|
103
|
+
executorBlocked: string | undefined;
|
|
104
|
+
}): {
|
|
105
|
+
step: DraftStep | null;
|
|
106
|
+
blocked: string | null;
|
|
107
|
+
};
|
|
108
|
+
export interface MailRelayDeps {
|
|
109
|
+
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'listStorages' | 'createPersistentStorage' | 'restart' | 'getDeployment' | 'deployAndWait' | 'raw'>;
|
|
110
|
+
runtime?: DeployHealthRuntime;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Makes the relay true: finds or creates the app, sets its internal name and
|
|
114
|
+
* host port 25, mounts the queue volume once, writes env, and then does the
|
|
115
|
+
* least that makes it true (decided from the live app):
|
|
116
|
+
* - a rebuild (stop, deploy) when the app was just created, the Dockerfile or
|
|
117
|
+
* volume changed, or the volume was just added. The old container is stopped
|
|
118
|
+
* first, because two containers cannot both bind port 25;
|
|
119
|
+
* - a deploy when the container is not running;
|
|
120
|
+
* - a restart of the same container when only the routes, domains or hostname
|
|
121
|
+
* changed. Postfix reads its routes at start, nothing else is needed.
|
|
122
|
+
* The two fingerprints are recorded last, so a run that fails halfway (env
|
|
123
|
+
* written, restart failed) leaves the step planned and the next apply
|
|
124
|
+
* finishes it. No secret exists here.
|
|
125
|
+
*/
|
|
126
|
+
export declare function provisionMailRelay(action: {
|
|
127
|
+
desired: Record<string, unknown>;
|
|
128
|
+
}, context: {
|
|
129
|
+
recordedIds(): Readonly<Record<string, string>>;
|
|
130
|
+
recordScopeId(key: string, id: string): void;
|
|
131
|
+
persist?(): Promise<void>;
|
|
132
|
+
}, deps: MailRelayDeps, input: {
|
|
133
|
+
projectUuid: string;
|
|
134
|
+
serverUuid: string;
|
|
135
|
+
hostname: string;
|
|
136
|
+
appScopeKey: string;
|
|
137
|
+
}): Promise<{
|
|
138
|
+
resourceId: string;
|
|
139
|
+
}>;
|