hop-endpoint 0.0.1 → 0.0.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28842786d70c3471cfb49cc90b3ed1c611de8b109816235d94328f6b470c0f83
4
- data.tar.gz: '08a7c7f9782a88233bdb6064213bb47e44ca73360aa05d736e32fddfbf4bed74'
3
+ metadata.gz: 4444de246ab976e4d12f2e9593e8f1ec56fffe4e12fab129a6a4dbabe08961e5
4
+ data.tar.gz: 454cc3feaa3444df7f1f938b145815dbbd96594eba9685bfebefedfa926bf577
5
5
  SHA512:
6
- metadata.gz: 3005f4bfb8aac1a6d5d796a7f39d5d978cd4af34373e3bf9917f1f3733403b74802ff9d21f103da5560eb5d4fa7080334ec14a9285b408720cdd27c4a42164e8
7
- data.tar.gz: 82a538d1ea0675abf7e85001c5417abbf556499bf1ff996f3b3731cccb7e45a86a2550ce88d27dae51832e1378f089080b6c7a66a4e64dbb42a01b28cc49b9d3
6
+ metadata.gz: 82e7ebb9dbf5bf773ce171f3dc1944a9dd5db63eb2e6f660d8feecba8e17e246755f309fc605117a7fb72864f340d205f360d109aebd4ea2cb34d1f287d27a69
7
+ data.tar.gz: 53f27756553c034c6115119a0349559389520370608c3bea7b8790c306bbdd316e983bdad2e727e3b19a2eb91d11b7367ab9ba943d1a8f2ee09c615484297528
data/lib/hop/endpoint.rb CHANGED
@@ -25,6 +25,9 @@ module Hop
25
25
 
26
26
  def initialize(key: nil, tick_ms: 50, cluster: nil, quorum: nil)
27
27
  Hop::FFI.assert_abi!
28
+ if key && key.bytesize != 32
29
+ raise ArgumentError, "identity key must be exactly 32 bytes, got #{key.bytesize}"
30
+ end
28
31
  @node = key ? Hop::FFI.node_with_secret(key) : Hop::FFI.node_new
29
32
  Hop::FFI.tick(@node, now_ms)
30
33
  Hop::FFI.publish_prekey(@node)
@@ -92,6 +95,8 @@ module Hop
92
95
  begin
93
96
  res = Timeout.timeout(timeout) { q.pop } # [status, body], or CLOSED if #close woke us
94
97
  raise "endpoint is closed" if res == CLOSED
98
+ accepted = with_node { |n| Hop::FFI.accept_service_response(n, req_id) }
99
+ raise "service response acceptance failed" unless accepted
95
100
  res
96
101
  rescue Timeout::Error
97
102
  @mutex.synchronize { @pending.delete(req_id) }
data/lib/hop/ffi.rb CHANGED
@@ -13,7 +13,7 @@ module Hop
13
13
  CH = Fiddle::TYPE_CHAR
14
14
  V = Fiddle::TYPE_VOID
15
15
 
16
- ABI_EXPECTED = 3
16
+ ABI_EXPECTED = 5
17
17
 
18
18
  def self.lib_path
19
19
  ext = case RbConfig::CONFIG["host_os"]
@@ -48,10 +48,12 @@ module Hop
48
48
  DRAIN_OUTGOING = fn("hop_drain_outgoing", [P, P, P], V)
49
49
  SUBSCRIBE = fn("hop_subscribe", [P, P], V)
50
50
  PUBLISH_PREKEY = fn("hop_publish_prekey", [P], CH)
51
+ ACCEPT_INBOX = fn("hop_accept_inbox", [P, P], CH)
51
52
  SEND_SERVICE_REQUEST = fn("hop_send_service_request", [P, P, P, P, P, SZ, P], CH)
52
53
  SEND_SERVICE_RESPONSE = fn("hop_send_service_response", [P, P, P, I, P, SZ], CH)
53
54
  POLL_SERVICE_REQUESTS = fn("hop_poll_service_requests", [P, P, P], V)
54
55
  POLL_SERVICE_RESPONSES = fn("hop_poll_service_responses", [P, P, P], V)
56
+ ACCEPT_SERVICE_RESPONSE = fn("hop_accept_service_response", [P, P], CH)
55
57
  ADDRESS_TO_BASE58 = fn("hop_address_to_base58", [P, P, SZ], SZ)
56
58
  ADDRESS_FROM_BASE58 = fn("hop_address_from_base58", [P, P], CH)
57
59
  SIGN_REACH_RECORD = fn("hop_sign_reach_record", [P, P, I, P, P], V)
@@ -69,6 +71,13 @@ module Hop
69
71
  raise "libhop ABI mismatch: wrapper expects #{ABI_EXPECTED}, library reports #{got}" if got != ABI_EXPECTED
70
72
  end
71
73
 
74
+ def self.require_32(value, name)
75
+ raise ArgumentError, "#{name} must be exactly 32 bytes, got #{value.bytesize}" unless value.bytesize == 32
76
+
77
+ value
78
+ end
79
+ private_class_method :require_32
80
+
72
81
  # ---- helpers: read C memory that is valid only during a call ----
73
82
  def self.read_bytes(ptr, len) = len.zero? ? "".b : Fiddle::Pointer.new(ptr)[0, len].b
74
83
  def self.read_cstr(ptr) = Fiddle::Pointer.new(ptr).to_s
@@ -82,11 +91,14 @@ module Hop
82
91
  def self.disconnected(node, link) = LINK_DOWN.call(node, link)
83
92
  def self.received(node, link, data) = BYTES_RECEIVED.call(node, link, data, data.bytesize)
84
93
  def self.subscribe(node, topic) = SUBSCRIBE.call(node, topic)
85
- def self.cluster_join(node, secret) = CLUSTER_JOIN.call(node, secret)
94
+ def self.cluster_join(node, secret) = CLUSTER_JOIN.call(node, require_32(secret, "cluster secret"))
86
95
  def self.cluster_join_passphrase(node, pass) = CLUSTER_JOIN_PASSPHRASE.call(node, pass, pass.bytesize)
87
96
  def self.cluster_members(node) = CLUSTER_MEMBERS.call(node)
88
97
  def self.cluster_set_quorum(node, min) = CLUSTER_SET_QUORUM.call(node, min)
89
98
  def self.publish_prekey(node) = PUBLISH_PREKEY.call(node) != 0
99
+ def self.accept_inbox(node, inbox_id)
100
+ ACCEPT_INBOX.call(node, require_32(inbox_id, "inbox id")) != 0
101
+ end
90
102
 
91
103
  def self.address(node)
92
104
  out = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
@@ -103,14 +115,19 @@ module Hop
103
115
 
104
116
  def self.send_service_request(node, dst, service, method, args)
105
117
  out = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
106
- ok = SEND_SERVICE_REQUEST.call(node, dst, service, method, args, args.bytesize, out) != 0
118
+ ok = SEND_SERVICE_REQUEST.call(node, require_32(dst, "destination"), service, method, args, args.bytesize, out) != 0
107
119
  raise "hop_send_service_request failed" unless ok
108
120
 
109
121
  out[0, 32].b
110
122
  end
111
123
 
112
124
  def self.send_service_response(node, to, for_request_id, status, body)
113
- SEND_SERVICE_RESPONSE.call(node, to, for_request_id, status, body, body.bytesize) != 0
125
+ SEND_SERVICE_RESPONSE.call(node, require_32(to, "response destination"),
126
+ require_32(for_request_id, "request id"), status, body, body.bytesize) != 0
127
+ end
128
+
129
+ def self.accept_service_response(node, request_id)
130
+ ACCEPT_SERVICE_RESPONSE.call(node, require_32(request_id, "request id")) != 0
114
131
  end
115
132
 
116
133
  def self.take_service_requests(node)
@@ -124,8 +141,9 @@ module Hop
124
141
 
125
142
  def self.take_service_responses(node)
126
143
  out = []
127
- sink = Closure.new(V, [P, P, P, I, P, SZ]) do |_ctx, frm, for_id, status, body, body_len|
144
+ sink = Closure.new(CH, [P, P, P, I, P, SZ]) do |_ctx, frm, for_id, status, body, body_len|
128
145
  out << [read_bytes(frm, 32), read_bytes(for_id, 32), status & 0xFFFF, read_bytes(body, body_len)]
146
+ 0
129
147
  end
130
148
  POLL_SERVICE_RESPONSES.call(node, sink, nil)
131
149
  out
@@ -133,7 +151,7 @@ module Hop
133
151
 
134
152
  def self.to_b58(addr32)
135
153
  out = Fiddle::Pointer.malloc(64, Fiddle::RUBY_FREE)
136
- n = ADDRESS_TO_BASE58.call(addr32, out, 64)
154
+ n = ADDRESS_TO_BASE58.call(require_32(addr32, "address"), out, 64)
137
155
  out[0, n]
138
156
  end
139
157
 
@@ -15,7 +15,13 @@ require "hop/discovery"
15
15
  module Hop
16
16
  module WssBearer
17
17
  GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
18
- MAX_FRAME_BYTES = 1 << 20
18
+ MAX_MESSAGE_BYTES = 1 << 20
19
+ MAX_FRAME_BYTES = MAX_MESSAGE_BYTES
20
+ MAX_HEADER_BYTES = 16 << 10
21
+ MAX_PENDING_CONNECTIONS = 64
22
+ HANDSHAKE_WORKERS = 4
23
+ HANDSHAKE_TIMEOUT_S = 5.0
24
+ READ_TIMEOUT_S = 15.0
19
25
 
20
26
  @seq = 60_000
21
27
  @seq_mutex = Mutex.new
@@ -25,6 +31,8 @@ module Hop
25
31
 
26
32
  def self.encode_frame(payload, mask)
27
33
  n = payload.bytesize
34
+ raise ArgumentError, "WebSocket message exceeds 1 MiB" if n > MAX_MESSAGE_BYTES
35
+
28
36
  header = (+"\x82").b # FIN + binary opcode
29
37
  mb = mask ? 0x80 : 0
30
38
  if n < 126
@@ -49,44 +57,117 @@ module Hop
49
57
  out
50
58
  end
51
59
 
52
- def self.read_exact(sock, n)
60
+ def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+
62
+ def self.wait_io(sock, readable, deadline)
63
+ remaining = deadline - monotonic
64
+ raise IOError, "socket deadline exceeded" unless remaining.positive?
65
+
66
+ io = sock.respond_to?(:to_io) ? sock.to_io : sock
67
+ ready = readable ? IO.select([io], nil, nil, remaining) : IO.select(nil, [io], nil, remaining)
68
+ raise IOError, "socket deadline exceeded" unless ready
69
+ end
70
+
71
+ def self.read_some(sock, n, deadline)
72
+ return sock.read(n) unless sock.respond_to?(:read_nonblock)
73
+
74
+ loop do
75
+ result = sock.read_nonblock(n, exception: false)
76
+ case result
77
+ when :wait_readable then wait_io(sock, true, deadline)
78
+ when :wait_writable then wait_io(sock, false, deadline)
79
+ else return result
80
+ end
81
+ end
82
+ end
83
+
84
+ def self.write_all(sock, data, timeout = HANDSHAKE_TIMEOUT_S)
85
+ return sock.write(data) unless sock.respond_to?(:write_nonblock)
86
+
87
+ deadline = monotonic + timeout
88
+ offset = 0
89
+ while offset < data.bytesize
90
+ result = sock.write_nonblock(data.byteslice(offset..), exception: false)
91
+ case result
92
+ when :wait_readable then wait_io(sock, true, deadline)
93
+ when :wait_writable then wait_io(sock, false, deadline)
94
+ else offset += result
95
+ end
96
+ end
97
+ offset
98
+ end
99
+
100
+ def self.read_exact(sock, n, deadline: monotonic + READ_TIMEOUT_S)
53
101
  return "".b if n.zero?
54
102
 
55
- data = sock.read(n)
56
- raise EOFError, "closed" unless data && data.bytesize == n
103
+ data = +"".b
104
+ while data.bytesize < n
105
+ chunk = read_some(sock, n - data.bytesize, deadline)
106
+ raise EOFError, "closed" unless chunk
57
107
 
108
+ data << chunk
109
+ end
58
110
  data
59
111
  end
60
112
 
61
- def self.read_frame(sock)
62
- b0, b1 = read_exact(sock, 2).bytes
113
+ def self.read_frame_part(sock, remaining = MAX_MESSAGE_BYTES, deadline: monotonic + READ_TIMEOUT_S)
114
+ b0, b1 = read_exact(sock, 2, deadline: deadline).bytes
115
+ raise IOError, "WebSocket extensions are not supported" unless (b0 & 0x70).zero?
116
+
117
+ final = (b0 & 0x80) != 0
63
118
  opcode = b0 & 0x0F
64
119
  masked = (b1 & 0x80) != 0
65
120
  len = b1 & 0x7F
66
- len = read_exact(sock, 2).unpack1("n") if len == 126
67
- len = read_exact(sock, 8).unpack1("Q>") if len == 127
68
- raise IOError, "WebSocket frame exceeds 1 MiB" if len > MAX_FRAME_BYTES
69
- mask = masked ? read_exact(sock, 4) : nil
70
- payload = read_exact(sock, len)
121
+ len = read_exact(sock, 2, deadline: deadline).unpack1("n") if len == 126
122
+ len = read_exact(sock, 8, deadline: deadline).unpack1("Q>") if len == 127
123
+ raise IOError, "WebSocket message exceeds 1 MiB" if len > remaining || len > MAX_MESSAGE_BYTES
124
+ raise IOError, "invalid WebSocket control frame" if opcode >= 0x8 && (!final || len > 125)
125
+
126
+ mask = masked ? read_exact(sock, 4, deadline: deadline) : nil
127
+ payload = read_exact(sock, len, deadline: deadline)
71
128
  payload = apply_mask(payload, mask) if mask
129
+ [final, opcode, payload]
130
+ end
131
+
132
+ def self.read_frame(sock)
133
+ _final, opcode, payload = read_frame_part(sock)
72
134
  [opcode, payload]
73
135
  end
74
136
 
137
+ def self.read_message(sock)
138
+ deadline = monotonic + READ_TIMEOUT_S
139
+ final, opcode, payload = read_frame_part(sock, deadline: deadline)
140
+ return [opcode, payload] if opcode >= 0x8
141
+ raise IOError, "expected a binary WebSocket message" unless opcode == 0x2
142
+ return [opcode, payload] if final
143
+
144
+ parts = [payload]
145
+ total = payload.bytesize
146
+ until final
147
+ final, continuation, payload = read_frame_part(sock, MAX_MESSAGE_BYTES - total, deadline: deadline)
148
+ raise IOError, "expected a WebSocket continuation frame" unless continuation.zero?
149
+
150
+ total += payload.bytesize
151
+ parts << payload
152
+ end
153
+ [opcode, parts.join]
154
+ end
155
+
75
156
  def self.run_link(endpoint, sock, role, mask)
76
157
  link = next_link
77
158
  send_fn = lambda do |buf|
78
- sock.write(encode_frame(buf, mask))
159
+ write_all(sock, encode_frame(buf, mask), READ_TIMEOUT_S)
79
160
  rescue StandardError
80
161
  nil
81
162
  end
82
163
  endpoint.register_link(link, role, send_fn)
83
164
  loop do
84
- opcode, payload = read_frame(sock)
165
+ opcode, payload = read_message(sock)
85
166
  break if opcode == 0x8
86
167
 
87
- endpoint.deliver(link, payload) if [0x2, 0x0].include?(opcode)
168
+ endpoint.deliver(link, payload) if opcode == 0x2
88
169
  end
89
- rescue EOFError, IOError, OpenSSL::SSL::SSLError, Errno::ECONNRESET
170
+ rescue EOFError, IOError, OpenSSL::SSL::SSLError, SystemCallError
90
171
  nil
91
172
  ensure
92
173
  endpoint.link_down(link)
@@ -97,74 +178,270 @@ module Hop
97
178
  end
98
179
  end
99
180
 
181
+ class Admission
182
+ def initialize(limit)
183
+ @limit = limit
184
+ @lock = Mutex.new
185
+ @sockets = {}
186
+ end
187
+
188
+ def acquire(sock)
189
+ @lock.synchronize do
190
+ return nil if @sockets.size >= @limit
191
+
192
+ lease = SocketLease.new(self, sock)
193
+ @sockets[lease] = sock
194
+ lease
195
+ end
196
+ end
197
+
198
+ def replace(lease, sock)
199
+ @lock.synchronize { @sockets[lease] = sock if @sockets.key?(lease) }
200
+ end
201
+
202
+ def release(lease)
203
+ @lock.synchronize { @sockets.delete(lease) }
204
+ end
205
+
206
+ def close_all
207
+ sockets = @lock.synchronize { @sockets.values.dup }
208
+ sockets.each { |sock| sock.close rescue nil }
209
+ end
210
+
211
+ def count = @lock.synchronize { @sockets.size }
212
+ end
213
+
214
+ class SocketLease
215
+ attr_reader :sock
216
+
217
+ def initialize(admission, sock)
218
+ @admission = admission
219
+ @sock = sock
220
+ @lock = Mutex.new
221
+ @released = false
222
+ end
223
+
224
+ def replace(sock)
225
+ @sock = sock
226
+ @admission.replace(self, sock)
227
+ end
228
+
229
+ def release
230
+ @lock.synchronize do
231
+ return if @released
232
+
233
+ @released = true
234
+ @admission.release(self)
235
+ end
236
+ end
237
+
238
+ def close = (@sock.close rescue nil)
239
+ end
240
+
241
+ def self.ssl_accept(raw, ssl_context)
242
+ sock = OpenSSL::SSL::SSLSocket.new(raw, ssl_context)
243
+ sock.sync_close = true
244
+ deadline = monotonic + HANDSHAKE_TIMEOUT_S
245
+ loop do
246
+ result = sock.accept_nonblock(exception: false)
247
+ case result
248
+ when :wait_readable then wait_io(sock, true, deadline)
249
+ when :wait_writable then wait_io(sock, false, deadline)
250
+ else return sock
251
+ end
252
+ end
253
+ rescue StandardError
254
+ sock&.close rescue nil
255
+ raise
256
+ end
257
+
258
+ def self.read_http_head(sock)
259
+ deadline = monotonic + HANDSHAKE_TIMEOUT_S
260
+ data = +"".b
261
+ until data.include?("\r\n\r\n")
262
+ room = MAX_HEADER_BYTES + 1 - data.bytesize
263
+ chunk = read_some(sock, [4096, room].min, deadline)
264
+ raise EOFError, "closed" unless chunk
265
+
266
+ data << chunk
267
+ raise IOError, "HTTP headers exceed 16 KiB" if data.bytesize > MAX_HEADER_BYTES
268
+ end
269
+ head, rest = data.split("\r\n\r\n", 2)
270
+ lines = head.split("\r\n")
271
+ request = lines.shift&.split(" ", 3)
272
+ raise IOError, "malformed HTTP request line" unless request&.size == 3
273
+
274
+ headers = {}
275
+ lines.each do |line|
276
+ key, value = line.split(":", 2)
277
+ headers[key.strip.downcase] = value.strip if value
278
+ end
279
+ [request[0], request[1], headers, rest.to_s.b]
280
+ end
281
+
100
282
  def self.serve(endpoint, port, ssl_context, public_url, ttl_secs = 3600)
101
283
  tcp = TCPServer.new(port)
102
- ssl_server = OpenSSL::SSL::SSLServer.new(tcp, ssl_context)
103
- # Let endpoint#close stop the listener so this accept loop exits instead of spinning on a closed
104
- # socket (accept on a closed server raises immediately, which without the break is a busy loop).
105
- endpoint.register_closer { ssl_server.close rescue nil }
106
- Thread.new do
107
- loop do
108
- sock = begin
109
- ssl_server.accept
110
- rescue StandardError
111
- break if tcp.closed?
284
+ tcp.listen(MAX_PENDING_CONNECTIONS)
285
+ pending = SizedQueue.new(MAX_PENDING_CONNECTIONS)
286
+ admission = Admission.new(MAX_PENDING_CONNECTIONS)
287
+ closing = false
288
+ close_lock = Mutex.new
112
289
 
113
- next
290
+ close_server = lambda do
291
+ close_lock.synchronize do
292
+ next if closing
293
+
294
+ closing = true
295
+ tcp.close rescue nil
296
+ admission.close_all
297
+ pending.close
298
+ end
299
+ end
300
+ endpoint.register_closer(&close_server)
301
+
302
+ HANDSHAKE_WORKERS.times do |i|
303
+ Thread.new do
304
+ Thread.current.name = "hop-wss-handshake-#{i}" if Thread.current.respond_to?(:name=)
305
+ loop do
306
+ break if close_lock.synchronize { closing && pending.empty? }
307
+
308
+ lease = pending.pop
309
+ break unless lease
310
+
311
+ transferred = false
312
+ begin
313
+ sock = ssl_accept(lease.sock, ssl_context)
314
+ lease.replace(sock)
315
+ transferred = handle_conn(endpoint, sock, public_url, ttl_secs, lease)
316
+ rescue StandardError
317
+ nil
318
+ ensure
319
+ unless transferred
320
+ lease.close
321
+ lease.release
322
+ end
323
+ end
114
324
  end
115
- Thread.new { handle_conn(endpoint, sock, public_url, ttl_secs) }
116
325
  end
117
326
  end
118
- ssl_server
119
- end
120
327
 
121
- def self.handle_conn(endpoint, sock, public_url, ttl_secs)
122
- request_line = sock.gets
123
- return unless request_line
328
+ Thread.new do
329
+ loop do
330
+ raw = tcp.accept
331
+ lease = admission.acquire(raw)
332
+ unless lease
333
+ raw.close rescue nil
334
+ next
335
+ end
336
+ begin
337
+ pending.push(lease, true)
338
+ rescue ThreadError
339
+ lease.close
340
+ lease.release
341
+ end
342
+ rescue IOError, Errno::EBADF
343
+ break
344
+ rescue StandardError
345
+ next unless close_lock.synchronize { closing }
124
346
 
125
- _method, path, = request_line.split
126
- headers = {}
127
- while (line = sock.gets) && line != "\r\n"
128
- k, v = line.split(":", 2)
129
- headers[k.strip.downcase] = v.strip if v
347
+ break
348
+ end
130
349
  end
350
+ tcp
351
+ end
131
352
 
353
+ def self.handle_conn(endpoint, sock, public_url, ttl_secs, lease = nil)
354
+ _method, path, headers, rest = read_http_head(sock)
132
355
  if path == "/.well-known/hop"
133
356
  body = Hop::Discovery.well_known_body(endpoint, public_url, ttl_secs)
134
- sock.write("HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: #{body.bytesize}\r\nconnection: close\r\n\r\n#{body}")
135
- sock.close
357
+ write_all(sock, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: #{body.bytesize}\r\nconnection: close\r\n\r\n#{body}")
358
+ false
136
359
  elsif path == "/_hop" && headers["upgrade"]&.downcase == "websocket"
137
- sock.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept_key(headers["sec-websocket-key"])}\r\n\r\n")
138
- run_link(endpoint, sock, :acceptor, false)
360
+ key = headers["sec-websocket-key"] or raise IOError, "missing Sec-WebSocket-Key"
361
+ write_all(sock, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept_key(key)}\r\n\r\n")
362
+ buffered = BufferedSocket.new(sock, rest)
363
+ if lease
364
+ Thread.new do
365
+ begin
366
+ run_link(endpoint, buffered, :acceptor, false)
367
+ ensure
368
+ lease.release
369
+ end
370
+ end
371
+ true
372
+ else
373
+ run_link(endpoint, buffered, :acceptor, false)
374
+ false
375
+ end
139
376
  else
140
- sock.write("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n")
141
- sock.close
377
+ write_all(sock, "HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n")
378
+ false
142
379
  end
143
- rescue StandardError
144
- begin
145
- sock.close
146
- rescue StandardError
147
- nil
380
+ end
381
+
382
+ class BufferedSocket
383
+ def initialize(sock, initial)
384
+ @sock = sock
385
+ @buffer = initial
148
386
  end
387
+
388
+ def read_nonblock(n, exception: true)
389
+ unless @buffer.empty?
390
+ chunk = @buffer.byteslice(0, n)
391
+ @buffer = @buffer.byteslice(chunk.bytesize..) || "".b
392
+ return chunk
393
+ end
394
+ @sock.read_nonblock(n, exception: exception)
395
+ end
396
+
397
+ def write_nonblock(data, exception: true) = @sock.write_nonblock(data, exception: exception)
398
+ def to_io = @sock.to_io
399
+ def close = @sock.close
149
400
  end
150
401
 
151
402
  def self.dial(endpoint, wss_url, insecure_tls: false)
152
403
  uri = URI.parse(wss_url)
153
404
  ctx = OpenSSL::SSL::SSLContext.new
154
405
  ctx.verify_mode = insecure_tls ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
155
- sock = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(uri.host, uri.port || 443), ctx)
406
+ raw = TCPSocket.new(uri.host, uri.port || 443, connect_timeout: HANDSHAKE_TIMEOUT_S)
407
+ sock = OpenSSL::SSL::SSLSocket.new(raw, ctx)
408
+ sock.sync_close = true
156
409
  sock.hostname = uri.host
157
- sock.connect
410
+ deadline = monotonic + HANDSHAKE_TIMEOUT_S
411
+ loop do
412
+ result = sock.connect_nonblock(exception: false)
413
+ case result
414
+ when :wait_readable then wait_io(sock, true, deadline)
415
+ when :wait_writable then wait_io(sock, false, deadline)
416
+ else break
417
+ end
418
+ end
158
419
  key = Base64.strict_encode64(Random.bytes(16))
159
420
  path = uri.path.to_s.empty? ? "/_hop" : uri.path
160
- sock.write("GET #{path} HTTP/1.1\r\nHost: #{uri.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
161
- status = sock.gets
421
+ write_all(sock, "GET #{path} HTTP/1.1\r\nHost: #{uri.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
422
+ _version, status, _headers, rest = read_http_response(sock)
162
423
  raise "WS upgrade failed: #{status}" unless status&.include?("101")
163
424
 
164
- nil while (line = sock.gets) && line != "\r\n" # drain response headers
165
425
  endpoint.register_closer { sock.close rescue nil } # so endpoint#close ends run_link's read loop
166
- Thread.new { run_link(endpoint, sock, :dialer, true) }
426
+ Thread.new { run_link(endpoint, BufferedSocket.new(sock, rest), :dialer, true) }
167
427
  sock
168
428
  end
429
+
430
+ def self.read_http_response(sock)
431
+ deadline = monotonic + HANDSHAKE_TIMEOUT_S
432
+ data = +"".b
433
+ until data.include?("\r\n\r\n")
434
+ room = MAX_HEADER_BYTES + 1 - data.bytesize
435
+ chunk = read_some(sock, [4096, room].min, deadline)
436
+ raise EOFError, "closed" unless chunk
437
+
438
+ data << chunk
439
+ raise IOError, "HTTP headers exceed 16 KiB" if data.bytesize > MAX_HEADER_BYTES
440
+ end
441
+ head, rest = data.split("\r\n\r\n", 2)
442
+ lines = head.split("\r\n")
443
+ status = lines.shift.to_s
444
+ [status.split(" ", 2).first, status, lines, rest.to_s.b]
445
+ end
169
446
  end
170
447
  end
metadata CHANGED
@@ -1,19 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hop-endpoint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Waldrip
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-16 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Receive Hop messages in Ruby with a hop.on / reply surface, over libhop
14
14
  via Fiddle. Your service becomes directly reachable on the mesh, no relay. Zero
15
15
  gems (stdlib only).
16
- email:
16
+ email:
17
17
  executables: []
18
18
  extensions: []
19
19
  extra_rdoc_files: []
@@ -31,7 +31,7 @@ licenses:
31
31
  - Apache-2.0
32
32
  metadata:
33
33
  rubygems_mfa_required: 'true'
34
- post_install_message:
34
+ post_install_message:
35
35
  rdoc_options: []
36
36
  require_paths:
37
37
  - lib
@@ -46,8 +46,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0'
48
48
  requirements: []
49
- rubygems_version: 3.0.3.1
50
- signing_key:
49
+ rubygems_version: 3.5.22
50
+ signing_key:
51
51
  specification_version: 4
52
52
  summary: Embeddable Hop mesh endpoint for Ruby (Sinatra/Rails-shaped) over the libhop
53
53
  C ABI