Haraka 3.1.5 → 3.1.7
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} +54 -3
- package/CONTRIBUTORS.md +26 -26
- package/Plugins.md +99 -99
- package/README.md +68 -93
- package/SECURITY.md +178 -0
- package/bin/haraka +7 -14
- package/config/plugins +0 -3
- package/config/smtp_forward.ini +10 -0
- package/config/smtp_proxy.ini +10 -0
- package/connection.js +25 -8
- 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/queue/smtp_forward.md +19 -3
- package/docs/plugins/queue/smtp_proxy.md +10 -2
- 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/haraka.js +1 -1
- package/outbound/hmail.js +42 -41
- package/outbound/index.js +7 -4
- package/outbound/tls.js +2 -43
- package/package.json +51 -61
- package/plugins/auth/auth_base.js +9 -3
- package/plugins/auth/auth_proxy.js +14 -11
- package/plugins/block_me.js +4 -2
- package/plugins/prevent_credential_leaks.js +3 -1
- package/plugins/process_title.js +6 -6
- package/plugins/queue/qmail-queue.js +15 -19
- package/plugins/queue/smtp_forward.js +12 -4
- package/plugins/queue/smtp_proxy.js +14 -3
- package/plugins/tls.js +13 -5
- package/plugins/xclient.js +3 -1
- package/server.js +22 -10
- package/smtp_client.js +20 -11
- package/test/config/block_me.recipient +1 -0
- package/test/config/block_me.senders +1 -0
- package/test/connection.js +258 -0
- package/test/endpoint.js +27 -0
- package/test/outbound/bounce_net_errors.js +3 -2
- 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_bridge.js +80 -0
- package/test/plugins/auth/flat_file.js +128 -0
- package/test/plugins/block_me.js +157 -0
- package/test/plugins/data.signatures.js +114 -0
- package/test/plugins/delay_deny.js +263 -0
- package/test/plugins/prevent_credential_leaks.js +178 -0
- package/test/plugins/process_title.js +135 -0
- package/test/plugins/queue/deliver.js +99 -0
- package/test/plugins/queue/discard.js +79 -0
- package/test/plugins/queue/lmtp.js +138 -0
- package/test/plugins/queue/qmail-queue.js +99 -0
- package/test/plugins/queue/quarantine.js +81 -0
- package/test/plugins/queue/smtp_bridge.js +154 -0
- package/test/plugins/queue/smtp_forward.js +42 -6
- package/test/plugins/queue/smtp_proxy.js +139 -0
- package/test/plugins/reseed_rng.js +34 -0
- package/test/plugins/tarpit.js +91 -0
- package/test/plugins/tls.js +25 -0
- package/test/plugins/toobusy.js +21 -0
- package/test/plugins/xclient.js +14 -0
- package/test/server.js +231 -0
- package/test/smtp_client.js +45 -12
- package/test/tls_socket.js +220 -0
- package/tls_socket.js +52 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { describe, it, beforeEach, afterEach, before } = require('node:test')
|
|
6
|
+
|
|
7
|
+
const fixtures = require('haraka-test-fixtures')
|
|
8
|
+
|
|
9
|
+
const tls_socket = require('../../../tls_socket')
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
require('haraka-constants').import(global)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('queue/smtp_proxy', () => {
|
|
16
|
+
let plugin, conn
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
plugin = new fixtures.plugin('queue/smtp_proxy')
|
|
20
|
+
plugin.load_smtp_proxy_ini()
|
|
21
|
+
conn = fixtures.connection.createConnection()
|
|
22
|
+
conn.init_transaction()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('hook_rset', () => {
|
|
26
|
+
it('calls next() when no smtp_client in notes', (t, done) => {
|
|
27
|
+
plugin.hook_rset((rc) => {
|
|
28
|
+
assert.equal(rc, undefined)
|
|
29
|
+
done()
|
|
30
|
+
}, conn)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('releases smtp_client and calls next() when smtp_client exists', (t, done) => {
|
|
34
|
+
let released = false
|
|
35
|
+
conn.notes.smtp_client = {
|
|
36
|
+
release: () => {
|
|
37
|
+
released = true
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
plugin.hook_rset((rc) => {
|
|
41
|
+
assert.equal(released, true)
|
|
42
|
+
assert.equal(conn.notes.smtp_client, undefined)
|
|
43
|
+
done()
|
|
44
|
+
}, conn)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('hook_quit', () => {
|
|
49
|
+
it('calls next() when no smtp_client in notes', (t, done) => {
|
|
50
|
+
plugin.hook_quit((rc) => {
|
|
51
|
+
assert.equal(rc, undefined)
|
|
52
|
+
done()
|
|
53
|
+
}, conn)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('is the same function as hook_rset', () => {
|
|
57
|
+
assert.equal(plugin.hook_rset, plugin.hook_quit)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('hook_disconnect', () => {
|
|
62
|
+
it('calls next() when no smtp_client in notes', (t, done) => {
|
|
63
|
+
plugin.hook_disconnect((rc) => {
|
|
64
|
+
assert.equal(rc, undefined)
|
|
65
|
+
done()
|
|
66
|
+
}, conn)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('releases and calls next when smtp_client exists', (t, done) => {
|
|
70
|
+
let released = false
|
|
71
|
+
let callNextCalled = false
|
|
72
|
+
conn.notes.smtp_client = {
|
|
73
|
+
release: () => {
|
|
74
|
+
released = true
|
|
75
|
+
},
|
|
76
|
+
call_next: () => {
|
|
77
|
+
callNextCalled = true
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
plugin.hook_disconnect((rc) => {
|
|
81
|
+
assert.equal(released, true)
|
|
82
|
+
assert.equal(callNextCalled, true)
|
|
83
|
+
assert.equal(conn.notes.smtp_client, undefined)
|
|
84
|
+
done()
|
|
85
|
+
}, conn)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('hook_queue', () => {
|
|
90
|
+
it('calls next() when transaction is missing', (t, done) => {
|
|
91
|
+
const connNoTxn = fixtures.connection.createConnection()
|
|
92
|
+
plugin.hook_queue((rc) => {
|
|
93
|
+
assert.equal(rc, undefined)
|
|
94
|
+
done()
|
|
95
|
+
}, connNoTxn)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('calls next() when smtp_client is not in notes', (t, done) => {
|
|
99
|
+
plugin.hook_queue((rc) => {
|
|
100
|
+
assert.equal(rc, undefined)
|
|
101
|
+
done()
|
|
102
|
+
}, conn)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('tls_options', () => {
|
|
107
|
+
let origTlsConfig, origTlsCfg
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
origTlsConfig = tls_socket.config
|
|
111
|
+
origTlsCfg = tls_socket.cfg
|
|
112
|
+
tls_socket.config = require('haraka-config').module_config(path.resolve('test'))
|
|
113
|
+
tls_socket.cfg = undefined
|
|
114
|
+
// re-derive with test/config in scope
|
|
115
|
+
plugin.load_smtp_proxy_ini()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
tls_socket.config = origTlsConfig
|
|
120
|
+
tls_socket.cfg = origTlsCfg
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('populates tls_options from tls.ini [main]', () => {
|
|
124
|
+
assert.ok(plugin.tls_options)
|
|
125
|
+
assert.equal(plugin.tls_options.rejectUnauthorized, false)
|
|
126
|
+
assert.equal(plugin.tls_options.minVersion, 'TLSv1')
|
|
127
|
+
assert.ok(plugin.tls_options.ciphers)
|
|
128
|
+
assert.ok(Array.isArray(plugin.tls_options.no_tls_hosts))
|
|
129
|
+
assert.ok(Array.isArray(plugin.tls_options.force_tls_hosts))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('reload re-derives tls_options', () => {
|
|
133
|
+
const first = plugin.tls_options
|
|
134
|
+
plugin.load_smtp_proxy_ini()
|
|
135
|
+
assert.ok(plugin.tls_options)
|
|
136
|
+
assert.notEqual(plugin.tls_options, first)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { describe, it } = require('node:test')
|
|
5
|
+
|
|
6
|
+
const fixtures = require('haraka-test-fixtures')
|
|
7
|
+
|
|
8
|
+
describe('reseed_rng', () => {
|
|
9
|
+
describe('hook_init_child', () => {
|
|
10
|
+
it('calls Math.seedrandom with a hex string and calls next', (t, done) => {
|
|
11
|
+
const plugin = new fixtures.plugin('reseed_rng')
|
|
12
|
+
let called = false
|
|
13
|
+
let calledArg
|
|
14
|
+
Math.seedrandom = (arg) => {
|
|
15
|
+
called = true
|
|
16
|
+
calledArg = arg
|
|
17
|
+
}
|
|
18
|
+
plugin.hook_init_child((rc) => {
|
|
19
|
+
delete Math.seedrandom
|
|
20
|
+
assert.equal(rc, undefined)
|
|
21
|
+
assert.ok(called, 'Math.seedrandom should have been called')
|
|
22
|
+
assert.equal(typeof calledArg, 'string')
|
|
23
|
+
assert.ok(calledArg.length > 0)
|
|
24
|
+
done()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('throws when Math.seedrandom is not defined', () => {
|
|
29
|
+
const plugin = new fixtures.plugin('reseed_rng')
|
|
30
|
+
delete Math.seedrandom
|
|
31
|
+
assert.throws(() => plugin.hook_init_child(() => {}), /Math\.seedrandom is not a function/)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { describe, it, beforeEach } = require('node:test')
|
|
5
|
+
|
|
6
|
+
const fixtures = require('haraka-test-fixtures')
|
|
7
|
+
|
|
8
|
+
describe('tarpit', () => {
|
|
9
|
+
let plugin
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
plugin = new fixtures.plugin('tarpit')
|
|
13
|
+
plugin.config.get = () => ({ main: {} })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('register', () => {
|
|
17
|
+
it('registers tarpit on all default hooks', () => {
|
|
18
|
+
const registered = []
|
|
19
|
+
plugin.register_hook = (hook) => registered.push(hook)
|
|
20
|
+
plugin.register()
|
|
21
|
+
assert.ok(registered.includes('connect'))
|
|
22
|
+
assert.ok(registered.includes('ehlo'))
|
|
23
|
+
assert.ok(registered.includes('mail'))
|
|
24
|
+
assert.ok(registered.includes('rcpt'))
|
|
25
|
+
assert.ok(registered.includes('data'))
|
|
26
|
+
assert.ok(registered.includes('queue'))
|
|
27
|
+
assert.ok(registered.includes('quit'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('registers only configured hooks when hooks_to_delay is set', () => {
|
|
31
|
+
plugin.config.get = () => ({ main: { hooks_to_delay: 'ehlo, mail' } })
|
|
32
|
+
const registered = []
|
|
33
|
+
plugin.register_hook = (hook) => registered.push(hook)
|
|
34
|
+
plugin.register()
|
|
35
|
+
assert.deepEqual(registered, ['ehlo', 'mail'])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('tarpit', () => {
|
|
40
|
+
let conn
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
conn = fixtures.connection.createConnection()
|
|
44
|
+
conn.init_transaction()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('calls next immediately when no transaction', (t, done) => {
|
|
48
|
+
conn.transaction = null
|
|
49
|
+
plugin.tarpit((rc) => {
|
|
50
|
+
assert.equal(rc, undefined)
|
|
51
|
+
done()
|
|
52
|
+
}, conn)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('calls next immediately when no tarpit delay set', (t, done) => {
|
|
56
|
+
// No tarpit note on connection or transaction
|
|
57
|
+
plugin.tarpit((rc) => {
|
|
58
|
+
assert.equal(rc, undefined)
|
|
59
|
+
done()
|
|
60
|
+
}, conn)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('calls next immediately when connection.notes.tarpit is 0', (t, done) => {
|
|
64
|
+
conn.notes.tarpit = 0
|
|
65
|
+
plugin.tarpit((rc) => {
|
|
66
|
+
assert.equal(rc, undefined)
|
|
67
|
+
done()
|
|
68
|
+
}, conn)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('delays and calls next when connection.notes.tarpit is set', { timeout: 3000 }, (t, done) => {
|
|
72
|
+
conn.notes.tarpit = 0.1
|
|
73
|
+
const start = Date.now()
|
|
74
|
+
plugin.tarpit((rc) => {
|
|
75
|
+
assert.equal(rc, undefined)
|
|
76
|
+
assert.ok(Date.now() - start >= 90, 'should have waited ~100ms')
|
|
77
|
+
done()
|
|
78
|
+
}, conn)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('uses transaction.notes.tarpit when connection note is absent', { timeout: 3000 }, (t, done) => {
|
|
82
|
+
conn.transaction.notes.tarpit = 0.1
|
|
83
|
+
const start = Date.now()
|
|
84
|
+
plugin.tarpit((rc) => {
|
|
85
|
+
assert.equal(rc, undefined)
|
|
86
|
+
assert.ok(Date.now() - start >= 90)
|
|
87
|
+
done()
|
|
88
|
+
}, conn)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
package/test/plugins/tls.js
CHANGED
|
@@ -60,4 +60,29 @@ describe('tls', () => {
|
|
|
60
60
|
)
|
|
61
61
|
})
|
|
62
62
|
})
|
|
63
|
+
|
|
64
|
+
describe('upgrade_connection (STARTTLS injection)', () => {
|
|
65
|
+
// RFC 3207 §4: data pipelined after STARTTLS but before the TLS
|
|
66
|
+
// handshake must be discarded, not processed on the cleartext channel.
|
|
67
|
+
it('discards pipelined plaintext before the TLS handshake', () => {
|
|
68
|
+
const c = this.connection
|
|
69
|
+
c.tls = { advertised: true }
|
|
70
|
+
c.notes = {}
|
|
71
|
+
// attacker pipelined an injected command after STARTTLS
|
|
72
|
+
c.current_data = Buffer.from('RCPT TO:<victim@example.com>\r\n')
|
|
73
|
+
let dataAtUpgrade = 'UPGRADE_NOT_CALLED'
|
|
74
|
+
c.client = {
|
|
75
|
+
upgrade() {
|
|
76
|
+
dataAtUpgrade = c.current_data
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
c.respond = () => {} // bypass the real _process_data path
|
|
80
|
+
this.plugin.timeout = 0
|
|
81
|
+
|
|
82
|
+
this.plugin.upgrade_connection(() => {}, c, ['STARTTLS'])
|
|
83
|
+
|
|
84
|
+
assert.equal(dataAtUpgrade, null, 'buffer cleared before upgrade()')
|
|
85
|
+
assert.equal(c.current_data, null)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
63
88
|
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { describe, it } = require('node:test')
|
|
5
|
+
|
|
6
|
+
const fixtures = require('haraka-test-fixtures')
|
|
7
|
+
|
|
8
|
+
describe('toobusy', () => {
|
|
9
|
+
describe('register', () => {
|
|
10
|
+
it('handles missing toobusy-js gracefully (does not throw)', () => {
|
|
11
|
+
const plugin = new fixtures.plugin('toobusy')
|
|
12
|
+
// toobusy-js is not installed; register should catch the error and return
|
|
13
|
+
let registered = false
|
|
14
|
+
plugin.register_hook = () => {
|
|
15
|
+
registered = true
|
|
16
|
+
}
|
|
17
|
+
assert.doesNotThrow(() => plugin.register())
|
|
18
|
+
assert.equal(registered, false, 'hook should not be registered without toobusy-js')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
})
|
package/test/plugins/xclient.js
CHANGED
|
@@ -99,4 +99,18 @@ describe('xclient', () => {
|
|
|
99
99
|
})
|
|
100
100
|
}
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('DESTPORT type', () => {
|
|
104
|
+
it('stores local.port as an integer (587/465 auth check)', async () => {
|
|
105
|
+
this.connection.remote.ip = '127.0.0.1'
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
this.plugin.hook_unrecognized_command(() => resolve(), this.connection, [
|
|
108
|
+
'XCLIENT',
|
|
109
|
+
'ADDR=1.2.3.4 DESTPORT=587',
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
assert.strictEqual(this.connection.local.port, 587)
|
|
113
|
+
assert.equal(typeof this.connection.local.port, 'number')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
102
116
|
})
|
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'))
|
|
@@ -355,3 +527,62 @@ describe('server', () => {
|
|
|
355
527
|
})
|
|
356
528
|
})
|
|
357
529
|
})
|
|
530
|
+
|
|
531
|
+
describe('_graceful (cluster restart)', () => {
|
|
532
|
+
it('actually disconnects workers (queued thunks are invoked)', async () => {
|
|
533
|
+
const cluster = require('node:cluster')
|
|
534
|
+
const Server = require('../server')
|
|
535
|
+
Server.cfg = Server.cfg || { main: {} }
|
|
536
|
+
Server.cfg.main = Server.cfg.main || {}
|
|
537
|
+
Server.cfg.main.force_shutdown_timeout = 1
|
|
538
|
+
|
|
539
|
+
const saved = {
|
|
540
|
+
cluster: Server.cluster,
|
|
541
|
+
workers: cluster.workers,
|
|
542
|
+
fork: cluster.fork,
|
|
543
|
+
rmAll: cluster.removeAllListeners,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let disconnected = 0
|
|
547
|
+
const mkWorker = () => ({
|
|
548
|
+
_cbs: {},
|
|
549
|
+
send() {},
|
|
550
|
+
kill() {},
|
|
551
|
+
once(ev, cb) {
|
|
552
|
+
;(this._cbs[ev] ||= []).push(cb)
|
|
553
|
+
},
|
|
554
|
+
on(ev, cb) {
|
|
555
|
+
;(this._cbs[ev] ||= []).push(cb)
|
|
556
|
+
},
|
|
557
|
+
_fire(ev) {
|
|
558
|
+
for (const cb of this._cbs[ev] || []) cb()
|
|
559
|
+
},
|
|
560
|
+
disconnect() {
|
|
561
|
+
disconnected++
|
|
562
|
+
setImmediate(() => {
|
|
563
|
+
this._fire('disconnect')
|
|
564
|
+
setImmediate(() => this._fire('exit'))
|
|
565
|
+
})
|
|
566
|
+
},
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
cluster.workers = { 1: mkWorker() }
|
|
570
|
+
cluster.removeAllListeners = () => {}
|
|
571
|
+
cluster.fork = () => {
|
|
572
|
+
const nw = mkWorker()
|
|
573
|
+
setImmediate(() => nw._fire('listening'))
|
|
574
|
+
return nw
|
|
575
|
+
}
|
|
576
|
+
Server.cluster = cluster
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await Server._graceful()
|
|
580
|
+
assert.equal(disconnected, 1, 'worker.disconnect() was invoked')
|
|
581
|
+
} finally {
|
|
582
|
+
Server.cluster = saved.cluster
|
|
583
|
+
cluster.workers = saved.workers
|
|
584
|
+
cluster.fork = saved.fork
|
|
585
|
+
cluster.removeAllListeners = saved.rmAll
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
})
|