haraka 0.0.32 → 3.3.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/.claude/settings.local.json +28 -0
- package/.githooks/pre-commit +41 -0
- package/.prettierignore +6 -0
- package/.qlty/.gitignore +7 -0
- package/.qlty/configs/.shellcheckrc +1 -0
- package/.qlty/qlty.toml +15 -0
- package/CHANGELOG.md +1872 -62
- package/CLAUDE.md +40 -0
- package/CONTRIBUTORS.md +34 -0
- package/Dockerfile +50 -0
- package/GEMINI.md +38 -0
- package/LICENSE +2 -1
- package/Plugins.md +227 -0
- package/README.md +100 -115
- package/SECURITY.md +178 -0
- package/TODO +22 -0
- package/address.js +53 -0
- package/bin/haraka +593 -0
- package/bin/haraka_grep +32 -0
- package/config/aliases +2 -0
- package/config/auth_flat_file.ini +7 -0
- package/config/auth_vpopmaild.ini +9 -0
- package/config/connection.ini +79 -0
- package/config/delay_deny.ini +7 -0
- package/config/dhparams.pem +8 -0
- package/config/host_list +3 -0
- package/config/host_list_regex +6 -0
- package/config/http.ini +11 -0
- package/config/lmtp.ini +7 -0
- package/config/log.ini +11 -0
- package/config/me +1 -0
- package/config/outbound.bounce_message +18 -0
- package/config/outbound.bounce_message_html +36 -0
- package/config/outbound.bounce_message_image +106 -0
- package/config/outbound.ini +24 -0
- package/config/plugins +67 -0
- package/config/smtp.ini +37 -0
- package/config/smtp_bridge.ini +4 -0
- package/config/smtp_forward.ini +31 -0
- package/config/smtp_proxy.ini +27 -0
- package/config/tarpit.timeout +1 -0
- package/config/tls.ini +83 -0
- package/config/tls_cert.pem +23 -0
- package/config/tls_key.pem +28 -0
- package/config/watch.ini +12 -0
- package/config/xclient.hosts +2 -0
- package/connection.js +1863 -0
- package/contrib/Haraka.cf +6 -0
- package/contrib/Haraka.pm +35 -0
- package/contrib/bad_smtp_server.pl +25 -0
- package/contrib/bsd-rc.d/haraka +61 -0
- package/contrib/debian-init.d/haraka +87 -0
- package/contrib/haraka.init +96 -0
- package/contrib/haraka.service +23 -0
- package/contrib/plugin2npm.sh +81 -0
- package/contrib/ubuntu-upstart/haraka.conf +27 -0
- package/coverage/coverage-final.json +2 -0
- package/coverage/coverage-summary.json +33 -0
- package/coverage/tmp/coverage-79131-1779241025146-0.json +1 -0
- package/coverage/tmp/coverage-79132-1779240999690-0.json +1 -0
- package/coverage/tmp/coverage-79172-1779241000095-0.json +1 -0
- package/coverage/tmp/coverage-79210-1779241000156-0.json +1 -0
- package/coverage/tmp/coverage-79211-1779241000209-0.json +1 -0
- package/coverage/tmp/coverage-79212-1779241000266-0.json +1 -0
- package/coverage/tmp/coverage-79213-1779241000441-0.json +1 -0
- package/coverage/tmp/coverage-79214-1779241000626-0.json +1 -0
- package/coverage/tmp/coverage-79215-1779241000795-0.json +1 -0
- package/coverage/tmp/coverage-79216-1779241000965-0.json +1 -0
- package/coverage/tmp/coverage-79218-1779241001013-0.json +1 -0
- package/coverage/tmp/coverage-79219-1779241001179-0.json +1 -0
- package/coverage/tmp/coverage-79220-1779241006249-0.json +1 -0
- package/coverage/tmp/coverage-79227-1779241011453-0.json +1 -0
- package/coverage/tmp/coverage-79229-1779241011537-0.json +1 -0
- package/coverage/tmp/coverage-79230-1779241011647-0.json +1 -0
- package/coverage/tmp/coverage-79231-1779241011765-0.json +1 -0
- package/coverage/tmp/coverage-79232-1779241011841-0.json +1 -0
- package/coverage/tmp/coverage-79233-1779241011909-0.json +1 -0
- package/coverage/tmp/coverage-79234-1779241011984-0.json +1 -0
- package/coverage/tmp/coverage-79235-1779241012055-0.json +1 -0
- package/coverage/tmp/coverage-79236-1779241012230-0.json +1 -0
- package/coverage/tmp/coverage-79237-1779241012300-0.json +1 -0
- package/coverage/tmp/coverage-79238-1779241012368-0.json +1 -0
- package/coverage/tmp/coverage-79239-1779241012438-0.json +1 -0
- package/coverage/tmp/coverage-79240-1779241012511-0.json +1 -0
- package/coverage/tmp/coverage-79241-1779241012582-0.json +1 -0
- package/coverage/tmp/coverage-79242-1779241012652-0.json +1 -0
- package/coverage/tmp/coverage-79243-1779241012814-0.json +1 -0
- package/coverage/tmp/coverage-79244-1779241012931-0.json +1 -0
- package/coverage/tmp/coverage-79245-1779241013007-0.json +1 -0
- package/coverage/tmp/coverage-79246-1779241013106-0.json +1 -0
- package/coverage/tmp/coverage-79247-1779241013178-0.json +1 -0
- package/coverage/tmp/coverage-79248-1779241013244-0.json +1 -0
- package/coverage/tmp/coverage-79249-1779241013409-0.json +1 -0
- package/coverage/tmp/coverage-79250-1779241013697-0.json +1 -0
- package/coverage/tmp/coverage-79251-1779241013847-0.json +1 -0
- package/coverage/tmp/coverage-79252-1779241014288-0.json +1 -0
- package/coverage/tmp/coverage-79253-1779241014378-0.json +1 -0
- package/coverage/tmp/coverage-79254-1779241014428-0.json +1 -0
- package/coverage/tmp/coverage-79255-1779241021774-0.json +1 -0
- package/coverage/tmp/coverage-80382-1779241021949-0.json +1 -0
- package/coverage/tmp/coverage-80383-1779241025019-0.json +1 -0
- package/coverage/tmp/coverage-80384-1779241025133-0.json +1 -0
- package/docs/Body.md +1 -0
- package/docs/Config.md +1 -0
- package/docs/Connection.md +153 -0
- package/docs/CoreConfig.md +96 -0
- package/docs/CustomReturnCodes.md +3 -0
- package/docs/HAProxy.md +62 -0
- package/docs/Header.md +1 -0
- package/docs/Logging.md +129 -0
- package/docs/Outbound.md +210 -0
- package/docs/Plugins.md +372 -0
- package/docs/Results.md +7 -0
- package/docs/Transaction.md +135 -0
- package/docs/Tutorial.md +183 -0
- package/docs/deprecated/access.md +3 -0
- package/docs/deprecated/backscatterer.md +9 -0
- package/docs/deprecated/connect.rdns_access.md +53 -0
- package/docs/deprecated/data.headers.md +3 -0
- package/docs/deprecated/data.nomsgid.md +7 -0
- package/docs/deprecated/data.noreceived.md +11 -0
- package/docs/deprecated/data.rfc5322_header_checks.md +11 -0
- package/docs/deprecated/dkim_sign.md +97 -0
- package/docs/deprecated/dkim_verify.md +28 -0
- package/docs/deprecated/dnsbl.md +80 -0
- package/docs/deprecated/dnswl.md +73 -0
- package/docs/deprecated/lookup_rdns.strict.md +67 -0
- package/docs/deprecated/mail_from.access.md +52 -0
- package/docs/deprecated/mail_from.blocklist.md +18 -0
- package/docs/deprecated/mail_from.nobounces.md +8 -0
- package/docs/deprecated/rcpt_to.access.md +53 -0
- package/docs/deprecated/rcpt_to.blocklist.md +18 -0
- package/docs/deprecated/rcpt_to.routes.md +3 -0
- package/docs/deprecated/rdns.regexp.md +30 -0
- package/docs/plugins/aliases.md +3 -0
- package/docs/plugins/auth/auth_bridge.md +34 -0
- package/docs/plugins/auth/auth_ldap.md +4 -0
- package/docs/plugins/auth/auth_proxy.md +36 -0
- package/docs/plugins/auth/auth_vpopmaild.md +33 -0
- package/docs/plugins/auth/flat_file.md +40 -0
- package/docs/plugins/block_me.md +18 -0
- package/docs/plugins/data.signatures.md +11 -0
- package/docs/plugins/delay_deny.md +23 -0
- package/docs/plugins/max_unrecognized_commands.md +6 -0
- package/docs/plugins/prevent_credential_leaks.md +22 -0
- package/docs/plugins/process_title.md +42 -0
- package/docs/plugins/queue/deliver.md +3 -0
- package/docs/plugins/queue/discard.md +32 -0
- package/docs/plugins/queue/lmtp.md +24 -0
- package/docs/plugins/queue/qmail-queue.md +16 -0
- package/docs/plugins/queue/quarantine.md +87 -0
- package/docs/plugins/queue/smtp_bridge.md +32 -0
- package/docs/plugins/queue/smtp_forward.md +127 -0
- package/docs/plugins/queue/smtp_proxy.md +68 -0
- package/docs/plugins/queue/test.md +7 -0
- package/docs/plugins/rcpt_to.in_host_list.md +34 -0
- package/docs/plugins/rcpt_to.max_count.md +3 -0
- package/docs/plugins/record_envelope_addresses.md +20 -0
- package/docs/plugins/relay.md +3 -0
- package/docs/plugins/reseed_rng.md +16 -0
- package/docs/plugins/status.md +41 -0
- package/docs/plugins/tarpit.md +50 -0
- package/docs/plugins/tls.md +235 -0
- package/docs/plugins/toobusy.md +27 -0
- package/docs/plugins/xclient.md +10 -0
- package/docs/tutorials/Migrating_from_v1_to_v2.md +96 -0
- package/docs/tutorials/SettingUpOutbound.md +62 -0
- package/eslint.config.mjs +2 -0
- package/haraka.js +74 -0
- package/haraka.sh +2 -0
- package/http/html/404.html +58 -0
- package/http/html/index.html +47 -0
- package/http/package.json +21 -0
- package/line_socket.js +24 -0
- package/logger.js +322 -0
- package/outbound/client_pool.js +59 -0
- package/outbound/config.js +134 -0
- package/outbound/hmail.js +1504 -0
- package/outbound/index.js +349 -0
- package/outbound/qfile.js +93 -0
- package/outbound/queue.js +399 -0
- package/outbound/tls.js +85 -0
- package/outbound/todo.js +17 -0
- package/package.json +91 -29
- package/plugins/.eslintrc.yaml +3 -0
- package/plugins/auth/auth_base.js +261 -0
- package/plugins/auth/auth_bridge.js +20 -0
- package/plugins/auth/auth_proxy.js +227 -0
- package/plugins/auth/auth_vpopmaild.js +162 -0
- package/plugins/auth/flat_file.js +44 -0
- package/plugins/block_me.js +88 -0
- package/plugins/data.signatures.js +30 -0
- package/plugins/delay_deny.js +153 -0
- package/plugins/prevent_credential_leaks.js +61 -0
- package/plugins/process_title.js +197 -0
- package/plugins/profile.js +11 -0
- package/plugins/queue/deliver.js +12 -0
- package/plugins/queue/discard.js +27 -0
- package/plugins/queue/lmtp.js +45 -0
- package/plugins/queue/qmail-queue.js +93 -0
- package/plugins/queue/quarantine.js +133 -0
- package/plugins/queue/smtp_bridge.js +45 -0
- package/plugins/queue/smtp_forward.js +371 -0
- package/plugins/queue/smtp_proxy.js +142 -0
- package/plugins/queue/test.js +15 -0
- package/plugins/rcpt_to.host_list_base.js +65 -0
- package/plugins/rcpt_to.in_host_list.js +56 -0
- package/plugins/record_envelope_addresses.js +17 -0
- package/plugins/reseed_rng.js +7 -0
- package/plugins/status.js +274 -0
- package/plugins/tarpit.js +45 -0
- package/plugins/tls.js +164 -0
- package/plugins/toobusy.js +47 -0
- package/plugins/xclient.js +124 -0
- package/plugins.js +604 -0
- package/queue/1772642154987_1775581346001_4_82235_TGwgfd_2_mattbook-m3.home.simerson.net +0 -0
- package/run_tests +11 -0
- package/server.js +827 -0
- package/smtp_client.js +504 -0
- package/test/.eslintrc.yaml +11 -0
- package/test/config/auth_flat_file.ini +5 -0
- package/test/config/block_me.recipient +1 -0
- package/test/config/block_me.senders +1 -0
- package/test/config/dhparams.pem +8 -0
- package/test/config/host_list +2 -0
- package/test/config/outbound_tls_cert.pem +1 -0
- package/test/config/outbound_tls_key.pem +1 -0
- package/test/config/plugins +7 -0
- package/test/config/smtp.ini +11 -0
- package/test/config/smtp_forward.ini +30 -0
- package/test/config/tls/example.com/_.example.com.key +28 -0
- package/test/config/tls/example.com/example.com.crt +25 -0
- package/test/config/tls/haraka.local.pem +51 -0
- package/test/config/tls.ini +45 -0
- package/test/config/tls_cert.pem +21 -0
- package/test/config/tls_key.pem +28 -0
- package/test/connection.js +817 -0
- package/test/fixtures/haproxy_allowed/config/connection.ini +3 -0
- package/test/fixtures/haproxy_disabled/config/connection.ini +3 -0
- package/test/fixtures/haproxy_untrusted/config/connection.ini +3 -0
- package/test/fixtures/line_socket.js +21 -0
- package/test/fixtures/todo_qfile.txt +0 -0
- package/test/fixtures/util_hmailitem.js +156 -0
- package/test/installation/config/test-plugin-flat +1 -0
- package/test/installation/config/test-plugin.ini +10 -0
- package/test/installation/config/tls.ini +1 -0
- package/test/installation/node_modules/load_first/index.js +5 -0
- package/test/installation/node_modules/load_first/package.json +11 -0
- package/test/installation/node_modules/test-plugin/config/test-plugin-flat +1 -0
- package/test/installation/node_modules/test-plugin/config/test-plugin.ini +9 -0
- package/test/installation/node_modules/test-plugin/package.json +5 -0
- package/test/installation/node_modules/test-plugin/test-plugin.js +5 -0
- package/test/installation/plugins/base_plugin.js +3 -0
- package/test/installation/plugins/folder_plugin/index.js +3 -0
- package/test/installation/plugins/folder_plugin/package.json +11 -0
- package/test/installation/plugins/inherits.js +7 -0
- package/test/installation/plugins/load_first.js +3 -0
- package/test/installation/plugins/plugin.js +1 -0
- package/test/installation/plugins/tls.js +3 -0
- package/test/logger.js +217 -0
- package/test/loud/config/dhparams.pem +0 -0
- package/test/loud/config/tls/goobered.pem +45 -0
- package/test/loud/config/tls.ini +43 -0
- package/test/mail_specimen/base64-root-part.txt +23 -0
- package/test/mail_specimen/varied-fold-lengths-preserve-data.txt +283 -0
- package/test/outbound/bounce_net_errors.js +133 -0
- package/test/outbound/bounce_rfc3464.js +226 -0
- package/test/outbound/hmail.js +210 -0
- package/test/outbound/index.js +385 -0
- package/test/outbound/qfile.js +124 -0
- package/test/outbound/queue.js +325 -0
- package/test/plugins/auth/auth_base.js +620 -0
- package/test/plugins/auth/auth_bridge.js +80 -0
- package/test/plugins/auth/auth_vpopmaild.js +81 -0
- package/test/plugins/auth/flat_file.js +123 -0
- package/test/plugins/block_me.js +141 -0
- package/test/plugins/data.signatures.js +111 -0
- package/test/plugins/delay_deny.js +262 -0
- package/test/plugins/prevent_credential_leaks.js +174 -0
- package/test/plugins/process_title.js +141 -0
- package/test/plugins/queue/deliver.js +98 -0
- package/test/plugins/queue/discard.js +78 -0
- package/test/plugins/queue/lmtp.js +137 -0
- package/test/plugins/queue/qmail-queue.js +98 -0
- package/test/plugins/queue/quarantine.js +80 -0
- package/test/plugins/queue/smtp_bridge.js +152 -0
- package/test/plugins/queue/smtp_forward.js +1023 -0
- package/test/plugins/queue/smtp_proxy.js +138 -0
- package/test/plugins/rcpt_to.host_list_base.js +102 -0
- package/test/plugins/rcpt_to.in_host_list.js +186 -0
- package/test/plugins/record_envelope_addresses.js +66 -0
- package/test/plugins/reseed_rng.js +34 -0
- package/test/plugins/status.js +207 -0
- package/test/plugins/tarpit.js +90 -0
- package/test/plugins/tls.js +86 -0
- package/test/plugins/toobusy.js +21 -0
- package/test/plugins/xclient.js +119 -0
- package/test/plugins.js +230 -0
- package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_fixed +0 -0
- package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka +0 -0
- package/test/queue/1508269674999_1508269674999_0_34002_socVUF_1_haraka +0 -0
- package/test/queue/1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka +0 -0
- package/test/queue/zero-length +0 -0
- package/test/server.js +1012 -0
- package/test/smtp_client.js +1303 -0
- package/test/tls_socket.js +321 -0
- package/test/transaction.js +554 -0
- package/tls_socket.js +771 -0
- package/transaction.js +267 -0
- package/lib/index.js +0 -371
package/test/server.js
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
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')
|
|
7
|
+
const { once } = require('node:events')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const tls = require('node:tls')
|
|
10
|
+
const constants = require('haraka-constants')
|
|
11
|
+
const net_utils = require('haraka-net-utils')
|
|
12
|
+
|
|
13
|
+
const { endpoint } = require('haraka-net-utils')
|
|
14
|
+
const message = require('haraka-email-message')
|
|
15
|
+
const { get_client } = require('../smtp_client')
|
|
16
|
+
|
|
17
|
+
function fixtureConfig(name) {
|
|
18
|
+
const testRoot = path.resolve('test')
|
|
19
|
+
return require('haraka-config').module_config(testRoot, path.resolve('test/fixtures', name))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function useHaproxyFixture(server, name) {
|
|
23
|
+
const originalConfig = net_utils.config
|
|
24
|
+
const originalConnectionCfg = server.connection.cfg
|
|
25
|
+
const config = fixtureConfig(name)
|
|
26
|
+
net_utils.config = config
|
|
27
|
+
server.connection.cfg = config.get('connection.ini', { booleans: ['+haproxy.enabled'] })
|
|
28
|
+
return () => {
|
|
29
|
+
net_utils.config = originalConfig
|
|
30
|
+
server.connection.cfg = originalConnectionCfg
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── CRAM-MD5 helper ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Compute a CRAM-MD5 response to a server challenge. */
|
|
37
|
+
const cramMd5Response = (user, pass, challenge) => {
|
|
38
|
+
const decoded = Buffer.from(challenge, 'base64').toString()
|
|
39
|
+
const hmac = createHmac('md5', pass).update(decoded).digest('hex')
|
|
40
|
+
return Buffer.from(`${user} ${hmac}`).toString('base64')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Server lifecycle helpers ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const setupServer = (ip_port) =>
|
|
46
|
+
new Promise((resolve) => {
|
|
47
|
+
process.env.YES_REALLY_DO_DISCARD = '1'
|
|
48
|
+
process.env.HARAKA_TEST_DIR = path.resolve('test')
|
|
49
|
+
const test_cfg_path = path.resolve('test')
|
|
50
|
+
|
|
51
|
+
this.server = require('../server')
|
|
52
|
+
this.config = require('haraka-config').module_config(test_cfg_path)
|
|
53
|
+
this.server.logger.loglevel = 6
|
|
54
|
+
this.server.config = this.config.module_config(test_cfg_path)
|
|
55
|
+
this.server.plugins.config = this.config.module_config(test_cfg_path)
|
|
56
|
+
|
|
57
|
+
this.server.load_smtp_ini()
|
|
58
|
+
this.server.cfg.main.listen = ip_port
|
|
59
|
+
this.server.cfg.main.smtps_port = 2465
|
|
60
|
+
|
|
61
|
+
this.server.load_default_tls_config(() => {
|
|
62
|
+
this.server.createServer({})
|
|
63
|
+
setTimeout(resolve, 200)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const tearDownServer = () =>
|
|
68
|
+
new Promise((resolve) => {
|
|
69
|
+
delete process.env.YES_REALLY_DO_DISCARD
|
|
70
|
+
delete process.env.HARAKA_TEST_DIR
|
|
71
|
+
this.server.stopListeners()
|
|
72
|
+
this.server.plugins.registered_hooks = {}
|
|
73
|
+
setTimeout(resolve, 200)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ─── SMTP session helper ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deliver a message via smtp_client and return a Promise that resolves on
|
|
80
|
+
* acceptance (dot event) or rejects on any SMTP error (bad_code event).
|
|
81
|
+
*
|
|
82
|
+
* When `user`/`pass` are provided, CRAM-MD5 authentication is performed
|
|
83
|
+
* before sending the message.
|
|
84
|
+
*/
|
|
85
|
+
const sendMessage = ({
|
|
86
|
+
host = '127.0.0.1',
|
|
87
|
+
port,
|
|
88
|
+
from = '<test@haraka.local>',
|
|
89
|
+
to = '<discard@haraka.local>',
|
|
90
|
+
user,
|
|
91
|
+
pass,
|
|
92
|
+
body = 'Hello from smtp_client test',
|
|
93
|
+
} = {}) =>
|
|
94
|
+
new Promise((resolve, reject) => {
|
|
95
|
+
get_client(
|
|
96
|
+
{ notes: {} },
|
|
97
|
+
(client) => {
|
|
98
|
+
let credsSent = false
|
|
99
|
+
|
|
100
|
+
client
|
|
101
|
+
.on('greeting', (cmd) => client.send_command(cmd, host))
|
|
102
|
+
.on('helo', () => {
|
|
103
|
+
if (user && !credsSent) {
|
|
104
|
+
client.authenticating = true
|
|
105
|
+
client.send_command('AUTH', 'CRAM-MD5')
|
|
106
|
+
} else {
|
|
107
|
+
client.send_command('MAIL', `FROM:${from}`)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.on('auth', () => {
|
|
111
|
+
if (client.authenticated) {
|
|
112
|
+
client.send_command('MAIL', `FROM:${from}`)
|
|
113
|
+
} else if (!credsSent) {
|
|
114
|
+
credsSent = true
|
|
115
|
+
const resp = cramMd5Response(user, pass, client.response[0])
|
|
116
|
+
// Write CRAM-MD5 response directly (no command prefix)
|
|
117
|
+
client.command = 'auth'
|
|
118
|
+
client.response = []
|
|
119
|
+
client.socket.write(`${resp}\r\n`)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
.on('mail', () => client.send_command('RCPT', `TO:${to}`))
|
|
123
|
+
.on('rcpt', () => client.send_command('DATA'))
|
|
124
|
+
.on('data', () => {
|
|
125
|
+
const stream = new message.stream({ main: { spool_after: 1024 } }, 'testId')
|
|
126
|
+
stream.on('end', () => client.socket.write('.\r\n'))
|
|
127
|
+
stream.add_line('Subject: test\r\n')
|
|
128
|
+
stream.add_line('\r\n')
|
|
129
|
+
stream.add_line(`${body}\r\n`)
|
|
130
|
+
stream.add_line_end()
|
|
131
|
+
client.start_data(stream)
|
|
132
|
+
})
|
|
133
|
+
.on('dot', () => {
|
|
134
|
+
client.release()
|
|
135
|
+
resolve()
|
|
136
|
+
})
|
|
137
|
+
.on('bad_code', (code, msg) => {
|
|
138
|
+
client.release()
|
|
139
|
+
reject(new Error(`${code} ${msg}`))
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
{ host, port, connect_timeout: 5 },
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const listen = (server, host = '127.0.0.1') =>
|
|
147
|
+
new Promise((resolve, reject) => {
|
|
148
|
+
server.once('error', reject)
|
|
149
|
+
server.listen(0, host, () => {
|
|
150
|
+
server.removeListener('error', reject)
|
|
151
|
+
resolve()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const close = (server) =>
|
|
156
|
+
new Promise((resolve) => {
|
|
157
|
+
server.close(resolve)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const withTimeout = (promise, ms, msg) =>
|
|
161
|
+
new Promise((resolve, reject) => {
|
|
162
|
+
const timer = setTimeout(() => reject(new Error(msg)), ms)
|
|
163
|
+
promise.then(
|
|
164
|
+
(result) => {
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
resolve(result)
|
|
167
|
+
},
|
|
168
|
+
(err) => {
|
|
169
|
+
clearTimeout(timer)
|
|
170
|
+
reject(err)
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('server', () => {
|
|
178
|
+
// ── get_listen_addrs ──────────────────────────────────────────────────────
|
|
179
|
+
describe('get_listen_addrs', () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
this.config = require('haraka-config')
|
|
182
|
+
this.server = require('../server')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const cases = [
|
|
186
|
+
{
|
|
187
|
+
desc: 'IPv4 fully qualified',
|
|
188
|
+
args: [{ listen: '127.0.0.1:25' }],
|
|
189
|
+
expected: ['127.0.0.1:25'],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
desc: 'IPv4, default port',
|
|
193
|
+
args: [{ listen: '127.0.0.1' }],
|
|
194
|
+
expected: ['127.0.0.1:25'],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
desc: 'IPv4, custom port',
|
|
198
|
+
args: [{ listen: '127.0.0.1' }, 250],
|
|
199
|
+
expected: ['127.0.0.1:250'],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
desc: 'IPv6 fully qualified',
|
|
203
|
+
args: [{ listen: '[::1]:25' }],
|
|
204
|
+
expected: ['[::1]:25'],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
desc: 'IPv6, default port',
|
|
208
|
+
args: [{ listen: '[::1]' }],
|
|
209
|
+
expected: ['[::1]:25'],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
desc: 'IPv6, custom port',
|
|
213
|
+
args: [{ listen: '[::1]' }, 250],
|
|
214
|
+
expected: ['[::1]:250'],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
desc: 'IPv4 & IPv6 fully qualified',
|
|
218
|
+
args: [{ listen: '127.0.0.1:25,[::1]:25' }],
|
|
219
|
+
expected: ['127.0.0.1:25', '[::1]:25'],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
desc: 'IPv4 & IPv6, default port',
|
|
223
|
+
args: [{ listen: '127.0.0.1:25,[::1]' }],
|
|
224
|
+
expected: ['127.0.0.1:25', '[::1]:25'],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
desc: 'IPv4 & IPv6, custom port',
|
|
228
|
+
args: [{ listen: '127.0.0.1,[::1]' }, 250],
|
|
229
|
+
expected: ['127.0.0.1:250', '[::1]:250'],
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
for (const { desc, args, expected } of cases) {
|
|
234
|
+
it(desc, () => {
|
|
235
|
+
assert.deepEqual(this.server.get_listen_addrs(...args), expected)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ── load_smtp_ini ─────────────────────────────────────────────────────────
|
|
241
|
+
describe('load_smtp_ini', () => {
|
|
242
|
+
beforeEach(() => {
|
|
243
|
+
this.config = require('haraka-config')
|
|
244
|
+
this.server = require('../server')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('saves settings to Server.cfg', () => {
|
|
248
|
+
this.server.load_smtp_ini()
|
|
249
|
+
const c = this.server.cfg.main
|
|
250
|
+
assert.notEqual(c.daemonize, undefined)
|
|
251
|
+
assert.notEqual(c.daemon_log_file, undefined)
|
|
252
|
+
assert.notEqual(c.daemon_pid_file, undefined)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// ── get_smtp_server ───────────────────────────────────────────────────────
|
|
257
|
+
describe('get_smtp_server', () => {
|
|
258
|
+
beforeEach(async () => {
|
|
259
|
+
this.config = require('haraka-config').module_config(path.resolve('test'))
|
|
260
|
+
this.server = require('../server')
|
|
261
|
+
this.server.config = this.config
|
|
262
|
+
this.server.plugins.config = this.config
|
|
263
|
+
await new Promise((resolve) => {
|
|
264
|
+
this.server.load_default_tls_config(() => setTimeout(resolve, 200))
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('gets a net server object', async () => {
|
|
269
|
+
const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
|
|
270
|
+
if (!server) {
|
|
271
|
+
if (process.env.CI) return
|
|
272
|
+
assert.fail('unable to bind to 0.0.0.0:2501')
|
|
273
|
+
}
|
|
274
|
+
assert.ok(server)
|
|
275
|
+
assert.equal(server.has_tls, false)
|
|
276
|
+
const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
|
|
277
|
+
assert.equal(count, 0)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('gets a TLS net server object', async () => {
|
|
281
|
+
this.server.cfg.main.smtps_port = 2502
|
|
282
|
+
const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
|
|
283
|
+
if (!server) {
|
|
284
|
+
if (process.env.CI) return
|
|
285
|
+
assert.fail('unable to bind to 0.0.0.0:2502')
|
|
286
|
+
}
|
|
287
|
+
assert.ok(server)
|
|
288
|
+
assert.equal(server.has_tls, true)
|
|
289
|
+
const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
|
|
290
|
+
assert.equal(count, 0)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('accepts PROXY v1 before the SMTPS TLS handshake', async () => {
|
|
294
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
295
|
+
this.server.cfg.main.smtps_port = 0
|
|
296
|
+
|
|
297
|
+
// PROXY-before-TLS takes slightly longer than the default 10 ms timeout on Windows,
|
|
298
|
+
// use 50 ms timeout to avoid flaky tests (default is 300000 ms).
|
|
299
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 50)
|
|
300
|
+
const tlsErrors = []
|
|
301
|
+
let raw
|
|
302
|
+
let client
|
|
303
|
+
|
|
304
|
+
server.on('tlsClientError', (err) => {
|
|
305
|
+
tlsErrors.push(err)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await listen(server)
|
|
310
|
+
|
|
311
|
+
raw = net.connect(server.address().port, '127.0.0.1')
|
|
312
|
+
await withTimeout(
|
|
313
|
+
Promise.race([
|
|
314
|
+
once(raw, 'connect'),
|
|
315
|
+
once(raw, 'error').then(([err]) => {
|
|
316
|
+
throw err
|
|
317
|
+
}),
|
|
318
|
+
]),
|
|
319
|
+
3000,
|
|
320
|
+
'SMTPS TCP connection timed out',
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
raw.write('PROXY TCP4 127.0.0.1 127.0.0.1 42310 465\r\n')
|
|
324
|
+
client = tls.connect({
|
|
325
|
+
socket: raw,
|
|
326
|
+
rejectUnauthorized: false,
|
|
327
|
+
servername: 'localhost',
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
await withTimeout(
|
|
331
|
+
Promise.race([
|
|
332
|
+
once(client, 'secureConnect'),
|
|
333
|
+
once(client, 'error').then(([err]) => {
|
|
334
|
+
throw err
|
|
335
|
+
}),
|
|
336
|
+
]),
|
|
337
|
+
3000,
|
|
338
|
+
'SMTPS PROXY handshake timed out',
|
|
339
|
+
)
|
|
340
|
+
const [banner] = await withTimeout(once(client, 'data'), 3000, 'SMTPS PROXY banner timed out')
|
|
341
|
+
assert.match(banner.toString(), /^220 /)
|
|
342
|
+
assert.equal(tlsErrors.length, 0)
|
|
343
|
+
} finally {
|
|
344
|
+
if (client) client.destroy()
|
|
345
|
+
else if (raw) raw.destroy()
|
|
346
|
+
await close(server)
|
|
347
|
+
restoreHaproxyConfig()
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('accepts direct SMTPS from a PROXY-allowed peer', async () => {
|
|
352
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
353
|
+
this.server.cfg.main.smtps_port = 0
|
|
354
|
+
|
|
355
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 1000)
|
|
356
|
+
const tlsErrors = []
|
|
357
|
+
let client
|
|
358
|
+
|
|
359
|
+
server.on('tlsClientError', (err) => {
|
|
360
|
+
tlsErrors.push(err)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await listen(server)
|
|
365
|
+
|
|
366
|
+
client = tls.connect({
|
|
367
|
+
port: server.address().port,
|
|
368
|
+
host: '127.0.0.1',
|
|
369
|
+
rejectUnauthorized: false,
|
|
370
|
+
servername: 'localhost',
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
await withTimeout(
|
|
374
|
+
Promise.race([
|
|
375
|
+
once(client, 'secureConnect'),
|
|
376
|
+
once(client, 'error').then(([err]) => {
|
|
377
|
+
throw err
|
|
378
|
+
}),
|
|
379
|
+
]),
|
|
380
|
+
3000,
|
|
381
|
+
'direct SMTPS handshake timed out',
|
|
382
|
+
)
|
|
383
|
+
const [banner] = await withTimeout(once(client, 'data'), 3000, 'direct SMTPS banner timed out')
|
|
384
|
+
assert.match(banner.toString(), /^220 /)
|
|
385
|
+
assert.equal(tlsErrors.length, 0)
|
|
386
|
+
} finally {
|
|
387
|
+
if (client) client.destroy()
|
|
388
|
+
await close(server)
|
|
389
|
+
restoreHaproxyConfig()
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('preserves TLS server events for SMTPS connections', async () => {
|
|
394
|
+
this.server.cfg.main.smtps_port = 0
|
|
395
|
+
|
|
396
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
397
|
+
let ocspRequests = 0
|
|
398
|
+
let first
|
|
399
|
+
let second
|
|
400
|
+
|
|
401
|
+
server.tlsServer.on('OCSPRequest', (cert, issuer, cb) => {
|
|
402
|
+
ocspRequests++
|
|
403
|
+
cb()
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await listen(server)
|
|
408
|
+
|
|
409
|
+
first = tls.connect({
|
|
410
|
+
port: server.address().port,
|
|
411
|
+
host: '127.0.0.1',
|
|
412
|
+
rejectUnauthorized: false,
|
|
413
|
+
requestOCSP: true,
|
|
414
|
+
servername: 'localhost',
|
|
415
|
+
maxVersion: 'TLSv1.2',
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
await withTimeout(
|
|
419
|
+
Promise.race([
|
|
420
|
+
once(first, 'secureConnect'),
|
|
421
|
+
once(first, 'error').then(([err]) => {
|
|
422
|
+
throw err
|
|
423
|
+
}),
|
|
424
|
+
]),
|
|
425
|
+
3000,
|
|
426
|
+
'first SMTPS handshake timed out',
|
|
427
|
+
)
|
|
428
|
+
const session = first.getSession()
|
|
429
|
+
first.destroy()
|
|
430
|
+
await withTimeout(once(first, 'close'), 3000, 'first SMTPS close timed out')
|
|
431
|
+
|
|
432
|
+
second = tls.connect({
|
|
433
|
+
port: server.address().port,
|
|
434
|
+
host: '127.0.0.1',
|
|
435
|
+
rejectUnauthorized: false,
|
|
436
|
+
requestOCSP: true,
|
|
437
|
+
servername: 'localhost',
|
|
438
|
+
maxVersion: 'TLSv1.2',
|
|
439
|
+
session,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
await withTimeout(
|
|
443
|
+
Promise.race([
|
|
444
|
+
once(second, 'secureConnect'),
|
|
445
|
+
once(second, 'error').then(([err]) => {
|
|
446
|
+
throw err
|
|
447
|
+
}),
|
|
448
|
+
]),
|
|
449
|
+
3000,
|
|
450
|
+
'resumed SMTPS handshake timed out',
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
assert.equal(ocspRequests, 1)
|
|
454
|
+
assert.equal(second.isSessionReused(), true)
|
|
455
|
+
} finally {
|
|
456
|
+
if (second) second.destroy()
|
|
457
|
+
if (first) first.destroy()
|
|
458
|
+
await close(server)
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('uses direct TLS for SMTPS when HAProxy support is disabled', async () => {
|
|
463
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_disabled')
|
|
464
|
+
this.server.cfg.main.smtps_port = 0
|
|
465
|
+
|
|
466
|
+
let server
|
|
467
|
+
let client
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
471
|
+
assert.equal(server.tlsServer, undefined)
|
|
472
|
+
|
|
473
|
+
await listen(server)
|
|
474
|
+
|
|
475
|
+
client = tls.connect({
|
|
476
|
+
port: server.address().port,
|
|
477
|
+
host: '127.0.0.1',
|
|
478
|
+
rejectUnauthorized: false,
|
|
479
|
+
servername: 'localhost',
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
await withTimeout(
|
|
483
|
+
Promise.race([
|
|
484
|
+
once(client, 'secureConnect'),
|
|
485
|
+
once(client, 'error').then(([err]) => {
|
|
486
|
+
throw err
|
|
487
|
+
}),
|
|
488
|
+
]),
|
|
489
|
+
3000,
|
|
490
|
+
'direct TLS fallback handshake timed out',
|
|
491
|
+
)
|
|
492
|
+
} finally {
|
|
493
|
+
if (client) client.destroy()
|
|
494
|
+
if (server) await close(server)
|
|
495
|
+
restoreHaproxyConfig()
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('accepts direct SMTPS from an untrusted PROXY peer', async () => {
|
|
500
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_untrusted')
|
|
501
|
+
this.server.cfg.main.smtps_port = 0
|
|
502
|
+
|
|
503
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
504
|
+
let client
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
await listen(server)
|
|
508
|
+
|
|
509
|
+
client = tls.connect({
|
|
510
|
+
port: server.address().port,
|
|
511
|
+
host: '127.0.0.1',
|
|
512
|
+
rejectUnauthorized: false,
|
|
513
|
+
servername: 'localhost',
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
await withTimeout(
|
|
517
|
+
Promise.race([
|
|
518
|
+
once(client, 'secureConnect'),
|
|
519
|
+
once(client, 'error').then(([err]) => {
|
|
520
|
+
throw err
|
|
521
|
+
}),
|
|
522
|
+
]),
|
|
523
|
+
3000,
|
|
524
|
+
'untrusted direct SMTPS handshake timed out',
|
|
525
|
+
)
|
|
526
|
+
} finally {
|
|
527
|
+
if (client) client.destroy()
|
|
528
|
+
await close(server)
|
|
529
|
+
restoreHaproxyConfig()
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('rejects malformed SMTPS PROXY lines before TLS', async () => {
|
|
534
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
535
|
+
this.server.cfg.main.smtps_port = 0
|
|
536
|
+
|
|
537
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
538
|
+
let raw
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
await listen(server)
|
|
542
|
+
|
|
543
|
+
raw = net.connect(server.address().port, '127.0.0.1')
|
|
544
|
+
await withTimeout(once(raw, 'connect'), 3000, 'malformed PROXY TCP connection timed out')
|
|
545
|
+
raw.write('PROXY TCP4 nope 127.0.0.1 42310 465\r\n')
|
|
546
|
+
|
|
547
|
+
const [response] = await withTimeout(once(raw, 'data'), 3000, 'malformed PROXY response timed out')
|
|
548
|
+
assert.match(response.toString(), /^421 Invalid PROXY format/)
|
|
549
|
+
} finally {
|
|
550
|
+
if (raw) raw.destroy()
|
|
551
|
+
await close(server)
|
|
552
|
+
restoreHaproxyConfig()
|
|
553
|
+
}
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('rejects oversized SMTPS PROXY lines before TLS', async () => {
|
|
557
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
558
|
+
this.server.cfg.main.smtps_port = 0
|
|
559
|
+
|
|
560
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
561
|
+
let raw
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
await listen(server)
|
|
565
|
+
|
|
566
|
+
raw = net.connect(server.address().port, '127.0.0.1')
|
|
567
|
+
await withTimeout(once(raw, 'connect'), 3000, 'oversized PROXY TCP connection timed out')
|
|
568
|
+
raw.write(`PROXY ${'x'.repeat(513)}`)
|
|
569
|
+
|
|
570
|
+
const [response] = await withTimeout(once(raw, 'data'), 3000, 'oversized PROXY response timed out')
|
|
571
|
+
assert.match(response.toString(), /^421 Invalid PROXY format/)
|
|
572
|
+
} finally {
|
|
573
|
+
if (raw) raw.destroy()
|
|
574
|
+
await close(server)
|
|
575
|
+
restoreHaproxyConfig()
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('times out waiting for SMTPS PROXY from an allowed peer', async () => {
|
|
580
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
581
|
+
const originalSetTimeout = global.setTimeout
|
|
582
|
+
global.setTimeout = (fn, ms, ...args) => originalSetTimeout(fn, ms === 30 * 1000 ? 20 : ms, ...args)
|
|
583
|
+
this.server.cfg.main.smtps_port = 0
|
|
584
|
+
|
|
585
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
|
|
586
|
+
let raw
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
await listen(server)
|
|
590
|
+
|
|
591
|
+
raw = net.connect(server.address().port, '127.0.0.1')
|
|
592
|
+
await withTimeout(once(raw, 'connect'), 3000, 'PROXY timeout TCP connection timed out')
|
|
593
|
+
|
|
594
|
+
const [response] = await withTimeout(once(raw, 'data'), 3000, 'PROXY timeout response timed out')
|
|
595
|
+
assert.match(response.toString(), /^421 PROXY timeout/)
|
|
596
|
+
} finally {
|
|
597
|
+
global.setTimeout = originalSetTimeout
|
|
598
|
+
if (raw) raw.destroy()
|
|
599
|
+
await close(server)
|
|
600
|
+
restoreHaproxyConfig()
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('accepts byte-by-byte direct SMTPS from a PROXY-allowed peer', async () => {
|
|
605
|
+
const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
|
|
606
|
+
this.server.cfg.main.smtps_port = 0
|
|
607
|
+
|
|
608
|
+
const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 1000)
|
|
609
|
+
let raw
|
|
610
|
+
let client
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
await listen(server)
|
|
614
|
+
|
|
615
|
+
raw = net.connect(server.address().port, '127.0.0.1')
|
|
616
|
+
await withTimeout(once(raw, 'connect'), 3000, 'fragmented direct SMTPS TCP connection timed out')
|
|
617
|
+
|
|
618
|
+
const write = raw.write.bind(raw)
|
|
619
|
+
raw.write = (chunk, encoding, cb) => {
|
|
620
|
+
if (typeof encoding === 'function') {
|
|
621
|
+
cb = encoding
|
|
622
|
+
encoding = undefined
|
|
623
|
+
}
|
|
624
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
|
|
625
|
+
let pos = 0
|
|
626
|
+
const write_next = () => {
|
|
627
|
+
if (pos >= buffer.length) {
|
|
628
|
+
if (cb) cb()
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
write(buffer.subarray(pos, pos + 1))
|
|
632
|
+
pos++
|
|
633
|
+
setImmediate(write_next)
|
|
634
|
+
}
|
|
635
|
+
write_next()
|
|
636
|
+
return true
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
client = tls.connect({
|
|
640
|
+
socket: raw,
|
|
641
|
+
rejectUnauthorized: false,
|
|
642
|
+
servername: 'localhost',
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
await withTimeout(
|
|
646
|
+
Promise.race([
|
|
647
|
+
once(client, 'secureConnect'),
|
|
648
|
+
once(client, 'error').then(([err]) => {
|
|
649
|
+
throw err
|
|
650
|
+
}),
|
|
651
|
+
]),
|
|
652
|
+
3000,
|
|
653
|
+
'fragmented direct SMTPS handshake timed out',
|
|
654
|
+
)
|
|
655
|
+
const [banner] = await withTimeout(
|
|
656
|
+
once(client, 'data'),
|
|
657
|
+
3000,
|
|
658
|
+
'fragmented direct SMTPS banner timed out',
|
|
659
|
+
)
|
|
660
|
+
assert.match(banner.toString(), /^220 /)
|
|
661
|
+
} finally {
|
|
662
|
+
if (client) client.destroy()
|
|
663
|
+
else if (raw) raw.destroy()
|
|
664
|
+
await close(server)
|
|
665
|
+
restoreHaproxyConfig()
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// ── get_http_docroot ──────────────────────────────────────────────────────
|
|
671
|
+
describe('get_http_docroot', () => {
|
|
672
|
+
beforeEach(() => {
|
|
673
|
+
this.config = require('haraka-config')
|
|
674
|
+
this.server = require('../server')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('gets a fs path', () => {
|
|
678
|
+
assert.ok(this.server.get_http_docroot())
|
|
679
|
+
})
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
describe('lifecycle helpers', () => {
|
|
683
|
+
beforeEach(() => {
|
|
684
|
+
this.server = require('../server')
|
|
685
|
+
this.server.cfg = this.server.cfg || { main: {} }
|
|
686
|
+
this.server.cfg.main = this.server.cfg.main || {}
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('init_child_respond OK path starts HTTP listeners', () => {
|
|
690
|
+
let called = 0
|
|
691
|
+
const original = this.server.setup_http_listeners
|
|
692
|
+
this.server.setup_http_listeners = () => {
|
|
693
|
+
called++
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
this.server.init_child_respond(constants.ok)
|
|
697
|
+
assert.equal(called, 1)
|
|
698
|
+
} finally {
|
|
699
|
+
this.server.setup_http_listeners = original
|
|
700
|
+
}
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('init_child_respond error path kills master and exits', () => {
|
|
704
|
+
process.env.CLUSTER_MASTER_PID = '12345'
|
|
705
|
+
const originalKill = process.kill
|
|
706
|
+
const originalDump = this.server.logger.dump_and_exit
|
|
707
|
+
let killed = null
|
|
708
|
+
let exitCode = null
|
|
709
|
+
process.kill = (pid) => {
|
|
710
|
+
killed = pid
|
|
711
|
+
}
|
|
712
|
+
this.server.logger.dump_and_exit = (code) => {
|
|
713
|
+
exitCode = code
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
this.server.init_child_respond(constants.deny, 'nope')
|
|
717
|
+
assert.equal(killed, '12345')
|
|
718
|
+
assert.equal(exitCode, 1)
|
|
719
|
+
} finally {
|
|
720
|
+
process.kill = originalKill
|
|
721
|
+
this.server.logger.dump_and_exit = originalDump
|
|
722
|
+
delete process.env.CLUSTER_MASTER_PID
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('listening applies configured uid/gid and marks ready', () => {
|
|
727
|
+
this.server.cfg.main.group = 'staff'
|
|
728
|
+
this.server.cfg.main.user = 'nobody'
|
|
729
|
+
const originalGetGid = process.getgid
|
|
730
|
+
const originalSetGid = process.setgid
|
|
731
|
+
const originalGetUid = process.getuid
|
|
732
|
+
const originalSetUid = process.setuid
|
|
733
|
+
const calls = { setgid: 0, setuid: 0 }
|
|
734
|
+
process.getgid = () => 20
|
|
735
|
+
process.setgid = () => {
|
|
736
|
+
calls.setgid++
|
|
737
|
+
}
|
|
738
|
+
process.getuid = () => 501
|
|
739
|
+
process.setuid = () => {
|
|
740
|
+
calls.setuid++
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
this.server.listening()
|
|
744
|
+
assert.equal(calls.setgid, 1)
|
|
745
|
+
assert.equal(calls.setuid, 1)
|
|
746
|
+
assert.equal(this.server.ready, 1)
|
|
747
|
+
} finally {
|
|
748
|
+
process.getgid = originalGetGid
|
|
749
|
+
process.setgid = originalSetGid
|
|
750
|
+
process.getuid = originalGetUid
|
|
751
|
+
process.setuid = originalSetUid
|
|
752
|
+
delete this.server.cfg.main.group
|
|
753
|
+
delete this.server.cfg.main.user
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('sendToMaster calls receiveAsMaster when not clustered', () => {
|
|
758
|
+
const originalCluster = this.server.cluster
|
|
759
|
+
const originalReceive = this.server.receiveAsMaster
|
|
760
|
+
const seen = []
|
|
761
|
+
this.server.cluster = null
|
|
762
|
+
this.server.receiveAsMaster = (cmd, params) => {
|
|
763
|
+
seen.push([cmd, params])
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
this.server.sendToMaster('flushQueue', ['example.com'])
|
|
767
|
+
assert.deepEqual(seen[0], ['flushQueue', ['example.com']])
|
|
768
|
+
} finally {
|
|
769
|
+
this.server.cluster = originalCluster
|
|
770
|
+
this.server.receiveAsMaster = originalReceive
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it('receiveAsMaster ignores invalid commands and executes valid ones', () => {
|
|
775
|
+
const errors = []
|
|
776
|
+
const originalLogError = this.server.logerror
|
|
777
|
+
this.server.logerror = (msg) => errors.push(msg)
|
|
778
|
+
this.server._testCommand = (a, b) => {
|
|
779
|
+
this.server.notes.received = [a, b]
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
this.server.receiveAsMaster('notACommand', [])
|
|
783
|
+
assert.equal(errors.length > 0, true)
|
|
784
|
+
|
|
785
|
+
this.server.receiveAsMaster('_testCommand', ['x', 'y'])
|
|
786
|
+
assert.deepEqual(this.server.notes.received, ['x', 'y'])
|
|
787
|
+
} finally {
|
|
788
|
+
this.server.logerror = originalLogError
|
|
789
|
+
delete this.server._testCommand
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
describe('HTTP helpers', () => {
|
|
795
|
+
beforeEach(() => {
|
|
796
|
+
this.server = require('../server')
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('handle404 serves html/json/text based on request accepts', () => {
|
|
800
|
+
const makeReq = (kind) => ({
|
|
801
|
+
accepts(type) {
|
|
802
|
+
return type === kind
|
|
803
|
+
},
|
|
804
|
+
})
|
|
805
|
+
const responses = []
|
|
806
|
+
const makeRes = () => ({
|
|
807
|
+
status(code) {
|
|
808
|
+
responses.push({ code })
|
|
809
|
+
return this
|
|
810
|
+
},
|
|
811
|
+
sendFile(name, opts) {
|
|
812
|
+
responses.push({ type: 'html', name, opts })
|
|
813
|
+
},
|
|
814
|
+
send(body) {
|
|
815
|
+
responses.push({ type: 'body', body })
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
this.server.handle404(makeReq('html'), makeRes())
|
|
820
|
+
this.server.handle404(makeReq('json'), makeRes())
|
|
821
|
+
this.server.handle404(makeReq('none'), makeRes())
|
|
822
|
+
|
|
823
|
+
assert.equal(responses[0].code, 404)
|
|
824
|
+
assert.equal(responses[1].type, 'html')
|
|
825
|
+
assert.equal(responses[3].type, 'body')
|
|
826
|
+
assert.deepEqual(responses[3].body, { err: 'Not found' })
|
|
827
|
+
assert.equal(responses[5].body, 'Not found!')
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('init_http_respond logs and returns when ws is unavailable', () => {
|
|
831
|
+
const Module = require('node:module')
|
|
832
|
+
const originalRequire = Module.prototype.require
|
|
833
|
+
const originalLogError = this.server.logerror
|
|
834
|
+
const errors = []
|
|
835
|
+
this.server.logerror = (msg) => {
|
|
836
|
+
errors.push(msg)
|
|
837
|
+
}
|
|
838
|
+
this.server.http = { server: {} }
|
|
839
|
+
Module.prototype.require = function (id) {
|
|
840
|
+
if (id === 'ws') throw new Error('ws missing')
|
|
841
|
+
return originalRequire.apply(this, arguments)
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
this.server.init_http_respond()
|
|
845
|
+
assert.equal(errors.length > 0, true)
|
|
846
|
+
} finally {
|
|
847
|
+
Module.prototype.require = originalRequire
|
|
848
|
+
this.server.logerror = originalLogError
|
|
849
|
+
}
|
|
850
|
+
})
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
// ── SMTP sessions ─────────────────────────────────────────────────────────
|
|
854
|
+
describe('SMTP sessions', () => {
|
|
855
|
+
beforeEach(async () => setupServer('127.0.0.1:2503'))
|
|
856
|
+
afterEach(async () => tearDownServer())
|
|
857
|
+
|
|
858
|
+
it('accepts plain SMTP message', async () => {
|
|
859
|
+
await sendMessage({ port: 2503 })
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
it('accepts CRAM-MD5 authenticated SMTP', async () => {
|
|
863
|
+
await sendMessage({ port: 2503, user: 'matt', pass: 'goodPass' })
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('rejects invalid CRAM-MD5 credentials', async () => {
|
|
867
|
+
await assert.rejects(() => sendMessage({ port: 2503, user: 'matt', pass: 'badPass' }), /5\d\d/)
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
it('accepts message with custom headers', async () => {
|
|
871
|
+
await sendMessage({
|
|
872
|
+
port: 2503,
|
|
873
|
+
from: '<sender@haraka.local>',
|
|
874
|
+
to: '<discard@haraka.local>',
|
|
875
|
+
body: 'X-Custom: test-value\r\n\r\nBody text',
|
|
876
|
+
})
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
// ── requireAuthorized: SMTPS (implicit TLS) ───────────────────────────────
|
|
881
|
+
describe('requireAuthorized_SMTPS', () => {
|
|
882
|
+
beforeEach(async () => setupServer('127.0.0.1:2465'))
|
|
883
|
+
afterEach(async () => tearDownServer())
|
|
884
|
+
|
|
885
|
+
it('rejects non-validated SMTPS connection', async () => {
|
|
886
|
+
// Port 2465 is configured as SMTPS with requireAuthorized.
|
|
887
|
+
// In TLSv1.3 the handshake completes (secureConnect fires), then the server
|
|
888
|
+
// sends a post-handshake "certificate required" alert as a socket error.
|
|
889
|
+
const err = await new Promise((resolve) => {
|
|
890
|
+
const socket = tls.connect({
|
|
891
|
+
host: '127.0.0.1',
|
|
892
|
+
port: 2465,
|
|
893
|
+
rejectUnauthorized: false,
|
|
894
|
+
})
|
|
895
|
+
socket.on('error', resolve)
|
|
896
|
+
// secureConnect may fire before the post-handshake alert; keep waiting.
|
|
897
|
+
socket.on('secureConnect', () => {})
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
socket.destroy()
|
|
900
|
+
resolve(new Error('timeout'))
|
|
901
|
+
}, 3000)
|
|
902
|
+
})
|
|
903
|
+
assert.ok(
|
|
904
|
+
/socket hang up|disconnected before secure TLS|alert certificate required/.test(err.message),
|
|
905
|
+
`unexpected error: ${err.message}`,
|
|
906
|
+
)
|
|
907
|
+
})
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
// ── requireAuthorized: STARTTLS ───────────────────────────────────────────
|
|
911
|
+
describe('requireAuthorized_STARTTLS', () => {
|
|
912
|
+
beforeEach(async () => setupServer('127.0.0.1:2587'))
|
|
913
|
+
afterEach(async () => tearDownServer())
|
|
914
|
+
|
|
915
|
+
it('rejects non-validated STARTTLS connection', async () => {
|
|
916
|
+
// Port 2587 is plain SMTP; requireAuthorized enforces mutual TLS on STARTTLS upgrade.
|
|
917
|
+
// In TLSv1.3 secureConnect fires first, then the server sends a post-handshake
|
|
918
|
+
// "certificate required" alert. Use raw sockets to observe the TLS error.
|
|
919
|
+
// (smtp_client's upgrade path silently swallows the post-upgrade error.)
|
|
920
|
+
const err = await new Promise((resolve) => {
|
|
921
|
+
const sock = net.connect({ host: '127.0.0.1', port: 2587 })
|
|
922
|
+
let state = 'greeting'
|
|
923
|
+
let buf = ''
|
|
924
|
+
sock.on('data', (d) => {
|
|
925
|
+
buf += d.toString()
|
|
926
|
+
for (const line of buf.split('\r\n').slice(0, -1)) {
|
|
927
|
+
buf = buf.slice(line.length + 2)
|
|
928
|
+
if (line[3] === '-') continue // multi-line continuation
|
|
929
|
+
if (state === 'greeting') {
|
|
930
|
+
sock.write('EHLO test\r\n')
|
|
931
|
+
state = 'ehlo'
|
|
932
|
+
} else if (state === 'ehlo') {
|
|
933
|
+
sock.write('STARTTLS\r\n')
|
|
934
|
+
state = 'starttls'
|
|
935
|
+
} else if (state === 'starttls') {
|
|
936
|
+
state = 'tls'
|
|
937
|
+
const cleartext = tls.connect({ socket: sock, rejectUnauthorized: false })
|
|
938
|
+
cleartext.on('secureConnect', () => {})
|
|
939
|
+
cleartext.on('error', resolve)
|
|
940
|
+
cleartext.on('close', () => resolve(new Error('closed without error')))
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
})
|
|
944
|
+
sock.on('error', resolve)
|
|
945
|
+
setTimeout(() => resolve(new Error('timeout')), 3000)
|
|
946
|
+
})
|
|
947
|
+
assert.ok(
|
|
948
|
+
/alert certificate required|socket hang up|disconnected/.test(err.message),
|
|
949
|
+
`unexpected error: ${err.message}`,
|
|
950
|
+
)
|
|
951
|
+
})
|
|
952
|
+
})
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
describe('_graceful (cluster restart)', () => {
|
|
956
|
+
it('actually disconnects workers (queued thunks are invoked)', async () => {
|
|
957
|
+
const cluster = require('node:cluster')
|
|
958
|
+
const Server = require('../server')
|
|
959
|
+
Server.cfg = Server.cfg || { main: {} }
|
|
960
|
+
Server.cfg.main = Server.cfg.main || {}
|
|
961
|
+
Server.cfg.main.force_shutdown_timeout = 1
|
|
962
|
+
|
|
963
|
+
const saved = {
|
|
964
|
+
cluster: Server.cluster,
|
|
965
|
+
workers: cluster.workers,
|
|
966
|
+
fork: cluster.fork,
|
|
967
|
+
rmAll: cluster.removeAllListeners,
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
let disconnected = 0
|
|
971
|
+
const mkWorker = () => ({
|
|
972
|
+
_cbs: {},
|
|
973
|
+
send() {},
|
|
974
|
+
kill() {},
|
|
975
|
+
once(ev, cb) {
|
|
976
|
+
;(this._cbs[ev] ||= []).push(cb)
|
|
977
|
+
},
|
|
978
|
+
on(ev, cb) {
|
|
979
|
+
;(this._cbs[ev] ||= []).push(cb)
|
|
980
|
+
},
|
|
981
|
+
_fire(ev) {
|
|
982
|
+
for (const cb of this._cbs[ev] || []) cb()
|
|
983
|
+
},
|
|
984
|
+
disconnect() {
|
|
985
|
+
disconnected++
|
|
986
|
+
setImmediate(() => {
|
|
987
|
+
this._fire('disconnect')
|
|
988
|
+
setImmediate(() => this._fire('exit'))
|
|
989
|
+
})
|
|
990
|
+
},
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
cluster.workers = { 1: mkWorker() }
|
|
994
|
+
cluster.removeAllListeners = () => {}
|
|
995
|
+
cluster.fork = () => {
|
|
996
|
+
const nw = mkWorker()
|
|
997
|
+
setImmediate(() => nw._fire('listening'))
|
|
998
|
+
return nw
|
|
999
|
+
}
|
|
1000
|
+
Server.cluster = cluster
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
await Server._graceful()
|
|
1004
|
+
assert.equal(disconnected, 1, 'worker.disconnect() was invoked')
|
|
1005
|
+
} finally {
|
|
1006
|
+
Server.cluster = saved.cluster
|
|
1007
|
+
cluster.workers = saved.workers
|
|
1008
|
+
cluster.fork = saved.fork
|
|
1009
|
+
cluster.removeAllListeners = saved.rmAll
|
|
1010
|
+
}
|
|
1011
|
+
})
|
|
1012
|
+
})
|