Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ruby:3.4.6-slim-bookworm AS base
FROM docker.io/ruby:3.4.6-slim-bookworm AS base

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update \
Expand Down
154 changes: 62 additions & 92 deletions app/lib/smtp_server/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,52 @@ def ssl_context
end
end

# --- MODIFIED SECTION ---
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)
@servers = []

# Si "::" -> IPv6 (et peut-être IPv4 selon bindv6only)
if bind_address == "::" || bind_address == "0.0.0.0"
["::", "0.0.0.0"].uniq.each do |addr|
begin
s = TCPServer.new(addr, port)
s.autoclose = false
s.close_on_exec = false
if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE)
s.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)
s.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50)
s.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
s.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5)
end
@servers << s
logger.info "Listening on #{addr}:#{port}"
rescue => e
logger.warn "Cannot bind to #{addr}:#{port} - #{e.message}"
end
end
else
# Sinon, comportement original
@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
@servers = [@server]
logger.info "Listening on #{bind_address}:#{port}"
end

logger.info "Listening on #{bind_address}:#{port}"
end
# --- END MODIFIED SECTION ---

def unlisten
# Instruct the nio loop to unlisten and wake it
Expand All @@ -97,45 +125,37 @@ def unlisten
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)

# Register all listening sockets
@servers.each { |srv| @io_selector.register(srv, :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)

# Accept new client connections from any listener
if @servers.include?(io)
begin
# Accept the connection
new_io = io.accept
increment_prometheus_counter :postal_smtp_server_connections_total
# Get the client's IP address and strip `::ffff:` for consistency.
client_ip_address = new_io.remote_address.ip_address.sub(/\A::ffff:/, "")
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?
client.logger&.debug "Connection opened from #{client_ip_address}"
end
client.logger&.debug "Connection opened from #{client_ip_address}" if Postal::Config.smtp_server.log_connections?
else
# We're not using the proxy protocol so we already know the client's IP
client = Client.new(client_ip_address)
if Postal::Config.smtp_server.log_connections?
client.logger&.debug "Connection opened from #{client_ip_address}"
end
# We know who the client is, welcome them.
client.logger&.debug "Client identified as #{client_ip_address}"
new_io.print("220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{client.trace_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: { trace_id: begin
client.trace_id
Expand All @@ -145,9 +165,7 @@ def run_event_loop
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
e.backtrace.each { |line| logger.error line }
increment_prometheus_counter :postal_smtp_server_exceptions_total,
labels: { error: e.class.to_s, type: "client-accept" }
begin
Expand All @@ -157,113 +175,75 @@ def run_event_loop
end
end
else
# This event is not an incoming connection so it must be data from a client
# (reste du code original pour gérer les clients)
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
rescue IO::WaitReadable, IO::WaitWritable
next
rescue OpenSSL::SSL::SSLError => e
client.logger&.debug "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.logger&.debug "\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.logger&.debug "Connection closed"
# 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
Process.exit(0) if @io_selector.empty?
end
rescue StandardError => e
# Something went wrong, log as appropriate
client_id = client ? client.trace_id : "------"
if defined?(Sentry)
Sentry.capture_exception(e, extra: { trace_id: begin
client&.trace_id
rescue StandardError
nil
end })
Sentry.capture_exception(e, extra: { trace_id: client&.trace_id rescue nil })
end
logger.error "An error occurred while processing data from a client.", trace_id: client_id
logger.error "#{e.class}: #{e.message}", trace_id: client_id
e.backtrace.each do |iline|
logger.error iline, trace_id: client_id
end

e.backtrace.each { |iline| logger.error iline, trace_id: client_id }
increment_prometheus_counter :postal_smtp_server_exceptions_total,
labels: { error: e.class.to_s, type: "data" }

# Close all IO and forget this client
begin
@io_selector.deregister(io)
rescue StandardError
Expand All @@ -275,22 +255,16 @@ def run_event_loop
rescue StandardError
nil
end
if @io_selector.empty?
Process.exit(0)
end
Process.exit(0) if @io_selector.empty?
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
@servers.each { |srv| @io_selector.deregister(srv) rescue nil }
@servers.each { |srv| srv.close rescue nil }
Process.exit(0) if @io_selector.empty?
@unlisten = false
end
end
Expand All @@ -302,16 +276,12 @@ def logger
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
end
69 changes: 65 additions & 4 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,83 @@ def find_from_oidc(auth, logger: nil)
end
end

# now, if we still don't have a user, we're not going to create one so we'll just
# return nil (we might auto create users in the future but not right now)
return if user.nil?
# If we still don't have a user and auto creation is enabled, create one from the OIDC details.
if user.nil?
if config.auto_create_users?
user = auto_create_user_from_oidc(uid, config, oidc_name, oidc_email_address, logger)
else
logger&.debug "OIDC auto user creation disabled; not creating user for #{oidc_email_address || uid}"
end
return if user.nil?
end

# otherwise, let's update our user as appropriate
user.oidc_uid = uid
user.oidc_issuer = config.issuer
user.email_address = oidc_email_address if oidc_email_address.present?
user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present?
if oidc_name.present?
user.first_name, user.last_name = derive_user_names_from_oidc(oidc_name, user.email_address)
end
user.password = nil
user.save!

# return the user
user
end

private

def auto_create_user_from_oidc(uid, config, oidc_name, oidc_email_address, logger)
unless oidc_email_address.present?
logger&.warn "OIDC auto user creation failed for UID #{uid}: no e-mail address provided"
return nil
end

first_name, last_name = derive_user_names_from_oidc(oidc_name, oidc_email_address)
user = new(
email_address: oidc_email_address,
first_name: first_name,
last_name: last_name
)
user.oidc_uid = uid
user.oidc_issuer = config.issuer
user.password = nil
user.save!
logger&.info "OIDC auto user creation succeeded for #{oidc_email_address} (user ID: #{user.id}) with Firstname: #{first_name}, Lastname: #{last_name}"
auto_create_organization_for(user, config, logger) if config.auto_create_organization?
user
rescue ActiveRecord::RecordInvalid => e
logger&.error "OIDC auto user creation failed for #{oidc_email_address}: #{e.message}"
nil
end

def derive_user_names_from_oidc(oidc_name, oidc_email_address)
raw_name = oidc_name.to_s.strip
if raw_name.present?
first_name, last_name = raw_name.split(/\s+/, 2)
else
local_part = oidc_email_address.to_s.split("@", 2).first.to_s
fallback_name = local_part.tr("._-", " ").strip
first_name, last_name = fallback_name.split(/\s+/, 2)
end

first_name = first_name.presence || "OIDC"
last_name = last_name.presence || first_name
[first_name, last_name]
end

def auto_create_organization_for(user, config, logger)
organization_name = config.auto_created_organization_name.presence || "My organization"
organization = Organization.new(name: organization_name, owner: user)
organization.save!
organization.organization_users.create!(user: user, admin: true, all_servers: true)
logger&.info "OIDC auto organization creation succeeded for user #{user.id} (organization ID: #{organization.id})"
organization
rescue ActiveRecord::RecordInvalid => e
logger&.error "OIDC auto organization creation failed for user #{user.id}: #{e.message}"
nil
end

end

end
Loading