diff options
author | Kazuki Yamaguchi <k@rhe.jp> | 2015-08-14 17:31:33 +0900 |
---|---|---|
committer | Kazuki Yamaguchi <k@rhe.jp> | 2015-08-14 17:31:33 +0900 |
commit | 881f8c942d450e28db1bc85928068a22ce37a656 (patch) | |
tree | 489528238e7258ca95b515017e51e256e32ba40d | |
parent | cdebfcf496dd65cff5d52667b625d49d1643bf16 (diff) | |
download | plum-881f8c942d450e28db1bc85928068a22ce37a656.tar.gz |
add support for HTTP/2 over TCP ('http' URI scheme)
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | examples/local_server.rb | 1 | ||||
-rw-r--r-- | examples/non_tls_server.rb | 115 | ||||
-rw-r--r-- | lib/plum.rb | 1 | ||||
-rw-r--r-- | lib/plum/connection.rb | 26 | ||||
-rw-r--r-- | lib/plum/http_connection.rb | 88 | ||||
-rw-r--r-- | lib/plum/https_connection.rb | 15 | ||||
-rw-r--r-- | plum.gemspec | 1 |
8 files changed, 211 insertions, 38 deletions
@@ -4,9 +4,9 @@ A minimal implementation of HTTP/2 server. ## Requirements * OpenSSL 1.0.2+ * Ruby 2.2 with [ALPN support](https://gist.github.com/rhenium/b1711edcc903e8887a51) and [ECDH support (r51348)](https://bugs.ruby-lang.org/projects/ruby-trunk/repository/revisions/51348/diff?format=diff) or latest Ruby 2.3.0-dev. +* [http-parser.rb gem](https://rubygems.org/gems/http_parser.rb) if you use "http" URI scheme. ## TODO -* "http" URIs support (upgrade from HTTP/1.1) * Stream Priority (RFC 7540 5.3) * Better API diff --git a/examples/local_server.rb b/examples/local_server.rb index c844a87..a777174 100644 --- a/examples/local_server.rb +++ b/examples/local_server.rb @@ -11,6 +11,7 @@ CONTENT_TYPES = { /\.jpg$/ => "image/jpeg", /\.css$/ => "text/css", /\.js$/ => "application/javascript", + /\.atom$/ => "application/atom+xml", } $LOAD_PATH << File.expand_path("../../lib", __FILE__) diff --git a/examples/non_tls_server.rb b/examples/non_tls_server.rb new file mode 100644 index 0000000..804e3d2 --- /dev/null +++ b/examples/non_tls_server.rb @@ -0,0 +1,115 @@ +$LOAD_PATH << File.expand_path("../../lib", __FILE__) +require "plum" +require "socket" +require "cgi" + +def log(con, stream, s) + prefix = "[%02x;%02x] " % [con, stream] + if s.is_a?(Enumerable) + puts s.map {|a| prefix + a.to_s }.join("\n") + else + puts prefix + s.to_s + end +end + +tcp_server = TCPServer.new("0.0.0.0", 40080) + +loop do + begin + sock = tcp_server.accept + id = sock.fileno + puts "#{id}: accept!" + rescue => e + STDERR.puts e + next + end + + plum = Plum::HTTPConnection.new(sock) + + plum.on(:frame) do |frame| + log(id, frame.stream_id, "recv: #{frame.inspect}") + end + + plum.on(:send_frame) do |frame| + log(id, frame.stream_id, "send: #{frame.inspect}") + end + + plum.on(:connection_error) do |exception| + puts exception + puts exception.backtrace + end + + plum.on(:stream) do |stream| + stream.on(:stream_error) do |exception| + puts exception + puts exception.backtrace + end + + stream.on(:send_deferred) do |frame| + log(id, frame.stream_id, "send (deferred): #{frame.inspect}") + end + + headers = data = nil + + stream.on(:open) do + headers = nil + data = "" + end + + stream.on(:headers) do |headers_| + log(id, stream.id, headers_.map {|name, value| "#{name}: #{value}" }) + headers = headers_.to_h + end + + stream.on(:data) do |data_| + log(id, stream.id, data_) + data << data_ + end + + stream.on(:end_stream) do + case [headers[":method"], headers[":path"]] + when ["GET", "/"] + body = "Hello World! <a href=/abc.html>ABC</a> <a href=/fgsd>Not found</a>" + body << <<-EOF + <form action=post.page method=post> + <input type=text name=key value=default_value> + <input type=submit> + </form> + EOF + stream.respond({ + ":status": "200", + "server": "plum", + "content-type": "text/html", + "content-length": body.size + }, body) + when ["POST", "/post.page"] + body = "Posted value is: #{CGI.unescape(data).gsub("<", "<").gsub(">", ">")}<br> <a href=/>Back to top page</a>" + stream.respond({ + ":status": "200", + "server": "plum", + "content-type": "text/html", + "content-length": body.size + }, body) + else + body = "Page not found! <a href=/>Back to top page</a>" + stream.respond({ + ":status": "404", + "server": "plum", + "content-type": "text/html", + "content-length": body.size + }, body) + end + end + end + + Thread.new { + begin + plum.run + rescue + puts $! + puts $!.backtrace + ensure + sock.close + end + } +end diff --git a/lib/plum.rb b/lib/plum.rb index 8842d6b..2715ccc 100644 --- a/lib/plum.rb +++ b/lib/plum.rb @@ -1,5 +1,6 @@ require "openssl" require "socket" +require "base64" require "plum/version" require "plum/errors" require "plum/binary_string" diff --git a/lib/plum/connection.rb b/lib/plum/connection.rb index 32a7d2e..2c2536a 100644 --- a/lib/plum/connection.rb +++ b/lib/plum/connection.rb @@ -65,10 +65,6 @@ module Plum receive_frame(frame) end end - rescue ConnectionError => e - callback(:connection_error, e) - goaway(e.http2_error_type) - close end alias << receive @@ -87,8 +83,20 @@ module Plum @io.write(frame.assemble) end + def negotiate! + if CLIENT_CONNECTION_PREFACE.start_with?(@buffer.byteslice(0, 24)) + if @buffer.bytesize >= 24 + @buffer.byteshift(24) + @state = :waiting_settings + settings(@local_settings) + end + else + raise ConnectionError.new(:protocol_error) # (MAY) send GOAWAY. sending. + end + end + def new_stream(stream_id, **args) - if @streams.size > 0 && @streams.keys.last >= stream_id + if @streams.size > 0 && @streams.keys.max >= stream_id raise Plum::ConnectionError.new(:protocol_error) end @@ -140,6 +148,10 @@ module Plum end stream.receive_frame(frame) end + rescue ConnectionError => e + callback(:connection_error, e) + goaway(e.http2_error_type) + close end def receive_control_frame(frame) @@ -164,7 +176,7 @@ module Plum end end - def receive_settings(frame) + def receive_settings(frame, send_ack: true) if frame.flags.include?(:ack) raise ConnectionError.new(:frame_size_error) if frame.length != 0 return @@ -178,7 +190,7 @@ module Plum callback(:remote_settings, @remote_settings, old_remote_settings) - send_immediately Frame.settings(:ack) + send_immediately Frame.settings(:ack) if send_ack end def apply_remote_settings(old_remote_settings) diff --git a/lib/plum/http_connection.rb b/lib/plum/http_connection.rb index bde8b7d..e5e63f6 100644 --- a/lib/plum/http_connection.rb +++ b/lib/plum/http_connection.rb @@ -1,33 +1,91 @@ +using Plum::BinaryString + module Plum class HTTPConnection < Connection def initialize(io, local_settings = {}) + require "http/parser" super + @_http_parser = setup_parser + @_upgrade_retry = false # After sent 426 (Upgrade Required) end private - def negotiate! - if @buffer.bytesize >= 4 - if CLIENT_CONNECTION_PREFACE.start_with?(@buffer) - negotiate_with_knowledge + def setup_parser + headers = nil + body = "" + parser = HTTP::Parser.new + parser.on_message_begin = proc { } + parser.on_headers_complete = proc {|_headers| headers = _headers } + parser.on_body = proc {|chunk| body << chunk } + parser.on_message_complete = proc do |env| + # Upgrade from HTTP/1.1 + heads = headers.map {|n, v| [n.downcase, v] }.to_h + connection = heads["connection"] || "" + upgrade = heads["upgrade"] || "" + settings = heads["http2-settings"] + + if (connection.split(", ").sort == ["Upgrade", "HTTP2-Settings"].sort && + upgrade.split(", ").include?("h2c") && + settings != nil) + respond_switching_protocol + self.on(:negotiated) { + _frame = Frame.new(type: :settings, stream_id: 0, payload: Base64.urlsafe_decode64(settings)) + receive_settings(_frame, send_ack: false) # HTTP2-Settings + process_first_request(parser, heads, body) + } else - negotiate_with_upgrade + respond_not_supported + close end end - # next + + parser end - def negotiate_with_knowledge - if @buffer.bytesize >= 24 - if @buffer.byteshift(24) == CLIENT_CONNECTION_PREFACE - @state = :waiting_settings - settings(@local_settings) - end + def negotiate! + begin + super + rescue ConnectionError + # Upgrade from HTTP/1.1 + offset = @_http_parser << @buffer + @buffer.byteshift(offset) end - # next end - def negotiate_with_upgrade - raise NotImplementedError, "Parsing HTTP/1.1 is hard..." + def respond_switching_protocol + resp = "" + resp << "HTTP/1.1 101 Switching Protocols\r\n" + resp << "Connection: Upgrade\r\n" + resp << "Upgrade: h2c\r\n" + resp << "\r\n" + + io.write(resp) + end + + def respond_not_supported + data = "Use modern web browser with HTTP/2 support." + + resp = "" + resp << "HTTP/1.1 505 HTTP Version Not Supported\r\n" + resp << "Content-Type: text/plain\r\n" + resp << "Content-Length: #{data.bytesize}\r\n" + resp << "\r\n" + resp << data + + io.write(resp) + end + + def process_first_request(parser, heads, dat) + stream = new_stream(1) + heads = heads.merge({ ":method" => parser.http_method, + ":path" => parser.request_url, + ":authority" => heads["host"] }) + .reject {|n, v| ["connection", "http2-settings", "upgrade", "host"].include?(n) } + encoder = HPACK::Encoder.new(0, indexing: false) # don't pollute connection's HPACK context + headers = Frame.headers(1, encoder.encode(heads), :end_headers) # stream ID is 1 + headers.split_headers(local_settings[:max_frame_size]).each {|hfrag| stream.receive_frame(hfrag) } + data = Frame.data(1, dat, :end_stream) + data.split_data(local_settings[:max_frame_size]).each {|dfrag| stream.receive_frame(dfrag) } end end end diff --git a/lib/plum/https_connection.rb b/lib/plum/https_connection.rb index d05da40..e96a785 100644 --- a/lib/plum/https_connection.rb +++ b/lib/plum/https_connection.rb @@ -5,20 +5,5 @@ module Plum def initialize(io, local_settings = {}) super end - - private - def negotiate! - return if @buffer.empty? - - if CLIENT_CONNECTION_PREFACE.start_with?(@buffer.byteslice(0, 24)) - if @buffer.bytesize >= 24 - @buffer.byteshift(24) - @state = :waiting_settings - settings(@local_settings) - end - else - raise ConnectionError.new(:protocol_error) # (MAY) send GOAWAY. sending. - end - end end end diff --git a/plum.gemspec b/plum.gemspec index 2292b62..f8bf2a5 100644 --- a/plum.gemspec +++ b/plum.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_development_dependency "bundler", "~> 1.10" + spec.add_development_dependency "http-parser.rb" spec.add_development_dependency "rake" spec.add_development_dependency "yard" spec.add_development_dependency "minitest", "~> 5.7.0" |