Skip to content

Commit

Permalink
Add option to bind to activated sockets
Browse files Browse the repository at this point in the history
Bind to (systemd) activated sockets, regardless of configured binds.

Systemd can present sockets as file descriptors that are already opened.
By default Puma will use these but only if it was explicitly told to bind
to the socket. If not, it will close the activated sockets. This means
all configuration is duplicated.

Binds can contain additional configuration, but only SSL config is really
relevant since the unix and TCP socket options are ignored.

This means there is a lot of duplicated configuration for no additional
value in most setups. This option tells the launcher to bind to all
activated sockets, regardless of existing binds.

The special value 'only' can be passed. If systemd activated sockets are
detected, all other binds are cleared. When they aren't detected, the
regular binds will be used.
  • Loading branch information
ekohl committed Nov 4, 2020
1 parent 7aa62c7 commit e9f8345
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 0 deletions.
1 change: 1 addition & 0 deletions History.md
Expand Up @@ -6,6 +6,7 @@
* Integrate with systemd's watchdog and notification features ([#2438])
* Adds max_fast_inline as a configuration option for the Server object ([#2406])
* You can now fork workers from worker 0 using SIGURG w/o fork_worker enabled [#2449]
* Add option to bind to systemd activated sockets ([#2362])

* Bugfixes
* Your bugfix goes here <Most recent on the top, like GitHub> (#Github Number)
Expand Down
15 changes: 15 additions & 0 deletions docs/systemd.md
Expand Up @@ -129,6 +129,21 @@ Puma will detect the release path socket as different than the one provided by
systemd and attempt to bind it again, resulting in the exception
`There is already a server bound to:`.

### Binding

By default you need to configure puma to have binds matching with all
ListenStream statements. Any mismatched systemd ListenStreams will be closed by
puma.

To automatically bind to all activated sockets, the option
`--bind-to-activated-sockets` can be used. This matches the config DSL
`bind_to_activated_sockets` statement. This will cause puma to create a bind
automatically for any activated socket. When systemd socket activation is not
enabled, this option does nothing.

This also accepts an optional argument `only` (DSL: `'only'`) to discard any
binds that's not socket activated.

## Usage

Without socket activation, use `systemctl` as root (e.g. via `sudo`) as
Expand Down
37 changes: 37 additions & 0 deletions lib/puma/binder.rb
Expand Up @@ -111,6 +111,43 @@ def create_activated_fds(env_hash)
["LISTEN_FDS", "LISTEN_PID"] # Signal to remove these keys from ENV
end

# Synthesize binds from systemd socket activation
#
# When systemd socket activation is enabled, it can be tedious to keep the
# binds in sync. This method can synthesize any binds based on the received
# activated sockets. Any existing matching binds will be respected.
#
# When only_matching is true in, all binds that do not match an activated
# socket is removed in place.
#
# It's a noop if no activated sockets were received.
def synthesize_binds_from_activated_fs(binds, only_matching)
return binds unless activated_sockets.any?

activated_binds = []

activated_sockets.keys.each do |proto, addr, port|
if port
tcp_url = "#{proto}://#{addr}:#{port}"
ssl_url = "ssl://#{addr}:#{port}"
ssl_url_prefix = "#{ssl_url}?"

existing = binds.find { |bind| bind == tcp_url || bind == ssl_url || bind.start_with?(ssl_url_prefix) }

activated_binds << (existing || tcp_url)
else
# TODO: can there be a SSL bind without a port?
activated_binds << "#{proto}://#{addr}"
end
end

if only_matching
activated_binds
else
binds | activated_binds
end
end

def parse(binds, logger, log_msg = 'Listening')
binds.each do |str|
uri = URI.parse str
Expand Down
4 changes: 4 additions & 0 deletions lib/puma/cli.rb
Expand Up @@ -104,6 +104,10 @@ def setup_options
user_config.bind arg
end

o.on "--bind-to-activated-sockets [only]", "Bind to all activated sockets" do |arg|
user_config.bind_to_activated_sockets(arg || true)
end

o.on "-C", "--config PATH", "Load PATH as a config file" do |arg|
file_config.load arg
end
Expand Down
26 changes: 26 additions & 0 deletions lib/puma/dsl.rb
Expand Up @@ -191,6 +191,32 @@ def clear_binds!
@options[:binds] = []
end

# Bind to (systemd) activated sockets, regardless of configured binds.
#
# Systemd can present sockets as file descriptors that are already opened.
# By default Puma will use these but only if it was explicitly told to bind
# to the socket. If not, it will close the activated sockets. This means
# all configuration is duplicated.
#
# Binds can contain additional configuration, but only SSL config is really
# relevant since the unix and TCP socket options are ignored.
#
# This means there is a lot of duplicated configuration for no additional
# value in most setups. This method tells the launcher to bind to all
# activated sockets, regardless of existing bind.
#
# To clear configured binds, the value only can be passed. This will clear
# out any binds that may have been configured.
#
# @example Use any systemd activated sockets as well as configured binds
# bind_to_activated_sockets
#
# @example Only bind to systemd activated sockets, ignoring other binds
# bind_to_activated_sockets 'only'
def bind_to_activated_sockets(bind=true)
@options[:bind_to_activated_sockets] = bind
end

# Define the TCP port to bind to. Use +bind+ for more advanced options.
#
# @example
Expand Down
7 changes: 7 additions & 0 deletions lib/puma/launcher.rb
Expand Up @@ -58,6 +58,13 @@ def initialize(conf, launcher_args={})

@config.load

if @config.options[:bind_to_activated_sockets]
@config.options[:binds] = @binder.synthesize_binds_from_activated_fs(
@config.options[:binds],
@config.options[:bind_to_activated_sockets] == 'only'
)
end

@options = @config.options
@config.clamp

Expand Down
45 changes: 45 additions & 0 deletions test/test_binder.rb
Expand Up @@ -34,6 +34,51 @@ def ssl_context_for_binder(binder = @binder)
class TestBinder < TestBinderBase
parallelize_me!

def test_synthesize_binds_from_activated_fds_no_sockets
binds = ['tcp://0.0.0.0:3000']
result = @binder.synthesize_binds_from_activated_fs(binds, true)

assert_equal ['tcp://0.0.0.0:3000'], result
end

def test_synthesize_binds_from_activated_fds_non_matching_together
binds = ['tcp://0.0.0.0:3000']
sockets = {['tcp', '0.0.0.0', '5000'] => nil}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, false)

assert_equal ['tcp://0.0.0.0:3000', 'tcp://0.0.0.0:5000'], result
end

def test_synthesize_binds_from_activated_fds_non_matching_only
binds = ['tcp://0.0.0.0:3000']
sockets = {['tcp', '0.0.0.0', '5000'] => nil}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, true)

assert_equal ['tcp://0.0.0.0:5000'], result
end

def test_synthesize_binds_from_activated_fds_complex_binds
binds = [
'tcp://0.0.0.0:3000',
'ssl://192.0.2.100:5000',
'ssl://192.0.2.101:5000?no_tlsv1=true',
'unix:///run/puma.sock'
]
sockets = {
['tcp', '0.0.0.0', '5000'] => nil,
['tcp', '192.0.2.100', '5000'] => nil,
['tcp', '192.0.2.101', '5000'] => nil,
['unix', '/run/puma.sock'] => nil
}
@binder.instance_variable_set(:@activated_sockets, sockets)
result = @binder.synthesize_binds_from_activated_fs(binds, false)

expected = ['tcp://0.0.0.0:3000', 'ssl://192.0.2.100:5000', 'ssl://192.0.2.101:5000?no_tlsv1=true', 'unix:///run/puma.sock', 'tcp://0.0.0.0:5000']
assert_equal expected, result
end

def test_localhost_addresses_dont_alter_listeners_for_tcp_addresses
@binder.parse ["tcp://localhost:0"], @events

Expand Down

0 comments on commit e9f8345

Please sign in to comment.