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 +4 -4
- data/lib/hop/endpoint.rb +5 -0
- data/lib/hop/ffi.rb +24 -6
- data/lib/hop/wss_bearer.rb +331 -54
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4444de246ab976e4d12f2e9593e8f1ec56fffe4e12fab129a6a4dbabe08961e5
|
|
4
|
+
data.tar.gz: 454cc3feaa3444df7f1f938b145815dbbd96594eba9685bfebefedfa926bf577
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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,
|
|
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(
|
|
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
|
|
data/lib/hop/wss_bearer.rb
CHANGED
|
@@ -15,7 +15,13 @@ require "hop/discovery"
|
|
|
15
15
|
module Hop
|
|
16
16
|
module WssBearer
|
|
17
17
|
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
18
|
-
|
|
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.
|
|
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 =
|
|
56
|
-
|
|
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.
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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 =
|
|
165
|
+
opcode, payload = read_message(sock)
|
|
85
166
|
break if opcode == 0x8
|
|
86
167
|
|
|
87
|
-
endpoint.deliver(link, payload) if
|
|
168
|
+
endpoint.deliver(link, payload) if opcode == 0x2
|
|
88
169
|
end
|
|
89
|
-
rescue EOFError, IOError, OpenSSL::SSL::SSLError,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
141
|
-
|
|
377
|
+
write_all(sock, "HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n")
|
|
378
|
+
false
|
|
142
379
|
end
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
status = sock
|
|
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.
|
|
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-
|
|
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.
|
|
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
|