haraka-tls 1.0.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/index.js +34 -0
- package/lib/certs.js +135 -0
- package/lib/config.js +59 -0
- package/lib/context.js +104 -0
- package/lib/logger.js +43 -0
- package/lib/outbound.js +128 -0
- package/lib/socket.js +250 -0
- package/package.json +41 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haraka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# haraka-tls
|
|
2
|
+
|
|
3
|
+
Modern TLS support for [Haraka](https://haraka.github.io).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **No `openssl` subprocess** — certificate parsing uses Node's built-in
|
|
8
|
+
`crypto.X509Certificate`
|
|
9
|
+
- **No global state** — every API returns plain objects; callers own them
|
|
10
|
+
- **STARTTLS upgrades** — `PluggableStream` wraps a raw TCP socket and upgrades it to TLS in-place without changing the reference held by the connection handler
|
|
11
|
+
- **SNI** — `ContextStore` manages per-hostname `SecureContext` objects and generates a ready-to-use SNI callback
|
|
12
|
+
- **Hot reload** — `ContextStore.invalidate()` clears all contexts; call `build()` again to pick up new certificates
|
|
13
|
+
- **Outbound TLS-NO-GO** — optional Redis cache that skips TLS for hosts that have previously failed negotiation
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install haraka-tls
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`haraka-plugin-redis` is an optional peer dependency, required only when the
|
|
22
|
+
`[redis] disable_for_failed_hosts` feature is enabled.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const {
|
|
28
|
+
load_config,
|
|
29
|
+
load_dir,
|
|
30
|
+
ContextStore,
|
|
31
|
+
createServer,
|
|
32
|
+
connect,
|
|
33
|
+
OutboundTLS,
|
|
34
|
+
} = require('haraka-tls')
|
|
35
|
+
|
|
36
|
+
const config = require('haraka-config').module_config('/path/to/haraka/config')
|
|
37
|
+
|
|
38
|
+
// Load tls.ini
|
|
39
|
+
const tls_cfg = load_config(config)
|
|
40
|
+
|
|
41
|
+
// Load per-hostname certs from config/tls/
|
|
42
|
+
const certs = await load_dir(config, 'tls')
|
|
43
|
+
|
|
44
|
+
// Build TLS contexts
|
|
45
|
+
const contexts = new ContextStore()
|
|
46
|
+
contexts.build(tls_cfg.main, certs)
|
|
47
|
+
|
|
48
|
+
// Inbound server
|
|
49
|
+
const server = createServer({ contexts, cfg: tls_cfg.main }, (socket) => {
|
|
50
|
+
// socket is a PluggableStream
|
|
51
|
+
socket.on('data', (chunk) => { /* ... */ })
|
|
52
|
+
|
|
53
|
+
// Upgrade to TLS when the client sends STARTTLS
|
|
54
|
+
socket.upgrade((verified, verifyErr, peerCert, cipher) => {
|
|
55
|
+
console.log('TLS established, cipher:', cipher.name)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
server.listen(25)
|
|
59
|
+
|
|
60
|
+
// Outbound connection
|
|
61
|
+
const ob = new OutboundTLS(config)
|
|
62
|
+
ob.load(tls_cfg)
|
|
63
|
+
|
|
64
|
+
const mx = { exchange: 'mail.example.com' }
|
|
65
|
+
const socket = connect({ host: mx.exchange, port: 25 })
|
|
66
|
+
socket.on('connect', () => {
|
|
67
|
+
// After the remote server sends 250 STARTTLS:
|
|
68
|
+
socket.upgrade(ob.get_tls_options(mx), (verified, verifyErr, peerCert, cipher) => {
|
|
69
|
+
console.log('Outbound TLS established')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
### `load_config(cfg_module)` → `object`
|
|
77
|
+
|
|
78
|
+
Reads `tls.ini` via the given `haraka-config` module and returns a normalised
|
|
79
|
+
config object. Each call returns a fresh plain object — safe to mutate.
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const { load_config } = require('haraka-tls')
|
|
83
|
+
const tls_cfg = load_config(config)
|
|
84
|
+
// tls_cfg.main, tls_cfg.outbound, tls_cfg.redis, tls_cfg.no_tls_hosts, …
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `parse_pem(pem)` → `object`
|
|
88
|
+
|
|
89
|
+
Parses a PEM string and extracts private key(s), the certificate chain,
|
|
90
|
+
hostnames (CN + SANs), and the leaf certificate's expiry date.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const { parse_pem } = require('haraka-tls')
|
|
94
|
+
const { keys, chain, names, expire } = parse_pem(fs.readFileSync('cert.pem', 'utf8'))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `load_dir(cfg_module, dir_name)` → `Promise<Map>`
|
|
98
|
+
|
|
99
|
+
Scans a config directory for `.pem` files, pairs keys with certificates by
|
|
100
|
+
hostname, and returns a `Map<string, { key: Buffer, cert: Buffer, file: string }>`.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const { load_dir } = require('haraka-tls')
|
|
104
|
+
const certs = await load_dir(config, 'tls') // reads config/tls/*.pem
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `ContextStore`
|
|
108
|
+
|
|
109
|
+
Manages a set of `tls.SecureContext` objects keyed by hostname. The special key
|
|
110
|
+
`'*'` is the fallback used by SNI when no hostname-specific context exists.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
const { ContextStore } = require('haraka-tls')
|
|
114
|
+
|
|
115
|
+
const store = new ContextStore()
|
|
116
|
+
store.build(base_opts, certs) // builds '*' + per-hostname contexts
|
|
117
|
+
store.get('mail.example.com') // returns context, falls back to '*'
|
|
118
|
+
store.sni_callback() // returns (servername, cb) => void
|
|
119
|
+
store.invalidate() // clears all contexts (force reload)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `build_context(opts)` → `tls.SecureContext`
|
|
123
|
+
|
|
124
|
+
Thin wrapper around `tls.createSecureContext(opts)`. Throws on invalid material
|
|
125
|
+
so callers can handle errors explicitly.
|
|
126
|
+
|
|
127
|
+
### `createServer(tls_state, connection_handler)` → `net.Server`
|
|
128
|
+
|
|
129
|
+
Creates a `net.Server` whose connections are wrapped in `PluggableStream`. Each
|
|
130
|
+
socket gains an `.upgrade(cb)` method for inbound STARTTLS.
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const { createServer } = require('haraka-tls')
|
|
134
|
+
|
|
135
|
+
const server = createServer({ contexts, cfg: tls_cfg.main }, (socket) => {
|
|
136
|
+
socket.upgrade((verified, verifyErr, peerCert, cipher) => { /* … */ })
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `connect(conn_opts)` → `PluggableStream`
|
|
141
|
+
|
|
142
|
+
Creates a plain TCP socket wrapped in `PluggableStream`. The returned socket has
|
|
143
|
+
an `.upgrade(tls_opts, cb)` method for outbound STARTTLS. Also exported as
|
|
144
|
+
`createConnection` for drop-in compatibility.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
const { connect } = require('haraka-tls')
|
|
148
|
+
|
|
149
|
+
const socket = connect({ host: 'mail.example.com', port: 25 })
|
|
150
|
+
socket.on('connect', () => {
|
|
151
|
+
socket.upgrade(tls_opts, (verified, verifyErr, peerCert, cipher) => { /* … */ })
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `PluggableStream`
|
|
156
|
+
|
|
157
|
+
An `EventEmitter` that wraps a `net.Socket` or `tls.TLSSocket` and supports
|
|
158
|
+
transparent STARTTLS upgrade. The reference held by the caller does not change
|
|
159
|
+
when the underlying socket is swapped.
|
|
160
|
+
|
|
161
|
+
Forwarded events: `data`, `connect`, `secureConnect`, `secure`, `end`, `close`,
|
|
162
|
+
`drain`, `error`, `timeout`.
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
socket.isEncrypted() // → boolean
|
|
166
|
+
socket.isSecure() // → boolean (encrypted + authorized)
|
|
167
|
+
socket.write(data)
|
|
168
|
+
socket.end()
|
|
169
|
+
socket.destroy()
|
|
170
|
+
socket.setTimeout(ms)
|
|
171
|
+
socket.setKeepAlive(bool)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `OutboundTLS`
|
|
175
|
+
|
|
176
|
+
Manages outbound TLS configuration and an optional Redis cache that disables TLS
|
|
177
|
+
for hosts that have previously failed negotiation.
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
const { OutboundTLS } = require('haraka-tls')
|
|
181
|
+
|
|
182
|
+
const ob = new OutboundTLS(config)
|
|
183
|
+
ob.load(tls_cfg) // inherit from [main], resolve file paths to Buffers
|
|
184
|
+
await ob.init(cb) // connect to Redis if disable_for_failed_hosts=true
|
|
185
|
+
|
|
186
|
+
const opts = ob.get_tls_options({ exchange: 'mail.example.com' })
|
|
187
|
+
// opts.servername is set to the hostname (never a bare IP)
|
|
188
|
+
|
|
189
|
+
ob.check_tls_nogo(host, cb_ok, cb_nogo)
|
|
190
|
+
ob.mark_tls_nogo(host, cb)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Configuration
|
|
194
|
+
|
|
195
|
+
`tls.ini` sections understood by this package:
|
|
196
|
+
|
|
197
|
+
```ini
|
|
198
|
+
; [main] — inbound TLS defaults
|
|
199
|
+
key = tls_key.pem
|
|
200
|
+
cert = tls_cert.pem
|
|
201
|
+
dhparam = dhparams.pem
|
|
202
|
+
ciphers = ECDHE-RSA-AES256-GCM-SHA384:…
|
|
203
|
+
minVersion = TLSv1.2
|
|
204
|
+
rejectUnauthorized = false
|
|
205
|
+
requestCert = true
|
|
206
|
+
honorCipherOrder = true
|
|
207
|
+
requireAuthorized[] = 465
|
|
208
|
+
requireAuthorized[] = 587
|
|
209
|
+
|
|
210
|
+
; [outbound] — overrides for outbound connections
|
|
211
|
+
; Any key absent here falls back to [main]
|
|
212
|
+
key = outbound_key.pem
|
|
213
|
+
cert = outbound_cert.pem
|
|
214
|
+
rejectUnauthorized = false
|
|
215
|
+
force_tls_hosts[] = smtp.example.com
|
|
216
|
+
no_tls_hosts[] = broken.example.net
|
|
217
|
+
|
|
218
|
+
; [redis] — TLS-NO-GO cache (optional)
|
|
219
|
+
disable_for_failed_hosts = true
|
|
220
|
+
disable_expiry = 604800 ; seconds (default: 7 days)
|
|
221
|
+
|
|
222
|
+
; [no_tls_hosts] — disable TLS for inbound connections from these hosts/CIDRs
|
|
223
|
+
192.168.1.0/24
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Per-hostname certificates
|
|
227
|
+
|
|
228
|
+
Place PEM files in `config/tls/`. Each file may contain a private key and one or
|
|
229
|
+
more certificates. The CN and SAN DNS entries are used as the hostname key:
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
config/
|
|
233
|
+
tls/
|
|
234
|
+
mail.example.com.pem ← CN=mail.example.com
|
|
235
|
+
smtp.example.com.pem ← CN=smtp.example.com
|
|
236
|
+
_.example.net.key ← key for wildcard (paired with example.net.crt)
|
|
237
|
+
example.net.crt
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Filenames starting with `_` have the leading underscore replaced with `*` to
|
|
241
|
+
work around Windows filesystem restrictions on wildcard filenames.
|
|
242
|
+
|
|
243
|
+
## Integrating with Haraka's logger
|
|
244
|
+
|
|
245
|
+
By default this package logs to `console`. To use Haraka's logger instead, call
|
|
246
|
+
`set_logger()` before any other import:
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
const tls_logger = require('haraka-tls/lib/logger')
|
|
250
|
+
tls_logger.set_logger(require('./logger')) // Haraka's logger
|
|
251
|
+
|
|
252
|
+
const haraka_tls = require('haraka-tls')
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The logger object passed to `set_logger` must implement `debug`, `info`,
|
|
256
|
+
`notice`, `warn`, and `error` methods that each accept a message string.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// haraka-tls: Modern TLS support for Haraka.
|
|
4
|
+
//
|
|
5
|
+
// Sub-modules are intentionally independent so callers can require only what
|
|
6
|
+
// they need without pulling in the entire stack.
|
|
7
|
+
|
|
8
|
+
const { load: load_config } = require('./lib/config')
|
|
9
|
+
const { parse_pem, load_dir } = require('./lib/certs')
|
|
10
|
+
const { ContextStore, build_context } = require('./lib/context')
|
|
11
|
+
const { PluggableStream, createServer, connect, createConnection } = require('./lib/socket')
|
|
12
|
+
const { OutboundTLS } = require('./lib/outbound')
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
// Config
|
|
16
|
+
load_config,
|
|
17
|
+
|
|
18
|
+
// Certificates
|
|
19
|
+
parse_pem,
|
|
20
|
+
load_dir,
|
|
21
|
+
|
|
22
|
+
// TLS Contexts
|
|
23
|
+
ContextStore,
|
|
24
|
+
build_context,
|
|
25
|
+
|
|
26
|
+
// Sockets
|
|
27
|
+
PluggableStream,
|
|
28
|
+
createServer,
|
|
29
|
+
connect,
|
|
30
|
+
createConnection,
|
|
31
|
+
|
|
32
|
+
// Outbound
|
|
33
|
+
OutboundTLS,
|
|
34
|
+
}
|
package/lib/certs.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Certificate parsing and directory loading.
|
|
4
|
+
// Uses Node's built-in crypto.X509Certificate — no openssl subprocess needed.
|
|
5
|
+
|
|
6
|
+
const { X509Certificate } = require('node:crypto')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
|
|
9
|
+
const log = require('./logger')
|
|
10
|
+
|
|
11
|
+
// PEM block patterns
|
|
12
|
+
const KEY_RE = /(-----BEGIN (?:\w+ )?PRIVATE KEY-----[\s\S]*?-----END (?:\w+ )?PRIVATE KEY-----)/g
|
|
13
|
+
const CERT_RE = /(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a PEM string and extract private key(s), certificate chain,
|
|
17
|
+
* hostnames (CN + SANs), and the leaf certificate's expiry date.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} pem
|
|
20
|
+
* @returns {{ keys?: string[], chain?: string[], names?: string[], expire?: Date }}
|
|
21
|
+
*/
|
|
22
|
+
function parse_pem(pem) {
|
|
23
|
+
const res = {}
|
|
24
|
+
if (!pem) return res
|
|
25
|
+
|
|
26
|
+
const keys = Array.from(pem.matchAll(KEY_RE), (m) => m[1])
|
|
27
|
+
if (keys.length) res.keys = keys
|
|
28
|
+
|
|
29
|
+
const chain = Array.from(pem.matchAll(CERT_RE), (m) => m[1])
|
|
30
|
+
if (!chain.length) return res
|
|
31
|
+
|
|
32
|
+
res.chain = chain
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const leaf = new X509Certificate(chain[0])
|
|
36
|
+
res.expire = new Date(leaf.validTo)
|
|
37
|
+
|
|
38
|
+
const cn_match = /CN=([^,\n]+)/.exec(leaf.subject)
|
|
39
|
+
res.names = cn_match ? [cn_match[1].trim()] : []
|
|
40
|
+
|
|
41
|
+
if (leaf.subjectAltName) {
|
|
42
|
+
for (const san of leaf.subjectAltName.split(',')) {
|
|
43
|
+
const m = /DNS:(.+)/.exec(san.trim())
|
|
44
|
+
if (m) {
|
|
45
|
+
const name = m[1].trim()
|
|
46
|
+
if (!res.names.includes(name)) res.names.push(name)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log.debug(`tls/certs: X509Certificate parse error: ${err.message}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return res
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load every PEM file in a config directory and return a Map of
|
|
59
|
+
* hostname → { key: Buffer, cert: Buffer, file: string }
|
|
60
|
+
*
|
|
61
|
+
* Files with a key but no embedded cert have their filename used as the CN.
|
|
62
|
+
* Incomplete pairs (key without cert or vice-versa) are silently dropped.
|
|
63
|
+
* Expired certificates are logged as errors but still loaded.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} cfg_module - haraka-config module (supports .getDir)
|
|
66
|
+
* @param {string} dir_name - directory name relative to config root (e.g. 'tls')
|
|
67
|
+
* @returns {Promise<Map<string, {key: Buffer, cert: Buffer, file: string}>>}
|
|
68
|
+
*/
|
|
69
|
+
async function load_dir(cfg_module, dir_name) {
|
|
70
|
+
const result = new Map()
|
|
71
|
+
let files
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
files = await cfg_module.getDir(dir_name, { type: 'binary' })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code !== 'ENOENT') log.error(`tls/certs: load_dir ${dir_name}: ${err.message}`)
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!files?.length) return result
|
|
81
|
+
|
|
82
|
+
// ── Stage 1: parse every file ─────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const parsed = {}
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
try {
|
|
87
|
+
parsed[file.path] = parse_pem(file.data.toString())
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log.debug(`tls/certs: skipping ${file.path}: ${err.message}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
log.debug(`tls/certs: parsed ${Object.keys(parsed).length} file(s) in ${dir_name}`)
|
|
94
|
+
|
|
95
|
+
// ── Stage 2: collate by hostname ──────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const by_name = {}
|
|
98
|
+
for (const [fp, info] of Object.entries(parsed)) {
|
|
99
|
+
if (info.expire && info.expire < new Date()) {
|
|
100
|
+
log.error(`tls/certs: ${fp} expired on ${info.expire.toUTCString()}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Files with a key but no cert use the base filename as the CN
|
|
104
|
+
if (!info.names) info.names = [path.parse(fp).name]
|
|
105
|
+
|
|
106
|
+
for (let name of info.names) {
|
|
107
|
+
if (name.startsWith('_')) name = name.replace('_', '*') // Windows wildcard workaround
|
|
108
|
+
by_name[name] ??= {}
|
|
109
|
+
if (!by_name[name].key && info.keys?.length) by_name[name].key = info.keys[0]
|
|
110
|
+
if (!by_name[name].cert && info.chain?.length) {
|
|
111
|
+
by_name[name].cert = info.chain[0]
|
|
112
|
+
by_name[name].file = fp
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Stage 3: emit complete pairs only ─────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
for (const [name, entry] of Object.entries(by_name)) {
|
|
120
|
+
if (!entry.key || !entry.cert) {
|
|
121
|
+
log.debug(`tls/certs: incomplete pair for "${name}" (key=${!!entry.key} cert=${!!entry.cert}), skipping`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
result.set(name, {
|
|
125
|
+
key: Buffer.from(entry.key),
|
|
126
|
+
cert: Buffer.from(entry.cert),
|
|
127
|
+
file: entry.file,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
log.info(`tls/certs: loaded ${result.size} cert(s) from ${dir_name}`)
|
|
132
|
+
return result
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { parse_pem, load_dir }
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Pure config loading — no global state, no side effects.
|
|
4
|
+
// Returns a plain object that callers own and may mutate freely.
|
|
5
|
+
|
|
6
|
+
const BOOLEANS = [
|
|
7
|
+
'-redis.disable_for_failed_hosts',
|
|
8
|
+
|
|
9
|
+
// Wildcards initialise the type parser but not the value
|
|
10
|
+
'*.requestCert',
|
|
11
|
+
'*.rejectUnauthorized',
|
|
12
|
+
'*.honorCipherOrder',
|
|
13
|
+
'*.requestOCSP',
|
|
14
|
+
|
|
15
|
+
// Explicitly declared so the defaults below are applied
|
|
16
|
+
'+main.requestCert',
|
|
17
|
+
'-main.rejectUnauthorized',
|
|
18
|
+
'+main.honorCipherOrder',
|
|
19
|
+
'-main.requestOCSP',
|
|
20
|
+
'-main.mutual_tls',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load tls.ini via the given haraka-config module and return a normalised
|
|
25
|
+
* config object. Calling load() twice with the same cfg_module is safe and
|
|
26
|
+
* cheap — each call returns a fresh plain object.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} cfg_module - haraka-config (or module_config() result)
|
|
29
|
+
* @returns {object} Normalised TLS config
|
|
30
|
+
*/
|
|
31
|
+
function load(cfg_module) {
|
|
32
|
+
const raw = cfg_module.get('tls.ini', { booleans: BOOLEANS })
|
|
33
|
+
|
|
34
|
+
// Handle deprecated enableOCSPStapling alias
|
|
35
|
+
if (raw.main?.enableOCSPStapling !== undefined) {
|
|
36
|
+
raw.main.requestOCSP = raw.main.enableOCSPStapling
|
|
37
|
+
delete raw.main.enableOCSPStapling
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = {
|
|
41
|
+
main: { ...raw.main },
|
|
42
|
+
redis: { disable_for_failed_hosts: false, ...raw.redis },
|
|
43
|
+
no_tls_hosts: raw.no_tls_hosts ?? {},
|
|
44
|
+
mutual_auth_hosts: raw.mutual_auth_hosts ?? {},
|
|
45
|
+
mutual_auth_hosts_exclude: raw.mutual_auth_hosts_exclude ?? {},
|
|
46
|
+
outbound: { ...(raw.outbound ?? {}) },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Always arrays — avoids scattered defensive checks everywhere
|
|
50
|
+
result.main.requireAuthorized = [result.main.requireAuthorized].flat().filter(Boolean)
|
|
51
|
+
result.main.no_starttls_ports = [result.main.no_starttls_ports].flat().filter(Boolean)
|
|
52
|
+
|
|
53
|
+
if (!Array.isArray(result.outbound.no_tls_hosts)) result.outbound.no_tls_hosts = []
|
|
54
|
+
if (!Array.isArray(result.outbound.force_tls_hosts)) result.outbound.force_tls_hosts = []
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { load }
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const tls = require('node:tls')
|
|
4
|
+
|
|
5
|
+
const log = require('./logger')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a tls.SecureContext from the given options.
|
|
9
|
+
* Throws on invalid key/cert material so callers can handle errors explicitly.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} opts - options accepted by tls.createSecureContext()
|
|
12
|
+
* @returns {tls.SecureContext}
|
|
13
|
+
*/
|
|
14
|
+
function build_context(opts) {
|
|
15
|
+
return tls.createSecureContext(opts)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages a set of TLS SecureContexts keyed by hostname.
|
|
20
|
+
*
|
|
21
|
+
* The special key '*' is the default/fallback used by SNI when no
|
|
22
|
+
* hostname-specific context exists.
|
|
23
|
+
*/
|
|
24
|
+
class ContextStore {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._ctxs = new Map()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Store a context under the given name (use '*' for the default). */
|
|
30
|
+
set(name, ctx) {
|
|
31
|
+
this._ctxs.set(name, ctx)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the context for `name`, falling back to '*'.
|
|
36
|
+
* Returns undefined only when no default context has been set.
|
|
37
|
+
*/
|
|
38
|
+
get(name) {
|
|
39
|
+
return this._ctxs.get(name) ?? this._ctxs.get('*')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
has(name) {
|
|
43
|
+
return this._ctxs.has(name)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get size() {
|
|
47
|
+
return this._ctxs.size
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build contexts from a base-options object and a certs Map produced by
|
|
52
|
+
* tls/certs.load_dir().
|
|
53
|
+
*
|
|
54
|
+
* The base_opts are used for the default '*' context and as a template
|
|
55
|
+
* for per-hostname contexts (with the hostname's key/cert substituted in).
|
|
56
|
+
*
|
|
57
|
+
* @param {object} base_opts - base TLS options
|
|
58
|
+
* @param {Map<string, {key, cert}>} certs - per-hostname material
|
|
59
|
+
*/
|
|
60
|
+
build(base_opts, certs) {
|
|
61
|
+
// Default context
|
|
62
|
+
if (base_opts.key && base_opts.cert) {
|
|
63
|
+
try {
|
|
64
|
+
this.set('*', build_context(base_opts))
|
|
65
|
+
log.debug('tls/context: built default (*) context')
|
|
66
|
+
} catch (err) {
|
|
67
|
+
log.error(`tls/context: failed to build default context: ${err.message}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Per-hostname contexts (inherit base opts, override key/cert)
|
|
72
|
+
for (const [name, entry] of certs) {
|
|
73
|
+
try {
|
|
74
|
+
this.set(name, build_context({ ...base_opts, key: entry.key, cert: entry.cert }))
|
|
75
|
+
log.debug(`tls/context: built context for ${name}`)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
log.error(`tls/context: failed to build context for "${name}": ${err.message}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Return an SNI callback suitable for passing to tls.TLSSocket / tls.Server.
|
|
84
|
+
* Resolves to the most specific context available, falling back to '*'.
|
|
85
|
+
*
|
|
86
|
+
* @returns {Function} (servername: string, done: Function) => void
|
|
87
|
+
*/
|
|
88
|
+
sni_callback() {
|
|
89
|
+
return (servername, done) => {
|
|
90
|
+
const ctx = this.get(servername)
|
|
91
|
+
if (!this._ctxs.has(servername)) {
|
|
92
|
+
log.debug(`tls/context: no context for "${servername}", using default`)
|
|
93
|
+
}
|
|
94
|
+
done(null, ctx ?? null)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clear all stored contexts (e.g. to force a reload). */
|
|
99
|
+
invalidate() {
|
|
100
|
+
this._ctxs.clear()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { ContextStore, build_context }
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Minimal console-based logger shim for standalone use.
|
|
4
|
+
// When haraka-tls is used inside Haraka, Haraka replaces this with its own
|
|
5
|
+
// logger by calling logger.set_logger(haraka_logger) before any other import.
|
|
6
|
+
|
|
7
|
+
const LEVELS = ['debug', 'info', 'notice', 'warn', 'error', 'crit', 'alert', 'emerg']
|
|
8
|
+
|
|
9
|
+
let _log = {
|
|
10
|
+
debug: (msg) => console.debug(msg),
|
|
11
|
+
info: (msg) => console.info(msg),
|
|
12
|
+
notice: (msg) => console.info(msg),
|
|
13
|
+
warn: (msg) => console.warn(msg),
|
|
14
|
+
error: (msg) => console.error(msg),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const log = {
|
|
18
|
+
debug: (msg) => _log.debug(msg),
|
|
19
|
+
info: (msg) => _log.info(msg),
|
|
20
|
+
notice: (msg) => _log.notice(msg),
|
|
21
|
+
warn: (msg) => _log.warn(msg),
|
|
22
|
+
error: (msg) => _log.error(msg),
|
|
23
|
+
|
|
24
|
+
/** Replace the backing logger (e.g. with Haraka's logger). */
|
|
25
|
+
set_logger(impl) {
|
|
26
|
+
_log = impl
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Add log methods (logdebug, loginfo, lognotice, logwarn, logerror, …)
|
|
31
|
+
* to an object instance — mirroring Haraka's logger.add_log_methods(obj).
|
|
32
|
+
*/
|
|
33
|
+
add_log_methods(obj) {
|
|
34
|
+
if (!obj) return
|
|
35
|
+
for (const level of LEVELS) {
|
|
36
|
+
const fn = `log${level}`
|
|
37
|
+
if (Object.hasOwn(obj, fn)) continue
|
|
38
|
+
obj[fn] = (msg) => _log[level] ? _log[level](msg) : _log.info(msg)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = log
|
package/lib/outbound.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const net = require('node:net')
|
|
4
|
+
|
|
5
|
+
const hkredis = require('haraka-plugin-redis')
|
|
6
|
+
|
|
7
|
+
const logger = require('./logger')
|
|
8
|
+
|
|
9
|
+
// Config keys that [outbound] inherits from [main] when not locally overridden.
|
|
10
|
+
// Exhaustive: adding a new TLS option to tls.ini[main] is sufficient — no
|
|
11
|
+
// code change needed here.
|
|
12
|
+
const MAIN_INHERITABLE = [
|
|
13
|
+
'key', 'cert', 'dhparam',
|
|
14
|
+
'ciphers', 'minVersion',
|
|
15
|
+
'honorCipherOrder', 'requestCert', 'rejectUnauthorized',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages outbound TLS configuration and the Redis "TLS-NO-GO" cache that
|
|
20
|
+
* tracks remote hosts which have previously failed TLS negotiation.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* const ob = new OutboundTLS(config_module)
|
|
24
|
+
* ob.load(tls_cfg) // tls_cfg from tls/config.load()
|
|
25
|
+
* await ob.init(cb) // starts Redis if enabled
|
|
26
|
+
* const opts = ob.get_tls_options(mx)
|
|
27
|
+
*/
|
|
28
|
+
class OutboundTLS {
|
|
29
|
+
constructor(cfg_module) {
|
|
30
|
+
this.config = cfg_module
|
|
31
|
+
this.name = 'OutboundTLS'
|
|
32
|
+
logger.add_log_methods(this)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the outbound TLS config from the merged tls_cfg returned by
|
|
37
|
+
* tls/config.load(). Resolves file-name references to Buffers.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} tls_cfg - result of tls/config.load()
|
|
40
|
+
* @returns {this}
|
|
41
|
+
*/
|
|
42
|
+
load(tls_cfg) {
|
|
43
|
+
const cfg = { ...tls_cfg.outbound }
|
|
44
|
+
cfg.redis = tls_cfg.redis // Don't clone — may contain a live redis client
|
|
45
|
+
|
|
46
|
+
// Inherit missing options from [main]
|
|
47
|
+
for (const opt of MAIN_INHERITABLE) {
|
|
48
|
+
if (cfg[opt] !== undefined) continue
|
|
49
|
+
if (tls_cfg.main[opt] !== undefined) cfg[opt] = tls_cfg.main[opt]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve file-name strings → Buffers using the haraka-config module
|
|
53
|
+
for (const field of ['key', 'cert', 'dhparam']) {
|
|
54
|
+
if (!cfg[field]) continue
|
|
55
|
+
const filename = Array.isArray(cfg[field]) ? cfg[field][0] : cfg[field]
|
|
56
|
+
cfg[field] = this.config.get(filename, 'binary')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cfg.no_tls_hosts = Array.isArray(cfg.no_tls_hosts) ? cfg.no_tls_hosts : []
|
|
60
|
+
cfg.force_tls_hosts = Array.isArray(cfg.force_tls_hosts) ? cfg.force_tls_hosts : []
|
|
61
|
+
|
|
62
|
+
this.cfg = cfg
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Optionally connect to Redis for the TLS-NO-GO feature.
|
|
68
|
+
* @param {Function} cb - called when initialisation is complete
|
|
69
|
+
*/
|
|
70
|
+
async init(cb) {
|
|
71
|
+
if (!this.cfg.redis?.disable_for_failed_hosts) return cb()
|
|
72
|
+
this.logdebug('Will disable outbound TLS for failing TLS hosts')
|
|
73
|
+
Object.assign(this, hkredis)
|
|
74
|
+
this.merge_redis_ini()
|
|
75
|
+
this.init_redis_plugin(cb)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Return a TLS options object for an outbound connection to the given MX.
|
|
80
|
+
* Sets `servername` to a hostname (never an IP) for correct SNI behaviour.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ exchange: string, from_dns?: string }} mx
|
|
83
|
+
* @returns {object}
|
|
84
|
+
*/
|
|
85
|
+
get_tls_options(mx) {
|
|
86
|
+
const opts = { ...this.cfg }
|
|
87
|
+
if (net.isIP(mx.exchange)) {
|
|
88
|
+
if (mx.from_dns) opts.servername = mx.from_dns
|
|
89
|
+
} else {
|
|
90
|
+
opts.servername = mx.exchange
|
|
91
|
+
}
|
|
92
|
+
return opts
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether `host` is in the TLS-NO-GO cache.
|
|
97
|
+
* Calls cb_ok() if TLS should be attempted, cb_nogo(reason) if not.
|
|
98
|
+
*/
|
|
99
|
+
check_tls_nogo(host, cb_ok, cb_nogo) {
|
|
100
|
+
if (!this.cfg.redis?.disable_for_failed_hosts) return cb_ok()
|
|
101
|
+
this.db
|
|
102
|
+
.get(`no_tls|${host}`)
|
|
103
|
+
.then((r) => (r ? cb_nogo(r) : cb_ok()))
|
|
104
|
+
.catch((err) => {
|
|
105
|
+
this.logdebug(`Redis error during check_tls_nogo: ${err}`)
|
|
106
|
+
cb_ok()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Record that `host` failed TLS so future connections skip it.
|
|
112
|
+
* @param {string} host
|
|
113
|
+
* @param {Function} [cb]
|
|
114
|
+
*/
|
|
115
|
+
mark_tls_nogo(host, cb) {
|
|
116
|
+
if (!this.cfg.redis?.disable_for_failed_hosts) return cb?.()
|
|
117
|
+
const expiry = this.cfg.redis.disable_expiry ?? 604800
|
|
118
|
+
this.lognotice(`TLS failed for ${host}, disabling for ${expiry}s`)
|
|
119
|
+
this.db
|
|
120
|
+
.setEx(`no_tls|${host}`, expiry, new Date().toISOString())
|
|
121
|
+
.then(() => cb?.())
|
|
122
|
+
.catch((err) => {
|
|
123
|
+
this.logerror(`Redis error during mark_tls_nogo: ${err}`)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { OutboundTLS }
|
package/lib/socket.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Socket wrapper that supports transparent STARTTLS upgrade.
|
|
4
|
+
// Used for both inbound (server) and outbound (client) connections.
|
|
5
|
+
|
|
6
|
+
const net = require('node:net')
|
|
7
|
+
const tls = require('node:tls')
|
|
8
|
+
const { EventEmitter } = require('node:events')
|
|
9
|
+
|
|
10
|
+
const log = require('./logger')
|
|
11
|
+
|
|
12
|
+
// Events forwarded from the underlying socket to the wrapper
|
|
13
|
+
const FORWARDED_EVENTS = ['data', 'connect', 'end', 'close', 'drain', 'timeout']
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A transparent socket wrapper that delegates reads and writes to an
|
|
17
|
+
* underlying net.Socket or tls.TLSSocket, and can be upgraded from plain
|
|
18
|
+
* TCP to TLS mid-stream (STARTTLS) without changing the reference held by
|
|
19
|
+
* the caller.
|
|
20
|
+
*
|
|
21
|
+
* Forwarded events: data, connect, secureConnect, secure, end, close, drain,
|
|
22
|
+
* error, timeout
|
|
23
|
+
*/
|
|
24
|
+
class PluggableStream extends EventEmitter {
|
|
25
|
+
constructor(socket) {
|
|
26
|
+
super()
|
|
27
|
+
this.readable = true
|
|
28
|
+
this.writable = true
|
|
29
|
+
this._timeout = 0
|
|
30
|
+
this._keepalive = false
|
|
31
|
+
this.targetsocket = null
|
|
32
|
+
this.cleartext = null
|
|
33
|
+
|
|
34
|
+
if (socket) this._attach(socket)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Internal attachment/detachment ─────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
_attach(socket) {
|
|
40
|
+
this.targetsocket = socket
|
|
41
|
+
|
|
42
|
+
socket.on('data', (d) => this.emit('data', d))
|
|
43
|
+
socket.on('connect', (...a) => this.emit('connect', ...a))
|
|
44
|
+
socket.on('secureConnect', (...a) => {
|
|
45
|
+
this.emit('secureConnect', ...a)
|
|
46
|
+
this.emit('secure', ...a)
|
|
47
|
+
})
|
|
48
|
+
socket.on('secure', (...a) => this.emit('secure', ...a))
|
|
49
|
+
socket.on('end', () => {
|
|
50
|
+
this.writable = socket.writable
|
|
51
|
+
this.emit('end')
|
|
52
|
+
})
|
|
53
|
+
socket.on('close', (had_err) => {
|
|
54
|
+
this.writable = socket.writable
|
|
55
|
+
this.emit('close', had_err)
|
|
56
|
+
})
|
|
57
|
+
socket.on('drain', () => this.emit('drain'))
|
|
58
|
+
socket.once('error', (err) => {
|
|
59
|
+
this.writable = socket.writable
|
|
60
|
+
err.source = 'tls'
|
|
61
|
+
this.emit('error', err)
|
|
62
|
+
})
|
|
63
|
+
socket.on('timeout', () => this.emit('timeout'))
|
|
64
|
+
|
|
65
|
+
// Mirror address metadata onto the wrapper
|
|
66
|
+
for (const prop of ['remotePort', 'remoteAddress', 'localPort', 'localAddress']) {
|
|
67
|
+
if (socket[prop] != null) this[prop] = socket[prop]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_detach() {
|
|
72
|
+
if (!this.targetsocket) return
|
|
73
|
+
for (const event of ['data', 'secureConnect', 'secure', ...FORWARDED_EVENTS, 'error']) {
|
|
74
|
+
this.targetsocket.removeAllListeners(event)
|
|
75
|
+
}
|
|
76
|
+
// Stub out the old socket so any stray writes go nowhere
|
|
77
|
+
this.targetsocket = { write: () => false, end: () => {} }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Socket interface (delegates to targetsocket) ───────────────────────────
|
|
81
|
+
|
|
82
|
+
write(data, encoding, cb) {
|
|
83
|
+
return this.targetsocket?.write(data, encoding, cb) ?? false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
end(data, encoding) {
|
|
87
|
+
return this.targetsocket?.end(data, encoding)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
destroy() {
|
|
91
|
+
return this.targetsocket?.destroy()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
destroySoon() {
|
|
95
|
+
return this.targetsocket?.destroySoon?.()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pause() {
|
|
99
|
+
this.readable = false
|
|
100
|
+
this.targetsocket?.pause()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resume() {
|
|
104
|
+
this.readable = true
|
|
105
|
+
this.targetsocket?.resume()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setTimeout(ms) {
|
|
109
|
+
this._timeout = ms
|
|
110
|
+
return this.targetsocket?.setTimeout(ms)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setKeepAlive(bool) {
|
|
114
|
+
this._keepalive = bool
|
|
115
|
+
return this.targetsocket?.setKeepAlive(bool)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setNoDelay() {} // no-op — callers may call this
|
|
119
|
+
|
|
120
|
+
unref() {
|
|
121
|
+
return this.targetsocket?.unref()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
isEncrypted() {
|
|
125
|
+
return this.targetsocket?.encrypted ?? false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
isSecure() {
|
|
129
|
+
return !!(this.targetsocket?.encrypted && this.targetsocket?.authorized)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Server factory ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a net.Server whose connections are wrapped in PluggableStream.
|
|
137
|
+
* Each socket gains an `.upgrade(cb)` method that performs the STARTTLS
|
|
138
|
+
* handshake in-place.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} tls_state - { contexts: ContextStore, cfg: object }
|
|
141
|
+
* @param {Function} connection_handler - called with (socket: PluggableStream)
|
|
142
|
+
* @returns {net.Server}
|
|
143
|
+
*/
|
|
144
|
+
function createServer(tls_state, connection_handler) {
|
|
145
|
+
const server = net.createServer((rawSocket) => {
|
|
146
|
+
const socket = new PluggableStream(rawSocket)
|
|
147
|
+
|
|
148
|
+
socket.upgrade = (upgrade_cb) => {
|
|
149
|
+
log.debug('tls/socket: upgrading inbound connection to TLS')
|
|
150
|
+
|
|
151
|
+
socket._detach()
|
|
152
|
+
rawSocket.removeAllListeners('data')
|
|
153
|
+
|
|
154
|
+
const { contexts, cfg } = tls_state
|
|
155
|
+
const tls_opts = {
|
|
156
|
+
...cfg,
|
|
157
|
+
isServer: true,
|
|
158
|
+
server,
|
|
159
|
+
SNICallback: contexts.sni_callback(),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (cfg.requireAuthorized?.includes(rawSocket.localPort)) {
|
|
163
|
+
tls_opts.rejectUnauthorized = true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const cleartext = new tls.TLSSocket(rawSocket, tls_opts)
|
|
167
|
+
|
|
168
|
+
cleartext.on('error', (err) => {
|
|
169
|
+
err.source = 'tls'
|
|
170
|
+
socket.emit('error', err)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
cleartext.on('secure', () => {
|
|
174
|
+
log.debug('tls/socket: inbound TLS secured')
|
|
175
|
+
socket._attach(cleartext)
|
|
176
|
+
const cipher = cleartext.getCipher()
|
|
177
|
+
if (cipher) cipher.version = cleartext.getProtocol()
|
|
178
|
+
socket.emit('secure')
|
|
179
|
+
upgrade_cb?.(
|
|
180
|
+
cleartext.authorized,
|
|
181
|
+
cleartext.authorizationError,
|
|
182
|
+
cleartext.getPeerCertificate(),
|
|
183
|
+
cipher,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
socket.cleartext = cleartext
|
|
188
|
+
if (socket._timeout) cleartext.setTimeout(socket._timeout)
|
|
189
|
+
cleartext.setKeepAlive(socket._keepalive)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
connection_handler(socket)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return server
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Client factory ─────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create a plain TCP socket wrapped in PluggableStream.
|
|
202
|
+
* The socket has an `.upgrade(tls_opts, cb)` method for outbound STARTTLS.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} conn_opts - options forwarded to net.connect()
|
|
205
|
+
* @returns {PluggableStream}
|
|
206
|
+
*/
|
|
207
|
+
function connect(conn_opts = {}) {
|
|
208
|
+
const rawSocket = net.connect(conn_opts)
|
|
209
|
+
const socket = new PluggableStream(rawSocket)
|
|
210
|
+
|
|
211
|
+
socket.upgrade = (tls_opts = {}, upgrade_cb) => {
|
|
212
|
+
log.debug('tls/socket: upgrading outbound connection to TLS')
|
|
213
|
+
|
|
214
|
+
socket._detach()
|
|
215
|
+
rawSocket.removeAllListeners('data')
|
|
216
|
+
|
|
217
|
+
const cleartext = tls.connect({ ...tls_opts, socket: rawSocket })
|
|
218
|
+
|
|
219
|
+
cleartext.on('error', (err) => {
|
|
220
|
+
if (err.reason) log.error(`tls/socket: client TLS error: ${err}`)
|
|
221
|
+
socket.emit('error', err)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
cleartext.once('secureConnect', () => {
|
|
225
|
+
log.debug('tls/socket: outbound TLS secured')
|
|
226
|
+
socket._attach(cleartext)
|
|
227
|
+
const cipher = cleartext.getCipher()
|
|
228
|
+
if (cipher) cipher.version = cleartext.getProtocol()
|
|
229
|
+
upgrade_cb?.(
|
|
230
|
+
cleartext.authorized,
|
|
231
|
+
cleartext.authorizationError,
|
|
232
|
+
cleartext.getPeerCertificate(),
|
|
233
|
+
cipher,
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
socket.cleartext = cleartext
|
|
238
|
+
if (socket._timeout) cleartext.setTimeout(socket._timeout)
|
|
239
|
+
cleartext.setKeepAlive(socket._keepalive)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return socket
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
PluggableStream,
|
|
247
|
+
createServer,
|
|
248
|
+
connect,
|
|
249
|
+
createConnection: connect, // alias for drop-in compat
|
|
250
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "haraka-tls",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Haraka TLS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"haraka",
|
|
7
|
+
"tls"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/haraka/haraka-tls#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/haraka/haraka-tls/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/haraka/haraka-tls.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Matt Simerson <matt@tnpi.net>",
|
|
19
|
+
"type": "commonjs",
|
|
20
|
+
"main": "index.js",
|
|
21
|
+
"files": [
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"index.js",
|
|
24
|
+
"lib"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.js",
|
|
28
|
+
"test:coverage": "node --experimental-test-coverage --test test/*.js",
|
|
29
|
+
"lint": "eslint .",
|
|
30
|
+
"lint:fix": "eslint --fix .",
|
|
31
|
+
"prettier": "prettier --check .",
|
|
32
|
+
"prettier:fix": "prettier --write .",
|
|
33
|
+
"format": "npm run prettier:fix && npm run lint:fix"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"haraka-config": "^1.5.0"
|
|
37
|
+
},
|
|
38
|
+
"optionalDependencies": {
|
|
39
|
+
"haraka-plugin-redis": "*"
|
|
40
|
+
}
|
|
41
|
+
}
|