plum 0.0.3 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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"