smtp_client.js
Rama master · réplica de lectura del repositorio original.
'use strict'
// SMTP client object and class. This allows every part of the client
// protocol to be hooked for different levels of control, such as
// smtp_forward and smtp_proxy queue plugins.
// It can use HostPool to get a connection to a pool of
// possible hosts in the configuration value "forwarding_host_pool", rather
// than a bunch of connections to a single host from the configuration values
// in "host" and "port" (see host_pool.js).
const events = require('node:events')
const ipaddr = require('ipaddr.js')
const net_utils = require('haraka-net-utils')
const utils = require('haraka-utils')
const tls_socket = require('./tls_socket')
const logger = require('./logger')
const HostPool = net_utils.HostPool
const smtp_regexp = /^(\d{3})([ -])(.*)/
const STATE = {
IDLE: 1,
ACTIVE: 2,
RELEASED: 3,
DESTROYED: 4,
}
class SMTPClient extends events.EventEmitter {
constructor(opts = {}) {
super()
this.uuid = utils.uuid()
this.connect_timeout = parseInt(opts.connect_timeout) || 30
this.socket = opts.socket || this.get_socket(opts)
this.socket.setTimeout(this.connect_timeout * 1000)
this.socket.setKeepAlive(true)
this.state = STATE.IDLE
this.command = 'greeting'
this.response = []
this.connected = false
this.authenticating = false
this.authenticated = false
this.auth_capabilities = []
this.host = opts.host
this.port = opts.port
this.smtputf8 = false
const client = this
client.socket.on('line', (line) => {
client.emit('server_protocol', line)
const matches = smtp_regexp.exec(line)
if (!matches) {
client.emit('error', `${client.uuid}: Unrecognized response from upstream server: ${line}`)
client.destroy()
return
}
const code = matches[1]
const cont = matches[2]
const msg = matches[3]
client.response.push(msg)
if (cont !== ' ') return
if (client.command === 'auth' || client.authenticating) {
logger.info(
`SERVER RESPONSE, CLIENT ${client.command}, authenticating=${client.authenticating},code=${code},cont=${cont},msg=${msg}`,
)
if (
/^3/.test(code) &&
(msg === 'VXNlcm5hbWU6' || msg === 'dXNlcm5hbWU6') // Workaround ill-mannered SMTP servers (namely smtp.163.com)
) {
client.emit('auth_username')
return
}
if (/^3/.test(code) && msg === 'UGFzc3dvcmQ6') {
client.emit('auth_password')
return
}
if (/^2/.test(code) && client.authenticating) {
logger.info('AUTHENTICATED')
client.authenticating = false
client.authenticated = true
client.emit('auth')
return
}
}
if (client.command === 'ehlo') {
if (code.match(/^5/)) {
// Handle fallback to HELO if EHLO is rejected
client.emit('greeting', 'HELO')
return
}
client.emit('capabilities')
if (client.command !== 'ehlo') {
return
}
}
if (client.command === 'xclient' && /^5/.test(code)) {
// XCLIENT command was rejected (no permission?)
// Carry on without XCLIENT
client.command = 'helo'
} else if (/^[45]/.test(code)) {
client.emit('bad_code', code, client.response.join(' '))
if (client.state !== STATE.ACTIVE) {
return
}
}
if (/^441/.test(code)) {
if (/Connection timed out/i.test(msg)) {
client.destroy()
}
}
switch (client.command) {
case 'xclient':
client.xclient = true
client.emit('xclient', 'EHLO')
break
case 'starttls':
client.upgrade(client.tls_options)
break
case 'greeting':
client.connected = true
client.emit('greeting', 'EHLO')
break
case 'ehlo':
client.emit('helo')
break
case 'helo':
case 'mail':
case 'rcpt':
case 'data':
case 'dot':
case 'rset':
case 'auth':
client.emit(client.command)
break
case 'quit':
client.emit('quit')
client.destroy()
break
default:
throw new Error(`Unknown command: ${client.command}`)
}
})
client.socket.on('connect', () => {
// Replace connection timeout with idle timeout
client.socket.setTimeout((opts.idle_timeout || 300) * 1000)
if (!client.socket.remoteAddress) {
// "Value may be undefined if the socket is destroyed"
logger.debug('socket.remoteAddress undefined')
return
}
client.remote_ip = ipaddr.process(client.socket.remoteAddress).toString()
})
function closed(msg) {
return (error) => {
if (!error) error = ''
// error is e.g. "Error: connect ECONNREFUSED"
const errMsg = `${client.uuid}: ${new net_utils.Endpoint({ host: client.host, port: client.port })} SMTP connection ${msg} ${error}`
/* eslint-disable no-fallthrough */
switch (client.state) {
case STATE.ACTIVE:
client.emit('error', errMsg)
case STATE.IDLE:
case STATE.RELEASED:
client.destroy()
break
case STATE.DESTROYED:
if (msg === 'errored' || msg === 'timed out') {
client.emit('connection-error', errMsg)
}
break
default:
}
logger.debug(`[smtp_client] ${errMsg} (state=${client.state})`)
}
}
client.socket.on('error', closed('errored'))
client.socket.on('timeout', closed('timed out'))
client.socket.on('close', closed('closed'))
client.socket.on('end', closed('ended'))
}
load_tls_options(opts = {}) {
this.tls_options = { servername: this.host, ...opts }
}
send_command(command, data) {
const line = command === 'dot' ? '.' : command + (data ? ` ${data}` : '')
this.emit('client_protocol', line)
this.command = command.toLowerCase()
this.response = []
this.socket.write(`${line}\r\n`)
}
start_data(data) {
this.response = []
this.command = 'dot'
data.pipe(this.socket, {
dot_stuffed: false,
ending_dot: true,
end: false,
})
}
release() {
if (this.state === STATE.DESTROYED) return
const listeners = [
'auth',
'bad_code',
'capabilities',
'client_protocol',
'connection-error',
'data',
'dot',
'error',
'greeting',
'helo',
'mail',
'rcpt',
'rset',
'server_protocol',
'xclient',
]
logger.debug(`[smtp_client] ${this.uuid} releasing, state=${this.state}`)
for (const l of listeners) {
this.removeAllListeners(l)
}
if (this.connected) this.send_command('QUIT')
this.destroy()
}
destroy() {
if (this.state === STATE.DESTROYED) return
this.state = STATE.DESTROYED
this.socket.destroy()
}
upgrade(tls_options) {
this.socket.upgrade(tls_options, (verified, verifyError, cert, cipher) => {
logger.info(
`secured:${
cipher ? ` cipher=${cipher.name} version=${cipher.version}` : ''
} verified=${verified}${verifyError ? ` error="${verifyError}"` : ''}${
cert?.subject ? ` cn="${cert.subject.CN}" organization="${cert.subject.O}"` : ''
}${cert?.issuer ? ` issuer="${cert.issuer.O}"` : ''}${
cert?.valid_to ? ` expires="${cert.valid_to}"` : ''
}${cert?.fingerprint ? ` fingerprint=${cert.fingerprint}` : ''}`,
)
})
}
is_dead_sender(plugin, connection) {
if (connection?.transaction) return false
// This likely means the sender went away on us, cleanup.
connection.logwarn(plugin, 'transaction went away, releasing smtp_client')
this.release()
return true
}
get_socket(opts) {
const socket = tls_socket.connect({
host: opts.host,
port: opts.port,
timeout: this.connect_timeout,
})
net_utils.add_line_processor(socket)
return socket
}
}
exports.smtp_client = SMTPClient
// Get a smtp_client for the given attributes.
// used only in testing
exports.get_client = (server, callback, opts = {}) => {
const smtp_client = new SMTPClient(opts)
logger.debug(`[smtp_client] uuid=${smtp_client.uuid} host=${opts.host} port=${opts.port} created`)
callback(smtp_client)
}
exports.onCapabilitiesOutbound = (smtp_client, secured, connection, config, on_secured) => {
for (const line in smtp_client.response) {
if (/^XCLIENT/.test(smtp_client.response[line])) {
if (!smtp_client.xclient) {
smtp_client.send_command('XCLIENT', `ADDR=${connection.remote.ip}`)
return
}
}
if (/^SMTPUTF8/.test(smtp_client.response[line])) {
smtp_client.smtputf8 = true
}
if (/^STARTTLS/.test(smtp_client.response[line]) && !secured) {
let hostBanned = false
let serverBanned = false
// Check if there are any banned TLS hosts
if (smtp_client.tls_options.no_tls_hosts) {
// If there are check if these hosts are in the blacklist
hostBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, config.host)
serverBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, smtp_client.remote_ip)
}
if (!hostBanned && !serverBanned && config.enable_tls) {
smtp_client.socket.on('secure', on_secured)
smtp_client.secured = false // have to wait in forward plugin before we can do auth, even if capabilities are there on first EHLO
smtp_client.send_command('STARTTLS')
return
}
}
let auth_matches = smtp_client.response[line].match(/^AUTH (.*)$/)
if (auth_matches) {
smtp_client.auth_capabilities = []
auth_matches = auth_matches[1].split(' ')
for (const authMatch of auth_matches) {
smtp_client.auth_capabilities.push(authMatch.toLowerCase())
}
}
}
}
// Get a smtp_client for the given attributes and set up the common
// config and listeners for plugins. This is what smtp_proxy and
// smtp_forward have in common.
exports.get_client_plugin = (plugin, connection, c, callback) => {
// c = config
// Merge in authentication settings from smtp_forward/proxy.ini if present
// FIXME: config.auth could be changed when API isn't frozen
if (c.auth_type || c.auth_user || c.auth_pass) {
c.auth = {
type: c.auth_type,
user: c.auth_user,
pass: c.auth_pass,
}
}
const hostport = get_hostport(connection, c)
const smtp_client = new SMTPClient(hostport)
logger.info(`[smtp_client] uuid=${smtp_client.uuid} host=${hostport.host} port=${hostport.port} created`)
connection.logdebug(plugin, `Got smtp_client: ${smtp_client.uuid}`)
let secured = false
smtp_client.load_tls_options(plugin.tls_options)
smtp_client.call_next = function (retval, msg) {
if (this.next) {
const { next } = this
delete this.next
next(retval, msg)
}
}
smtp_client.on('client_protocol', (line) => {
// Don't leak SASL credentials (e.g. AUTH PLAIN <base64>) into
// protocol logs.
const safe = String(line).replace(/^(AUTH\s+\S+\s+).+$/i, '$1[redacted]')
connection.logprotocol(plugin, `C: ${safe}`)
})
smtp_client.on('server_protocol', (line) => {
connection.logprotocol(plugin, `S: ${line}`)
})
function helo(command) {
if (smtp_client.xclient) {
smtp_client.send_command(command, connection.hello.host)
} else {
smtp_client.send_command(command, connection.local.host)
}
}
smtp_client.on('greeting', helo)
smtp_client.on('xclient', helo)
function on_secured() {
if (secured) return
secured = true
smtp_client.secured = true
smtp_client.emit('greeting', 'EHLO')
}
smtp_client.on('capabilities', () => {
exports.onCapabilitiesOutbound(smtp_client, secured, connection, c, on_secured)
})
smtp_client.on('helo', () => {
if (!c.auth || smtp_client.authenticated) {
if (smtp_client.is_dead_sender(plugin, connection)) return
smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
return
}
if (c.auth.type === null || typeof c.auth.type === 'undefined') return // Ignore blank
const auth_type = c.auth.type.toLowerCase()
// This listener runs from the socket line handler; an uncaught
// throw here crashes the forwarding worker. Route failures
// through the existing smtp_client 'error' flow (logwarn +
// call_next) so a misconfigured/hostile upstream degrades to a
// normal SMTP error path instead.
if (!smtp_client.auth_capabilities.includes(auth_type)) {
return smtp_client.emit(
'error',
`Auth type "${auth_type}" not supported by server (supports: ${smtp_client.auth_capabilities.join(',')})`,
)
}
switch (auth_type) {
case 'plain':
if (!c.auth.user || !c.auth.pass) {
return smtp_client.emit('error', 'Must include auth.user and auth.pass for PLAIN auth.')
}
logger.debug(`[smtp_client] uuid=${smtp_client.uuid} authenticating as "${c.auth.user}"`)
smtp_client.send_command(
'AUTH',
`PLAIN ${utils.base64(`${c.auth.user}\0${c.auth.user}\0${c.auth.pass}`)}`,
)
break
case 'cram-md5':
return smtp_client.emit('error', `AUTH ${auth_type} not implemented`)
default:
return smtp_client.emit('error', `Unknown AUTH type: ${auth_type}`)
}
})
smtp_client.on('auth', () => {
// if authentication has been handled by plugin(s)
if (smtp_client.authenticating) return
if (smtp_client.is_dead_sender(plugin, connection)) return
smtp_client.authenticated = true
smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
})
// these errors only get thrown when the connection is still active
smtp_client.on('error', (msg) => {
connection.logwarn(plugin, msg)
smtp_client.call_next()
})
// these are the errors thrown when the connection is dead
smtp_client.on('connection-error', (error) => {
// error contains e.g. "Error: connect ECONNREFUSE"
logger.error(
`backend failure: ${new net_utils.Endpoint({ host: smtp_client.host, port: smtp_client.port })} - ${error}`,
)
const { host_pool } = connection.server.notes
// only exists for if forwarding_host_pool is set in the config
if (host_pool) {
host_pool.failed(smtp_client.host, smtp_client.port)
}
smtp_client.call_next()
})
if (smtp_client.connected) {
if (smtp_client.xclient) {
smtp_client.send_command('XCLIENT', `ADDR=${connection.remote.ip}`)
} else {
smtp_client.emit('helo')
}
}
callback(null, smtp_client)
}
function get_hostport(connection, cfg) {
const server = connection.server
if (cfg.forwarding_host_pool) {
if (!server.notes.host_pool) {
connection.logwarn(`creating host_pool from ${cfg.forwarding_host_pool}`)
server.notes.host_pool = new HostPool(
cfg.forwarding_host_pool, // 1.2.3.4:420, 5.6.7.8:420
cfg.dead_forwarding_host_retry_secs,
{ logger },
)
}
const host = server.notes.host_pool.get_host()
if (host) return host // { host: 1.2.3.4, port: 567 }
logger.error('[smtp_client] no backend hosts in pool!')
throw new Error('no backend hosts found in pool!')
}
if (cfg.host && cfg.port) return { host: cfg.host, port: cfg.port }
logger.warn('[smtp_client] forwarding_host_pool or host and port were not found in config file')
throw new Error('You must specify either forwarding_host_pool or host and port')
}