@bumail/smtp 0.4.0 → 0.6.0

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.
Files changed (43) hide show
  1. package/README.md +64 -5
  2. package/dist/chunks/{index-753q702a.js → index-n4xwep5m.js} +3 -3
  3. package/dist/chunks/{index-753q702a.js.map → index-n4xwep5m.js.map} +3 -3
  4. package/dist/client/index.js +1 -1
  5. package/dist/errors.d.ts +3 -1
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +617 -71
  10. package/dist/index.js.map +15 -8
  11. package/dist/server/admission.d.ts +37 -0
  12. package/dist/server/admission.d.ts.map +1 -0
  13. package/dist/server/listener.d.ts +17 -8
  14. package/dist/server/listener.d.ts.map +1 -1
  15. package/dist/server/options.d.ts +21 -1
  16. package/dist/server/options.d.ts.map +1 -1
  17. package/dist/server/proxy/address.d.ts +17 -0
  18. package/dist/server/proxy/address.d.ts.map +1 -0
  19. package/dist/server/proxy/header.d.ts +26 -0
  20. package/dist/server/proxy/header.d.ts.map +1 -0
  21. package/dist/server/proxy/reader.d.ts +15 -0
  22. package/dist/server/proxy/reader.d.ts.map +1 -0
  23. package/dist/server/proxy/tls.d.ts +50 -0
  24. package/dist/server/proxy/tls.d.ts.map +1 -0
  25. package/dist/server/proxy/trusted.d.ts +9 -0
  26. package/dist/server/proxy/trusted.d.ts.map +1 -0
  27. package/dist/server/raw-socket.d.ts +9 -0
  28. package/dist/server/raw-socket.d.ts.map +1 -0
  29. package/dist/server/received.d.ts +2 -0
  30. package/dist/server/received.d.ts.map +1 -1
  31. package/dist/server/server.d.ts +9 -1
  32. package/dist/server/server.d.ts.map +1 -1
  33. package/dist/server/settings.d.ts +5 -0
  34. package/dist/server/settings.d.ts.map +1 -1
  35. package/dist/server/tls-context.d.ts +46 -0
  36. package/dist/server/tls-context.d.ts.map +1 -0
  37. package/dist/server/transport.d.ts +2 -2
  38. package/dist/server/transport.d.ts.map +1 -1
  39. package/docs/README.md +2 -2
  40. package/docs/guide.md +417 -13
  41. package/docs/roadmap.md +19 -0
  42. package/docs/troubleshooting.md +368 -1
  43. package/package.json +1 -1
package/docs/guide.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Guide
2
2
 
3
3
  How to run `@bumail/smtp` and what it does on the wire: the session and its
4
- replies, the hooks, what a delivered message looks like, TLS, sending mail
5
- with the client, and the RFCs behind each behaviour.
4
+ replies, the hooks, what a delivered message looks like, TLS, running
5
+ behind a TCP proxy, sending mail with the client, and the RFCs behind each
6
+ behaviour.
6
7
 
7
8
  - [The smallest server](#the-smallest-server)
8
9
  - [Options](#options)
@@ -14,7 +15,9 @@ with the client, and the RFCs behind each behaviour.
14
15
  - [Delivering into @bumail/store](#delivering-into-bumailstore)
15
16
  - [Authentication](#authentication)
16
17
  - [TLS](#tls)
18
+ - [Renewing the certificate without a restart](#renewing-the-certificate-without-a-restart)
17
19
  - [Limits](#limits)
20
+ - [Running behind a TCP proxy](#running-behind-a-tcp-proxy)
18
21
  - [Sending mail: the client](#sending-mail-the-client)
19
22
  - [Protocol helpers](#protocol-helpers)
20
23
  - [The RFCs implemented](#the-rfcs-implemented)
@@ -69,7 +72,8 @@ export interface SmtpServer {
69
72
  | `maxRecipients` | `number` | `100` | recipients per message |
70
73
  | `maxConnections` | `number` | `1000` | open connections at once |
71
74
  | `maxConnectionsPerClient` | `number` | `10` | open connections at once from one client: an IPv4 address, or an IPv6 /64; see [Limits](#limits) |
72
- | `handshakeTimeout` | `number` | `10` | seconds a client on implicit TLS has to complete its handshake, from the TCP connection on; past it, the socket is closed. See [TLS](#tls) |
75
+ | `handshakeTimeout` | `number` | `10` | seconds a client on implicit TLS has to complete its handshake, from the TCP connection on; past it, the socket is closed. See [TLS](#tls). With `proxyProtocol`, also the seconds a trusted proxy has to send its PROXY header; see [Running behind a TCP proxy](#running-behind-a-tcp-proxy) |
76
+ | `proxyProtocol` | `{ trusted: string[] }` | none, off | reads the PROXY protocol, v1 and v2, from the peers `trusted` lists (IPv4 and IPv6 addresses and CIDRs), so the client's address replaces the proxy's; see [Running behind a TCP proxy](#running-behind-a-tcp-proxy) |
73
77
  | `maxErrors` | `number` | `10` | failed commands before the server hangs up |
74
78
  | `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
75
79
  | `hookTimeout` | `number` | `60` | seconds a hook, `authenticate` or `localDomains` has to settle, and `onData` to read on; past it, `451 4.3.0`. At most 2 147 483, what a timer can wait |
@@ -115,9 +119,16 @@ await server.listen({ port: 25 });
115
119
  | `createSmtpServer(): hookTimeout must be at most 2147483 seconds, not <value>` | `hookTimeout` past what `setTimeout` can wait (about 24.8 days) |
116
120
  | `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
117
121
  | `createSmtpServer(): greetingDelay (<n> s) must be shorter than timeout (<n> s), or every client times out before the greeting` | `greetingDelay` as long as `timeout`, or longer |
122
+ | `createSmtpServer(): proxyProtocol.trusted must list the addresses or CIDRs of the proxies, at least one` | `proxyProtocol` without `trusted`, with an empty one, or one that is not an array |
123
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" is neither an IP address nor a CIDR` | an entry that is a host name, a malformed address, an address with a zone (`fe80::1%eth0`), or has more than one `/` |
124
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" has a prefix length out of range` | a prefix past 32 for IPv4 or 128 for IPv6, not a number, or below 96 for an IPv4-mapped address (`::ffff:10.0.0.0/8`) |
125
+ | `createSmtpServer(): proxyProtocol.trusted: "<entry>" has a prefix length of 0, which trusts every peer` | `0.0.0.0/0`, `::/0`, `::ffff:0.0.0.0/96`: the option would trust anyone |
126
+ | `createSmtpServer(): proxyProtocol.trusted: <value> is not a string` | an entry that is a number, `null`, an object |
118
127
 
119
128
  `listen` a second time throws an `SmtpError` with `code:
120
- 'ALREADY_LISTENING'`: create another server for another port.
129
+ 'ALREADY_LISTENING'`: create another server for another port. A `stop()`
130
+ called before `listen` resolved makes that `listen` reject with
131
+ `code: 'STOPPED'`, and nothing is left listening.
121
132
 
122
133
  What to do about each is in [Troubleshooting](troubleshooting.md).
123
134
 
@@ -474,7 +485,8 @@ Subject: hi
474
485
  body
475
486
  ```
476
487
 
477
- - `from` is the EHLO or HELO name, then the client's address in brackets.
488
+ - `from` is the EHLO or HELO name, then the client's address in brackets:
489
+ an IPv6 address is the literal of RFC 5321 §4.1.3, `([IPv6:2001:db8::7])`.
478
490
  Any character of the name that is not printable ASCII is written `?`.
479
491
  - `with` is the protocol of RFC 3848: `SMTP` after HELO; `ESMTP` after EHLO,
480
492
  `ESMTPS` with TLS, `ESMTPA` with AUTH, `ESMTPSA` with both.
@@ -770,9 +782,10 @@ export interface TlsOptions {
770
782
  }
771
783
  ```
772
784
 
773
- `key` and `cert` are PEM, as `Bun.listen` takes them. Read them once, at
785
+ `key` and `cert` are PEM, as `Bun.listen` takes them. Read them at
774
786
  start-up, with `Bun.file().text()`; a missing file then fails before the
775
- server listens, not at a client's first STARTTLS:
787
+ server listens, not at a client's first STARTTLS. A renewed pair is
788
+ applied with `setTls`, [below](#renewing-the-certificate-without-a-restart):
776
789
 
777
790
  ```ts
778
791
  import { createSmtpServer } from '@bumail/smtp';
@@ -829,10 +842,17 @@ reads them over TLS and `session.secure` is already `true`. A handshake
829
842
  that fails is closed by Bun at once and counted out. A STARTTLS handshake
830
843
  is bounded by the idle `timeout`, the connection already counted.
831
844
 
832
- Bun 1.4.2 calls a TLS listener's `open` only once the handshake completed
833
- unless the listener has a `handshake` handler; with one, `open` comes at
834
- the TCP connection and the socket's timer runs during the handshake. The
835
- server sets one for that reason.
845
+ The listener of an implicit TLS port is a clear one, and each socket is
846
+ upgraded to TLS (`socket.upgradeTLS`) as it opens, with the pair in use
847
+ at that moment: that is what lets `setTls` apply to the next connection
848
+ (see [below](#renewing-the-certificate-without-a-restart)). The upgraded
849
+ socket calls `open` at the TCP connection, with the socket's timer
850
+ running during the handshake, which is how a stuck handshake is counted
851
+ and closed.
852
+
853
+ Behind a TCP proxy that sends the PROXY protocol, implicit TLS starts after
854
+ the proxy's header, and runs differently: see
855
+ [Running behind a TCP proxy](#running-behind-a-tcp-proxy).
836
856
 
837
857
  An MX takes mail in clear as well as encrypted: senders on the Internet that
838
858
  cannot do TLS still deliver. Whether to refuse them is policy — check
@@ -897,6 +917,67 @@ await createSmtpServer(submission).listen({ port: 587 });
897
917
  await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
898
918
  ```
899
919
 
920
+ ### Renewing the certificate without a restart
921
+
922
+ A certificate that is renewed — by certbot, by Traefik's dump, by an ACME
923
+ client — should take effect without a restart, and without dropping the
924
+ sessions open. `setTls({ key, cert })` does that:
925
+
926
+ ```ts
927
+ const mx = createSmtpServer({ hostname: 'mx.example.com', localDomains: ['example.com'], tls, onData });
928
+ await mx.listen({ port: 25 });
929
+
930
+ // Later, once the files changed:
931
+ await mx.setTls({
932
+ key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
933
+ cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(),
934
+ });
935
+ ```
936
+
937
+ - **What changes**: the next STARTTLS upgrade, and on implicit TLS the next
938
+ connection, use the new pair. A session already encrypted keeps the TLS
939
+ it has until it ends, and goes on working; a handshake that began
940
+ before the call may still finish on the old pair.
941
+ - **It is checked first.** `setTls` reads both (a `Bun.file` included) and
942
+ makes a TLS context of them, as `listen()` does on implicit TLS. A pair
943
+ that cannot be read, is not PEM or is a key for another certificate
944
+ rejects with an `SmtpError`, `INVALID_OPTION`, message `setTls(): tls:
945
+ { key, cert } cannot be used: …`, the reason as its `cause`; the old
946
+ pair stays in use. It does not check that the certificate has not
947
+ expired or names the host: that is the caller's.
948
+ - **One call per server.** The MX on 25, submission on 587 and the
949
+ implicit TLS server on 465 are three servers, each with its own
950
+ `setTls`. Call it on all of them with the same pair.
951
+ - **Calls apply in order.** Two `setTls` in flight, the first reading
952
+ files, end on the second's pair: the later call wins. A key or
953
+ certificate that is empty is refused like any pair that cannot be used.
954
+ - **Before `listen()` too**: the pair set is the one served. A server made
955
+ without `tls` has none to replace: `setTls` rejects with
956
+ `INVALID_OPTION`.
957
+ - **A file read at `setTls`, not at each connection.** Passing a
958
+ `Bun.file` reads it once, when `setTls` runs: to take a later change,
959
+ call `setTls` again.
960
+
961
+ How it works on each kind of port, measured on Bun 1.4.2:
962
+
963
+ - **STARTTLS**: the options of `socket.upgradeTLS` are read for each
964
+ upgrade, so the server swaps the pair it keeps.
965
+ - **Native implicit TLS**: a `Bun.listen` TLS listener keeps the TLS
966
+ context it was created with. `listener.reload()` takes handlers only,
967
+ and a `tls` given to it is ignored (a new connection still got the old
968
+ certificate). The port listens in clear instead, and every socket is
969
+ upgraded with `socket.upgradeTLS` as it opens, with the pair in use
970
+ then, which is the STARTTLS mechanism from the first byte. The
971
+ encrypted bytes also pass through the clear socket's handler, which
972
+ drops them.
973
+ - **Implicit TLS behind `proxyProtocol`** already runs on `node:tls`: the
974
+ server swaps the secure context its new sockets are given.
975
+ - **A second listener on the same port** was not used: `Bun.listen` takes
976
+ `reusePort`, so a new listener could bind beside the old one and the old
977
+ one stop, but both must have opted in from the start, and a
978
+ connection waiting to be accepted by the old one when it stops is reset.
979
+ Upgrading in `open` has neither cost.
980
+
900
981
  ## Limits
901
982
 
902
983
  | Limit | Default | Past it |
@@ -905,7 +986,7 @@ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465
905
986
  | `maxRecipients` | 100, the least RFC 5321 §4.5.3.1.8 asks a server to take | `452 4.5.3` for each extra recipient (RFC 5321 §4.5.3.1.10); the client sends the rest in another transaction |
906
987
  | `maxConnections` | 1000 | `421 4.3.2` and the server hangs up, before the greeting and before `onConnect` |
907
988
  | `maxConnectionsPerClient` | 10 | `421 4.7.0 <hostname> Too many connections from your address, try later` and the server hangs up, before the greeting and before `onConnect` |
908
- | `handshakeTimeout` | 10 seconds, on implicit TLS; ticks of about 4 s, as `timeout` | the socket is closed without a reply: there is no TLS to write one on |
989
+ | `handshakeTimeout` | 10 seconds, on implicit TLS; ticks of about 4 s, as `timeout`. For a trusted proxy's PROXY header, to the millisecond | the socket is closed without a reply: there is no TLS to write one on, and a proxy's socket has no client yet |
909
990
  | `maxErrors` | 10 | `421 4.7.0` and the server hangs up |
910
991
  | `timeout` | 300 seconds, RFC 5321 §4.5.3.2.7's, counted from the client's last byte, or from the 220; Bun's socket timer ticks in steps of about 4 s, so the hang-up comes up to that much later | `421 4.4.2` and the server hangs up |
911
992
  | `hookTimeout` | 60 seconds | `451 4.3.0` for that command; `onError` gets an `SmtpError` `HOOK_TIMEOUT` |
@@ -1025,7 +1106,9 @@ TLS buffer. So a `221` behind replies that are never read holds the slot
1025
1106
  Stop a server with `stop()`; `stop(true)` also hangs up on every client,
1026
1107
  those moved to TLS by STARTTLS included, as the idle timeout does; a
1027
1108
  socket still in its implicit TLS handshake is reset, with no 421, having
1028
- no TLS to write one on. `connections` counts the clients currently connected:
1109
+ no TLS to write one on, and so is a socket still awaiting a proxy's PROXY
1110
+ header, nothing written and nothing passed to `onError`. `connections`
1111
+ counts the clients currently connected:
1029
1112
 
1030
1113
  ```ts
1031
1114
  import { createSmtpServer } from '@bumail/smtp';
@@ -1045,6 +1128,326 @@ process.on('SIGTERM', () => {
1045
1128
  });
1046
1129
  ```
1047
1130
 
1131
+ ## Running behind a TCP proxy
1132
+
1133
+ `proxyProtocol` lets the server sit behind a TCP proxy — a Traefik TCP
1134
+ router, HAProxy, a cloud load balancer — and still see each client's own
1135
+ address, as the proxy reports it with the PROXY protocol (HAProxy's
1136
+ `proxy-protocol.txt`, versions 1 and 2):
1137
+
1138
+ ```ts
1139
+ import { createSmtpServer } from '@bumail/smtp';
1140
+
1141
+ const server = createSmtpServer({
1142
+ hostname: 'mx.example.com',
1143
+ localDomains: ['example.com'],
1144
+ proxyProtocol: { trusted: ['10.0.0.5'] },
1145
+ onConnect: (session) => console.log('client', session.remoteAddress), // the client's, not 10.0.0.5
1146
+ async onData(message) {
1147
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1148
+ },
1149
+ });
1150
+
1151
+ await server.listen({ port: 25 });
1152
+ ```
1153
+
1154
+ ```ts
1155
+ export interface ProxyProtocolOptions {
1156
+ /** The proxies' addresses or CIDRs, IPv4 or IPv6: `['10.0.0.5', '172.16.0.0/12']`. */
1157
+ readonly trusted: readonly string[];
1158
+ }
1159
+
1160
+ export interface SmtpServerOptions {
1161
+ // …
1162
+ readonly proxyProtocol?: ProxyProtocolOptions; // off by default
1163
+ readonly handshakeTimeout?: number; // also bounds a trusted proxy's header; default 10
1164
+ }
1165
+ ```
1166
+
1167
+ - [Who is trusted](#who-is-trusted)
1168
+ - [The header, and `handshakeTimeout`](#the-header-and-handshaketimeout)
1169
+ - [Which address the client gets](#which-address-the-client-gets)
1170
+ - [STARTTLS on 25 and 587](#starttls-on-25-and-587)
1171
+ - [Implicit TLS on 465, behind a proxy](#implicit-tls-on-465-behind-a-proxy)
1172
+ - [With Traefik](#with-traefik)
1173
+
1174
+ ### Who is trusted
1175
+
1176
+ `trusted` lists the peers whose header is read: IPv4 and IPv6 addresses,
1177
+ and CIDRs of either. A peer is the TCP address the connection comes from,
1178
+ the proxy's.
1179
+
1180
+ | Entry | Trusts |
1181
+ | --- | --- |
1182
+ | `'10.0.0.5'`, `'2001:db8::5'` | that address alone |
1183
+ | `'172.20.0.0/16'`, `'2001:db8:1::/48'` | every address in the network |
1184
+ | `'::ffff:10.0.0.5'` | `10.0.0.5`: an IPv4-mapped entry is its IPv4 address |
1185
+ | `'::ffff:10.0.0.0/104'` | `10.0.0.0/8`: a mapped CIDR counts its prefix over the whole IPv6 address, so 96 and up |
1186
+
1187
+ A peer reported IPv4-mapped (`::ffff:10.0.0.5`), as a listener on `::`
1188
+ sees an IPv4 client, matches as its IPv4 address, so `'10.0.0.5'` covers
1189
+ both. An address with a zone (`fe80::1%eth0`) is refused, as an entry and as a
1190
+ peer: a zone names an interface of this host, not a client. A prefix of 0
1191
+ (`0.0.0.0/0`, `::/0`) is refused too, since it trusts every peer. A bad entry throws an
1192
+ `SmtpError` (`INVALID_OPTION`) from `createSmtpServer`; the messages are in
1193
+ [Options](#options).
1194
+
1195
+ **List the proxies' own addresses, and nothing else.** A trusted peer can
1196
+ claim any client address it likes, and every check that uses the address
1197
+ — `maxConnectionsPerClient`, your `onConnect` policy, the Received field —
1198
+ believes it. Never list a range clients connect from, and never
1199
+ `0.0.0.0/0` or `::/0` on a port the Internet reaches. A container network
1200
+ is safe to list only when nothing but the proxy reaches the server
1201
+ through it.
1202
+
1203
+ A peer not listed is served exactly as without the option. A PROXY header
1204
+ it sends is just bad input and never sets an address: after the greeting,
1205
+ `PROXY …` is a command the server does not know (`500`); before it, the
1206
+ client talked first (`554 <hostname> Talked before the greeting`). On
1207
+ implicit TLS the header is not a ClientHello: the handshake fails and the
1208
+ socket is closed.
1209
+
1210
+ ### The header, and `handshakeTimeout`
1211
+
1212
+ A trusted peer must send a valid header first, before any byte of the
1213
+ client's, and all of it within `handshakeTimeout` seconds (default 10) of
1214
+ the TCP connection. That bound is a timer of its own, to the millisecond,
1215
+ not Bun's socket timer with its 4-second ticks; a header trickled a byte at
1216
+ a time does not stretch it. Past it, or on a header that is not valid, the
1217
+ socket is reset:
1218
+
1219
+ - nothing is written to it, not even a `421`;
1220
+ - `onConnect` is never called, and `onError` is told nothing;
1221
+ - the proxy's address is never taken for a client's.
1222
+
1223
+ While it waits for its header, a socket holds no slot of `maxConnections`
1224
+ nor of `maxConnectionsPerClient`: it is counted, by the client's address,
1225
+ once the header named it. `stop(true)` resets the sockets still awaiting
1226
+ their header, writing nothing and calling no `onError`.
1227
+
1228
+ What is read, and what is refused:
1229
+
1230
+ | Header | Refused when |
1231
+ | --- | --- |
1232
+ | v1, `PROXY TCP4 …`, `PROXY TCP6 …`, `PROXY UNKNOWN …` | the line, CRLF included, is longer than 107 bytes; it does not end in CRLF; it holds a byte that is not printable ASCII; the protocol is none of the three; an address or a port is malformed, or of the other family than `TCP4` or `TCP6` says |
1233
+ | v2, the 12-byte signature, then version 2 | the TLVs (the extensions after the addresses) announce more than 2048 bytes — refused from the first 16 bytes, before any of it is waited for; a TLV runs past the end of the block; the command, family or transport is unknown; the address block is shorter than its family needs |
1234
+
1235
+ TLVs are checked for their shape, then skipped: the server reads none,
1236
+ SSL and authority included.
1237
+
1238
+ What the client sent in the same segment as the header, behind it, is the
1239
+ client's own. On a clear port that is bytes sent before the greeting, so
1240
+ the client is refused with `554 <hostname> Talked before the greeting`, as
1241
+ it would be without a proxy.
1242
+
1243
+ ### Which address the client gets
1244
+
1245
+ | The proxy sends | `session.remoteAddress` is |
1246
+ | --- | --- |
1247
+ | v1 `PROXY TCP4` or `PROXY TCP6` | the source address |
1248
+ | v2 `PROXY`, over TCP (STREAM), IPv4 or IPv6 | the source address |
1249
+ | v2 `LOCAL`: the proxy's own connection, a health check | the peer's, the proxy's |
1250
+ | v2 `PROXY` with an UNSPEC or UNIX address, or over UDP (DGRAM) | the peer's |
1251
+ | v1 `PROXY UNKNOWN` | the peer's |
1252
+
1253
+ An IPv6 source is written in RFC 5952's form — lower case, no leading
1254
+ zeros, the longest run of zeros as `::` — and an IPv4-mapped source as its
1255
+ plain IPv4 address: `::FFFF:C633:6407` reaches your code as
1256
+ `198.51.100.7`.
1257
+
1258
+ That address is the client's everywhere the server uses one:
1259
+ `session.remoteAddress` in `onConnect`, in every hook, in `authenticate`
1260
+ and in `onError`; the Received field (`from client.example
1261
+ ([198.51.100.7])`); and `maxConnectionsPerClient`, which counts it by
1262
+ `clientKey` as any other. So a policy written for a server without a proxy
1263
+ works unchanged behind one:
1264
+
1265
+ ```ts
1266
+ import { clientKey, createSmtpServer, reply } from '@bumail/smtp';
1267
+
1268
+ const blocked = new Set(['203.0.113.7', '2001:db8:0:0::/64']);
1269
+
1270
+ const server = createSmtpServer({
1271
+ hostname: 'mx.example.com',
1272
+ localDomains: ['example.com'],
1273
+ proxyProtocol: { trusted: ['10.0.0.5', 'fd00::5'] },
1274
+ maxConnectionsPerClient: 5, // per client behind the proxy, not for the proxy
1275
+ onConnect: (session) =>
1276
+ blocked.has(clientKey(session.remoteAddress) ?? '') ? reply(554, '5.7.1', 'Go away') : undefined,
1277
+ onData: async (message) => {
1278
+ await new Response(message.content).bytes();
1279
+ },
1280
+ });
1281
+
1282
+ await server.listen({ port: 25 });
1283
+ ```
1284
+
1285
+ ### STARTTLS on 25 and 587
1286
+
1287
+ Nothing changes for STARTTLS. The header is read before the greeting; the
1288
+ session then runs in clear, `STARTTLS` upgrades it as usual, and the
1289
+ client's address carries over to the encrypted session. The proxy passes
1290
+ the TLS records through untouched: it must not terminate TLS itself.
1291
+
1292
+ ### Implicit TLS on 465, behind a proxy
1293
+
1294
+ On an `implicitTls` port the proxy's header comes first, then the client's
1295
+ ClientHello. A Bun TLS listener would take the header for a broken
1296
+ ClientHello, and Bun's `socket.upgradeTLS` reads only bytes that arrive
1297
+ after it is called, while a proxy often sends the ClientHello in the same
1298
+ TCP segment as the header (with Bun 1.4.2, the handshake then never
1299
+ completes).
1300
+
1301
+ So with `proxyProtocol` and `implicitTls` together, the port listens in
1302
+ clear, and every connection's TLS runs through `node:tls` over the raw
1303
+ socket: after the header from a trusted proxy, from the first byte from
1304
+ anyone else. For you, it changes little:
1305
+
1306
+ - `tls.key` and `tls.cert` still take a string, bytes or a `BunFile`; they
1307
+ are read when `listen()` is called.
1308
+ - Behind the header, the TLS handshake has `handshakeTimeout` again, on
1309
+ Bun's socket timer, as on implicit TLS without a proxy; meanwhile the
1310
+ socket holds its slot, counted by the client's address.
1311
+ - A refusal by a limit, `onConnect`, the greeting and `greetingDelay` wait
1312
+ for the handshake, so the client reads them over TLS and
1313
+ `session.secure` is `true`.
1314
+
1315
+ ```ts
1316
+ import { createSmtpServer } from '@bumail/smtp';
1317
+
1318
+ const server = createSmtpServer({
1319
+ hostname: 'smtp.example.com',
1320
+ mode: 'submission',
1321
+ implicitTls: true,
1322
+ localDomains: ['example.com'],
1323
+ tls: {
1324
+ key: Bun.file('/etc/ssl/smtp.example.com.key'),
1325
+ cert: Bun.file('/etc/ssl/smtp.example.com.crt'),
1326
+ },
1327
+ proxyProtocol: { trusted: ['10.0.0.5'] },
1328
+ authenticate: ({ username, password }) => username === 'alice' && password === Bun.env['ALICE_PASSWORD'],
1329
+ async onData(message) {
1330
+ await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
1331
+ },
1332
+ });
1333
+
1334
+ await server.listen({ port: 465 });
1335
+ ```
1336
+
1337
+ ### With Traefik
1338
+
1339
+ Traefik's TCP routers pass a connection through and, with a
1340
+ `serversTransport` whose `proxyProtocol.version` is 2, send a v2 header
1341
+ first. One entry
1342
+ point per port, in the static configuration:
1343
+
1344
+ ```yaml
1345
+ # traefik.yml
1346
+ entryPoints:
1347
+ smtp:
1348
+ address: ':25'
1349
+ submission:
1350
+ address: ':587'
1351
+ submissions:
1352
+ address: ':465'
1353
+ ```
1354
+
1355
+ Then, in the dynamic configuration, one router and one service each.
1356
+ ``HostSNI(`*`)`` with no `tls` section matches every connection without
1357
+ terminating TLS, so STARTTLS on 25 and 587, and the TLS of 465, pass
1358
+ through to the server, which holds the certificate:
1359
+
1360
+ ```yaml
1361
+ # dynamic.yml
1362
+ tcp:
1363
+ routers:
1364
+ smtp:
1365
+ entryPoints: [smtp]
1366
+ rule: 'HostSNI(`*`)'
1367
+ service: smtp
1368
+ submission:
1369
+ entryPoints: [submission]
1370
+ rule: 'HostSNI(`*`)'
1371
+ service: submission
1372
+ submissions:
1373
+ entryPoints: [submissions]
1374
+ rule: 'HostSNI(`*`)'
1375
+ service: submissions
1376
+ serversTransports:
1377
+ proxy-v2:
1378
+ proxyProtocol:
1379
+ version: 2
1380
+ services:
1381
+ smtp:
1382
+ loadBalancer:
1383
+ serversTransport: proxy-v2
1384
+ servers:
1385
+ - address: 'mail:25'
1386
+ submission:
1387
+ loadBalancer:
1388
+ serversTransport: proxy-v2
1389
+ servers:
1390
+ - address: 'mail:587'
1391
+ submissions:
1392
+ loadBalancer:
1393
+ serversTransport: proxy-v2
1394
+ servers:
1395
+ - address: 'mail:465'
1396
+ ```
1397
+
1398
+ This was run against Traefik v3.7 in Docker, a client reaching it with
1399
+ `openssl s_client` on 465 and with `-starttls smtp` on 25 and 587: the
1400
+ server saw the client's address in `onConnect` and every hook, Traefik's
1401
+ nowhere. Older v3 releases take the version on the service instead —
1402
+ `loadBalancer.proxyProtocol: { version: 2 }` beside `servers` — which
1403
+ v3.7 still honours, with a warning that it is deprecated in favour of the
1404
+ `serversTransport` above; the header sent is the same.
1405
+
1406
+ The matching servers, trusting Traefik's address on the network it shares
1407
+ with the mail server (`172.20.0.2` here; give the proxy a fixed address
1408
+ there):
1409
+
1410
+ ```ts
1411
+ import { createSmtpServer, type ReceivedMessage, type SmtpServerOptions } from '@bumail/smtp';
1412
+
1413
+ const tls = {
1414
+ key: await Bun.file('/etc/ssl/mail.example.com.key').text(),
1415
+ cert: await Bun.file('/etc/ssl/mail.example.com.crt').text(),
1416
+ };
1417
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
1418
+
1419
+ async function deliver(message: ReceivedMessage): Promise<void> {
1420
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1421
+ }
1422
+
1423
+ const shared = {
1424
+ hostname: 'mail.example.com',
1425
+ localDomains: ['example.com'],
1426
+ tls,
1427
+ // Traefik alone: never the network's whole range if anything else on it is not yours.
1428
+ proxyProtocol: { trusted: ['172.20.0.2'] },
1429
+ onData: deliver,
1430
+ } satisfies SmtpServerOptions;
1431
+
1432
+ const submission = {
1433
+ ...shared,
1434
+ mode: 'submission',
1435
+ async authenticate({ username, password }) {
1436
+ const hash = users.get(username);
1437
+ return hash !== undefined && (await Bun.password.verify(password, hash));
1438
+ },
1439
+ } satisfies SmtpServerOptions;
1440
+
1441
+ await createSmtpServer(shared).listen({ port: 25 });
1442
+ await createSmtpServer(submission).listen({ port: 587 });
1443
+ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
1444
+ ```
1445
+
1446
+ Traefik's health checks, and any connection it opens on its own, come as
1447
+ v2 `LOCAL`: they keep the proxy's address, by design. A client that
1448
+ reaches the server without Traefik, on another network, is a peer not
1449
+ listed and is served as such.
1450
+
1048
1451
  ## Sending mail: the client
1049
1452
 
1050
1453
  `@bumail/smtp/client` is the other side of the conversation: `sendMail`
@@ -1472,6 +1875,7 @@ console.log(chunk.done, reader.bareLineBreaks);
1472
1875
  | ENHANCEDSTATUSCODES and the codes themselves | RFC 2034, RFC 3463 |
1473
1876
  | STARTTLS | RFC 3207 |
1474
1877
  | implicit TLS | RFC 8314 |
1878
+ | the PROXY protocol, v1 and v2 | not an RFC: HAProxy's `proxy-protocol.txt` |
1475
1879
  | AUTH | RFC 4954 |
1476
1880
  | SASL PLAIN | RFC 4616 |
1477
1881
  | submission mode | RFC 6409 |
package/docs/roadmap.md CHANGED
@@ -34,6 +34,25 @@ Nothing in progress.
34
34
 
35
35
  ### Unreleased — merged, not yet published
36
36
 
37
+ - **A renewed certificate without a restart**, `server.setTls({ key, cert
38
+ })`: the next STARTTLS upgrade and the next implicit TLS connection use
39
+ it, the sessions open keep their TLS, and a pair that cannot be used
40
+ throws `INVALID_OPTION` and leaves the old one. Implicit TLS without a
41
+ proxy now listens in clear and upgrades each socket as it opens, since
42
+ a native Bun TLS listener cannot swap its certificate.
43
+ - **Behind a TCP proxy, the client's own address**, with `proxyProtocol:
44
+ { trusted: [...] }`: the server reads the PROXY protocol, versions 1 and
45
+ 2, from the proxies listed — a Traefik TCP router, HAProxy — so
46
+ `session.remoteAddress`, every hook, the Received field and
47
+ `maxConnectionsPerClient` see the client, not the proxy. It works on 25
48
+ and 587 with STARTTLS and on 465 with implicit TLS. A listed peer that
49
+ sends no valid header within `handshakeTimeout` is reset without a word
50
+ and holds no slot while it waits; a peer not listed is served as before,
51
+ and a header it sends never sets an address. `ProxyProtocolOptions` is
52
+ exported.
53
+
54
+ ### 0.4.0
55
+
37
56
  - **A connection limit per client**, `maxConnectionsPerClient` (default
38
57
  10): one host can no longer take every slot of `maxConnections` on an
39
58
  MX. A client is an IPv4 address, or an IPv6 address by its /64, and an