├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── uninterruptible.rb └── uninterruptible │ ├── binder.rb │ ├── configuration.rb │ ├── file_descriptor_server.rb │ ├── network_restrictions.rb │ ├── server.rb │ ├── tls_server_factory.rb │ └── version.rb ├── spec ├── binder_spec.rb ├── configuration_spec.rb ├── echo_server_spec.rb ├── file_descriptor_server_spec.rb ├── network_restrictions_spec.rb ├── server_spec.rb ├── spec_helper.rb ├── support │ ├── echo_server.rb │ ├── echo_server_controls.rb │ ├── environmental_controls.rb │ ├── tcp_server │ ├── tls_cert.pem │ ├── tls_configuration.rb │ ├── tls_key.pem │ ├── tls_server │ └── unix_server ├── tls_server_factory_spec.rb └── uninterruptible_spec.rb └── uninterruptible.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.0 4 | before_install: gem install bundler -v 1.11.2 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.5.3 4 | * Accept new connections with #accept instead of #accept_nonblock 5 | * Remove SSLExtensions module as this is no longer required 6 | 7 | ## 2.5.2 8 | * Pass a class to UNIXSocket.recv_io to have the C-code instantiate our socket server. If we don't, they have a habit of getting garbage collected resulting in Errno:EBADF exceptions 9 | 10 | ## 2.5.1 11 | * Fix bug where new restart code didn't work with TLS servers 12 | * Add echo server tests for UNIX and TLS servers 13 | 14 | ## 2.5.0 15 | * Rewrite restart code to avoid potentially nasty hangs. 16 | 17 | ## 2.4.1 18 | * Handle an error raised (`Errno::EINVAL`) when a client would connect and immediately disconnect before any processing occurs. 19 | 20 | ## 2.4.0 21 | * When restarting a server, the socket is passed to the new server via a UNIX socket instead of inheriting open file descriptors from the parent. 22 | 23 | ## 2.3.0 24 | * Incoming connections can be restricted to certain networks by setting `allowed_networks` in the configuration. 25 | 26 | ## 2.2.1 27 | * Allow multiple certificates to be used in one build file 28 | 29 | ## 2.2.0 30 | * Verify client TLS certificates 31 | * Allow trusted client CA to be set 32 | 33 | ## 2.1.1 34 | * Prevent bad SSL handshakes from crashing server 35 | 36 | ## 2.1.0 37 | * Add TLS support for TCP connections 38 | 39 | ## 2.0.0 40 | * Use an internal pipe for delivering signals to the main thread. 41 | * `accept_connections` retired in favour of a select loop and `accept_client_connection` being called for each waiting connection 42 | * Logging when shutting down or restarting 43 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in uninterruptible.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dan Wentworth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uninterruptible 2 | 3 | Uninterruptible gives you zero downtime restarts for your socket servers with nearly zero effort. Sounds good? Read on. 4 | 5 | Small socket servers are great, sometimes you need a quick and efficient way of moving data between servers (or even 6 | processes on the same machine). Restarting these processes can be a bit hairy though, you either need to build your 7 | clients smart enough to keep trying to connect, potentially backing up traffic or you just leave your server and 8 | hope for the best. 9 | 10 | You _know_ that you'll need to restart it one day and cross your fingers that you can kill the old one and start the 11 | new one before anyone notices. Not ideal at all. 12 | 13 | ![Just a quick switch](http://i.imgur.com/aFyJJM6.jpg) 14 | 15 | Uninterruptible gives your socket server magic restarting powers. Send your running Uninterruptible server USR1 and 16 | it will start a brand new copy of itself which will immediately start handling new requests while the old server stays 17 | alive until all of it's active connections are complete. 18 | 19 | ## Basic Usage 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'uninterruptible' 25 | ``` 26 | 27 | To build your server all you need to do is include `Uninterruptible::Server` and implement `handle_request`. Let's build 28 | a simple echo server: 29 | 30 | ```ruby 31 | # echo_server.rb 32 | class EchoServer 33 | include Uninterruptible::Server 34 | 35 | def handle_request(client_socket) 36 | received_data = client_socket.gets 37 | client_socket.puts(received_data) 38 | end 39 | end 40 | ``` 41 | 42 | To turn this into a running server you only need to configure a port to listen on and the command used to start the 43 | server and call `run`: 44 | 45 | ```ruby 46 | echo_server = EchoServer.new 47 | echo_server.configure do |config| 48 | config.bind_port = 6789 49 | config.start_command = 'ruby echo_server.rb' 50 | end 51 | echo_server.run 52 | ``` 53 | 54 | To restart the server just send `USR1`, a new server will start listening on your port, the old one will quit once it's 55 | finished processing all of it's existing connections. To kill the server (allowing for all connections to finish) call 56 | `TERM`. 57 | 58 | ## Configuration Options 59 | 60 | ```ruby 61 | echo_server.configure do |config| 62 | config.start_command = 'ruby echo_server.rb' # *Required* Command to execute to start a new server process 63 | config.bind = "tcp://0.0.0.0:12345" # *Required* Interface to listen on, falls back to 0.0.0.0 on ENV['PORT'] 64 | config.pidfile_path = 'tmp/pids/echoserver.pid' # Location to write a pidfile, falls back to ENV['PID_FILE'] 65 | config.log_path = 'log/echoserver.log' # Location to write logfile, defaults to STDOUT 66 | config.log_level = Logger::INFO # Log writing severity, defaults to Logger::INFO 67 | config.tls_version = 'TLSv1_2' # TLS version to use, defaults to TLSv1_2, falls back to ENV['TLS_VERSION'] 68 | config.tls_key = nil # Private key to use for TLS, reads file from ENV['TLS_KEY'] if set 69 | config.tls_certificate = nil # Certificate to use for TLS, reads file from ENV['TLS_CERTIFICATE'] if set 70 | config.verify_client_tls_certificate = false # Should client TLS certificates be required and verifiyed? Falls back to ENV['VERIFY_CLIENT_TLS_CERTIFICATE'] 71 | config.client_tls_certificate_ca = nil # Path to a trusted CA for client certificates. Implies `config.verify_client_tls_certificate = true`. Falls back to ENV['CLIENT_TLS_CERTIFICATE_CA'] 72 | config.allowed_networks = ['127.0.0.1/8', '2001:db8::/32'] # A list of networks that clients are allowed to connect from. If blank, all networks are allowed. Falls back to a comma-separated list from ENV['ALLOWED_NETWORKS'] 73 | end 74 | ``` 75 | 76 | Uninterruptible supports both TCP and UNIX sockets. To connect to a unix socket simply pass the path in the bind 77 | configuration parameter: 78 | 79 | ```ruby 80 | echo_server.configure do |config| 81 | config.bind = "unix:///tmp/echo_server.sock" 82 | end 83 | ``` 84 | 85 | ## The Magic 86 | 87 | Upon receiving `USR1`, your server will spawn a new copy of itself and pass the file descriptor of the open socket to 88 | the new server. The new server attaches itself to the file descriptor then sends a `TERM` signal to the original 89 | process. The original server stops listening on the socket and shuts itself down once all ongoing requests have 90 | completed. 91 | 92 | ![Restart Flow](http://i.imgur.com/k8uNP55.png) 93 | 94 | ## Concurrency 95 | 96 | By default, Uninterruptible operates on a very simple one thread per connection concurrency model. If you'd like to use 97 | something more advanced such as a threadpool or an event driven pattern you can define this in your server class. 98 | 99 | By overriding `accept_client_connection` you can change how connections are accepted and handled. It is recommended 100 | that you call `process_request` from this method and implement `handle_request` to do the bulk of the work since 101 | `process_request` tracks the number of active connections to the server and handles network restrictions. 102 | 103 | `accept_client_connection` is called whenever a connection is waiting to be accepted on the socket server. 104 | 105 | If you wanted to implement a threadpool to process your requests you could do the following: 106 | 107 | ```ruby 108 | class EchoServer 109 | # ... 110 | 111 | def accept_client_connection 112 | @worker_threads ||= 4.times.map do 113 | Thread.new { worker_loop } 114 | end 115 | 116 | threads.each(&:join) 117 | end 118 | 119 | def worker_loop 120 | loop do 121 | client_socket = socket_server.accept 122 | process_request(client_socket) 123 | end 124 | end 125 | end 126 | ``` 127 | 128 | ## TLS Support 129 | 130 | If you would like to encrypt your TCP socket, Uninterruptible supports TLSv1.1 and TLSv1.2. Simply set `configuration.tls_key` and `configuration.tls_certificate` (see "Configuration" above) and your TCP socket will automatically be wrapped with TLS. 131 | 132 | To generate a key, run a command similar to the following: 133 | 134 | ```sh 135 | openssl req -newkey rsa:4096 -nodes -sha512 -x509 -days 3650 -nodes -out tls_cert.pem -keyout tls_key.pem 136 | ``` 137 | 138 | ## Contributing 139 | 140 | Bug reports and pull requests are welcome on GitHub at https://github.com/darkphnx/uninterruptible. 141 | 142 | ## License 143 | 144 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 145 | 146 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "uninterruptible" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/uninterruptible.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'ipaddr' 3 | 4 | require "uninterruptible/version" 5 | require 'uninterruptible/configuration' 6 | require 'uninterruptible/binder' 7 | require 'uninterruptible/file_descriptor_server' 8 | require 'uninterruptible/network_restrictions' 9 | require 'uninterruptible/tls_server_factory' 10 | require 'uninterruptible/server' 11 | 12 | # All of the interesting stuff is in Uninterruptible::Server 13 | module Uninterruptible 14 | class ConfigurationError < StandardError; end 15 | 16 | FILE_DESCRIPTOR_SERVER_VAR = 'FILE_DESCRIPTOR_SERVER_PATH'.freeze 17 | end 18 | -------------------------------------------------------------------------------- /lib/uninterruptible/binder.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module Uninterruptible 4 | class Binder 5 | attr_reader :bind_uri 6 | 7 | # @param [String] bind_address The config for a server we're returning the socket for 8 | # @example 9 | # "unix:///tmp/server.sock" 10 | # "tcp://127.0.0.1:8080" 11 | def initialize(bind_address) 12 | @bind_uri = parse_bind_address(bind_address) 13 | end 14 | 15 | # Bind to the TCP or UNIX socket defined in the #bind_uri 16 | # 17 | # @return [TCPServer, UNIXServer] Successfully bound server 18 | # 19 | # @raise [Uninterruptible::ConfigurationError] Raised when the URI indicates a non-tcp or unix scheme 20 | def bind_to_socket 21 | case bind_uri.scheme 22 | when 'tcp' 23 | bind_to_tcp_socket 24 | when 'unix' 25 | bind_to_unix_socket 26 | else 27 | raise Uninterruptible::ConfigurationError, "Can only bind to TCP and UNIX sockets" 28 | end 29 | end 30 | 31 | private 32 | 33 | # Connect (or reconnect if the FD is set) to a TCP server 34 | # 35 | # @return [TCPServer] Socket server for the configured address and port 36 | def bind_to_tcp_socket 37 | if ENV[FILE_DESCRIPTOR_SERVER_VAR] 38 | bind_from_file_descriptor_server(TCPServer) 39 | else 40 | TCPServer.new(bind_uri.host, bind_uri.port) 41 | end 42 | end 43 | 44 | # Connect (or reconnect if FD is set) to a UNIX socket. Will delete existing socket at path if required. 45 | # 46 | # @return [UNIXServer] Socket server for the configured path 47 | def bind_to_unix_socket 48 | if ENV[FILE_DESCRIPTOR_SERVER_VAR] 49 | bind_from_file_descriptor_server(UNIXServer) 50 | else 51 | File.delete(bind_uri.path) if File.exist?(bind_uri.path) 52 | UNIXServer.new(bind_uri.path) 53 | end 54 | end 55 | 56 | private 57 | 58 | # Parse the bind address in the configuration 59 | # 60 | # @param [String] bind_address The config for a server we're returning the socket for 61 | # 62 | # @return [URI::Generic] Parsed version of the bind_address 63 | # 64 | # @raise [Uninterruptible::ConfigurationError] Raised if the bind_address could not be parsed 65 | def parse_bind_address(bind_address) 66 | URI.parse(bind_address) 67 | rescue URI::Error 68 | raise Uninterruptible::ConfigurationError, "Couldn't parse the bind address: \"#{bind_address}\"" 69 | end 70 | 71 | # Open a connection to a running FileDesciptorServer on the parent of this server and obtain a file descriptor 72 | # for the running socket server on there. 73 | # 74 | # @param socket_klass [#for_fd] Class which responds to for_fd and accepts a file descriptor 75 | # 76 | # @return [IO] An IO instance created from the file descriptor received from the file descriptor server 77 | def bind_from_file_descriptor_server(socket_klass) 78 | fds_socket = UNIXSocket.new(ENV[FILE_DESCRIPTOR_SERVER_VAR]) 79 | socket_file_descriptor = fds_socket.recv_io(socket_klass) 80 | fds_socket.close 81 | socket_file_descriptor 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/uninterruptible/configuration.rb: -------------------------------------------------------------------------------- 1 | module Uninterruptible 2 | # Configuration parameters for an individual instance of a server. 3 | # 4 | # See {Server#configure} for usage instructions. 5 | class Configuration 6 | AVAILABLE_SSL_VERSIONS = %w[TLSv1_1 TLSv1_2].freeze 7 | 8 | attr_writer :bind, :bind_port, :bind_address, :pidfile_path, :start_command, :log_path, :log_level, :tls_version, 9 | :tls_key, :tls_certificate, :verify_client_tls_certificate, :client_tls_certificate_ca, :allowed_networks 10 | 11 | # Available TCP Port for the server to bind to (required). Falls back to environment variable PORT if set. 12 | # 13 | # @return [Integer] Port number to bind to 14 | def bind_port 15 | port = (@bind_port || ENV["PORT"]) 16 | raise ConfigurationError, "You must configure a bind_port" if port.nil? 17 | port.to_i 18 | end 19 | 20 | # Address to bind the server to (defaults to +0.0.0.0+). 21 | def bind_address 22 | @bind_address || "0.0.0.0" 23 | end 24 | 25 | # URI to bind to, falls back to tcp://bind_address:bind_port if unset. Accepts tcp:// or unix:// schemes. 26 | def bind 27 | @bind || "tcp://#{bind_address}:#{bind_port}" 28 | end 29 | 30 | # Location to write the pid of the current server to. If blank pidfile will not be written. Falls back to 31 | # environment variable PID_FILE if set. 32 | def pidfile_path 33 | @pidfile_path || ENV["PID_FILE"] 34 | end 35 | 36 | # Command that should be used to reexecute the server (required). Note: +bundle exec+ will be automatically added. 37 | # 38 | # @example 39 | # rake app:run_server 40 | def start_command 41 | raise ConfigurationError, "You must configure a start_command" unless @start_command 42 | @start_command 43 | end 44 | 45 | # Where should log output be written to? (defaults to STDOUT) 46 | def log_path 47 | @log_path || STDOUT 48 | end 49 | 50 | # Severity of entries written to the log, should be one of Logger::Severity (default Logger::INFO) 51 | def log_level 52 | @log_level || Logger::INFO 53 | end 54 | 55 | # Should the socket server be wrapped with a TLS server (TCP only). Automatically enabled when #tls_key or 56 | # #tls_certificate is set 57 | def tls_enabled? 58 | !tls_key.nil? || !tls_certificate.nil? 59 | end 60 | 61 | # TLS version to use for the connection. Must be one of +Uninterruptible::Configuration::AVAILABLE_SSL_VERSIONS+ 62 | # If unset, connection will be unencrypted. 63 | def tls_version 64 | version = @tls_version || ENV['TLS_VERSION'] || 'TLSv1_2' 65 | 66 | unless AVAILABLE_SSL_VERSIONS.include?(version) 67 | raise ConfigurationError, "Please ensure tls_version is one of #{AVAILABLE_SSL_VERSIONS.join(', ')}" 68 | end 69 | 70 | version 71 | end 72 | 73 | # Private key used for encrypting TLS connection. If environment variable TLS_KEY is set, attempt to read from a 74 | # file at that location. 75 | def tls_key 76 | @tls_key || (ENV['TLS_KEY'] ? File.read(ENV['TLS_KEY']) : nil) 77 | end 78 | 79 | # Certificate used for authenticating TLS connection. If environment variable TLS_CERTIFICATE is set, attempt to 80 | # read from a file at that location 81 | def tls_certificate 82 | @tls_certificate || (ENV['TLS_CERTIFICATE'] ? File.read(ENV['TLS_CERTIFICATE']) : nil) 83 | end 84 | 85 | # Should the client be required to present it's own SSL Certificate? Set #verify_client_tls_certificate to true, 86 | # or environment variable VERIFY_CLIENT_TLS_CERTIFICATE to enable 87 | def verify_client_tls_certificate? 88 | @verify_client_tls_certificate == true || !ENV['VERIFY_CLIENT_TLS_CERTIFICATE'].nil? || 89 | !client_tls_certificate_ca.nil? 90 | end 91 | 92 | # Validate any connecting clients against a specific CA. If environment variable CLIENT_TLS_CERTIFICATE_CA is set, 93 | # attempt to read from that file. Setting this enables #verify_client_tls_certificate? 94 | def client_tls_certificate_ca 95 | @client_tls_certificate_ca || ENV['CLIENT_TLS_CERTIFICATE_CA'] 96 | end 97 | 98 | # Specifiy allowed networks to reject all connections except those originating from allowed networks. Set to an 99 | # array of networks in CIDR format. If environment variable ALLOWED_NETWORKS is set, a comma separated list will be 100 | # read from that. Setting this enables #block_connections? 101 | def allowed_networks 102 | @allowed_networks || (ENV['ALLOWED_NETWORKS'] && ENV['ALLOWED_NETWORKS'].split(',')) || [] 103 | end 104 | 105 | # True when allowed_networks is set 106 | def block_connections? 107 | !allowed_networks.empty? 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/uninterruptible/file_descriptor_server.rb: -------------------------------------------------------------------------------- 1 | require 'tmpdir' 2 | require 'socket' 3 | 4 | module Uninterruptible 5 | class FileDescriptorServer 6 | attr_reader :io_object, :socket_server 7 | 8 | # Creates a new FileDescriptorServer and starts a listenting socket server 9 | # 10 | # @param [IO] Any IO object that will be shared by this server 11 | def initialize(io_object) 12 | @io_object = io_object 13 | 14 | start_socket_server 15 | end 16 | 17 | # @return [String] Location on disk where socket server is listening 18 | def socket_path 19 | @socket_path ||= File.join(socket_directory, 'fd.sock') 20 | end 21 | 22 | # Accept the next client connection and send it the file descriptor 23 | # 24 | # @raise [RuntimeError] Raises a runtime error if the socket server is closed 25 | def serve_file_descriptor 26 | raise "File descriptor server has been closed" if socket_server.closed? 27 | 28 | client = socket_server.accept 29 | client.send_io(io_object.to_io) 30 | client.close 31 | end 32 | 33 | # Close the socket server and tidy up any created files 34 | def close 35 | socket_server.close 36 | 37 | File.delete(socket_path) 38 | Dir.rmdir(socket_directory) 39 | end 40 | 41 | private 42 | 43 | def socket_directory 44 | @socket_directory ||= Dir.mktmpdir('u-') 45 | end 46 | 47 | def start_socket_server 48 | @socket_server = UNIXServer.new(socket_path) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/uninterruptible/network_restrictions.rb: -------------------------------------------------------------------------------- 1 | module Uninterruptible 2 | class NetworkRestrictions 3 | attr_reader :configuration 4 | 5 | # @param [Uninterruptible::Configuration] configuration Object with allowed_networks configuration 6 | def initialize(configuration) 7 | @configuration = configuration 8 | check_configuration! 9 | end 10 | 11 | # Should the incoming connection be allowed to connect? 12 | # 13 | # @param [TCPSocket] client_socket Incoming socket from the client connection 14 | def connection_allowed_from?(client_address) 15 | return true unless configuration.block_connections? 16 | allowed_networks.any? { |allowed_network| allowed_network.include?(client_address) } 17 | end 18 | 19 | private 20 | 21 | # Parse the list of allowed networks from the configuration and turn them into IPAddr objects 22 | # 23 | # @return [Array] Parsed list of IP networks 24 | def allowed_networks 25 | @allowed_networks ||= configuration.allowed_networks.map do |network| 26 | IPAddr.new(network) 27 | end 28 | end 29 | 30 | # Check the configuration parameters for network restrictions 31 | # 32 | # @raise [Uninterruptible::ConfigurationError] Correct options are not set for network restrictions 33 | def check_configuration! 34 | if configuration.block_connections? && !configuration.bind.start_with?('tcp://') 35 | raise ConfigurationError, "Network restrictions can only be used on TCP servers" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/uninterruptible/server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'logger' 3 | 4 | module Uninterruptible 5 | # The meat and potatoes of uninterruptible, include this in your server, configure it and override #handle_request. 6 | # 7 | # Calling #run will listen on the configured port and start a blocking server. Send that server signal USR1 to 8 | # begin a hot-restart and TERM to start a graceful shutdown. Send TERM again for an immediate shutdown. 9 | # 10 | # @example 11 | # class HelloServer 12 | # include Uninterruptible::Server 13 | # 14 | # def handle_request(client_socket) 15 | # name = client_socket.read 16 | # client_socket.write("Hello #{name}!") 17 | # end 18 | # end 19 | # 20 | # To then use this server, call #configure on it to set the port and restart command, then call #run to start. 21 | # 22 | # @example 23 | # hello_server = HelloServer.new 24 | # hello_server.configure do |config| 25 | # config.start_command = 'rake my_app:hello_server' 26 | # config.port = 7000 27 | # end 28 | # hello_server.run 29 | # 30 | module Server 31 | def self.included(base) 32 | base.class_eval do 33 | attr_reader :active_connections, :socket_server, :signal_pipe_r, :signal_pipe_w, :mutex, :file_descriptor_server 34 | end 35 | end 36 | 37 | # Configure the server, see {Uninterruptible::Configuration} for full options. 38 | # 39 | # @yield [Uninterruptible::Configuration] the current configuration for this server instance 40 | # 41 | # @return [Uninterruptible::Configuration] the current configuration (after yield) 42 | def configure 43 | yield server_configuration if block_given? 44 | server_configuration 45 | end 46 | 47 | # Starts the server, this is a blocking operation. Bind to the address and port specified in the configuration, 48 | # write the pidfile (if configured) and start accepting new connections for processing. 49 | def run 50 | @active_connections = 0 51 | @mutex = Mutex.new 52 | 53 | logger.info "Starting server on #{server_configuration.bind}" 54 | 55 | # Set up each listener and add it to an array ready for the event loop 56 | @active_descriptors = [] 57 | @active_descriptors << establish_socket_server 58 | @active_descriptors << establish_file_descriptor_server 59 | @active_descriptors << setup_signal_traps 60 | 61 | write_pidfile 62 | 63 | # Enter the main loop 64 | select_loop 65 | end 66 | 67 | # @abstract Override this method to process incoming requests. Each request is handled in it's own thread. 68 | # Socket will be automatically closed after completion. 69 | # 70 | # @param [TCPSocket] client_socket Incoming socket from the client 71 | def handle_request(client_socket) 72 | raise NotImplementedError 73 | end 74 | 75 | private 76 | 77 | # Start a blocking loop which awaits new connections before calling #accept_client_connection. Also monitors 78 | # signal_pipe_r for processing any signals sent to the process. 79 | def select_loop 80 | loop do 81 | # Wait for descriptors or a 1 second timeout 82 | readable, = IO.select(@active_descriptors, [], [], 1) 83 | # Only process one descriptor per iteration. 84 | # We don't want to process a descriptor that has been deleted. 85 | reader = readable&.first 86 | if reader == signal_pipe_r 87 | signal = reader.gets.chomp 88 | process_signal(signal) 89 | elsif reader == file_descriptor_server.socket_server 90 | file_descriptor_server.serve_file_descriptor 91 | @active_descriptors.delete(file_descriptor_server.socket_server) 92 | graceful_shutdown 93 | elsif reader == socket_server 94 | accept_client_connection 95 | end 96 | 97 | if @shutdown 98 | if active_connections.zero? 99 | logger.debug "No more active connections. Exiting'" 100 | Process.exit(0) 101 | else 102 | logger.debug "#{active_connections} connections still active" 103 | end 104 | end 105 | 106 | end 107 | end 108 | 109 | # Accept a waiting connection. Should only be called when it is known a connection is waiting, from an IO.select 110 | # loop for example. By default this creates one thread per connection. Override this method to provide a new 111 | # concurrency model. 112 | def accept_client_connection 113 | Thread.start(socket_server.accept) do |client_socket| 114 | if client_socket.is_a?(OpenSSL::SSL::SSLSocket) 115 | begin 116 | client_socket.accept 117 | rescue OpenSSL::SSL::SSLError => e 118 | logger.warn e.message 119 | client_socket.close rescue true 120 | Thread.exit 121 | end 122 | end 123 | process_request(client_socket) 124 | end 125 | end 126 | 127 | # Keeps a track of the number of active connections and passes the client connection to #handle_request for the 128 | # user to do with as they wish. Automatically closes a connection once #handle_request has completed. 129 | # 130 | # @param [TCPSocket] client_socket Incoming socket from the client connection 131 | def process_request(client_socket) 132 | mutex.synchronize { @active_connections += 1 } 133 | begin 134 | client_address = client_socket.peeraddr.last 135 | if network_restrictions.connection_allowed_from?(client_address) 136 | logger.debug "Accepting connection from #{client_address}" 137 | handle_request(client_socket) 138 | else 139 | logger.debug "Rejecting connection from #{client_address}" 140 | end 141 | rescue Errno::EINVAL 142 | logger.warn "Connection was closed before request could be processed" 143 | ensure 144 | client_socket.close 145 | mutex.synchronize { @active_connections -= 1 } 146 | end 147 | end 148 | 149 | # Listen (or reconnect) to the bind address and port specified in the config. If FILE_DESCRIPTOR_SERVER_PATH is set 150 | # in the env, reconnect to that file descriptor. 151 | def establish_socket_server 152 | @socket_server = Uninterruptible::Binder.new(server_configuration.bind).bind_to_socket 153 | 154 | if server_configuration.tls_enabled? 155 | @socket_server = Uninterruptible::TLSServerFactory.new(server_configuration).wrap_with_tls(@socket_server) 156 | end 157 | @socket_server 158 | end 159 | 160 | # Create the UNIX socket server that will pass the server file descriptor 161 | # to the child process when a restart occurs. 162 | def establish_file_descriptor_server 163 | @file_descriptor_server = FileDescriptorServer.new(socket_server) 164 | @file_descriptor_server.socket_server 165 | end 166 | 167 | # Write the current pid out to pidfile_path if configured 168 | def write_pidfile 169 | return unless server_configuration.pidfile_path 170 | 171 | logger.debug "Writing pid to #{server_configuration.pidfile_path}" 172 | File.write(server_configuration.pidfile_path, Process.pid.to_s) 173 | end 174 | 175 | # Catch TERM and USR1 signals which control the lifecycle of the server. These get written to an internal pipe 176 | # which will be picked up by the main accept_connection loop and passed to #process_signal 177 | def setup_signal_traps 178 | @signal_pipe_r, @signal_pipe_w = IO.pipe 179 | 180 | %w(TERM USR1).each do |signal_name| 181 | trap(signal_name) do 182 | @signal_pipe_w.puts(signal_name) 183 | end 184 | end 185 | @signal_pipe_r 186 | end 187 | 188 | # When a signal has been caught, it should be passed here for the appropriate action to be taken 189 | # On TERM begin a graceful shutdown, if a second TERM is received shutdown immediately with an exit code of 1 190 | # On USR1 begin a hot restart which will bring up a new copy of the server and then shut down the old one 191 | # 192 | # @param [String] signal_name Signal to process 193 | def process_signal(signal_name) 194 | if signal_name == 'TERM' 195 | if @shutdown 196 | logger.info "TERM received again, exiting immediately" 197 | Process.exit(1) 198 | else 199 | logger.info "TERM received, starting graceful shutdown" 200 | graceful_shutdown 201 | end 202 | elsif signal_name == 'USR1' 203 | logger.info "USR1 received, hot restart in progress" 204 | hot_restart 205 | end 206 | end 207 | 208 | # Stop listening on socket_server, wait until all active connections have finished processing and exit with 0. 209 | def graceful_shutdown 210 | socket_server.close unless socket_server.closed? 211 | @active_descriptors.delete(socket_server) 212 | @shutdown = true 213 | end 214 | 215 | # Start a new copy of this server, maintaining all current file descriptors and env. 216 | def hot_restart 217 | fork do 218 | # Let the new server know where to find the file descriptor server 219 | ENV[FILE_DESCRIPTOR_SERVER_VAR] = file_descriptor_server.socket_path 220 | 221 | Dir.chdir(ENV['APP_ROOT']) if ENV['APP_ROOT'] 222 | ENV.delete('BUNDLE_GEMFILE') # Ensure a fresh bundle is used 223 | 224 | exec("bundle exec #{server_configuration.start_command}") 225 | end 226 | end 227 | 228 | def network_restrictions 229 | @network_restrictions ||= Uninterruptible::NetworkRestrictions.new(server_configuration) 230 | end 231 | 232 | # The current configuration of this server 233 | # 234 | # @return [Uninterruptible::Configuration] Current or new configuration if unset. 235 | def server_configuration 236 | @server_configuration ||= Uninterruptible::Configuration.new 237 | end 238 | 239 | def logger 240 | @logger ||= begin 241 | log = Logger.new(server_configuration.log_path) 242 | log.level = server_configuration.log_level 243 | log 244 | end 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /lib/uninterruptible/tls_server_factory.rb: -------------------------------------------------------------------------------- 1 | module Uninterruptible 2 | # Wraps a bound TCP server with an OpenSSL::SSL::SSLServer according to the Uninterruptible::Configuration for 3 | # this server. 4 | class TLSServerFactory 5 | attr_reader :configuration 6 | 7 | # Extracts pulling multiple certificates out of one file 8 | class CertificateChain 9 | attr_reader :cert_file 10 | 11 | def initialize(cert_file) 12 | @cert_file = cert_file 13 | end 14 | 15 | def to_a 16 | certs = cert_file.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) 17 | certs.map { |cert| OpenSSL::X509::Certificate.new(cert) } 18 | end 19 | end 20 | 21 | # @param [Uninterruptible::Configuration] configuration Object with valid TLS configuration options 22 | # 23 | # @raise [Uninterruptible::ConfigurationError] Correct options are not set for TLS 24 | def initialize(configuration) 25 | @configuration = configuration 26 | check_configuration! 27 | end 28 | 29 | # Accepts a TCP server, gives it a nice friendly SSLServer wrapper and returns the SSLServer 30 | # 31 | # @param [TCPServer] tcp_server Server to be wrapped 32 | # 33 | # @return [OpenSSL::SSL::SSLServer] tcp_server with a TLS layer 34 | def wrap_with_tls(tcp_server) 35 | server = OpenSSL::SSL::SSLServer.new(tcp_server, ssl_context) 36 | server.start_immediately = false 37 | server 38 | end 39 | 40 | private 41 | 42 | # Build an OpenSSL::SSL::SSLContext object from the configuration passed to the initializer 43 | # 44 | # @return [OpenSSL::SSL::SSLContext] SSL context for the server config 45 | def ssl_context 46 | context = OpenSSL::SSL::SSLContext.new 47 | 48 | certificates = CertificateChain.new(configuration.tls_certificate).to_a 49 | context.cert = certificates.shift 50 | context.extra_chain_cert = certificates # Remaining certificataes that aren't the primary. Could be empty. 51 | 52 | context.key = OpenSSL::PKey::RSA.new(configuration.tls_key) 53 | context.ssl_version = configuration.tls_version.to_sym 54 | 55 | if configuration.verify_client_tls_certificate? 56 | context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT 57 | end 58 | context.ca_file = configuration.client_tls_certificate_ca if configuration.client_tls_certificate_ca 59 | 60 | context 61 | end 62 | 63 | # Check the configuration parameters for TLS are correct 64 | # 65 | # @raise [Uninterruptible::ConfigurationError] Correct options are not set for TLS 66 | def check_configuration! 67 | raise ConfigurationError, "TLS can only be used on TCP servers" unless configuration.bind.start_with?('tcp://') 68 | 69 | empty = %i[tls_certificate tls_key].any? { |config_param| configuration.send(config_param).nil? } 70 | raise ConfigurationError, "tls_certificate and tls_key must be set to use TLS" if empty 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/uninterruptible/version.rb: -------------------------------------------------------------------------------- 1 | module Uninterruptible 2 | VERSION = "2.6.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/binder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Uninterruptible::Binder do 4 | include EnvironmentalControls 5 | 6 | describe '#new' do 7 | let(:config) { tcp_configuration } 8 | 9 | it 'parses the bind configuration' do 10 | binder = described_class.new(config) 11 | expect(binder.bind_uri).to be_a(URI::Generic) 12 | end 13 | 14 | it 'raises an error when the bind cannot be parsed' do 15 | config = "nonsense uri" 16 | expect { described_class.new(config) }.to raise_error(Uninterruptible::ConfigurationError) 17 | end 18 | end 19 | 20 | describe '#bind_to_socket' do 21 | context 'when given a TCP address' do 22 | let(:binder) { described_class.new(tcp_configuration) } 23 | 24 | it 'binds to a fresh socket' do 25 | server = binder.bind_to_socket 26 | expect(server).to be_a(TCPServer) 27 | server.close 28 | end 29 | 30 | it 'rebinds to an existing socket when a FileDescriptorServer is available' do 31 | # open a socket first so we can bind to it 32 | existing_server = TCPServer.new(binder.bind_uri.host, binder.bind_uri.port) 33 | existing_server_port = existing_server.addr[2] 34 | 35 | # Start a file descriptor server and prepare it to serve the file descriptor to the binder 36 | file_descriptor_server = Uninterruptible::FileDescriptorServer.new(existing_server) 37 | Thread.new do 38 | file_descriptor_server.serve_file_descriptor 39 | end 40 | 41 | within_env(Uninterruptible::FILE_DESCRIPTOR_SERVER_VAR => file_descriptor_server.socket_path) do 42 | new_server = binder.bind_to_socket 43 | new_server_port = existing_server.addr[2] 44 | 45 | expect(new_server).to be_a(TCPServer) 46 | expect(new_server_port).to eq(existing_server_port) 47 | 48 | new_server.close 49 | end 50 | end 51 | end 52 | 53 | context 'when given a UNIX address' do 54 | let(:binder) { described_class.new(unix_configuration) } 55 | 56 | it 'binds to a fresh socket' do 57 | server = binder.bind_to_socket 58 | expect(server).to be_a(UNIXServer) 59 | server.close 60 | end 61 | 62 | it 'rebinds to an existing socket when a FileDescriporServer is available' do 63 | File.delete(unix_path) if File.exist?(unix_path) 64 | existing_server = UNIXServer.new(unix_path) 65 | existing_server_path = existing_server.path 66 | 67 | # Start a file descriptor server and prepare it to serve the file descriptor to the binder 68 | file_descriptor_server = Uninterruptible::FileDescriptorServer.new(existing_server) 69 | Thread.new do 70 | file_descriptor_server.serve_file_descriptor 71 | end 72 | 73 | within_env(Uninterruptible::FILE_DESCRIPTOR_SERVER_VAR => file_descriptor_server.socket_path) do 74 | new_server = binder.bind_to_socket 75 | new_server_path = new_server.path 76 | 77 | expect(new_server).to be_a(UNIXServer) 78 | expect(new_server_path).to eq(existing_server_path) 79 | 80 | new_server.close 81 | end 82 | end 83 | end 84 | 85 | it 'raises an error when given a non-tcp or unix address' do 86 | binder = described_class.new("https://google.com") 87 | expect { binder.bind_to_socket }.to raise_error(Uninterruptible::ConfigurationError) 88 | end 89 | end 90 | 91 | def tcp_configuration 92 | "tcp://127.0.0.1:#{tcp_port}" 93 | end 94 | 95 | def unix_configuration 96 | "unix://#{unix_path}" 97 | end 98 | 99 | def tcp_port 100 | 8004 101 | end 102 | 103 | def unix_path 104 | '/tmp/unix_server.sock' 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Uninterruptible::Configuration do 4 | include EnvironmentalControls 5 | 6 | let(:configuration) { described_class.new } 7 | 8 | describe "#bind" do 9 | it 'falls back to a TCP address with #bind_address and #bind_port' do 10 | configuration.bind_port = 1024 11 | configuration.bind_address = '127.0.0.2' 12 | 13 | expect(configuration.bind).to eq('tcp://127.0.0.2:1024') 14 | end 15 | 16 | it 'returns the value set by #bind=' do 17 | configuration.bind = "unix:///tmp/server.sock" 18 | expect(configuration.bind).to eq("unix:///tmp/server.sock") 19 | end 20 | end 21 | 22 | describe "#bind_port" do 23 | it "falls back to PORT in ENV when unset" do 24 | within_env("PORT" => "1000") do 25 | expect(configuration.bind_port).to eq(1000) 26 | end 27 | end 28 | 29 | it "raises an exception when no port is set" do 30 | expect { configuration.bind_port }.to raise_error(Uninterruptible::ConfigurationError) 31 | end 32 | 33 | it "returns the value set by bind_port=" do 34 | # PORT should be ignored as it is superceded by bind_port= 35 | within_env("PORT" => "1000") do 36 | configuration.bind_port = 1001 37 | expect(configuration.bind_port).to eq(1001) 38 | end 39 | end 40 | end 41 | 42 | describe "#bind_address" do 43 | it 'defaults to 0.0.0.0 if unset' do 44 | expect(configuration.bind_address).to eq('0.0.0.0') 45 | end 46 | 47 | it 'returns the value set by bind_address=' do 48 | configuration.bind_address = '127.0.0.1' 49 | expect(configuration.bind_address).to eq('127.0.0.1') 50 | end 51 | end 52 | 53 | describe "#pidfile_path" do 54 | it 'falls back to PID_FILE in ENV whjen unset' do 55 | within_env("PID_FILE" => "/tmp/server.pid") do 56 | expect(configuration.pidfile_path).to eq("/tmp/server.pid") 57 | end 58 | end 59 | 60 | it 'returns the value set by pidfile_path=' do 61 | # PID_FILE should be ignored since it is superceded by pidfile_path= 62 | within_env("PID_FILE" => "/tmp/server.pid") do 63 | configuration.pidfile_path = '/tmp/server2.pid' 64 | expect(configuration.pidfile_path).to eq("/tmp/server2.pid") 65 | end 66 | end 67 | end 68 | 69 | describe "#start_command" do 70 | it 'returns the value set by start_command=' do 71 | configuration.start_command = 'rake myapp:run' 72 | expect(configuration.start_command).to eq('rake myapp:run') 73 | end 74 | 75 | it 'raises an exception when unset' do 76 | expect { configuration.start_command }.to raise_error(Uninterruptible::ConfigurationError) 77 | end 78 | end 79 | 80 | describe '#log_path' do 81 | it 'returns the value set by log_path=' do 82 | configuration.log_path = 'log/server.log' 83 | expect(configuration.log_path).to eq('log/server.log') 84 | end 85 | 86 | it 'defaults to STDOUT when unset' do 87 | expect(configuration.log_path).to eq(STDOUT) 88 | end 89 | end 90 | 91 | describe "#log_level" do 92 | it 'returns the value set by log_level=' do 93 | configuration.log_level = Logger::FATAL 94 | expect(configuration.log_level).to eq(Logger::FATAL) 95 | end 96 | 97 | it 'defaults to Logger::INFO when unset' do 98 | expect(configuration.log_level).to eq(Logger::INFO) 99 | end 100 | end 101 | 102 | describe '#tls_enabled?' do 103 | it 'returns false by default' do 104 | expect(configuration.tls_enabled?).to eq(false) 105 | end 106 | 107 | it 'returns true when #tls_certificate is set' do 108 | configuration.tls_certificate = 'somethjign' 109 | expect(configuration.tls_enabled?).to eq(true) 110 | end 111 | 112 | it 'returns true when #tls_key is set' do 113 | configuration.tls_key = 'somethjign' 114 | expect(configuration.tls_enabled?).to eq(true) 115 | end 116 | end 117 | 118 | describe "#tls_version" do 119 | it 'returns the value set by #tls_version=' do 120 | within_env("TLS_VERSION" => 'NOTVERSION') do 121 | configuration.tls_version = "TLSv1_2" 122 | expect(configuration.tls_version).to eq("TLSv1_2") 123 | end 124 | end 125 | 126 | it 'falls back to TLS_VERSION in env when unset' do 127 | within_env("TLS_VERSION" => 'TLSv1_1') do 128 | expect(configuration.tls_version).to eq("TLSv1_1") 129 | end 130 | end 131 | 132 | it 'returns TLSv1_2 when unset' do 133 | expect(configuration.tls_version).to eq('TLSv1_2') 134 | end 135 | 136 | it "raises an error if the version is not approved" do 137 | configuration.tls_version = "SSLv3" 138 | expect { configuration.tls_version }.to raise_error(Uninterruptible::ConfigurationError) 139 | end 140 | end 141 | 142 | describe '#tls_key' do 143 | it 'returns the value set by #tls_key=' do 144 | within_env("TLS_KEY" => 'dummypath') do 145 | configuration.tls_key = "BEGIN PRIVATE KEY" 146 | expect(configuration.tls_key).to eq("BEGIN PRIVATE KEY") 147 | end 148 | end 149 | 150 | it 'falls back to reading a file located at TLS_KEY in ENV' do 151 | tempfile = Tempfile.new('test-tls-key') 152 | tempfile.write("BEGIN PRIVATE KEY FILE") 153 | tempfile.close 154 | 155 | within_env("TLS_KEY" => tempfile.path) do 156 | expect(configuration.tls_key).to eq("BEGIN PRIVATE KEY FILE") 157 | end 158 | 159 | tempfile.unlink 160 | end 161 | 162 | it 'returns nil when unset' do 163 | expect(configuration.tls_key).to be_nil 164 | end 165 | end 166 | 167 | describe '#tls_certificate' do 168 | it 'returns the value set by #tls_certificate=' do 169 | within_env("TLS_CERTIFICATE" => 'dummy_path') do 170 | configuration.tls_certificate = "BEGIN CERTIFICATE" 171 | expect(configuration.tls_certificate = "BEGIN CERTIFICATE") 172 | end 173 | end 174 | 175 | it 'falls back to reading a file located at TLS_CERTIFICATE in ENV' do 176 | tempfile = Tempfile.new('test-tls-cert') 177 | tempfile.write("BEGIN CERTIFICATE FILE") 178 | tempfile.close 179 | 180 | within_env("TLS_CERTIFICATE" => tempfile.path) do 181 | expect(configuration.tls_certificate).to eq("BEGIN CERTIFICATE FILE") 182 | end 183 | 184 | tempfile.unlink 185 | end 186 | 187 | it 'returns nil when unset' do 188 | expect(configuration.tls_certificate).to be_nil 189 | end 190 | end 191 | 192 | describe '#client_tls_certificate_ca' do 193 | it 'returns the value set by #client_tls_certificate_ca=' do 194 | within_env("CLIENT_TLS_CERTIFICATE" => 'dummy_path') do 195 | configuration.client_tls_certificate_ca = "BEGIN CERTIFICATE" 196 | expect(configuration.client_tls_certificate_ca).to eq("BEGIN CERTIFICATE") 197 | end 198 | end 199 | 200 | it 'falls back to reading a file located at CLIENT_TLS_CERTIFICATE_CA in ENV' do 201 | within_env("CLIENT_TLS_CERTIFICATE_CA" => 'notarealca') do 202 | expect(configuration.client_tls_certificate_ca).to eq("notarealca") 203 | end 204 | end 205 | 206 | it 'returns nil when unset' do 207 | expect(configuration.client_tls_certificate_ca).to be_nil 208 | end 209 | end 210 | 211 | describe '#verify_client_tls_certificate?' do 212 | it 'returns true when #verify_client_tls_certificate is true' do 213 | configuration.verify_client_tls_certificate = true 214 | expect(configuration.verify_client_tls_certificate?).to be(true) 215 | end 216 | 217 | it 'returns false if #verify_client_tls_certificate is anything else' do 218 | configuration.verify_client_tls_certificate = 'not a true bool' 219 | expect(configuration.verify_client_tls_certificate?).to be(false) 220 | end 221 | 222 | it 'returns false by default' do 223 | expect(configuration.verify_client_tls_certificate?).to be(false) 224 | end 225 | 226 | it 'returns true when VERIFY_CLIENT_TLS_CERTIFICATE is set' do 227 | within_env("VERIFY_CLIENT_TLS_CERTIFICATE" => 'anything') do 228 | expect(configuration.verify_client_tls_certificate?).to be(true) 229 | end 230 | end 231 | 232 | it 'returns true if #client_tls_certificate_ca is set' do 233 | configuration.client_tls_certificate_ca = "BEGIN CERTIFICATE" 234 | expect(configuration.verify_client_tls_certificate?).to be(true) 235 | end 236 | end 237 | 238 | describe '#allowed_networks' do 239 | it 'returns the value set by allowed_networks=' do 240 | within_env("ALLOWED_NETWORKS" => 'not a network') do 241 | configuration.allowed_networks = ['192.168.0.0/24'] 242 | expect(configuration.allowed_networks).to eq(['192.168.0.0/24']) 243 | end 244 | end 245 | 246 | it 'falls back to ALLOWED_NETWORKS in env when unset, split by comma' do 247 | within_env("ALLOWED_NETWORKS" => '192.168.0.0/24,127.0.0.0/8') do 248 | expect(configuration.allowed_networks).to eq(['192.168.0.0/24', '127.0.0.0/8']) 249 | end 250 | end 251 | 252 | it 'returns an empty array when unset' do 253 | expect(configuration.allowed_networks).to eq([]) 254 | end 255 | end 256 | 257 | describe '#block_connections?' do 258 | it 'returns false when no allowed_networks are set' do 259 | expect(configuration.block_connections?).to be false 260 | end 261 | 262 | it 'returns true when allowed networks are configured' do 263 | configuration.allowed_networks = ['192.168.0.1'] 264 | expect(configuration.block_connections?).to be true 265 | end 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /spec/echo_server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.shared_examples "an echo server" do 4 | it "writes a PID file" do 5 | expect(File.exist?(echo_pid_path)).to be true 6 | end 7 | 8 | it "echoes data sent to it" do 9 | with_connection do |socket| 10 | message = "hello world!" 11 | socket.puts(message) 12 | rtn_data = socket.gets.chomp 13 | 14 | expect(rtn_data).to eq(message) 15 | end 16 | end 17 | 18 | context "on recieving TERM" do 19 | after(:each) do 20 | # Every test in this block should end with the echo server stopped, so start a new one for the next test 21 | start_server 22 | end 23 | 24 | it "quits immediately when no connections are active" do 25 | Process.kill("TERM", current_echo_pid) 26 | sleep 0.1 # immediately-ish 27 | expect(echo_server_running?).to be false 28 | end 29 | 30 | it "does not quit until all connections are complete" do 31 | connection_thread = Thread.start do 32 | with_connection do |socket| 33 | sleep 1 34 | end 35 | end 36 | 37 | # Make sure the thread has had time to establish a socket 38 | sleep 0.1 39 | 40 | # Try to kill immediately, this should fail 41 | Process.kill("TERM", current_echo_pid) 42 | 43 | # Make sure the term has time to arrive 44 | sleep 0.1 45 | 46 | expect(echo_server_running?).to be true 47 | 48 | # Should quit after the last process has disconnected 49 | connection_thread.join 50 | 51 | # Make sure the process has time to gracefully exit 52 | sleep 1 53 | 54 | expect(echo_server_running?).to be false 55 | end 56 | 57 | it "quits immediately after receiving a second TERM" do 58 | Thread.start do 59 | with_connection do |socket| 60 | sleep 1 61 | end 62 | end 63 | 64 | # Make sure the thread has had time to establish a socket 65 | sleep 0.1 66 | 67 | # First TERM waits for the connection to finish 68 | Process.kill("TERM", current_echo_pid) 69 | 70 | # Make sure the term has time to arrive 71 | sleep 0.1 72 | 73 | expect(echo_server_running?).to be true 74 | 75 | # Second TERN bails out immediately 76 | Process.kill("TERM", current_echo_pid) 77 | sleep 0.1 # immediately-ish 78 | expect(echo_server_running?).to be false 79 | end 80 | end 81 | 82 | context "on receiving USR1" do 83 | it 'spawns a new copy of the server' do 84 | original_pid = current_echo_pid 85 | Process.kill('USR1', original_pid) 86 | 87 | wait_for_pid_change 88 | 89 | expect(current_echo_pid).not_to eq(original_pid) 90 | expect(pid_running?(current_echo_pid)).to be true 91 | end 92 | 93 | it 'terminates the original server' do 94 | original_pid = current_echo_pid 95 | Process.kill('USR1', original_pid) 96 | 97 | wait_for_pid_change 98 | expect(pid_running?(original_pid)).to be false 99 | end 100 | 101 | it 'updates the pid file' do 102 | original_pid = current_echo_pid 103 | Process.kill('USR1', original_pid) 104 | 105 | wait_for_pid_change 106 | 107 | new_pid = File.read(echo_pid_path) 108 | expect(new_pid).not_to eq(original_pid) 109 | end 110 | end 111 | end 112 | 113 | # A functional test of an Uninterruptible::Server, see support/echo_server for server implementation 114 | RSpec.describe "TcpServer" do 115 | include EchoServerControls 116 | 117 | before(:all) do 118 | start_server 119 | end 120 | 121 | after(:all) do 122 | stop_echo_server 123 | end 124 | 125 | it "starts a TCP server" do 126 | with_connection do |socket| 127 | expect(socket).to be_a(TCPSocket) 128 | end 129 | end 130 | 131 | it_behaves_like "an echo server" 132 | 133 | def start_server 134 | start_echo_server('tcp_server') 135 | end 136 | 137 | # Open a connection to the running echo server and yield the socket in the block. Autocloses once finished. 138 | def with_connection 139 | socket = TCPSocket.new("localhost", 6789) 140 | yield socket if block_given? 141 | ensure 142 | socket.close if socket 143 | end 144 | end 145 | 146 | RSpec.describe "UNIXServer", focus: true do 147 | include EchoServerControls 148 | 149 | before(:all) do 150 | start_server 151 | end 152 | 153 | after(:all) do 154 | stop_echo_server 155 | end 156 | 157 | it "starts a UNIX server" do 158 | with_connection do |socket| 159 | expect(socket).to be_a(UNIXSocket) 160 | end 161 | end 162 | 163 | it_behaves_like "an echo server" 164 | 165 | def start_server 166 | start_echo_server('unix_server') 167 | end 168 | 169 | # Open a connection to the running echo server and yield the socket in the block. Autocloses once finished. 170 | def with_connection 171 | socket = UNIXSocket.new('/tmp/echo_server.sock') 172 | yield socket if block_given? 173 | ensure 174 | socket.close if socket 175 | end 176 | end 177 | 178 | RSpec.describe "SSLServer" do 179 | include EchoServerControls 180 | 181 | before(:all) do 182 | start_server 183 | end 184 | 185 | after(:all) do 186 | stop_echo_server 187 | end 188 | 189 | it "starts a TLS server" do 190 | with_connection do |socket| 191 | expect(socket).to be_a(OpenSSL::SSL::SSLSocket) 192 | end 193 | end 194 | 195 | it_behaves_like "an echo server" 196 | 197 | def start_server 198 | start_echo_server('tls_server') 199 | end 200 | 201 | # Open a connection to the running echo server and yield the socket in the block. Autocloses once finished. 202 | def with_connection 203 | socket = TCPSocket.new("localhost", 6789) 204 | 205 | context = OpenSSL::SSL::SSLContext.new 206 | context.ssl_version = :TLSv1_2 207 | context.verify_mode = OpenSSL::SSL::VERIFY_NONE 208 | 209 | ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, context) 210 | ssl_socket.connect 211 | 212 | yield ssl_socket if block_given? 213 | ensure 214 | ssl_socket.close if ssl_socket 215 | socket.close if socket 216 | end 217 | end 218 | 219 | def echo_server_running? 220 | pid_running?(current_echo_pid) 221 | end 222 | 223 | def pid_running?(pid) 224 | # Use waitpid to check on child processes, getpgid reports incorrectly for them 225 | Process.waitpid(pid, Process::WNOHANG).nil? 226 | rescue Errno::ECHILD 227 | # Use Process.getpgid if it's not a child process we're looking for 228 | begin 229 | Process.getpgid(pid) 230 | true 231 | rescue Errno::ESRCH 232 | false 233 | end 234 | end 235 | 236 | # Wait for the echo server pidfile to change. 237 | # 238 | # @param [Integer] timeout Timeout in seconds 239 | def wait_for_pid_change(timeout = 5) 240 | starting_pid = current_echo_pid 241 | timeout_tries = timeout * 2 # half second intervals 242 | 243 | tries = 0 244 | while current_echo_pid == starting_pid && tries < timeout_tries 245 | tries += 1 246 | sleep 0.5 247 | end 248 | 249 | raise "Timeout waiting for PID change" if timeout_tries == tries 250 | end 251 | -------------------------------------------------------------------------------- /spec/file_descriptor_server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Uninterruptible::FileDescriptorServer do 4 | let(:io_object) { build_io_object } 5 | let(:file_descriptor_server) { described_class.new(io_object) } 6 | 7 | describe '.new' do 8 | it 'accepts an IO object to send to a client' do 9 | file_descriptor_server = described_class.new(io_object) 10 | 11 | expect(file_descriptor_server.io_object).to eq(io_object) 12 | end 13 | 14 | it 'starts a unix socket server' do 15 | file_descriptor_server = described_class.new(io_object) 16 | 17 | expect(File.socket?(file_descriptor_server.socket_path)).to be true 18 | end 19 | end 20 | 21 | describe '#socket_path' do 22 | it 'returns the path where the socket server is running' do 23 | socket_path = file_descriptor_server.socket_path 24 | 25 | expect(File.socket?(socket_path)).to be true 26 | end 27 | end 28 | 29 | describe '#socket_server' do 30 | it 'returns the started unix socket server' do 31 | socket_server = file_descriptor_server.socket_server 32 | 33 | expect(socket_server).to be_a(UNIXServer) 34 | end 35 | end 36 | 37 | describe '#serve_file_descriptor', focus: true do 38 | it 'sends the file descriptor of the IO object to the next client to connect' do 39 | socket_path = file_descriptor_server.socket_path 40 | 41 | client_thread = Thread.new do 42 | socket = UNIXSocket.new(socket_path) 43 | received_io = socket.recv_io 44 | socket.close 45 | received_io 46 | end 47 | 48 | file_descriptor_server.serve_file_descriptor 49 | 50 | # The received_io will infact have a new file descriptor, pointing to the same place not sure how we test 51 | # they're exactly the same 52 | expect(client_thread.value).to be_a(IO) 53 | end 54 | 55 | it 'raises an error if the socket server is closed' do 56 | file_descriptor_server.close 57 | 58 | expect { file_descriptor_server.serve_file_descriptor }.to raise_error(RuntimeError) 59 | end 60 | end 61 | 62 | describe '#close' do 63 | it 'closes the socket server' do 64 | file_descriptor_server.close 65 | 66 | expect(file_descriptor_server.socket_server.closed?).to be true 67 | end 68 | 69 | it 'removes the path on the file system' do 70 | socket_path = file_descriptor_server.socket_path 71 | 72 | file_descriptor_server.close 73 | 74 | expect(File.exist?(socket_path)).to be false 75 | end 76 | end 77 | 78 | private 79 | 80 | def build_io_object 81 | _, w_pipe = IO.pipe 82 | w_pipe 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/network_restrictions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Uninterruptible::NetworkRestrictions do 4 | let(:config) { simple_configuration } 5 | 6 | describe '.new' do 7 | it 'accepts a server configuration' do 8 | network_restrictions = described_class.new(config) 9 | 10 | expect(network_restrictions.configuration).to eq(config) 11 | end 12 | 13 | it 'raises a configuration error if the configuration is not for a TCP server and restrictions have been made' do 14 | config.bind = "unix:///tmp/testsocket.sock" 15 | config.allowed_networks = allowed_networks 16 | 17 | expect { described_class.new(config) }.to raise_error(Uninterruptible::ConfigurationError) 18 | end 19 | end 20 | 21 | describe '#connection_allowed_from?' do 22 | it 'returns true if now allowed networks are configured' do 23 | network_restrictions = described_class.new(config) 24 | expect(network_restrictions.connection_allowed_from?('127.0.0.1')).to be true 25 | end 26 | 27 | describe 'when allowed networks are configured' do 28 | let(:config) { restricted_configuration } 29 | let(:network_restrictions) { described_class.new(config) } 30 | 31 | it 'returns true if the connecting address is in the included networks' do 32 | allowed_addresses.each do |allowed_address| 33 | expect(network_restrictions.connection_allowed_from?(allowed_address)).to be true 34 | end 35 | end 36 | 37 | it 'returns false if the connecting address is not in the included networks' do 38 | disallowed_addresses.each do |disallowed_addresses| 39 | expect(network_restrictions.connection_allowed_from?(disallowed_addresses)).to be false 40 | end 41 | end 42 | end 43 | end 44 | 45 | def allowed_networks 46 | ['127.0.0.0/8', '192.168.23.0/24', '2001:db8::/32'] 47 | end 48 | 49 | def allowed_addresses 50 | ['127.0.0.1', '127.254.254.254', '192.168.23.1', '192.168.23.254', '2001:db8::12'] 51 | end 52 | 53 | def disallowed_addresses 54 | ['8.8.8.8', '126.0.0.1', '192.168.0.1', '192.168.24.254', '2001:db7::12'] 55 | end 56 | 57 | def simple_configuration 58 | Uninterruptible::Configuration.new.tap do |config| 59 | config.bind = "tcp://127.0.0.1:6626" 60 | end 61 | end 62 | 63 | def restricted_configuration 64 | Uninterruptible::Configuration.new.tap do |config| 65 | config.bind = "tcp://127.0.0.1:6626" 66 | config.allowed_networks = allowed_networks 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Not a lot of publically accessible API, test what's there and do a more functional test with EchoServer 4 | RSpec.describe Uninterruptible::Server do 5 | let(:server_class) { Class.new { include Uninterruptible::Server } } 6 | let(:server) { server_class.new } 7 | 8 | describe '#handle_request' do 9 | it 'raises NotImplementedError' do 10 | expect { server.handle_request(nil) }.to raise_error(NotImplementedError) 11 | end 12 | end 13 | 14 | describe "#configure" do 15 | it 'yields the configuration for the server' do 16 | expect { |b| server.configure(&b) }.to yield_with_args(Uninterruptible::Configuration) 17 | end 18 | end 19 | 20 | describe '#run' do 21 | it 'raises a configuration error when the server is unconfigured' do 22 | expect { server.run }.to raise_error(Uninterruptible::ConfigurationError) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 4 | require 'uninterruptible' 5 | Dir[File.expand_path('../support/**/*.rb', __FILE__)].each { |f| require f } 6 | -------------------------------------------------------------------------------- /spec/support/echo_server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift File.expand_path('../../../lib', __FILE__) 4 | require 'uninterruptible' 5 | 6 | # Simple server, writes back what it reads 7 | class EchoServer 8 | include Uninterruptible::Server 9 | 10 | def handle_request(client_socket) 11 | data_to_echo = client_socket.gets 12 | client_socket.puts(data_to_echo) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/echo_server_controls.rb: -------------------------------------------------------------------------------- 1 | # Include this module in your tests to start and stop an echo server for testing purposes. 2 | module EchoServerControls 3 | def start_echo_server(server_name) 4 | cleanup_echo_server 5 | 6 | fork do 7 | exec({ "PID_FILE" => echo_pid_path }, "bundle exec spec/support/#{server_name}") 8 | end 9 | 10 | # Wait for the pidfile to appear so we know the server is started 11 | sleep 0.1 until File.exist?(echo_pid_path) 12 | end 13 | 14 | def stop_echo_server 15 | Process.kill("TERM", current_echo_pid) 16 | cleanup_echo_server 17 | end 18 | 19 | def current_echo_pid 20 | File.read(echo_pid_path).to_i 21 | end 22 | 23 | def echo_pid_path 24 | File.expand_path("../echo_server.pid", __FILE__) 25 | end 26 | 27 | def cleanup_echo_server 28 | File.delete(echo_pid_path) if File.exist?(echo_pid_path) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/environmental_controls.rb: -------------------------------------------------------------------------------- 1 | # Helpers to assist with setting and unsetting environment variables 2 | module EnvironmentalControls 3 | # Set some environment options, yield to a block and then put everything back as it was. 4 | # 5 | # @params [Hash] env_options Hash of environment variables to be set 6 | def within_env(env_options) 7 | # Keep a note of what the environment variables are before we set them 8 | old_env_options = env_options.keys.each_with_object({}) { |env_key, hsh| hsh[env_key] = ENV[env_key] } 9 | 10 | self.environment = env_options 11 | yield if block_given? 12 | ensure 13 | # Put the original environment back 14 | self.environment = old_env_options 15 | end 16 | 17 | # Set the environment variables 18 | # 19 | # @params [Hash] env_options Hash of environment variables to be set 20 | def environment=(env_options) 21 | env_options.each do |key, value| 22 | ENV[key] = value 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/tcp_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative './echo_server' 4 | 5 | tcp_server = EchoServer.new 6 | tcp_server.configure do |config| 7 | config.start_command = 'spec/support/tcp_server' 8 | config.bind = 'tcp://127.0.0.1:6789' 9 | config.log_level = Logger::FATAL 10 | config.allowed_networks = ['127.0.0.1/8', '::1/128'] 11 | end 12 | tcp_server.run 13 | -------------------------------------------------------------------------------- /spec/support/tls_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /spec/support/tls_configuration.rb: -------------------------------------------------------------------------------- 1 | # Easy access to tls configuration parameters 2 | module TLSConfiguration 3 | def valid_tls_configuration 4 | Uninterruptible::Configuration.new.tap do |config| 5 | config.bind = "tcp://127.0.0.1:6626" 6 | config.tls_key = tls_key 7 | config.tls_certificate = tls_certificate 8 | end 9 | end 10 | 11 | def tls_key 12 | File.read(File.expand_path('../tls_key.pem', __FILE__)) 13 | end 14 | 15 | def tls_certificate 16 | File.read(File.expand_path('../tls_cert.pem', __FILE__)) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/tls_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | h/QXoD1JiqeR6zt0gMR66JQgPjM4WhQ= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /spec/support/tls_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative './echo_server' 4 | require_relative './tls_configuration' 5 | 6 | include TLSConfiguration 7 | 8 | tls_server = EchoServer.new 9 | tls_server.configure do |config| 10 | config.start_command = 'spec/support/tls_server' 11 | config.bind = 'tcp://127.0.0.1:6789' 12 | config.log_level = Logger::FATAL 13 | config.allowed_networks = ['127.0.0.1/8', '::1/128'] 14 | config.tls_key = tls_key 15 | config.tls_certificate = tls_certificate 16 | config.tls_version = 'TLSv1_2' 17 | end 18 | tls_server.run 19 | -------------------------------------------------------------------------------- /spec/support/unix_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative './echo_server' 4 | 5 | unix_server = EchoServer.new 6 | unix_server.configure do |config| 7 | config.start_command = 'spec/support/unix_server' 8 | config.bind = 'unix:///tmp/echo_server.sock' 9 | config.log_level = Logger::FATAL 10 | end 11 | unix_server.run 12 | -------------------------------------------------------------------------------- /spec/tls_server_factory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Uninterruptible::TLSServerFactory do 4 | include TLSConfiguration 5 | 6 | describe '.new' do 7 | it 'accepts an Uninterruptible configuration' do 8 | factory = described_class.new(valid_tls_configuration) 9 | expect(factory).to be_a(described_class) 10 | end 11 | 12 | it 'raises an error if the configuration is for a unix server' do 13 | config = valid_tls_configuration 14 | config.bind = "unix:///tmp/mysocket.sock" 15 | 16 | expect { described_class.new(config) }.to raise_error(Uninterruptible::ConfigurationError) 17 | end 18 | 19 | it 'raises an error if the tls_key is not set' do 20 | config = valid_tls_configuration 21 | config.tls_key = nil 22 | 23 | expect { described_class.new(config) }.to raise_error(Uninterruptible::ConfigurationError) 24 | end 25 | 26 | it 'raises an error if the tls_certificate is not set' do 27 | config = valid_tls_configuration 28 | config.tls_certificate = nil 29 | 30 | expect { described_class.new(config) }.to raise_error(Uninterruptible::ConfigurationError) 31 | end 32 | end 33 | 34 | describe '#wrap_with_tls' do 35 | let(:factory) { described_class.new(valid_tls_configuration) } 36 | let(:tcp_server) { TCPServer.new('127.0.0.1', 6626) } 37 | 38 | it 'returns an OpenSSL::SSL::SSLServer' do 39 | expect(factory.wrap_with_tls(tcp_server)).to be_a(OpenSSL::SSL::SSLServer) 40 | tcp_server.close 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/uninterruptible_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Uninterruptible do 4 | it 'has a version number' do 5 | expect(Uninterruptible::VERSION).not_to be nil 6 | end 7 | 8 | it 'has the name of the environment variable file descriptor servers will be stored in' do 9 | expect(Uninterruptible::FILE_DESCRIPTOR_SERVER_VAR).to be_a(String) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /uninterruptible.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'uninterruptible/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "uninterruptible" 8 | spec.version = Uninterruptible::VERSION 9 | spec.authors = ["Dan Wentworth", "Charlie Smurthwaite"] 10 | spec.email = ["support@atechmedia.com"] 11 | 12 | spec.summary = "Zero-downtime restarts for your trivial socket servers" 13 | spec.description = "Uninterruptible gives your socket server magic restarting powers. Send your running "\ 14 | "Uninterruptible server USR1 and it will start a brand new copy of itself which will immediately start handling "\ 15 | "new requests while the old server stays alive until all of it's active connections are complete." 16 | spec.homepage = "https://github.com/darkphnx/uninterruptible" 17 | spec.license = "MIT" 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = "exe" 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_development_dependency "bundler", "~> 1.11" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "rspec", "~> 3.0" 27 | end 28 | --------------------------------------------------------------------------------