diff --git a/bin/smart-proxy b/bin/smart-proxy index 78e6c6282..5c6abb31d 100755 --- a/bin/smart-proxy +++ b/bin/smart-proxy @@ -3,4 +3,5 @@ $LOAD_PATH.unshift(*Dir[File.expand_path('../lib', __dir__), File.expand_path('../modules', __dir__)]) require 'smart_proxy_main' +require 'proxy/launcher' Proxy::Launcher.new.launch diff --git a/config.ru b/config.ru index 691ab4671..da9336468 100644 --- a/config.ru +++ b/config.ru @@ -1,5 +1,7 @@ -$LOAD_PATH.unshift *Dir[File.expand_path('lib', __dir__), File.expand_path('modules', __dir__)] +$LOAD_PATH.unshift(*Dir[File.expand_path('lib', __dir__), File.expand_path('modules', __dir__)]) require 'smart_proxy_main' -::Proxy::PluginInitializer.new(::Proxy::Plugins.instance).initialize_plugins -::Proxy::Plugins.instance.select { |p| p[:state] == :running && p[:https_enabled] }.each { |p| instance_eval(p[:class].https_rackup) } +require 'proxy/app' +plugins = ::Proxy::Plugins.instance +::Proxy::PluginInitializer.new(plugins).initialize_plugins +run ::Proxy::App.new(plugins) diff --git a/lib/proxy/app.rb b/lib/proxy/app.rb new file mode 100644 index 000000000..5ca001086 --- /dev/null +++ b/lib/proxy/app.rb @@ -0,0 +1,29 @@ +module Proxy + class App + def initialize(plugins) + @apps = {} + + http_plugins = plugins.select { |p| p[:state] == :running && p[:http_enabled] } + if http_plugins.any? + @apps['http'] = Rack::Builder.new do + http_plugins.each { |p| instance_eval(p[:class].http_rackup) } + end + end + + https_plugins = plugins.select { |p| p[:state] == :running && p[:https_enabled] } + if https_plugins.any? + @apps['https'] = Rack::Builder.new do + https_plugins.each { |p| instance_eval(p[:class].https_rackup) } + end + end + end + + def call(env) + # TODO: Respect X-Forwarded-Proto? + scheme = env['rack.url_scheme'] + app = @apps[scheme] + fail "Unsupported URL scheme #{scheme}" unless app + app.call(env) + end + end +end diff --git a/lib/proxy/launcher.rb b/lib/proxy/launcher.rb new file mode 100644 index 000000000..2568d3588 --- /dev/null +++ b/lib/proxy/launcher.rb @@ -0,0 +1,106 @@ +require 'proxy/log' +require 'proxy/settings' +require 'proxy/signal_handler' +require 'proxy/log_buffer/trace_decorator' + +CIPHERS = ['ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES256-GCM-SHA384', + 'AES128-GCM-SHA256', 'AES256-GCM-SHA384', 'AES128-SHA256', + 'AES256-SHA256', 'AES128-SHA', 'AES256-SHA'].freeze + +module Proxy + class Launcher + include ::Proxy::Log + + attr_reader :settings + + def initialize(settings = ::Proxy::SETTINGS) + @settings = settings + end + + def pid_path + settings.daemon_pid + end + + def http_enabled? + !settings.http_port.nil? + end + + def https_enabled? + settings.ssl_private_key && settings.ssl_certificate && settings.ssl_ca_file + end + + def plugins + ::Proxy::Plugins.instance + end + + def pid_status + return :exited unless File.exist?(pid_path) + pid = ::File.read(pid_path).to_i + return :dead if pid == 0 + Process.kill(0, pid) + :running + rescue Errno::ESRCH + :dead + rescue Errno::EPERM + :not_owned + end + + def check_pid + case pid_status + when :running, :not_owned + logger.error "A server is already running. Check #{pid_path}" + exit(2) + when :dead + File.delete(pid_path) + end + end + + def write_pid + FileUtils.mkdir_p(File.dirname(pid_path)) unless File.exist?(pid_path) + File.open(pid_path, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(Process.pid.to_s) } + at_exit { File.delete(pid_path) if File.exist?(pid_path) } + rescue Errno::EEXIST + check_pid + retry + end + + def ciphers + CIPHERS - settings.ssl_disabled_ciphers + end + + def launch + raise Exception.new("Both http and https are disabled, unable to start.") unless http_enabled? || https_enabled? + + if settings.daemon + check_pid + Process.daemon + write_pid + end + + ::Proxy::PluginInitializer.new(plugins).initialize_plugins + + case settings.http_server_type + when 'webrick' + require 'proxy/launcher/webrick' + launcher = ::Launcher::Webrick.new(self) + when 'puma' + require 'proxy/launcher/puma' + launcher = ::Launcher::Puma.new(self) + else + fail "Unknown http_server_type: #{settings.http_server_type}" + end + + launcher.launch + rescue SignalException => e + logger.debug("Caught #{e}. Exiting") + raise + rescue SystemExit + # do nothing. This is to prevent the exception handler below from catching SystemExit exceptions. + raise + rescue Exception => e + logger.error "Error during startup, terminating", e + puts "Errors detected on startup, see log for details. Exiting: #{e}" + exit(1) + end + end +end diff --git a/lib/proxy/launcher/puma.rb b/lib/proxy/launcher/puma.rb new file mode 100644 index 000000000..ebe4de403 --- /dev/null +++ b/lib/proxy/launcher/puma.rb @@ -0,0 +1,87 @@ +require 'puma' +require 'puma/configuration' +require 'proxy/app' + +module Launcher + class Puma + attr_reader :launcher + + def initialize(launcher) + @launcher = launcher + end + + def launch + ::Puma::Launcher.new(conf).run + end + + private + + def conf + ::Puma::Configuration.new do |user_config| + user_config.environment('production') + user_config.app(app) + + if launcher.http_enabled? + bind_hosts do |host| + user_config.bind "tcp://#{host}:#{settings.http_port}" + end + end + + if launcher.https_enabled? + ssl_options = { + key: settings.ssl_private_key, + cert: settings.ssl_certificate, + ca: settings.ssl_ca_file, + ssl_cipher_filter: launcher.ciphers.join(':'), + verify_mode: 'peer', + no_tlsv1: true, + no_tlsv1_1: true, + } + + bind_hosts do |host| + user_config.ssl_bind(host, settings.https_port, ssl_options) + end + end + + user_config.on_restart do + ::Proxy::LogBuffer::Decorator.instance.roll_log = true + end + + begin + user_config.plugin('systemd') + rescue ::Puma::UnknownPlugin + end + end + end + + def app + ::Proxy::App.new(launcher.plugins) + end + + def binds + end + + def bind_hosts + settings.bind_host.each do |host| + if host == '*' + yield ipv6_enabled? ? '[::]' : '0.0.0.0' + else + begin + addr = IPAddr.new(host) + yield addr.ipv6? ? "[#{addr}]" : addr.to_s + rescue IPAddr::InvalidAddressError + yield host + end + end + end + end + + def settings + launcher.settings + end + + def ipv6_enabled? + File.exist?('/proc/net/if_inet6') || (RUBY_PLATFORM =~ /cygwin|mswin|mingw|bccwin|wince|emx/) + end + end +end diff --git a/lib/launcher.rb b/lib/proxy/launcher/webrick.rb similarity index 54% rename from lib/launcher.rb rename to lib/proxy/launcher/webrick.rb index 020968e7e..8adc4e349 100644 --- a/lib/launcher.rb +++ b/lib/proxy/launcher/webrick.rb @@ -1,58 +1,56 @@ -require 'proxy/log' -require 'proxy/settings' -require 'proxy/signal_handler' -require 'proxy/log_buffer/trace_decorator' require 'sd_notify' -CIPHERS = ['ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES256-GCM-SHA384', - 'AES128-GCM-SHA256', 'AES256-GCM-SHA384', 'AES128-SHA256', - 'AES256-SHA256', 'AES128-SHA', 'AES256-SHA'].freeze - -module Proxy - class Launcher +module Launcher + class Webrick include ::Proxy::Log - attr_reader :settings + attr_reader :launcher - def initialize(settings = SETTINGS) - @settings = settings + def initialize(launcher) + @launcher = launcher end - def pid_path - settings.daemon_pid - end + def launch + http_app = build_http_app + https_app = build_https_app + install_webrick_callback!(http_app, https_app) - def http_enabled? - !settings.http_port.nil? - end + t1 = Thread.new { webrick_server(https_app, settings.bind_host, settings.https_port).start } unless https_app.nil? + t2 = Thread.new { webrick_server(http_app, settings.bind_host, settings.http_port).start } unless http_app.nil? - def https_enabled? - settings.ssl_private_key && settings.ssl_certificate && settings.ssl_ca_file - end + Proxy::SignalHandler.install_traps - def plugins - ::Proxy::Plugins.instance.select { |p| p[:state] == :running } + (t1 || t2).join end - def http_plugins - plugins.select { |p| p[:http_enabled] }.map { |p| p[:class] } + private + + def settings + launcher.settings end - def https_plugins - plugins.select { |p| p[:https_enabled] }.map { |p| p[:class] } + def webrick_server(app, addresses, port) + server = ::WEBrick::HTTPServer.new(app) + addresses.each { |a| server.listen(a, port) } + server.mount "/", Rack::Handler::WEBrick, app[:app] + server end - def http_app(http_port, plugins = http_plugins) - return nil unless http_enabled? + def build_http_app + return unless launcher.http_enabled? + + plugins = launcher.plugins.select { |p| p[:state] == :running && p[:http_enabled] } + return unless plugins.any? + app = Rack::Builder.new do - plugins.each { |p| instance_eval(p.http_rackup) } + plugins.each { |p| instance_eval(p[:class].http_rackup) } end { :app => app, :server => :webrick, :DoNotListen => true, - :Port => http_port, # only being used to correctly log http port being used + :Port => settings.http_port, # only being used to correctly log http port being used :Logger => ::Proxy::LogBuffer::TraceDecorator.instance, :AccessLog => [], :ServerSoftware => "foreman-proxy/#{Proxy::VERSION}", @@ -60,14 +58,17 @@ def http_app(http_port, plugins = http_plugins) } end - def https_app(https_port, plugins = https_plugins) - unless https_enabled? + def build_https_app + unless launcher.https_enabled? logger.warn "Missing SSL setup, https is disabled." - return nil + return end + plugins = launcher.plugins.select { |p| p[:state] == :running && p[:https_enabled] } + return unless plugins.any? + app = Rack::Builder.new do - plugins.each { |p| instance_eval(p.https_rackup) } + plugins.each { |p| instance_eval(p[:class].https_rackup) } end ssl_options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] @@ -78,7 +79,7 @@ def https_app(https_port, plugins = https_plugins) ssl_options |= OpenSSL::SSL::OP_NO_TLSv1 if defined?(OpenSSL::SSL::OP_NO_TLSv1) ssl_options |= OpenSSL::SSL::OP_NO_TLSv1_1 if defined?(OpenSSL::SSL::OP_NO_TLSv1_1) - Proxy::SETTINGS.tls_disabled_versions&.each do |version| + settings.tls_disabled_versions&.each do |version| constant = OpenSSL::SSL.const_get("OP_NO_TLSv#{version.to_s.tr('.', '_')}") rescue nil if constant @@ -93,7 +94,7 @@ def https_app(https_port, plugins = https_plugins) :app => app, :server => :webrick, :DoNotListen => true, - :Port => https_port, # only being used to correctly log https port being used + :Port => settings.https_port, # only being used to correctly log https port being used :Logger => ::Proxy::LogBuffer::Decorator.instance, :ServerSoftware => "foreman-proxy/#{Proxy::VERSION}", :SSLEnable => true, @@ -102,7 +103,7 @@ def https_app(https_port, plugins = https_plugins) :SSLCertificate => load_ssl_certificate(settings.ssl_certificate), :SSLCACertificateFile => settings.ssl_ca_file, :SSLOptions => ssl_options, - :SSLCiphers => CIPHERS - Proxy::SETTINGS.ssl_disabled_ciphers, + :SSLCiphers => launcher.ciphers, :daemonize => false, } end @@ -121,77 +122,6 @@ def load_ssl_certificate(path) raise e end - def pid_status - return :exited unless File.exist?(pid_path) - pid = ::File.read(pid_path).to_i - return :dead if pid == 0 - Process.kill(0, pid) - :running - rescue Errno::ESRCH - :dead - rescue Errno::EPERM - :not_owned - end - - def check_pid - case pid_status - when :running, :not_owned - logger.error "A server is already running. Check #{pid_path}" - exit(2) - when :dead - File.delete(pid_path) - end - end - - def write_pid - FileUtils.mkdir_p(File.dirname(pid_path)) unless File.exist?(pid_path) - File.open(pid_path, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(Process.pid.to_s) } - at_exit { File.delete(pid_path) if File.exist?(pid_path) } - rescue Errno::EEXIST - check_pid - retry - end - - def webrick_server(app, addresses, port) - server = ::WEBrick::HTTPServer.new(app) - addresses.each { |a| server.listen(a, port) } - server.mount "/", Rack::Handler::WEBrick, app[:app] - server - end - - def launch - raise Exception.new("Both http and https are disabled, unable to start.") unless http_enabled? || https_enabled? - - if settings.daemon - check_pid - Process.daemon - write_pid - end - - ::Proxy::PluginInitializer.new(::Proxy::Plugins.instance).initialize_plugins - - http_app = http_app(settings.http_port) - https_app = https_app(settings.https_port) - install_webrick_callback!(http_app, https_app) - - t1 = Thread.new { webrick_server(https_app, settings.bind_host, settings.https_port).start } unless https_app.nil? - t2 = Thread.new { webrick_server(http_app, settings.bind_host, settings.http_port).start } unless http_app.nil? - - Proxy::SignalHandler.install_traps - - (t1 || t2).join - rescue SignalException => e - logger.debug("Caught #{e}. Exiting") - raise - rescue SystemExit - # do nothing. This is to prevent the exception handler below from catching SystemExit exceptions. - raise - rescue Exception => e - logger.error "Error during startup, terminating", e - puts "Errors detected on startup, see log for details. Exiting: #{e}" - exit(1) - end - def install_webrick_callback!(*apps) apps.compact! diff --git a/lib/proxy/settings/global.rb b/lib/proxy/settings/global.rb index 6b227b329..a7b4e64a0 100644 --- a/lib/proxy/settings/global.rb +++ b/lib/proxy/settings/global.rb @@ -2,6 +2,7 @@ module ::Proxy::Settings class Global < ::OpenStruct DEFAULT_SETTINGS = { :settings_directory => Pathname.new(__dir__).join("..", "..", "..", "config", "settings.d").expand_path.to_s, + :http_server_type => 'puma', :https_port => 8443, :log_file => "/var/log/foreman-proxy/proxy.log", :file_rolling_keep => 6, diff --git a/lib/smart_proxy_for_testing.rb b/lib/smart_proxy_for_testing.rb index b8a8d9333..721753e94 100644 --- a/lib/smart_proxy_for_testing.rb +++ b/lib/smart_proxy_for_testing.rb @@ -21,7 +21,6 @@ require 'proxy/provider' require 'proxy/error' require 'proxy/request' -require 'launcher' require 'sinatra/base' require 'sinatra/authorization' diff --git a/lib/smart_proxy_main.rb b/lib/smart_proxy_main.rb index 6428cda65..b38dbb32e 100644 --- a/lib/smart_proxy_main.rb +++ b/lib/smart_proxy_main.rb @@ -1,7 +1,6 @@ APP_ROOT = "#{__dir__}/.." require 'smart_proxy' -require 'launcher' require 'fileutils' require 'pathname' diff --git a/test/launcher_test.rb b/test/launcher_test.rb index ef9cc7621..d621fa312 100644 --- a/test/launcher_test.rb +++ b/test/launcher_test.rb @@ -1,5 +1,5 @@ require 'test_helper' -require 'launcher' +require 'proxy/launcher' class LauncherTest < Test::Unit::TestCase def setup