Haraka 3.1.4 → 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} +34 -0
- 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 +21 -34
- package/plugins/queue/smtp_forward.js +4 -4
- package/run_tests +3 -15
- package/server.js +17 -7
- package/smtp_client.js +8 -6
- package/test/connection.js +234 -0
- package/test/endpoint.js +32 -4
- package/test/host_pool.js +57 -31
- package/test/logger.js +75 -135
- package/test/outbound/bounce_net_errors.js +87 -131
- package/test/outbound/bounce_rfc3464.js +177 -254
- package/test/outbound/hmail.js +19 -0
- package/test/outbound/index.js +189 -0
- package/test/outbound/queue.js +92 -0
- package/test/plugins/auth/auth_base.js +39 -44
- package/test/plugins/auth/auth_vpopmaild.js +8 -9
- package/test/plugins/queue/smtp_forward.js +953 -183
- package/test/plugins/rcpt_to.host_list_base.js +58 -93
- package/test/plugins/rcpt_to.in_host_list.js +126 -175
- package/test/plugins/record_envelope_addresses.js +8 -8
- package/test/plugins/status.js +10 -10
- package/test/plugins/tls.js +9 -19
- package/test/plugins/xclient.js +75 -110
- package/test/plugins.js +10 -13
- package/test/rfc1869.js +50 -70
- package/test/server.js +438 -421
- package/test/smtp_client.js +1192 -218
- package/test/tls_socket.js +242 -0
- package/tls_socket.js +18 -22
package/test/server.js
CHANGED
|
@@ -1,124 +1,203 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
4
|
+
const assert = require('node:assert/strict')
|
|
5
|
+
const { createHmac } = require('node:crypto')
|
|
6
|
+
const net = require('node:net')
|
|
2
7
|
const path = require('node:path')
|
|
8
|
+
const tls = require('node:tls')
|
|
9
|
+
const constants = require('haraka-constants')
|
|
3
10
|
|
|
4
11
|
const endpoint = require('../endpoint')
|
|
5
12
|
const message = require('haraka-email-message')
|
|
13
|
+
const { get_client } = require('../smtp_client')
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
this.config = require('haraka-config')
|
|
9
|
-
this.server = require('../server')
|
|
10
|
-
done()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const _setupServer = (ip_port, done) => {
|
|
14
|
-
process.env.YES_REALLY_DO_DISCARD = 1 // for queue/discard plugin
|
|
15
|
-
process.env.HARAKA_TEST_DIR = path.resolve('test')
|
|
16
|
-
|
|
17
|
-
// test sets the default path for plugin instances to the test dir
|
|
18
|
-
const test_cfg_path = path.resolve('test')
|
|
19
|
-
|
|
20
|
-
this.server = require('../server')
|
|
21
|
-
this.config = require('haraka-config').module_config(test_cfg_path)
|
|
22
|
-
this.server.logger.loglevel = 6 // INFO
|
|
15
|
+
// ─── CRAM-MD5 helper ──────────────────────────────────────────────────────────
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.server.load_smtp_ini()
|
|
30
|
-
this.server.cfg.main.listen = ip_port
|
|
31
|
-
this.server.cfg.main.smtps_port = 2465
|
|
32
|
-
|
|
33
|
-
this.server.load_default_tls_config(() => {
|
|
34
|
-
this.server.createServer({})
|
|
35
|
-
setTimeout(() => {
|
|
36
|
-
done()
|
|
37
|
-
}, 200)
|
|
38
|
-
})
|
|
17
|
+
/** Compute a CRAM-MD5 response to a server challenge. */
|
|
18
|
+
const cramMd5Response = (user, pass, challenge) => {
|
|
19
|
+
const decoded = Buffer.from(challenge, 'base64').toString()
|
|
20
|
+
const hmac = createHmac('md5', pass).update(decoded).digest('hex')
|
|
21
|
+
return Buffer.from(`${user} ${hmac}`).toString('base64')
|
|
39
22
|
}
|
|
40
23
|
|
|
41
|
-
|
|
42
|
-
delete process.env.YES_REALLY_DO_DISCARD
|
|
43
|
-
delete process.env.HARAKA_TEST_DIR
|
|
44
|
-
this.server.stopListeners()
|
|
45
|
-
this.server.plugins.registered_hooks = {}
|
|
46
|
-
setTimeout(() => {
|
|
47
|
-
done()
|
|
48
|
-
}, 200)
|
|
49
|
-
}
|
|
24
|
+
// ─── Server lifecycle helpers ─────────────────────────────────────────────────
|
|
50
25
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
26
|
+
const setupServer = (ip_port) =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
process.env.YES_REALLY_DO_DISCARD = '1'
|
|
29
|
+
process.env.HARAKA_TEST_DIR = path.resolve('test')
|
|
30
|
+
const test_cfg_path = path.resolve('test')
|
|
54
31
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
32
|
+
this.server = require('../server')
|
|
33
|
+
this.config = require('haraka-config').module_config(test_cfg_path)
|
|
34
|
+
this.server.logger.loglevel = 6
|
|
35
|
+
this.server.config = this.config.module_config(test_cfg_path)
|
|
36
|
+
this.server.plugins.config = this.config.module_config(test_cfg_path)
|
|
61
37
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
})
|
|
66
|
-
assert.deepEqual(['127.0.0.1:25'], listeners)
|
|
67
|
-
})
|
|
38
|
+
this.server.load_smtp_ini()
|
|
39
|
+
this.server.cfg.main.listen = ip_port
|
|
40
|
+
this.server.cfg.main.smtps_port = 2465
|
|
68
41
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
42
|
+
this.server.load_default_tls_config(() => {
|
|
43
|
+
this.server.createServer({})
|
|
44
|
+
setTimeout(resolve, 200)
|
|
72
45
|
})
|
|
46
|
+
})
|
|
73
47
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
48
|
+
const tearDownServer = () =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
delete process.env.YES_REALLY_DO_DISCARD
|
|
51
|
+
delete process.env.HARAKA_TEST_DIR
|
|
52
|
+
this.server.stopListeners()
|
|
53
|
+
this.server.plugins.registered_hooks = {}
|
|
54
|
+
setTimeout(resolve, 200)
|
|
55
|
+
})
|
|
80
56
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
57
|
+
// ─── SMTP session helper ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deliver a message via smtp_client and return a Promise that resolves on
|
|
61
|
+
* acceptance (dot event) or rejects on any SMTP error (bad_code event).
|
|
62
|
+
*
|
|
63
|
+
* When `user`/`pass` are provided, CRAM-MD5 authentication is performed
|
|
64
|
+
* before sending the message.
|
|
65
|
+
*/
|
|
66
|
+
const sendMessage = ({
|
|
67
|
+
host = '127.0.0.1',
|
|
68
|
+
port,
|
|
69
|
+
from = '<test@haraka.local>',
|
|
70
|
+
to = '<discard@haraka.local>',
|
|
71
|
+
user,
|
|
72
|
+
pass,
|
|
73
|
+
body = 'Hello from smtp_client test',
|
|
74
|
+
} = {}) =>
|
|
75
|
+
new Promise((resolve, reject) => {
|
|
76
|
+
get_client(
|
|
77
|
+
{ notes: {} },
|
|
78
|
+
(client) => {
|
|
79
|
+
let credsSent = false
|
|
80
|
+
|
|
81
|
+
client
|
|
82
|
+
.on('greeting', (cmd) => client.send_command(cmd, host))
|
|
83
|
+
.on('helo', () => {
|
|
84
|
+
if (user && !credsSent) {
|
|
85
|
+
client.authenticating = true
|
|
86
|
+
client.send_command('AUTH', 'CRAM-MD5')
|
|
87
|
+
} else {
|
|
88
|
+
client.send_command('MAIL', `FROM:${from}`)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.on('auth', () => {
|
|
92
|
+
if (client.authenticated) {
|
|
93
|
+
client.send_command('MAIL', `FROM:${from}`)
|
|
94
|
+
} else if (!credsSent) {
|
|
95
|
+
credsSent = true
|
|
96
|
+
const resp = cramMd5Response(user, pass, client.response[0])
|
|
97
|
+
// Write CRAM-MD5 response directly (no command prefix)
|
|
98
|
+
client.command = 'auth'
|
|
99
|
+
client.response = []
|
|
100
|
+
client.socket.write(`${resp}\r\n`)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
.on('mail', () => client.send_command('RCPT', `TO:${to}`))
|
|
104
|
+
.on('rcpt', () => client.send_command('DATA'))
|
|
105
|
+
.on('data', () => {
|
|
106
|
+
const stream = new message.stream({ main: { spool_after: 1024 } }, 'testId')
|
|
107
|
+
stream.on('end', () => client.socket.write('.\r\n'))
|
|
108
|
+
stream.add_line('Subject: test\r\n')
|
|
109
|
+
stream.add_line('\r\n')
|
|
110
|
+
stream.add_line(`${body}\r\n`)
|
|
111
|
+
stream.add_line_end()
|
|
112
|
+
client.start_data(stream)
|
|
113
|
+
})
|
|
114
|
+
.on('dot', () => {
|
|
115
|
+
client.release()
|
|
116
|
+
resolve()
|
|
117
|
+
})
|
|
118
|
+
.on('bad_code', (code, msg) => {
|
|
119
|
+
client.release()
|
|
120
|
+
reject(new Error(`${code} ${msg}`))
|
|
121
|
+
})
|
|
122
|
+
},
|
|
123
|
+
{ host, port, connect_timeout: 5 },
|
|
124
|
+
)
|
|
125
|
+
})
|
|
85
126
|
|
|
86
|
-
|
|
87
|
-
const listeners = this.server.get_listen_addrs({ listen: '[::1]' }, 250)
|
|
88
|
-
assert.deepEqual(['[::1]:250'], listeners)
|
|
89
|
-
})
|
|
127
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
90
128
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
describe('server', () => {
|
|
130
|
+
// ── get_listen_addrs ──────────────────────────────────────────────────────
|
|
131
|
+
describe('get_listen_addrs', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
this.config = require('haraka-config')
|
|
134
|
+
this.server = require('../server')
|
|
96
135
|
})
|
|
97
136
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
const cases = [
|
|
138
|
+
{
|
|
139
|
+
desc: 'IPv4 fully qualified',
|
|
140
|
+
args: [{ listen: '127.0.0.1:25' }],
|
|
141
|
+
expected: ['127.0.0.1:25'],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
desc: 'IPv4, default port',
|
|
145
|
+
args: [{ listen: '127.0.0.1' }],
|
|
146
|
+
expected: ['127.0.0.1:25'],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
desc: 'IPv4, custom port',
|
|
150
|
+
args: [{ listen: '127.0.0.1' }, 250],
|
|
151
|
+
expected: ['127.0.0.1:250'],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
desc: 'IPv6 fully qualified',
|
|
155
|
+
args: [{ listen: '[::1]:25' }],
|
|
156
|
+
expected: ['[::1]:25'],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
desc: 'IPv6, default port',
|
|
160
|
+
args: [{ listen: '[::1]' }],
|
|
161
|
+
expected: ['[::1]:25'],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
desc: 'IPv6, custom port',
|
|
165
|
+
args: [{ listen: '[::1]' }, 250],
|
|
166
|
+
expected: ['[::1]:250'],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
desc: 'IPv4 & IPv6 fully qualified',
|
|
170
|
+
args: [{ listen: '127.0.0.1:25,[::1]:25' }],
|
|
171
|
+
expected: ['127.0.0.1:25', '[::1]:25'],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
desc: 'IPv4 & IPv6, default port',
|
|
175
|
+
args: [{ listen: '127.0.0.1:25,[::1]' }],
|
|
176
|
+
expected: ['127.0.0.1:25', '[::1]:25'],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
desc: 'IPv4 & IPv6, custom port',
|
|
180
|
+
args: [{ listen: '127.0.0.1,[::1]' }, 250],
|
|
181
|
+
expected: ['127.0.0.1:250', '[::1]:250'],
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for (const { desc, args, expected } of cases) {
|
|
186
|
+
it(desc, () => {
|
|
187
|
+
assert.deepEqual(this.server.get_listen_addrs(...args), expected)
|
|
101
188
|
})
|
|
102
|
-
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('IPv4 & IPv6, custom port', () => {
|
|
106
|
-
const listeners = this.server.get_listen_addrs(
|
|
107
|
-
{
|
|
108
|
-
listen: '127.0.0.1,[::1]',
|
|
109
|
-
},
|
|
110
|
-
250,
|
|
111
|
-
)
|
|
112
|
-
assert.deepEqual(['127.0.0.1:250', '[::1]:250'], listeners)
|
|
113
|
-
})
|
|
189
|
+
}
|
|
114
190
|
})
|
|
115
191
|
|
|
192
|
+
// ── load_smtp_ini ─────────────────────────────────────────────────────────
|
|
116
193
|
describe('load_smtp_ini', () => {
|
|
117
|
-
beforeEach(
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
this.config = require('haraka-config')
|
|
196
|
+
this.server = require('../server')
|
|
197
|
+
})
|
|
118
198
|
|
|
119
199
|
it('saves settings to Server.cfg', () => {
|
|
120
200
|
this.server.load_smtp_ini()
|
|
121
|
-
// console.log(this.server.cfg);
|
|
122
201
|
const c = this.server.cfg.main
|
|
123
202
|
assert.notEqual(c.daemonize, undefined)
|
|
124
203
|
assert.notEqual(c.daemon_log_file, undefined)
|
|
@@ -126,387 +205,325 @@ describe('server', () => {
|
|
|
126
205
|
})
|
|
127
206
|
})
|
|
128
207
|
|
|
208
|
+
// ── get_smtp_server ───────────────────────────────────────────────────────
|
|
129
209
|
describe('get_smtp_server', () => {
|
|
130
|
-
beforeEach((
|
|
131
|
-
this.config = require('haraka-config')
|
|
132
|
-
this.config = this.config.module_config(path.resolve('test'))
|
|
133
|
-
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
this.config = require('haraka-config').module_config(path.resolve('test'))
|
|
134
212
|
this.server = require('../server')
|
|
135
213
|
this.server.config = this.config
|
|
136
214
|
this.server.plugins.config = this.config
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
setTimeout(() => {
|
|
140
|
-
done()
|
|
141
|
-
}, 200)
|
|
215
|
+
await new Promise((resolve) => {
|
|
216
|
+
this.server.load_default_tls_config(() => setTimeout(resolve, 200))
|
|
142
217
|
})
|
|
143
218
|
})
|
|
144
219
|
|
|
145
|
-
it('gets a net server object', (
|
|
146
|
-
this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
done()
|
|
156
|
-
})
|
|
157
|
-
})
|
|
220
|
+
it('gets a net server object', async () => {
|
|
221
|
+
const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
|
|
222
|
+
if (!server) {
|
|
223
|
+
if (process.env.CI) return
|
|
224
|
+
assert.fail('unable to bind to 0.0.0.0:2501')
|
|
225
|
+
}
|
|
226
|
+
assert.ok(server)
|
|
227
|
+
assert.equal(server.has_tls, false)
|
|
228
|
+
const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
|
|
229
|
+
assert.equal(count, 0)
|
|
158
230
|
})
|
|
159
231
|
|
|
160
|
-
it('gets a TLS net server object', (
|
|
232
|
+
it('gets a TLS net server object', async () => {
|
|
161
233
|
this.server.cfg.main.smtps_port = 2502
|
|
162
|
-
this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
done()
|
|
172
|
-
})
|
|
173
|
-
})
|
|
234
|
+
const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
|
|
235
|
+
if (!server) {
|
|
236
|
+
if (process.env.CI) return
|
|
237
|
+
assert.fail('unable to bind to 0.0.0.0:2502')
|
|
238
|
+
}
|
|
239
|
+
assert.ok(server)
|
|
240
|
+
assert.equal(server.has_tls, true)
|
|
241
|
+
const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
|
|
242
|
+
assert.equal(count, 0)
|
|
174
243
|
})
|
|
175
244
|
})
|
|
176
245
|
|
|
246
|
+
// ── get_http_docroot ──────────────────────────────────────────────────────
|
|
177
247
|
describe('get_http_docroot', () => {
|
|
178
|
-
beforeEach(
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
this.config = require('haraka-config')
|
|
250
|
+
this.server = require('../server')
|
|
251
|
+
})
|
|
179
252
|
|
|
180
253
|
it('gets a fs path', () => {
|
|
181
254
|
assert.ok(this.server.get_http_docroot())
|
|
182
255
|
})
|
|
183
256
|
})
|
|
184
257
|
|
|
185
|
-
describe('
|
|
186
|
-
beforeEach((
|
|
187
|
-
|
|
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 || {}
|
|
188
263
|
})
|
|
189
264
|
|
|
190
|
-
|
|
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
|
+
})
|
|
191
278
|
|
|
192
|
-
it('
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
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
|
|
196
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
|
+
})
|
|
197
301
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
client.start_data(message_stream)
|
|
228
|
-
})
|
|
229
|
-
.on('dot', () => {
|
|
230
|
-
assert.ok(1)
|
|
231
|
-
client.release()
|
|
232
|
-
})
|
|
233
|
-
.on('bad_code', (code, msg) => {
|
|
234
|
-
client.release()
|
|
235
|
-
})
|
|
236
|
-
},
|
|
237
|
-
{ port: 2500, host: 'localhost', cfg },
|
|
238
|
-
)
|
|
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
|
+
}
|
|
239
331
|
})
|
|
240
|
-
})
|
|
241
332
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
}
|
|
245
348
|
})
|
|
246
349
|
|
|
247
|
-
|
|
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
|
+
})
|
|
248
369
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
host: '127.0.0.1',
|
|
253
|
-
port: 2503,
|
|
254
|
-
tls: {
|
|
255
|
-
// do not fail on invalid certs
|
|
256
|
-
rejectUnauthorized: false,
|
|
257
|
-
},
|
|
258
|
-
})
|
|
259
|
-
transporter.sendMail(
|
|
260
|
-
{
|
|
261
|
-
from: '"Testalicious Matt" <harakamail@gmail.com>',
|
|
262
|
-
to: 'nobody-will-see-this@haraka.local',
|
|
263
|
-
envelope: {
|
|
264
|
-
from: 'Haraka Test <test@haraka.local>',
|
|
265
|
-
to: 'Discard Queue <discard@haraka.local>',
|
|
266
|
-
},
|
|
267
|
-
subject: 'Hello ✔',
|
|
268
|
-
text: 'Hello world ?',
|
|
269
|
-
html: '<b>Hello world ?</b>',
|
|
270
|
-
},
|
|
271
|
-
(error, info) => {
|
|
272
|
-
if (error) {
|
|
273
|
-
console.log(error)
|
|
274
|
-
return
|
|
275
|
-
}
|
|
276
|
-
assert.deepEqual(info.accepted, ['discard@haraka.local'])
|
|
277
|
-
console.log(`Message sent: ${info.response}`)
|
|
278
|
-
done()
|
|
279
|
-
},
|
|
280
|
-
)
|
|
370
|
+
describe('HTTP helpers', () => {
|
|
371
|
+
beforeEach(() => {
|
|
372
|
+
this.server = require('../server')
|
|
281
373
|
})
|
|
282
374
|
|
|
283
|
-
it('
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
port: 2503,
|
|
288
|
-
auth: {
|
|
289
|
-
user: 'matt',
|
|
290
|
-
pass: 'goodPass',
|
|
291
|
-
},
|
|
292
|
-
requireTLS: true,
|
|
293
|
-
tls: {
|
|
294
|
-
// do not fail on invalid certs
|
|
295
|
-
rejectUnauthorized: false,
|
|
375
|
+
it('handle404 serves html/json/text based on request accepts', () => {
|
|
376
|
+
const makeReq = (kind) => ({
|
|
377
|
+
accepts(type) {
|
|
378
|
+
return type === kind
|
|
296
379
|
},
|
|
297
380
|
})
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
envelope: {
|
|
304
|
-
from: 'Haraka Test <test@haraka.local>',
|
|
305
|
-
to: 'Discard Queue <discard@haraka.local>',
|
|
306
|
-
},
|
|
307
|
-
subject: 'Hello ✔',
|
|
308
|
-
text: 'Hello world ?',
|
|
309
|
-
html: '<b>Hello world ?</b>',
|
|
381
|
+
const responses = []
|
|
382
|
+
const makeRes = () => ({
|
|
383
|
+
status(code) {
|
|
384
|
+
responses.push({ code })
|
|
385
|
+
return this
|
|
310
386
|
},
|
|
311
|
-
(
|
|
312
|
-
|
|
313
|
-
console.log(error)
|
|
314
|
-
return
|
|
315
|
-
}
|
|
316
|
-
assert.deepEqual(info.accepted, ['discard@haraka.local'])
|
|
317
|
-
console.log(`Message sent: ${info.response}`)
|
|
318
|
-
done()
|
|
387
|
+
sendFile(name, opts) {
|
|
388
|
+
responses.push({ type: 'html', name, opts })
|
|
319
389
|
},
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
it('rejects invalid auth', (done) => {
|
|
324
|
-
const nodemailer = require('nodemailer')
|
|
325
|
-
const transporter = nodemailer.createTransport({
|
|
326
|
-
host: '127.0.0.1',
|
|
327
|
-
port: 2503,
|
|
328
|
-
auth: {
|
|
329
|
-
user: 'matt',
|
|
330
|
-
pass: 'badPass',
|
|
331
|
-
},
|
|
332
|
-
tls: {
|
|
333
|
-
// do not fail on invalid certs
|
|
334
|
-
rejectUnauthorized: false,
|
|
390
|
+
send(body) {
|
|
391
|
+
responses.push({ type: 'body', body })
|
|
335
392
|
},
|
|
336
393
|
})
|
|
337
394
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
to: 'nobody-will-see-this@haraka.local',
|
|
342
|
-
envelope: {
|
|
343
|
-
from: 'Haraka Test <test@haraka.local>',
|
|
344
|
-
to: 'Discard Queue <discard@haraka.local>',
|
|
345
|
-
},
|
|
346
|
-
subject: 'Hello ✔',
|
|
347
|
-
text: 'Hello world ?',
|
|
348
|
-
html: '<b>Hello world ?</b>',
|
|
349
|
-
},
|
|
350
|
-
(error, info) => {
|
|
351
|
-
if (error) {
|
|
352
|
-
assert.equal(error.code, 'EAUTH')
|
|
353
|
-
// console.log(error);
|
|
354
|
-
return done()
|
|
355
|
-
}
|
|
356
|
-
console.log(info.response)
|
|
357
|
-
done()
|
|
358
|
-
},
|
|
359
|
-
)
|
|
360
|
-
})
|
|
395
|
+
this.server.handle404(makeReq('html'), makeRes())
|
|
396
|
+
this.server.handle404(makeReq('json'), makeRes())
|
|
397
|
+
this.server.handle404(makeReq('none'), makeRes())
|
|
361
398
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// do not fail on invalid certs
|
|
369
|
-
rejectUnauthorized: false,
|
|
370
|
-
},
|
|
371
|
-
})
|
|
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
|
+
})
|
|
372
405
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (error) {
|
|
394
|
-
console.log(error)
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
assert.deepEqual(info.accepted, ['discard@haraka.local'])
|
|
398
|
-
console.log(`Message sent: ${info.response}`)
|
|
399
|
-
done()
|
|
400
|
-
},
|
|
401
|
-
)
|
|
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
|
+
}
|
|
402
426
|
})
|
|
403
427
|
})
|
|
404
428
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
429
|
+
// ── SMTP sessions ─────────────────────────────────────────────────────────
|
|
430
|
+
describe('SMTP sessions', () => {
|
|
431
|
+
beforeEach(async () => setupServer('127.0.0.1:2503'))
|
|
432
|
+
afterEach(async () => tearDownServer())
|
|
409
433
|
|
|
410
|
-
|
|
434
|
+
it('accepts plain SMTP message', async () => {
|
|
435
|
+
await sendMessage({ port: 2503 })
|
|
436
|
+
})
|
|
411
437
|
|
|
412
|
-
it('
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
host: '127.0.0.1',
|
|
416
|
-
port: 2465,
|
|
417
|
-
secure: true,
|
|
418
|
-
tls: {
|
|
419
|
-
// do not fail on invalid certs
|
|
420
|
-
rejectUnauthorized: false,
|
|
421
|
-
},
|
|
422
|
-
})
|
|
438
|
+
it('accepts CRAM-MD5 authenticated SMTP', async () => {
|
|
439
|
+
await sendMessage({ port: 2503, user: 'matt', pass: 'goodPass' })
|
|
440
|
+
})
|
|
423
441
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
transporter.sendMail(
|
|
427
|
-
{
|
|
428
|
-
from: '"Testalicious Matt" <harakamail@gmail.com>',
|
|
429
|
-
to: 'nobody-will-see-this@haraka.local',
|
|
430
|
-
envelope: {
|
|
431
|
-
from: 'Haraka Test <test@haraka.local>',
|
|
432
|
-
to: 'Discard Queue <discard@haraka.local>',
|
|
433
|
-
},
|
|
434
|
-
subject: 'Hello ✔',
|
|
435
|
-
text: 'Hello world ?',
|
|
436
|
-
html: '<b>Hello world ?</b>',
|
|
437
|
-
},
|
|
438
|
-
(error, info) => {
|
|
439
|
-
if (error) {
|
|
440
|
-
// console.log(error);
|
|
441
|
-
if (error.message === 'socket hang up') {
|
|
442
|
-
// node 6 & 8
|
|
443
|
-
assert.equal(error.message, 'socket hang up')
|
|
444
|
-
} else if (/alert certificate required/.test(error.message)) {
|
|
445
|
-
// node 18
|
|
446
|
-
assert.ok(/alert certificate required/.test(error.message))
|
|
447
|
-
} else {
|
|
448
|
-
// node 10+
|
|
449
|
-
assert.equal(
|
|
450
|
-
error.message,
|
|
451
|
-
'Client network socket disconnected before secure TLS connection was established',
|
|
452
|
-
)
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
done()
|
|
456
|
-
},
|
|
457
|
-
)
|
|
458
|
-
}, 500)
|
|
442
|
+
it('rejects invalid CRAM-MD5 credentials', async () => {
|
|
443
|
+
await assert.rejects(() => sendMessage({ port: 2503, user: 'matt', pass: 'badPass' }), /5\d\d/)
|
|
459
444
|
})
|
|
460
|
-
})
|
|
461
445
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
446
|
+
it('accepts message with custom headers', async () => {
|
|
447
|
+
await sendMessage({
|
|
448
|
+
port: 2503,
|
|
449
|
+
from: '<sender@haraka.local>',
|
|
450
|
+
to: '<discard@haraka.local>',
|
|
451
|
+
body: 'X-Custom: test-value\r\n\r\nBody text',
|
|
452
|
+
})
|
|
465
453
|
})
|
|
454
|
+
})
|
|
466
455
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
456
|
+
// ── requireAuthorized: SMTPS (implicit TLS) ───────────────────────────────
|
|
457
|
+
describe('requireAuthorized_SMTPS', () => {
|
|
458
|
+
beforeEach(async () => setupServer('127.0.0.1:2465'))
|
|
459
|
+
afterEach(async () => tearDownServer())
|
|
460
|
+
|
|
461
|
+
it('rejects non-validated SMTPS connection', async () => {
|
|
462
|
+
// Port 2465 is configured as SMTPS with requireAuthorized.
|
|
463
|
+
// In TLSv1.3 the handshake completes (secureConnect fires), then the server
|
|
464
|
+
// sends a post-handshake "certificate required" alert as a socket error.
|
|
465
|
+
const err = await new Promise((resolve) => {
|
|
466
|
+
const socket = tls.connect({
|
|
467
|
+
host: '127.0.0.1',
|
|
468
|
+
port: 2465,
|
|
475
469
|
rejectUnauthorized: false,
|
|
476
|
-
}
|
|
470
|
+
})
|
|
471
|
+
socket.on('error', resolve)
|
|
472
|
+
// secureConnect may fire before the post-handshake alert; keep waiting.
|
|
473
|
+
socket.on('secureConnect', () => {})
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
socket.destroy()
|
|
476
|
+
resolve(new Error('timeout'))
|
|
477
|
+
}, 3000)
|
|
477
478
|
})
|
|
479
|
+
assert.ok(
|
|
480
|
+
/socket hang up|disconnected before secure TLS|alert certificate required/.test(err.message),
|
|
481
|
+
`unexpected error: ${err.message}`,
|
|
482
|
+
)
|
|
483
|
+
})
|
|
484
|
+
})
|
|
478
485
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
486
|
+
// ── requireAuthorized: STARTTLS ───────────────────────────────────────────
|
|
487
|
+
describe('requireAuthorized_STARTTLS', () => {
|
|
488
|
+
beforeEach(async () => setupServer('127.0.0.1:2587'))
|
|
489
|
+
afterEach(async () => tearDownServer())
|
|
490
|
+
|
|
491
|
+
it('rejects non-validated STARTTLS connection', async () => {
|
|
492
|
+
// Port 2587 is plain SMTP; requireAuthorized enforces mutual TLS on STARTTLS upgrade.
|
|
493
|
+
// In TLSv1.3 secureConnect fires first, then the server sends a post-handshake
|
|
494
|
+
// "certificate required" alert. Use raw sockets to observe the TLS error.
|
|
495
|
+
// (smtp_client's upgrade path silently swallows the post-upgrade error.)
|
|
496
|
+
const err = await new Promise((resolve) => {
|
|
497
|
+
const sock = net.connect({ host: '127.0.0.1', port: 2587 })
|
|
498
|
+
let state = 'greeting'
|
|
499
|
+
let buf = ''
|
|
500
|
+
sock.on('data', (d) => {
|
|
501
|
+
buf += d.toString()
|
|
502
|
+
for (const line of buf.split('\r\n').slice(0, -1)) {
|
|
503
|
+
buf = buf.slice(line.length + 2)
|
|
504
|
+
if (line[3] === '-') continue // multi-line continuation
|
|
505
|
+
if (state === 'greeting') {
|
|
506
|
+
sock.write('EHLO test\r\n')
|
|
507
|
+
state = 'ehlo'
|
|
508
|
+
} else if (state === 'ehlo') {
|
|
509
|
+
sock.write('STARTTLS\r\n')
|
|
510
|
+
state = 'starttls'
|
|
511
|
+
} else if (state === 'starttls') {
|
|
512
|
+
state = 'tls'
|
|
513
|
+
const cleartext = tls.connect({ socket: sock, rejectUnauthorized: false })
|
|
514
|
+
cleartext.on('secureConnect', () => {})
|
|
515
|
+
cleartext.on('error', resolve)
|
|
516
|
+
cleartext.on('close', () => resolve(new Error('closed without error')))
|
|
505
517
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
)
|
|
509
|
-
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
sock.on('error', resolve)
|
|
521
|
+
setTimeout(() => resolve(new Error('timeout')), 3000)
|
|
522
|
+
})
|
|
523
|
+
assert.ok(
|
|
524
|
+
/alert certificate required|socket hang up|disconnected/.test(err.message),
|
|
525
|
+
`unexpected error: ${err.message}`,
|
|
526
|
+
)
|
|
510
527
|
})
|
|
511
528
|
})
|
|
512
529
|
})
|