mfp 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,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ module Server
5
+ class Conn
6
+ attr_reader :id, :application, :hostname, :max_streams, :max_payload, :closed
7
+
8
+ def initialize(server, id, peer_socket, call_sign, settings)
9
+ @logger = settings.logger
10
+ @server = server
11
+ @sock = peer_socket
12
+ @call_sign = call_sign
13
+ @max_streams = settings.max_streams
14
+ @max_payload = settings.max_payload
15
+ @id = id
16
+ @first_frame_timeout = settings.first_frame_timeout_secs
17
+
18
+ @to_write = Async::Queue.new
19
+ @to_read = Async::Queue.new
20
+ @compression = Compressor.new(:identity)
21
+ @ping_handler = PingHandler.new(
22
+ idle_timeout: settings.idle_timeout_secs,
23
+ ping_timeout: settings.ping_timeout_secs,
24
+ )
25
+
26
+ @application = nil
27
+ @hostname = nil
28
+ @closed = false
29
+ end
30
+
31
+ def start(task)
32
+ @task = task.async do |t|
33
+ @write_task = t.async { handle_writes }
34
+ begin
35
+ status, err = t.with_timeout(@first_frame_timeout) { perform_handshake }
36
+ rescue Async::TimeoutError => e
37
+ @logger.error("Timeout waiting for full handshake", e)
38
+ status = :failed
39
+ err = e
40
+ rescue StandardError => e
41
+ @logger.error("Failed performing handshake", e)
42
+ status = :failed
43
+ err = e
44
+ end
45
+
46
+ if status == :failed
47
+ @logger.error("Failed completing handshake", err)
48
+ next
49
+ end
50
+
51
+ @server.connect(self)
52
+ @ping_handler.start(t)
53
+ t.async { handle_pings }
54
+
55
+ run_loop
56
+ ensure
57
+ close
58
+ end
59
+ end
60
+
61
+ def close
62
+ return if @closed
63
+
64
+ @logger.debug("Closing connection")
65
+
66
+ @closed = true
67
+
68
+ @ping_handler&.stop
69
+ @sock&.close
70
+ @server.disconnect(self)
71
+ @to_read.close
72
+ @task&.cancel
73
+ end
74
+
75
+ def recv = @to_read.dequeue
76
+
77
+ def send_stream(stream_id, msg, flags: 0)
78
+ raise ArgumentError, "msg must be a String or nil" unless msg.nil? || msg.is_a?(String)
79
+
80
+ send_conn_frame(Proto::Frame.new(
81
+ stream_id:,
82
+ kind: Proto::MessageKind::STREAM,
83
+ flags:,
84
+ length: msg&.bytesize || 0,
85
+ payload: msg,
86
+ ))
87
+ end
88
+
89
+ def send_control(stream_id, msg, flags: 0)
90
+ raise ArgumentError, "msg must be a String or nil" unless msg.nil? || msg.is_a?(String)
91
+
92
+ send_conn_frame(Proto::Frame.new(
93
+ stream_id:,
94
+ kind: Proto::MessageKind::CONTROL,
95
+ flags:,
96
+ length: msg&.bytesize || 0,
97
+ payload: msg,
98
+ ))
99
+ end
100
+
101
+ def send_error(stream_id, terminal:, code:, debug:)
102
+ debug = debug.to_s unless debug.nil?
103
+ msg = Proto::Error.new(code, terminal:, debug:)
104
+ send_conn_frame(msg.encode(stream_id))
105
+ end
106
+
107
+ private
108
+
109
+ def run_loop
110
+ @logger.debug("Client entering runloop")
111
+ loop do
112
+ begin
113
+ fr = read_frame
114
+ rescue StandardError => e
115
+ unless @closed
116
+ @logger.error("Failed reading frame", e)
117
+ @to_read.enqueue ConnectionEvent.new(CONNECTION_EVENT_CODE_BROKEN)
118
+ close
119
+ end
120
+ break
121
+ end
122
+ @ping_handler.notify_received
123
+ handle_frame(fr)
124
+ end
125
+ end
126
+
127
+ def failure(msg) = StandardError.new(msg)
128
+
129
+ def read_frame = Proto::Frame.read(@sock, @max_payload, @call_sign)
130
+
131
+ def perform_handshake
132
+ fr = read_frame
133
+ if fr.kind != Proto::MessageKind::HELLO
134
+ @logger.debug("Rejecting client: Invalid frame kind for handshake")
135
+ return [:failed, failure("invalid frame kind")]
136
+ end
137
+
138
+ hello = Proto::Hello.read(fr)
139
+ if [hello.version_major, hello.version_minor] != [1, 0]
140
+ @logger.debug("Rejecting client: Invalid version", vMaj: hello.version_major, vMin: hello.version_minor)
141
+ return [:failed, failure("invalid version")]
142
+ end
143
+
144
+ @application = hello.application
145
+ @hostname = hello.host
146
+
147
+ answer = Proto::HelloResponse.new(
148
+ capabilities: handle_caps(hello.capabilities),
149
+ max_streams: @max_streams,
150
+ max_payload: @max_payload,
151
+ )
152
+
153
+ begin
154
+ send_conn(answer)
155
+ rescue StandardError => e
156
+ @logger.error("Failed replying to hello", e)
157
+ terminate_error(Proto::ErrorKind::INTERNAL_ERROR)
158
+ return
159
+ end
160
+
161
+ case answer.capabilities
162
+ when Proto::Capability::COMPRESSION_BROTLI
163
+ @compression = Compressor.new(:brotli)
164
+ when Proto::Capability::COMPRESSION_GZIP
165
+ @compression = Compressor.new(:gzip)
166
+ end
167
+
168
+ [:ok, nil]
169
+ rescue Mfp::Error => e
170
+ [:failed, e]
171
+ end
172
+
173
+ def handle_writes
174
+ until @closed
175
+ msg = @to_write.dequeue
176
+ frm = msg.frame
177
+ frm.call_sign = @call_sign
178
+
179
+ begin
180
+ @sock.write(frm.encode)
181
+ rescue StandardError => e
182
+ msg.complete(e)
183
+ next
184
+ end
185
+
186
+ msg.complete(nil)
187
+ end
188
+ end
189
+
190
+ def send_conn(msg)
191
+ raise ConnectionClosedError if @closed
192
+
193
+ fr = msg.encode(0)
194
+ send_conn_frame(fr)
195
+ end
196
+
197
+ def send_conn_frame(fr)
198
+ raise ConnectionClosedError if @closed
199
+
200
+ @logger.debug("Sending frame", frame: fr.inspect)
201
+
202
+ fr.call_sign = @call_sign
203
+ if fr.kind == Proto::MessageKind::STREAM && fr.length.positive? && @compression.kind != :identity
204
+ fr.payload = @compression.compress(fr.payload)
205
+ fr.length = fr.payload.bytesize
206
+ end
207
+ out = OutgoingMessage.new(fr)
208
+ err = Async::Task.current.defer_cancel do
209
+ @to_write.enqueue out
210
+ out.wait
211
+ end
212
+ raise err if err
213
+ end
214
+
215
+ def handle_pings
216
+ until @closed
217
+ msg = @ping_handler.recv
218
+ break if msg.nil?
219
+
220
+ case msg[:kind]
221
+ when :request
222
+ begin
223
+ send_conn(Proto::Ping.new(ack: false, payload: msg[:cookie]))
224
+ rescue StandardError => e
225
+ @logger.error("Failed sending ping", e)
226
+ @to_read.enqueue(ConnectionEvent.new(code: CONNECTION_EVENT_CODE_BROKEN))
227
+ close
228
+ return
229
+ end
230
+
231
+ when :timeout
232
+ @logger.warn("Timed out waiting for ping acknowledgement. Dropping connection...")
233
+ @to_read.enqueue(ConnectionEvent.new(code: CONNECTION_EVENT_CODE_TIMED_OUT))
234
+ close
235
+ return
236
+ end
237
+ end
238
+ end
239
+
240
+ def handle_caps(r)
241
+ if r.anybits?(Proto::Capability::COMPRESSION_BROTLI)
242
+ Proto::Capability::COMPRESSION_BROTLI
243
+ elsif r.anybits?(Proto::Capability::COMPRESSION_GZIP)
244
+ Proto::Capability::COMPRESSION_GZIP
245
+ else
246
+ 0
247
+ end
248
+ end
249
+
250
+ def handle_connection_error(fr)
251
+ err = nil
252
+ failed = false
253
+ begin
254
+ err = Proto::Error.read(fr)
255
+ rescue StandardError => e
256
+ @logger.error("Received connection-level error message from client, but decoding failed", e)
257
+ failed = true
258
+ end
259
+
260
+ unless failed
261
+ kvs = {
262
+ code: err.code,
263
+ debug_len: err.debug.respond_to?(:bytesize) ? err.debug.bytesize : 0,
264
+ }
265
+ kvs[:debug] = err.debug if !err.debug.nil? && err.debug.is_a?(String)
266
+
267
+ @logger.error("Received connection-level error message from client", **kvs)
268
+ end
269
+
270
+ @to_read.enqueue(ConnectionEvent.new(code: CONNECTION_EVENT_CODE_BROKEN))
271
+ close
272
+ end
273
+
274
+ def handle_stream_error(fr)
275
+ err = Proto::Error.read(fr)
276
+ @to_read.enqueue(ErrorEvent.new(
277
+ stream_id: fr.stream_id,
278
+ code: err.code,
279
+ terminal: err.terminal,
280
+ debug: err.debug,
281
+ ))
282
+ rescue StandardError => e
283
+ @logger.error("Failed handling stream error", e)
284
+ terminate_error(Proto::ErrorKind::PROTOCOL_ERROR)
285
+ end
286
+
287
+ def handle_ping(fr)
288
+ png = Proto::Ping.read(fr)
289
+ return @ping_handler.notify_pong(png.payload) if png.ack
290
+
291
+ send_conn(Proto::Ping.new(
292
+ ack: true,
293
+ payload: png.payload,
294
+ ))
295
+ end
296
+
297
+ def handle_frame(fr)
298
+ @logger.debug("Read frame", frame: fr.inspect)
299
+ case fr.kind
300
+ when Proto::MessageKind::HELLO, Proto::MessageKind::HELLO_RESPONSE, Proto::MessageKind::UNKNOWN
301
+ terminate_error(Proto::ErrorKind::PROTOCOL_ERROR)
302
+
303
+ when Proto::MessageKind::ERROR
304
+ return handle_connection_error(fr) if fr.stream_id.zero?
305
+
306
+ handle_stream_error(fr)
307
+
308
+ when Proto::MessageKind::CONTROL
309
+ @to_read.enqueue(ControlEvent.new(
310
+ stream_id: fr.stream_id,
311
+ payload: fr.payload,
312
+ ))
313
+
314
+ when Proto::MessageKind::PING
315
+ return terminate_error(Proto::ErrorKind::PROTOCOL_ERROR) unless fr.stream_id.zero?
316
+ return terminate_error(Proto::ErrorKind::PROTOCOL_ERROR) if fr.length != 8
317
+
318
+ begin
319
+ handle_ping(fr)
320
+ rescue StandardError => e
321
+ @logger.error("Failed handling ping", e)
322
+ end
323
+
324
+ when Proto::MessageKind::STREAM
325
+ return terminate_error(Proto::ErrorKind::PROTOCOL_ERROR) if fr.stream_id.zero?
326
+
327
+ if @compression.kind != :identity && fr.length.positive?
328
+ begin
329
+ out = @compression.decompress(fr.payload)
330
+ fr.payload = out
331
+ fr.length = out.bytesize
332
+ rescue StandardError => e
333
+ @logger.error("Failed decompressing stream", e)
334
+ terminate_error(Proto::ErrorKind::COMPRESSION_ERROR)
335
+ return
336
+ end
337
+ end
338
+
339
+ @to_read.enqueue(StreamEvent.new(
340
+ stream_id: fr.stream_id,
341
+ payload: fr.payload,
342
+ ))
343
+ end
344
+ end
345
+
346
+ def terminate_error(code)
347
+ @to_read.enqueue(ConnectionEvent.new(
348
+ code: CONNECTION_EVENT_CODE_BROKEN,
349
+ ))
350
+ begin
351
+ send_conn(Proto::Error.new(
352
+ code,
353
+ terminal: true,
354
+ ))
355
+ rescue StandardError
356
+ # noop
357
+ end
358
+ close
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ module Server
5
+ Settings = Data.define(
6
+ :max_streams,
7
+ :max_payload,
8
+ :idle_timeout_secs,
9
+ :ping_timeout_secs,
10
+ :first_frame_timeout_secs,
11
+ :logger,
12
+ )
13
+
14
+ class Server
15
+ DEFAULT_OPTIONS = {
16
+ max_streams: 0,
17
+ max_payload: 1 << 24,
18
+ idle_timeout_secs: 30,
19
+ ping_timeout_secs: 10,
20
+ cert: nil,
21
+ key: nil,
22
+ ca_certs: [],
23
+ client_auth: nil,
24
+ first_frame_timeout_secs: 5,
25
+ logger: Logrb.noop,
26
+ }.freeze
27
+
28
+ attr_reader :port
29
+
30
+ def initialize(host, port, call_sign, **opts)
31
+ raise ArgumentError, "call_sign must have 4 bytes" if call_sign.bytesize != 4
32
+
33
+ opts = DEFAULT_OPTIONS.merge(opts)
34
+ @client_id = 0
35
+ @clients = {}
36
+ @client_queue = Async::Queue.new
37
+ @call_sign = call_sign
38
+
39
+ @logger = opts[:logger]
40
+
41
+ @listener = if opts[:cert] && opts[:key]
42
+ @logger.debug("Starting TLS server", host:, port:)
43
+ ctx = Mfp.default_tls_context
44
+ ctx.cert = OpenSSL::X509::Certificate.new(opts[:cert])
45
+ ctx.key = OpenSSL::PKey.read(opts[:key])
46
+ store = OpenSSL::X509::Store.new
47
+ store.set_default_paths
48
+ opts[:ca_certs].each do |raw|
49
+ store.add_cert(OpenSSL::X509::Certificate.new(raw))
50
+ end
51
+ ctx.cert_store = store
52
+ ctx.verify_mode = opts[:client_auth] if opts[:client_auth]
53
+ IO::Endpoint.ssl(host, port, ssl_context: ctx)
54
+ else
55
+ @logger.debug("Starting plain server", host:, port:)
56
+ IO::Endpoint.tcp(host, port)
57
+ end
58
+
59
+ @settings = Settings.new(
60
+ opts[:max_streams],
61
+ opts[:max_payload],
62
+ opts[:idle_timeout_secs],
63
+ opts[:ping_timeout_secs],
64
+ opts[:first_frame_timeout_secs],
65
+ opts[:logger],
66
+ )
67
+
68
+ @sockets = @listener.bind
69
+ @port = @sockets.first.local_address.ip_port
70
+ end
71
+
72
+ def start(task)
73
+ return if @running
74
+
75
+ @running = true
76
+ @accept_tasks = @sockets.map do |sock|
77
+ task.async do |t|
78
+ loop do
79
+ peer = nil
80
+ begin
81
+ peer, = sock.accept
82
+ peer.accept if peer.respond_to?(:accept) && peer.is_a?(OpenSSL::SSL::SSLSocket)
83
+ rescue StandardError => e
84
+ @logger.debug("Error accepting connection", err: e.to_s)
85
+ peer&.close
86
+ next
87
+ end
88
+ client_addr = peer.remote_address.inspect_sockaddr
89
+ @logger.debug("Accepting connection", addr: client_addr)
90
+ id = @client_id
91
+ @client_id += 1
92
+ settings = Settings.new(**@settings.to_h, logger: @logger.with_fields(id:))
93
+ conn = Conn.new(self, id, peer, @call_sign, settings)
94
+ @logger.debug("Created client", addr: client_addr, id:)
95
+ @clients[id] = conn
96
+ @logger.debug("Starting client in background", id:)
97
+ conn.start(t)
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ def close
104
+ @accept_tasks&.each(&:stop)
105
+ @sockets.each(&:close)
106
+ end
107
+
108
+ def connect(client) = @client_queue.enqueue(client)
109
+
110
+ def disconnect(client)
111
+ @clients.delete(client.id)
112
+ @logger.debug("Disconnected client", id: client.id, connected_clients: @clients.length)
113
+ end
114
+
115
+ def accept = @client_queue.dequeue
116
+ end
117
+ end
118
+ end
data/lib/mfp/server.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server/server"
4
+ require_relative "server/conn"
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ module_function
5
+
6
+ def default_tls_context
7
+ OpenSSL::SSL::SSLContext.new.tap do |v|
8
+ v.min_version = v.max_version = OpenSSL::SSL::TLS1_3_VERSION
9
+ v.ecdh_curves = "X25519:P-256:P-384"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mfp
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mfp.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "io/endpoint"
5
+ require "io/endpoint/host_endpoint"
6
+ require "io/endpoint/ssl_endpoint"
7
+ require "brotli"
8
+ require "zlib"
9
+ require "openssl"
10
+ require "logrb"
11
+
12
+ require_relative "mfp/version"
13
+ require_relative "mfp/enum"
14
+ require_relative "mfp/tls_config"
15
+ require_relative "mfp/error"
16
+ require_relative "mfp/compressor"
17
+ require_relative "mfp/outgoing_message"
18
+ require_relative "mfp/resettable_timer"
19
+ require_relative "mfp/ping_handler"
20
+ require_relative "mfp/proto"
21
+ require_relative "mfp/event"
22
+ require_relative "mfp/server"
23
+ require_relative "mfp/client"
24
+
25
+ module Mfp
26
+ module_function
27
+
28
+ def listen(host, port, call_sign, task, **)
29
+ srv = Server::Server.new(host, port, call_sign, **)
30
+ srv.start(task)
31
+ srv
32
+ end
33
+
34
+ def dial(host, port, call_sign, task, **)
35
+ cli = Client.new(host, port, call_sign, **)
36
+ cli.start(task)
37
+ cli
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mfp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vito Sartori
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: brotli
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: io-endpoint
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.17'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.17'
54
+ - !ruby/object:Gem::Dependency
55
+ name: io-stream
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.14'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.14'
68
+ - !ruby/object:Gem::Dependency
69
+ name: logrb
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 0.2.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 0.2.0
82
+ description: General-purpose framing protocol for organising data across multiplexed
83
+ streams
84
+ email:
85
+ - hey@vito.io
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".editorconfig"
91
+ - ".ruby-version"
92
+ - CODE_OF_CONDUCT.md
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - lib/mfp.rb
97
+ - lib/mfp/client.rb
98
+ - lib/mfp/compressor.rb
99
+ - lib/mfp/enum.rb
100
+ - lib/mfp/error.rb
101
+ - lib/mfp/event.rb
102
+ - lib/mfp/outgoing_message.rb
103
+ - lib/mfp/ping_handler.rb
104
+ - lib/mfp/proto.rb
105
+ - lib/mfp/proto/error.rb
106
+ - lib/mfp/proto/frame.rb
107
+ - lib/mfp/proto/hello.rb
108
+ - lib/mfp/proto/hello_response.rb
109
+ - lib/mfp/proto/ping.rb
110
+ - lib/mfp/proto/protocol.rb
111
+ - lib/mfp/proto/reader.rb
112
+ - lib/mfp/proto/writer.rb
113
+ - lib/mfp/resettable_timer.rb
114
+ - lib/mfp/server.rb
115
+ - lib/mfp/server/conn.rb
116
+ - lib/mfp/server/server.rb
117
+ - lib/mfp/tls_config.rb
118
+ - lib/mfp/version.rb
119
+ homepage: https://vito.io
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ allowed_push_host: https://rubygems.org
124
+ homepage_uri: https://vito.io
125
+ rubygems_mfa_required: 'true'
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '4.0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 4.0.16
141
+ specification_version: 4
142
+ summary: mfp stands for Multiplexed Framing Protocol
143
+ test_files: []