@wtfalch/foundry 0.34.0 → 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 +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- 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.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -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 +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- 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 +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- 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 +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +770 -231
- 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/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -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 +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
|
@@ -13,35 +13,52 @@ import type { CoolifyPort } from './executor.js';
|
|
|
13
13
|
* Everything it knows reaches the container as env the step writes.
|
|
14
14
|
*/
|
|
15
15
|
export declare const PLATFORM_MAIL_RELAY_KEY = "platform:mail-relay";
|
|
16
|
-
/** Fingerprint of the sorted routes
|
|
16
|
+
/** Fingerprint of the sorted routes the last successful apply put live. */
|
|
17
17
|
export declare const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = "mail_relay_routes_applied";
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
*/
|
|
21
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;
|
|
22
41
|
export declare const MAIL_RELAY_APP_NAME = "platform-mail-relay";
|
|
23
|
-
/** One recipient domain and the mail server that receives it: `target` is `<
|
|
42
|
+
/** One recipient domain and the mail server that receives it: `target` is `<host>:<port>`. */
|
|
24
43
|
export interface MailRoute {
|
|
25
44
|
domain: string;
|
|
26
45
|
target: string;
|
|
27
46
|
}
|
|
28
47
|
/** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
|
|
29
48
|
export declare function normalizeMailDomain(domain: string): string | null;
|
|
30
|
-
/** Lower-cases and checks `<host
|
|
49
|
+
/** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
|
|
31
50
|
export declare function normalizeMailTarget(target: string): string | null;
|
|
32
51
|
/**
|
|
33
52
|
* The routes, checked and sorted by domain. `null` when any entry is invalid or
|
|
34
53
|
* one domain has two targets (a message could not know where to go).
|
|
35
54
|
*/
|
|
36
55
|
export declare function normalizeMailRoutes(routes: readonly MailRoute[]): MailRoute[] | null;
|
|
37
|
-
/** The platform's own mail domains, checked, de-duplicated, sorted; `null` when any is invalid. */
|
|
38
|
-
export declare function normalizeMailDomains(domains: readonly string[]): string[] | null;
|
|
39
56
|
/**
|
|
40
57
|
* What the relay was last built to do, as one value. Order of the inputs does
|
|
41
|
-
* not matter; a changed target
|
|
58
|
+
* not matter; a changed target or a new route does.
|
|
42
59
|
* Never empty, so a platform with no company route still records one.
|
|
43
60
|
*/
|
|
44
|
-
export declare function mailRelayFingerprint(routes: readonly MailRoute[]
|
|
61
|
+
export declare function mailRelayFingerprint(routes: readonly MailRoute[]): string;
|
|
45
62
|
/**
|
|
46
63
|
* The relay's container start script. It renders Postfix's config from env
|
|
47
64
|
* only and refuses to start on a value that is not a plain domain or host, so
|
|
@@ -49,31 +66,30 @@ export declare function mailRelayFingerprint(routes: readonly MailRoute[], platf
|
|
|
49
66
|
* the listed domains and nothing else (`reject_unauth_destination`), so it
|
|
50
67
|
* cannot be used to send to a third party.
|
|
51
68
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
56
74
|
* `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
|
|
57
75
|
*/
|
|
58
|
-
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
|
|
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";
|
|
59
77
|
/** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
|
|
60
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;
|
|
61
81
|
/** The env the step writes; the only input the container reads. */
|
|
62
82
|
export declare function mailRelayEnv(input: {
|
|
63
83
|
hostname: string;
|
|
64
|
-
defaultTarget: string;
|
|
65
84
|
routes: readonly MailRoute[];
|
|
66
|
-
platformDomains: readonly string[];
|
|
67
85
|
}): Array<{
|
|
68
86
|
key: string;
|
|
69
87
|
value: string;
|
|
70
88
|
literal: boolean;
|
|
71
89
|
}>;
|
|
72
90
|
export declare const MAIL_RELAY_BLOCKED: {
|
|
73
|
-
readonly mailNotEnabled: "the platform's mail relay is blocked, not planned: the platform's mail server is not enabled";
|
|
74
|
-
readonly mailNotReady: "the platform's mail relay is blocked, not planned: the platform's mail server is neither applied nor planned";
|
|
75
91
|
readonly executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies";
|
|
76
|
-
readonly invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route
|
|
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)";
|
|
77
93
|
};
|
|
78
94
|
/**
|
|
79
95
|
* Plans the relay: nothing unless `mail.relay` is true. Planned when it was
|
|
@@ -84,24 +100,28 @@ export declare const MAIL_RELAY_BLOCKED: {
|
|
|
84
100
|
*/
|
|
85
101
|
export declare function planMailRelay(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
|
|
86
102
|
routes: readonly MailRoute[];
|
|
87
|
-
platformDomains: readonly string[];
|
|
88
103
|
executorBlocked: string | undefined;
|
|
89
|
-
mailReady: boolean;
|
|
90
104
|
}): {
|
|
91
105
|
step: DraftStep | null;
|
|
92
106
|
blocked: string | null;
|
|
93
107
|
};
|
|
94
108
|
export interface MailRelayDeps {
|
|
95
|
-
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw'>;
|
|
109
|
+
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'listStorages' | 'createPersistentStorage' | 'restart' | 'getDeployment' | 'deployAndWait' | 'raw'>;
|
|
96
110
|
runtime?: DeployHealthRuntime;
|
|
97
111
|
}
|
|
98
112
|
/**
|
|
99
113
|
* Makes the relay true: finds or creates the app, sets its internal name and
|
|
100
|
-
* host port 25,
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
105
125
|
*/
|
|
106
126
|
export declare function provisionMailRelay(action: {
|
|
107
127
|
desired: Record<string, unknown>;
|
|
@@ -113,7 +133,6 @@ export declare function provisionMailRelay(action: {
|
|
|
113
133
|
projectUuid: string;
|
|
114
134
|
serverUuid: string;
|
|
115
135
|
hostname: string;
|
|
116
|
-
defaultTarget: string;
|
|
117
136
|
appScopeKey: string;
|
|
118
137
|
}): Promise<{
|
|
119
138
|
resourceId: string;
|
package/dist/plan/mail-relay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { pinnedRef } from '../bootstrap/versions.js';
|
|
3
3
|
import { waitForDeploymentFinished } from './deploy-health.js';
|
|
4
4
|
import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js';
|
|
5
5
|
/**
|
|
@@ -13,12 +13,36 @@ import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js
|
|
|
13
13
|
* Everything it knows reaches the container as env the step writes.
|
|
14
14
|
*/
|
|
15
15
|
export const PLATFORM_MAIL_RELAY_KEY = 'platform:mail-relay';
|
|
16
|
-
/** Fingerprint of the sorted routes
|
|
16
|
+
/** Fingerprint of the sorted routes the last successful apply put live. */
|
|
17
17
|
export const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = 'mail_relay_routes_applied';
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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 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 const MAIL_RELAY_SPOOL_DIR = '/var/spool/postfix';
|
|
26
|
+
export 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
|
+
*/
|
|
21
33
|
export 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 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 const OWN_MAIL_SMTP_PORT_BASE = 25000;
|
|
38
|
+
export const OWN_MAIL_SMTP_PORT_MAX = 25999;
|
|
39
|
+
/** Whether `port` is one of the ports a company's mail server may publish. */
|
|
40
|
+
export function isOwnMailSmtpPort(port) {
|
|
41
|
+
return (typeof port === 'number' &&
|
|
42
|
+
Number.isInteger(port) &&
|
|
43
|
+
port >= OWN_MAIL_SMTP_PORT_BASE &&
|
|
44
|
+
port <= OWN_MAIL_SMTP_PORT_MAX);
|
|
45
|
+
}
|
|
22
46
|
/** `executor.ts`'s `isRunningOrStarting`, restated: importing it would make this module and the planner import each other. */
|
|
23
47
|
function isRunningOrStarting(status) {
|
|
24
48
|
if (!status)
|
|
@@ -34,12 +58,16 @@ export function normalizeMailDomain(domain) {
|
|
|
34
58
|
const lower = domain.toLowerCase();
|
|
35
59
|
return lower.length <= 253 && DOMAIN_PATTERN.test(lower) ? lower : null;
|
|
36
60
|
}
|
|
37
|
-
/** Lower-cases and checks `<host
|
|
61
|
+
/** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
|
|
38
62
|
export function normalizeMailTarget(target) {
|
|
39
63
|
const lower = target.toLowerCase();
|
|
40
|
-
|
|
64
|
+
const colon = lower.lastIndexOf(':');
|
|
65
|
+
if (colon < 1)
|
|
66
|
+
return null;
|
|
67
|
+
const host = lower.slice(0, colon);
|
|
68
|
+
const port = lower.slice(colon + 1);
|
|
69
|
+
if (!/^[1-9][0-9]{0,4}$/.test(port) || Number(port) > 65535)
|
|
41
70
|
return null;
|
|
42
|
-
const host = lower.slice(0, -3);
|
|
43
71
|
return host.length <= 253 && HOST_PATTERN.test(host) ? lower : null;
|
|
44
72
|
}
|
|
45
73
|
/**
|
|
@@ -62,27 +90,15 @@ export function normalizeMailRoutes(routes) {
|
|
|
62
90
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
63
91
|
.map(([domain, target]) => ({ domain, target }));
|
|
64
92
|
}
|
|
65
|
-
/** The platform's own mail domains, checked, de-duplicated, sorted; `null` when any is invalid. */
|
|
66
|
-
export function normalizeMailDomains(domains) {
|
|
67
|
-
const out = new Set();
|
|
68
|
-
for (const domain of domains) {
|
|
69
|
-
const checked = normalizeMailDomain(domain);
|
|
70
|
-
if (!checked)
|
|
71
|
-
return null;
|
|
72
|
-
out.add(checked);
|
|
73
|
-
}
|
|
74
|
-
return [...out].sort();
|
|
75
|
-
}
|
|
76
93
|
/**
|
|
77
94
|
* What the relay was last built to do, as one value. Order of the inputs does
|
|
78
|
-
* not matter; a changed target
|
|
95
|
+
* not matter; a changed target or a new route does.
|
|
79
96
|
* Never empty, so a platform with no company route still records one.
|
|
80
97
|
*/
|
|
81
|
-
export function mailRelayFingerprint(routes
|
|
98
|
+
export function mailRelayFingerprint(routes) {
|
|
82
99
|
const sortedRoutes = normalizeMailRoutes(routes) ?? [];
|
|
83
|
-
const sortedDomains = normalizeMailDomains(platformDomains) ?? [];
|
|
84
100
|
return createHash('sha256')
|
|
85
|
-
.update(JSON.stringify(
|
|
101
|
+
.update(JSON.stringify(sortedRoutes.map((r) => [r.domain, r.target])))
|
|
86
102
|
.digest('hex');
|
|
87
103
|
}
|
|
88
104
|
/**
|
|
@@ -92,10 +108,11 @@ export function mailRelayFingerprint(routes, platformDomains) {
|
|
|
92
108
|
* the listed domains and nothing else (`reject_unauth_destination`), so it
|
|
93
109
|
* cannot be used to send to a third party.
|
|
94
110
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
111
|
+
* Mail for a domain no company routes is refused: there is no platform mail
|
|
112
|
+
* server to fall back on, so no default target exists.
|
|
113
|
+
*
|
|
114
|
+
* Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_ROUTES` (`domain=host:port`, comma
|
|
115
|
+
* separated). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
|
|
99
116
|
* `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
|
|
100
117
|
*/
|
|
101
118
|
export const MAIL_RELAY_ENTRYPOINT = `#!/bin/sh
|
|
@@ -111,10 +128,14 @@ ok_name() {
|
|
|
111
128
|
}
|
|
112
129
|
ok_domain() { ok_name "$1" && case "$1" in *.*) return 0 ;; esac; return 1; }
|
|
113
130
|
ok_target() {
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
h="\${1%:*}"
|
|
132
|
+
p="\${1##*:}"
|
|
133
|
+
[ "$h" != "$1" ] || return 1
|
|
134
|
+
case "$p" in ''|0*|*[!0-9]*) return 1 ;; esac
|
|
135
|
+
[ "\${#p}" -le 5 ] || return 1
|
|
136
|
+
[ "$p" -le 65535 ] || return 1
|
|
137
|
+
ok_name "$h"
|
|
116
138
|
}
|
|
117
|
-
ok_target "\${MAIL_RELAY_DEFAULT_TARGET:-}" || fail "MAIL_RELAY_DEFAULT_TARGET is not <host>:25"
|
|
118
139
|
ok_name "\${MAIL_RELAY_HOSTNAME:-}" || fail "MAIL_RELAY_HOSTNAME is not a host name"
|
|
119
140
|
: > "$conf/relay_domains"
|
|
120
141
|
: > "$conf/transport"
|
|
@@ -123,14 +144,9 @@ for entry in \${MAIL_RELAY_ROUTES:-}; do
|
|
|
123
144
|
d="\${entry%%=*}"
|
|
124
145
|
t="\${entry#*=}"
|
|
125
146
|
ok_domain "$d" || fail "a route domain is not a plain domain"
|
|
126
|
-
ok_target "$t" || fail "a route target is not <host
|
|
127
|
-
printf '%s OK\\n' "$d" >> "$conf/relay_domains"
|
|
128
|
-
printf '%s smtp:[%s]:25\\n' "$d" "\${t%:25}" >> "$conf/transport"
|
|
129
|
-
done
|
|
130
|
-
for d in \${MAIL_RELAY_PLATFORM_DOMAINS:-}; do
|
|
131
|
-
ok_domain "$d" || fail "a platform domain is not a plain domain"
|
|
147
|
+
ok_target "$t" || fail "a route target is not <host>:<port>"
|
|
132
148
|
printf '%s OK\\n' "$d" >> "$conf/relay_domains"
|
|
133
|
-
printf '%s smtp:[%s]
|
|
149
|
+
printf '%s smtp:[%s]:%s\\n' "$d" "\${t%:*}" "\${t##*:}" >> "$conf/transport"
|
|
134
150
|
done
|
|
135
151
|
unset IFS
|
|
136
152
|
cat > "$conf/main.cf" <<CONF
|
|
@@ -144,6 +160,7 @@ local_recipient_maps =
|
|
|
144
160
|
alias_maps =
|
|
145
161
|
alias_database =
|
|
146
162
|
relay_domains = texthash:$conf/relay_domains
|
|
163
|
+
parent_domain_matches_subdomains =
|
|
147
164
|
transport_maps = texthash:$conf/transport
|
|
148
165
|
relay_recipient_maps =
|
|
149
166
|
smtpd_relay_restrictions = reject_unauth_destination
|
|
@@ -158,30 +175,32 @@ exec postfix start-fg
|
|
|
158
175
|
export function mailRelayDockerfile() {
|
|
159
176
|
const script = Buffer.from(MAIL_RELAY_ENTRYPOINT, 'utf8').toString('base64');
|
|
160
177
|
return [
|
|
161
|
-
`FROM alpine:${
|
|
178
|
+
`FROM alpine:${pinnedRef('alpine')}`,
|
|
162
179
|
`RUN apk add --no-cache postfix && echo ${script} | base64 -d > /entrypoint.sh && chmod +x /entrypoint.sh`,
|
|
163
180
|
'EXPOSE 25',
|
|
164
181
|
'ENTRYPOINT ["/entrypoint.sh"]',
|
|
165
182
|
'',
|
|
166
183
|
].join('\n');
|
|
167
184
|
}
|
|
185
|
+
/** What a rebuild depends on: the Dockerfile and the spool mount. Routes, domains and hostname are not in it. */
|
|
186
|
+
export function mailRelayBuildFingerprint() {
|
|
187
|
+
return createHash('sha256')
|
|
188
|
+
.update(JSON.stringify([mailRelayDockerfile(), MAIL_RELAY_SPOOL_DIR]))
|
|
189
|
+
.digest('hex');
|
|
190
|
+
}
|
|
168
191
|
/** The env the step writes; the only input the container reads. */
|
|
169
192
|
export function mailRelayEnv(input) {
|
|
170
193
|
return [
|
|
171
194
|
{ key: 'MAIL_RELAY_HOSTNAME', value: input.hostname },
|
|
172
|
-
{ key: 'MAIL_RELAY_DEFAULT_TARGET', value: input.defaultTarget },
|
|
173
195
|
{
|
|
174
196
|
key: 'MAIL_RELAY_ROUTES',
|
|
175
197
|
value: input.routes.map((r) => `${r.domain}=${r.target}`).join(','),
|
|
176
198
|
},
|
|
177
|
-
{ key: 'MAIL_RELAY_PLATFORM_DOMAINS', value: input.platformDomains.join(',') },
|
|
178
199
|
].map((e) => ({ ...e, literal: true }));
|
|
179
200
|
}
|
|
180
201
|
export const MAIL_RELAY_BLOCKED = {
|
|
181
|
-
mailNotEnabled: "the platform's mail relay is blocked, not planned: the platform's mail server is not enabled",
|
|
182
|
-
mailNotReady: "the platform's mail relay is blocked, not planned: the platform's mail server is neither applied nor planned",
|
|
183
202
|
executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies",
|
|
184
|
-
invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route
|
|
203
|
+
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)",
|
|
185
204
|
};
|
|
186
205
|
/**
|
|
187
206
|
* Plans the relay: nothing unless `mail.relay` is true. Planned when it was
|
|
@@ -193,21 +212,16 @@ export const MAIL_RELAY_BLOCKED = {
|
|
|
193
212
|
export function planMailRelay(platform, recordedIds, input) {
|
|
194
213
|
if (platform.mail.relay !== true)
|
|
195
214
|
return { step: null, blocked: null };
|
|
196
|
-
if (!platform.mail.enabled)
|
|
197
|
-
return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotEnabled };
|
|
198
215
|
if (input.executorBlocked)
|
|
199
216
|
return { step: null, blocked: input.executorBlocked };
|
|
200
|
-
if (!input.mailReady)
|
|
201
|
-
return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotReady };
|
|
202
217
|
const routes = normalizeMailRoutes(input.routes);
|
|
203
|
-
|
|
204
|
-
if (!routes || !platformDomains)
|
|
218
|
+
if (!routes)
|
|
205
219
|
return { step: null, blocked: MAIL_RELAY_BLOCKED.invalidRoutes };
|
|
206
|
-
const fingerprint = mailRelayFingerprint(routes
|
|
220
|
+
const fingerprint = mailRelayFingerprint(routes);
|
|
207
221
|
const applied = Boolean(recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`]);
|
|
208
222
|
if (applied &&
|
|
209
223
|
recordedIds[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === fingerprint &&
|
|
210
|
-
recordedIds[
|
|
224
|
+
recordedIds[PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY] === mailRelayBuildFingerprint()) {
|
|
211
225
|
return { step: null, blocked: null };
|
|
212
226
|
}
|
|
213
227
|
return {
|
|
@@ -216,7 +230,7 @@ export function planMailRelay(platform, recordedIds, input) {
|
|
|
216
230
|
system: 'coolify',
|
|
217
231
|
kind: 'app',
|
|
218
232
|
key: PLATFORM_MAIL_RELAY_KEY,
|
|
219
|
-
desired: { routes,
|
|
233
|
+
desired: { routes, fingerprint },
|
|
220
234
|
},
|
|
221
235
|
step: {
|
|
222
236
|
description: applied
|
|
@@ -231,18 +245,22 @@ export function planMailRelay(platform, recordedIds, input) {
|
|
|
231
245
|
}
|
|
232
246
|
/**
|
|
233
247
|
* Makes the relay true: finds or creates the app, sets its internal name and
|
|
234
|
-
* host port 25,
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
248
|
+
* host port 25, mounts the queue volume once, writes env, and then does the
|
|
249
|
+
* least that makes it true (decided from the live app):
|
|
250
|
+
* - a rebuild (stop, deploy) when the app was just created, the Dockerfile or
|
|
251
|
+
* volume changed, or the volume was just added. The old container is stopped
|
|
252
|
+
* first, because two containers cannot both bind port 25;
|
|
253
|
+
* - a deploy when the container is not running;
|
|
254
|
+
* - a restart of the same container when only the routes, domains or hostname
|
|
255
|
+
* changed. Postfix reads its routes at start, nothing else is needed.
|
|
256
|
+
* The two fingerprints are recorded last, so a run that fails halfway (env
|
|
257
|
+
* written, restart failed) leaves the step planned and the next apply
|
|
258
|
+
* finishes it. No secret exists here.
|
|
239
259
|
*/
|
|
240
260
|
export async function provisionMailRelay(action, context, deps, input) {
|
|
241
261
|
const routes = normalizeMailRoutes((action.desired.routes ?? []));
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (!routes || !platformDomains || !defaultTarget) {
|
|
245
|
-
throw new Error('platform:mail-relay: a mail route, platform domain or default target is invalid');
|
|
262
|
+
if (!routes) {
|
|
263
|
+
throw new Error('platform:mail-relay: a mail route is invalid');
|
|
246
264
|
}
|
|
247
265
|
const runtime = deps.runtime ?? {};
|
|
248
266
|
const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -275,7 +293,18 @@ export async function provisionMailRelay(action, context, deps, input) {
|
|
|
275
293
|
})).uuid;
|
|
276
294
|
}
|
|
277
295
|
await record(input.appScopeKey, appId);
|
|
278
|
-
const
|
|
296
|
+
const buildFingerprint = mailRelayBuildFingerprint();
|
|
297
|
+
const routesFingerprint = mailRelayFingerprint(routes);
|
|
298
|
+
const spoolMounted = (await deps.coolify.listStorages(appId)).persistent_storages?.some((s) => s.mount_path === MAIL_RELAY_SPOOL_DIR);
|
|
299
|
+
if (!spoolMounted) {
|
|
300
|
+
await deps.coolify.createPersistentStorage(appId, {
|
|
301
|
+
name: MAIL_RELAY_SPOOL_VOLUME,
|
|
302
|
+
mount_path: MAIL_RELAY_SPOOL_DIR,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// A volume added to an app that already exists binds only at a new container, so it is a rebuild too.
|
|
306
|
+
const baseChanged = !justCreated &&
|
|
307
|
+
(recorded[PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY] !== buildFingerprint || !spoolMounted);
|
|
279
308
|
await deps.coolify.updateApplication(appId, {
|
|
280
309
|
custom_internal_name: MAIL_RELAY_APP_NAME,
|
|
281
310
|
ports_exposes: '25',
|
|
@@ -288,22 +317,32 @@ export async function provisionMailRelay(action, context, deps, input) {
|
|
|
288
317
|
});
|
|
289
318
|
const envs = mailRelayEnv({
|
|
290
319
|
hostname: input.hostname,
|
|
291
|
-
defaultTarget,
|
|
292
320
|
routes,
|
|
293
|
-
platformDomains,
|
|
294
321
|
});
|
|
295
322
|
const live = new Map((await deps.coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
296
323
|
const envsChanged = envs.some((e) => live.get(e.key) !== e.value);
|
|
297
324
|
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
298
|
-
|
|
299
|
-
|
|
325
|
+
// The recorded routes fingerprint, not only the env diff: env written by a run whose restart failed
|
|
326
|
+
// reads as unchanged on the next apply, and the routes would never go live.
|
|
327
|
+
const routesChanged = envsChanged || recorded[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] !== routesFingerprint;
|
|
328
|
+
const rebuild = async (stopFirst) => {
|
|
329
|
+
if (stopFirst) {
|
|
300
330
|
await deps.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
301
331
|
await waitForStopped(deps.coolify, appId, runtime, sleep);
|
|
302
332
|
}
|
|
303
333
|
await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail-relay', 'the relay keeps its previous routes');
|
|
334
|
+
};
|
|
335
|
+
if (justCreated || !running || baseChanged) {
|
|
336
|
+
await rebuild(!justCreated && running);
|
|
304
337
|
}
|
|
305
|
-
|
|
306
|
-
|
|
338
|
+
else if (routesChanged) {
|
|
339
|
+
// A restart that names no deployment cannot be told from the old container still being healthy.
|
|
340
|
+
const followed = await restartAndWaitHealthy(deps.coolify, appId, runtime, sleep);
|
|
341
|
+
if (!followed)
|
|
342
|
+
await rebuild(true);
|
|
343
|
+
}
|
|
344
|
+
await record(PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY, buildFingerprint);
|
|
345
|
+
await record(PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, routesFingerprint);
|
|
307
346
|
return { resourceId: appId };
|
|
308
347
|
}
|
|
309
348
|
async function waitForStopped(coolify, appId, runtime, sleep) {
|
|
@@ -321,3 +360,41 @@ async function waitForStopped(coolify, appId, runtime, sleep) {
|
|
|
321
360
|
await sleep(interval);
|
|
322
361
|
}
|
|
323
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Restarts the same container (no stop, no image build) and returns once it is
|
|
365
|
+
* healthy again. Coolify queues a restart and answers before the container
|
|
366
|
+
* moves, so the old container's healthy status alone proves nothing: the
|
|
367
|
+
* answer must name a deployment and that record must finish first. Returns
|
|
368
|
+
* false, having waited for nothing, when the answer names none.
|
|
369
|
+
*/
|
|
370
|
+
async function restartAndWaitHealthy(coolify, appId, runtime, sleep) {
|
|
371
|
+
const now = runtime.now ?? Date.now;
|
|
372
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
373
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
374
|
+
const deadline = now() + timeout;
|
|
375
|
+
const late = (what) => new Error(`platform:mail-relay: the relay app ${appId} did not ${what} within ${Math.round(timeout / 1000)}s after its restart; the relay keeps its previous routes. Apply again`);
|
|
376
|
+
const answer = (await coolify.restart(appId));
|
|
377
|
+
const deploymentUuid = typeof answer?.deployment_uuid === 'string' ? answer.deployment_uuid : undefined;
|
|
378
|
+
// No deployment to follow: health would be the old container's, so the caller rebuilds instead.
|
|
379
|
+
if (!deploymentUuid)
|
|
380
|
+
return false;
|
|
381
|
+
for (;;) {
|
|
382
|
+
const { status } = await coolify.getDeployment(deploymentUuid);
|
|
383
|
+
if (status === 'finished')
|
|
384
|
+
break;
|
|
385
|
+
if (status === 'failed' || status.startsWith('cancelled')) {
|
|
386
|
+
throw new Error(`platform:mail-relay: the restart (${deploymentUuid}) of ${appId} did not finish (status: ${status}); the relay keeps its previous routes. Apply again`);
|
|
387
|
+
}
|
|
388
|
+
if (now() >= deadline)
|
|
389
|
+
throw late('finish restarting');
|
|
390
|
+
await sleep(interval);
|
|
391
|
+
}
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { status } = await coolify.getApplication(appId);
|
|
394
|
+
if (status?.includes('healthy') && !status.includes('unhealthy'))
|
|
395
|
+
return true;
|
|
396
|
+
if (now() >= deadline)
|
|
397
|
+
throw late('become healthy');
|
|
398
|
+
await sleep(interval);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* Recorded when a company's Coolify resources are known to sit on its own network (`co-<slug>`): by the
|
|
5
|
+
* first run for a company that has none yet, and by the move step when it ends. The value is the network name.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NETWORK_APPLIED_SCOPE_KEY = "network_applied";
|
|
8
|
+
/** A company with a Coolify project but no network marker was built on the shared network. */
|
|
9
|
+
export declare function networkMoveNeeded(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
10
|
+
/** Whether the company holds a resource foundry adopted: it did not create it and never deletes it. */
|
|
11
|
+
export declare function hasAdoptedResources(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The one step a company built on the shared network plans, instead of all its others. Coolify cannot
|
|
14
|
+
* move a resource between networks in production, so the move deletes the company's apps and databases and
|
|
15
|
+
* the next apply builds them again on `co-<slug>`. It destroys and migrates, so apply asks for the typed
|
|
16
|
+
* company name, and it needs a fresh dump of every database first (`requireFreshBackupsForMove`). A company
|
|
17
|
+
* that adopted resources plans no move: foundry does not delete what it did not create, so those stay on
|
|
18
|
+
* the shared network (`diff.ts` reports it).
|
|
19
|
+
*/
|
|
20
|
+
export declare function planNetworkMove(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): DraftStep | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorded when a company's Coolify resources are known to sit on its own network (`co-<slug>`): by the
|
|
3
|
+
* first run for a company that has none yet, and by the move step when it ends. The value is the network name.
|
|
4
|
+
*/
|
|
5
|
+
export const NETWORK_APPLIED_SCOPE_KEY = 'network_applied';
|
|
6
|
+
/** The company's Coolify project: where `ensureCoolifyProject` records it. */
|
|
7
|
+
const COOLIFY_PROJECT_SCOPE_KEY = 'coolify_project';
|
|
8
|
+
/** A company with a Coolify project but no network marker was built on the shared network. */
|
|
9
|
+
export function networkMoveNeeded(recordedIds) {
|
|
10
|
+
return Boolean(recordedIds[COOLIFY_PROJECT_SCOPE_KEY]) && !recordedIds[NETWORK_APPLIED_SCOPE_KEY];
|
|
11
|
+
}
|
|
12
|
+
/** Whether the company holds a resource foundry adopted: it did not create it and never deletes it. */
|
|
13
|
+
export function hasAdoptedResources(recordedIds) {
|
|
14
|
+
return Object.keys(recordedIds).some((key) => key.startsWith('adopted.'));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The one step a company built on the shared network plans, instead of all its others. Coolify cannot
|
|
18
|
+
* move a resource between networks in production, so the move deletes the company's apps and databases and
|
|
19
|
+
* the next apply builds them again on `co-<slug>`. It destroys and migrates, so apply asks for the typed
|
|
20
|
+
* company name, and it needs a fresh dump of every database first (`requireFreshBackupsForMove`). A company
|
|
21
|
+
* that adopted resources plans no move: foundry does not delete what it did not create, so those stay on
|
|
22
|
+
* the shared network (`diff.ts` reports it).
|
|
23
|
+
*/
|
|
24
|
+
export function planNetworkMove(org, recordedIds) {
|
|
25
|
+
if (!networkMoveNeeded(recordedIds) || hasAdoptedResources(recordedIds))
|
|
26
|
+
return null;
|
|
27
|
+
const network = `co-${org.slug}`;
|
|
28
|
+
return {
|
|
29
|
+
action: {
|
|
30
|
+
system: 'coolify',
|
|
31
|
+
kind: 'network',
|
|
32
|
+
key: org.id,
|
|
33
|
+
desired: { network },
|
|
34
|
+
},
|
|
35
|
+
step: {
|
|
36
|
+
description: `move ${org.slug} onto its own Docker network ${network}: delete its apps and databases in Coolify after a fresh backup, then apply again to build them there (data is restored by hand)`,
|
|
37
|
+
destroys: true,
|
|
38
|
+
migrates: true,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|