mailsmtp 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +215 -0
- data/lib/mailsmtp/data_reader.rb +238 -0
- data/lib/mailsmtp/error.rb +58 -0
- data/lib/mailsmtp/errors.rb +85 -0
- data/lib/mailsmtp/server.rb +1199 -0
- data/lib/mailsmtp/session.rb +63 -0
- data/lib/mailsmtp/version.rb +3 -0
- data/lib/mailsmtp.rb +10 -0
- data/mailsmtp.gemspec +37 -0
- metadata +85 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module MailSmtp
|
|
9
|
+
# A thread-per-connection SMTP *server* (no client). Handles the RFC 5321
|
|
10
|
+
# command sequence (HELO/EHLO, AUTH PLAIN/LOGIN, MAIL FROM, RCPT TO, DATA,
|
|
11
|
+
# STARTTLS, RSET, NOOP, QUIT, VRFY), leaving domain policy to subclass hooks.
|
|
12
|
+
# SIZE is advertised and enforced when +max_message_size+ is set; 8BITMIME,
|
|
13
|
+
# SMTPUTF8, PIPELINING, PROXY, and multi-listen are optional.
|
|
14
|
+
# STARTTLS always drops any plaintext still buffered after the handshake
|
|
15
|
+
# (CVE-2011-0411). Implicit TLS (port 465) is not supported yet.
|
|
16
|
+
#
|
|
17
|
+
# Policy is subclass verbs (+authenticate+, +mail_from+, +rcpt_to+, +receive+).
|
|
18
|
+
# During DATA the engine passes a live +DataReader+ into +receive(session, io)+
|
|
19
|
+
# (dot-unstuffed socket bytes); the app reads or discards, then the engine
|
|
20
|
+
# drains to "." and replies once.
|
|
21
|
+
#
|
|
22
|
+
# +tls+ is any object that responds to +#start(io)+ and returns an SSL socket,
|
|
23
|
+
# or +nil+ to disable STARTTLS. Reassign +tls+ to hot-swap certificates.
|
|
24
|
+
class Server
|
|
25
|
+
AUTH_MODES = %i[ disabled required ].freeze
|
|
26
|
+
STARTTLS_MODES = %i[ optional required ].freeze
|
|
27
|
+
|
|
28
|
+
# RFC 4954 §4: AUTH LOGIN prompts with base64 "Username:" then "Password:".
|
|
29
|
+
AUTH_LOGIN_USERNAME_PROMPT = "334 #{Base64.strict_encode64("Username:")}".freeze
|
|
30
|
+
AUTH_LOGIN_PASSWORD_PROMPT = "334 #{Base64.strict_encode64("Password:")}".freeze
|
|
31
|
+
|
|
32
|
+
IO_BUFFER_CHUNK_SIZE = 4 * 1024
|
|
33
|
+
IO_WAIT_TIMEOUT = 1
|
|
34
|
+
DEFAULT_IO_BUFFER_MAX_SIZE = 1 * 1024 * 1024
|
|
35
|
+
# RFC 5321 §4.5.3.2.7: servers should wait at least 5 minutes for the next
|
|
36
|
+
# client command. (The earlier §4.5.3.2.1–.6 times are client-side.)
|
|
37
|
+
DEFAULT_READ_TIMEOUT = 300
|
|
38
|
+
DEFAULT_WRITE_TIMEOUT = 30
|
|
39
|
+
DEFAULT_HANDSHAKE_TIMEOUT = 30
|
|
40
|
+
DEFAULT_MAX_PROCESSINGS_WAIT = 30
|
|
41
|
+
DEFAULT_MAX_RECIPIENTS = 100
|
|
42
|
+
DEFAULT_MAX_MESSAGE_SIZE = 10_485_760 # 10 MiB
|
|
43
|
+
# Absolute ceiling so idle resets on each line cannot stretch a session forever.
|
|
44
|
+
DEFAULT_MAX_SESSION_DURATION = 1800
|
|
45
|
+
DEFAULT_MAX_AUTH_FAILURES = 3
|
|
46
|
+
DEFAULT_MAX_EXCEPTIONS = 20
|
|
47
|
+
# Short write budget for a 421 abort after a normal write already timed out,
|
|
48
|
+
# so we do not stack another full +write_timeout+ before dropping the socket.
|
|
49
|
+
ABORT_REPLY_WRITE_TIMEOUT = 2
|
|
50
|
+
# RFC 5321 §4.5.3.1.4 / §4.5.3.1.6 (octets, including the CRLF).
|
|
51
|
+
MAX_COMMAND_LINE_LENGTH = 512
|
|
52
|
+
MAX_TEXT_LINE_LENGTH = 1000
|
|
53
|
+
# RFC 4954 §4: AUTH base64 responses (commands and continuations) may be
|
|
54
|
+
# up to 12288 octets — not the 512-octet SMTP command-line ceiling.
|
|
55
|
+
MAX_AUTH_LINE_LENGTH = 12_288
|
|
56
|
+
|
|
57
|
+
MAX_FAILED_ACCEPTS = 10
|
|
58
|
+
FAILED_ACCEPT_BACKOFF = 0.5
|
|
59
|
+
|
|
60
|
+
StopService = Class.new(RuntimeError)
|
|
61
|
+
StopConnection = Class.new(RuntimeError)
|
|
62
|
+
BufferOverrun = Class.new(RuntimeError)
|
|
63
|
+
private_constant :StopService, :StopConnection, :BufferOverrun
|
|
64
|
+
|
|
65
|
+
attr_reader :max_connections, :max_connections_per_ip, :max_processings
|
|
66
|
+
# Reassign between STARTTLS negotiations to hot-swap certificates (e.g. ACME).
|
|
67
|
+
# Engine paths call +#tls+ (not +@tls+) so an overridden reader is honored.
|
|
68
|
+
attr_accessor :tls
|
|
69
|
+
|
|
70
|
+
# +host+/+port+ bind a single address. +hosts+/+ports+ (arrays or
|
|
71
|
+
# comma-separated strings) expand to every host×port pair (multi-listen).
|
|
72
|
+
# +proxy_hosts+ non-empty enables PROXY from those IPs or CIDRs.
|
|
73
|
+
# +add_received+ prepends {#received_header} onto the DATA reader.
|
|
74
|
+
#
|
|
75
|
+
# +max_message_size+ is enforced while DATA lines are accepted from the
|
|
76
|
+
# client (before they are offered to +receive+). After +receive+ returns or
|
|
77
|
+
# raises, +drain+ discards remaining bytes through "." without re-applying
|
|
78
|
+
# SIZE — one reply after the body (or a size-ceiling abort).
|
|
79
|
+
#
|
|
80
|
+
# When +proxy_hosts+ allowlists a peer, the per-IP connection cap is deferred
|
|
81
|
+
# until PROXY rewrites the client address — otherwise a load balancer's
|
|
82
|
+
# shared peer IP would cap the whole listener.
|
|
83
|
+
def initialize(tls:, max_connections:, max_processings:, auth:, starttls:,
|
|
84
|
+
host: nil, port: nil, hosts: nil, ports: nil,
|
|
85
|
+
max_connections_per_ip: nil, eight_bit_mime: false, smtputf8: false,
|
|
86
|
+
pipelining: false, proxy_hosts: [], add_received: false,
|
|
87
|
+
forbid_bare_newline: true,
|
|
88
|
+
max_message_size: DEFAULT_MAX_MESSAGE_SIZE, max_recipients: DEFAULT_MAX_RECIPIENTS,
|
|
89
|
+
max_processings_wait: DEFAULT_MAX_PROCESSINGS_WAIT,
|
|
90
|
+
read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT,
|
|
91
|
+
handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT,
|
|
92
|
+
max_session_duration: DEFAULT_MAX_SESSION_DURATION,
|
|
93
|
+
max_auth_failures: DEFAULT_MAX_AUTH_FAILURES,
|
|
94
|
+
max_exceptions: DEFAULT_MAX_EXCEPTIONS,
|
|
95
|
+
io_buffer_max_size: DEFAULT_IO_BUFFER_MAX_SIZE,
|
|
96
|
+
logger: nil, logger_severity: Logger::INFO)
|
|
97
|
+
raise ArgumentError, "Unknown auth mode #{auth.inspect}" unless AUTH_MODES.include?(auth)
|
|
98
|
+
raise ArgumentError, "Unknown starttls mode #{starttls.inspect}" unless STARTTLS_MODES.include?(starttls)
|
|
99
|
+
raise ArgumentError, "max_recipients must be at least 100 (RFC 5321 §4.5.3.1)" if max_recipients < 100
|
|
100
|
+
raise ArgumentError, "starttls: :required requires a tls object" if starttls == :required && tls.nil?
|
|
101
|
+
raise ArgumentError, "max_connections must be an Integer >= 1" unless max_connections.is_a?(Integer) && max_connections >= 1
|
|
102
|
+
raise ArgumentError, "max_processings must be an Integer >= 1" unless max_processings.is_a?(Integer) && max_processings >= 1
|
|
103
|
+
unless max_connections_per_ip.nil? || (max_connections_per_ip.is_a?(Integer) && max_connections_per_ip >= 1)
|
|
104
|
+
raise ArgumentError, "max_connections_per_ip must be nil or an Integer >= 1"
|
|
105
|
+
end
|
|
106
|
+
unless max_auth_failures.nil? || (max_auth_failures.is_a?(Integer) && max_auth_failures >= 1)
|
|
107
|
+
raise ArgumentError, "max_auth_failures must be nil or an Integer >= 1"
|
|
108
|
+
end
|
|
109
|
+
unless max_exceptions.nil? || (max_exceptions.is_a?(Integer) && max_exceptions >= 1)
|
|
110
|
+
raise ArgumentError, "max_exceptions must be nil or an Integer >= 1"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@addresses = expand_addresses(hosts || host, ports || port)
|
|
114
|
+
raise ArgumentError, "host and port (or hosts and ports) are required" if @addresses.empty?
|
|
115
|
+
|
|
116
|
+
@tls = tls
|
|
117
|
+
@max_connections = max_connections
|
|
118
|
+
@max_connections_per_ip = max_connections_per_ip
|
|
119
|
+
@max_processings = max_processings
|
|
120
|
+
@max_processings_wait = max_processings_wait
|
|
121
|
+
@auth = auth
|
|
122
|
+
@starttls = starttls
|
|
123
|
+
@eight_bit_mime = eight_bit_mime
|
|
124
|
+
@smtputf8 = smtputf8
|
|
125
|
+
@pipelining = pipelining
|
|
126
|
+
@forbid_bare_newline = forbid_bare_newline
|
|
127
|
+
@proxy_hosts = Array(proxy_hosts).map { |entry| IPAddr.new(entry) }
|
|
128
|
+
@add_received = add_received
|
|
129
|
+
@max_message_size = max_message_size
|
|
130
|
+
@max_recipients = max_recipients
|
|
131
|
+
@read_timeout = read_timeout
|
|
132
|
+
@write_timeout = write_timeout
|
|
133
|
+
# STARTTLS budget is independent of +read_timeout+ (which may be nil to
|
|
134
|
+
# disable idle timeouts). Always apply a finite handshake ceiling —
|
|
135
|
+
# +Timeout+ alone cannot interrupt OpenSSL's blocking accept, so we also
|
|
136
|
+
# set +IO#timeout+ around the handshake when the socket supports it.
|
|
137
|
+
@handshake_timeout = handshake_timeout || DEFAULT_HANDSHAKE_TIMEOUT
|
|
138
|
+
@max_session_duration = max_session_duration
|
|
139
|
+
@max_auth_failures = max_auth_failures
|
|
140
|
+
@max_exceptions = max_exceptions
|
|
141
|
+
@io_buffer_max_size = io_buffer_max_size
|
|
142
|
+
|
|
143
|
+
@stdout_logger = logger || build_default_logger(logger_severity)
|
|
144
|
+
|
|
145
|
+
@connections = []
|
|
146
|
+
@connection_ips = {}
|
|
147
|
+
@processings = []
|
|
148
|
+
@connections_mutex = Mutex.new
|
|
149
|
+
@connections_cv = ConditionVariable.new
|
|
150
|
+
@shutdown = false
|
|
151
|
+
@tcp_servers = []
|
|
152
|
+
@acceptor_threads = []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def start
|
|
156
|
+
raise "Service was already started" unless stopped?
|
|
157
|
+
|
|
158
|
+
@shutdown = false
|
|
159
|
+
@tcp_servers = @addresses.map { |host, port| TCPServer.new(host, port) }
|
|
160
|
+
attach_acceptors
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def stop(wait_seconds_before_close: 2)
|
|
164
|
+
@shutdown = true
|
|
165
|
+
|
|
166
|
+
acceptors = @acceptor_threads.dup
|
|
167
|
+
@connections_mutex.synchronize do
|
|
168
|
+
acceptors.each { |thread| thread.raise(StopService) }
|
|
169
|
+
end
|
|
170
|
+
acceptors.each(&:join)
|
|
171
|
+
drain_connections(wait_seconds_before_close)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def stopped?
|
|
175
|
+
@acceptor_threads.empty?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def connections_count
|
|
179
|
+
@connections.size
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def connections?
|
|
183
|
+
connections_count.positive?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def processings_count
|
|
187
|
+
@processings.size
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def addresses
|
|
191
|
+
@addresses.map { |host, port| "#{host}:#{port}" }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Override to customize the opt-in Received header (+add_received: true+).
|
|
195
|
+
def received_header(session)
|
|
196
|
+
protocol = session.encrypted? ? (session.authenticated? ? "ESMTPSA" : "ESMTPS") : "ESMTP"
|
|
197
|
+
helo = session.helo.to_s.empty? ? "unknown" : session.helo
|
|
198
|
+
"Received: from #{helo} (unknown [#{session.remote_ip}]) " \
|
|
199
|
+
"by #{session.helo_response} with #{protocol}; #{Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S +0000")}\r\n"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Optional: inspect or replace PROXY data after a valid PROXY line.
|
|
203
|
+
def proxy(session, proxy_data)
|
|
204
|
+
proxy_data
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def log(session, severity, msg, err: nil)
|
|
208
|
+
if session.respond_to?(:remote_ip) && session.remote_ip
|
|
209
|
+
msg = "[#{session.remote_ip}] #{msg}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
case severity
|
|
213
|
+
when Logger::FATAL
|
|
214
|
+
@stdout_logger.fatal(msg)
|
|
215
|
+
Array(err&.backtrace).each { |line| @stdout_logger.fatal(line) }
|
|
216
|
+
when Logger::ERROR
|
|
217
|
+
@stdout_logger.error(msg)
|
|
218
|
+
when Logger::WARN
|
|
219
|
+
@stdout_logger.warn(msg)
|
|
220
|
+
when Logger::INFO
|
|
221
|
+
@stdout_logger.info(msg)
|
|
222
|
+
else
|
|
223
|
+
@stdout_logger.debug(msg)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def connected(session)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def disconnected(session)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def helo(session, name)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def authenticate(session, username:, password:, authorization_id: "")
|
|
237
|
+
raise AuthenticationFailed
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# SASL mechanism names advertised on EHLO (`250-AUTH …`) and accepted by
|
|
241
|
+
# +AUTH+. Default is PLAIN and LOGIN; override to narrow the list or to
|
|
242
|
+
# advertise additional names before their handlers exist (unknown names
|
|
243
|
+
# still reply 504 until the engine learns them).
|
|
244
|
+
def auth_mechanisms(_session)
|
|
245
|
+
%w[ PLAIN LOGIN ]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def mail_from(session, address)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def rcpt_to(session, address)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def receiving_started(session)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def headers_received(session)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# +io+ is a +DataReader+ (or +PrefixedReader+) of dot-unstuffed message bytes.
|
|
261
|
+
# Prefer +IO.copy_stream+ (or chunked +read+) over +io.read+ so large mail
|
|
262
|
+
# does not become one String. A SIZE or line-length failure raises a
|
|
263
|
+
# +MailSmtp::Error+ from +read+ — do not treat a short body as success.
|
|
264
|
+
# The engine drains unread data through "." before replying, except after
|
|
265
|
+
# +MessageTooLarge+, when it replies 552 and drops the connection.
|
|
266
|
+
def receive(session, io)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def unknown_command(_session, _line)
|
|
270
|
+
raise CommandNotRecognized
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Override to supply a Session subclass.
|
|
274
|
+
def build_session
|
|
275
|
+
Session.new
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Override for per-command policy (rate limits, session expiry, …). Call
|
|
279
|
+
# +super+ to keep the engine's command dispatch.
|
|
280
|
+
def process_line(session, line)
|
|
281
|
+
case session.phase
|
|
282
|
+
when :auth_plain
|
|
283
|
+
process_auth_plain(session, line)
|
|
284
|
+
when :auth_login_user
|
|
285
|
+
process_auth_login_username(session, line)
|
|
286
|
+
when :auth_login_pass
|
|
287
|
+
process_auth_login_password(session, line)
|
|
288
|
+
else
|
|
289
|
+
process_command_line(session, line)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def shutdown?
|
|
294
|
+
@shutdown
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
298
|
+
def build_default_logger(severity)
|
|
299
|
+
Logger.new($stdout).tap do |logger|
|
|
300
|
+
logger.level = severity
|
|
301
|
+
logger.datetime_format = "%Y-%m-%d %H:%M:%S"
|
|
302
|
+
logger.formatter = proc { |sev, datetime, _progname, msg| "#{datetime}: [#{sev}] #{msg.to_s.chomp}\n" }
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def expand_addresses(hosts, ports)
|
|
307
|
+
host_list = Array(hosts).flat_map { |value| value.to_s.split(/[,\s]+/) }.map(&:strip).reject(&:empty?)
|
|
308
|
+
port_list = Array(ports).flat_map { |value| value.to_s.split(/[,\s:]+/) }.map(&:strip).reject(&:empty?).map { |port| Integer(port) }
|
|
309
|
+
host_list.product(port_list)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def attach_acceptors
|
|
313
|
+
@acceptor_threads = @tcp_servers.map do |tcp_server|
|
|
314
|
+
Thread.new { accept_loop(tcp_server) }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def proxy_enabled?
|
|
319
|
+
@proxy_hosts.any?
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def proxy_allowed?(remote_ip)
|
|
323
|
+
return false unless proxy_enabled?
|
|
324
|
+
return false if remote_ip.nil?
|
|
325
|
+
|
|
326
|
+
@proxy_hosts.any? { |allowed| allowed.include?(remote_ip) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# A failed accept is usually the machine's problem, not ours — a client that
|
|
330
|
+
# vanished mid-handshake, or no file descriptors left for a moment — so we
|
|
331
|
+
# log it and keep listening. Only a run of failures long enough to look
|
|
332
|
+
# permanent takes the listener down, and then the supervisor restarts it.
|
|
333
|
+
def accept_loop(tcp_server)
|
|
334
|
+
failed_accepts = 0
|
|
335
|
+
|
|
336
|
+
until shutdown?
|
|
337
|
+
begin
|
|
338
|
+
client = tcp_server.accept
|
|
339
|
+
failed_accepts = 0
|
|
340
|
+
Thread.new(client) { |io| handle_connection(io) }
|
|
341
|
+
rescue StopService
|
|
342
|
+
raise
|
|
343
|
+
rescue StandardError => e
|
|
344
|
+
failed_accepts += 1
|
|
345
|
+
raise if failed_accepts > MAX_FAILED_ACCEPTS
|
|
346
|
+
|
|
347
|
+
log(nil, Logger::ERROR, "Can't accept a connection (#{failed_accepts}/#{MAX_FAILED_ACCEPTS}): #{loggable_error(e)}")
|
|
348
|
+
sleep FAILED_ACCEPT_BACKOFF
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
rescue StopService
|
|
352
|
+
# signaled by #stop, not an error
|
|
353
|
+
rescue StandardError => e
|
|
354
|
+
log(nil, Logger::FATAL, loggable_error(e), err: e)
|
|
355
|
+
ensure
|
|
356
|
+
close_tcp_server(tcp_server)
|
|
357
|
+
@acceptor_threads.delete(Thread.current)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Give in-flight connections a bounded moment to finish on their own (a
|
|
361
|
+
# delivery is a quick DB transaction), then force any stragglers and wait
|
|
362
|
+
# for them to unwind, so we never exit the process mid-message.
|
|
363
|
+
def drain_connections(grace_seconds)
|
|
364
|
+
deadline = monotonic_time + grace_seconds
|
|
365
|
+
sleep 0.05 while connections? && monotonic_time < deadline
|
|
366
|
+
|
|
367
|
+
stragglers = @connections.dup
|
|
368
|
+
stragglers.each { |thread| thread.raise(StopConnection) }
|
|
369
|
+
stragglers.each(&:join)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def close_tcp_server(tcp_server)
|
|
373
|
+
tcp_server.close
|
|
374
|
+
rescue StandardError
|
|
375
|
+
# already gone
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Keyed by thread so a slot is released exactly where the connection is: a
|
|
379
|
+
# count that drifted upward would leave the listener refusing everyone.
|
|
380
|
+
def handle_connection(io)
|
|
381
|
+
begin
|
|
382
|
+
session = build_session
|
|
383
|
+
io = serve_client(session, io)
|
|
384
|
+
rescue StopConnection
|
|
385
|
+
# Forced stop after the grace window — RST so the peer does not sit
|
|
386
|
+
# half-open waiting on a bare FIN.
|
|
387
|
+
abort_socket!(io)
|
|
388
|
+
io = nil
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
log(nil, Logger::FATAL, loggable_error(e), err: e)
|
|
391
|
+
ensure
|
|
392
|
+
begin
|
|
393
|
+
if io
|
|
394
|
+
abort_socket!(io) if shutdown?
|
|
395
|
+
io.close unless io.closed?
|
|
396
|
+
end
|
|
397
|
+
rescue StandardError
|
|
398
|
+
# already gone
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
release_connection_slot!
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Register this thread under +remote_ip+ and enforce global / per-IP caps in
|
|
406
|
+
# the same critical section. Re-calling with a new IP (PROXY) updates the
|
|
407
|
+
# identity and re-checks the per-IP cap against the rewritten address.
|
|
408
|
+
# Pass +enforce_per_ip: false+ for an allowlisted PROXY peer before the
|
|
409
|
+
# PROXY line rewrites the client — the load balancer IP must not consume
|
|
410
|
+
# the per-IP budget for every tenant behind it.
|
|
411
|
+
def claim_connection_slot!(remote_ip, enforce_per_ip: true)
|
|
412
|
+
@connections_mutex.synchronize do
|
|
413
|
+
if @connections.include?(Thread.current)
|
|
414
|
+
old_ip = @connection_ips[Thread.current]
|
|
415
|
+
if old_ip != remote_ip
|
|
416
|
+
if enforce_per_ip && max_connections_per_ip && remote_ip &&
|
|
417
|
+
@connection_ips.values.count(remote_ip) >= max_connections_per_ip
|
|
418
|
+
raise ServiceUnavailable, "Too many connections from #{remote_ip}"
|
|
419
|
+
end
|
|
420
|
+
@connection_ips[Thread.current] = remote_ip
|
|
421
|
+
end
|
|
422
|
+
else
|
|
423
|
+
if max_connections && @connections.size >= max_connections
|
|
424
|
+
raise ServiceUnavailable, "Abort connection while too busy, exceeding max_connections!"
|
|
425
|
+
end
|
|
426
|
+
if enforce_per_ip && max_connections_per_ip && remote_ip &&
|
|
427
|
+
@connection_ips.values.count(remote_ip) >= max_connections_per_ip
|
|
428
|
+
raise ServiceUnavailable, "Too many connections from #{remote_ip}"
|
|
429
|
+
end
|
|
430
|
+
@connections << Thread.current
|
|
431
|
+
@connection_ips[Thread.current] = remote_ip
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def release_connection_slot!
|
|
437
|
+
@connections_mutex.synchronize do
|
|
438
|
+
@connections.delete(Thread.current)
|
|
439
|
+
@connection_ips.delete(Thread.current)
|
|
440
|
+
# Safety: a mid-DATA abort must not leave a processing slot stuck.
|
|
441
|
+
@processings.delete(Thread.current)
|
|
442
|
+
@connections_cv.signal
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Processing slots gate concurrent DATA transfers only — idle/envelope
|
|
447
|
+
# sessions do not consume one.
|
|
448
|
+
def claim_processing_slot!
|
|
449
|
+
@connections_mutex.synchronize do
|
|
450
|
+
deadline = monotonic_time + @max_processings_wait
|
|
451
|
+
until processings_count < max_processings
|
|
452
|
+
remaining = deadline - monotonic_time
|
|
453
|
+
raise ServiceUnavailable, "Abort connection while too busy, exceeding max_processings!" if remaining <= 0
|
|
454
|
+
|
|
455
|
+
@connections_cv.wait(@connections_mutex, remaining)
|
|
456
|
+
end
|
|
457
|
+
@processings << Thread.current
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def release_processing_slot!
|
|
462
|
+
@connections_mutex.synchronize do
|
|
463
|
+
@processings.delete(Thread.current)
|
|
464
|
+
@connections_cv.signal
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# A socket the client has already abandoned has no peer to name; claim under
|
|
469
|
+
# a nil IP (counts only against the global cap).
|
|
470
|
+
def serve_client(session, io)
|
|
471
|
+
notified_connected = false
|
|
472
|
+
begin
|
|
473
|
+
session.local_ip = io.addr(:numeric)[3]
|
|
474
|
+
session.remote_ip = io.peeraddr(:numeric)[3]
|
|
475
|
+
session.connected_at = Time.now.utc
|
|
476
|
+
session.local_response = "#{session.local_ip} ESMTP"
|
|
477
|
+
session.helo_response = session.local_ip
|
|
478
|
+
|
|
479
|
+
claim_connection_slot!(session.remote_ip, enforce_per_ip: !proxy_allowed?(session.remote_ip))
|
|
480
|
+
connected(session)
|
|
481
|
+
notified_connected = true
|
|
482
|
+
|
|
483
|
+
# Greeting is before EHLO advertises ENHANCEDSTATUSCODES — plain 220.
|
|
484
|
+
respond(io, session, reply(220, session.local_response.to_s.strip, enhanced: nil))
|
|
485
|
+
|
|
486
|
+
io_buffer = +""
|
|
487
|
+
io_buffer_line_lf = nil
|
|
488
|
+
idle_since = monotonic_time
|
|
489
|
+
session_deadline = @max_session_duration && (idle_since + @max_session_duration)
|
|
490
|
+
|
|
491
|
+
loop do
|
|
492
|
+
if session.phase == :starttls
|
|
493
|
+
begin
|
|
494
|
+
io = with_handshake_timeout(io) { tls.start(io) }
|
|
495
|
+
rescue Timeout::Error, IO::TimeoutError
|
|
496
|
+
raise ServiceUnavailable, "STARTTLS timed out"
|
|
497
|
+
rescue StandardError => e
|
|
498
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
499
|
+
return io
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# RFC 3207 §4.2: after the handshake, discard everything the client
|
|
503
|
+
# told us in plaintext (HELO, envelope, AUTH) and require a fresh HELO.
|
|
504
|
+
session.reset
|
|
505
|
+
session.reset_authentication
|
|
506
|
+
session.helo = nil
|
|
507
|
+
session.encrypted_at = Time.now.utc
|
|
508
|
+
record_tls_details!(session, io)
|
|
509
|
+
session.phase = :helo
|
|
510
|
+
idle_since = monotonic_time
|
|
511
|
+
|
|
512
|
+
# Bytes still buffered here arrived in the clear behind STARTTLS, so
|
|
513
|
+
# a MITM could have written them. Drop them rather than let them be
|
|
514
|
+
# read back as commands from inside the TLS session (CVE-2011-0411).
|
|
515
|
+
io_buffer.clear
|
|
516
|
+
io_buffer_line_lf = nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
begin
|
|
520
|
+
unless io_buffer_line_lf
|
|
521
|
+
raise_if_session_expired!(session_deadline)
|
|
522
|
+
raise_if_idle_timeout!(idle_since)
|
|
523
|
+
io_buffer << io.read_nonblock(IO_BUFFER_CHUNK_SIZE)
|
|
524
|
+
raise BufferOverrun if @io_buffer_max_size && io_buffer.length > @io_buffer_max_size
|
|
525
|
+
io_buffer_line_lf = io_buffer.index("\n")
|
|
526
|
+
end
|
|
527
|
+
# No data ready: block on the socket instead of busy-polling. We only
|
|
528
|
+
# get here once read_nonblock has drained any bytes SSLSocket buffered
|
|
529
|
+
# internally, so waiting on the raw fd is safe. A TLS renegotiation can
|
|
530
|
+
# ask to write mid-read, hence the WaitWritable arm.
|
|
531
|
+
rescue IO::WaitReadable
|
|
532
|
+
raise_if_session_expired!(session_deadline)
|
|
533
|
+
raise_if_idle_timeout!(idle_since)
|
|
534
|
+
IO.select([ io.to_io ], nil, nil, IO_WAIT_TIMEOUT)
|
|
535
|
+
rescue IO::WaitWritable
|
|
536
|
+
raise_if_session_expired!(session_deadline)
|
|
537
|
+
raise_if_idle_timeout!(idle_since)
|
|
538
|
+
IO.select(nil, [ io.to_io ], nil, IO_WAIT_TIMEOUT)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
while io_buffer_line_lf
|
|
542
|
+
line = io_buffer.slice!(0, io_buffer_line_lf + 1)
|
|
543
|
+
io_buffer_line_lf = io_buffer.index("\n")
|
|
544
|
+
command_ok = false
|
|
545
|
+
|
|
546
|
+
begin
|
|
547
|
+
raise_if_session_expired!(session_deadline)
|
|
548
|
+
raise_if_idle_timeout!(idle_since)
|
|
549
|
+
raise PipeliningNotSupported if io_buffer_line_lf && !pipelining_allowed?(session)
|
|
550
|
+
|
|
551
|
+
# Commands get every CR and LF removed, not just the terminator,
|
|
552
|
+
# so a bare CR can't ride inside an address into the envelope.
|
|
553
|
+
line.delete!("\r\n")
|
|
554
|
+
max_line = auth_line_length?(session, line) ? MAX_AUTH_LINE_LENGTH : MAX_COMMAND_LINE_LENGTH
|
|
555
|
+
raise InvalidParameters, "Command line too long" if line.bytesize + 2 > max_line
|
|
556
|
+
log(session, Logger::DEBUG, "<<< #{loggable_command(session, line)}\n")
|
|
557
|
+
|
|
558
|
+
output = process_line(session, line)
|
|
559
|
+
|
|
560
|
+
# After 354, pull the body with a live reader into +receive+, then
|
|
561
|
+
# one final reply — no SMTP responses mid-DATA.
|
|
562
|
+
if session.phase == :data
|
|
563
|
+
respond(io, session, output) if output && !output.empty?
|
|
564
|
+
output = transfer_data(session, io, io_buffer, session_deadline: session_deadline)
|
|
565
|
+
io_buffer_line_lf = io_buffer.index("\n")
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
respond(io, session, output) if output && !output.empty?
|
|
569
|
+
command_ok = true
|
|
570
|
+
break if session.close_after_reply
|
|
571
|
+
rescue ServiceUnavailable
|
|
572
|
+
# 421 aborts the connection: re-raise past the command loop so the
|
|
573
|
+
# outer handler sends a single closing reply and drops the socket.
|
|
574
|
+
raise
|
|
575
|
+
rescue PipeliningNotSupported => e
|
|
576
|
+
session.exceptions += 1
|
|
577
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
578
|
+
respond(io, session, e.response)
|
|
579
|
+
# Drop the pipelined continuation still buffered (e.g. an AUTH
|
|
580
|
+
# credential) so it isn't re-read, logged unredacted, and run out
|
|
581
|
+
# of sequence. Same reasoning as the STARTTLS buffer drop above.
|
|
582
|
+
io_buffer.clear
|
|
583
|
+
io_buffer_line_lf = nil
|
|
584
|
+
raise_if_exception_caps!(session, e)
|
|
585
|
+
rescue Error => e
|
|
586
|
+
session.exceptions += 1
|
|
587
|
+
session.auth_failures += 1 if e.is_a?(AuthenticationFailed)
|
|
588
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
589
|
+
respond(io, session, e.response)
|
|
590
|
+
raise_if_exception_caps!(session, e)
|
|
591
|
+
rescue StandardError => e
|
|
592
|
+
session.exceptions += 1
|
|
593
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
594
|
+
respond(io, session, LocalError.new.response)
|
|
595
|
+
raise_if_exception_caps!(session, e)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Idle timer advances only on a fully successful command — errors and
|
|
599
|
+
# probes must not keep a slowloris session alive forever by themselves.
|
|
600
|
+
idle_since = monotonic_time if command_ok
|
|
601
|
+
|
|
602
|
+
# STARTTLS and QUIT end the plaintext conversation, so anything the
|
|
603
|
+
# client pipelined behind them must not be run as if it belonged to
|
|
604
|
+
# the session that follows. Hand back to the outer loop instead.
|
|
605
|
+
break if %i[ starttls quit ].include?(session.phase)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
break if session.phase == :quit || session.close_after_reply || io.closed? || shutdown?
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
respond(io, session, reply(221, "Service closing transmission channel")) unless session.close_after_reply
|
|
612
|
+
rescue StopConnection, StopService
|
|
613
|
+
raise
|
|
614
|
+
rescue EOFError => e
|
|
615
|
+
log(session, Logger::DEBUG, "Connection lost due abort by client! (EOFError)", err: e)
|
|
616
|
+
rescue ServiceUnavailable => e
|
|
617
|
+
session.exceptions += 1
|
|
618
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
619
|
+
begin
|
|
620
|
+
respond(io, session, e.response, abort_reply: true)
|
|
621
|
+
rescue ServiceUnavailable, StandardError => send_error
|
|
622
|
+
log(session, Logger::DEBUG, "Can't send 421 abort code! (#{send_error.class})", err: send_error)
|
|
623
|
+
end
|
|
624
|
+
rescue StandardError => e
|
|
625
|
+
session.exceptions += 1
|
|
626
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
627
|
+
begin
|
|
628
|
+
respond(io, session, ServiceUnavailable.new.response, abort_reply: true)
|
|
629
|
+
rescue ServiceUnavailable, StandardError => send_error
|
|
630
|
+
log(session, Logger::DEBUG, "Can't send 421 abort code! (#{send_error.class})", err: send_error)
|
|
631
|
+
end
|
|
632
|
+
ensure
|
|
633
|
+
disconnected(session) if notified_connected
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
io
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def connections_from(remote_ip)
|
|
640
|
+
@connections_mutex.synchronize { @connection_ips.values.count(remote_ip) }
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Message lines lose only their terminator. Bare LF is rejected when
|
|
644
|
+
# +forbid_bare_newline+ is set (default); see +read_data_line!+.
|
|
645
|
+
def strip_line_terminator(line)
|
|
646
|
+
line.sub!(/\r?\n\z/, "")
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Mask credentials before they reach the DEBUG log: an AUTH value-state line
|
|
650
|
+
# is the secret itself, and an inline "AUTH PLAIN/LOGIN <arg>" carries it in
|
|
651
|
+
# the argument. Everything else logs verbatim.
|
|
652
|
+
def loggable_command(session, line)
|
|
653
|
+
if auth_value_phase?(session.phase)
|
|
654
|
+
"[redacted credential]"
|
|
655
|
+
else
|
|
656
|
+
line.sub(/\A(AUTH\s+\S+)\s+\S.*\z/i, '\1 [redacted]')
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def auth_value_phase?(phase)
|
|
661
|
+
%i[ auth_plain auth_login_user auth_login_pass ].include?(phase)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Most protocol errors are raised bare, because the class is the whole
|
|
665
|
+
# explanation — and a bare exception's message *is* its class name, so the
|
|
666
|
+
# usual "message (Class)" form prints it twice. Name the class once when
|
|
667
|
+
# that is all there is to say. The remote IP is left to +log+, which
|
|
668
|
+
# prefixes every line with it.
|
|
669
|
+
def loggable_error(error)
|
|
670
|
+
message = error.message.to_s
|
|
671
|
+
message == error.class.name ? message : "#{message} (#{error.class})"
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def respond(io, session, output, abort_reply: false)
|
|
675
|
+
log(session, Logger::DEBUG, ">>> #{output}")
|
|
676
|
+
return if io.closed?
|
|
677
|
+
|
|
678
|
+
write_timeout = if abort_reply
|
|
679
|
+
[ @write_timeout, ABORT_REPLY_WRITE_TIMEOUT ].compact.min
|
|
680
|
+
else
|
|
681
|
+
@write_timeout
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
if write_timeout
|
|
685
|
+
Timeout.timeout(write_timeout) { io.print("#{output}\r\n") }
|
|
686
|
+
else
|
|
687
|
+
io.print("#{output}\r\n")
|
|
688
|
+
end
|
|
689
|
+
rescue Timeout::Error, IO::TimeoutError
|
|
690
|
+
raise ServiceUnavailable, "Write timed out"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Success and informational replies. Pass +enhanced: nil+ for replies that
|
|
694
|
+
# omit the RFC 2034 status (greeting, EHLO multilines, 354, AUTH 334).
|
|
695
|
+
def reply(code, text, enhanced: "2.0.0")
|
|
696
|
+
if enhanced.nil?
|
|
697
|
+
"#{code} #{text}"
|
|
698
|
+
else
|
|
699
|
+
"#{code} #{enhanced} #{text}"
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def process_command_line(session, line)
|
|
704
|
+
case line
|
|
705
|
+
when /\A(HELO|EHLO)(\s+.*)?\z/i
|
|
706
|
+
process_helo(session, line)
|
|
707
|
+
when /\APROXY(\s+)/i
|
|
708
|
+
process_proxy(session, line)
|
|
709
|
+
when /\ASTARTTLS\s*\z/i
|
|
710
|
+
process_starttls(session)
|
|
711
|
+
# The initial response is framed loosely and validated where it is
|
|
712
|
+
# decoded: the continuation form arrives on a bare line no regex guards,
|
|
713
|
+
# so decode_base64 is already the one gate on that path, and keeping a
|
|
714
|
+
# second copy of the base64 alphabet here would only let the two drift.
|
|
715
|
+
when /\AAUTH(\s+)(\S+)(\s+\S+)?\s*\z/i
|
|
716
|
+
process_auth(session, line)
|
|
717
|
+
when /\ANOOP(\s+.*)?\z/i
|
|
718
|
+
reply(250, "OK")
|
|
719
|
+
when /\AVRFY(\s+.*)?\z/i
|
|
720
|
+
# RFC 5321 §3.5 / §4.5.1: always implement VRFY; 252 is the safe
|
|
721
|
+
# answer when we will not confirm mailbox existence.
|
|
722
|
+
reply(252, "Cannot VRFY user, but will accept message and attempt delivery", enhanced: "2.5.0")
|
|
723
|
+
when /\AEXPN(\s+.*)?\z/i
|
|
724
|
+
raise CommandNotImplemented
|
|
725
|
+
when /\AHELP(\s+.*)?\z/i
|
|
726
|
+
raise CommandNotImplemented
|
|
727
|
+
when /\ARSET\s*\z/i
|
|
728
|
+
process_rset(session)
|
|
729
|
+
when /\AQUIT\s*\z/i
|
|
730
|
+
session.phase = :quit
|
|
731
|
+
""
|
|
732
|
+
when /\AMAIL FROM:/i
|
|
733
|
+
process_mail_from(session, line)
|
|
734
|
+
when /\ARCPT TO:/i
|
|
735
|
+
process_rcpt_to(session, line)
|
|
736
|
+
when /\ADATA\s*\z/i
|
|
737
|
+
process_data_start(session)
|
|
738
|
+
else
|
|
739
|
+
unknown_command(session, line)
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def process_helo(session, line)
|
|
744
|
+
# Mid-AUTH / mid-DATA are not a fresh greeting opportunity.
|
|
745
|
+
raise BadSequence if auth_value_phase?(session.phase) || session.phase == :data
|
|
746
|
+
|
|
747
|
+
# Bare HELO/EHLO (no argument) must not treat the verb as the hostname.
|
|
748
|
+
match = line.match(/\A(HELO|EHLO)(?:\s+(.*))?\z/i)
|
|
749
|
+
name = match[2].to_s.strip
|
|
750
|
+
raise InvalidParameters if name.empty?
|
|
751
|
+
|
|
752
|
+
# RFC 5321 §4.1.4: a later EHLO/HELO resets state exactly as RSET.
|
|
753
|
+
session.reset if session.phase != :helo
|
|
754
|
+
|
|
755
|
+
helo(session, name)
|
|
756
|
+
session.helo = name
|
|
757
|
+
session.phase = :ready
|
|
758
|
+
|
|
759
|
+
if line.match?(/\AEHLO/i)
|
|
760
|
+
[
|
|
761
|
+
"250-#{session.helo_response.to_s.strip}",
|
|
762
|
+
"250-ENHANCEDSTATUSCODES",
|
|
763
|
+
(@max_message_size ? "250-SIZE #{@max_message_size}" : nil),
|
|
764
|
+
(@eight_bit_mime ? "250-8BITMIME" : nil),
|
|
765
|
+
(@smtputf8 ? "250-SMTPUTF8" : nil),
|
|
766
|
+
(@pipelining ? "250-PIPELINING" : nil),
|
|
767
|
+
(auth_ehlo_line(session)),
|
|
768
|
+
(starttls_available?(session) ? "250-STARTTLS" : nil),
|
|
769
|
+
# EHLO multilines omit enhanced codes.
|
|
770
|
+
reply(250, "OK", enhanced: nil)
|
|
771
|
+
].compact.join("\r\n")
|
|
772
|
+
else
|
|
773
|
+
reply(250, session.helo_response.to_s.strip)
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def process_proxy(session, line)
|
|
778
|
+
raise CommandNotRecognized unless proxy_enabled?
|
|
779
|
+
raise BadSequence if session.phase != :helo
|
|
780
|
+
raise ServiceUnavailable, "Abort connection while PROXY not allowed from #{session.remote_ip}" unless proxy_allowed?(session.remote_ip)
|
|
781
|
+
raise ServiceUnavailable, "Abort connection while PROXY already set!" if session.proxy
|
|
782
|
+
raise ServiceUnavailable, "Abort connection while illegal PROXY command!" unless line.match?(
|
|
783
|
+
/\APROXY(\s+)(UNKNOWN(|(\s+).*)|TCP(4|6)(\s+)([0-9a-f.:]+)(\s+)([0-9a-f.:]+)(\s+)([0-9]+)(\s+)([0-9]+)\s*)\z/i
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
parts = line.sub(/\APROXY\s+/i, "").strip.split(/\s+/)
|
|
787
|
+
proxy_data = { proto: parts[0].upcase, source_ip: nil, source_port: nil, dest_ip: nil, dest_port: nil }
|
|
788
|
+
|
|
789
|
+
unless proxy_data[:proto] == "UNKNOWN"
|
|
790
|
+
begin
|
|
791
|
+
proxy_data[:source_ip] = IPAddr.new(parts[1])
|
|
792
|
+
proxy_data[:dest_ip] = IPAddr.new(parts[2])
|
|
793
|
+
proxy_data[:source_port] = Integer(parts[3])
|
|
794
|
+
proxy_data[:dest_port] = Integer(parts[4])
|
|
795
|
+
raise unless proxy_data[:source_port].between?(1, 65_535)
|
|
796
|
+
raise unless proxy_data[:dest_port].between?(1, 65_535)
|
|
797
|
+
|
|
798
|
+
if proxy_data[:proto] == "TCP4"
|
|
799
|
+
raise unless proxy_data[:source_ip].ipv4? && proxy_data[:dest_ip].ipv4?
|
|
800
|
+
else
|
|
801
|
+
raise unless proxy_data[:source_ip].ipv6? && proxy_data[:dest_ip].ipv6?
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
proxy_data[:source_ip] = proxy_data[:source_ip].to_s
|
|
805
|
+
proxy_data[:dest_ip] = proxy_data[:dest_ip].to_s
|
|
806
|
+
rescue StandardError
|
|
807
|
+
raise ServiceUnavailable, "Abort connection for unsupported PROXY parameters!"
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
proxy_data = proxy(session, proxy_data) || proxy_data
|
|
812
|
+
session.proxy = proxy_data
|
|
813
|
+
if proxy_data[:source_ip]
|
|
814
|
+
claim_connection_slot!(proxy_data[:source_ip], enforce_per_ip: true)
|
|
815
|
+
session.remote_ip = proxy_data[:source_ip]
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# No SMTP reply — PROXY is a preamble, not a command the client awaits.
|
|
819
|
+
""
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def process_starttls(session)
|
|
823
|
+
raise CommandNotRecognized unless tls
|
|
824
|
+
raise BadSequence unless session.phase == :ready
|
|
825
|
+
raise BadSequence if session.encrypted?
|
|
826
|
+
|
|
827
|
+
session.phase = :starttls
|
|
828
|
+
reply(220, "Ready to start TLS")
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def process_auth(session, line)
|
|
832
|
+
raise CommandNotRecognized if @auth == :disabled
|
|
833
|
+
raise BadSequence if session.phase != :ready || session.authenticated?
|
|
834
|
+
raise EncryptionRequired if encryption_required? && !session.encrypted?
|
|
835
|
+
|
|
836
|
+
auth_data = line.sub(/\AAUTH\s/i, "").strip.split(/\s+/)
|
|
837
|
+
mechanism = auth_data[0].to_s.upcase
|
|
838
|
+
raise AuthenticationTypeNotSupported unless auth_mechanism_offered?(session, mechanism)
|
|
839
|
+
|
|
840
|
+
# PLAIN and LOGIN both send the same secret over the required TLS, just in
|
|
841
|
+
# one response vs. across two prompts. Other advertised names need an
|
|
842
|
+
# engine handler before AUTH can succeed (504 until then).
|
|
843
|
+
case mechanism
|
|
844
|
+
when "PLAIN"
|
|
845
|
+
if auth_data.length == 1
|
|
846
|
+
session.phase = :auth_plain
|
|
847
|
+
"334 "
|
|
848
|
+
else
|
|
849
|
+
process_auth_plain(session, auth_data[1])
|
|
850
|
+
end
|
|
851
|
+
when "LOGIN"
|
|
852
|
+
if auth_data.length == 1
|
|
853
|
+
session.phase = :auth_login_user
|
|
854
|
+
AUTH_LOGIN_USERNAME_PROMPT
|
|
855
|
+
else
|
|
856
|
+
process_auth_login_username(session, auth_data[1])
|
|
857
|
+
end
|
|
858
|
+
else
|
|
859
|
+
raise AuthenticationTypeNotSupported
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def process_rset(session)
|
|
864
|
+
raise BadSequence if auth_value_phase?(session.phase) || session.phase == :data
|
|
865
|
+
raise EncryptionRequired if session.phase != :helo && encryption_required? && !session.encrypted?
|
|
866
|
+
|
|
867
|
+
# Before HELO there is no transaction to clear; still answer 250 (RFC 5321).
|
|
868
|
+
session.reset unless session.phase == :helo
|
|
869
|
+
reply(250, "OK")
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def process_mail_from(session, line)
|
|
873
|
+
raise BadSequence if session.phase != :ready
|
|
874
|
+
raise EncryptionRequired if encryption_required? && !session.encrypted?
|
|
875
|
+
raise AuthenticationRequired if @auth == :required && !session.authenticated?
|
|
876
|
+
|
|
877
|
+
address = line.sub(/\AMAIL FROM:/i, "").strip
|
|
878
|
+
raise InvalidParameters if address.include?("\0")
|
|
879
|
+
address = apply_mail_from_extensions!(session, address)
|
|
880
|
+
|
|
881
|
+
address = mail_from(session, address) || address
|
|
882
|
+
session.envelope.from = address
|
|
883
|
+
session.phase = :mail
|
|
884
|
+
reply(250, "OK")
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# BODY is the 8BITMIME parameter (RFC 6152). SMTPUTF8 (RFC 6531) marks an
|
|
888
|
+
# internationalized envelope. SIZE= (RFC 1870) is checked against
|
|
889
|
+
# +max_message_size+ when set. Unknown BODY= values are always refused;
|
|
890
|
+
# BODY=/SMTPUTF8 themselves require the matching extension to be on.
|
|
891
|
+
# Remaining KEY=VALUE ESMTP parameters after known ones are stripped are
|
|
892
|
+
# refused with 555 (RFC 1869 §6.1) — never silently dropped.
|
|
893
|
+
def apply_mail_from_extensions!(session, address)
|
|
894
|
+
if (declared = address[/\sSIZE=(\d+)\b/i, 1])
|
|
895
|
+
declared = Integer(declared)
|
|
896
|
+
session.envelope.declared_size = declared
|
|
897
|
+
if @max_message_size && declared > @max_message_size
|
|
898
|
+
raise MessageTooLarge, "Declared #{declared} bytes, limit is #{@max_message_size}"
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
if address.match?(/\sBODY=7BIT\b/i)
|
|
903
|
+
raise InvalidParameters unless @eight_bit_mime
|
|
904
|
+
session.envelope.encoding_body = "7bit"
|
|
905
|
+
elsif address.match?(/\sBODY=8BITMIME\b/i)
|
|
906
|
+
raise InvalidParameters unless @eight_bit_mime
|
|
907
|
+
session.envelope.encoding_body = "8bitmime"
|
|
908
|
+
elsif address.match?(/\sBODY=\S+/i)
|
|
909
|
+
raise InvalidParameters
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
if address.match?(/\sSMTPUTF8\b/i)
|
|
913
|
+
raise InvalidParameters unless @smtputf8
|
|
914
|
+
session.envelope.encoding_utf8 = "utf8"
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
raise InvalidParameters if !@smtputf8 && !address.ascii_only?
|
|
918
|
+
|
|
919
|
+
address = address
|
|
920
|
+
.gsub(/\sSIZE=\d+\b/i, "")
|
|
921
|
+
.gsub(/\sBODY=(?:7BIT|8BITMIME)\b/i, "")
|
|
922
|
+
.gsub(/\sSMTPUTF8\b/i, "")
|
|
923
|
+
.strip
|
|
924
|
+
|
|
925
|
+
raise ParametersNotRecognized if address.match?(/\s+\S+=\S+/)
|
|
926
|
+
address
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def process_rcpt_to(session, line)
|
|
930
|
+
raise BadSequence unless %i[ mail rcpt ].include?(session.phase)
|
|
931
|
+
raise EncryptionRequired if encryption_required? && !session.encrypted?
|
|
932
|
+
raise AuthenticationRequired if @auth == :required && !session.authenticated?
|
|
933
|
+
raise TooManyRecipients if session.envelope.to.size >= @max_recipients
|
|
934
|
+
|
|
935
|
+
address = line.sub(/\ARCPT TO:/i, "").strip
|
|
936
|
+
raise InvalidParameters if address.include?("\0")
|
|
937
|
+
raise InvalidParameters if !@smtputf8 && !address.ascii_only?
|
|
938
|
+
# RCPT TO advertises no ESMTP parameters — refuse any KEY=VALUE trail.
|
|
939
|
+
raise ParametersNotRecognized if address.match?(/\s+\S+=\S+/)
|
|
940
|
+
|
|
941
|
+
address = rcpt_to(session, address) || address
|
|
942
|
+
session.envelope.to << address
|
|
943
|
+
session.phase = :rcpt
|
|
944
|
+
reply(250, "OK")
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def process_data_start(session)
|
|
948
|
+
raise BadSequence if session.phase != :rcpt
|
|
949
|
+
raise EncryptionRequired if encryption_required? && !session.encrypted?
|
|
950
|
+
raise AuthenticationRequired if @auth == :required && !session.authenticated?
|
|
951
|
+
|
|
952
|
+
session.phase = :data
|
|
953
|
+
reply(354, "Enter message, ending with \".\" on a line by itself", enhanced: nil)
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# Send no SMTP replies until the terminating "." — then one success or error.
|
|
957
|
+
def transfer_data(session, io, io_buffer, session_deadline: nil)
|
|
958
|
+
claim_processing_slot!
|
|
959
|
+
idle_since = [ monotonic_time ]
|
|
960
|
+
reader = DataReader.new(
|
|
961
|
+
max_message_size: @max_message_size,
|
|
962
|
+
max_line_length: MAX_TEXT_LINE_LENGTH,
|
|
963
|
+
on_headers: -> { headers_received(session) }
|
|
964
|
+
) { read_data_line!(io, io_buffer, idle_since, session_deadline) }
|
|
965
|
+
|
|
966
|
+
reader = PrefixedReader.new(received_header(session), reader) if @add_received
|
|
967
|
+
|
|
968
|
+
error = nil
|
|
969
|
+
begin
|
|
970
|
+
receiving_started(session)
|
|
971
|
+
receive(session, reader)
|
|
972
|
+
rescue ServiceUnavailable
|
|
973
|
+
raise
|
|
974
|
+
rescue Error => e
|
|
975
|
+
error = e
|
|
976
|
+
rescue StandardError => e
|
|
977
|
+
log(session, Logger::ERROR, loggable_error(e), err: e)
|
|
978
|
+
error = LocalError.new
|
|
979
|
+
ensure
|
|
980
|
+
reader.drain
|
|
981
|
+
session.reset
|
|
982
|
+
# RFC 5321 §4.5.3.1.7: after 552 for SIZE, the server may close.
|
|
983
|
+
# Same when drain hit the absolute ceiling with no "." in sight.
|
|
984
|
+
if (error || reader.failure).is_a?(MessageTooLarge) || reader.drain_ceiling_hit?
|
|
985
|
+
session.close_after_reply = true
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
error ||= reader.failure
|
|
990
|
+
if error
|
|
991
|
+
session.exceptions += 1
|
|
992
|
+
log(session, Logger::ERROR, loggable_error(error), err: error)
|
|
993
|
+
error.response
|
|
994
|
+
else
|
|
995
|
+
reply(250, "Requested mail action okay, completed")
|
|
996
|
+
end
|
|
997
|
+
ensure
|
|
998
|
+
release_processing_slot!
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# Next DATA line as dot-unstuffed bytes plus CRLF, or +nil+ at the terminator.
|
|
1002
|
+
# The terminating "." counts only when it arrived as CRLF-terminated
|
|
1003
|
+
# (RFC 5321 / CVE-2023-51764 class SMTP smuggling). A bare-LF "." is body.
|
|
1004
|
+
def read_data_line!(io, io_buffer, idle_since, session_deadline)
|
|
1005
|
+
loop do
|
|
1006
|
+
raise_if_session_expired!(session_deadline)
|
|
1007
|
+
|
|
1008
|
+
lf = io_buffer.index("\n")
|
|
1009
|
+
unless lf
|
|
1010
|
+
raise_if_idle_timeout!(idle_since[0])
|
|
1011
|
+
|
|
1012
|
+
begin
|
|
1013
|
+
io_buffer << io.read_nonblock(IO_BUFFER_CHUNK_SIZE)
|
|
1014
|
+
raise BufferOverrun if @io_buffer_max_size && io_buffer.length > @io_buffer_max_size
|
|
1015
|
+
rescue IO::WaitReadable
|
|
1016
|
+
raise_if_session_expired!(session_deadline)
|
|
1017
|
+
raise_if_idle_timeout!(idle_since[0])
|
|
1018
|
+
IO.select([ io.to_io ], nil, nil, IO_WAIT_TIMEOUT)
|
|
1019
|
+
rescue IO::WaitWritable
|
|
1020
|
+
raise_if_session_expired!(session_deadline)
|
|
1021
|
+
raise_if_idle_timeout!(idle_since[0])
|
|
1022
|
+
IO.select(nil, [ io.to_io ], nil, IO_WAIT_TIMEOUT)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
raise ServiceUnavailable, "Connection lost during DATA" if io.closed?
|
|
1026
|
+
raise ServiceUnavailable, "Server shutting down" if shutdown?
|
|
1027
|
+
next
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
line = io_buffer.slice!(0, lf + 1)
|
|
1031
|
+
crlf = line.end_with?("\r\n")
|
|
1032
|
+
raise BareNewline if @forbid_bare_newline && !crlf
|
|
1033
|
+
|
|
1034
|
+
strip_line_terminator(line)
|
|
1035
|
+
idle_since[0] = monotonic_time
|
|
1036
|
+
|
|
1037
|
+
# End-of-data is only <CRLF>.<CRLF> — never a bare-LF dot.
|
|
1038
|
+
return nil if line == "." && crlf
|
|
1039
|
+
|
|
1040
|
+
line.slice!(0) if line[0] == "."
|
|
1041
|
+
return "#{line}\r\n"
|
|
1042
|
+
end
|
|
1043
|
+
rescue BufferOverrun
|
|
1044
|
+
raise ServiceUnavailable, "Input buffer overrun"
|
|
1045
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED
|
|
1046
|
+
raise ServiceUnavailable, "Connection lost during DATA"
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def monotonic_time
|
|
1050
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Apply +@handshake_timeout+ even when +read_timeout+ is nil. Prefer the
|
|
1054
|
+
# socket's own timeout (interrupts OpenSSL accept); wrap with Timeout as a
|
|
1055
|
+
# fallback for pure-Ruby +tls+ objects (test doubles, sleep-based stubs).
|
|
1056
|
+
def with_handshake_timeout(io)
|
|
1057
|
+
previous = io.respond_to?(:timeout) ? io.timeout : :unset
|
|
1058
|
+
io.timeout = @handshake_timeout if io.respond_to?(:timeout=)
|
|
1059
|
+
Timeout.timeout(@handshake_timeout) { yield }
|
|
1060
|
+
ensure
|
|
1061
|
+
if previous != :unset && io.respond_to?(:timeout=)
|
|
1062
|
+
io.timeout = previous
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def raise_if_idle_timeout!(idle_since)
|
|
1067
|
+
return unless @read_timeout
|
|
1068
|
+
raise ServiceUnavailable, "Read timed out" if monotonic_time - idle_since > @read_timeout
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def raise_if_session_expired!(session_deadline)
|
|
1072
|
+
return unless session_deadline
|
|
1073
|
+
raise ServiceUnavailable, "Session time limit exceeded" if monotonic_time > session_deadline
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def process_auth_plain(session, encoded_auth_response)
|
|
1077
|
+
auth_values = decode_base64(encoded_auth_response).split("\x00")
|
|
1078
|
+
raise InvalidParameters unless auth_values.length == 3
|
|
1079
|
+
|
|
1080
|
+
authorize(session, *auth_values)
|
|
1081
|
+
ensure
|
|
1082
|
+
session.phase = :ready
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# LOGIN sends the username and secret on separate base64 lines; hold the
|
|
1086
|
+
# username between turns, then run the same authorize path PLAIN does.
|
|
1087
|
+
def process_auth_login_username(session, encoded_username)
|
|
1088
|
+
session.auth_login_username = decode_base64(encoded_username)
|
|
1089
|
+
session.phase = :auth_login_pass
|
|
1090
|
+
AUTH_LOGIN_PASSWORD_PROMPT
|
|
1091
|
+
rescue Error
|
|
1092
|
+
session.phase = :ready
|
|
1093
|
+
raise
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def process_auth_login_password(session, encoded_password)
|
|
1097
|
+
authorize(session, "", session.auth_login_username, decode_base64(encoded_password))
|
|
1098
|
+
ensure
|
|
1099
|
+
session.auth_login_username = nil
|
|
1100
|
+
session.phase = :ready
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# Strictly, so that a mangled credential is refused outright: Base64.decode64
|
|
1104
|
+
# silently discards anything outside the alphabet, which would turn a typo or
|
|
1105
|
+
# a smuggled byte into a different, perfectly plausible credential.
|
|
1106
|
+
def decode_base64(encoded_auth_response)
|
|
1107
|
+
Base64.strict_decode64(encoded_auth_response)
|
|
1108
|
+
rescue ArgumentError
|
|
1109
|
+
raise InvalidParameters
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
def authorize(session, authorization_id, authentication_id, authentication)
|
|
1113
|
+
return_value = authenticate(session,
|
|
1114
|
+
username: authentication_id,
|
|
1115
|
+
password: authentication,
|
|
1116
|
+
authorization_id: authorization_id)
|
|
1117
|
+
authorization_id = return_value if return_value
|
|
1118
|
+
|
|
1119
|
+
session.authorization_id = authorization_id.to_s.empty? ? authentication_id : authorization_id
|
|
1120
|
+
session.authentication_id = authentication_id
|
|
1121
|
+
session.authenticated_at = Time.now.utc
|
|
1122
|
+
reply(235, "OK", enhanced: "2.7.0")
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def starttls_available?(session)
|
|
1126
|
+
tls && !session.encrypted?
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# Copy cipher metadata off the SSL socket after STARTTLS. OpenSSL returns
|
|
1130
|
+
# cipher as [name, version, bits, alg_bits]; we keep the name string.
|
|
1131
|
+
def record_tls_details!(session, io)
|
|
1132
|
+
session.tls_version = io.ssl_version if io.respond_to?(:ssl_version)
|
|
1133
|
+
return unless io.respond_to?(:cipher)
|
|
1134
|
+
|
|
1135
|
+
cipher = io.cipher
|
|
1136
|
+
session.tls_cipher = cipher.is_a?(Array) ? cipher[0] : cipher
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
def auth_available?(session)
|
|
1140
|
+
return false if @auth == :disabled
|
|
1141
|
+
# When STARTTLS is required, do not advertise AUTH until the session is encrypted.
|
|
1142
|
+
return false if encryption_required? && !session.encrypted?
|
|
1143
|
+
return false if auth_mechanisms(session).empty?
|
|
1144
|
+
|
|
1145
|
+
true
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
def auth_ehlo_line(session)
|
|
1149
|
+
return unless auth_available?(session)
|
|
1150
|
+
|
|
1151
|
+
mechanisms = auth_mechanisms(session).map { |name| name.to_s.upcase }.uniq
|
|
1152
|
+
return if mechanisms.empty?
|
|
1153
|
+
|
|
1154
|
+
"250-AUTH #{mechanisms.join(" ")}"
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
def auth_mechanism_offered?(session, mechanism)
|
|
1158
|
+
auth_mechanisms(session).any? { |name| name.to_s.casecmp?(mechanism) }
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
def encryption_required?
|
|
1162
|
+
@starttls == :required
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def auth_line_length?(session, line)
|
|
1166
|
+
auth_value_phase?(session.phase) || line.match?(/\AAUTH\s/i)
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
# After the SMTP error reply, drop the session when AUTH or general error
|
|
1170
|
+
# budgets are exhausted. +nil+ disables either ceiling.
|
|
1171
|
+
def raise_if_exception_caps!(session, error)
|
|
1172
|
+
if error.is_a?(AuthenticationFailed) && @max_auth_failures &&
|
|
1173
|
+
session.auth_failures >= @max_auth_failures
|
|
1174
|
+
raise ServiceUnavailable, "Too many authentication failures"
|
|
1175
|
+
end
|
|
1176
|
+
if @max_exceptions && session.exceptions >= @max_exceptions
|
|
1177
|
+
raise ServiceUnavailable, "Too many protocol errors"
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
# SO_LINGER with timeout 0 yields RST on close — used on forced stop so
|
|
1182
|
+
# clients do not linger on a half-closed FIN.
|
|
1183
|
+
def abort_socket!(io)
|
|
1184
|
+
return unless io && !io.closed?
|
|
1185
|
+
|
|
1186
|
+
io.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [ 1, 0 ].pack("ii"))
|
|
1187
|
+
io.close
|
|
1188
|
+
rescue StandardError
|
|
1189
|
+
# already gone
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
# DATA always accepts following body lines in the buffer. PROXY may be
|
|
1193
|
+
# followed immediately by HELO/EHLO before the extension finishes negotiating
|
|
1194
|
+
# the real client IP (typical load-balancer / PROXY clients).
|
|
1195
|
+
def pipelining_allowed?(session)
|
|
1196
|
+
@pipelining || (proxy_enabled? && session.phase == :helo)
|
|
1197
|
+
end
|
|
1198
|
+
end
|
|
1199
|
+
end
|