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