spinel-spinner 0.1.0.pre.1

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,116 @@
1
+ require "ffi"
2
+
3
+ module Spinner
4
+ module TLSNative
5
+ extend FFI::Library
6
+
7
+ def self.load!
8
+ return if @loaded
9
+ # macOS's system libssl can be incompatible with the libcrypto used by
10
+ # Spinel's bundled OpenSSL package. Allow an explicit, matching library.
11
+ library = ENV["SPINNER_SSL_LIBRARY"]
12
+ if library.nil? || library.empty?
13
+ library = ["/opt/homebrew/opt/openssl@3/lib/libssl.dylib",
14
+ "/usr/local/opt/openssl@3/lib/libssl.dylib"].find { |path| File.file?(path) }
15
+ library ||= "libssl.so.3"
16
+ end
17
+ ffi_lib library
18
+
19
+ attach_function :TLS_server_method, [], :pointer
20
+ callback :alpn_select, [:pointer, :pointer, :pointer, :pointer, :uint, :pointer], :int
21
+ attach_function :SSL_CTX_set_alpn_select_cb, [:pointer, :alpn_select, :pointer], :void
22
+ attach_function :SSL_CTX_new, [:pointer], :pointer
23
+ attach_function :SSL_CTX_free, [:pointer], :void
24
+ attach_function :SSL_CTX_ctrl, [:pointer, :int, :long, :pointer], :long
25
+ attach_function :SSL_CTX_use_certificate_chain_file, [:pointer, :string], :int
26
+ attach_function :SSL_CTX_use_PrivateKey_file, [:pointer, :string, :int], :int
27
+ attach_function :SSL_CTX_check_private_key, [:pointer], :int
28
+ attach_function :SSL_new, [:pointer], :pointer
29
+ attach_function :SSL_free, [:pointer], :void
30
+ attach_function :SSL_set_fd, [:pointer, :int], :int
31
+ attach_function :SSL_accept, [:pointer], :int
32
+ attach_function :SSL_get_error, [:pointer, :int], :int
33
+ attach_function :SSL_read, [:pointer, :pointer, :int], :int
34
+ attach_function :SSL_write, [:pointer, :pointer, :int], :int
35
+ attach_function :SSL_pending, [:pointer], :int
36
+ attach_function :SSL_shutdown, [:pointer], :int
37
+ attach_function :ERR_get_error, [], :ulong
38
+ attach_function :ERR_error_string_n, [:ulong, :pointer, :size_t], :void
39
+
40
+ @loaded = true
41
+ end
42
+
43
+ def self.error(prefix)
44
+ code = self.ERR_get_error
45
+ return prefix if code == 0
46
+ buffer = FFI::MemoryPointer.new(:char, 256)
47
+ self.ERR_error_string_n(code, buffer, 256)
48
+ "#{prefix}: #{buffer.read_string}"
49
+ end
50
+ end
51
+
52
+ module TLSFileControl
53
+ extend FFI::Library
54
+ ffi_lib FFI::Library::LIBC
55
+ attach_function :fcntl, [:int, :int, :int], :int
56
+
57
+ def self.nonblock!(fd)
58
+ flags = fcntl(fd, 3, 0) # F_GETFL
59
+ raise IOError, "fcntl(F_GETFL) failed" if flags < 0
60
+ bit = RUBY_PLATFORM.include?("darwin") ? 4 : 2048
61
+ raise IOError, "fcntl(F_SETFL) failed" if fcntl(fd, 4, flags | bit) < 0
62
+ end
63
+ end
64
+
65
+ module TLS
66
+ class Context
67
+ attr_reader :pointer
68
+
69
+ def initialize(cert_file, key_file)
70
+ TLSNative.load!
71
+ @pointer = TLSNative.SSL_CTX_new(TLSNative.TLS_server_method)
72
+ raise IOError, TLSNative.error("SSL_CTX_new") if @pointer.null?
73
+ begin
74
+ # SSL_CTRL_SET_MIN_PROTO_VERSION, TLS1_2_VERSION.
75
+ raise IOError, "TLS 1.2 minimum unavailable" unless TLSNative.SSL_CTX_ctrl(@pointer, 123, 0x0303, nil) == 1
76
+ raise IOError, "TLS 1.3 maximum unavailable" unless TLSNative.SSL_CTX_ctrl(@pointer, 124, 0x0304, nil) == 1
77
+ @alpn_callback = proc do |_ssl, output, output_length, offered, length, _arg|
78
+ protocols = offered.read_string(length)
79
+ offset = 0
80
+ selected = nil
81
+ while offset < length
82
+ size = protocols.getbyte(offset)
83
+ break if size.nil? || size == 0 || offset + size + 1 > length
84
+ if protocols.byteslice(offset + 1, size) == "http/1.1"
85
+ selected = offset + 1
86
+ break
87
+ end
88
+ offset += size + 1
89
+ end
90
+ if selected.nil?
91
+ 2 # fatal alert: never select HTTP/2
92
+ else
93
+ output.write_pointer(offered + selected)
94
+ output_length.write_uint8(8)
95
+ 0
96
+ end
97
+ end
98
+ TLSNative.SSL_CTX_set_alpn_select_cb(@pointer, @alpn_callback, nil)
99
+ raise IOError, TLSNative.error("certificate chain") unless TLSNative.SSL_CTX_use_certificate_chain_file(@pointer, cert_file) == 1
100
+ raise IOError, TLSNative.error("private key") unless TLSNative.SSL_CTX_use_PrivateKey_file(@pointer, key_file, 1) == 1
101
+ raise IOError, TLSNative.error("certificate/key mismatch") unless TLSNative.SSL_CTX_check_private_key(@pointer) == 1
102
+ rescue StandardError
103
+ close
104
+ raise
105
+ end
106
+ end
107
+
108
+ def close
109
+ return if @pointer.nil? || @pointer.null?
110
+ TLSNative.SSL_CTX_free(@pointer)
111
+ @pointer = nil
112
+ end
113
+ end
114
+
115
+ end
116
+ end
@@ -0,0 +1,121 @@
1
+ module Spinner
2
+ module TLS
3
+ class Socket
4
+ def initialize(socket, context)
5
+ @socket = socket
6
+ @io_lock = Mutex.new
7
+ @ssl = nil
8
+ @handshake_complete = false
9
+ @pending_write = nil
10
+ @pending_bytes = nil
11
+ return if context.nil?
12
+ TLSFileControl.nonblock!(socket.fileno)
13
+ @ssl = TLSNative.SSL_new(context.pointer)
14
+ raise IOError, TLSNative.error("SSL_new") if @ssl.null?
15
+ unless TLSNative.SSL_set_fd(@ssl, socket.fileno) == 1
16
+ close
17
+ raise IOError, TLSNative.error("SSL_set_fd")
18
+ end
19
+ end
20
+
21
+ def to_io
22
+ @socket
23
+ end
24
+
25
+ def closed?
26
+ @socket.closed?
27
+ end
28
+
29
+ def pending
30
+ @io_lock.synchronize { pending_unlocked }
31
+ end
32
+
33
+ def pending_unlocked
34
+ @ssl.nil? ? 0 : TLSNative.SSL_pending(@ssl)
35
+ end
36
+
37
+ def accept_nonblock
38
+ @io_lock.synchronize { accept_nonblock_unlocked }
39
+ end
40
+
41
+ def accept_nonblock_unlocked
42
+ return self if @ssl.nil?
43
+ result = TLSNative.SSL_accept(@ssl)
44
+ if result == 1
45
+ @handshake_complete = true
46
+ return self
47
+ end
48
+ wait_or_raise(result, "SSL_accept")
49
+ end
50
+
51
+ def read_nonblock(max_bytes)
52
+ @io_lock.synchronize { read_nonblock_unlocked(max_bytes) }
53
+ end
54
+
55
+ def read_nonblock_unlocked(max_bytes)
56
+ return @socket.read_nonblock(max_bytes) if @ssl.nil?
57
+ buffer = FFI::MemoryPointer.new(:char, max_bytes)
58
+ result = TLSNative.SSL_read(@ssl, buffer, max_bytes)
59
+ return buffer.read_string(result).b if result > 0
60
+ wait_or_raise(result, "SSL_read")
61
+ end
62
+
63
+ def write_nonblock(bytes)
64
+ @io_lock.synchronize { write_nonblock_unlocked(bytes) }
65
+ end
66
+
67
+ # Keep the binary conversion at the native socket call for framed writes.
68
+ def write_stream_nonblock(bytes)
69
+ @io_lock.synchronize do
70
+ if @ssl.nil?
71
+ @socket.write_nonblock(bytes.b)
72
+ else
73
+ write_nonblock_unlocked(bytes)
74
+ end
75
+ end
76
+ end
77
+
78
+ def write_nonblock_unlocked(bytes)
79
+ return @socket.write_nonblock(bytes.b) if @ssl.nil?
80
+ if @pending_write.nil?
81
+ @pending_write = FFI::MemoryPointer.new(:char, bytes.bytesize)
82
+ @pending_write.put_bytes(0, bytes)
83
+ @pending_bytes = bytes
84
+ elsif @pending_bytes != bytes
85
+ raise IOError, "TLS write changed during retry"
86
+ end
87
+ result = TLSNative.SSL_write(@ssl, @pending_write, bytes.bytesize)
88
+ if result > 0
89
+ @pending_write = nil
90
+ @pending_bytes = nil
91
+ return result
92
+ end
93
+ wait_or_raise(result, "SSL_write")
94
+ end
95
+
96
+ def close
97
+ @io_lock.synchronize { close_unlocked }
98
+ end
99
+
100
+ def close_unlocked
101
+ unless @ssl.nil? || @ssl.null?
102
+ TLSNative.SSL_shutdown(@ssl) if @handshake_complete && !@socket.closed?
103
+ TLSNative.SSL_free(@ssl)
104
+ @ssl = nil
105
+ end
106
+ @socket.close unless @socket.closed?
107
+ end
108
+
109
+ private
110
+
111
+ def wait_or_raise(result, operation)
112
+ case TLSNative.SSL_get_error(@ssl, result)
113
+ when 2 then raise IO::EAGAINWaitReadable
114
+ when 3 then raise IO::EAGAINWaitWritable
115
+ when 6 then raise EOFError
116
+ else raise IOError, TLSNative.error(operation)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,20 @@
1
+ module Spinner
2
+ # The callback owns the transport for its lifetime. Returning closes it.
3
+ class Upgrade
4
+ def initialize(close_handler = nil, &handler)
5
+ @handler = handler
6
+ @close_handler = close_handler
7
+ @closed = false
8
+ end
9
+
10
+ def call(transport, buffered_bytes)
11
+ @handler.call(transport, buffered_bytes)
12
+ end
13
+
14
+ def close
15
+ return if @closed
16
+ @closed = true
17
+ @close_handler.call unless @close_handler.nil?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spinner
4
+ VERSION = "0.1.0.pre.1"
5
+ end
@@ -0,0 +1,179 @@
1
+ require "base64"
2
+ require "digest/sha1"
3
+ require_relative "../spinner"
4
+
5
+ module Spinner
6
+ module WebSocket
7
+ class Adapter
8
+ def initialize(application, max_message_bytes: 1048576, idle_timeout: 30.0)
9
+ raise ArgumentError, "invalid WebSocket limits" unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0 && idle_timeout.is_a?(Numeric) && idle_timeout > 0 && idle_timeout.finite?
10
+ @application = application
11
+ @maximum = max_message_bytes
12
+ @timeout = idle_timeout
13
+ end
14
+
15
+ def call(env)
16
+ upgrades = env["HTTP_UPGRADE"].to_s.downcase.split(",").map(&:strip)
17
+ return @application.call(env) unless upgrades.include?("websocket")
18
+ return [400, {}, ["Invalid WebSocket upgrade"]] unless upgrades == ["websocket"]
19
+ key = env["HTTP_SEC_WEBSOCKET_KEY"].to_s
20
+ valid_key = key.match?(/\A[A-Za-z0-9+\/]{22}==\z/)
21
+ valid_key &&= Base64.strict_decode64(key).bytesize == 16
22
+ valid_key &&= Base64.strict_encode64(Base64.strict_decode64(key)) == key
23
+ connection = env["HTTP_CONNECTION"].to_s.downcase.split(",").map(&:strip)
24
+ unless valid_key && connection.include?("upgrade") && env["REQUEST_METHOD"] == "GET" &&
25
+ env["SERVER_PROTOCOL"] == "HTTP/1.1" && env["HTTP_SEC_WEBSOCKET_VERSION"] == "13" &&
26
+ env["CONTENT_LENGTH"].to_s.to_i == 0 && env["HTTP_TRANSFER_ENCODING"].nil?
27
+ return [400, {}, ["Invalid WebSocket request"]]
28
+ end
29
+ response = @application.call(env)
30
+ return response unless response.is_a?(Array) && response.length == 3 && response[0] == 101
31
+ handler = response[2]
32
+ raise ArgumentError, "expected WebSocket handler" unless handler.respond_to?(:call)
33
+ accept = Base64.strict_encode64(Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
34
+ headers = response[1].merge("upgrade" => "websocket", "sec-websocket-accept" => accept)
35
+ upgrade = Upgrade.new(proc { handler.close if handler.respond_to?(:close) }) do |transport, buffered|
36
+ session = Session.new(transport, buffered, @maximum, @timeout)
37
+ begin
38
+ handler.call(session)
39
+ ensure
40
+ session.close
41
+ end
42
+ end
43
+ [101, headers, upgrade]
44
+ rescue ArgumentError
45
+ [400, {}, ["Invalid WebSocket request"]]
46
+ end
47
+ end
48
+
49
+ class Session
50
+ def initialize(transport, buffered, maximum, timeout)
51
+ @transport = transport
52
+ @buffer = buffered.b
53
+ @maximum = maximum
54
+ @timeout = timeout
55
+ @closed = false
56
+ @received_close = false
57
+ end
58
+
59
+ def exact(length, deadline)
60
+ while @buffer.bytesize < length
61
+ @buffer << @transport.read_chunk(deadline, [4096, length - @buffer.bytesize].min, "websocket")
62
+ end
63
+ @buffer.slice!(0, length).b
64
+ end
65
+
66
+ def read_message
67
+ data = "".b
68
+ message_opcode = nil
69
+ deadline = Transport.now + @timeout
70
+ loop do
71
+ head = exact(2, deadline)
72
+ first = head.getbyte(0)
73
+ second = head.getbyte(1)
74
+ fin = (first & 128) != 0
75
+ opcode = first & 15
76
+ return protocol_error(1002) if (first & 112) != 0 || (second & 128) == 0
77
+ length = second & 127
78
+ if length == 126 || length == 127
79
+ count = length == 126 ? 2 : 8
80
+ encoded = exact(count, deadline)
81
+ length = 0
82
+ return protocol_error(1002) if count == 8 && (encoded.getbyte(0) & 128) != 0
83
+ encoded.bytes.each do |byte|
84
+ return protocol_error(1009) if length > (@maximum >> 8)
85
+ length = (length << 8) | byte
86
+ end
87
+ return protocol_error(1002) if (count == 2 && length < 126) || (count == 8 && length < 65536)
88
+ end
89
+ return protocol_error(1009) if opcode < 8 && (length > @maximum || data.bytesize + length > @maximum)
90
+ return protocol_error(1002) if opcode >= 8 && (!fin || length > 125)
91
+ mask = exact(4, deadline)
92
+ payload = exact(length, deadline)
93
+ decoded = "".b
94
+ payload.bytes.each_with_index { |byte, index| decoded << (byte ^ mask.getbyte(index % 4)).chr.b }
95
+ if opcode == 8
96
+ return protocol_error(1002) if decoded.bytesize == 1
97
+ if decoded.bytesize >= 2
98
+ code = (decoded.getbyte(0) << 8) | decoded.getbyte(1)
99
+ return protocol_error(1002) unless [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014].include?(code) || (code >= 3000 && code <= 4999)
100
+ return protocol_error(1007) unless decoded.byteslice(2, decoded.bytesize - 2).dup.force_encoding("UTF-8").valid_encoding?
101
+ end
102
+ write_frame(8, decoded) unless @closed
103
+ @received_close = true
104
+ @closed = true
105
+ return nil
106
+ elsif opcode == 9
107
+ write_frame(10, decoded)
108
+ next
109
+ elsif opcode == 10
110
+ next
111
+ elsif opcode == 1 || opcode == 2
112
+ return protocol_error(1002) unless message_opcode.nil?
113
+ message_opcode = opcode
114
+ elsif opcode == 0
115
+ return protocol_error(1002) if message_opcode.nil?
116
+ else
117
+ return protocol_error(1002)
118
+ end
119
+ data << decoded
120
+ if fin
121
+ return protocol_error(1007) if message_opcode == 1 && !data.dup.force_encoding("UTF-8").valid_encoding?
122
+ return [message_opcode, data]
123
+ end
124
+ end
125
+ end
126
+
127
+ def write_message(opcode, bytes)
128
+ raise IOError, "closed WebSocket" if @closed
129
+ raise ArgumentError, "invalid message" unless [1, 2].include?(opcode) && bytes.is_a?(String) && bytes.bytesize <= @maximum
130
+ raise ArgumentError, "invalid text message" if opcode == 1 && !bytes.dup.force_encoding("UTF-8").valid_encoding?
131
+ write_frame(opcode, bytes)
132
+ end
133
+
134
+ def write_frame(opcode, bytes)
135
+ length = bytes.bytesize
136
+ header = [128 | opcode]
137
+ if length < 126
138
+ header << length
139
+ else
140
+ count = length <= 65535 ? 2 : 8
141
+ header << (count == 2 ? 126 : 127)
142
+ (count - 1).downto(0) { |index| header << ((length >> (index * 8)) & 255) }
143
+ end
144
+ deadline = Transport.now + @timeout
145
+ write_binary_frame_bytes(header.pack("C*").b, deadline)
146
+ # Pack dynamic payloads too: Spinel's native socket otherwise stops
147
+ # a decoded string at an embedded NUL during an echo.
148
+ write_binary_frame_bytes(bytes.bytes.pack("C*").b, deadline)
149
+ end
150
+
151
+ def write_binary_frame_bytes(bytes, deadline)
152
+ offset = 0
153
+ while offset < bytes.bytesize
154
+ remaining = bytes.byteslice(offset, bytes.bytesize - offset).b
155
+ count = @transport.write_stream_chunk(remaining, deadline)
156
+ raise IOError, "invalid WebSocket write progress" if count <= 0 || count > remaining.bytesize
157
+ offset += count
158
+ end
159
+ end
160
+
161
+ def protocol_error(code)
162
+ close(code, false)
163
+ raise IOError, "invalid WebSocket frame"
164
+ end
165
+
166
+ def close(code = 1000, wait = true)
167
+ return if @closed
168
+ @closed = true
169
+ write_frame(8, (code >> 8).chr.b + (code & 255).chr.b)
170
+ deadline = Transport.now + @timeout
171
+ while wait && !@received_close && Transport.now < deadline
172
+ read_message
173
+ end
174
+ rescue IOError, SystemCallError, RequestError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+ end
data/lib/spinner.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spinner/version"
4
+ require "socket"
5
+ require_relative "spinner/transport_socket"
6
+ require_relative "spinner/input"
7
+ require_relative "spinner/body_storage"
8
+ require_relative "spinner/task_pool"
9
+ require_relative "spinner/proxy_metadata"
10
+ require_relative "spinner/request_parser"
11
+ require_relative "spinner/chunk_decoder"
12
+ require_relative "spinner/host_authority"
13
+ require_relative "spinner/upgrade"
14
+ require_relative "spinner/response_writer"
15
+ require_relative "spinner/reactor"
16
+ require_relative "spinner/server"
17
+ require_relative "spinner/thread_pool"
18
+
19
+ module Spinner
20
+ class Error < StandardError; end
21
+ end
data/sig/spinner.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Spinner
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spinel-spinner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.1
5
+ platform: ruby
6
+ authors:
7
+ - Dillon Wyatt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Spinner compiles with a Spinel application to serve HTTP/1.1 traffic.
13
+ email:
14
+ - dostaglou@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - lib/spinner.rb
23
+ - lib/spinner/body_storage.rb
24
+ - lib/spinner/chunk_decoder.rb
25
+ - lib/spinner/host_authority.rb
26
+ - lib/spinner/input.rb
27
+ - lib/spinner/proxy_metadata.rb
28
+ - lib/spinner/reactor.rb
29
+ - lib/spinner/request_parser.rb
30
+ - lib/spinner/response_writer.rb
31
+ - lib/spinner/server.rb
32
+ - lib/spinner/task_pool.rb
33
+ - lib/spinner/thread_pool.rb
34
+ - lib/spinner/tls.rb
35
+ - lib/spinner/transport_socket.rb
36
+ - lib/spinner/upgrade.rb
37
+ - lib/spinner/version.rb
38
+ - lib/spinner/websocket.rb
39
+ - sig/spinner.rbs
40
+ homepage: https://github.com/dostaglou/spinner
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ source_code_uri: https://github.com/dostaglou/spinner
45
+ changelog_uri: https://github.com/dostaglou/spinner/blob/main/CHANGELOG.md
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.0.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.6.9
61
+ specification_version: 4
62
+ summary: A Spinel-compatible HTTP application server
63
+ test_files: []