plum 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ require "optparse"
2
+ require "rack/builder"
3
+
4
+ module Plum
5
+ module Rack
6
+ # CLI runner. Parses command line options and start ::Plum::Rack::Server.
7
+ class CLI
8
+ # Creates new CLI runner and parses command line.
9
+ #
10
+ # @param argv [Array<String>] ARGV
11
+ def initialize(argv)
12
+ @argv = argv
13
+ @options = {}
14
+
15
+ parse!
16
+ end
17
+
18
+ # Starts ::Plum::Rack::Server
19
+ def run
20
+ @server.start
21
+ end
22
+
23
+ private
24
+ def parse!
25
+ @parser = setup_parser
26
+ @parser.parse!(@argv)
27
+
28
+ config = transform_options
29
+ # TODO: parse rack_opts?
30
+ rack_app, rack_opts = ::Rack::Builder.parse_file(@argv.shift || "config.ru")
31
+
32
+ @server = Plum::Rack::Server.new(rack_app, config)
33
+ end
34
+
35
+ def transform_options
36
+ if @options[:config]
37
+ dsl = DSL::Config.new.instance_eval(File.read(@options[:config]))
38
+ config = dsl.config
39
+ else
40
+ config = Config.new
41
+ end
42
+
43
+ ENV["RACK_ENV"] = @options[:env] if @options[:env]
44
+ config[:debug] = @options[:debug] unless @options[:debug].nil?
45
+ config[:server_push] = @options[:server_push] unless @options[:server_push].nil?
46
+
47
+ if @options[:socket]
48
+ config[:listeners] << { listener: UNIXListener,
49
+ path: @options[:socket] }
50
+ end
51
+
52
+ if !@options[:socket] || @options[:host] || @options[:port]
53
+ if @options[:tls] == false
54
+ config[:listeners] << { listener: TCPListener,
55
+ hostname: @options[:host] || "0.0.0.0",
56
+ port: @options[:port] || 8080 }
57
+ else
58
+ config[:listeners] << { listener: TLSListener,
59
+ hostname: @options[:host] || "0.0.0.0",
60
+ port: @options[:port] || 8080,
61
+ certificate: @options[:cert] && File.read(@options[:cert]),
62
+ certificate_key: @options[:cert] && File.read(@options[:key]) }
63
+ end
64
+ end
65
+
66
+ config
67
+ end
68
+
69
+ def setup_parser
70
+ parser = OptionParser.new do |o|
71
+ o.on "-C", "--config PATH", "Load PATH as a config" do |arg|
72
+ @options[:config] = arg
73
+ end
74
+
75
+ o.on "-D", "--debug", "Run puma in debug mode" do
76
+ @options[:debug] = true
77
+ end
78
+
79
+ o.on "-e", "--environment ENV", "Rack environment (default: development)" do |arg|
80
+ @options[:env] = arg
81
+ end
82
+
83
+ o.on "-a", "--address HOST", "Bind to host HOST (default: 0.0.0.0)" do |arg|
84
+ @options[:host] = arg
85
+ end
86
+
87
+ o.on "-p", "--port PORT", "Bind to port PORT (default: 8080)" do |arg|
88
+ @options[:port] = arg.to_i
89
+ end
90
+
91
+ o.on "-S", "--socket PATH", "Bind to UNIX domain socket" do |arg|
92
+ @options[:socket] = arg
93
+ end
94
+
95
+ o.on "--http", "Use http URI scheme (use raw TCP)" do |arg|
96
+ @options[:tls] = false
97
+ end
98
+
99
+ o.on "--https", "Use https URI scheme (use TLS; default)" do |arg|
100
+ @options[:tls] = true
101
+ end
102
+
103
+ o.on "--server-push BOOL", "Enable HTTP/2 server push" do |arg|
104
+ @options[:server_push] = arg != "false"
105
+ end
106
+
107
+ o.on "--cert PATH", "Use PATH as server certificate" do |arg|
108
+ @options[:cert] = arg
109
+ end
110
+
111
+ o.on "--key PATH", "Use PATH as server certificate's private key" do |arg|
112
+ @options[:key] = arg
113
+ end
114
+
115
+ o.on "-v", "--version", "Show version" do
116
+ puts "plum version #{::Plum::VERSION}"
117
+ exit(0)
118
+ end
119
+
120
+ o.on "-h", "--help", "Show this message" do
121
+ puts o
122
+ exit(0)
123
+ end
124
+
125
+ o.banner = "plum [options] [rackup config file]"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,28 @@
1
+ module Plum
2
+ module Rack
3
+ class Config
4
+ DEFAULT_CONFIG = {
5
+ listeners: [],
6
+ debug: false,
7
+ log: nil, # $stdout
8
+ server_push: true
9
+ }.freeze
10
+
11
+ def initialize(config = {})
12
+ @config = DEFAULT_CONFIG.merge(config)
13
+ end
14
+
15
+ def [](key)
16
+ @config[key]
17
+ end
18
+
19
+ def []=(key, value)
20
+ @config[key] = value
21
+ end
22
+
23
+ def to_s
24
+ @config.to_s
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,169 @@
1
+ module Plum
2
+ module Rack
3
+ class Connection
4
+ attr_reader :app, :plum
5
+
6
+ def initialize(app, plum, logger)
7
+ @app = app
8
+ @plum = plum
9
+ @logger = logger
10
+
11
+ setup_plum
12
+ end
13
+
14
+ def stop
15
+ @plum.close
16
+ end
17
+
18
+ def run
19
+ begin
20
+ @plum.run
21
+ rescue Errno::EPIPE, Errno::ECONNRESET => e
22
+ rescue StandardError => e
23
+ @logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}")
24
+ end
25
+ end
26
+
27
+ private
28
+ def setup_plum
29
+ @plum.on(:connection_error) { |ex| @logger.error(ex) }
30
+
31
+ # @plum.on(:stream) { |stream| @logger.debug("new stream: #{stream}") }
32
+ @plum.on(:stream_error) { |stream, ex| @logger.error(ex) }
33
+
34
+ reqs = {}
35
+ @plum.on(:headers) { |stream, h|
36
+ reqs[stream] = { headers: h, data: "".force_encoding(Encoding::BINARY) }
37
+ }
38
+
39
+ @plum.on(:data) { |stream, d|
40
+ reqs[stream][:data] << d # TODO: store to file?
41
+ }
42
+
43
+ @plum.on(:end_stream) { |stream|
44
+ handle_request(stream, reqs[stream][:headers], reqs[stream][:data])
45
+ }
46
+ end
47
+
48
+ def send_body(stream, body)
49
+ begin
50
+ if body.is_a?(Array)
51
+ last = body.size - 1
52
+ body.each_with_index { |part, i|
53
+ stream.send_data(part, end_stream: last == i)
54
+ }
55
+ elsif body.is_a?(IO)
56
+ stream.send_data(body, end_stream: true)
57
+ else
58
+ body.each { |part| stream.send_data(part, end_stream: false) }
59
+ stream.send_data(nil, end_stream: true)
60
+ end
61
+ ensure
62
+ body.close if body.respond_to?(:close)
63
+ end
64
+ end
65
+
66
+ def extract_push(r_extheaders)
67
+ if pushs = r_extheaders["plum.serverpush"]
68
+ pushs.split(";").map { |push| push.split(" ", 2) }
69
+ else
70
+ []
71
+ end
72
+ end
73
+
74
+ def handle_request(stream, headers, data)
75
+ env = new_env(headers, data)
76
+ r_status, r_rawheaders, r_body = @app.call(env)
77
+ r_headers, r_extheaders = extract_headers(r_status, r_rawheaders)
78
+ r_topushs = extract_push(r_extheaders)
79
+
80
+ stream.send_headers(r_headers, end_stream: false)
81
+ r_pushstreams = r_topushs.map { |method, path|
82
+ preq = { ":authority" => headers.find { |k, v| k == ":authority" }[1],
83
+ ":method" => method.to_s.upcase,
84
+ ":scheme" => headers.find { |k, v| k == ":scheme" }[1],
85
+ ":path" => path }
86
+ st = stream.promise(preq)
87
+ [st, preq]
88
+ }
89
+
90
+ send_body(stream, r_body)
91
+
92
+ r_pushstreams.each { |st, preq|
93
+ penv = new_env(preq, "")
94
+ p_status, p_h, p_body = @app.call(penv)
95
+ p_headers = extract_headers(p_status, p_h)
96
+ st.send_headers(p_headers, end_stream: false)
97
+ send_body(st, p_body)
98
+ }
99
+ end
100
+
101
+ def new_env(h, data)
102
+ ebase = {
103
+ "SCRIPT_NAME" => "",
104
+ "rack.version" => ::Rack::VERSION,
105
+ "rack.input" => StringIO.new(data),
106
+ "rack.errors" => $stderr,
107
+ "rack.multithread" => true,
108
+ "rack.multiprocess" => false,
109
+ "rack.run_once" => false,
110
+ "rack.hijack?" => false,
111
+ }
112
+
113
+ h.each { |k, v|
114
+ case k
115
+ when ":method"
116
+ ebase["REQUEST_METHOD"] = v
117
+ when ":path"
118
+ cpath_name, cpath_query = v.split("?", 2)
119
+ ebase["PATH_INFO"] = cpath_name
120
+ ebase["QUERY_STRING"] = cpath_query || ""
121
+ when ":authority"
122
+ chost, cport = v.split(":", 2)
123
+ ebase["SERVER_NAME"] = chost
124
+ ebase["SERVER_PORT"] = (cport || 443).to_i
125
+ when ":scheme"
126
+ ebase["rack.url_scheme"] = v
127
+ else
128
+ if k.start_with?(":")
129
+ # unknown HTTP/2 pseudo-headers
130
+ else
131
+ if "cookie" == k && ebase["HTTP_COOKIE"]
132
+ ebase["HTTP_COOKIE"] << "; " << v
133
+ else
134
+ ebase["HTTP_" << k.tr("-", "_").upcase!] = v
135
+ end
136
+ end
137
+ end
138
+ }
139
+
140
+ ebase
141
+ end
142
+
143
+ def extract_headers(r_status, r_h)
144
+ rbase = {
145
+ ":status" => r_status,
146
+ "server" => "plum/#{::Plum::VERSION}",
147
+ }
148
+ rext = {}
149
+
150
+ r_h.each do |key, v_|
151
+ if key.include?(".")
152
+ rext[key] = v_
153
+ else
154
+ key = key.downcase
155
+
156
+ if "set-cookie" == key
157
+ rbase[key] = v_.gsub("\n", "; ") # RFC 7540 8.1.2.5
158
+ else
159
+ key = key.byteshift(2) if key.start_with?("x-")
160
+ rbase[key] = v_.tr("\n", ",") # RFC 7230 7
161
+ end
162
+ end
163
+ end
164
+
165
+ [rbase, rext]
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,44 @@
1
+ module Plum
2
+ module Rack
3
+ module DSL
4
+ class Config
5
+ attr_reader :config
6
+
7
+ def initialize
8
+ @config = ::Plum::Rack::Config::DEFAULT_CONFIG.dup
9
+ end
10
+
11
+ def log(out)
12
+ if out.is_a?(String)
13
+ @config[:log] = File.open(out, "a")
14
+ else
15
+ @config[:log] = out
16
+ end
17
+ end
18
+
19
+ def debug(bool)
20
+ @config[:debug] = !!bool
21
+ end
22
+
23
+ def listener(type, conf)
24
+ case type
25
+ when :unix
26
+ lc = conf.merge(listener: UNIXListener)
27
+ when :tcp
28
+ lc = conf.merge(listener: TCPListener)
29
+ when :tls
30
+ lc = conf.merge(listener: TLSListener)
31
+ else
32
+ raise "Unknown listener type: #{type} (known type: :unix, :http, :https)"
33
+ end
34
+
35
+ @config[:listeners] << lc
36
+ end
37
+
38
+ def server_push(bool)
39
+ @config[:server_push] = !!bool
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,122 @@
1
+ module Plum
2
+ module Rack
3
+ class BaseListener
4
+ def stop
5
+ @server.close
6
+ end
7
+
8
+ def to_io
9
+ raise "not implemented"
10
+ end
11
+
12
+ def method_missing(name, *args)
13
+ @server.__send__(name, *args)
14
+ end
15
+ end
16
+
17
+ class TCPListener < BaseListener
18
+ def initialize(lc)
19
+ @server = ::TCPServer.new(lc[:hostname], lc[:port])
20
+ end
21
+
22
+ def to_io
23
+ @server.to_io
24
+ end
25
+
26
+ def plum(sock)
27
+ ::Plum::HTTPConnection.new(sock)
28
+ end
29
+ end
30
+
31
+ class TLSListener < BaseListener
32
+ def initialize(lc)
33
+ cert, key = lc[:certificate], lc[:certificate_key]
34
+ unless cert && key
35
+ puts "WARNING: using dummy certificate"
36
+ cert, key = dummy_key
37
+ end
38
+
39
+ ctx = OpenSSL::SSL::SSLContext.new
40
+ ctx.ssl_version = :TLSv1_2
41
+ ctx.alpn_select_cb = -> protocols {
42
+ raise "Client does not support HTTP/2: #{protocols}" unless protocols.include?("h2")
43
+ "h2"
44
+ }
45
+ ctx.tmp_ecdh_callback = -> (sock, ise, keyl) { OpenSSL::PKey::EC.new("prime256v1") }
46
+ ctx.cert = OpenSSL::X509::Certificate.new(cert)
47
+ ctx.key = OpenSSL::PKey::RSA.new(key)
48
+ tcp_server = ::TCPServer.new(lc[:hostname], lc[:port])
49
+ @server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
50
+ @server.start_immediately = false
51
+ end
52
+
53
+ def to_io
54
+ @server.to_io
55
+ end
56
+
57
+ def plum(sock)
58
+ ::Plum::HTTPSConnection.new(sock)
59
+ end
60
+
61
+ private
62
+ # returns: [cert, key]
63
+ def dummy_key
64
+ puts "WARNING: Generating new dummy certificate..."
65
+
66
+ key = OpenSSL::PKey::RSA.new(2048)
67
+ cert = OpenSSL::X509::Certificate.new
68
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/C=JP/O=Test/OU=Test/CN=example.com")
69
+ cert.not_before = Time.now
70
+ cert.not_after = Time.now + 363 * 24 * 60 * 60
71
+ cert.public_key = key.public_key
72
+ cert.serial = rand((1 << 20) - 1)
73
+ cert.version = 2
74
+
75
+ ef = OpenSSL::X509::ExtensionFactory.new
76
+ ef.subject_certificate = cert
77
+ ef.issuer_certificate = cert
78
+ cert.extensions = [
79
+ ef.create_extension("basicConstraints","CA:TRUE", true),
80
+ ef.create_extension("subjectKeyIdentifier", "hash"),
81
+ ]
82
+ cert.add_extension ef.create_extension("authorityKeyIdentifier", "keyid:always,issuer:always")
83
+
84
+ cert.sign key, OpenSSL::Digest::SHA1.new
85
+
86
+ [cert, key]
87
+ end
88
+ end
89
+
90
+ class UNIXListener < BaseListener
91
+ def initialize(lc)
92
+ if File.exist?(lc[:path])
93
+ begin
94
+ old = UNIXSocket.new(lc[:path])
95
+ rescue SystemCallError, IOError
96
+ File.unlink(lc[:path])
97
+ else
98
+ old.close
99
+ raise "Already a server bound to: #{lc[:path]}"
100
+ end
101
+ end
102
+
103
+ @server = ::UNIXServer.new(lc[:path])
104
+
105
+ File.chmod(lc[:mode], lc[:path]) if lc[:mode]
106
+ end
107
+
108
+ def stop
109
+ super
110
+ File.unlink(lc[:path])
111
+ end
112
+
113
+ def to_io
114
+ @server.to_io
115
+ end
116
+
117
+ def plum(sock)
118
+ ::Plum::HTTPSConnection.new(sock)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,68 @@
1
+ module Plum
2
+ module Rack
3
+ class Server
4
+ def initialize(app, config)
5
+ @state = :null
6
+ @app = config[:debug] ? ::Rack::CommonLogger.new(app) : app
7
+ @logger = Logger.new(config[:log] || $stdout).tap { |l|
8
+ l.level = config[:debug] ? Logger::DEBUG : Logger::INFO
9
+ }
10
+ @listeners = config[:listeners].map { |lc|
11
+ lc[:listener].new(lc)
12
+ }
13
+
14
+ @logger.info("Plum #{::Plum::VERSION}")
15
+ @logger.info("Config: #{config}")
16
+ end
17
+
18
+ def start
19
+ @state = :running
20
+ while @state == :running
21
+ break if @listeners.empty?
22
+ begin
23
+ if ss = IO.select(@listeners, nil, nil, 2.0)
24
+ ss[0].each { |svr|
25
+ new_con(svr)
26
+ }
27
+ end
28
+ rescue Errno::EBADF, Errno::ENOTSOCK, IOError => e # closed
29
+ rescue StandardError => e
30
+ log_exception(e)
31
+ end
32
+ end
33
+ end
34
+
35
+ def stop
36
+ @state = :stop
37
+ @listeners.map(&:stop)
38
+ # TODO: gracefully shutdown connections
39
+ end
40
+
41
+ private
42
+ def new_con(svr)
43
+ sock = svr.accept
44
+ Thread.new {
45
+ begin
46
+ sock = sock.accept if sock.respond_to?(:accept)
47
+ plum = svr.plum(sock)
48
+
49
+ con = Connection.new(@app, plum, @logger)
50
+ con.run
51
+ rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINVAL => e # closed
52
+ sock.close if sock
53
+ rescue StandardError => e
54
+ log_exception(e)
55
+ sock.close if sock
56
+ end
57
+ }
58
+ rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINVAL => e # closed
59
+ rescue StandardError => e
60
+ log_exception(e)
61
+ end
62
+
63
+ def log_exception(e)
64
+ @logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}")
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/plum/rack.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "logger"
2
+ require "stringio"
3
+ require "plum"
4
+ require "rack"
5
+ require "rack/handler/plum"
6
+ require "plum/rack/config"
7
+ require "plum/rack/dsl"
8
+ require "plum/rack/listener"
9
+ require "plum/rack/server"
10
+ require "plum/rack/connection"