@blamejs/core 0.18.55 → 0.18.56
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/CHANGELOG.md +36 -0
- package/NOTICE +4 -4
- package/README.md +6 -6
- package/lib/audit.js +26 -24
- package/lib/chain-writer.js +17 -0
- package/lib/mail-server-managesieve.js +15 -5
- package/lib/mail-server-mx.js +201 -9
- package/lib/mail-server-tls.js +23 -8
- package/lib/middleware/csrf-protect.js +37 -20
- package/lib/session-stores.js +6 -3
- package/lib/vendor/MANIFEST.json +30 -30
- package/lib/vendor/browser/noble-ciphers.mjs +15 -1
- package/lib/vendor/browser/noble-hashes.mjs +12 -4
- package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
- package/lib/vendor/noble-ciphers.cjs +15 -1
- package/lib/vendor/noble-curves.cjs +46 -21
- package/lib/vendor/noble-post-quantum.cjs +184 -75
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,42 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.56 (2026-08-27) — **A CSRF exemption turned on a header the attacker writes, and a certificate renewal reached four mail listeners but not the fifth.** `csrfProtect`'s `skipStateless` waived the token check for any request carrying an `Authorization` header. Presence is not authenticity: an attacker composing a cross-site request writes their own headers. Worse, the header says nothing about which credential authenticated the request, and `attachUser` with `tokenFrom: "both"` reads the cookie first — so a request carrying both was authenticated by exactly the ambient credential the gate protects, and skipped the gate on a header nobody read. The exemption now turns on the absence of a cookie, and it no longer waives the origin check.
|
|
12
|
+
|
|
13
|
+
The mail listeners had two cases of the same shape: a value read once at construction that the operator changes while the server runs. ManageSieve captured `opts.tlsContext`, so a certificate renewal reached its four siblings and not it — and nothing reported that, because the watcher fired and the context rebuilt exactly as expected. `mail.server.mx` captured `localDomains`, so a withdrawn domain kept drawing 250 at RCPT until a restart while every management surface agreed it was gone.
|
|
14
|
+
|
|
15
|
+
Also: an audit checkpoint read the chain tip outside the lock that makes appends atomic, so it could sign a counter and a hash that were never the tip together. **Changed:** *Vendored `@noble` cryptography refreshed* — `@noble/ciphers` and `@noble/curves` to 2.4.0, `@noble/post-quantum` to 0.7.1, `@noble/hashes` to 2.4.0. The four move together because 0.7.1 pins its siblings to exactly 2.4.0, which keeps the browser hash bundle and the copy inlined in the server's PQC bundle the same code.
|
|
16
|
+
|
|
17
|
+
No output bytes change on anything blamejs computes or has stored. ML-KEM, ML-DSA and SLH-DSA known-answer vectors are byte-identical across the bump, in both directions, and so are XChaCha20-Poly1305 ciphertexts; no constant, domain-separation string, nonce construction or serialization order moved. Nothing already encrypted, wrapped or signed needs re-doing.
|
|
18
|
+
|
|
19
|
+
Two behaviour changes are reachable only through the raw primitives `b.pqcSoftware` exposes, and are worth knowing if you call them directly. `prehash(shake256)` on the 128-bit parameter sets now throws rather than producing a non-conformant signature. And an options object with an unknown or misspelled key — or one that is a class instance rather than a plain object — is now refused instead of silently ignored. **Fixed:** *A checkpoint reads the chain tip under the lock that makes appends atomic (#673)* — A checkpoint is a signed statement about a specific tip, and the value of that signature is that the pair it names was real. The tip read that feeds one ran outside the mutex the chain writer holds across its own read-tip → insert, so it could land in the middle of an append and pair a counter with a hash that were never the tip together. The signature over that pair is valid, the arithmetic is self-consistent, and it describes a state the chain was never in.
|
|
20
|
+
|
|
21
|
+
The read now takes the same lock, through `withChainLock` on the writer handle. It is held for the read only: signing is post-quantum and slow, every concurrent append queues behind that lock, and holding it across the signature would charge that cost to unrelated writers without adding a guarantee — a checkpoint claims a prefix, not that nothing has been appended since. A lock is per chain key, so an append to a different partition is never blocked by one.
|
|
22
|
+
|
|
23
|
+
A second, unreachable insert path into `audit_log` is removed. It took no lock and computed no row hash, and a future reader finding two insert paths into the chain table would reasonably have assumed both were live. · *A certificate renewal reaches the ManageSieve listener (#671)* — `b.mail.server.mx`, `.submission`, `.imap` and `.pop3` read `opts.tlsContext` at the point they need a context, so a consumer supplying an accessor gets the renewed certificate on the next connection. `.managesieve` captured it at construction, calling that accessor exactly once and serving the boot certificate for the life of the process.
|
|
24
|
+
|
|
25
|
+
Certificates renew every 60 to 90 days on any automated CA, so the frozen one expires while every piece of surrounding evidence says rotation is working: the watcher fired, the context rebuilt, and it simply never reached this one listener. That is worse than a listener that never supported rotation, because nothing reports it.
|
|
26
|
+
|
|
27
|
+
It now reads the option per connection like its siblings.
|
|
28
|
+
|
|
29
|
+
The rotation example in `b.mail.server.tls.context` was also wrong. It showed `mx.replaceTlsContext(newCtx)`, a method no listener implements and none needs: pass `get tlsContext() { return tls.secureContext; }` and a reload reaches the next connection with nothing to swap. Passing `tlsContext: tls.secureContext` instead copies the context current at boot, which is the frozen form. · *The hosted-domain set can change while the server runs (#661)* — `b.mail.server.mx.create` captured `localDomains` at construction, so the answer to "do we host this domain" was frozen at boot. Hosting a domain is administrative state, not configuration — operators add and withdraw them while the process runs.
|
|
30
|
+
|
|
31
|
+
Withdrawing is the case that bit: the operator disables a domain, every management surface agrees it is gone, and the listener keeps answering 250 at RCPT for it until a restart, with nothing signalling that mail is still arriving. Adding is milder but still wrong — a new domain draws 550 5.7.1, which a sending queue reads as permanent and drops rather than retrying.
|
|
32
|
+
|
|
33
|
+
`localDomains` now also accepts a function, answered per recipient. The neighbouring `recipientPolicy` already was, which is what made the frozen half odd: two parts of one question answered a line apart with different currency. The array form is unchanged and still validated once at boot. A set held in one array and mutated in place — pushed on add, spliced on withdraw — is read correctly, because the result is cached on the set's contents rather than on the array's identity.
|
|
34
|
+
|
|
35
|
+
An entry a live set returns that `b.guardDomain` refuses is dropped, with an audit event naming it, rather than thrown on — this is the request path, and a throw there turns a typo in an admin form into a mail outage. Mail for that entry is refused; the rest of the set keeps serving. The same holds for a set that cannot be read at all, an entry that cannot be coerced to a string, and anything else the resolver may raise: the recipients are refused and the connection survives.
|
|
36
|
+
|
|
37
|
+
`authservId` follows the same set rather than pinning to whichever domain was first at boot, and is resolved once per message so the header written and the forged headers stripped always name the same identity. **Security:** *`csrfProtect` `skipStateless` turns on the absence of a cookie, not on a header (#663)* — The exemption exists because a request with no ambient credential cannot be forged on a victim's behalf: CSRF spends a cookie the browser attaches by itself. It tested for an `Authorization` header as well.
|
|
38
|
+
|
|
39
|
+
Presence is not authenticity. An attacker composing a cross-site request writes their own headers, so `Authorization: Bearer nonsense` met the condition by being typed. And the header says nothing about which credential authenticated the request: `b.middleware.attachUser` with `tokenFrom: "both"` reads the cookie FIRST, so a request carrying a session cookie and a junk bearer header was authenticated by exactly the ambient credential the gate protects, and skipped the gate because of a header nothing had read. The two middlewares disagreed about which credential was in play, and the disagreement is what opened it.
|
|
40
|
+
|
|
41
|
+
The test is now the absence of a Cookie header, and nothing else. A bearer client that also sends an unrelated cookie is validated like any other request; deciding otherwise needs the auth layer's verdict about which credential authenticated it, which header presence cannot supply.
|
|
42
|
+
|
|
43
|
+
The exemption also sits below the origin cross-check now instead of above it, so `checkOrigin` is no longer waived by it. A consumer that asked for an origin check asked for something the token compare does not provide, and there is no reading of "stateless" under which a cross-origin state change becomes acceptable.
|
|
44
|
+
|
|
45
|
+
**Upgrading.** A caller relying on the header to skip the token check now needs to send the token, or to send no cookie. This is the shape the option was documented to cover; the header was never evidence of anything.
|
|
46
|
+
|
|
11
47
|
- v0.18.55 (2026-08-25) — **Opening an encrypted database twice destroyed the first process's data, and four mail-listener limits were documented but never applied.** `b.db.init` in encrypted mode reclaimed temporary working copies by filename alone, so a second process opening the same volume unlinked a running process's live database. On Linux the unlink succeeds against an open file, so the first process kept reading and writing normally while every flush from then on wrote nothing and reported success — losing everything back to its last flush, not merely the overlap. It does not reproduce on Windows, where unlinking an open file fails, so a deployment developed on a desktop and shipped to a container met it in production first.
|
|
12
48
|
|
|
13
49
|
The mail listeners had a matching shape in smaller form: `minBytesPerSecond` was validated, defaulted and exposed as a getter no listener called, so a peer trickling a few bytes at a time held a connection and its slot in the per-address cap for as long as it liked. `mail.server.imap` released a peer's rate-limit slot on disconnect but never removed it from the live-connection set, so an unauthenticated peer could grow that set without bound by connecting and dropping. No listener set a ceiling of its own, leaving the per-address cap as the only limit and the process-wide total at that cap times however many addresses a caller could speak from.
|
package/NOTICE
CHANGED
|
@@ -17,7 +17,7 @@ lib/vendor/MANIFEST.json.
|
|
|
17
17
|
|
|
18
18
|
--------------------------------------------------------------------------------
|
|
19
19
|
Component: @noble/ciphers
|
|
20
|
-
Version: 2.
|
|
20
|
+
Version: 2.4.0
|
|
21
21
|
Source: https://github.com/paulmillr/noble-ciphers
|
|
22
22
|
License: MIT
|
|
23
23
|
Copyright: Copyright (c) 2023 Paul Miller (https://paulmillr.com)
|
|
@@ -26,7 +26,7 @@ Used for: XChaCha20-Poly1305 authenticated encryption (lib/crypto.js,
|
|
|
26
26
|
Thank you to Paul Miller for the audited noble-ciphers suite.
|
|
27
27
|
--------------------------------------------------------------------------------
|
|
28
28
|
Component: @noble/curves
|
|
29
|
-
Version: 2.
|
|
29
|
+
Version: 2.4.0
|
|
30
30
|
Source: https://github.com/paulmillr/noble-curves
|
|
31
31
|
License: MIT
|
|
32
32
|
Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
|
@@ -36,7 +36,7 @@ Used for: RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF)
|
|
|
36
36
|
audited noble-curves suite.
|
|
37
37
|
--------------------------------------------------------------------------------
|
|
38
38
|
Component: @noble/hashes
|
|
39
|
-
Version: 2.
|
|
39
|
+
Version: 2.4.0
|
|
40
40
|
Source: https://github.com/paulmillr/noble-hashes
|
|
41
41
|
License: MIT
|
|
42
42
|
Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
|
@@ -53,7 +53,7 @@ Used for: The client half of a hybrid exchange, as a browser build
|
|
|
53
53
|
Miller for the audited noble-hashes suite.
|
|
54
54
|
--------------------------------------------------------------------------------
|
|
55
55
|
Component: @noble/post-quantum
|
|
56
|
-
Version: 0.7.
|
|
56
|
+
Version: 0.7.1
|
|
57
57
|
Source: https://github.com/paulmillr/noble-post-quantum
|
|
58
58
|
License: MIT
|
|
59
59
|
Copyright: Copyright (c) 2024 Paul Miller (https://paulmillr.com)
|
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
132
132
|
- CSP nonce — generated per request, merged into the CSP (`b.middleware.cspNonce`)
|
|
133
133
|
- Fetch-metadata resource-isolation guard (`b.middleware.fetchMetadata`)
|
|
134
134
|
- Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
|
|
135
|
-
- CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips
|
|
135
|
+
- CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips cookieless requests, which carry no ambient credential to abuse and so are not CSRF-able. A request that sends a cookie is validated whatever else it carries, and the Origin check is never skipped (`b.middleware.csrfProtect`)
|
|
136
136
|
- CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
|
|
137
137
|
- `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
|
|
138
138
|
- Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
|
|
@@ -314,15 +314,15 @@ All runtime dependencies are committed to the repo — no transitive npm install
|
|
|
314
314
|
```bash
|
|
315
315
|
./scripts/vendor-update.sh --check # see what's outdated
|
|
316
316
|
./scripts/vendor-update.sh --diff @noble/ciphers # see changelog before bumping
|
|
317
|
-
./scripts/vendor-update.sh @noble/ciphers 2.
|
|
317
|
+
./scripts/vendor-update.sh @noble/ciphers 2.4.0 # bundle + commit a new version
|
|
318
318
|
```
|
|
319
319
|
|
|
320
320
|
| Package | Version | Author | Purpose |
|
|
321
321
|
|---|---|---|---|
|
|
322
|
-
| [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.
|
|
323
|
-
| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.
|
|
324
|
-
| [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.
|
|
325
|
-
| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.
|
|
322
|
+
| [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.4.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
|
|
323
|
+
| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.4.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
|
|
324
|
+
| [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.4.0 (bundles @noble/hashes 2.4.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
|
|
325
|
+
| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.1 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.4.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
|
|
326
326
|
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.31 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
|
|
327
327
|
| [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
|
|
328
328
|
| [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
|
package/lib/audit.js
CHANGED
|
@@ -190,23 +190,12 @@ async function _readAuditRowHashAtCounter(counter) {
|
|
|
190
190
|
);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
for (var i = 0; i < allCols.length; i++) rowObj[allCols[i]] = values[i];
|
|
200
|
-
var built = sql.insert("audit_log", _sqlOpts())
|
|
201
|
-
.columns(allCols)
|
|
202
|
-
.values(rowObj)
|
|
203
|
-
.toSql();
|
|
204
|
-
return await safeAsync.withTimeout(
|
|
205
|
-
clusterStorage.execute(built.sql, built.params),
|
|
206
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
207
|
-
{ name: "audit.insertRow" }
|
|
208
|
-
);
|
|
209
|
-
}
|
|
193
|
+
// A second insert path into audit_log used to live here, unreachable. It held
|
|
194
|
+
// this file's only sql.insert("audit_log") and nothing called it: every append
|
|
195
|
+
// goes through chain-writer, which is what serializes read-tip → hash → insert.
|
|
196
|
+
// Removed rather than left, because a future reader finding two insert paths
|
|
197
|
+
// into the chain table would reasonably assume both were live, and the dead one
|
|
198
|
+
// took no lock and computed no row hash.
|
|
210
199
|
|
|
211
200
|
var _CHECKPOINT_COLS = [
|
|
212
201
|
"_id", "createdAt", "atMonotonicCounter", "atRowHash",
|
|
@@ -1090,13 +1079,26 @@ async function _checkpointOnDatabase(opts, dbGenAtEntry) {
|
|
|
1090
1079
|
.orderBy("monotonicCounter", "desc")
|
|
1091
1080
|
.limit(1)
|
|
1092
1081
|
.toSql();
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1082
|
+
// Read the tip under the SAME lock append() holds across its own
|
|
1083
|
+
// read-tip → insert. A checkpoint is a signed statement about a tip, so it
|
|
1084
|
+
// has to observe one that existed: read unlocked, it can land mid-append and
|
|
1085
|
+
// pair a counter with a hash that were never the tip together, and the
|
|
1086
|
+
// signature over that pair is valid, self-consistent, and describes a state
|
|
1087
|
+
// the chain was never in.
|
|
1088
|
+
//
|
|
1089
|
+
// The lock covers the READ only. Signing is post-quantum and slow, and every
|
|
1090
|
+
// concurrent append queues behind this lock; holding it across the signature
|
|
1091
|
+
// would charge that to unrelated writers without adding anything, because a
|
|
1092
|
+
// checkpoint claims a prefix rather than claiming nothing was appended after.
|
|
1093
|
+
var tip = await _chainWriter.withChainLock(null, function () {
|
|
1094
|
+
return safeAsync.withTimeout(
|
|
1095
|
+
safeAsync.asyncRetry(function () {
|
|
1096
|
+
return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
|
|
1097
|
+
}),
|
|
1098
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
1099
|
+
{ name: "audit.checkpoint.readTip" }
|
|
1100
|
+
);
|
|
1101
|
+
});
|
|
1100
1102
|
|
|
1101
1103
|
if (!tip) return null; // empty audit log; nothing to anchor
|
|
1102
1104
|
|
package/lib/chain-writer.js
CHANGED
|
@@ -481,6 +481,23 @@ function create(opts) {
|
|
|
481
481
|
table: table,
|
|
482
482
|
chainKey: chainKey,
|
|
483
483
|
append: append,
|
|
484
|
+
// Run `fn` under the same lock append() holds across read-tip → insert.
|
|
485
|
+
//
|
|
486
|
+
// A checkpoint signs a statement about the tip, so it has to observe a tip
|
|
487
|
+
// that actually existed. Reading it outside this lock can land in the
|
|
488
|
+
// middle of an append — between the row being written and the counter
|
|
489
|
+
// advancing — and pair a counter with a hash that were never the tip
|
|
490
|
+
// together. The signature over that pair is valid and self-consistent and
|
|
491
|
+
// describes a state the chain was never in.
|
|
492
|
+
//
|
|
493
|
+
// Callers hold it for the READ only and sign afterwards. The signature is
|
|
494
|
+
// post-quantum and slow, and every concurrent append waits on this lock;
|
|
495
|
+
// signing under it would charge that cost to unrelated writers for no
|
|
496
|
+
// added guarantee, since a checkpoint claims a prefix rather than claiming
|
|
497
|
+
// nothing has been appended since.
|
|
498
|
+
withChainLock: function (keyValue, fn) {
|
|
499
|
+
return _mutexFor(keyValue).runExclusive(fn);
|
|
500
|
+
},
|
|
484
501
|
_resetForTest: _resetForTest,
|
|
485
502
|
// Expose for diagnostic introspection — the lock for a given key (or the
|
|
486
503
|
// single-chain lock when no chainKey is configured).
|
|
@@ -222,7 +222,16 @@ function create(opts) {
|
|
|
222
222
|
var authConfig = opts.auth || null;
|
|
223
223
|
var mailStore = opts.mailStore;
|
|
224
224
|
var allowPlaintext = opts.allowPlaintext === true;
|
|
225
|
-
|
|
225
|
+
|
|
226
|
+
// Read at the point of use, never captured. A consumer wiring the watching
|
|
227
|
+
// loader from mail.server.tls.context installs an accessor on this option, and
|
|
228
|
+
// capturing it here would call that accessor exactly once and freeze whatever
|
|
229
|
+
// certificate was current at boot — for a listener whose siblings all rotate.
|
|
230
|
+
// Certificates renew every 60 to 90 days on any automated CA, so the frozen
|
|
231
|
+
// one expires while the operator's evidence says rotation is working: the
|
|
232
|
+
// watcher fired, the context rebuilt, and it simply never reached here. The
|
|
233
|
+
// four sibling listeners already read the option per connection.
|
|
234
|
+
function _tlsContext() { return opts.tlsContext || null; }
|
|
226
235
|
|
|
227
236
|
// safeSieve cap matches the guard's per-profile script cap (the
|
|
228
237
|
// guard caps the literal-byte announcement; safeSieve.parse caps
|
|
@@ -280,7 +289,7 @@ function create(opts) {
|
|
|
280
289
|
_emitCapabilityBanner(state, socket);
|
|
281
290
|
_writeOk(socket, greeting + " ready");
|
|
282
291
|
|
|
283
|
-
if (allowPlaintext && !
|
|
292
|
+
if (allowPlaintext && !_tlsContext()) {
|
|
284
293
|
_emit("mail.server.managesieve.plaintext_warning",
|
|
285
294
|
{ connectionId: connectionId,
|
|
286
295
|
remark: "allowPlaintext=true; no STARTTLS available — operators MUST gate at network layer" },
|
|
@@ -511,7 +520,7 @@ function create(opts) {
|
|
|
511
520
|
} else {
|
|
512
521
|
socket.write('"SASL" ""\r\n');
|
|
513
522
|
}
|
|
514
|
-
if (!state.tls &&
|
|
523
|
+
if (!state.tls && _tlsContext()) {
|
|
515
524
|
socket.write('"STARTTLS"\r\n');
|
|
516
525
|
}
|
|
517
526
|
}
|
|
@@ -539,7 +548,8 @@ function create(opts) {
|
|
|
539
548
|
_writeNo(socket, "STARTTLS only valid pre-AUTH (RFC 5804 §2.2)");
|
|
540
549
|
return;
|
|
541
550
|
}
|
|
542
|
-
|
|
551
|
+
var startTlsContext = _tlsContext();
|
|
552
|
+
if (!startTlsContext) {
|
|
543
553
|
_writeNo(socket, "STARTTLS unavailable (listener configured with allowPlaintext=true and no tlsContext)");
|
|
544
554
|
return;
|
|
545
555
|
}
|
|
@@ -552,7 +562,7 @@ function create(opts) {
|
|
|
552
562
|
mailServerTls.upgradeLineProtocol({
|
|
553
563
|
state: state,
|
|
554
564
|
socket: socket,
|
|
555
|
-
secureContext:
|
|
565
|
+
secureContext: startTlsContext,
|
|
556
566
|
idleTimeoutMs: idleTimeoutMs,
|
|
557
567
|
clearFields: ["pendingLiteral", "pendingAuth"],
|
|
558
568
|
drain: _drainBuffer,
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -157,6 +157,7 @@
|
|
|
157
157
|
|
|
158
158
|
var net = require("node:net");
|
|
159
159
|
var lazyRequire = require("./lazy-require");
|
|
160
|
+
var boundedMap = require("./bounded-map");
|
|
160
161
|
var C = require("./constants");
|
|
161
162
|
var numericBounds = require("./numeric-bounds");
|
|
162
163
|
var safeAsync = require("./safe-async");
|
|
@@ -321,7 +322,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
|
|
|
321
322
|
* greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
|
|
322
323
|
* agent: b.mail.agent, // optional delivery handoff
|
|
323
324
|
* relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
|
|
324
|
-
* localDomains: [string],
|
|
325
|
+
* localDomains: [string]|fn, // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1). A function returning the array is answered per RCPT, for a hosted set the operator changes while the server runs; an entry it returns that b.guardDomain refuses is dropped with an audit event rather than throwing on the connection that happened to arrive.
|
|
325
326
|
* recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
|
|
326
327
|
* maxLineBytes: number, // default 1 KiB — per-command line cap
|
|
327
328
|
* maxMessageBytes: number, // default 50 MiB — DATA body cap
|
|
@@ -332,7 +333,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
|
|
|
332
333
|
* guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
|
|
333
334
|
* mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
|
|
334
335
|
* onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
|
|
335
|
-
* authservId?: string, // RFC 8601 authserv-id;
|
|
336
|
+
* authservId?: string, // RFC 8601 authserv-id; defaults to the first local domain, re-read per message when localDomains is a function
|
|
336
337
|
* dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
|
|
337
338
|
* maxSignatures?: number, // DKIM verify cap (1-16)
|
|
338
339
|
* clockSkewMs?: number, // DKIM timestamp skew tolerance
|
|
@@ -375,9 +376,11 @@ function create(opts) {
|
|
|
375
376
|
// entirely, so the only way to get a listener was to get one that accepted
|
|
376
377
|
// everything. An allowlist that disappears when it is empty is a firewall
|
|
377
378
|
// rule set that opens when the last rule is deleted.
|
|
378
|
-
if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains)
|
|
379
|
+
if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains) &&
|
|
380
|
+
typeof opts.localDomains !== "function") {
|
|
379
381
|
throw new MailServerMxError("mail-server-mx/bad-opts",
|
|
380
|
-
"mail.server.mx.create: localDomains must be an array of domain strings " +
|
|
382
|
+
"mail.server.mx.create: localDomains must be an array of domain strings, or a " +
|
|
383
|
+
"function returning one when the hosted set changes while the server runs " +
|
|
381
384
|
"(an empty array means this server hosts no domains and refuses every recipient)");
|
|
382
385
|
}
|
|
383
386
|
if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
|
|
@@ -409,7 +412,22 @@ function create(opts) {
|
|
|
409
412
|
var maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
|
|
410
413
|
var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
|
|
411
414
|
var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
|
|
412
|
-
|
|
415
|
+
// The hosted-domain set is administrative state, not configuration: operators
|
|
416
|
+
// add and withdraw domains while the process runs, and that is an ordinary
|
|
417
|
+
// act rather than a reconfiguration. Captured once, a withdrawn domain kept
|
|
418
|
+
// drawing 250 at RCPT until a restart — with every management surface
|
|
419
|
+
// agreeing it was gone and nothing telling the operator mail was still
|
|
420
|
+
// arriving for it — while a newly added one drew 550 5.7.1, which a sending
|
|
421
|
+
// queue reads as "never retry" rather than "not yet".
|
|
422
|
+
//
|
|
423
|
+
// So it may be a function, answered per RCPT. The neighbouring
|
|
424
|
+
// recipientPolicy already is, which is what made the frozen half odd: the two
|
|
425
|
+
// parts of one question were answered a line apart with different currency.
|
|
426
|
+
// The array form is unchanged and still validated once at boot.
|
|
427
|
+
var localDomainsFn = typeof opts.localDomains === "function" ? opts.localDomains : null;
|
|
428
|
+
var localDomains = localDomainsFn
|
|
429
|
+
? []
|
|
430
|
+
: (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
|
|
413
431
|
var relayAllowedFor = (opts.relayAllowedFor || []).map(function (__e) {
|
|
414
432
|
return (__e && typeof __e === "object" && !Array.isArray(__e))
|
|
415
433
|
? Object.assign({}, __e, { cidr: _normalizeRelayCidr(__e.cidr) })
|
|
@@ -501,7 +519,10 @@ function create(opts) {
|
|
|
501
519
|
// Authentication-Results header. Defaults to the first local
|
|
502
520
|
// domain; with neither, the header is skipped (the verdict
|
|
503
521
|
// still reaches the agent handoff).
|
|
504
|
-
|
|
522
|
+
// A getter, so a listener whose hosted set is a function does not pin the
|
|
523
|
+
// authserv-id to whichever domain happened to be first at boot. Freezing
|
|
524
|
+
// the object prevents redefining this, not invoking it.
|
|
525
|
+
get authservId() { return ge.authservId || _resolveLocalDomains()[0] || null; },
|
|
505
526
|
dnsLookup: ge.dnsLookup || undefined,
|
|
506
527
|
maxSignatures: ge.maxSignatures,
|
|
507
528
|
clockSkewMs: ge.clockSkewMs,
|
|
@@ -559,6 +580,165 @@ function create(opts) {
|
|
|
559
580
|
}
|
|
560
581
|
}
|
|
561
582
|
|
|
583
|
+
// Resolve the hosted set at the point it is needed.
|
|
584
|
+
//
|
|
585
|
+
// The array form was checked at boot and cannot change, so it is returned as
|
|
586
|
+
// is. The function form is checked when it is read, because a set that can
|
|
587
|
+
// change can acquire a bad entry after boot — an IDN homograph typed into an
|
|
588
|
+
// admin form is exactly the case the boot check exists for, and it would
|
|
589
|
+
// otherwise reach RCPT unexamined.
|
|
590
|
+
//
|
|
591
|
+
// A bad entry is DROPPED rather than thrown on. This is the request path: a
|
|
592
|
+
// throw here would take down the connection that happened to arrive, and the
|
|
593
|
+
// operator would see a mail outage rather than a typo. Dropping refuses mail
|
|
594
|
+
// for the entry that failed and keeps serving the domains that passed, which
|
|
595
|
+
// is what an allowlist with one bad line should do. Each distinct bad value
|
|
596
|
+
// is reported once so it does not become a per-recipient log flood.
|
|
597
|
+
//
|
|
598
|
+
// The cache key is the CONTENT of the returned set, never its identity. An
|
|
599
|
+
// operator holding one array and mutating it — push on add, splice on
|
|
600
|
+
// withdraw — hands back the same object every time, which is the most
|
|
601
|
+
// ordinary way to keep this state. Keyed on identity, that array would be
|
|
602
|
+
// normalized once and then frozen: added domains refused forever and
|
|
603
|
+
// withdrawn ones accepted forever, which is this very bug wearing a hat.
|
|
604
|
+
var _ldSeenKey = null;
|
|
605
|
+
var _ldSeenOut = [];
|
|
606
|
+
// Bounded, because its keys come from the operator's live set and a control
|
|
607
|
+
// plane that churns tenants — or an admin form collecting typos — would
|
|
608
|
+
// otherwise grow this without limit for the life of the process, one entry
|
|
609
|
+
// per distinct bad value ever seen. It exists only to stop a per-recipient
|
|
610
|
+
// log flood, so evicting the oldest costs at most a repeated warning about a
|
|
611
|
+
// domain nobody has mentioned in a long time.
|
|
612
|
+
var _ldWarned = boundedMap.boundedMap({ maxEntries: 256, policy: "evict-oldest" });
|
|
613
|
+
|
|
614
|
+
// Warn once per distinct reason. getOrInsert runs the factory only when the
|
|
615
|
+
// key is absent, which is the whole "first time only" rule — a hand-rolled
|
|
616
|
+
// has-then-set says the same thing in two statements that can drift apart.
|
|
617
|
+
function _warnOnce(key, emitFn) {
|
|
618
|
+
boundedMap.getOrInsert(_ldWarned, key, function () { emitFn(); return true; });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Backstop for the whole resolution, not any one operation inside it.
|
|
622
|
+
//
|
|
623
|
+
// Three separate throws were found here one at a time — the callback itself,
|
|
624
|
+
// serializing its result, coercing an entry — and each was guarded where it
|
|
625
|
+
// stood. The rule is what matters rather than the list: NOTHING in resolving
|
|
626
|
+
// an operator-supplied set may reach the RCPT handler as an exception, since
|
|
627
|
+
// there it ends the connection of whoever happened to be delivering. An
|
|
628
|
+
// unreadable set means no domains are known, and no domains known means every
|
|
629
|
+
// recipient is refused, which is what an empty hosted set already means.
|
|
630
|
+
function _resolveLocalDomains() {
|
|
631
|
+
try { return _resolveLocalDomainsInner(); }
|
|
632
|
+
catch (err) {
|
|
633
|
+
_warnOnce("__resolver", function () {
|
|
634
|
+
_emit("mail.server.mx.local_domains_unavailable",
|
|
635
|
+
{ reason: "resolver-threw",
|
|
636
|
+
remark: String((err && err.message) || err).slice(0, 200) },
|
|
637
|
+
"warning");
|
|
638
|
+
});
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function _resolveLocalDomainsInner() {
|
|
644
|
+
if (!localDomainsFn) return localDomains;
|
|
645
|
+
var raw;
|
|
646
|
+
try { raw = localDomainsFn(); }
|
|
647
|
+
catch (err) {
|
|
648
|
+
_warnOnce("__threw", function () {
|
|
649
|
+
_emit("mail.server.mx.local_domains_unavailable",
|
|
650
|
+
{ reason: "threw", remark: String((err && err.message) || err).slice(0, 200) },
|
|
651
|
+
"warning");
|
|
652
|
+
});
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
if (!Array.isArray(raw)) {
|
|
656
|
+
_warnOnce("__shape", function () {
|
|
657
|
+
_emit("mail.server.mx.local_domains_unavailable",
|
|
658
|
+
{ reason: "not-an-array", remark: "localDomains() returned " + (typeof raw) },
|
|
659
|
+
"warning");
|
|
660
|
+
});
|
|
661
|
+
return [];
|
|
662
|
+
}
|
|
663
|
+
// Coerce first, and key the cache on the COERCED strings.
|
|
664
|
+
//
|
|
665
|
+
// Keying on the raw array does not work: JSON.stringify is not injective
|
|
666
|
+
// with respect to the String() that follows it. Two objects with different
|
|
667
|
+
// toString() results both serialize as {}, and undefined, null and
|
|
668
|
+
// functions are indistinguishable inside an array — so two genuinely
|
|
669
|
+
// different hosted sets could share a key and the second one would be
|
|
670
|
+
// answered from the first one's cache. A withdrawn domain would stay
|
|
671
|
+
// accepted, which is the bug the callback exists to fix.
|
|
672
|
+
//
|
|
673
|
+
// Coercion is per ENTRY and guarded, because it runs with operator-supplied
|
|
674
|
+
// runtime data inside RCPT handling: an object whose toString throws must
|
|
675
|
+
// drop that entry, not end the connection that happened to arrive.
|
|
676
|
+
var coerced = [];
|
|
677
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
678
|
+
try {
|
|
679
|
+
coerced.push(String(raw[i]).toLowerCase());
|
|
680
|
+
} catch (entryErr) {
|
|
681
|
+
var entryIndex = i;
|
|
682
|
+
var entryReason = String((entryErr && entryErr.message) || entryErr).slice(0, 160);
|
|
683
|
+
_warnOnce("__entry" + entryIndex, function () {
|
|
684
|
+
_emit("mail.server.mx.local_domain_refused",
|
|
685
|
+
{ domain: null, kind: "unreadable",
|
|
686
|
+
remark: "localDomains() entry " + entryIndex + " could not be read (" +
|
|
687
|
+
entryReason +
|
|
688
|
+
"); it is refused and the rest of the set still serves" },
|
|
689
|
+
"warning");
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Every element is a string now, so this key is injective over exactly the
|
|
695
|
+
// values the decision below is made from. Coercion is cheap and runs every
|
|
696
|
+
// time; what the cache buys is skipping a b.guardDomain call per entry per
|
|
697
|
+
// recipient, which is the expensive half.
|
|
698
|
+
var contentKey = JSON.stringify(coerced);
|
|
699
|
+
if (contentKey === _ldSeenKey) return _ldSeenOut;
|
|
700
|
+
|
|
701
|
+
// One bad entry drops and the rest of the set still serves, which is the
|
|
702
|
+
// same answer the guard gives for one it rejects on its merits.
|
|
703
|
+
var out = [];
|
|
704
|
+
for (var ci = 0; ci < coerced.length; ci += 1) {
|
|
705
|
+
var d = coerced[ci];
|
|
706
|
+
if (guardDomainProfile) {
|
|
707
|
+
var verdict;
|
|
708
|
+
try { verdict = guardDomain.validate(d, guardDomainProfile); }
|
|
709
|
+
catch (guardErr) {
|
|
710
|
+
var guardReason = String((guardErr && guardErr.message) || guardErr).slice(0, 160);
|
|
711
|
+
_warnOnce("__guard:" + d, function () {
|
|
712
|
+
_emit("mail.server.mx.local_domain_refused",
|
|
713
|
+
{ domain: d, kind: "guard-threw",
|
|
714
|
+
remark: "b.guardDomain threw on this entry (" + guardReason +
|
|
715
|
+
"); it is refused and the rest of the set still serves" },
|
|
716
|
+
"warning");
|
|
717
|
+
});
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!verdict.ok) {
|
|
721
|
+
var refusedDomain = d;
|
|
722
|
+
var refusedKind =
|
|
723
|
+
(verdict.issues && verdict.issues[0] && verdict.issues[0].kind) || null;
|
|
724
|
+
_warnOnce(refusedDomain, function () {
|
|
725
|
+
_emit("mail.server.mx.local_domain_refused",
|
|
726
|
+
{ domain: refusedDomain,
|
|
727
|
+
kind: refusedKind,
|
|
728
|
+
remark: "rejected by b.guardDomain; mail for it is refused and the rest " +
|
|
729
|
+
"of the set still serves" },
|
|
730
|
+
"warning");
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
out.push(d);
|
|
736
|
+
}
|
|
737
|
+
_ldSeenKey = contentKey;
|
|
738
|
+
_ldSeenOut = out;
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
|
|
562
742
|
var connections = new Set();
|
|
563
743
|
|
|
564
744
|
var _emit = auditEmit.emit;
|
|
@@ -1068,7 +1248,7 @@ function create(opts) {
|
|
|
1068
1248
|
// every recipient. An empty hosted set now refuses everything naturally,
|
|
1069
1249
|
// which is what an empty allowlist has to mean; `relayAllowedFor` is
|
|
1070
1250
|
// still the way to permit a scope deliberately.
|
|
1071
|
-
if (
|
|
1251
|
+
if (_resolveLocalDomains().indexOf(rcptDomain) === -1 &&
|
|
1072
1252
|
!_isRelayAllowed(state.remoteAddress, rcpt)) {
|
|
1073
1253
|
rateLimit.noteRcptFailure(state.remoteAddress);
|
|
1074
1254
|
_trackRefusedRcpt(state, rcpt, "relay-denied");
|
|
@@ -1230,6 +1410,18 @@ function create(opts) {
|
|
|
1230
1410
|
var inboundAuth = null;
|
|
1231
1411
|
if (envelopeGate) {
|
|
1232
1412
|
var inboundVerdict = null;
|
|
1413
|
+
// Resolve the authserv-id ONCE for this message and use that value for
|
|
1414
|
+
// both the header written and the forged headers stripped.
|
|
1415
|
+
//
|
|
1416
|
+
// It can now come from a hosted-domain callback, so two reads either
|
|
1417
|
+
// side of an await can disagree — and these two reads are exactly the
|
|
1418
|
+
// pair that must not. The strip removes sender-attached
|
|
1419
|
+
// Authentication-Results claiming this receiver's identity (RFC 8601
|
|
1420
|
+
// §5) before the computed one is prepended. Strip under the old id and
|
|
1421
|
+
// write under the new one, and a forged header claiming the id the
|
|
1422
|
+
// trusted verdict was written under survives next to it, which is the
|
|
1423
|
+
// shadowing this defense exists to prevent.
|
|
1424
|
+
var messageAuthservId = envelopeGate.authservId;
|
|
1233
1425
|
try {
|
|
1234
1426
|
// Wall-clock ceiling around the whole pipeline — a message
|
|
1235
1427
|
// stuffed with signatures pointing at slow resolvers must
|
|
@@ -1245,7 +1437,7 @@ function create(opts) {
|
|
|
1245
1437
|
maxSignatures: envelopeGate.maxSignatures,
|
|
1246
1438
|
clockSkewMs: envelopeGate.clockSkewMs,
|
|
1247
1439
|
minRsaBits: envelopeGate.minRsaBits,
|
|
1248
|
-
authservId:
|
|
1440
|
+
authservId: messageAuthservId || undefined,
|
|
1249
1441
|
}),
|
|
1250
1442
|
envelopeGate.timeoutMs,
|
|
1251
1443
|
{ name: "mail.server.mx.guardEnvelope" });
|
|
@@ -1317,7 +1509,7 @@ function create(opts) {
|
|
|
1317
1509
|
// RFC 8601 §5 — strip any sender-attached A-R header
|
|
1318
1510
|
// claiming this receiver's authserv-id before prepending
|
|
1319
1511
|
// the computed one (forged-verdict shadowing defense).
|
|
1320
|
-
dedotted = _stripForgedAuthResults(dedotted,
|
|
1512
|
+
dedotted = _stripForgedAuthResults(dedotted, messageAuthservId);
|
|
1321
1513
|
dedotted = Buffer.concat([
|
|
1322
1514
|
Buffer.from(inboundVerdict.authResults + "\r\n", "utf8"),
|
|
1323
1515
|
dedotted,
|
package/lib/mail-server-tls.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* });
|
|
25
25
|
*
|
|
26
26
|
* var mx = b.mail.server.mx.create({
|
|
27
|
-
*
|
|
27
|
+
* // A getter, not `tlsCtx.secureContext` — see "Picking up a rotation".
|
|
28
|
+
* get tlsContext() { return tlsCtx.secureContext; },
|
|
28
29
|
* ...
|
|
29
30
|
* });
|
|
30
31
|
* ```
|
|
@@ -75,10 +76,20 @@
|
|
|
75
76
|
*
|
|
76
77
|
* // Once per process at boot:
|
|
77
78
|
* var tls = b.mail.server.tls.context({ certFile, keyFile, watch: true });
|
|
78
|
-
* var mx = b.mail.server.mx.create({
|
|
79
|
-
*
|
|
79
|
+
* var mx = b.mail.server.mx.create({
|
|
80
|
+
* get tlsContext() { return tls.secureContext; },
|
|
81
|
+
* ...
|
|
82
|
+
* });
|
|
80
83
|
* ```
|
|
81
84
|
*
|
|
85
|
+
* **Picking up a rotation.** Every listener reads `opts.tlsContext` at the
|
|
86
|
+
* point it needs a context rather than capturing it at construction, so a
|
|
87
|
+
* getter is all a rotation needs: the next connection sees the reloaded
|
|
88
|
+
* context and nothing has to be swapped or restarted. Passing
|
|
89
|
+
* `tlsContext: tls.secureContext` instead copies the context that happened
|
|
90
|
+
* to be current at boot, and that one keeps being served after it expires.
|
|
91
|
+
* There is no listener method to swap a context, and none is needed.
|
|
92
|
+
*
|
|
82
93
|
* The cleartext-refused error message from `b.mail.server.mx` /
|
|
83
94
|
* `b.mail.server.submission` points at this primitive so the
|
|
84
95
|
* operator's boot dead-end becomes a one-line fix.
|
|
@@ -137,12 +148,16 @@ var DEFAULT_POLL_MS = C.TIME.seconds(30);
|
|
|
137
148
|
* keyFile: "/etc/letsencrypt/live/mail.example.com/privkey.pem",
|
|
138
149
|
* watch: true,
|
|
139
150
|
* });
|
|
140
|
-
* //
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
151
|
+
* // Pass a getter so each connection reads the current context. Listeners
|
|
152
|
+
* // read opts.tlsContext at the point of use, so a reload reaches the next
|
|
153
|
+
* // connection with nothing to swap.
|
|
154
|
+
* var mx = b.mail.server.mx.create({
|
|
155
|
+
* get tlsContext() { return tls.secureContext; },
|
|
156
|
+
* localDomains: ["mail.example.com"]
|
|
145
157
|
* });
|
|
158
|
+
* // onReload is for observing a rotation — logging it, re-checking expiry.
|
|
159
|
+
* // Delivering it to the listeners is not something it has to do.
|
|
160
|
+
* tls.onReload(function (newCtx) { void newCtx; });
|
|
146
161
|
*
|
|
147
162
|
* // ... later, on shutdown:
|
|
148
163
|
* tls.stop(); // clears the poll timer
|