h2 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09291052e9be6853fdbc3a57b31516e8a61b6a3a8a3e11e0d46215d4ec14cf7c'
4
- data.tar.gz: a18029d8019099fa7908163981f4a03da9adff28c0a6c0851626fd852846c211
3
+ metadata.gz: b2ce1f4f619d3bd9eb680ad49ad0181ef4a5e81b0a9fa07f0b8e544b85b77028
4
+ data.tar.gz: 34de290127df74e156b02e595f2b1ea91ab3da6edf38cfff263a4734e16297cc
5
5
  SHA512:
6
- metadata.gz: e0252b16b333e1218f0869a876f4687cb0513b0efe82af6fcc7ceedc140fa97dd5a30d7a22ca32d3d9894ce20669b6a34fb17fc56661e1d6676b421f7ca24a97
7
- data.tar.gz: de69b22e572d2498ad4cc085ec821fab75043119dcaa219fe4fba48b9c2d5aac9552534274b39629d209a5aa6859e29b9562f6e193e54c9db435f97a8ad2f65a
6
+ metadata.gz: 2a89223df4da4f8effb784628e711bd9341b63b096ecfa5d1bb970f2d9a447c59e51871167c7da1f48eb3fc70436970fe64a84400ab93ab7e835e7d165cc203b
7
+ data.tar.gz: 81cd03234bc8eff7a0acc6d69105ba5cf9599b8ad9b8193c28679d6c1fb504a612cb9c5062bba7f4c3ebe5448636908c8c87e75666de0575b78bb28d9a4b0e8f
data/Gemfile CHANGED
@@ -12,7 +12,8 @@ end
12
12
 
13
13
  group :development, :test do
14
14
  gem 'awesome_print'
15
+ gem 'certificate_authority'
15
16
  gem 'guard-rake'
16
17
  gem 'pry-byebug', platforms: [:mri]
17
- gem 'reel', require: 'reel/h2', git: 'https://github.com/kenichi/reel', branch: 'h2'
18
+ gem 'reel', '0.6.1'
18
19
  end
data/README.md CHANGED
@@ -2,14 +2,36 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/kenichi/h2.svg?branch=master)](https://travis-ci.org/kenichi/h2)
4
4
 
5
- H2 is a basic, _experimental_ HTTP/2 client based on the [http-2](https://github.com/igrigorik/http-2) gem.
5
+ H2 is an HTTP/2 client and server based on the [http-2](https://github.com/igrigorik/http-2) gem.
6
6
 
7
7
  H2 uses:
8
8
 
9
9
  * keyword arguments (>=2.0)
10
10
  * exception-less socket IO (>=2.3).
11
11
 
12
- ## Usage
12
+ ## Server Usage
13
+
14
+ Server API is currently optional, and must be required separately. The server
15
+ uses [Reel](https://github.com/celluloid/reel), but since this API is optional,
16
+ reel must be separately added to `Gemfile`. It is currently based on `reel-0.6.1`.
17
+
18
+ ```ruby
19
+ require 'h2/server'
20
+
21
+ server = H2::Server::HTTP.new host: addr, port: port do |connection|
22
+ connection.each_stream do |stream|
23
+ stream.respond :ok, "hello, world!\n"
24
+ stream.connection.goaway
25
+ end
26
+ end
27
+
28
+ stream = H2.get url: "http://#{addr}:#{port}", tls: false
29
+ stream.body #=> "hello, world!\n"
30
+ ```
31
+
32
+ See [examples](https://github.com/kenichi/h2/tree/master/examples/server/)
33
+
34
+ ## Client Usage
13
35
 
14
36
  ```ruby
15
37
  require 'h2'
@@ -44,10 +66,10 @@ stream.closed? #=> true
44
66
 
45
67
  client.closed? #=> false unless server sent GOAWAY
46
68
 
47
- stream = client.get path: '/push_promise' do |s| # H2::Stream === s
48
- s.on :headers do |h|
49
- if h['ETag'] == 'some_value']
50
- s.cancel! # already have
69
+ client.on :promise do |p| # check/cancel a promise
70
+ p.on :headers do |h|
71
+ if h['etag'] == 'some_value'
72
+ p.cancel! # already have
51
73
  end
52
74
  end
53
75
  end
@@ -69,13 +91,13 @@ end
69
91
  client.goaway!
70
92
  ```
71
93
 
72
- ## CLI
94
+ ## Client CLI
73
95
 
74
96
  For more info on using the CLI `h2` installed with this gem:
75
97
 
76
98
  `$ h2 --help`
77
99
 
78
- ## TLS CA Certificates
100
+ ## Using TLS CA Certificates with the Client
79
101
 
80
102
  If you're running on macOS and using Homebrew's openssl package, you may need to
81
103
  specify the CA file in the TLS options:
@@ -101,7 +123,7 @@ Neither of these gems are hard dependencies. If you want to use either one, you
101
123
  have it available to your Ruby VM, most likely via Bundler, *and* require the
102
124
  sub-component of h2 that will prepend and extend `H2::Client`. They are also intended
103
125
  to be mutually exclusive: you can have both in your VM, but you can only use one at a
104
- time with h2.
126
+ time with h2's client.
105
127
 
106
128
  #### Celluloid Pool
107
129
 
@@ -113,6 +135,10 @@ require 'h2/client/celluloid'
113
135
 
114
136
  This will lazily fire up a celluloid pool, with defaults defined by Celluloid.
115
137
 
138
+ NOTE: if you've added reel and required the 'h2/server' API, Celluloid will be
139
+ loaded in your Ruby VM already; however, you must still require this to have
140
+ the client use Celluloid actor pools.
141
+
116
142
  #### Concurrent-Ruby ThreadPoolExecutor
117
143
 
118
144
  To use a concurrent-ruby thread pool executor for reading from `H2::Client` connections:
@@ -137,6 +163,7 @@ max_queue: procs * 5
137
163
  * [x] push promise cancellation
138
164
  * [x] alternate concurrency models
139
165
  * [ ] fix up CLI to be more curlish
166
+ * [ ] update server API
140
167
 
141
168
  ## Contributing
142
169
 
data/bin/console CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'bundler/setup'
4
4
  require 'h2'
5
5
  require 'irb'
6
+ require 'celluloid/current'
6
7
 
7
8
  Bundler.require :development
8
9
  IRB.start
Binary file
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # Run with: bundle exec examples/server/hello_world.rb
3
+
4
+ require 'bundler/setup'
5
+ require 'h2/server'
6
+
7
+ H2::Logger.level = ::Logger::DEBUG
8
+ H2.verbose!
9
+
10
+ addr, port = '127.0.0.1', 1234
11
+
12
+ puts "*** Starting server on http://#{addr}:#{port}"
13
+ s = H2::Server::HTTP.new host: addr, port: port do |connection|
14
+ connection.each_stream do |stream|
15
+ stream.respond :ok, "hello, world!\n"
16
+ stream.connection.goaway
17
+ end
18
+ end
19
+
20
+ sleep
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # Run with: bundle exec examples/server/https_hello_world.rb
3
+
4
+ require 'bundler/setup'
5
+ require 'h2/server'
6
+
7
+ port = 1234
8
+ addr = Socket.getaddrinfo('localhost', port).first[3]
9
+ certs_dir = File.expand_path '../../../tmp/certs', __FILE__
10
+
11
+ tls = {
12
+ cert: certs_dir + '/server.crt',
13
+ key: certs_dir + '/server.key',
14
+ # :extra_chain_cert => certs_dir + '/chain.pem'
15
+ }
16
+
17
+ puts "*** Starting server on https://#{addr}:#{port}"
18
+
19
+ s = H2::Server::HTTPS.new host: addr, port: port, **tls do |connection|
20
+ connection.each_stream do |stream|
21
+ stream.goaway_on_complete
22
+
23
+ if stream.request.path == '/favicon.ico'
24
+ stream.respond :not_found
25
+ else
26
+ stream.respond :ok, "hello, world!\n"
27
+ end
28
+ end
29
+ end
30
+
31
+ sleep
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+ # Run with: bundle exec examples/server/push_promise.rb
3
+
4
+ require 'bundler/setup'
5
+ require 'h2/server'
6
+
7
+ H2::Logger.level = ::Logger::DEBUG
8
+ H2.verbose!
9
+
10
+ port = 1234
11
+ addr = Socket.getaddrinfo('localhost', port).first[3]
12
+ certs_dir = File.expand_path '../../../tmp/certs', __FILE__
13
+ dog_png = File.read File.expand_path '../dog.png', __FILE__
14
+ push_promise = '<html>wait for it...<img src="/dog.png"/><script src="/pushed.js"></script></html>'
15
+ pushed_js = '(()=>{ alert("hello h2 push promise!"); })();'
16
+
17
+ sni = {
18
+ 'localhost' => {
19
+ :cert => certs_dir + '/server.crt',
20
+ :key => certs_dir + '/server.key',
21
+ # :extra_chain_cert => certs_dir + '/chain.pem'
22
+ }
23
+ }
24
+
25
+ puts "*** Starting server on https://#{addr}:#{port}"
26
+ s = H2::Server::HTTPS.new host: addr, port: port, sni: sni do |connection|
27
+ connection.each_stream do |stream|
28
+
29
+ if stream.request.path == '/favicon.ico'
30
+ stream.respond :not_found
31
+
32
+ else
33
+ stream.goaway_on_complete
34
+
35
+ stream.push_promise '/dog.png', { 'content-type' => 'image/png' }, dog_png
36
+
37
+ js_promise = stream.push_promise_for '/pushed.js', { 'content-type' => 'application/javascript' }, pushed_js
38
+ js_promise.make_on stream
39
+
40
+ stream.respond :ok, push_promise
41
+
42
+ js_promise.keep
43
+ end
44
+ end
45
+ end
46
+
47
+ sleep
data/h2.gemspec CHANGED
@@ -6,8 +6,8 @@ Gem::Specification.new do |spec|
6
6
  spec.version = H2::VERSION
7
7
  spec.authors = ["Kenichi Nakamura"]
8
8
  spec.email = ["kenichi.nakamura@gmail.com"]
9
- spec.summary = 'an http/2 client based on http-2'
10
- spec.description = 'a pure ruby http/2 client based on http-2'
9
+ spec.summary = 'an http/2 client & server based on http-2'
10
+ spec.description = 'a pure ruby http/2 client & server based on http-2'
11
11
  spec.homepage = 'https://github.com/kenichi/h2'
12
12
  spec.license = 'MIT'
13
13
  spec.bindir = 'exe'
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
 
18
18
  spec.required_ruby_version = '>= 2.2'
19
19
 
20
- spec.add_dependency 'http-2', '~> 0.8', '>= 0.8.4'
20
+ spec.add_dependency 'http-2', '~> 0.9', '>= 0.9.1'
21
21
  spec.add_dependency 'colored', '1.2'
22
22
 
23
23
  spec.add_development_dependency "bundler", "~> 1.15"
@@ -0,0 +1,48 @@
1
+ require 'reel/connection'
2
+ require 'reel/request'
3
+ require 'reel/server'
4
+
5
+ # see also: https://github.com/celluloid/reel/pull/228
6
+
7
+
8
+ # this is a little sneaky, not as direct as the PR above, but the least
9
+ # invasive way i could come up with to get access to the server from the
10
+ # request.
11
+
12
+ module Reel
13
+
14
+ # we add a `server` accessor to +Connection+...
15
+ #
16
+ class Request
17
+ attr_reader :connection
18
+ end
19
+
20
+ # ... and a `connection` reader to +Request+.
21
+ #
22
+ class Connection
23
+ attr_accessor :server
24
+ end
25
+
26
+ end
27
+
28
+ module H2
29
+ module Reel
30
+ module ServerConnection
31
+
32
+ # then we hijack +Server+ construction, and wrap the callback at the last
33
+ # minute with one that sets the server on every connection, before
34
+ # calling the original.
35
+ #
36
+ def initialize server, options = {}, &callback
37
+ super
38
+ @og_callback = @callback
39
+ @callback = ->(conn) {
40
+ conn.server = self
41
+ @og_callback[conn]
42
+ }
43
+ end
44
+ end
45
+
46
+ ::Reel::Server.prepend ServerConnection
47
+ end
48
+ end
@@ -0,0 +1,153 @@
1
+ require 'h2/server/stream'
2
+
3
+ module H2
4
+ class Server
5
+
6
+ # handles reading data from the +@socket+ into the +HTTP2::Server+ +@parser+,
7
+ # callbacks from the +@parser+, and closing of the +@socket+
8
+ #
9
+ class Connection
10
+
11
+ # each +@parser+ event method is wrapped in a block to call a local instance
12
+ # method of the same name
13
+ #
14
+ PARSER_EVENTS = [
15
+ :frame,
16
+ :frame_sent,
17
+ :frame_received,
18
+ :stream,
19
+ :goaway
20
+ ]
21
+
22
+ attr_reader :parser, :server, :socket
23
+
24
+ def initialize socket:, server:
25
+ @socket = socket
26
+ @server = server
27
+ @parser = ::HTTP2::Server.new
28
+ @attached = true
29
+
30
+ yield self if block_given?
31
+
32
+ bind_events
33
+
34
+ Logger.debug "new H2::Connection: #{self}" if H2.verbose?
35
+ end
36
+
37
+ # is this connection still attached to the server reactor?
38
+ #
39
+ def attached?
40
+ @attached
41
+ end
42
+
43
+ # bind parser events to this instance
44
+ #
45
+ def bind_events
46
+ PARSER_EVENTS.each do |e|
47
+ on = "on_#{e}".to_sym
48
+ @parser.on(e) { |x| __send__ on, x }
49
+ end
50
+ end
51
+
52
+ # closes this connection's socket if attached
53
+ #
54
+ def close
55
+ socket.close if socket && attached?
56
+ end
57
+
58
+ # is this connection's socket closed?
59
+ #
60
+ def closed?
61
+ socket.closed?
62
+ end
63
+
64
+ # prevent this server reactor from handling this connection
65
+ #
66
+ def detach
67
+ @attached = false
68
+ self
69
+ end
70
+
71
+ # accessor for stream handler
72
+ #
73
+ def each_stream &block
74
+ @each_stream = block if block_given?
75
+ @each_stream
76
+ end
77
+
78
+ # queue a goaway frame
79
+ #
80
+ def goaway
81
+ server.async.goaway self
82
+ end
83
+
84
+ # begins the read loop, handling all errors with a log message,
85
+ # backtrace, and closing the +@socket+
86
+ #
87
+ def read
88
+ begin
89
+ while attached? && !@socket.closed? && !(@socket.eof? rescue true)
90
+ data = @socket.readpartial(4096)
91
+ @parser << data
92
+ end
93
+ close
94
+
95
+ rescue => e
96
+ Logger.error "Exception: #{e.message} - closing socket"
97
+ STDERR.puts e.backtrace
98
+ close
99
+
100
+ end
101
+ end
102
+
103
+ protected
104
+
105
+ # +@parser+ event methods
106
+
107
+ # called by +@parser+ with a binary frame to write to the +@socket+
108
+ #
109
+ def on_frame bytes
110
+ Logger.debug "Writing bytes: #{truncate_string(bytes.unpack("H*").first)}" if H2.verbose?
111
+
112
+ # N.B. this is the important bit
113
+ #
114
+ @socket.write bytes
115
+ rescue IOError, Errno::EPIPE => e
116
+ Logger.error e.message
117
+ close
118
+ end
119
+
120
+ def on_frame_sent f
121
+ Logger.debug "Sent frame: #{truncate_frame(f).inspect}" if H2.verbose?
122
+ end
123
+
124
+ def on_frame_received f
125
+ Logger.debug "Received frame: #{truncate_frame(f).inspect}" if H2.verbose?
126
+ end
127
+
128
+ # the +@parser+ calls this when a new stream has been initiated by the
129
+ # client
130
+ #
131
+ def on_stream stream
132
+ H2::Server::Stream.new connection: self, stream: stream
133
+ end
134
+
135
+ # the +@parser+ calls this when a goaway frame is received from the client
136
+ #
137
+ def on_goaway event
138
+ close
139
+ end
140
+
141
+ private
142
+
143
+ def truncate_string s
144
+ (String === s && s.length > 64) ? "#{s[0,64]}..." : s
145
+ end
146
+
147
+ def truncate_frame f
148
+ f.reduce({}) { |h, (k, v)| h[k] = truncate_string(v); h }
149
+ end
150
+
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,171 @@
1
+ module H2
2
+ class Server
3
+
4
+ # 'h2' server - for TLS 1.2 ALPN HTTP/2 connection
5
+ #
6
+ # @see https://tools.ietf.org/html/rfc7540#section-3.3
7
+ #
8
+ class HTTPS < H2::Server
9
+
10
+ ALPN_PROTOCOL = 'h2'
11
+ ALPN_SELECT_CALLBACK = ->(ps){ ps.find { |p| ALPN_PROTOCOL == p }}
12
+ ECDH_CURVES = 'P-256'
13
+ TMP_ECDH_CALLBACK = ->(*_){ OpenSSL::PKey::EC.new 'prime256v1' }
14
+
15
+ ECDH_OPENSSL_MIN_VERSION = '2.0'
16
+
17
+ # create a new h2 server that uses SNI to determine TLS cert/key to use
18
+ #
19
+ # @see https://en.wikipedia.org/wiki/Server_Name_Indication
20
+ #
21
+ # @param [String] host the IP address for this server to listen on
22
+ # @param [Integer] port the TCP port for this server to listen on
23
+ # @param [Hash] sni the SNI option hash with certs/keys for domains
24
+ # @param [Hash] options
25
+ #
26
+ # @option [String] :cert TLS certificate
27
+ # @option [String] :extra_chain_cert TLS certificate
28
+ # @option [String] :key TLS key
29
+ #
30
+ # == SNI options with default callback
31
+ #
32
+ # [:sni] Hash with domain name +String+ keys and +Hash+ values:
33
+ # [:cert] +String+ TLS certificate
34
+ # [:extra_chain_cert] +String+ TLS certificate
35
+ # [:key] +String+ TLS key
36
+ #
37
+ # == SNI options with _custom_ callback
38
+ #
39
+ # [:sni] Hash:
40
+ # [:callback] +Proc+ creates +OpenSSL::SSL::SSLContext+ for each
41
+ # connection
42
+ #
43
+ def initialize host:, port:, sni: {}, **options, &on_connection
44
+ @sni = sni
45
+ @sni_callback = @sni[:callback] || method(:sni_callback)
46
+ @tcpserver = Celluloid::IO::TCPServer.new host, port
47
+ @sslserver = Celluloid::IO::SSLServer.new @tcpserver, create_ssl_context(options)
48
+ options.merge! host: host, port: port, sni: sni
49
+ super @sslserver, options, &on_connection
50
+ end
51
+
52
+ # accept a socket connection, possibly attach spy, hand off to +#handle_connection+
53
+ # asyncronously, repeat
54
+ #
55
+ def run
56
+ loop do
57
+ begin
58
+ socket = @server.accept
59
+ rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::EPIPE,
60
+ Errno::ETIMEDOUT, Errno::EHOSTUNREACH => ex
61
+ Logger.warn "Error accepting SSLSocket: #{ex.class}: #{ex.to_s}"
62
+ retry
63
+ end
64
+
65
+ socket = ::Reel::Spy.new(socket, @spy) if @spy
66
+ async.handle_connection socket
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # default SNI callback - builds SSLContext from cert/key by domain name in +@sni+
73
+ # or returns existing one if name is not found
74
+ #
75
+ def sni_callback args
76
+ socket, name = args
77
+ @contexts ||= {}
78
+ if @contexts[name]
79
+ @contexts[name]
80
+ elsif sni_opts = @sni[name] and Hash === sni_opts
81
+ @contexts[name] = create_ssl_context sni_opts
82
+ else
83
+ socket.context
84
+ end
85
+ end
86
+
87
+ # builds a new SSLContext suitable for use in 'h2' connections
88
+ #
89
+ def create_ssl_context **opts
90
+ ctx = OpenSSL::SSL::SSLContext.new
91
+ ctx.ca_file = opts[:ca_file] if opts[:ca_file]
92
+ ctx.ca_path = opts[:ca_path] if opts[:ca_path]
93
+ ctx.cert = context_cert opts[:cert]
94
+ ctx.ciphers = opts[:ciphers] || OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:ciphers]
95
+ ctx.extra_chain_cert = context_extra_chain_cert opts[:extra_chain_cert]
96
+ ctx.key = context_key opts[:key]
97
+ ctx.options = opts[:options] || OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options]
98
+ ctx.servername_cb = @sni_callback
99
+ ctx.ssl_version = :TLSv1_2
100
+ context_ecdh ctx
101
+
102
+ # https://github.com/jruby/jruby-openssl/issues/99
103
+ context_set_protocols ctx unless H2.jruby?
104
+
105
+ ctx
106
+ end
107
+
108
+ private
109
+
110
+ if OpenSSL::VERSION >= ECDH_OPENSSL_MIN_VERSION && RUBY_VERSION >= '2.3'
111
+ def context_ecdh ctx
112
+ ctx.ecdh_curves = ECDH_CURVES
113
+ end
114
+ else
115
+ if H2.jruby? || RUBY_VERSION < '2.3'
116
+ def context_ecdh ctx
117
+ end
118
+ else
119
+ def context_ecdh ctx
120
+ ctx.tmp_ecdh_callback = TMP_ECDH_CALLBACK
121
+ end
122
+ end
123
+ end
124
+
125
+ def context_cert cert
126
+ case cert
127
+ when String
128
+ cert = File.read cert if File.exist? cert
129
+ OpenSSL::X509::Certificate.new cert
130
+ when OpenSSL::X509::Certificate
131
+ cert
132
+ end
133
+ end
134
+
135
+ def context_key key
136
+ case key
137
+ when String
138
+ key = File.read key if File.exist? key
139
+ OpenSSL::PKey::RSA.new key
140
+ when OpenSSL::PKey::RSA
141
+ key
142
+ end
143
+ end
144
+
145
+ def context_extra_chain_cert chain
146
+ case chain
147
+ when String
148
+ chain = File.read chain if File.exist? chain
149
+ [OpenSSL::X509::Certificate.new(chain)]
150
+ when OpenSSL::X509::Certificate
151
+ [chain]
152
+ when Array
153
+ chain
154
+ end
155
+ end
156
+
157
+ if H2.alpn?
158
+ def context_set_protocols ctx
159
+ ctx.alpn_protocols = [ALPN_PROTOCOL]
160
+ ctx.alpn_select_cb = ALPN_SELECT_CALLBACK
161
+ end
162
+ else
163
+ def context_set_protocols ctx
164
+ ctx.npn_protocols = [ALPN_PROTOCOL]
165
+ ctx.npn_select_cb = ALPN_SELECT_CALLBACK
166
+ end
167
+ end
168
+
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,125 @@
1
+ module H2
2
+ class Server
3
+ class PushPromise
4
+
5
+ GET = 'GET'
6
+ STATUS = '200'
7
+
8
+ attr_reader :content_length, :path, :push_stream
9
+
10
+ # build a new +PushPromise+ for the path, with the headers and body given
11
+ #
12
+ def initialize path, body_or_headers = {}, body = nil
13
+ @path = path
14
+ if Hash === body_or_headers
15
+ headers = body_or_headers.dup
16
+ @body = body
17
+ else
18
+ headers = {}
19
+ @body = body_or_headers
20
+ end
21
+
22
+ @promise_headers = {
23
+ METHOD_KEY => GET,
24
+ AUTHORITY_KEY => headers.delete(AUTHORITY_KEY),
25
+ PATH_KEY => @path,
26
+ SCHEME_KEY => headers.delete(SCHEME_KEY)
27
+ }
28
+
29
+ @content_length = @body.bytesize.to_s
30
+
31
+ @push_headers = {
32
+ STATUS_KEY => STATUS,
33
+ CONTENT_LENGTH_KEY => @content_length
34
+ }.merge headers
35
+
36
+ @fsm = FSM.new
37
+ end
38
+
39
+ # create a new promise stream from +stream+, send the headers and set
40
+ # +@push_stream+ from the callback
41
+ #
42
+ def make_on stream
43
+ return unless @fsm.state == :init
44
+ @stream = stream
45
+ @stream.stream.promise(@promise_headers, end_headers: false) do |push|
46
+ push.headers @push_headers
47
+ @push_stream = push
48
+ @push_stream.on(:close){|err| cancel! if err == :cancel}
49
+ end
50
+ @fsm.transition :made
51
+ self
52
+ end
53
+
54
+ def keep_async
55
+ @stream.connection.server.async.handle_push_promise self
56
+ end
57
+
58
+ # deliver the body for this promise, optionally splicing into +size+ chunks
59
+ #
60
+ def keep size = nil
61
+ return false unless @fsm.state == :made
62
+
63
+ if size.nil?
64
+ @push_stream.data @body
65
+ else
66
+ body = @body
67
+
68
+ if body.bytesize > size
69
+ body = @body.dup
70
+ while body.bytesize > size
71
+ @push_stream.data body.slice!(0, size), end_stream: false
72
+ yield if block_given?
73
+ end
74
+ else
75
+ yield if block_given?
76
+ end
77
+
78
+ @push_stream.data body
79
+ end
80
+
81
+ @fsm.transition :kept
82
+ log :info, self
83
+ @stream.on_complete
84
+ end
85
+
86
+ def kept?
87
+ @fsm.state == :kept
88
+ end
89
+
90
+ def canceled?
91
+ @fsm.state == :canceled
92
+ end
93
+
94
+ # cancel this promise, most likely due to a RST_STREAM frame from the
95
+ # client (already in cache, etc...)
96
+ #
97
+ def cancel!
98
+ @fsm.transition :canceled
99
+ @stream.on_complete
100
+ end
101
+
102
+ def log level, msg
103
+ Logger.__send__ level, "[stream #{@push_stream.id}] #{msg}"
104
+ end
105
+
106
+ def to_s
107
+ request = @stream.request
108
+ %{#{request.addr} "push #{@path} HTTP/2" #{STATUS} #{@content_length}}
109
+ end
110
+ alias to_str to_s
111
+
112
+ # simple state machine to guarantee promise process
113
+ #
114
+ class FSM
115
+ include Celluloid::FSM
116
+ default_state :init
117
+ state :init, to: [:canceled, :made]
118
+ state :made, to: [:canceled, :kept]
119
+ state :kept
120
+ state :canceled
121
+ end
122
+
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,65 @@
1
+ module H2
2
+ class Server
3
+ class Stream
4
+ class Request
5
+
6
+ # a case-insensitive hash that also handles symbol translation i.e. s/_/-/
7
+ #
8
+ HEADER_HASH = Hash.new do |hash, key|
9
+ k = key.to_s.upcase
10
+ k.gsub! '_', '-' if Symbol === key
11
+ _, value = hash.find {|header_key,v| header_key.upcase == k}
12
+ hash[key] = value if value
13
+ end
14
+
15
+ attr_reader :body, :headers, :stream
16
+
17
+ def initialize stream
18
+ @stream = stream
19
+ @headers = HEADER_HASH.dup
20
+ @body = ''
21
+ end
22
+
23
+ # retreive the IP address of the connection
24
+ #
25
+ def addr
26
+ @addr ||= @stream.connection.socket.peeraddr[3] rescue nil
27
+ end
28
+
29
+ # retreive the authority from the stream request headers
30
+ #
31
+ def authority
32
+ @authority ||= headers[AUTHORITY_KEY]
33
+ end
34
+
35
+ # retreive the HTTP method as a lowercase +Symbol+
36
+ #
37
+ def method
38
+ return @method unless @method.nil?
39
+ @method = headers[METHOD_KEY]
40
+ @method = @method.downcase.to_sym if @method
41
+ @method
42
+ end
43
+
44
+ # retreive the path from the stream request headers
45
+ #
46
+ def path
47
+ @path ||= headers[PATH_KEY]
48
+ end
49
+
50
+ # retreive the scheme from the stream request headers
51
+ #
52
+ def scheme
53
+ @scheme ||= headers[SCHEME_KEY]
54
+ end
55
+
56
+ # respond to this request on its stream
57
+ #
58
+ def respond response, body_or_headers = nil, body = nil
59
+ @stream.respond response, body_or_headers, body
60
+ end
61
+
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,99 @@
1
+ module H2
2
+ class Server
3
+ class Stream
4
+ class Response
5
+
6
+ attr_reader :body, :content_length, :headers, :status, :stream
7
+
8
+ # build a new +Response+ object
9
+ #
10
+ def initialize stream:, status:, headers: {}, body: ''
11
+ @stream = stream
12
+ @headers = headers
13
+ @body = body
14
+ self.status = status
15
+
16
+ init_content_length
17
+ end
18
+
19
+ # sets the content length in the headers by the byte size of +@body+
20
+ #
21
+ def init_content_length
22
+ @content_length = case @body
23
+ when String
24
+ @body.bytesize
25
+ when IO
26
+ @body.stat.size
27
+ when NilClass
28
+ '0'
29
+ else
30
+ raise TypeError, "can't render #{@body.class} as a response body"
31
+ end
32
+
33
+ unless @headers.any? {|k,_| k.downcase == CONTENT_LENGTH_KEY}
34
+ @headers[CONTENT_LENGTH_KEY] = @content_length
35
+ end
36
+ end
37
+
38
+ # the corresponding +Request+ to this +Response+
39
+ #
40
+ def request
41
+ stream.request
42
+ end
43
+
44
+ # send the headers and body out on +s+, an +HTTP2::Stream+ object
45
+ #
46
+ # NOTE: +:status+ must come first?
47
+ #
48
+ def respond_on s
49
+ headers = { STATUS_KEY => @status.to_s }.merge @headers
50
+ s.headers stringify_headers(headers)
51
+ case @body
52
+ when String
53
+ s.data @body
54
+ when IO
55
+ raise NotImplementedError # TODO
56
+ else
57
+ end
58
+ end
59
+
60
+ # sets +@status+ either from given integer value (HTTP status code) or by
61
+ # mapping a +Symbol+ in +Reel::Response::SYMBOL_TO_STATUS_CODE+ to one
62
+ #
63
+ def status= status
64
+ case status
65
+ when Integer
66
+ @status = status
67
+ when Symbol
68
+ if code = ::Reel::Response::SYMBOL_TO_STATUS_CODE[status]
69
+ self.status = code
70
+ else
71
+ raise ArgumentError, "unrecognized status symbol: #{status}"
72
+ end
73
+ else
74
+ raise TypeError, "invalid status type: #{status.inspect}"
75
+ end
76
+ end
77
+
78
+ def to_s
79
+ %{#{request.addr} "#{request.method} #{request.path} HTTP/2" #{status} #{content_length}}
80
+ end
81
+ alias to_str to_s
82
+
83
+ private
84
+
85
+ def stringify_headers hash
86
+ hash.keys.each do |k|
87
+ hash[k] = hash[k].to_s unless String === hash[k]
88
+ if Symbol === k
89
+ key = k.to_s.gsub '_', '-'
90
+ hash[key] = hash.delete k
91
+ end
92
+ end
93
+ hash
94
+ end
95
+
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,208 @@
1
+ require 'h2/server/stream/request'
2
+ require 'h2/server/stream/response'
3
+ require 'h2/server/push_promise'
4
+
5
+ module H2
6
+ class Server
7
+ class Stream
8
+
9
+ # each stream event method is wrapped in a block to call a local instance
10
+ # method of the same name
11
+ #
12
+ STREAM_EVENTS = [
13
+ :active,
14
+ :close,
15
+ :half_close
16
+ ]
17
+
18
+ # the above take only the event, the following receive both the event
19
+ # and the data
20
+ #
21
+ STREAM_DATA_EVENTS = [
22
+ :headers,
23
+ :data
24
+ ]
25
+
26
+ attr_reader :connection,
27
+ :push_promises,
28
+ :request,
29
+ :response,
30
+ :stream
31
+
32
+ def initialize connection:, stream:
33
+ @closed = false
34
+ @completed = false
35
+ @connection = connection
36
+ @push_promises = Set.new
37
+ @responded = false
38
+ @stream = stream
39
+
40
+ bind_events
41
+ end
42
+
43
+ # mimicing Reel::Connection#respond
44
+ #
45
+ # write status, headers, and data to +@stream+
46
+ #
47
+ def respond response, body_or_headers = nil, body = nil
48
+
49
+ # :/
50
+ #
51
+ if Hash === body_or_headers
52
+ headers = body_or_headers
53
+ body ||= ''
54
+ else
55
+ headers = {}
56
+ body = body_or_headers ||= ''
57
+ end
58
+
59
+ @response = case response
60
+ when Symbol, Integer
61
+ response = Response.new stream: self,
62
+ status: response,
63
+ headers: headers,
64
+ body: body
65
+ when Response
66
+ response
67
+ else raise TypeError, "invalid response: #{response.inspect}"
68
+ end
69
+
70
+ if @closed
71
+ log :warn, 'stream closed before response sent'
72
+ else
73
+ response.respond_on(stream)
74
+ log :info, response
75
+ @responded = true
76
+ end
77
+ end
78
+
79
+ # create a push promise, send the headers, then queue an asynchronous
80
+ # task on the reactor to deliver the data
81
+ #
82
+ def push_promise *args
83
+ pp = push_promise_for *args
84
+ make_promise pp
85
+ @connection.server.async.handle_push_promise pp
86
+ end
87
+
88
+ # create a push promise - mimicing Reel::Connection#respond
89
+ #
90
+ def push_promise_for path, body_or_headers = {}, body = nil
91
+
92
+ # :/
93
+ #
94
+ case body_or_headers
95
+ when Hash
96
+ headers = body_or_headers
97
+ else
98
+ headers = {}
99
+ body = body_or_headers
100
+ end
101
+
102
+ headers.merge! AUTHORITY_KEY => @request.authority,
103
+ SCHEME_KEY => @request.scheme
104
+
105
+ PushPromise.new path, headers, body
106
+ end
107
+
108
+ # begin the new push promise stream from this +@stream+ by sending the
109
+ # initial headers frame
110
+ #
111
+ # @see +PushPromise#make_on!+
112
+ # @see +HTTP2::Stream#promise+
113
+ #
114
+ def make_promise p
115
+ p.make_on self
116
+ push_promises << p
117
+ p
118
+ end
119
+
120
+ # set or call +@complete+ callback
121
+ #
122
+ def on_complete &block
123
+ return true if @completed
124
+ if block
125
+ @complete = block
126
+ elsif @completed = (@responded and push_promises_complete?)
127
+ @complete[] if Proc === @complete
128
+ true
129
+ else
130
+ false
131
+ end
132
+ end
133
+
134
+ # check for push promises completion
135
+ #
136
+ def push_promises_complete?
137
+ @push_promises.empty? or @push_promises.all? {|p| p.kept? or p.canceled?}
138
+ end
139
+
140
+ # trigger a GOAWAY frame when this stream is responded to and any/all push
141
+ # promises are complete
142
+ #
143
+ def goaway_on_complete
144
+ on_complete { connection.goaway }
145
+ end
146
+
147
+ # logging helper
148
+ #
149
+ def log level, msg
150
+ Logger.__send__ level, "[stream #{@stream.id}] #{msg}"
151
+ end
152
+
153
+ protected
154
+
155
+ # bind parser events to this instance
156
+ #
157
+ def bind_events
158
+ STREAM_EVENTS.each do |e|
159
+ on = "on_#{e}".to_sym
160
+ @stream.on(e) { __send__ on }
161
+ end
162
+ STREAM_DATA_EVENTS.each do |e|
163
+ on = "on_#{e}".to_sym
164
+ @stream.on(e) { |x| __send__ on, x }
165
+ end
166
+ end
167
+
168
+ # called by +@stream+ when this stream is activated
169
+ #
170
+ def on_active
171
+ log :debug, 'active' if H2.verbose?
172
+ @request = H2::Server::Stream::Request.new self
173
+ end
174
+
175
+ # called by +@stream+ when this stream is closed
176
+ #
177
+ def on_close
178
+ log :debug, 'close' if H2.verbose?
179
+ on_complete
180
+ @closed = true
181
+ end
182
+
183
+ # called by +@stream+ with a +Hash+
184
+ #
185
+ def on_headers h
186
+ incoming_headers = Hash[h]
187
+ log :debug, "headers: #{incoming_headers}" if H2.verbose?
188
+ @request.headers.merge! incoming_headers
189
+ end
190
+
191
+ # called by +@stream+ with a +String+ body part
192
+ #
193
+ def on_data d
194
+ log :debug, "data: <<#{d}>>" if H2.verbose?
195
+ @request.body << d
196
+ end
197
+
198
+ # called by +@stream+ when body/request is complete, signaling that client
199
+ # is ready for response(s)
200
+ #
201
+ def on_half_close
202
+ log :debug, 'half_close' if H2.verbose?
203
+ connection.server.async.handle_stream self
204
+ end
205
+
206
+ end
207
+ end
208
+ end
data/lib/h2/server.rb ADDED
@@ -0,0 +1,98 @@
1
+ require 'celluloid/current'
2
+ require 'logger'
3
+ require 'reel'
4
+ require 'h2/reel/ext'
5
+ require 'h2'
6
+
7
+ module H2
8
+
9
+ CONTENT_LENGTH_KEY = 'content-length'
10
+
11
+ Logger = ::Logger.new STDOUT
12
+
13
+ class << self
14
+
15
+ def alpn?
16
+ !jruby? && OpenSSL::OPENSSL_VERSION_NUMBER >= ALPN_OPENSSL_MIN_VERSION && RUBY_VERSION >= '2.3'
17
+ end
18
+
19
+ def jruby?
20
+ return @jruby if defined? @jruby
21
+ @jruby = RUBY_ENGINE == 'jruby'
22
+ end
23
+
24
+ # turn on extra verbose debug logging
25
+ #
26
+ def verbose!
27
+ @verbose = true
28
+ end
29
+
30
+ def verbose?
31
+ @verbose = false unless defined?(@verbose)
32
+ @verbose
33
+ end
34
+
35
+ end
36
+
37
+ # base H2 server, a direct subclass of +Reel::Server+
38
+ #
39
+ class Server < ::Reel::Server
40
+
41
+ def initialize server, **options, &on_connection
42
+ @on_connection = on_connection
43
+ super server, options
44
+ end
45
+
46
+ # build a new connection object, run it through the given block, and
47
+ # start reading from the socket if still attached
48
+ #
49
+ def handle_connection socket
50
+ connection = H2::Server::Connection.new socket: socket, server: self
51
+ @on_connection[connection]
52
+ connection.read if connection.attached?
53
+ end
54
+
55
+ # async stream handling
56
+ #
57
+ def handle_stream stream
58
+ stream.connection.each_stream[stream]
59
+ end
60
+
61
+ # async push promise
62
+ #
63
+ def handle_push_promise push_promise
64
+ push_promise.keep
65
+ end
66
+
67
+ # async goaway
68
+ #
69
+ def goaway connection
70
+ sleep 0.25
71
+ connection.parser.goaway unless connection.closed?
72
+ end
73
+
74
+ # 'h2c' server - for plaintext HTTP/2 connection
75
+ #
76
+ # NOTE: browsers don't support this and probably never will
77
+ #
78
+ # @see https://tools.ietf.org/html/rfc7540#section-3.4
79
+ # @see https://hpbn.co/http2/#upgrading-to-http2
80
+ #
81
+ class HTTP < H2::Server
82
+
83
+ # create a new h2c server
84
+ #
85
+ def initialize host:, port:, **options, &on_connection
86
+ @tcpserver = Celluloid::IO::TCPServer.new host, port
87
+ options.merge! host: host, port: port
88
+ super @tcpserver, options, &on_connection
89
+ end
90
+
91
+ end
92
+
93
+ end
94
+
95
+ end
96
+
97
+ require 'h2/server/connection'
98
+ require 'h2/server/https'
data/lib/h2/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module H2
2
- VERSION = '0.4.1'
2
+ VERSION = '0.5.0'
3
3
 
4
4
  class << self
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: h2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kenichi Nakamura
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-07-17 00:00:00.000000000 Z
11
+ date: 2018-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: http-2
@@ -16,20 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.8'
19
+ version: '0.9'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 0.8.4
22
+ version: 0.9.1
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - "~>"
28
28
  - !ruby/object:Gem::Version
29
- version: '0.8'
29
+ version: '0.9'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.8.4
32
+ version: 0.9.1
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: colored
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -86,7 +86,7 @@ dependencies:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '5.0'
89
- description: a pure ruby http/2 client based on http-2
89
+ description: a pure ruby http/2 client & server based on http-2
90
90
  email:
91
91
  - kenichi.nakamura@gmail.com
92
92
  executables:
@@ -104,6 +104,10 @@ files:
104
104
  - README.md
105
105
  - Rakefile
106
106
  - bin/console
107
+ - examples/server/dog.png
108
+ - examples/server/hello_world.rb
109
+ - examples/server/https_hello_world.rb
110
+ - examples/server/push_promise.rb
107
111
  - exe/h2
108
112
  - h2.gemspec
109
113
  - lib/h2.rb
@@ -111,6 +115,14 @@ files:
111
115
  - lib/h2/client/celluloid.rb
112
116
  - lib/h2/client/concurrent.rb
113
117
  - lib/h2/client/tcp_socket.rb
118
+ - lib/h2/reel/ext.rb
119
+ - lib/h2/server.rb
120
+ - lib/h2/server/connection.rb
121
+ - lib/h2/server/https.rb
122
+ - lib/h2/server/push_promise.rb
123
+ - lib/h2/server/stream.rb
124
+ - lib/h2/server/stream/request.rb
125
+ - lib/h2/server/stream/response.rb
114
126
  - lib/h2/stream.rb
115
127
  - lib/h2/version.rb
116
128
  homepage: https://github.com/kenichi/h2
@@ -136,5 +148,5 @@ rubyforge_project:
136
148
  rubygems_version: 2.7.6
137
149
  signing_key:
138
150
  specification_version: 4
139
- summary: an http/2 client based on http-2
151
+ summary: an http/2 client & server based on http-2
140
152
  test_files: []