From 01a51265bd1e5e1f942b681abd7923861d134cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20M=C3=BCller?= Date: Tue, 17 Apr 2018 12:46:22 +0200 Subject: [PATCH] Refactor `HTTP::Server` to bind to multiple addresses (#5776) * Refactor HTTP::Server to use multiple interfaces * Remove HTTP::Server#port and add HTTP::Server#bind_unused_port The return type is now an Socket::IPAddress instead of only the port number. * Rename `HTTP::Server#bind` to `#bind_tcp`, add overloads to `#listen` * Add HTTP::Server#addresses * fixup! Refactor HTTP::Server to use multiple interfaces * fixup! Refactor HTTP::Server to use multiple interfaces * Add HTTP::Server.listening? and raise errors when running or closed --- samples/http_server.cr | 4 +- spec/std/http/server/server_spec.cr | 132 ++++++++++--- spec/std/http/web_socket_spec.cr | 24 +-- .../crystal/tools/playground/server.cr | 10 +- .../tools/playground/views/_about.html | 3 +- src/http/formdata.cr | 3 +- src/http/server.cr | 183 ++++++++++++------ 7 files changed, 259 insertions(+), 100 deletions(-) diff --git a/samples/http_server.cr b/samples/http_server.cr index f885fb42d51a..9bcbd0b9c66d 100644 --- a/samples/http_server.cr +++ b/samples/http_server.cr @@ -1,9 +1,9 @@ require "http/server" -server = HTTP::Server.new "0.0.0.0", 8080 do |context| +server = HTTP::Server.new do |context| context.response.headers["Content-Type"] = "text/plain" context.response.print("Hello world!") end puts "Listening on http://0.0.0.0:8080" -server.listen +server.listen "0.0.0.0", 8080 diff --git a/spec/std/http/server/server_spec.cr b/spec/std/http/server/server_spec.cr index cd64fedcd376..c0c562433704 100644 --- a/spec/std/http/server/server_spec.cr +++ b/spec/std/http/server/server_spec.cr @@ -194,21 +194,19 @@ module HTTP end describe HTTP::Server do - it "re-sets special port zero after bind" do - server = Server.new(0) { |ctx| } - server.bind - server.port.should_not eq(0) - end - - it "re-sets port to zero after close" do - server = Server.new(0) { |ctx| } - server.bind - server.close - server.port.should eq(0) + it "binds to unused port" do + server = Server.new { |ctx| } + address = server.bind_unused_port + address.port.should_not eq(0) + + server = Server.new { |ctx| } + port = server.bind_tcp(0).port + port.should_not eq(0) end it "doesn't raise on accept after close #2692" do - server = Server.new("0.0.0.0", 0) { } + server = Server.new { } + server.bind_unused_port spawn do server.close @@ -219,15 +217,99 @@ module HTTP end it "reuses the TCP port (SO_REUSEPORT)" do - s1 = Server.new(0) { |ctx| } - s1.bind(reuse_port: true) + s1 = Server.new { |ctx| } + address = s1.bind_unused_port(reuse_port: true) - s2 = Server.new(s1.port) { |ctx| } - s2.bind(reuse_port: true) + s2 = Server.new { |ctx| } + s2.bind_tcp(address.port, reuse_port: true) s1.close s2.close end + + it "binds to different ports" do + server = Server.new do |context| + context.response.print "Test Server (#{context.request.headers["Host"]?})" + end + + tcp_server = TCPServer.new("127.0.0.1", 0) + server.bind tcp_server + address1 = tcp_server.local_address + + address2 = server.bind_unused_port + + address1.should_not eq address2 + + spawn { server.listen } + + Fiber.yield + + HTTP::Client.get("http://#{address2}/").body.should eq "Test Server (#{address2})" + HTTP::Client.get("http://#{address1}/").body.should eq "Test Server (#{address1})" + HTTP::Client.get("http://#{address1}/").body.should eq "Test Server (#{address1})" + end + + it "lists addresses" do + server = Server.new { } + + tcp_server = TCPServer.new("127.0.0.1", 0) + addresses = [server.bind_unused_port, server.bind_unused_port, tcp_server.local_address] + server.bind tcp_server + + server.addresses.should eq addresses + end + + describe "#bind" do + it "fails after listen" do + server = Server.new { } + server.bind_unused_port + spawn { server.listen } + Fiber.yield + expect_raises(Exception, "Can't add socket to running server") do + server.bind_unused_port + end + server.close + end + + it "fails after close" do + server = Server.new { } + server.bind_unused_port + spawn { server.listen } + Fiber.yield + server.close + expect_raises(Exception, "Can't add socket to closed server") do + server.bind_unused_port + end + server.close unless server.closed? + end + end + + describe "#listen" do + it "fails after listen" do + server = Server.new { } + server.bind_unused_port + spawn { server.listen } + Fiber.yield + server.listening?.should be_true + expect_raises(Exception, "Can't start running server") do + server.listen + end + server.close + end + + it "fails after close" do + server = Server.new { } + server.bind_unused_port + spawn { server.listen } + Fiber.yield + server.listening?.should be_true + server.close + server.listening?.should be_false + expect_raises(Exception, "Can't re-start closed server") do + server.listen + end + end + end end describe HTTP::Server::RequestProcessor do @@ -310,40 +392,46 @@ module HTTP typeof(begin # Initialize with custom host - server = Server.new("0.0.0.0", 0) { |ctx| } + server = Server.new { |ctx| } + server.bind_tcp "0.0.0.0", 0 server.listen server.close - server = Server.new("0.0.0.0", 0, [ + server = Server.new([ ErrorHandler.new, LogHandler.new, CompressHandler.new, StaticFileHandler.new("."), ] ) + server.bind_tcp "0.0.0.0", 0 server.listen server.close - server = Server.new("0.0.0.0", 0, [StaticFileHandler.new(".")]) { |ctx| } + server = Server.new([StaticFileHandler.new(".")]) { |ctx| } + server.bind_tcp "0.0.0.0", 0 server.listen server.close # Initialize with default host - server = Server.new(0) { |ctx| } + server = Server.new { |ctx| } + server.bind_tcp 0 server.listen server.close - server = Server.new(0, [ + server = Server.new([ ErrorHandler.new, LogHandler.new, CompressHandler.new, StaticFileHandler.new("."), ] ) + server.bind_tcp 0 server.listen server.close - server = Server.new(0, [StaticFileHandler.new(".")]) { |ctx| } + server = Server.new([StaticFileHandler.new(".")]) { |ctx| } + server.bind_tcp 0 server.listen server.close end) diff --git a/spec/std/http/web_socket_spec.cr b/spec/std/http/web_socket_spec.cr index 81cd6a460970..77a6119855de 100644 --- a/spec/std/http/web_socket_spec.cr +++ b/spec/std/http/web_socket_spec.cr @@ -295,7 +295,7 @@ describe HTTP::WebSocket do end it "negotiates over HTTP correctly" do - port_chan = Channel(Int32).new + address_chan = Channel(Socket::IPAddress).new spawn do http_ref = nil @@ -313,15 +313,15 @@ describe HTTP::WebSocket do end end - http_server = http_ref = HTTP::Server.new(0, [ws_handler]) - http_server.bind - port_chan.send(http_server.port) + http_server = http_ref = HTTP::Server.new([ws_handler]) + address = http_server.bind_unused_port + address_chan.send(address) http_server.listen end - listen_port = port_chan.receive + listen_address = address_chan.receive - ws2 = HTTP::WebSocket.new("ws://127.0.0.1:#{listen_port}/foo/bar?query=arg&yes=please") + ws2 = HTTP::WebSocket.new("ws://#{listen_address}/foo/bar?query=arg&yes=please") random = Random::Secure.hex ws2.on_message do |str| @@ -334,7 +334,7 @@ describe HTTP::WebSocket do end it "negotiates over HTTPS correctly" do - port_chan = Channel(Int32).new + address_chan = Channel(Socket::IPAddress).new spawn do http_ref = nil @@ -350,19 +350,19 @@ describe HTTP::WebSocket do end end - http_server = http_ref = HTTP::Server.new(0, [ws_handler]) + http_server = http_ref = HTTP::Server.new([ws_handler]) tls = http_server.tls = OpenSSL::SSL::Context::Server.new tls.certificate_chain = File.join(__DIR__, "../openssl/ssl/openssl.crt") tls.private_key = File.join(__DIR__, "../openssl/ssl/openssl.key") - http_server.bind - port_chan.send(http_server.port) + address = http_server.bind_unused_port + address_chan.send(address) http_server.listen end - listen_port = port_chan.receive + listen_address = address_chan.receive client_context = OpenSSL::SSL::Context::Client.insecure - ws2 = HTTP::WebSocket.new("127.0.0.1", port: listen_port, path: "/", tls: client_context) + ws2 = HTTP::WebSocket.new(listen_address.address, port: listen_address.port, path: "/", tls: client_context) random = Random::Secure.hex ws2.on_message do |str| diff --git a/src/compiler/crystal/tools/playground/server.cr b/src/compiler/crystal/tools/playground/server.cr index e25292450783..ee26f93338b1 100644 --- a/src/compiler/crystal/tools/playground/server.cr +++ b/src/compiler/crystal/tools/playground/server.cr @@ -503,15 +503,17 @@ module Crystal::Playground HTTP::StaticFileHandler.new(public_dir), ] + server = HTTP::Server.new handlers + host = @host if host - server = HTTP::Server.new host, @port, handlers + address = server.bind_tcp host, @port else - server = HTTP::Server.new @port, handlers - host = "localhost" + address = server.bind_tcp @port end + @port = address.port - puts "Listening on http://#{host}:#{@port}" + puts "Listening on http://#{address}" if host == "0.0.0.0" puts "WARNING running playground with 0.0.0.0 is unsecure." end diff --git a/src/compiler/crystal/tools/playground/views/_about.html b/src/compiler/crystal/tools/playground/views/_about.html index b690355d320b..c4b576c69d0b 100644 --- a/src/compiler/crystal/tools/playground/views/_about.html +++ b/src/compiler/crystal/tools/playground/views/_about.html @@ -60,11 +60,12 @@

Usage

 require "http/server"
 
-server = HTTP::Server.new "0.0.0.0", 5678 do |context|
+server = HTTP::Server.new do |context|
   context.request.path
   context.response.headers["Content-Type"] = "text/plain"
   context.response.print("Hello world!")
 end
+server.bind "0.0.0.0", 5678
 
 puts "Listening on http://0.0.0.0:5678"
 server.listen
diff --git a/src/http/formdata.cr b/src/http/formdata.cr index 70e8a58f3aba..0aa455329aa4 100644 --- a/src/http/formdata.cr +++ b/src/http/formdata.cr @@ -12,7 +12,7 @@ require "./formdata/**" # require "http" # require "tempfile" # -# server = HTTP::Server.new(8085) do |context| +# server = HTTP::Server.new do |context| # name = nil # file = nil # HTTP::FormData.parse(context.request) do |part| @@ -34,6 +34,7 @@ require "./formdata/**" # context.response << file.path # end # +# server.bind 8085 # server.listen # ``` # diff --git a/src/http/server.cr b/src/http/server.cr index b117d8420c18..b038a2fe9ab3 100644 --- a/src/http/server.cr +++ b/src/http/server.cr @@ -33,11 +33,12 @@ require "./common" # ``` # require "http/server" # -# server = HTTP::Server.new(8080) do |context| +# server = HTTP::Server.new do |context| # context.response.content_type = "text/plain" # context.response.print "Hello world!" # end # +# server.bind 8080 # puts "Listening on http://127.0.0.1:8080" # server.listen # ``` @@ -47,11 +48,12 @@ require "./common" # ``` # require "http/server" # -# server = HTTP::Server.new("0.0.0.0", 8080) do |context| +# server = HTTP::Server.new do |context| # context.response.content_type = "text/plain" # context.response.print "Hello world!" # end # +# server.bind "0.0.0.0", 8080 # puts "Listening on http://0.0.0.0:8080" # server.listen # ``` @@ -63,12 +65,15 @@ require "./common" # ``` # require "http/server" # -# HTTP::Server.new("127.0.0.1", 8080, [ +# server = HTTP::Server.new([ # HTTP::ErrorHandler.new, # HTTP::LogHandler.new, # HTTP::CompressHandler.new, # HTTP::StaticFileHandler.new("."), -# ]).listen +# ]) +# +# server.bind "127.0.0.1", 8080 +# server.listen # ``` # # ### Add handlers and block @@ -78,15 +83,15 @@ require "./common" # ``` # require "http/server" # -# server = HTTP::Server.new("0.0.0.0", 8080, -# [ -# HTTP::ErrorHandler.new, -# HTTP::LogHandler.new, -# ]) do |context| +# server = HTTP::Server.new([ +# HTTP::ErrorHandler.new, +# HTTP::LogHandler.new, +# ]) do |context| # context.response.content_type = "text/plain" # context.response.print "Hello world!" # end # +# server.bind "0.0.0.0", 8080 # server.listen # ``` class HTTP::Server @@ -94,91 +99,153 @@ class HTTP::Server property tls : OpenSSL::SSL::Context::Server? {% end %} - @wants_close = false + @sockets = [] of Socket::Server - def self.new(port, &handler : Context ->) - new("127.0.0.1", port, &handler) - end + # Returns `true` if this server is closed. + getter? closed : Bool = false - def self.new(port, handlers : Array(HTTP::Handler), &handler : Context ->) - new("127.0.0.1", port, handlers, &handler) - end + # Returns `true` if this server is listening on it's sockets. + getter? listening : Bool = false - def self.new(port, handlers : Array(HTTP::Handler)) - new("127.0.0.1", port, handlers) + # Creates a new HTTP server with the given block as handler. + def self.new(&handler : HTTP::Handler::Proc) : self + new(handler) end - def self.new(port, handler) - new("127.0.0.1", port, handler) + # Creates a new HTTP server with a handler chain constructed from the *handlers* + # array and the given block. + def self.new(handlers : Array(HTTP::Handler), &handler : HTTP::Handler::Proc) : self + new(HTTP::Server.build_middleware(handlers, handler)) end - def initialize(@host : String, @port : Int32, &handler : Context ->) - @processor = RequestProcessor.new(handler) + # Creates a new HTTP server with the *handlers* array as handler chain. + def self.new(handlers : Array(HTTP::Handler)) : self + new(HTTP::Server.build_middleware(handlers)) end - def initialize(@host : String, @port : Int32, handlers : Array(HTTP::Handler), &handler : Context ->) - handler = HTTP::Server.build_middleware handlers, handler + # Creates a new HTTP server with the given *handler*. + def initialize(handler : HTTP::Handler | HTTP::Handler::Proc) @processor = RequestProcessor.new(handler) end - def initialize(@host : String, @port : Int32, handlers : Array(HTTP::Handler)) - handler = HTTP::Server.build_middleware handlers - @processor = RequestProcessor.new(handler) + # Creates a `TCPServer` listenting on `host:port` and adds it as a socket, returning the local address + # and port the server listens on. + # + # If *reuse_port* is `true`, it enables the `SO_REUSEPORT` socket option, + # which allows multiple processes to bind to the same port. + def bind_tcp(host : String, port : Int32, reuse_port : Bool = false) : Socket::IPAddress + tcp_server = TCPServer.new(host, port, reuse_port: reuse_port) + + bind(tcp_server) + + tcp_server.local_address end - def initialize(@host : String, @port : Int32, handler : HTTP::Handler | HTTP::Handler::Proc) - @processor = RequestProcessor.new(handler) + # Creates a `TCPServer` listenting on `127.0.0.1:port` and adds it as a socket, + # returning the local address and port the server listens on. + # + # If *reuse_port* is `true`, it enables the `SO_REUSEPORT` socket option, + # which allows multiple processes to bind to the same port. + def bind_tcp(port : Int32, reuse_port : Bool = false) : Socket::IPAddress + bind_tcp "127.0.0.1", port, reuse_port end - # Returns the TCP port the server is connected to. + # Creates a `TCPServer` listening on an unused port and adds it as a socket. + # + # Returns the `Socket::IPAddress` with the determined port number. # - # For example you may let the system choose a port, then report it: # ``` - # server = HTTP::Server.new(0) { } - # server.bind - # server.port # => 12345 + # server = HTTP::Server.new { } + # server.bind_unused_port # => Socket::IPAddress.new("127.0.0.1", 12345) # ``` - def port - if server = @server - server.local_address.port.to_i - else - @port + def bind_unused_port(host : String = "127.0.0.1", reuse_port : Bool = false) : Socket::IPAddress + bind_tcp host, 0, reuse_port + end + + # Adds a `Socket::Server` *socket* to this server. + def bind(socket : Socket::Server) : Nil + raise "Can't add socket to running server" if listening? + raise "Can't add socket to closed server" if closed? + + @sockets << socket + end + + # Enumerates all addresses this server is bound to. + def each_address(&block : Socket::Address ->) + @sockets.each do |socket| + yield socket.local_address end end - # Creates the underlying `TCPServer` if the doesn't already exist. + def addresses : Array(Socket::Address) + array = [] of Socket::Address + each_address do |address| + array << address + end + array + end + + # Creates a `TCPServer` listenting on `127.0.0.1:port`, adds it as a socket + # and starts the server. Blocks until the server is closed. # - # You may set *reuse_port* to true to enable the `SO_REUSEPORT` socket option, - # which allows multiple processes to bind to the same port. - def bind(reuse_port = false) - @server ||= TCPServer.new(@host, @port, reuse_port: reuse_port) + # See `#bind(port : Int32)` for details. + def listen(port : Int32, reuse_port : Bool = false) + bind_tcp(port, reuse_port) + + listen end - # Starts the server. Blocks until the server is closed. + # Creates a `TCPServer` listenting on `host:port`, adds it as a socket + # and starts the server. Blocks until the server is closed. # - # See `#bind` for details on the *reuse_port* argument. - def listen(reuse_port = false) - server = bind(reuse_port) - until @wants_close - spawn handle_client(server.accept?) + # See `#bind(host : String, port : Int32)` for details. + def listen(host : String, port : Int32, reuse_port : Bool = false) + bind_tcp(host, port, reuse_port) + + listen + end + + # Starts the server. Blocks until the server is closed. + def listen + raise "Can't re-start closed server" if closed? + raise "Can't start server with no sockets to listen to, use HTTP::Server#bind first" if @sockets.empty? + raise "Can't start running server" if listening? + + @listening = true + done = Channel(Nil).new + + @sockets.each do |socket| + spawn do + until closed? + spawn handle_client(socket.accept? || break) + end + ensure + done.send nil + end end + + @sockets.size.times { done.receive } end # Gracefully terminates the server. It will process currently accepted # requests, but it won't accept new connections. def close - @wants_close = true + raise "Can't close server, it's already closed" if closed? + + @closed = true @processor.close - if server = @server - server.close - @server = nil + + @sockets.each do |socket| + socket.close + rescue + # ignore exception on close end - end - private def handle_client(io) - # nil means the server was closed - return unless io + @listening = false + @sockets.clear + end + private def handle_client(io : IO) io.sync = false {% if !flag?(:without_openssl) %}