From e9f8345ec4de4bf48283ffca90777e891e52800d Mon Sep 17 00:00:00 2001 From: Ewoud Kohl van Wijngaarden Date: Thu, 10 Sep 2020 17:06:31 +0200 Subject: [PATCH] Add option to bind to activated sockets 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. --- History.md | 1 + docs/systemd.md | 15 +++++++++++++++ lib/puma/binder.rb | 37 ++++++++++++++++++++++++++++++++++++ lib/puma/cli.rb | 4 ++++ lib/puma/dsl.rb | 26 +++++++++++++++++++++++++ lib/puma/launcher.rb | 7 +++++++ test/test_binder.rb | 45 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+) diff --git a/History.md b/History.md index b33b310bfd..c61adfacbd 100644 --- a/History.md +++ b/History.md @@ -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 (#Github Number) diff --git a/docs/systemd.md b/docs/systemd.md index 7c1a83288a..95920c0c58 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -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 diff --git a/lib/puma/binder.rb b/lib/puma/binder.rb index 1348e5400e..a2bbd2acf5 100644 --- a/lib/puma/binder.rb +++ b/lib/puma/binder.rb @@ -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 diff --git a/lib/puma/cli.rb b/lib/puma/cli.rb index b53262e9c7..d91d7889b0 100644 --- a/lib/puma/cli.rb +++ b/lib/puma/cli.rb @@ -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 diff --git a/lib/puma/dsl.rb b/lib/puma/dsl.rb index 3c9b39a3b4..c56d602048 100644 --- a/lib/puma/dsl.rb +++ b/lib/puma/dsl.rb @@ -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 diff --git a/lib/puma/launcher.rb b/lib/puma/launcher.rb index b690dfa88e..2e88c3ef97 100644 --- a/lib/puma/launcher.rb +++ b/lib/puma/launcher.rb @@ -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 diff --git a/test/test_binder.rb b/test/test_binder.rb index ab80c636c8..e62b28450c 100644 --- a/test/test_binder.rb +++ b/test/test_binder.rb @@ -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