مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-12-01 05:43:04 +00:00
Previously Postal was fairly forgiving about line endings. This requires that the end of data sequence is `<CR><LF>.<CR><LF>`.
318 أسطر
12 KiB
Ruby
318 أسطر
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "ipaddr"
|
|
require "nio"
|
|
|
|
module SMTPServer
|
|
class Server
|
|
|
|
include HasPrometheusMetrics
|
|
|
|
class << self
|
|
|
|
def tls_private_key
|
|
@tls_private_key ||= OpenSSL::PKey.read(File.read(Postal::Config.smtp_server.tls_private_key_path))
|
|
end
|
|
|
|
def tls_certificates
|
|
@tls_certificates ||= begin
|
|
data = File.read(Postal::Config.smtp_server.tls_certificate_path)
|
|
certs = data.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m)
|
|
certs.map do |c|
|
|
OpenSSL::X509::Certificate.new(c)
|
|
end.freeze
|
|
end
|
|
end
|
|
|
|
end
|
|
|
|
def initialize(options = {})
|
|
@options = options
|
|
@options[:debug] ||= false
|
|
register_prometheus_metrics
|
|
prepare_environment
|
|
end
|
|
|
|
def run
|
|
logger.tagged(component: "smtp-server") do
|
|
listen
|
|
run_event_loop
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def prepare_environment
|
|
$\ = "\r\n"
|
|
BasicSocket.do_not_reverse_lookup = true
|
|
|
|
trap("TERM") do
|
|
$stdout.puts "Received TERM signal, shutting down."
|
|
unlisten
|
|
end
|
|
|
|
trap("INT") do
|
|
$stdout.puts "Received INT signal, shutting down."
|
|
unlisten
|
|
end
|
|
end
|
|
|
|
def ssl_context
|
|
@ssl_context ||= begin
|
|
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
ssl_context.cert = Postal.smtp_certificates[0]
|
|
ssl_context.extra_chain_cert = self.class.tls_certificates[1..]
|
|
ssl_context.key = self.class.tls_private_key
|
|
ssl_context.ssl_version = Postal::Config.smtp_server.ssl_version if Postal::Config.smtp_server.ssl_version
|
|
ssl_context.ciphers = Postal::Config.smtp_server.tls_ciphers if Postal::Config.smtp_server.tls_ciphers
|
|
ssl_context
|
|
end
|
|
end
|
|
|
|
def listen
|
|
bind_address = ENV.fetch("BIND_ADDRESS", Postal::Config.smtp_server.default_bind_address)
|
|
port = ENV.fetch("PORT", Postal::Config.smtp_server.default_port)
|
|
|
|
@server = TCPServer.open(bind_address, port)
|
|
@server.autoclose = false
|
|
@server.close_on_exec = false
|
|
if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE)
|
|
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
|
end
|
|
if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT)
|
|
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50)
|
|
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
|
|
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5)
|
|
end
|
|
|
|
logger.info "Listening on #{bind_address}:#{port}"
|
|
end
|
|
|
|
def unlisten
|
|
# Instruct the nio loop to unlisten and wake it
|
|
@unlisten = true
|
|
@io_selector.wakeup
|
|
end
|
|
|
|
def run_event_loop
|
|
# Set up an instance of nio4r to monitor for connections and data
|
|
@io_selector = NIO::Selector.new
|
|
# Register the SMTP listener
|
|
@io_selector.register(@server, :r)
|
|
# Create a hash to contain a buffer for each client.
|
|
buffers = Hash.new { |h, k| h[k] = String.new.force_encoding("BINARY") }
|
|
loop do
|
|
# Wait for an event to occur
|
|
@io_selector.select do |monitor|
|
|
# Get the IO from the nio monitor
|
|
io = monitor.io
|
|
# Is this event an incoming connection?
|
|
if io.is_a?(TCPServer)
|
|
begin
|
|
# Accept the connection
|
|
new_io = io.accept
|
|
increment_prometheus_counter :postal_smtp_server_connections_total
|
|
if Postal::Config.smtp_server.proxy_protocol?
|
|
# If we are using the haproxy proxy protocol, we will be sent the
|
|
# client's IP later. Delay the welcome process.
|
|
client = Client.new(nil)
|
|
if Postal::Config.smtp_server.log_connections?
|
|
logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m"
|
|
end
|
|
else
|
|
# We're not using the proxy protocol so we already know the client's IP
|
|
client = Client.new(new_io.remote_address.ip_address)
|
|
if Postal::Config.smtp_server.log_connections?
|
|
logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m"
|
|
end
|
|
# We know who the client is, welcome them.
|
|
client.log "\e[35m Client identified as #{new_io.remote_address.ip_address}\e[0m"
|
|
new_io.print("220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{client.id}")
|
|
end
|
|
# Register the client and its socket with nio4r
|
|
monitor = @io_selector.register(new_io, :r)
|
|
monitor.value = client
|
|
rescue StandardError => e
|
|
# If something goes wrong, log as appropriate and disconnect the client
|
|
if defined?(Sentry)
|
|
Sentry.capture_exception(e, extra: { log_id: begin
|
|
client.id
|
|
rescue StandardError
|
|
nil
|
|
end })
|
|
end
|
|
logger.error "An error occurred while accepting a new client."
|
|
logger.error "#{e.class}: #{e.message}"
|
|
e.backtrace.each do |line|
|
|
logger.error line
|
|
end
|
|
increment_prometheus_counter :postal_smtp_server_exceptions_total,
|
|
error: e.class.to_s,
|
|
type: "client-accept"
|
|
begin
|
|
new_io.close
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
end
|
|
else
|
|
# This event is not an incoming connection so it must be data from a client
|
|
begin
|
|
# Get the client from the nio monitor
|
|
client = monitor.value
|
|
# For now we assume the connection isn't closed
|
|
eof = false
|
|
# Is the client negotiating a TLS handshake?
|
|
if client.start_tls?
|
|
begin
|
|
# Can we accept the TLS connection at this time?
|
|
io.accept_nonblock
|
|
# Increment prometheus
|
|
increment_prometheus_counter :postal_smtp_server_tls_connections_total
|
|
# We were able to accept the connection, the client is no longer handshaking
|
|
client.start_tls = false
|
|
rescue IO::WaitReadable, IO::WaitWritable => e
|
|
# Could not accept without blocking
|
|
# We will try again later
|
|
next
|
|
rescue OpenSSL::SSL::SSLError => e
|
|
client.log "SSL Negotiation Failed: #{e.message}"
|
|
eof = true
|
|
end
|
|
else
|
|
# The client is not negotiating a TLS handshake at this time
|
|
begin
|
|
# Read 10kiB of data at a time from the socket.
|
|
buffers[io] << io.readpartial(10_240)
|
|
|
|
# There is an extra step for SSL sockets
|
|
if io.is_a?(OpenSSL::SSL::SSLSocket)
|
|
buffers[io] << io.readpartial(10_240) while io.pending.positive?
|
|
end
|
|
rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT
|
|
# Client went away
|
|
eof = true
|
|
end
|
|
|
|
# We line buffer, so look to see if we have received a newline
|
|
# and keep doing so until all buffered lines have been processed.
|
|
while buffers[io].index("\n")
|
|
# Extract the line
|
|
line, buffers[io] = buffers[io].split("\n", 2)
|
|
# Send the received line to the client object for processing
|
|
result = client.handle(line)
|
|
# If the client object returned some data, write it back to the client
|
|
next if result.nil?
|
|
|
|
result = [result] unless result.is_a?(Array)
|
|
result.compact.each do |iline|
|
|
client.log "\e[34m=> #{iline.strip}\e[0m"
|
|
begin
|
|
io.write(iline.to_s + "\r\n")
|
|
io.flush
|
|
rescue Errno::ECONNRESET
|
|
# Client disconnected before we could write response
|
|
eof = true
|
|
end
|
|
end
|
|
end
|
|
|
|
# Did the client request STARTTLS?
|
|
if !eof && client.start_tls?
|
|
# Deregister the unencrypted IO
|
|
@io_selector.deregister(io)
|
|
buffers.delete(io)
|
|
io = OpenSSL::SSL::SSLSocket.new(io, ssl_context)
|
|
# Close the underlying IO when the TLS socket is closed
|
|
io.sync_close = true
|
|
# Register the new TLS socket with nio
|
|
monitor = @io_selector.register(io, :r)
|
|
monitor.value = client
|
|
end
|
|
end
|
|
|
|
# Has the client requested we close the connection?
|
|
if client.finished? || eof
|
|
client.log "\e[35m Connection closed\e[0m"
|
|
# Deregister the socket and close it
|
|
@io_selector.deregister(io)
|
|
buffers.delete(io)
|
|
io.close
|
|
# If we have no more clients or listeners left, exit the process
|
|
if @io_selector.empty?
|
|
Process.exit(0)
|
|
end
|
|
end
|
|
rescue StandardError => e
|
|
# Something went wrong, log as appropriate
|
|
client_id = client ? client.id : "------"
|
|
if defined?(Sentry)
|
|
Sentry.capture_exception(e, extra: { log_id: begin
|
|
client.id
|
|
rescue StandardError
|
|
nil
|
|
end })
|
|
end
|
|
logger.error "[#{client_id}] An error occurred while processing data from a client."
|
|
logger.error "[#{client_id}] #{e.class}: #{e.message}"
|
|
e.backtrace.each do |iline|
|
|
logger.error "[#{client_id}] #{iline}"
|
|
end
|
|
|
|
increment_prometheus_counter :postal_smtp_server_exceptions_total,
|
|
error: e.class.to_s,
|
|
type: "data"
|
|
|
|
# Close all IO and forget this client
|
|
begin
|
|
@io_selector.deregister(io)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
buffers.delete(io)
|
|
begin
|
|
io.close
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
if @io_selector.empty?
|
|
Process.exit(0)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
# If unlisten has been called, stop listening
|
|
next unless @unlisten
|
|
|
|
@io_selector.deregister(@server)
|
|
@server.close
|
|
# If there's nothing left to do, shut down the process
|
|
if @io_selector.empty?
|
|
Process.exit(0)
|
|
end
|
|
# Clear the request
|
|
@unlisten = false
|
|
end
|
|
end
|
|
|
|
def logger
|
|
Postal.logger
|
|
end
|
|
|
|
def register_prometheus_metrics
|
|
register_prometheus_counter :postal_smtp_server_connections_total,
|
|
docstring: "The number of connections made to the Postal SMTP server."
|
|
|
|
register_prometheus_counter :postal_smtp_server_exceptions_total,
|
|
docstring: "The number of server exceptions encountered by the SMTP server",
|
|
labels: [:type, :error]
|
|
|
|
register_prometheus_counter :postal_smtp_server_tls_connections_total,
|
|
docstring: "The number of successfuly TLS connections established"
|
|
|
|
Client.register_prometheus_metrics
|
|
end
|
|
|
|
end
|
|
end
|