@bumail/smtp 0.4.0 → 0.5.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.
- package/README.md +44 -5
- package/dist/chunks/{index-753q702a.js → index-n4xwep5m.js} +3 -3
- package/dist/chunks/{index-753q702a.js.map → index-n4xwep5m.js.map} +3 -3
- package/dist/client/index.js +1 -1
- package/dist/errors.d.ts +3 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +550 -70
- package/dist/index.js.map +15 -8
- package/dist/server/admission.d.ts +29 -0
- package/dist/server/admission.d.ts.map +1 -0
- package/dist/server/listener.d.ts +17 -8
- package/dist/server/listener.d.ts.map +1 -1
- package/dist/server/options.d.ts +21 -1
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/proxy/address.d.ts +17 -0
- package/dist/server/proxy/address.d.ts.map +1 -0
- package/dist/server/proxy/header.d.ts +26 -0
- package/dist/server/proxy/header.d.ts.map +1 -0
- package/dist/server/proxy/reader.d.ts +15 -0
- package/dist/server/proxy/reader.d.ts.map +1 -0
- package/dist/server/proxy/tls.d.ts +50 -0
- package/dist/server/proxy/tls.d.ts.map +1 -0
- package/dist/server/proxy/trusted.d.ts +9 -0
- package/dist/server/proxy/trusted.d.ts.map +1 -0
- package/dist/server/raw-socket.d.ts +9 -0
- package/dist/server/raw-socket.d.ts.map +1 -0
- package/dist/server/received.d.ts +2 -0
- package/dist/server/received.d.ts.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/settings.d.ts +2 -0
- package/dist/server/settings.d.ts.map +1 -1
- package/dist/server/tls-context.d.ts +16 -0
- package/dist/server/tls-context.d.ts.map +1 -0
- package/dist/server/transport.d.ts +2 -2
- package/dist/server/transport.d.ts.map +1 -1
- package/docs/README.md +2 -2
- package/docs/guide.md +345 -7
- package/docs/roadmap.md +13 -0
- package/docs/troubleshooting.md +322 -1
- 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,
|
|
5
|
-
with the client, and the RFCs behind each
|
|
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)
|
|
@@ -15,6 +16,7 @@ with the client, and the RFCs behind each behaviour.
|
|
|
15
16
|
- [Authentication](#authentication)
|
|
16
17
|
- [TLS](#tls)
|
|
17
18
|
- [Limits](#limits)
|
|
19
|
+
- [Running behind a TCP proxy](#running-behind-a-tcp-proxy)
|
|
18
20
|
- [Sending mail: the client](#sending-mail-the-client)
|
|
19
21
|
- [Protocol helpers](#protocol-helpers)
|
|
20
22
|
- [The RFCs implemented](#the-rfcs-implemented)
|
|
@@ -69,7 +71,8 @@ export interface SmtpServer {
|
|
|
69
71
|
| `maxRecipients` | `number` | `100` | recipients per message |
|
|
70
72
|
| `maxConnections` | `number` | `1000` | open connections at once |
|
|
71
73
|
| `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) |
|
|
74
|
+
| `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) |
|
|
75
|
+
| `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
76
|
| `maxErrors` | `number` | `10` | failed commands before the server hangs up |
|
|
74
77
|
| `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
|
|
75
78
|
| `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 +118,16 @@ await server.listen({ port: 25 });
|
|
|
115
118
|
| `createSmtpServer(): hookTimeout must be at most 2147483 seconds, not <value>` | `hookTimeout` past what `setTimeout` can wait (about 24.8 days) |
|
|
116
119
|
| `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
|
|
117
120
|
| `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 |
|
|
121
|
+
| `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 |
|
|
122
|
+
| `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 `/` |
|
|
123
|
+
| `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`) |
|
|
124
|
+
| `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 |
|
|
125
|
+
| `createSmtpServer(): proxyProtocol.trusted: <value> is not a string` | an entry that is a number, `null`, an object |
|
|
118
126
|
|
|
119
127
|
`listen` a second time throws an `SmtpError` with `code:
|
|
120
|
-
'ALREADY_LISTENING'`: create another server for another port.
|
|
128
|
+
'ALREADY_LISTENING'`: create another server for another port. A `stop()`
|
|
129
|
+
called before `listen` resolved makes that `listen` reject with
|
|
130
|
+
`code: 'STOPPED'`, and nothing is left listening.
|
|
121
131
|
|
|
122
132
|
What to do about each is in [Troubleshooting](troubleshooting.md).
|
|
123
133
|
|
|
@@ -474,7 +484,8 @@ Subject: hi
|
|
|
474
484
|
body
|
|
475
485
|
```
|
|
476
486
|
|
|
477
|
-
- `from` is the EHLO or HELO name, then the client's address in brackets
|
|
487
|
+
- `from` is the EHLO or HELO name, then the client's address in brackets:
|
|
488
|
+
an IPv6 address is the literal of RFC 5321 §4.1.3, `([IPv6:2001:db8::7])`.
|
|
478
489
|
Any character of the name that is not printable ASCII is written `?`.
|
|
479
490
|
- `with` is the protocol of RFC 3848: `SMTP` after HELO; `ESMTP` after EHLO,
|
|
480
491
|
`ESMTPS` with TLS, `ESMTPA` with AUTH, `ESMTPSA` with both.
|
|
@@ -834,6 +845,10 @@ unless the listener has a `handshake` handler; with one, `open` comes at
|
|
|
834
845
|
the TCP connection and the socket's timer runs during the handshake. The
|
|
835
846
|
server sets one for that reason.
|
|
836
847
|
|
|
848
|
+
Behind a TCP proxy that sends the PROXY protocol, implicit TLS starts after
|
|
849
|
+
the proxy's header, and runs differently: see
|
|
850
|
+
[Running behind a TCP proxy](#running-behind-a-tcp-proxy).
|
|
851
|
+
|
|
837
852
|
An MX takes mail in clear as well as encrypted: senders on the Internet that
|
|
838
853
|
cannot do TLS still deliver. Whether to refuse them is policy — check
|
|
839
854
|
`session.secure` in a hook:
|
|
@@ -905,7 +920,7 @@ await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465
|
|
|
905
920
|
| `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
921
|
| `maxConnections` | 1000 | `421 4.3.2` and the server hangs up, before the greeting and before `onConnect` |
|
|
907
922
|
| `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
|
|
923
|
+
| `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
924
|
| `maxErrors` | 10 | `421 4.7.0` and the server hangs up |
|
|
910
925
|
| `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
926
|
| `hookTimeout` | 60 seconds | `451 4.3.0` for that command; `onError` gets an `SmtpError` `HOOK_TIMEOUT` |
|
|
@@ -1025,7 +1040,9 @@ TLS buffer. So a `221` behind replies that are never read holds the slot
|
|
|
1025
1040
|
Stop a server with `stop()`; `stop(true)` also hangs up on every client,
|
|
1026
1041
|
those moved to TLS by STARTTLS included, as the idle timeout does; a
|
|
1027
1042
|
socket still in its implicit TLS handshake is reset, with no 421, having
|
|
1028
|
-
no TLS to write one on
|
|
1043
|
+
no TLS to write one on, and so is a socket still awaiting a proxy's PROXY
|
|
1044
|
+
header, nothing written and nothing passed to `onError`. `connections`
|
|
1045
|
+
counts the clients currently connected:
|
|
1029
1046
|
|
|
1030
1047
|
```ts
|
|
1031
1048
|
import { createSmtpServer } from '@bumail/smtp';
|
|
@@ -1045,6 +1062,326 @@ process.on('SIGTERM', () => {
|
|
|
1045
1062
|
});
|
|
1046
1063
|
```
|
|
1047
1064
|
|
|
1065
|
+
## Running behind a TCP proxy
|
|
1066
|
+
|
|
1067
|
+
`proxyProtocol` lets the server sit behind a TCP proxy — a Traefik TCP
|
|
1068
|
+
router, HAProxy, a cloud load balancer — and still see each client's own
|
|
1069
|
+
address, as the proxy reports it with the PROXY protocol (HAProxy's
|
|
1070
|
+
`proxy-protocol.txt`, versions 1 and 2):
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
1074
|
+
|
|
1075
|
+
const server = createSmtpServer({
|
|
1076
|
+
hostname: 'mx.example.com',
|
|
1077
|
+
localDomains: ['example.com'],
|
|
1078
|
+
proxyProtocol: { trusted: ['10.0.0.5'] },
|
|
1079
|
+
onConnect: (session) => console.log('client', session.remoteAddress), // the client's, not 10.0.0.5
|
|
1080
|
+
async onData(message) {
|
|
1081
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
await server.listen({ port: 25 });
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
```ts
|
|
1089
|
+
export interface ProxyProtocolOptions {
|
|
1090
|
+
/** The proxies' addresses or CIDRs, IPv4 or IPv6: `['10.0.0.5', '172.16.0.0/12']`. */
|
|
1091
|
+
readonly trusted: readonly string[];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export interface SmtpServerOptions {
|
|
1095
|
+
// …
|
|
1096
|
+
readonly proxyProtocol?: ProxyProtocolOptions; // off by default
|
|
1097
|
+
readonly handshakeTimeout?: number; // also bounds a trusted proxy's header; default 10
|
|
1098
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
- [Who is trusted](#who-is-trusted)
|
|
1102
|
+
- [The header, and `handshakeTimeout`](#the-header-and-handshaketimeout)
|
|
1103
|
+
- [Which address the client gets](#which-address-the-client-gets)
|
|
1104
|
+
- [STARTTLS on 25 and 587](#starttls-on-25-and-587)
|
|
1105
|
+
- [Implicit TLS on 465, behind a proxy](#implicit-tls-on-465-behind-a-proxy)
|
|
1106
|
+
- [With Traefik](#with-traefik)
|
|
1107
|
+
|
|
1108
|
+
### Who is trusted
|
|
1109
|
+
|
|
1110
|
+
`trusted` lists the peers whose header is read: IPv4 and IPv6 addresses,
|
|
1111
|
+
and CIDRs of either. A peer is the TCP address the connection comes from,
|
|
1112
|
+
the proxy's.
|
|
1113
|
+
|
|
1114
|
+
| Entry | Trusts |
|
|
1115
|
+
| --- | --- |
|
|
1116
|
+
| `'10.0.0.5'`, `'2001:db8::5'` | that address alone |
|
|
1117
|
+
| `'172.20.0.0/16'`, `'2001:db8:1::/48'` | every address in the network |
|
|
1118
|
+
| `'::ffff:10.0.0.5'` | `10.0.0.5`: an IPv4-mapped entry is its IPv4 address |
|
|
1119
|
+
| `'::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 |
|
|
1120
|
+
|
|
1121
|
+
A peer reported IPv4-mapped (`::ffff:10.0.0.5`), as a listener on `::`
|
|
1122
|
+
sees an IPv4 client, matches as its IPv4 address, so `'10.0.0.5'` covers
|
|
1123
|
+
both. An address with a zone (`fe80::1%eth0`) is refused, as an entry and as a
|
|
1124
|
+
peer: a zone names an interface of this host, not a client. A prefix of 0
|
|
1125
|
+
(`0.0.0.0/0`, `::/0`) is refused too, since it trusts every peer. A bad entry throws an
|
|
1126
|
+
`SmtpError` (`INVALID_OPTION`) from `createSmtpServer`; the messages are in
|
|
1127
|
+
[Options](#options).
|
|
1128
|
+
|
|
1129
|
+
**List the proxies' own addresses, and nothing else.** A trusted peer can
|
|
1130
|
+
claim any client address it likes, and every check that uses the address
|
|
1131
|
+
— `maxConnectionsPerClient`, your `onConnect` policy, the Received field —
|
|
1132
|
+
believes it. Never list a range clients connect from, and never
|
|
1133
|
+
`0.0.0.0/0` or `::/0` on a port the Internet reaches. A container network
|
|
1134
|
+
is safe to list only when nothing but the proxy reaches the server
|
|
1135
|
+
through it.
|
|
1136
|
+
|
|
1137
|
+
A peer not listed is served exactly as without the option. A PROXY header
|
|
1138
|
+
it sends is just bad input and never sets an address: after the greeting,
|
|
1139
|
+
`PROXY …` is a command the server does not know (`500`); before it, the
|
|
1140
|
+
client talked first (`554 <hostname> Talked before the greeting`). On
|
|
1141
|
+
implicit TLS the header is not a ClientHello: the handshake fails and the
|
|
1142
|
+
socket is closed.
|
|
1143
|
+
|
|
1144
|
+
### The header, and `handshakeTimeout`
|
|
1145
|
+
|
|
1146
|
+
A trusted peer must send a valid header first, before any byte of the
|
|
1147
|
+
client's, and all of it within `handshakeTimeout` seconds (default 10) of
|
|
1148
|
+
the TCP connection. That bound is a timer of its own, to the millisecond,
|
|
1149
|
+
not Bun's socket timer with its 4-second ticks; a header trickled a byte at
|
|
1150
|
+
a time does not stretch it. Past it, or on a header that is not valid, the
|
|
1151
|
+
socket is reset:
|
|
1152
|
+
|
|
1153
|
+
- nothing is written to it, not even a `421`;
|
|
1154
|
+
- `onConnect` is never called, and `onError` is told nothing;
|
|
1155
|
+
- the proxy's address is never taken for a client's.
|
|
1156
|
+
|
|
1157
|
+
While it waits for its header, a socket holds no slot of `maxConnections`
|
|
1158
|
+
nor of `maxConnectionsPerClient`: it is counted, by the client's address,
|
|
1159
|
+
once the header named it. `stop(true)` resets the sockets still awaiting
|
|
1160
|
+
their header, writing nothing and calling no `onError`.
|
|
1161
|
+
|
|
1162
|
+
What is read, and what is refused:
|
|
1163
|
+
|
|
1164
|
+
| Header | Refused when |
|
|
1165
|
+
| --- | --- |
|
|
1166
|
+
| 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 |
|
|
1167
|
+
| 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 |
|
|
1168
|
+
|
|
1169
|
+
TLVs are checked for their shape, then skipped: the server reads none,
|
|
1170
|
+
SSL and authority included.
|
|
1171
|
+
|
|
1172
|
+
What the client sent in the same segment as the header, behind it, is the
|
|
1173
|
+
client's own. On a clear port that is bytes sent before the greeting, so
|
|
1174
|
+
the client is refused with `554 <hostname> Talked before the greeting`, as
|
|
1175
|
+
it would be without a proxy.
|
|
1176
|
+
|
|
1177
|
+
### Which address the client gets
|
|
1178
|
+
|
|
1179
|
+
| The proxy sends | `session.remoteAddress` is |
|
|
1180
|
+
| --- | --- |
|
|
1181
|
+
| v1 `PROXY TCP4` or `PROXY TCP6` | the source address |
|
|
1182
|
+
| v2 `PROXY`, over TCP (STREAM), IPv4 or IPv6 | the source address |
|
|
1183
|
+
| v2 `LOCAL`: the proxy's own connection, a health check | the peer's, the proxy's |
|
|
1184
|
+
| v2 `PROXY` with an UNSPEC or UNIX address, or over UDP (DGRAM) | the peer's |
|
|
1185
|
+
| v1 `PROXY UNKNOWN` | the peer's |
|
|
1186
|
+
|
|
1187
|
+
An IPv6 source is written in RFC 5952's form — lower case, no leading
|
|
1188
|
+
zeros, the longest run of zeros as `::` — and an IPv4-mapped source as its
|
|
1189
|
+
plain IPv4 address: `::FFFF:C633:6407` reaches your code as
|
|
1190
|
+
`198.51.100.7`.
|
|
1191
|
+
|
|
1192
|
+
That address is the client's everywhere the server uses one:
|
|
1193
|
+
`session.remoteAddress` in `onConnect`, in every hook, in `authenticate`
|
|
1194
|
+
and in `onError`; the Received field (`from client.example
|
|
1195
|
+
([198.51.100.7])`); and `maxConnectionsPerClient`, which counts it by
|
|
1196
|
+
`clientKey` as any other. So a policy written for a server without a proxy
|
|
1197
|
+
works unchanged behind one:
|
|
1198
|
+
|
|
1199
|
+
```ts
|
|
1200
|
+
import { clientKey, createSmtpServer, reply } from '@bumail/smtp';
|
|
1201
|
+
|
|
1202
|
+
const blocked = new Set(['203.0.113.7', '2001:db8:0:0::/64']);
|
|
1203
|
+
|
|
1204
|
+
const server = createSmtpServer({
|
|
1205
|
+
hostname: 'mx.example.com',
|
|
1206
|
+
localDomains: ['example.com'],
|
|
1207
|
+
proxyProtocol: { trusted: ['10.0.0.5', 'fd00::5'] },
|
|
1208
|
+
maxConnectionsPerClient: 5, // per client behind the proxy, not for the proxy
|
|
1209
|
+
onConnect: (session) =>
|
|
1210
|
+
blocked.has(clientKey(session.remoteAddress) ?? '') ? reply(554, '5.7.1', 'Go away') : undefined,
|
|
1211
|
+
onData: async (message) => {
|
|
1212
|
+
await new Response(message.content).bytes();
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
await server.listen({ port: 25 });
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
### STARTTLS on 25 and 587
|
|
1220
|
+
|
|
1221
|
+
Nothing changes for STARTTLS. The header is read before the greeting; the
|
|
1222
|
+
session then runs in clear, `STARTTLS` upgrades it as usual, and the
|
|
1223
|
+
client's address carries over to the encrypted session. The proxy passes
|
|
1224
|
+
the TLS records through untouched: it must not terminate TLS itself.
|
|
1225
|
+
|
|
1226
|
+
### Implicit TLS on 465, behind a proxy
|
|
1227
|
+
|
|
1228
|
+
On an `implicitTls` port the proxy's header comes first, then the client's
|
|
1229
|
+
ClientHello. A Bun TLS listener would take the header for a broken
|
|
1230
|
+
ClientHello, and Bun's `socket.upgradeTLS` reads only bytes that arrive
|
|
1231
|
+
after it is called, while a proxy often sends the ClientHello in the same
|
|
1232
|
+
TCP segment as the header (with Bun 1.4.2, the handshake then never
|
|
1233
|
+
completes).
|
|
1234
|
+
|
|
1235
|
+
So with `proxyProtocol` and `implicitTls` together, the port listens in
|
|
1236
|
+
clear, and every connection's TLS runs through `node:tls` over the raw
|
|
1237
|
+
socket: after the header from a trusted proxy, from the first byte from
|
|
1238
|
+
anyone else. For you, it changes little:
|
|
1239
|
+
|
|
1240
|
+
- `tls.key` and `tls.cert` still take a string, bytes or a `BunFile`; they
|
|
1241
|
+
are read when `listen()` is called.
|
|
1242
|
+
- Behind the header, the TLS handshake has `handshakeTimeout` again, on
|
|
1243
|
+
Bun's socket timer, as on implicit TLS without a proxy; meanwhile the
|
|
1244
|
+
socket holds its slot, counted by the client's address.
|
|
1245
|
+
- A refusal by a limit, `onConnect`, the greeting and `greetingDelay` wait
|
|
1246
|
+
for the handshake, so the client reads them over TLS and
|
|
1247
|
+
`session.secure` is `true`.
|
|
1248
|
+
|
|
1249
|
+
```ts
|
|
1250
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
1251
|
+
|
|
1252
|
+
const server = createSmtpServer({
|
|
1253
|
+
hostname: 'smtp.example.com',
|
|
1254
|
+
mode: 'submission',
|
|
1255
|
+
implicitTls: true,
|
|
1256
|
+
localDomains: ['example.com'],
|
|
1257
|
+
tls: {
|
|
1258
|
+
key: Bun.file('/etc/ssl/smtp.example.com.key'),
|
|
1259
|
+
cert: Bun.file('/etc/ssl/smtp.example.com.crt'),
|
|
1260
|
+
},
|
|
1261
|
+
proxyProtocol: { trusted: ['10.0.0.5'] },
|
|
1262
|
+
authenticate: ({ username, password }) => username === 'alice' && password === Bun.env['ALICE_PASSWORD'],
|
|
1263
|
+
async onData(message) {
|
|
1264
|
+
await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
await server.listen({ port: 465 });
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
### With Traefik
|
|
1272
|
+
|
|
1273
|
+
Traefik's TCP routers pass a connection through and, with a
|
|
1274
|
+
`serversTransport` whose `proxyProtocol.version` is 2, send a v2 header
|
|
1275
|
+
first. One entry
|
|
1276
|
+
point per port, in the static configuration:
|
|
1277
|
+
|
|
1278
|
+
```yaml
|
|
1279
|
+
# traefik.yml
|
|
1280
|
+
entryPoints:
|
|
1281
|
+
smtp:
|
|
1282
|
+
address: ':25'
|
|
1283
|
+
submission:
|
|
1284
|
+
address: ':587'
|
|
1285
|
+
submissions:
|
|
1286
|
+
address: ':465'
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
Then, in the dynamic configuration, one router and one service each.
|
|
1290
|
+
``HostSNI(`*`)`` with no `tls` section matches every connection without
|
|
1291
|
+
terminating TLS, so STARTTLS on 25 and 587, and the TLS of 465, pass
|
|
1292
|
+
through to the server, which holds the certificate:
|
|
1293
|
+
|
|
1294
|
+
```yaml
|
|
1295
|
+
# dynamic.yml
|
|
1296
|
+
tcp:
|
|
1297
|
+
routers:
|
|
1298
|
+
smtp:
|
|
1299
|
+
entryPoints: [smtp]
|
|
1300
|
+
rule: 'HostSNI(`*`)'
|
|
1301
|
+
service: smtp
|
|
1302
|
+
submission:
|
|
1303
|
+
entryPoints: [submission]
|
|
1304
|
+
rule: 'HostSNI(`*`)'
|
|
1305
|
+
service: submission
|
|
1306
|
+
submissions:
|
|
1307
|
+
entryPoints: [submissions]
|
|
1308
|
+
rule: 'HostSNI(`*`)'
|
|
1309
|
+
service: submissions
|
|
1310
|
+
serversTransports:
|
|
1311
|
+
proxy-v2:
|
|
1312
|
+
proxyProtocol:
|
|
1313
|
+
version: 2
|
|
1314
|
+
services:
|
|
1315
|
+
smtp:
|
|
1316
|
+
loadBalancer:
|
|
1317
|
+
serversTransport: proxy-v2
|
|
1318
|
+
servers:
|
|
1319
|
+
- address: 'mail:25'
|
|
1320
|
+
submission:
|
|
1321
|
+
loadBalancer:
|
|
1322
|
+
serversTransport: proxy-v2
|
|
1323
|
+
servers:
|
|
1324
|
+
- address: 'mail:587'
|
|
1325
|
+
submissions:
|
|
1326
|
+
loadBalancer:
|
|
1327
|
+
serversTransport: proxy-v2
|
|
1328
|
+
servers:
|
|
1329
|
+
- address: 'mail:465'
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
This was run against Traefik v3.7 in Docker, a client reaching it with
|
|
1333
|
+
`openssl s_client` on 465 and with `-starttls smtp` on 25 and 587: the
|
|
1334
|
+
server saw the client's address in `onConnect` and every hook, Traefik's
|
|
1335
|
+
nowhere. Older v3 releases take the version on the service instead —
|
|
1336
|
+
`loadBalancer.proxyProtocol: { version: 2 }` beside `servers` — which
|
|
1337
|
+
v3.7 still honours, with a warning that it is deprecated in favour of the
|
|
1338
|
+
`serversTransport` above; the header sent is the same.
|
|
1339
|
+
|
|
1340
|
+
The matching servers, trusting Traefik's address on the network it shares
|
|
1341
|
+
with the mail server (`172.20.0.2` here; give the proxy a fixed address
|
|
1342
|
+
there):
|
|
1343
|
+
|
|
1344
|
+
```ts
|
|
1345
|
+
import { createSmtpServer, type ReceivedMessage, type SmtpServerOptions } from '@bumail/smtp';
|
|
1346
|
+
|
|
1347
|
+
const tls = {
|
|
1348
|
+
key: await Bun.file('/etc/ssl/mail.example.com.key').text(),
|
|
1349
|
+
cert: await Bun.file('/etc/ssl/mail.example.com.crt').text(),
|
|
1350
|
+
};
|
|
1351
|
+
const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
|
|
1352
|
+
|
|
1353
|
+
async function deliver(message: ReceivedMessage): Promise<void> {
|
|
1354
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const shared = {
|
|
1358
|
+
hostname: 'mail.example.com',
|
|
1359
|
+
localDomains: ['example.com'],
|
|
1360
|
+
tls,
|
|
1361
|
+
// Traefik alone: never the network's whole range if anything else on it is not yours.
|
|
1362
|
+
proxyProtocol: { trusted: ['172.20.0.2'] },
|
|
1363
|
+
onData: deliver,
|
|
1364
|
+
} satisfies SmtpServerOptions;
|
|
1365
|
+
|
|
1366
|
+
const submission = {
|
|
1367
|
+
...shared,
|
|
1368
|
+
mode: 'submission',
|
|
1369
|
+
async authenticate({ username, password }) {
|
|
1370
|
+
const hash = users.get(username);
|
|
1371
|
+
return hash !== undefined && (await Bun.password.verify(password, hash));
|
|
1372
|
+
},
|
|
1373
|
+
} satisfies SmtpServerOptions;
|
|
1374
|
+
|
|
1375
|
+
await createSmtpServer(shared).listen({ port: 25 });
|
|
1376
|
+
await createSmtpServer(submission).listen({ port: 587 });
|
|
1377
|
+
await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
Traefik's health checks, and any connection it opens on its own, come as
|
|
1381
|
+
v2 `LOCAL`: they keep the proxy's address, by design. A client that
|
|
1382
|
+
reaches the server without Traefik, on another network, is a peer not
|
|
1383
|
+
listed and is served as such.
|
|
1384
|
+
|
|
1048
1385
|
## Sending mail: the client
|
|
1049
1386
|
|
|
1050
1387
|
`@bumail/smtp/client` is the other side of the conversation: `sendMail`
|
|
@@ -1472,6 +1809,7 @@ console.log(chunk.done, reader.bareLineBreaks);
|
|
|
1472
1809
|
| ENHANCEDSTATUSCODES and the codes themselves | RFC 2034, RFC 3463 |
|
|
1473
1810
|
| STARTTLS | RFC 3207 |
|
|
1474
1811
|
| implicit TLS | RFC 8314 |
|
|
1812
|
+
| the PROXY protocol, v1 and v2 | not an RFC: HAProxy's `proxy-protocol.txt` |
|
|
1475
1813
|
| AUTH | RFC 4954 |
|
|
1476
1814
|
| SASL PLAIN | RFC 4616 |
|
|
1477
1815
|
| submission mode | RFC 6409 |
|
package/docs/roadmap.md
CHANGED
|
@@ -34,6 +34,19 @@ Nothing in progress.
|
|
|
34
34
|
|
|
35
35
|
### Unreleased — merged, not yet published
|
|
36
36
|
|
|
37
|
+
- **Behind a TCP proxy, the client's own address**, with `proxyProtocol:
|
|
38
|
+
{ trusted: [...] }`: the server reads the PROXY protocol, versions 1 and
|
|
39
|
+
2, from the proxies listed — a Traefik TCP router, HAProxy — so
|
|
40
|
+
`session.remoteAddress`, every hook, the Received field and
|
|
41
|
+
`maxConnectionsPerClient` see the client, not the proxy. It works on 25
|
|
42
|
+
and 587 with STARTTLS and on 465 with implicit TLS. A listed peer that
|
|
43
|
+
sends no valid header within `handshakeTimeout` is reset without a word
|
|
44
|
+
and holds no slot while it waits; a peer not listed is served as before,
|
|
45
|
+
and a header it sends never sets an address. `ProxyProtocolOptions` is
|
|
46
|
+
exported.
|
|
47
|
+
|
|
48
|
+
### 0.4.0
|
|
49
|
+
|
|
37
50
|
- **A connection limit per client**, `maxConnectionsPerClient` (default
|
|
38
51
|
10): one host can no longer take every slot of `maxConnections` on an
|
|
39
52
|
MX. A client is an IPv4 address, or an IPv6 address by its /64, and an
|