@blamejs/core 0.18.54 → 0.18.55

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.
@@ -133,7 +133,6 @@ var safeBuffer = require("./safe-buffer");
133
133
  var mailServerTls = require("./mail-server-tls");
134
134
  var mailServerNet = require("./mail-server-net");
135
135
  var C = require("./constants");
136
- var bCrypto = require("./crypto");
137
136
  var numericBounds = require("./numeric-bounds");
138
137
  var validateOpts = require("./validate-opts");
139
138
  var guardManageSieveCommand = require("./guard-managesieve-command");
@@ -172,6 +171,7 @@ var ERR_CLAMP = 200;
172
171
  * greeting: string, // default "blamejs ManageSieve"
173
172
  * maxLineBytes: number, // default 8192
174
173
  * idleTimeoutMs: number, // default 5 min
174
+ * maxConnections: number, // default 1024 — listener-wide ceiling
175
175
  * profile: "strict" | "balanced" | "permissive", // default "strict"
176
176
  * auth: {
177
177
  * mechanisms: ["SCRAM-SHA-256", "OAUTHBEARER", ...], // SASL mechs to advertise
@@ -212,7 +212,7 @@ function create(opts) {
212
212
  "operator-supplied backend)");
213
213
  }
214
214
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
215
- ["maxLineBytes", "idleTimeoutMs"],
215
+ ["maxLineBytes", "idleTimeoutMs", "maxConnections"],
216
216
  "mail.server.managesieve.", MailServerManageSieveError, "mail-server-managesieve/bad-bound");
217
217
 
218
218
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -236,22 +236,18 @@ function create(opts) {
236
236
  var _emit = auditEmit.emit;
237
237
 
238
238
  function _handleConnection(rawSocket) {
239
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
239
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
240
+ rateLimit: rateLimit,
241
+ connections: connections,
242
+ emit: _emit,
240
243
  refusedEvent: "mail.server.managesieve.rate_limit_refused",
241
244
  refusalLine: 'NO "Too many connections from your IP"\r\n',
245
+ idPrefix: "msvconn-",
242
246
  });
243
- if (remoteAddress === null) return;
244
- var connectionId = "msvconn-" + bCrypto.generateToken(8); // connection-id length
245
- var socket = rawSocket;
246
- connections.add(socket);
247
- // Single close handler covers BOTH operator-driven `_close(socket)`
248
- // and client-initiated disconnects (TCP FIN / RST). Releases the
249
- // rate-limit slot AND removes the socket from the tracking set so
250
- // long-lived deployments don't accumulate stale entries.
251
- rawSocket.once("close", function () {
252
- rateLimit.releaseConnection(remoteAddress);
253
- connections.delete(socket);
254
- });
247
+ if (accepted === null) return;
248
+ var remoteAddress = accepted.remoteAddress;
249
+ var connectionId = accepted.connectionId;
250
+ var socket = accepted.socket;
255
251
 
256
252
  var state = {
257
253
  id: connectionId,
@@ -302,6 +298,31 @@ function create(opts) {
302
298
  // AUTHENTICATE with a non-synchronizing initial-response), the next
303
299
  // N bytes are the literal-payload; accumulate them before resuming
304
300
  // line-mode dispatch.
301
+ // The CRLF that ends the LINE a literal sat on. RFC 5804 §4 puts it after
302
+ // the payload, outside the declared octet count, so every literal owes one
303
+ // and no literal's size includes it.
304
+ //
305
+ // Consumed here when it is already buffered, and otherwise awaited: it may
306
+ // arrive in a later segment, and read as a line it is an EMPTY line. On the
307
+ // SASL path an empty line is a second response and the pipelining guard
308
+ // fails the exchange for it; on the PUTSCRIPT path it draws a spurious
309
+ // "empty command line" refusal AFTER the script was accepted, so one command
310
+ // is answered twice and a client that reads one reply per command spends the
311
+ // rest of the session attributing each answer to the command before it.
312
+ //
313
+ // A peek alone is not enough, because whether the terminator has arrived yet
314
+ // is a property of the network rather than of the protocol. Shared by both
315
+ // literal paths: one of them used to do this and the other did not, which is
316
+ // the entire defect.
317
+ function _consumeLiteralTerminator(state) {
318
+ if (state.lineBuffer.length >= 2 &&
319
+ state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
320
+ state.lineBuffer = state.lineBuffer.subarray(2);
321
+ } else {
322
+ state.awaitingLiteralTerminator = true;
323
+ }
324
+ }
325
+
305
326
  function _drainBuffer(state, socket) {
306
327
  while (true) {
307
328
  if (state.pendingLiteral) {
@@ -314,6 +335,7 @@ function create(opts) {
314
335
  }
315
336
  pl.body = Buffer.concat([pl.body, state.lineBuffer.subarray(0, need)]);
316
337
  state.lineBuffer = state.lineBuffer.subarray(need);
338
+ _consumeLiteralTerminator(state);
317
339
  state.pendingLiteral = null;
318
340
  _completePutscript(state, socket, pl);
319
341
  if (state.stage === "closed") return;
@@ -329,26 +351,7 @@ function create(opts) {
329
351
  }
330
352
  pa.irBody = Buffer.concat([pa.irBody, state.lineBuffer.subarray(0, needA)]);
331
353
  state.lineBuffer = state.lineBuffer.subarray(needA);
332
- // The CRLF that ends the line the literal sat on. It was left in the
333
- // buffer, so the next pass read it as an empty LINE — harmless while
334
- // nothing was in flight, and not harmless during a multi-step SASL
335
- // exchange, where an empty line is a second response and the pipelining
336
- // guard fails the exchange for it. The bug only appeared when the two
337
- // writes arrived as separate TCP segments, which is why the host run
338
- // was green and the container run was not.
339
- // The CRLF that ends the line the literal sat on. Consumed here when it
340
- // is already buffered, and otherwise awaited: it may arrive in a later
341
- // segment, and read as a line it is an EMPTY line. That was harmless
342
- // while nothing was in flight, and during a multi-step SASL exchange it
343
- // is a second response — the pipelining guard fails the exchange for
344
- // it. A peek alone is not enough, because whether the terminator has
345
- // arrived yet is a property of the network, not of the protocol.
346
- if (state.lineBuffer.length >= 2 &&
347
- state.lineBuffer[0] === 0x0d && state.lineBuffer[1] === 0x0a) { // CR LF
348
- state.lineBuffer = state.lineBuffer.subarray(2);
349
- } else {
350
- state.awaitingLiteralTerminator = true;
351
- }
354
+ _consumeLiteralTerminator(state);
352
355
  pa.irBytes = null;
353
356
  _completeAuthenticate(state, socket);
354
357
  if (state.stage === "closed") return;
@@ -625,10 +628,19 @@ function create(opts) {
625
628
  mech: mech,
626
629
  irBytes: parsed.literalBytes,
627
630
  irPlus: parsed.literalPlus,
628
- irBody: Buffer.alloc(0),
631
+ // RFC 5804 §2.1 lets the initial response come inline as a quoted string
632
+ // instead of a literal. It arrives complete on this line, so there is
633
+ // nothing to wait for — the exchange runs now, with the response the
634
+ // client actually sent. This used to fall through to the no-initial-
635
+ // response branch and answer a conforming client as though it had said
636
+ // nothing.
637
+ irBody: parsed.initialResponse === null || parsed.initialResponse === undefined
638
+ ? Buffer.alloc(0)
639
+ : Buffer.from(parsed.initialResponse, "utf8"),
629
640
  };
630
641
  if (parsed.literalBytes === null) {
631
- // No initial-response call verify with empty client response.
642
+ // Either an inline initial response, complete above, or none at all
643
+ // in which case verify is called with an empty client response.
632
644
  _completeAuthenticate(state, socket);
633
645
  return;
634
646
  }
@@ -987,6 +999,7 @@ function create(opts) {
987
999
  // ---- Lifecycle ----------------------------------------------------------
988
1000
  return mailServerNet.createStoreServer(net, {
989
1001
  defaultPort: DEFAULT_PORT,
1002
+ maxConnections: opts.maxConnections,
990
1003
  handleConnection: _handleConnection,
991
1004
  errorClass: MailServerManageSieveError,
992
1005
  errorCodePrefix: "mail-server-managesieve/",
@@ -158,7 +158,6 @@
158
158
  var net = require("node:net");
159
159
  var lazyRequire = require("./lazy-require");
160
160
  var C = require("./constants");
161
- var bCrypto = require("./crypto");
162
161
  var numericBounds = require("./numeric-bounds");
163
162
  var safeAsync = require("./safe-async");
164
163
  var safeBuffer = require("./safe-buffer");
@@ -328,6 +327,7 @@ function _stripForgedAuthResults(messageBuf, authservId) {
328
327
  * maxMessageBytes: number, // default 50 MiB — DATA body cap
329
328
  * maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
330
329
  * idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
330
+ * maxConnections: number, // default 1024 — listener-wide ceiling
331
331
  * profile: "strict" | "balanced" | "permissive", // gate posture cascade
332
332
  * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
333
333
  * mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
@@ -365,12 +365,20 @@ function create(opts) {
365
365
  "output directly. Cert provisioning lives in b.acme (RFC 8555 + RFC 9773 ARI).");
366
366
  }
367
367
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
368
- ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
368
+ ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
369
369
  "mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
370
- if (opts.localDomains !== undefined &&
371
- (!Array.isArray(opts.localDomains) || opts.localDomains.length === 0)) {
370
+ // An EMPTY array is accepted and means "this server hosts no domains", which
371
+ // is a real state — a first boot before the first domain is added — and the
372
+ // only honest thing to do with it is refuse every recipient. Refusing it at
373
+ // construction left the operator with one spelling that started a server
374
+ // (omitting the option), and that spelling used to skip the relay check
375
+ // entirely, so the only way to get a listener was to get one that accepted
376
+ // everything. An allowlist that disappears when it is empty is a firewall
377
+ // rule set that opens when the last rule is deleted.
378
+ if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains)) {
372
379
  throw new MailServerMxError("mail-server-mx/bad-opts",
373
- "mail.server.mx.create: localDomains must be a non-empty array if provided");
380
+ "mail.server.mx.create: localDomains must be an array of domain strings " +
381
+ "(an empty array means this server hosts no domains and refuses every recipient)");
374
382
  }
375
383
  if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
376
384
  throw new MailServerMxError("mail-server-mx/bad-opts",
@@ -559,15 +567,17 @@ function create(opts) {
559
567
  function _handleConnection(socket) {
560
568
  // 421 4.7.0 — transient refusal; sender retries elsewhere or later.
561
569
  // RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
562
- var remoteAddress = mailServerNet.admitConnection(socket, rateLimit, _emit, {
570
+ var accepted = mailServerNet.acceptConnection(socket, {
571
+ rateLimit: rateLimit,
572
+ connections: connections,
573
+ emit: _emit,
563
574
  refusedEvent: "mail.server.mx.rate_limit_refused",
564
575
  refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
576
+ idPrefix: "mxconn-",
565
577
  });
566
- if (remoteAddress === null) return;
567
- socket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
568
-
569
- var connectionId = "mxconn-" + bCrypto.generateToken(8); // connection-id length
570
- connections.add(socket);
578
+ if (accepted === null) return;
579
+ var remoteAddress = accepted.remoteAddress;
580
+ var connectionId = accepted.connectionId;
571
581
 
572
582
  // Backpressure observer — `_writeReply` flips `_bpEmitted` after
573
583
  // the first audit emission per socket to bound the audit volume.
@@ -597,6 +607,15 @@ function create(opts) {
597
607
  // message. Decode to string only for the per-command line parse.
598
608
  var lineBuffer = Buffer.alloc(0);
599
609
  var bodyCollector = null;
610
+ // Watches the DATA body for its terminator and the smuggling shape as bytes
611
+ // arrive, so neither screen re-reads what it has already seen. Lives exactly
612
+ // as long as bodyCollector.
613
+ var bodyScanner = null;
614
+ // The slow-loris byte-rate floor, measured over bounded windows so an early
615
+ // burst cannot buy credit for a slow tail.
616
+ var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
617
+ // Every byte this connection has received, counted once at the wire funnel.
618
+ var wireBytes = 0;
600
619
  var inDataBody = false;
601
620
  // Async command pump: gates (HELO / RBL / greylist / envelope /
602
621
  // DMARC) may await DNS or a store, so command handling is async.
@@ -621,10 +640,9 @@ function create(opts) {
621
640
  _closeConnection(socket);
622
641
  });
623
642
 
624
- socket.on("close", function () {
625
- connClosed = true;
626
- connections.delete(socket);
627
- });
643
+ // The set entry and the rate-limit slot are released by trackConnection;
644
+ // this handler carries only the per-transaction flag the drain reads.
645
+ socket.on("close", function () { connClosed = true; });
628
646
 
629
647
  _emit("mail.server.mx.connect", {
630
648
  connectionId: state.id,
@@ -647,6 +665,12 @@ function create(opts) {
647
665
  // 421 path. `activeSock` is whichever socket is current (plaintext or
648
666
  // TLS) so the 421/close lands on the right transport.
649
667
  function _feedChunk(activeSock, chunk) {
668
+ // Every wire byte, counted once, on the single funnel both the plaintext
669
+ // and the post-STARTTLS socket feed. The rate window takes its baseline
670
+ // from this, so the measurement is "bytes since the transfer opened"
671
+ // rather than "bytes the body parser happened to see" — the distinction
672
+ // that let the sibling listener's count go flat across a window roll.
673
+ wireBytes += chunk.length;
650
674
  pumpChain = pumpChain.then(function () {
651
675
  if (connClosed) return undefined;
652
676
  return _ingestBytes(state, activeSock, chunk);
@@ -665,6 +689,27 @@ function create(opts) {
665
689
 
666
690
  // ---- Byte-level ingestion --------------------------------------------
667
691
  async function _ingestBytes(state, socket, chunk) {
692
+ // The body-rate floor is enforced HERE, on every inbound byte, rather
693
+ // than inside the DATA handler below. A check reached only from a body
694
+ // handler is one the peer chooses whether to reach: on the sibling
695
+ // listener the same floor was skipped first by using BDAT, then by a
696
+ // zero-length chunk, then by interleaving NOOP, each of which resets the
697
+ // socket idle timer without passing through a body handler. What a peer
698
+ // cannot do is hold the connection without sending bytes, and every byte
699
+ // arrives here.
700
+ if (inDataBody && bodyRateWindow.starved(wireBytes, Date.now())) {
701
+ _emit("mail.server.mx.data_refused",
702
+ { connectionId: state.id, reason: "body-rate-below-floor",
703
+ minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
704
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
705
+ "4.7.0 Message body arriving below the minimum rate; closing connection");
706
+ _resetTransaction(state);
707
+ inDataBody = false;
708
+ bodyCollector = null;
709
+ bodyScanner = null;
710
+ _closeConnection(socket);
711
+ return;
712
+ }
668
713
  if (inDataBody) {
669
714
  // DATA body — accumulate via boundedChunkCollector, watch for
670
715
  // canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
@@ -679,13 +724,25 @@ function create(opts) {
679
724
  _resetTransaction(state);
680
725
  inDataBody = false;
681
726
  bodyCollector = null;
727
+ bodyScanner = null;
682
728
  return;
683
729
  }
684
- var collected = bodyCollector.result();
685
- // Smuggling detector bare LF dot-line in body before the
686
- // CRLF dot terminator. Refuse the whole transaction; emit
687
- // smuggling-detected audit.
688
- if (guardSmtpCommand.detectBodySmuggling(collected)) {
730
+ // Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
731
+ // Re-deriving the whole accumulated body per chunk (`result()` is a
732
+ // fresh concat of everything received so far) and scanning it twice
733
+ // made acceptance quadratic in the message size. The byte cap bounds
734
+ // BYTES, not processor time, so a message well inside maxMessageBytes
735
+ // still cost 4949 ms at 8 MiB against 143 ms at 1 MiB — and on this
736
+ // listener that is reachable unauthenticated. `result()` is now called
737
+ // ONCE, when the terminator is found.
738
+ //
739
+ // The slow-loris floor is NOT applied here — it runs at the top of
740
+ // _ingestBytes, where every inbound byte passes whatever command it
741
+ // belongs to.
742
+ var seen = bodyScanner.push(chunk);
743
+ // Smuggling detector — bare LF dot-line in body before the CRLF dot
744
+ // terminator. Refuse the whole transaction; emit a smuggling audit.
745
+ if (seen.smuggling) {
689
746
  _emit("mail.server.mx.smtp_smuggling_detected",
690
747
  { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
691
748
  "denied");
@@ -694,14 +751,15 @@ function create(opts) {
694
751
  _resetTransaction(state);
695
752
  inDataBody = false;
696
753
  bodyCollector = null;
754
+ bodyScanner = null;
697
755
  return;
698
756
  }
699
757
  // Canonical \r\n.\r\n terminator?
700
- var endIdx = safeSmtp.findDotTerminator(collected);
701
- if (endIdx !== -1) {
702
- var body = collected.subarray(0, endIdx);
758
+ if (seen.terminatorAt !== -1) {
759
+ var body = bodyCollector.result().subarray(0, seen.terminatorAt);
703
760
  inDataBody = false;
704
761
  bodyCollector = null;
762
+ bodyScanner = null;
705
763
  await _finalizeDataBody(state, socket, body);
706
764
  }
707
765
  return;
@@ -868,6 +926,7 @@ function create(opts) {
868
926
  // shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
869
927
  lineBuffer = Buffer.alloc(0);
870
928
  bodyCollector = null;
929
+ bodyScanner = null;
871
930
  inDataBody = false;
872
931
  mailServerTls.upgradeSocket({
873
932
  plainSocket: socket,
@@ -1003,17 +1062,21 @@ function create(opts) {
1003
1062
  }
1004
1063
  // Local-domain check — refuse non-local recipients unless the
1005
1064
  // operator explicitly allowed relay for this scope.
1006
- if (localDomains.length > 0) {
1007
- if (localDomains.indexOf(rcptDomain) === -1 &&
1008
- !_isRelayAllowed(state.remoteAddress, rcpt)) {
1009
- rateLimit.noteRcptFailure(state.remoteAddress);
1010
- _trackRefusedRcpt(state, rcpt, "relay-denied");
1011
- _emit("mail.server.mx.relay_refused",
1012
- { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1013
- remoteAddress: state.remoteAddress }, "denied");
1014
- _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
1015
- return;
1016
- }
1065
+ //
1066
+ // Run UNCONDITIONALLY. This used to sit inside `if (localDomains.length
1067
+ // > 0)`, so a server hosting no domains ran no check at all and accepted
1068
+ // every recipient. An empty hosted set now refuses everything naturally,
1069
+ // which is what an empty allowlist has to mean; `relayAllowedFor` is
1070
+ // still the way to permit a scope deliberately.
1071
+ if (localDomains.indexOf(rcptDomain) === -1 &&
1072
+ !_isRelayAllowed(state.remoteAddress, rcpt)) {
1073
+ rateLimit.noteRcptFailure(state.remoteAddress);
1074
+ _trackRefusedRcpt(state, rcpt, "relay-denied");
1075
+ _emit("mail.server.mx.relay_refused",
1076
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1077
+ remoteAddress: state.remoteAddress }, "denied");
1078
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
1079
+ return;
1017
1080
  }
1018
1081
  // RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
1019
1082
  // IP. The verdict is per-connection, so it's evaluated once and
@@ -1131,6 +1194,8 @@ function create(opts) {
1131
1194
  sizeCode: "mail-server-mx/body-too-large",
1132
1195
  sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
1133
1196
  });
1197
+ bodyScanner = safeSmtp.createBodyScanner();
1198
+ bodyRateWindow.start(Date.now(), wireBytes);
1134
1199
  }
1135
1200
 
1136
1201
  async function _finalizeDataBody(state, socket, body) {
@@ -1215,6 +1280,11 @@ function create(opts) {
1215
1280
  dkim: dkimSummary,
1216
1281
  dmarc: inboundVerdict.dmarc.result,
1217
1282
  arc: inboundVerdict.arc && inboundVerdict.arc.chainStatus,
1283
+ // The status alone cannot tell an operator reading the audit
1284
+ // whether a chain failed because a seal was bad or because a
1285
+ // resolver was down — the two want opposite responses.
1286
+ arcReason: inboundVerdict.arc && inboundVerdict.arc.reason,
1287
+ arcTransient: !!(inboundVerdict.arc && inboundVerdict.arc.transient),
1218
1288
  action: envAction,
1219
1289
  mode: envelopeGate.mode,
1220
1290
  }, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
@@ -1257,6 +1327,16 @@ function create(opts) {
1257
1327
  spf: inboundVerdict.spf,
1258
1328
  dkim: inboundVerdict.dkim,
1259
1329
  dmarc: inboundVerdict.dmarc,
1330
+ // The ARC chain sits beside the other three because it was
1331
+ // computed with them. It used to reach the delivered message as an
1332
+ // `arc=` token and the audit event as a status, and stop there —
1333
+ // so a consumer wanting to act on it re-parsed a header the
1334
+ // pipeline had just written. The header is also lossier than the
1335
+ // verdict: RFC 8601 has one `arc=fail` token, while the verdict
1336
+ // separates a chain that is structurally incomplete from one whose
1337
+ // seal did not verify, and only the second says anything about the
1338
+ // sender.
1339
+ arc: inboundVerdict.arc,
1260
1340
  from: inboundVerdict.from,
1261
1341
  action: envAction,
1262
1342
  mode: envelopeGate.mode,
@@ -1370,6 +1450,7 @@ function create(opts) {
1370
1450
  // explicit 0 (only an OMITTED port falls back to the default).
1371
1451
  var _tcpListener = mailServerNet.createTcpListener(net, {
1372
1452
  defaultPort: 25, // SMTP MX port (IANA)
1453
+ maxConnections: opts.maxConnections,
1373
1454
  handleConnection: _handleConnection,
1374
1455
  errorFactory: function (code, message) { return new MailServerMxError("mail-server-mx/" + code, message); },
1375
1456
  emit: _emit,
@@ -3,6 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  var codepointClass = require("./codepoint-class");
6
+ var bCrypto = require("./crypto");
6
7
 
7
8
  // mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
8
9
  // servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
@@ -16,6 +17,14 @@ var codepointClass = require("./codepoint-class");
16
17
  // EACCES) rejects the listen promise instead of crashing the process. That, plus
17
18
  // the listening/server state, is what createTcpListener owns.
18
19
 
20
+ // The listener's own ceiling when an operator names none. Every mail listener
21
+ // shares it, so a deployment that raises or lowers it does so in one place and
22
+ // the five listeners cannot drift apart on what "too many" means. Set well
23
+ // above any single-host mail deployment's working set and far below what an
24
+ // unbounded accept loop will take: the point is that a ceiling EXISTS, since
25
+ // the per-address cap alone leaves the total at (cap x source addresses).
26
+ var DEFAULT_MAX_CONNECTIONS = 1024;
27
+
19
28
  // createTcpListener(net, cfg) — build a listener lifecycle.
20
29
  // cfg.defaultPort port used when listenOpts.port is omitted (an explicit
21
30
  // 0 is honored, for an ephemeral test bind).
@@ -26,6 +35,15 @@ var codepointClass = require("./codepoint-class");
26
35
  // cfg.listeningEvent the "...listening" audit action.
27
36
  // cfg.listeningExtra optional () => object merged onto the listening event
28
37
  // payload (Submission reports implicitTls).
38
+ // cfg.maxConnections the listener's own ceiling on concurrently accepted
39
+ // sockets. The per-address cap in b.mail.server.rateLimit
40
+ // bounds ONE peer; it says nothing about how many peers
41
+ // there are, so the process-wide total was the per-address
42
+ // cap times however many source addresses the caller could
43
+ // speak from — a number a botnet, a NAT pool or a single
44
+ // v6 /64 makes large. Enforced by the runtime, which closes
45
+ // the excess socket before handleConnection ever sees it,
46
+ // so a refusal costs no descriptor and no state machine.
29
47
  // Returns { listen, getServer, isListening, markClosed } — the server wires its
30
48
  // own close() through getServer()/isListening()/markClosed().
31
49
  function createTcpListener(net, cfg) {
@@ -40,6 +58,7 @@ function createTcpListener(net, cfg) {
40
58
  var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
41
59
  var address = listenOpts.address || "0.0.0.0";
42
60
  server = net.createServer(function (socket) { cfg.handleConnection(socket); });
61
+ server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
43
62
  return new Promise(function (resolve, reject) {
44
63
  server.once("error", reject);
45
64
  server.listen(port, address, function () {
@@ -110,6 +129,7 @@ function createStoreServer(net, cfg) {
110
129
  var ErrorClass = cfg.errorClass;
111
130
  var listener = createTcpListener(net, {
112
131
  defaultPort: cfg.defaultPort,
132
+ maxConnections: cfg.maxConnections,
113
133
  handleConnection: cfg.handleConnection,
114
134
  errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
115
135
  emit: cfg.emit,
@@ -122,7 +142,137 @@ function createStoreServer(net, cfg) {
122
142
  closedEvent: cfg.eventBase + ".closed",
123
143
  });
124
144
  }
125
- return { listen: listener.listen, close: close };
145
+ return {
146
+ listen: listener.listen,
147
+ close: close,
148
+ connectionCount: function () { return cfg.connections.size; },
149
+ };
150
+ }
151
+
152
+ // trackConnection(socket, cfg) — the other half of admitConnection, and the
153
+ // reason it is here rather than written out per listener.
154
+ //
155
+ // A connection occupies two ledgers: the rate limiter's per-address count, and
156
+ // the listener's live-socket set that shutdown drains. Both are released by the
157
+ // SAME event — the socket closing — and it does not matter who closed it. Five
158
+ // listeners each wrote that pairing by hand and one of them registered only the
159
+ // release, so a peer that opened a connection, took the greeting and dropped TCP
160
+ // freed its rate-limit slot (and could reconnect at once) while its set entry
161
+ // stayed forever. Nothing authenticates before that point, so the growth was
162
+ // unauthenticated and unbounded.
163
+ //
164
+ // Registering both in one handler is what makes the pair impossible to
165
+ // half-write.
166
+ // cfg.connections the listener's live-socket Set.
167
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
168
+ // cfg.remoteAddress the address admitConnection returned.
169
+ // cfg.closeSource optional socket whose "close" drives teardown, when the
170
+ // tracked socket is a wrapper (Submission's implicit-TLS
171
+ // path tracks the TLSSocket but the raw socket is the one
172
+ // that carries the FIN).
173
+ function trackConnection(socket, cfg) {
174
+ cfg.connections.add(socket);
175
+ var source = cfg.closeSource || socket;
176
+ source.once("close", function () {
177
+ cfg.rateLimit.releaseConnection(cfg.remoteAddress);
178
+ cfg.connections.delete(socket);
179
+ });
180
+ }
181
+
182
+ // createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
183
+ // windows instead of the whole transfer.
184
+ //
185
+ // A lifetime average lets an early burst pay for an arbitrarily slow tail: at
186
+ // the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
187
+ // six, which a peer spends holding a connection and its slot in the per-address
188
+ // cap while sending a byte at a time. The floor was enforced and still
189
+ // bypassable, which is the worse of the two states — it reads as covered.
190
+ //
191
+ // Each window has to meet the floor on its own, so nothing sent earlier pays
192
+ // for what is sent now. A window only rolls forward once it has run long enough
193
+ // to be a real judgement; inside that stretch no verdict is reached, so a
194
+ // sender pausing to read from its own spool is not cut off for the pause, and a
195
+ // fast chunk cannot keep resetting the clock to dodge the next measurement.
196
+ //
197
+ // `now` is a parameter rather than a call to the clock so the whole thing can
198
+ // be driven across days of simulated time without waiting for them.
199
+ // rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
200
+ function createBodyRateWindow(rateLimit) {
201
+ var windowStart = 0;
202
+ var windowBytes = 0;
203
+ return {
204
+ // Open the first window. `bytesSeen` is the caller's running total AT THIS
205
+ // MOMENT, which becomes the baseline every later reading is measured
206
+ // against — so the caller is free to keep one connection-lifetime counter
207
+ // incremented at the wire boundary rather than a per-transfer one. That
208
+ // matters where a listener re-feeds part of a chunk through its own parser:
209
+ // a counter maintained inside the parser credits those bytes twice, and a
210
+ // peer that pipelines a one-byte payload with the next command gets roughly
211
+ // double the rate it is actually sending.
212
+ start: function (now, bytesSeen) {
213
+ windowStart = now;
214
+ windowBytes = bytesSeen || 0;
215
+ },
216
+ // `bytesSeen` is the running total for the whole body; the window's own
217
+ // count is derived, so the caller keeps one counter rather than two.
218
+ starved: function (bytesSeen, now) {
219
+ var elapsed = now - windowStart;
220
+ if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
221
+ // The limiter says how long a window must run before rolling, because the
222
+ // limiter is what decides when a measurement is old enough to mean
223
+ // something. Rolling on a number held HERE would ask a limiter that
224
+ // judges over a longer stretch only ever before it can answer: every call
225
+ // returning "too early", the window resetting underneath it, and its rate
226
+ // protection silently disabled while looking wired.
227
+ if (elapsed >= rateLimit.bodyRateWindowMs()) {
228
+ windowStart = now;
229
+ windowBytes = bytesSeen;
230
+ }
231
+ return false;
232
+ },
233
+ };
234
+ }
235
+
236
+ // acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
237
+ // accept and its own state machine: gate the address, mint the connection id,
238
+ // wrap the socket if the protocol starts in TLS, and enter both ledgers.
239
+ //
240
+ // Every listener performed these four steps in the same order, and the parts
241
+ // that differ between them are three strings and an optional wrap. Keeping the
242
+ // order in one place is what stops a fifth listener from being written with one
243
+ // of the steps missing, which is how the tracking-set entry and the rate-limit
244
+ // slot came apart in the first place.
245
+ //
246
+ // Returns null when the address was refused — the caller returns immediately;
247
+ // the refusal line and teardown are already done.
248
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
249
+ // cfg.connections the listener's live-socket Set.
250
+ // cfg.emit the listener's audit emitter.
251
+ // cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
252
+ // cfg.refusalLine the protocol's refusal bytes.
253
+ // cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
254
+ // cfg.wrap optional (rawSocket) => socket, for a protocol that
255
+ // starts inside TLS. The RAW socket still drives teardown:
256
+ // a handshake that never completes closes it without the
257
+ // wrapper ever being established.
258
+ function acceptConnection(rawSocket, cfg) {
259
+ var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
260
+ refusedEvent: cfg.refusedEvent,
261
+ refusalLine: cfg.refusalLine,
262
+ });
263
+ if (remoteAddress === null) return null;
264
+ var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
265
+ trackConnection(socket, {
266
+ connections: cfg.connections,
267
+ rateLimit: cfg.rateLimit,
268
+ remoteAddress: remoteAddress,
269
+ closeSource: socket === rawSocket ? null : rawSocket,
270
+ });
271
+ return {
272
+ socket: socket,
273
+ remoteAddress: remoteAddress,
274
+ connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
275
+ };
126
276
  }
127
277
 
128
278
  // admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
@@ -299,6 +449,10 @@ module.exports = {
299
449
  runSaslStep: runSaslStep,
300
450
  createStoreServer: createStoreServer,
301
451
  admitConnection: admitConnection,
452
+ trackConnection: trackConnection,
453
+ acceptConnection: acceptConnection,
454
+ createBodyRateWindow: createBodyRateWindow,
455
+ DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
302
456
  validateDomainHardened: validateDomainHardened,
303
457
  saslChallengeOrNull: saslChallengeOrNull,
304
458
  replyTextOrFallback: replyTextOrFallback,