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 ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
+
5
+ ### Unreleased
6
+
7
+
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
@@ -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
+ }