Haraka 3.1.5 → 3.1.6
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/.prettierignore +1 -1
- package/{Changes.md → CHANGELOG.md} +22 -2
- package/CONTRIBUTORS.md +26 -26
- package/README.md +68 -93
- package/SECURITY.md +178 -0
- package/bin/haraka +7 -14
- package/config/plugins +0 -3
- package/docs/Connection.md +126 -39
- package/docs/CoreConfig.md +92 -74
- package/docs/HAProxy.md +41 -25
- package/docs/Logging.md +68 -38
- package/docs/Outbound.md +124 -179
- package/docs/Plugins.md +38 -59
- package/docs/Transaction.md +78 -83
- package/docs/Tutorial.md +122 -209
- package/docs/plugins/aliases.md +1 -141
- package/docs/plugins/auth/auth_ldap.md +2 -39
- package/docs/plugins/max_unrecognized_commands.md +4 -18
- package/docs/plugins/process_title.md +3 -3
- package/docs/plugins/reseed_rng.md +11 -13
- package/docs/plugins/tls.md +7 -7
- package/docs/plugins/toobusy.md +10 -4
- package/docs/tutorials/SettingUpOutbound.md +40 -48
- package/endpoint.js +32 -2
- package/outbound/hmail.js +3 -2
- package/outbound/index.js +3 -0
- package/package.json +19 -30
- package/server.js +17 -7
- package/test/connection.js +234 -0
- package/test/endpoint.js +27 -0
- package/test/outbound/hmail.js +19 -0
- package/test/outbound/index.js +189 -0
- package/test/outbound/queue.js +92 -0
- package/test/server.js +172 -0
- package/test/tls_socket.js +138 -0
- package/tls_socket.js +2 -2
package/test/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const { createHmac } = require('node:crypto')
|
|
|
6
6
|
const net = require('node:net')
|
|
7
7
|
const path = require('node:path')
|
|
8
8
|
const tls = require('node:tls')
|
|
9
|
+
const constants = require('haraka-constants')
|
|
9
10
|
|
|
10
11
|
const endpoint = require('../endpoint')
|
|
11
12
|
const message = require('haraka-email-message')
|
|
@@ -254,6 +255,177 @@ describe('server', () => {
|
|
|
254
255
|
})
|
|
255
256
|
})
|
|
256
257
|
|
|
258
|
+
describe('lifecycle helpers', () => {
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
this.server = require('../server')
|
|
261
|
+
this.server.cfg = this.server.cfg || { main: {} }
|
|
262
|
+
this.server.cfg.main = this.server.cfg.main || {}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('init_child_respond OK path starts HTTP listeners', () => {
|
|
266
|
+
let called = 0
|
|
267
|
+
const original = this.server.setup_http_listeners
|
|
268
|
+
this.server.setup_http_listeners = () => {
|
|
269
|
+
called++
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
this.server.init_child_respond(constants.ok)
|
|
273
|
+
assert.equal(called, 1)
|
|
274
|
+
} finally {
|
|
275
|
+
this.server.setup_http_listeners = original
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('init_child_respond error path kills master and exits', () => {
|
|
280
|
+
process.env.CLUSTER_MASTER_PID = '12345'
|
|
281
|
+
const originalKill = process.kill
|
|
282
|
+
const originalDump = this.server.logger.dump_and_exit
|
|
283
|
+
let killed = null
|
|
284
|
+
let exitCode = null
|
|
285
|
+
process.kill = (pid) => {
|
|
286
|
+
killed = pid
|
|
287
|
+
}
|
|
288
|
+
this.server.logger.dump_and_exit = (code) => {
|
|
289
|
+
exitCode = code
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
this.server.init_child_respond(constants.deny, 'nope')
|
|
293
|
+
assert.equal(killed, '12345')
|
|
294
|
+
assert.equal(exitCode, 1)
|
|
295
|
+
} finally {
|
|
296
|
+
process.kill = originalKill
|
|
297
|
+
this.server.logger.dump_and_exit = originalDump
|
|
298
|
+
delete process.env.CLUSTER_MASTER_PID
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('listening applies configured uid/gid and marks ready', () => {
|
|
303
|
+
this.server.cfg.main.group = 'staff'
|
|
304
|
+
this.server.cfg.main.user = 'nobody'
|
|
305
|
+
const originalGetGid = process.getgid
|
|
306
|
+
const originalSetGid = process.setgid
|
|
307
|
+
const originalGetUid = process.getuid
|
|
308
|
+
const originalSetUid = process.setuid
|
|
309
|
+
const calls = { setgid: 0, setuid: 0 }
|
|
310
|
+
process.getgid = () => 20
|
|
311
|
+
process.setgid = () => {
|
|
312
|
+
calls.setgid++
|
|
313
|
+
}
|
|
314
|
+
process.getuid = () => 501
|
|
315
|
+
process.setuid = () => {
|
|
316
|
+
calls.setuid++
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
this.server.listening()
|
|
320
|
+
assert.equal(calls.setgid, 1)
|
|
321
|
+
assert.equal(calls.setuid, 1)
|
|
322
|
+
assert.equal(this.server.ready, 1)
|
|
323
|
+
} finally {
|
|
324
|
+
process.getgid = originalGetGid
|
|
325
|
+
process.setgid = originalSetGid
|
|
326
|
+
process.getuid = originalGetUid
|
|
327
|
+
process.setuid = originalSetUid
|
|
328
|
+
delete this.server.cfg.main.group
|
|
329
|
+
delete this.server.cfg.main.user
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('sendToMaster calls receiveAsMaster when not clustered', () => {
|
|
334
|
+
const originalCluster = this.server.cluster
|
|
335
|
+
const originalReceive = this.server.receiveAsMaster
|
|
336
|
+
const seen = []
|
|
337
|
+
this.server.cluster = null
|
|
338
|
+
this.server.receiveAsMaster = (cmd, params) => {
|
|
339
|
+
seen.push([cmd, params])
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
this.server.sendToMaster('flushQueue', ['example.com'])
|
|
343
|
+
assert.deepEqual(seen[0], ['flushQueue', ['example.com']])
|
|
344
|
+
} finally {
|
|
345
|
+
this.server.cluster = originalCluster
|
|
346
|
+
this.server.receiveAsMaster = originalReceive
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('receiveAsMaster ignores invalid commands and executes valid ones', () => {
|
|
351
|
+
const errors = []
|
|
352
|
+
const originalLogError = this.server.logerror
|
|
353
|
+
this.server.logerror = (msg) => errors.push(msg)
|
|
354
|
+
this.server._testCommand = (a, b) => {
|
|
355
|
+
this.server.notes.received = [a, b]
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
this.server.receiveAsMaster('notACommand', [])
|
|
359
|
+
assert.equal(errors.length > 0, true)
|
|
360
|
+
|
|
361
|
+
this.server.receiveAsMaster('_testCommand', ['x', 'y'])
|
|
362
|
+
assert.deepEqual(this.server.notes.received, ['x', 'y'])
|
|
363
|
+
} finally {
|
|
364
|
+
this.server.logerror = originalLogError
|
|
365
|
+
delete this.server._testCommand
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('HTTP helpers', () => {
|
|
371
|
+
beforeEach(() => {
|
|
372
|
+
this.server = require('../server')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('handle404 serves html/json/text based on request accepts', () => {
|
|
376
|
+
const makeReq = (kind) => ({
|
|
377
|
+
accepts(type) {
|
|
378
|
+
return type === kind
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
const responses = []
|
|
382
|
+
const makeRes = () => ({
|
|
383
|
+
status(code) {
|
|
384
|
+
responses.push({ code })
|
|
385
|
+
return this
|
|
386
|
+
},
|
|
387
|
+
sendFile(name, opts) {
|
|
388
|
+
responses.push({ type: 'html', name, opts })
|
|
389
|
+
},
|
|
390
|
+
send(body) {
|
|
391
|
+
responses.push({ type: 'body', body })
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
this.server.handle404(makeReq('html'), makeRes())
|
|
396
|
+
this.server.handle404(makeReq('json'), makeRes())
|
|
397
|
+
this.server.handle404(makeReq('none'), makeRes())
|
|
398
|
+
|
|
399
|
+
assert.equal(responses[0].code, 404)
|
|
400
|
+
assert.equal(responses[1].type, 'html')
|
|
401
|
+
assert.equal(responses[3].type, 'body')
|
|
402
|
+
assert.deepEqual(responses[3].body, { err: 'Not found' })
|
|
403
|
+
assert.equal(responses[5].body, 'Not found!')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('init_http_respond logs and returns when ws is unavailable', () => {
|
|
407
|
+
const Module = require('node:module')
|
|
408
|
+
const originalRequire = Module.prototype.require
|
|
409
|
+
const originalLogError = this.server.logerror
|
|
410
|
+
const errors = []
|
|
411
|
+
this.server.logerror = (msg) => {
|
|
412
|
+
errors.push(msg)
|
|
413
|
+
}
|
|
414
|
+
this.server.http = { server: {} }
|
|
415
|
+
Module.prototype.require = function (id) {
|
|
416
|
+
if (id === 'ws') throw new Error('ws missing')
|
|
417
|
+
return originalRequire.apply(this, arguments)
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
this.server.init_http_respond()
|
|
421
|
+
assert.equal(errors.length > 0, true)
|
|
422
|
+
} finally {
|
|
423
|
+
Module.prototype.require = originalRequire
|
|
424
|
+
this.server.logerror = originalLogError
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
257
429
|
// ── SMTP sessions ─────────────────────────────────────────────────────────
|
|
258
430
|
describe('SMTP sessions', () => {
|
|
259
431
|
beforeEach(async () => setupServer('127.0.0.1:2503'))
|
package/test/tls_socket.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('node:path')
|
|
|
6
6
|
const net = require('node:net')
|
|
7
7
|
const tls = require('node:tls')
|
|
8
8
|
const fs = require('node:fs')
|
|
9
|
+
const { EventEmitter } = require('node:events')
|
|
9
10
|
|
|
10
11
|
// Mock dependencies before requiring the target
|
|
11
12
|
const mock = require('node:test').mock
|
|
@@ -95,10 +96,147 @@ test('tls_socket', async (t) => {
|
|
|
95
96
|
await new Promise((resolve) => server.close(resolve))
|
|
96
97
|
}
|
|
97
98
|
})
|
|
99
|
+
|
|
100
|
+
await t.test('second error handler does not crash when first handler removes all listeners', () => {
|
|
101
|
+
// Regression test for issue #3553
|
|
102
|
+
const originalNetConnect = net.connect
|
|
103
|
+
const originalTlsConnect = tls.connect
|
|
104
|
+
const originalTlsValid = tls_socket.tls_valid
|
|
105
|
+
|
|
106
|
+
const fakeCrypto = new EventEmitter()
|
|
107
|
+
fakeCrypto.writable = true
|
|
108
|
+
fakeCrypto.removeAllListeners = EventEmitter.prototype.removeAllListeners
|
|
109
|
+
fakeCrypto.setTimeout = () => {}
|
|
110
|
+
fakeCrypto.setKeepAlive = () => {}
|
|
111
|
+
|
|
112
|
+
let capturedCleartext
|
|
113
|
+
net.connect = () => fakeCrypto
|
|
114
|
+
tls.connect = () => {
|
|
115
|
+
capturedCleartext = new EventEmitter()
|
|
116
|
+
capturedCleartext.writable = true
|
|
117
|
+
capturedCleartext.setTimeout = () => {}
|
|
118
|
+
capturedCleartext.setKeepAlive = () => {}
|
|
119
|
+
return capturedCleartext
|
|
120
|
+
}
|
|
121
|
+
tls_socket.tls_valid = false
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const socket = tls_socket.connect({ host: 'bad-tls.example.com', port: 25 })
|
|
125
|
+
|
|
126
|
+
// Simulate what release_client does: strip all listeners on first error
|
|
127
|
+
socket.once('error', () => socket.removeAllListeners())
|
|
128
|
+
|
|
129
|
+
socket.upgrade({}, () => {})
|
|
130
|
+
|
|
131
|
+
// capturedCleartext now has two 'error' handlers (on from upgrade, once from attach).
|
|
132
|
+
// Emitting error must NOT throw even though the first handler removes all
|
|
133
|
+
// listeners from the outer socket before the second fires.
|
|
134
|
+
const tlsError = new Error('dh key too small')
|
|
135
|
+
assert.doesNotThrow(() => capturedCleartext.emit('error', tlsError))
|
|
136
|
+
} finally {
|
|
137
|
+
net.connect = originalNetConnect
|
|
138
|
+
tls.connect = originalTlsConnect
|
|
139
|
+
tls_socket.tls_valid = originalTlsValid
|
|
140
|
+
}
|
|
141
|
+
})
|
|
98
142
|
})
|
|
99
143
|
|
|
100
144
|
await t.test('getSocketOpts', async (t) => {
|
|
101
145
|
// Exercise the typo path (would requires failing config.getDir)
|
|
102
146
|
assert.strictEqual(typeof tls_socket.getSocketOpts, 'function')
|
|
103
147
|
})
|
|
148
|
+
|
|
149
|
+
await t.test('getSocketOpts handles missing tls dir', async () => {
|
|
150
|
+
const originalGetCertsDir = tls_socket.get_certs_dir
|
|
151
|
+
tls_socket.get_certs_dir = async () => {
|
|
152
|
+
const err = new Error('missing')
|
|
153
|
+
err.code = 'ENOENT'
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const opts = await tls_socket.getSocketOpts('*')
|
|
158
|
+
assert.ok(opts)
|
|
159
|
+
} finally {
|
|
160
|
+
tls_socket.get_certs_dir = originalGetCertsDir
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await t.test('connect upgrade applies mutual auth cert and timeout/keepalive', async () => {
|
|
165
|
+
const originalNetConnect = net.connect
|
|
166
|
+
const originalTlsConnect = tls.connect
|
|
167
|
+
const originalTlsValid = tls_socket.tls_valid
|
|
168
|
+
const originalCfg = tls_socket.cfg
|
|
169
|
+
const originalCertMap = {
|
|
170
|
+
default: tls_socket.certsByHost['*'],
|
|
171
|
+
host: tls_socket.certsByHost['client-cert.example'],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fakeSocket = new EventEmitter()
|
|
175
|
+
fakeSocket.remotePort = 2525
|
|
176
|
+
fakeSocket.remoteAddress = '127.0.0.1'
|
|
177
|
+
fakeSocket.localPort = 25
|
|
178
|
+
fakeSocket.localAddress = '127.0.0.1'
|
|
179
|
+
fakeSocket.writable = true
|
|
180
|
+
fakeSocket.removeAllListeners = EventEmitter.prototype.removeAllListeners
|
|
181
|
+
fakeSocket.setTimeout = () => {}
|
|
182
|
+
fakeSocket.setKeepAlive = () => {}
|
|
183
|
+
|
|
184
|
+
let capturedOptions
|
|
185
|
+
let timeoutSeen = null
|
|
186
|
+
let keepaliveSeen = null
|
|
187
|
+
|
|
188
|
+
net.connect = () => fakeSocket
|
|
189
|
+
tls.connect = (options) => {
|
|
190
|
+
capturedOptions = options
|
|
191
|
+
const clear = new EventEmitter()
|
|
192
|
+
clear.writable = true
|
|
193
|
+
clear.getCipher = () => ({ name: 'TLS_AES_256_GCM_SHA384' })
|
|
194
|
+
clear.getProtocol = () => 'TLSv1.3'
|
|
195
|
+
clear.getPeerCertificate = () => ({})
|
|
196
|
+
clear.setTimeout = (ms) => {
|
|
197
|
+
timeoutSeen = ms
|
|
198
|
+
}
|
|
199
|
+
clear.setKeepAlive = (value) => {
|
|
200
|
+
keepaliveSeen = value
|
|
201
|
+
}
|
|
202
|
+
process.nextTick(() => clear.emit('secureConnect'))
|
|
203
|
+
return clear
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
tls_socket.tls_valid = true
|
|
207
|
+
tls_socket.cfg = {
|
|
208
|
+
mutual_auth_hosts: { 'mx.example.com': 'client-cert.example' },
|
|
209
|
+
mutual_auth_hosts_exclude: {},
|
|
210
|
+
main: { mutual_tls: false },
|
|
211
|
+
}
|
|
212
|
+
tls_socket.certsByHost['*'] = { key: 'default-key', cert: 'default-cert' }
|
|
213
|
+
tls_socket.certsByHost['client-cert.example'] = { key: 'host-key', cert: 'host-cert' }
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const socket = tls_socket.connect({ host: 'mx.example.com', port: 25 })
|
|
217
|
+
socket.setTimeout(3210)
|
|
218
|
+
socket.setKeepAlive(true)
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve) => {
|
|
221
|
+
socket.upgrade({ rejectUnauthorized: false }, () => resolve())
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
assert.equal(capturedOptions.key, 'host-key')
|
|
225
|
+
assert.equal(capturedOptions.cert, 'host-cert')
|
|
226
|
+
assert.equal(capturedOptions.socket, fakeSocket)
|
|
227
|
+
assert.equal(timeoutSeen, 3210)
|
|
228
|
+
assert.equal(keepaliveSeen, true)
|
|
229
|
+
} finally {
|
|
230
|
+
net.connect = originalNetConnect
|
|
231
|
+
tls.connect = originalTlsConnect
|
|
232
|
+
tls_socket.tls_valid = originalTlsValid
|
|
233
|
+
tls_socket.cfg = originalCfg
|
|
234
|
+
tls_socket.certsByHost['*'] = originalCertMap.default
|
|
235
|
+
if (originalCertMap.host === undefined) {
|
|
236
|
+
delete tls_socket.certsByHost['client-cert.example']
|
|
237
|
+
} else {
|
|
238
|
+
tls_socket.certsByHost['client-cert.example'] = originalCertMap.host
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
})
|
|
104
242
|
})
|
package/tls_socket.js
CHANGED
|
@@ -76,7 +76,7 @@ class pluggableStream extends stream.Stream {
|
|
|
76
76
|
this.targetsocket.once('error', (exception) => {
|
|
77
77
|
this.writable = this.targetsocket.writable
|
|
78
78
|
exception.source = 'tls'
|
|
79
|
-
this.emit('error', exception)
|
|
79
|
+
if (this.listenerCount('error') > 0) this.emit('error', exception)
|
|
80
80
|
})
|
|
81
81
|
this.targetsocket.on('timeout', () => {
|
|
82
82
|
this.emit('timeout')
|
|
@@ -225,7 +225,7 @@ exports.load_tls_ini = (opts) => {
|
|
|
225
225
|
|
|
226
226
|
if (ocsp === undefined && cfg.main.requestOCSP) {
|
|
227
227
|
try {
|
|
228
|
-
ocsp = require('ocsp')
|
|
228
|
+
ocsp = require('@haraka/ocsp')
|
|
229
229
|
log.debug('ocsp loaded')
|
|
230
230
|
ocspCache = new ocsp.Cache()
|
|
231
231
|
} catch (ignore) {
|