sillydev 1.0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "websocket"
|
|
7
|
+
|
|
8
|
+
module SillyDev
|
|
9
|
+
# One message from (or to) the console, as the websocket carries it. event is such as
|
|
10
|
+
# "console output", "status", "stats", "daemon message" or "auth success"; args are strings
|
|
11
|
+
# ("console output" has the line as args[0]).
|
|
12
|
+
ConsoleMessage = Struct.new(:event, :args)
|
|
13
|
+
|
|
14
|
+
# A server's live console: its output, state and resource use as they happen, and commands and
|
|
15
|
+
# power actions sent back.
|
|
16
|
+
#
|
|
17
|
+
# There are four ways to reach it, tried in this order by transport "auto" (the default):
|
|
18
|
+
#
|
|
19
|
+
# - websocket: a websocket to the server's node. The quickest, and what the panel uses.
|
|
20
|
+
# - stream: the same messages from a session on the node, streamed over plain HTTPS
|
|
21
|
+
# (server-sent events), for networks that block websockets.
|
|
22
|
+
# - poll: the same session read by long polling, for proxies that hold streams back.
|
|
23
|
+
# - relay: the panel reads a session on the node for you, for networks that can't reach the
|
|
24
|
+
# node at all. Output arrives a second or two late.
|
|
25
|
+
#
|
|
26
|
+
# Every way carries the same messages. The connection renews its 10-minute token by itself, and
|
|
27
|
+
# reconnects (trying each way again) if it drops. It runs on background threads, which is where
|
|
28
|
+
# listeners added with #on are called.
|
|
29
|
+
#
|
|
30
|
+
# client.panel.console.connect("1a7ce997") do |console|
|
|
31
|
+
# console.on("console output") { |message| puts message.args[0] }
|
|
32
|
+
# console.command("say Hello")
|
|
33
|
+
# sleep 10
|
|
34
|
+
# end
|
|
35
|
+
class Console
|
|
36
|
+
TRANSPORTS = %w[websocket stream poll relay].freeze
|
|
37
|
+
CONNECT_TIMEOUTS = { "websocket" => 6.0, "stream" => 8.0, "poll" => 10.0, "relay" => 10.0 }.freeze
|
|
38
|
+
TOKEN_AGE = 5 * 60.0 # A token older than this is swapped before connecting again; they last 10 minutes.
|
|
39
|
+
QUEUE_LIMIT = 1000 # Messages kept for a reader that isn't reading; the oldest go first.
|
|
40
|
+
MAX_FAILURES = 3 # Failed reads in a row before a session or relay is taken as lost.
|
|
41
|
+
HEALTHY_STREAM = 30.0 # A stream that lasted this long ended normally (the node ends them every 5 minutes).
|
|
42
|
+
ENDED = "The session has ended."
|
|
43
|
+
|
|
44
|
+
# How long things may take; replaceable in tests.
|
|
45
|
+
Timings = Struct.new(:first_byte, :stream_idle, :request, :poll_pause, keyword_init: true)
|
|
46
|
+
DEFAULT_TIMINGS = Timings.new(first_byte: 8.0, stream_idle: 40.0, request: 30.0, poll_pause: 1.0).freeze
|
|
47
|
+
|
|
48
|
+
# Reads {event, args} from a websocket frame or a queued message.
|
|
49
|
+
def self.parse_message(value)
|
|
50
|
+
return nil unless value.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
event = value["event"]
|
|
53
|
+
return nil unless event.is_a?(String) && !event.empty?
|
|
54
|
+
|
|
55
|
+
args = if value["args"].is_a?(Array)
|
|
56
|
+
value["args"].map { |a| a.is_a?(String) ? a : a.to_s }
|
|
57
|
+
else
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
ConsoleMessage.new(event, args)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The origin of a URL: https://sillydev.co.uk/panel/api/client gives https://sillydev.co.uk.
|
|
64
|
+
def self.origin_of(url)
|
|
65
|
+
uri = URI(url)
|
|
66
|
+
return url if uri.scheme.nil? || uri.host.nil?
|
|
67
|
+
|
|
68
|
+
default = uri.scheme == "https" ? 443 : 80
|
|
69
|
+
uri.port == default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
url
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.monotonic
|
|
75
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A one-shot signal a thread can wait on, with a timeout and an optional error.
|
|
79
|
+
class Latch
|
|
80
|
+
def initialize
|
|
81
|
+
@mutex = Mutex.new
|
|
82
|
+
@cond = ConditionVariable.new
|
|
83
|
+
@done = false
|
|
84
|
+
@error = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def set(error = nil)
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
next if @done
|
|
90
|
+
|
|
91
|
+
@done = true
|
|
92
|
+
@error = error
|
|
93
|
+
@cond.broadcast
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def done?
|
|
98
|
+
@done
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# True once set (raising its error, if any), false when the timeout passed first.
|
|
102
|
+
def wait(timeout = nil) # rubocop:disable Naming/PredicateMethod
|
|
103
|
+
deadline = timeout ? Console.monotonic + timeout : nil
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
until @done
|
|
106
|
+
remaining = deadline ? deadline - Console.monotonic : nil
|
|
107
|
+
return false if remaining && remaining <= 0
|
|
108
|
+
|
|
109
|
+
@cond.wait(@mutex, remaining)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
raise @error if @error
|
|
113
|
+
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# What a transport calls: message(ConsoleMessage) for each message, closed(ConsoleError) once
|
|
119
|
+
# it drops after starting.
|
|
120
|
+
Handlers = Struct.new(:message, :closed)
|
|
121
|
+
|
|
122
|
+
# A websocket to the node.
|
|
123
|
+
class WebsocketTransport
|
|
124
|
+
attr_reader :kind
|
|
125
|
+
|
|
126
|
+
def initialize(url, origin, token, handlers)
|
|
127
|
+
@kind = "websocket"
|
|
128
|
+
@url = url
|
|
129
|
+
@origin = origin
|
|
130
|
+
@token = token
|
|
131
|
+
@handlers = handlers
|
|
132
|
+
@closed = false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def start(timeout)
|
|
136
|
+
begin
|
|
137
|
+
@ws = WebSocket.connect(@url, origin: @origin, timeout: timeout,
|
|
138
|
+
headers: { "User-Agent" => "sillydev-ruby/#{VERSION}" })
|
|
139
|
+
rescue WebSocket::Closed => e
|
|
140
|
+
raise ConsoleError.new("Could not open the websocket: #{e.message}", "network", transport: kind)
|
|
141
|
+
end
|
|
142
|
+
started = Latch.new
|
|
143
|
+
@reader = Thread.new { read_loop(started) }
|
|
144
|
+
@reader.name = "sillydev-console-websocket" if @reader.respond_to?(:name=)
|
|
145
|
+
begin
|
|
146
|
+
@ws.send_text(JSON.generate({ event: "auth", args: [@token] }))
|
|
147
|
+
rescue WebSocket::Closed
|
|
148
|
+
nil # The reader sees why (4409 for a suspended server).
|
|
149
|
+
end
|
|
150
|
+
return if started.wait(timeout)
|
|
151
|
+
|
|
152
|
+
close
|
|
153
|
+
raise ConsoleError.new("Connecting took longer than #{timeout} s.", "timeout", transport: kind)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def send_message(message)
|
|
157
|
+
raise ConsoleError.new("The websocket is closed.", "network", transport: kind) if @closed || @ws.nil?
|
|
158
|
+
|
|
159
|
+
@ws.send_text(JSON.generate({ event: message.event, args: message.args }))
|
|
160
|
+
rescue WebSocket::Closed => e
|
|
161
|
+
raise ConsoleError.new("Could not send on the websocket: #{e.message}", "network", transport: kind)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def close
|
|
165
|
+
return if @closed
|
|
166
|
+
|
|
167
|
+
@closed = true
|
|
168
|
+
@ws&.close
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def read_loop(started)
|
|
174
|
+
error = nil
|
|
175
|
+
loop do
|
|
176
|
+
raw = @ws.read
|
|
177
|
+
return if @closed
|
|
178
|
+
|
|
179
|
+
message = begin
|
|
180
|
+
Console.parse_message(JSON.parse(raw))
|
|
181
|
+
rescue JSON::ParserError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
next if message.nil?
|
|
185
|
+
|
|
186
|
+
unless started.done?
|
|
187
|
+
if message.event == "auth success"
|
|
188
|
+
started.set
|
|
189
|
+
@handlers.message.call(message)
|
|
190
|
+
elsif message.event == "jwt error"
|
|
191
|
+
error = ConsoleError.new("The node refused the token: #{message.args.first}".strip, "token",
|
|
192
|
+
transport: kind)
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
next
|
|
196
|
+
end
|
|
197
|
+
@handlers.message.call(message)
|
|
198
|
+
end
|
|
199
|
+
rescue WebSocket::Closed => e
|
|
200
|
+
error ||= close_error(e.code, e.reason)
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
error ||= ConsoleError.new("The websocket failed: #{e.message}", "network", transport: kind)
|
|
203
|
+
ensure
|
|
204
|
+
finish(started, error) unless @closed && error.nil?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def finish(started, error)
|
|
208
|
+
return if @closed && started.done?
|
|
209
|
+
|
|
210
|
+
was_closed = @closed
|
|
211
|
+
@closed = true
|
|
212
|
+
@ws&.close
|
|
213
|
+
error ||= ConsoleError.new("The websocket closed.", "network", transport: kind)
|
|
214
|
+
if started.done?
|
|
215
|
+
@handlers.closed.call(error) unless was_closed
|
|
216
|
+
else
|
|
217
|
+
started.set(error)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def close_error(code, reason)
|
|
222
|
+
case code
|
|
223
|
+
when 4409 then ConsoleError.new("The server is suspended.", "suspended", transport: kind)
|
|
224
|
+
when 4410 then ConsoleError.new("The node is still loading the server.", "booting", transport: kind)
|
|
225
|
+
else
|
|
226
|
+
why = reason.nil? || reason.empty? ? "" : ": #{reason}"
|
|
227
|
+
ConsoleError.new("The websocket closed (code #{code || 'none'})#{why}.", "network", transport: kind)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A session on the node, read by streaming ("stream") or long polling ("poll").
|
|
233
|
+
class SessionTransport
|
|
234
|
+
attr_reader :kind
|
|
235
|
+
|
|
236
|
+
def initialize(kind, base, token, handlers, poll_wait, timings)
|
|
237
|
+
@kind = kind
|
|
238
|
+
@base = base.chomp("/")
|
|
239
|
+
@token = token
|
|
240
|
+
@handlers = handlers
|
|
241
|
+
@poll_wait = poll_wait
|
|
242
|
+
@timings = timings
|
|
243
|
+
@session = nil
|
|
244
|
+
@cursor = 0
|
|
245
|
+
@started = false
|
|
246
|
+
@closed = false
|
|
247
|
+
@lock = Mutex.new
|
|
248
|
+
@http = nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def start(timeout)
|
|
252
|
+
status, body = request("POST", @base, headers: { "Authorization" => "Bearer #{@token}" },
|
|
253
|
+
timeout: [timeout, @timings.request].min)
|
|
254
|
+
session = body["session"]
|
|
255
|
+
raise refusal(status, body) unless status == 201 && session.is_a?(String)
|
|
256
|
+
|
|
257
|
+
@session = session
|
|
258
|
+
if @closed
|
|
259
|
+
end_session
|
|
260
|
+
raise ConsoleError.new("Closed while connecting.", "network", transport: kind)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The session is already authenticated, so its first message is "auth success".
|
|
264
|
+
@ready = Latch.new
|
|
265
|
+
@runner = Thread.new { run }
|
|
266
|
+
return if @ready.wait(timeout)
|
|
267
|
+
|
|
268
|
+
close
|
|
269
|
+
raise ConsoleError.new("Connecting took longer than #{timeout} s.", "timeout", transport: kind)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def send_message(message)
|
|
273
|
+
raise ConsoleError.new("The session is closed.", "network", transport: kind) if @session.nil? || @closed
|
|
274
|
+
|
|
275
|
+
status, body = request("POST", "#{@base}/#{@session}/send",
|
|
276
|
+
headers: { "Content-Type" => "text/plain" },
|
|
277
|
+
body: JSON.generate({ event: message.event, args: message.args }))
|
|
278
|
+
return if status == 202
|
|
279
|
+
|
|
280
|
+
if status == 410
|
|
281
|
+
error = gone(body)
|
|
282
|
+
unless @closed
|
|
283
|
+
close
|
|
284
|
+
@handlers.closed.call(error)
|
|
285
|
+
end
|
|
286
|
+
raise error
|
|
287
|
+
end
|
|
288
|
+
raise ConsoleError.new("Too many messages; the node is dropping them.", "throttled", transport: kind) if status == 429
|
|
289
|
+
|
|
290
|
+
detail = body["error"]
|
|
291
|
+
raise ConsoleError.new(detail.is_a?(String) ? detail : "The node refused the message (#{status}).", "network",
|
|
292
|
+
transport: kind)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def close
|
|
296
|
+
return if @closed
|
|
297
|
+
|
|
298
|
+
@closed = true
|
|
299
|
+
stream = @lock.synchronize { @http }
|
|
300
|
+
begin
|
|
301
|
+
stream&.finish
|
|
302
|
+
rescue StandardError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
@runner&.kill unless @runner == Thread.current
|
|
306
|
+
end_session if @session
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def run
|
|
312
|
+
kind == "stream" ? stream_loop : poll_loop
|
|
313
|
+
rescue StandardError => e
|
|
314
|
+
return if @closed
|
|
315
|
+
|
|
316
|
+
error = e.is_a?(ConsoleError) ? e : ConsoleError.new(e.message, "network", transport: kind)
|
|
317
|
+
was_started = @started
|
|
318
|
+
close
|
|
319
|
+
if was_started
|
|
320
|
+
@handlers.closed.call(error)
|
|
321
|
+
else
|
|
322
|
+
@ready&.set(error)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Tells the node the session is no longer wanted. It would end by itself within a minute.
|
|
327
|
+
def end_session
|
|
328
|
+
session = @session
|
|
329
|
+
Thread.new do
|
|
330
|
+
request("DELETE", "#{@base}/#{session}", timeout: 5)
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def gone(body)
|
|
337
|
+
ConsoleError.new(ENDED, body["suspended"] ? "suspended" : "network", transport: kind)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def refusal(status, body)
|
|
341
|
+
error = body["error"]
|
|
342
|
+
message = error.is_a?(String) && !error.empty? ? error : "The node refused the session (#{status})."
|
|
343
|
+
reason = if body["booting"] then "booting"
|
|
344
|
+
elsif body["suspended"] then "suspended"
|
|
345
|
+
elsif status == 401 || body["jwt"] then "token"
|
|
346
|
+
else "network"
|
|
347
|
+
end
|
|
348
|
+
ConsoleError.new(message, reason, transport: kind)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def http_for(uri, read_timeout)
|
|
352
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
353
|
+
http.use_ssl = uri.scheme == "https"
|
|
354
|
+
http.open_timeout = [@timings.request, 10].min
|
|
355
|
+
http.read_timeout = read_timeout
|
|
356
|
+
http.write_timeout = @timings.request
|
|
357
|
+
http
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# One request to the node, without the API key: [status, parsed JSON body (or {})].
|
|
361
|
+
def request(method, url, headers: {}, body: nil, params: nil, timeout: nil)
|
|
362
|
+
uri = URI(url)
|
|
363
|
+
uri.query = URI.encode_www_form(params) if params
|
|
364
|
+
http = http_for(uri, timeout || @timings.request)
|
|
365
|
+
req = Net::HTTPGenericRequest.new(method, !body.nil?, method != "HEAD", uri.request_uri)
|
|
366
|
+
req["User-Agent"] = "sillydev-ruby/#{VERSION}"
|
|
367
|
+
req["Accept"] = "application/json"
|
|
368
|
+
headers.each { |k, v| req[k] = v }
|
|
369
|
+
req.body = body if body
|
|
370
|
+
res = http.start { |h| h.request(req) }
|
|
371
|
+
[res.code.to_i, parse_json(res.body)]
|
|
372
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
373
|
+
raise ConsoleError.new("The node took too long to answer.", "timeout", transport: kind)
|
|
374
|
+
rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError => e
|
|
375
|
+
raise ConsoleError.new("Could not reach the node: #{e.message}", "network", transport: kind)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def parse_json(text)
|
|
379
|
+
value = JSON.parse(text.to_s)
|
|
380
|
+
value.is_a?(Hash) ? value : {}
|
|
381
|
+
rescue JSON::ParserError
|
|
382
|
+
{}
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def deliver(items)
|
|
386
|
+
items.each do |m|
|
|
387
|
+
break if @closed
|
|
388
|
+
next unless m.is_a?(Hash)
|
|
389
|
+
|
|
390
|
+
ident = m["id"]
|
|
391
|
+
next unless ident.is_a?(Integer) && ident > @cursor
|
|
392
|
+
|
|
393
|
+
@cursor = ident
|
|
394
|
+
message = Console.parse_message(m)
|
|
395
|
+
next if message.nil?
|
|
396
|
+
|
|
397
|
+
unless @started
|
|
398
|
+
next unless message.event == "auth success"
|
|
399
|
+
|
|
400
|
+
@started = true
|
|
401
|
+
@handlers.message.call(message)
|
|
402
|
+
@ready.set
|
|
403
|
+
next
|
|
404
|
+
end
|
|
405
|
+
@handlers.message.call(message)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def poll_loop
|
|
410
|
+
failures = 0
|
|
411
|
+
until @closed
|
|
412
|
+
asked = Console.monotonic
|
|
413
|
+
begin
|
|
414
|
+
status, body = request("GET", "#{@base}/#{@session}/poll", params: { after: @cursor, wait: @poll_wait },
|
|
415
|
+
timeout: @timings.request + @poll_wait)
|
|
416
|
+
raise gone(body) if status == 410
|
|
417
|
+
raise ConsoleError.new("Polling failed (#{status}).", "network", transport: kind) unless status.between?(200, 299)
|
|
418
|
+
|
|
419
|
+
before = @cursor
|
|
420
|
+
deliver(body["events"].is_a?(Array) ? body["events"] : [])
|
|
421
|
+
failures = 0
|
|
422
|
+
fresh = @cursor != before
|
|
423
|
+
# Back early with nothing new: a proxy cutting long polls short. Don't ask without end.
|
|
424
|
+
sleep(@timings.poll_pause) if !body["more"] && !fresh && Console.monotonic - asked < @timings.poll_pause
|
|
425
|
+
rescue ConsoleError => e
|
|
426
|
+
return if @closed
|
|
427
|
+
|
|
428
|
+
failures += 1
|
|
429
|
+
raise if e.message == ENDED || !@started || failures >= MAX_FAILURES
|
|
430
|
+
|
|
431
|
+
sleep(failures)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def stream_loop
|
|
437
|
+
failures = 0
|
|
438
|
+
until @closed
|
|
439
|
+
opened = Console.monotonic
|
|
440
|
+
begin
|
|
441
|
+
read_stream
|
|
442
|
+
failures = Console.monotonic - opened > HEALTHY_STREAM ? 0 : failures + 1
|
|
443
|
+
rescue ConsoleError => e
|
|
444
|
+
return if @closed
|
|
445
|
+
raise if e.message == ENDED || !@started
|
|
446
|
+
|
|
447
|
+
failures += 1
|
|
448
|
+
end
|
|
449
|
+
raise ConsoleError.new("The stream keeps dropping.", "network", transport: kind) if failures >= MAX_FAILURES
|
|
450
|
+
|
|
451
|
+
sleep(failures) if failures.positive? && !@closed
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Reads one stream until it ends.
|
|
456
|
+
def read_stream
|
|
457
|
+
uri = URI("#{@base}/#{@session}/stream")
|
|
458
|
+
uri.query = URI.encode_www_form(after: @cursor)
|
|
459
|
+
# A stream a proxy is holding back looks like a slow one until its first bytes arrive.
|
|
460
|
+
# The node sends some at once, so waiting long means held back.
|
|
461
|
+
http = http_for(uri, @timings.first_byte)
|
|
462
|
+
http.open_timeout = @timings.first_byte
|
|
463
|
+
@lock.synchronize { @http = http }
|
|
464
|
+
req = Net::HTTP::Get.new(uri)
|
|
465
|
+
req["Accept"] = "text/event-stream"
|
|
466
|
+
req["User-Agent"] = "sillydev-ruby/#{VERSION}"
|
|
467
|
+
buffer = +""
|
|
468
|
+
http.start do |h|
|
|
469
|
+
h.request(req) do |res|
|
|
470
|
+
status = res.code.to_i
|
|
471
|
+
raise gone(parse_json(res.read_body)) if status == 410
|
|
472
|
+
raise ConsoleError.new("Streaming failed (#{status}).", "network", transport: kind) unless status.between?(200, 299)
|
|
473
|
+
|
|
474
|
+
res.read_body do |chunk|
|
|
475
|
+
h.read_timeout = @timings.stream_idle
|
|
476
|
+
buffer << chunk.to_s.force_encoding(Encoding::UTF_8).gsub("\r\n", "\n").tr("\r", "\n")
|
|
477
|
+
while (index = buffer.index("\n\n"))
|
|
478
|
+
block = buffer.slice!(0, index + 2)
|
|
479
|
+
deliver_event(block)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
485
|
+
raise ConsoleError.new(@started ? "The stream went quiet." : "The stream was held back.", "timeout", transport: kind)
|
|
486
|
+
rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError => e
|
|
487
|
+
raise ConsoleError.new("The stream failed: #{e.message}", "network", transport: kind)
|
|
488
|
+
ensure
|
|
489
|
+
@lock.synchronize { @http = nil }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Delivers one server-sent event: "id: <n>" and "data: <message>" lines.
|
|
493
|
+
def deliver_event(block)
|
|
494
|
+
ident = 0
|
|
495
|
+
data = []
|
|
496
|
+
block.split("\n").each do |line|
|
|
497
|
+
if line.start_with?("id:")
|
|
498
|
+
ident = Integer(line[3..].strip, exception: false) || 0
|
|
499
|
+
elsif line.start_with?("data:")
|
|
500
|
+
value = line[5..]
|
|
501
|
+
data << (value.start_with?(" ") ? value[1..] : value)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
return if ident.zero? || data.empty?
|
|
505
|
+
|
|
506
|
+
body = begin
|
|
507
|
+
JSON.parse(data.join("\n"))
|
|
508
|
+
rescue JSON::ParserError
|
|
509
|
+
nil
|
|
510
|
+
end
|
|
511
|
+
deliver([body.merge("id" => ident)]) if body.is_a?(Hash)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# A session on the node that the panel reads for us.
|
|
516
|
+
class RelayTransport
|
|
517
|
+
attr_reader :kind
|
|
518
|
+
|
|
519
|
+
def initialize(client, server, handlers, interval)
|
|
520
|
+
@kind = "relay"
|
|
521
|
+
@client = client
|
|
522
|
+
@server = server
|
|
523
|
+
@handlers = handlers
|
|
524
|
+
@interval = interval
|
|
525
|
+
@session = nil
|
|
526
|
+
@cursor = 0
|
|
527
|
+
@started = false
|
|
528
|
+
@closed = false
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def start(timeout)
|
|
532
|
+
begin
|
|
533
|
+
session = @client.panel.servers.open_relay(@server)
|
|
534
|
+
rescue Error => e
|
|
535
|
+
raise Console.relay_error(e)
|
|
536
|
+
end
|
|
537
|
+
raise ConsoleError.new("The panel did not open a session.", "network", transport: kind) if session.to_s.empty?
|
|
538
|
+
|
|
539
|
+
@session = session
|
|
540
|
+
if @closed
|
|
541
|
+
end_session
|
|
542
|
+
raise ConsoleError.new("Closed while connecting.", "network", transport: kind)
|
|
543
|
+
end
|
|
544
|
+
@ready = Latch.new
|
|
545
|
+
@runner = Thread.new { run }
|
|
546
|
+
return if @ready.wait(timeout)
|
|
547
|
+
|
|
548
|
+
close
|
|
549
|
+
raise ConsoleError.new("Connecting took longer than #{timeout} s.", "timeout", transport: kind)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def send_message(message)
|
|
553
|
+
raise ConsoleError.new("The relay is closed.", "network", transport: kind) if @session.nil? || @closed
|
|
554
|
+
|
|
555
|
+
@client.panel.servers.send_relay(@server, @session, message.event, *message.args)
|
|
556
|
+
rescue APIStatusError => e
|
|
557
|
+
converted = Console.relay_error(e)
|
|
558
|
+
if e.status_code == 410 && !@closed && converted.is_a?(ConsoleError)
|
|
559
|
+
close
|
|
560
|
+
@handlers.closed.call(converted)
|
|
561
|
+
elsif e.status_code == 429
|
|
562
|
+
raise ConsoleError.new("Too many messages; the node is dropping them.", "throttled", transport: kind)
|
|
563
|
+
end
|
|
564
|
+
raise converted
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def close
|
|
568
|
+
return if @closed
|
|
569
|
+
|
|
570
|
+
@closed = true
|
|
571
|
+
@runner&.kill unless @runner == Thread.current
|
|
572
|
+
end_session if @session
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
private
|
|
576
|
+
|
|
577
|
+
def end_session
|
|
578
|
+
session = @session
|
|
579
|
+
Thread.new do
|
|
580
|
+
@client.panel.servers.close_relay(@server, session)
|
|
581
|
+
rescue StandardError
|
|
582
|
+
nil
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def run
|
|
587
|
+
relay_loop
|
|
588
|
+
rescue StandardError => e
|
|
589
|
+
return if @closed
|
|
590
|
+
|
|
591
|
+
converted = e.is_a?(Error) ? Console.relay_error(e) : e
|
|
592
|
+
was_started = @started
|
|
593
|
+
close
|
|
594
|
+
if was_started
|
|
595
|
+
error = converted.is_a?(ConsoleError) ? converted : ConsoleError.new(converted.message, "network", transport: kind)
|
|
596
|
+
@handlers.closed.call(error)
|
|
597
|
+
else
|
|
598
|
+
@ready.set(converted)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def relay_loop
|
|
603
|
+
failures = 0
|
|
604
|
+
until @closed
|
|
605
|
+
more = false
|
|
606
|
+
begin
|
|
607
|
+
data = @client.panel.servers.poll_relay(@server, @session, after: @cursor)
|
|
608
|
+
events = data.is_a?(Hash) && data["events"].is_a?(Array) ? data["events"] : []
|
|
609
|
+
more = data.is_a?(Hash) && data["more"] ? true : false
|
|
610
|
+
failures = 0
|
|
611
|
+
deliver(events)
|
|
612
|
+
rescue StandardError => e
|
|
613
|
+
return if @closed
|
|
614
|
+
|
|
615
|
+
failures += 1
|
|
616
|
+
status = e.is_a?(APIStatusError) ? e.status_code : nil
|
|
617
|
+
raise if status == 410 || !@started || failures >= MAX_FAILURES
|
|
618
|
+
end
|
|
619
|
+
next if more || @closed
|
|
620
|
+
|
|
621
|
+
sleep(@interval * [1, failures].max)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def deliver(events)
|
|
626
|
+
events.each do |m|
|
|
627
|
+
break if @closed
|
|
628
|
+
next unless m.is_a?(Hash)
|
|
629
|
+
|
|
630
|
+
ident = m["id"]
|
|
631
|
+
next unless ident.is_a?(Integer) && ident > @cursor
|
|
632
|
+
|
|
633
|
+
@cursor = ident
|
|
634
|
+
message = Console.parse_message(m)
|
|
635
|
+
next if message.nil?
|
|
636
|
+
|
|
637
|
+
unless @started
|
|
638
|
+
next unless message.event == "auth success"
|
|
639
|
+
|
|
640
|
+
@started = true
|
|
641
|
+
@handlers.message.call(message)
|
|
642
|
+
@ready.set
|
|
643
|
+
next
|
|
644
|
+
end
|
|
645
|
+
@handlers.message.call(message)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Turns an error from the relay's panel endpoints into a ConsoleError, or returns it unchanged.
|
|
651
|
+
def self.relay_error(error)
|
|
652
|
+
return error if error.is_a?(ConsoleError)
|
|
653
|
+
return ConsoleError.new(error.message, "network", transport: "relay") if error.is_a?(APIConnectionError) || error.is_a?(ServerError)
|
|
654
|
+
|
|
655
|
+
if error.is_a?(APIStatusError)
|
|
656
|
+
meta = (error.errors.first || {})["meta"] || {}
|
|
657
|
+
return ConsoleError.new(error.message, "suspended", transport: "relay") if meta["suspended"]
|
|
658
|
+
return ConsoleError.new(error.message, "booting", transport: "relay") if meta["booting"]
|
|
659
|
+
return ConsoleError.new(error.message, "token", transport: "relay") if meta["jwt"]
|
|
660
|
+
return ConsoleError.new(ENDED, "network", transport: "relay") if error.status_code == 410
|
|
661
|
+
end
|
|
662
|
+
# The API key, a missing permission or a missing server: no other way would do better.
|
|
663
|
+
error
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# The way in use (or last used): "websocket", "stream", "poll" or "relay".
|
|
667
|
+
attr_reader :transport
|
|
668
|
+
# Why the console ended, if it ended with an error.
|
|
669
|
+
attr_reader :close_reason
|
|
670
|
+
attr_reader :server
|
|
671
|
+
|
|
672
|
+
# Made by client.panel.console.connect.
|
|
673
|
+
def initialize(client, server, transport: "auto", transports: nil, origin: nil, connect_timeout: nil,
|
|
674
|
+
reconnect: true, max_reconnect_attempts: 5, poll_wait: 20, relay_interval: 2.0,
|
|
675
|
+
timings: DEFAULT_TIMINGS, buffer: true)
|
|
676
|
+
transport = transport.to_s
|
|
677
|
+
unless transport == "auto" || TRANSPORTS.include?(transport)
|
|
678
|
+
raise ArgumentError, "transport must be \"auto\" or one of #{TRANSPORTS.join(', ')}"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
@client = client
|
|
682
|
+
@server = server
|
|
683
|
+
@kinds = transport == "auto" ? (transports || TRANSPORTS).map(&:to_s) : [transport]
|
|
684
|
+
@origin = origin || Console.origin_of(client.base_url)
|
|
685
|
+
@connect_timeout = connect_timeout
|
|
686
|
+
@reconnect = reconnect
|
|
687
|
+
@max_reconnects = [max_reconnect_attempts.to_i, 1].max
|
|
688
|
+
@poll_wait = poll_wait.to_i.clamp(0, 25)
|
|
689
|
+
@relay_interval = relay_interval
|
|
690
|
+
@timings = timings
|
|
691
|
+
@buffer = buffer
|
|
692
|
+
|
|
693
|
+
@transport = nil
|
|
694
|
+
@close_reason = nil
|
|
695
|
+
@current = nil
|
|
696
|
+
@credentials = nil
|
|
697
|
+
@credentials_at = 0.0
|
|
698
|
+
@last_jwt_renewal = -60.0
|
|
699
|
+
@ended = false
|
|
700
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
701
|
+
@close_listeners = []
|
|
702
|
+
@queue = Queue.new
|
|
703
|
+
@state_lock = Mutex.new
|
|
704
|
+
# Set once a connection is in use, so a message sent meanwhile waits for it.
|
|
705
|
+
@ready = Latch.new
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def inspect
|
|
709
|
+
"#<#{self.class.name} server=#{@server.inspect} transport=#{@transport.inspect} closed=#{@ended}>"
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Whether the console has been closed, or has ended.
|
|
713
|
+
def closed?
|
|
714
|
+
@ended
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Connects (called by client.panel.console.connect). Returns self.
|
|
718
|
+
def open
|
|
719
|
+
connect_once
|
|
720
|
+
@ready.set
|
|
721
|
+
self
|
|
722
|
+
rescue StandardError => e
|
|
723
|
+
@ended = true
|
|
724
|
+
@ready.set(e)
|
|
725
|
+
raise
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Calls the block with every message whose event is event ("*" for every one), on the
|
|
729
|
+
# console's thread. Returns a proc that removes the listener.
|
|
730
|
+
def on(event, &listener)
|
|
731
|
+
raise ArgumentError, "on needs a block" unless listener
|
|
732
|
+
|
|
733
|
+
@state_lock.synchronize { @listeners[event.to_s] << listener }
|
|
734
|
+
-> { @state_lock.synchronize { @listeners[event.to_s].delete(listener) } }
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Calls the block once the console ends: with the ConsoleError, or nil after #close.
|
|
738
|
+
def on_close(&listener)
|
|
739
|
+
raise ArgumentError, "on_close needs a block" unless listener
|
|
740
|
+
|
|
741
|
+
@state_lock.synchronize { @close_listeners << listener }
|
|
742
|
+
-> { @state_lock.synchronize { @close_listeners.delete(listener) } }
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Sends a websocket message, such as send_message("send command", "say hi"). Waits while
|
|
746
|
+
# reconnecting.
|
|
747
|
+
def send_message(event, *args)
|
|
748
|
+
raise(@close_reason || ConsoleError.new("The console is closed.", "network")) if @ended
|
|
749
|
+
|
|
750
|
+
@ready.wait
|
|
751
|
+
current = @current
|
|
752
|
+
raise(@close_reason || ConsoleError.new("The console is closed.", "network")) if current.nil? || @ended
|
|
753
|
+
|
|
754
|
+
current.send_message(ConsoleMessage.new(event.to_s, args.map(&:to_s)))
|
|
755
|
+
nil
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Types a command into the console (needs control.console; ignored while the server is offline).
|
|
759
|
+
def command(command)
|
|
760
|
+
send_message("send command", command)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# "start", "stop", "restart" or "kill" the server (needs the matching control.* permission).
|
|
764
|
+
def power(signal)
|
|
765
|
+
send_message("set state", signal)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Asks for the recent console history; it arrives as "console output" messages.
|
|
769
|
+
def request_logs
|
|
770
|
+
send_message("send logs")
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Asks for a "stats" message straight away.
|
|
774
|
+
def request_stats
|
|
775
|
+
send_message("send stats")
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# The messages as they arrive, until the console ends (raising its close_reason, if any) or,
|
|
779
|
+
# with a timeout, until timeout seconds pass without one. Without a block, an Enumerator.
|
|
780
|
+
def each(timeout: nil)
|
|
781
|
+
return enum_for(:each, timeout: timeout) unless block_given?
|
|
782
|
+
|
|
783
|
+
loop do
|
|
784
|
+
item = pop(timeout)
|
|
785
|
+
return if item == :timeout
|
|
786
|
+
|
|
787
|
+
if item == :done
|
|
788
|
+
@queue << :done
|
|
789
|
+
raise @close_reason if @close_reason
|
|
790
|
+
|
|
791
|
+
return
|
|
792
|
+
end
|
|
793
|
+
yield item
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
alias messages each
|
|
797
|
+
|
|
798
|
+
# Closes the console, telling the node (or the panel) it is done with.
|
|
799
|
+
def close
|
|
800
|
+
finish(nil)
|
|
801
|
+
nil
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# -- used by the transports and the threads ---------------------------------------------------
|
|
805
|
+
|
|
806
|
+
private
|
|
807
|
+
|
|
808
|
+
def pop(timeout)
|
|
809
|
+
return @queue.pop if timeout.nil?
|
|
810
|
+
return @queue.pop(timeout: timeout) || :timeout if RUBY_VERSION >= "3.2"
|
|
811
|
+
|
|
812
|
+
deadline = Console.monotonic + timeout
|
|
813
|
+
loop do
|
|
814
|
+
begin
|
|
815
|
+
return @queue.pop(true)
|
|
816
|
+
rescue ThreadError
|
|
817
|
+
nil
|
|
818
|
+
end
|
|
819
|
+
return :timeout if Console.monotonic >= deadline
|
|
820
|
+
|
|
821
|
+
sleep(0.02)
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def timeout_for(kind)
|
|
826
|
+
case @connect_timeout
|
|
827
|
+
when Numeric then @connect_timeout.to_f
|
|
828
|
+
when Hash then (@connect_timeout[kind] || @connect_timeout[kind.to_sym] || CONNECT_TIMEOUTS[kind]).to_f
|
|
829
|
+
else CONNECT_TIMEOUTS[kind]
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def creds(force: false)
|
|
834
|
+
return @credentials if !force && @credentials && Console.monotonic - @credentials_at < TOKEN_AGE
|
|
835
|
+
|
|
836
|
+
@credentials = @client.panel.servers.websocket(@server).to_h
|
|
837
|
+
@credentials_at = Console.monotonic
|
|
838
|
+
@credentials
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def build(kind, handlers)
|
|
842
|
+
case kind
|
|
843
|
+
when "websocket"
|
|
844
|
+
c = creds
|
|
845
|
+
WebsocketTransport.new(c["socket"].to_s, @origin, c["token"].to_s, handlers)
|
|
846
|
+
when "stream", "poll"
|
|
847
|
+
c = creds
|
|
848
|
+
raise ConsoleError.new("The panel did not give a sessions address.", "unavailable", transport: kind) if c["sessions"].to_s.empty?
|
|
849
|
+
|
|
850
|
+
SessionTransport.new(kind, c["sessions"].to_s, c["token"].to_s, handlers, @poll_wait, @timings)
|
|
851
|
+
when "relay"
|
|
852
|
+
RelayTransport.new(@client, @server, handlers, @relay_interval)
|
|
853
|
+
else
|
|
854
|
+
raise ConsoleError.new("Unknown transport #{kind}.", "unavailable")
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Connects with the first way that works.
|
|
859
|
+
def connect_once
|
|
860
|
+
failures = []
|
|
861
|
+
@kinds.each do |kind|
|
|
862
|
+
tries = 0
|
|
863
|
+
loop do
|
|
864
|
+
raise ConsoleError.new("The console was closed while connecting.", "network") if @ended
|
|
865
|
+
|
|
866
|
+
holder = {}
|
|
867
|
+
closed = lambda do |error|
|
|
868
|
+
lost(error) if !@current.nil? && holder[:transport].equal?(@current)
|
|
869
|
+
end
|
|
870
|
+
transport = nil
|
|
871
|
+
error = nil
|
|
872
|
+
begin
|
|
873
|
+
transport = build(kind, Handlers.new(method(:dispatch), closed))
|
|
874
|
+
holder[:transport] = transport
|
|
875
|
+
transport.start(timeout_for(kind))
|
|
876
|
+
rescue ConsoleError => e
|
|
877
|
+
transport&.close
|
|
878
|
+
error = e
|
|
879
|
+
rescue StandardError
|
|
880
|
+
transport&.close
|
|
881
|
+
raise
|
|
882
|
+
end
|
|
883
|
+
if error.nil?
|
|
884
|
+
if @ended
|
|
885
|
+
transport.close
|
|
886
|
+
raise ConsoleError.new("The console was closed while connecting.", "network")
|
|
887
|
+
end
|
|
888
|
+
@current = transport
|
|
889
|
+
@transport = kind
|
|
890
|
+
return # rubocop:disable Lint/NonLocalExitFromIterator
|
|
891
|
+
end
|
|
892
|
+
if error.reason == "token" && tries.zero? && kind != "relay"
|
|
893
|
+
# The token may have been too old: one more go with a new one.
|
|
894
|
+
tries += 1
|
|
895
|
+
@credentials = nil
|
|
896
|
+
next
|
|
897
|
+
end
|
|
898
|
+
raise error if @kinds.length == 1 || %w[suspended booting].include?(error.reason)
|
|
899
|
+
|
|
900
|
+
failures << error
|
|
901
|
+
break
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
summary = failures.map { |f| "#{f.transport}: #{f.message}" }.join("; ")
|
|
905
|
+
reason = failures.empty? ? "unavailable" : failures.last.reason
|
|
906
|
+
raise ConsoleError.new("Could not connect to the console (#{summary}).", reason, attempts: failures)
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def dispatch(message)
|
|
910
|
+
return if @ended
|
|
911
|
+
|
|
912
|
+
if ["token expiring", "token expired"].include?(message.event)
|
|
913
|
+
renew
|
|
914
|
+
elsif message.event == "jwt error" && Console.monotonic - @last_jwt_renewal > 30
|
|
915
|
+
@last_jwt_renewal = Console.monotonic
|
|
916
|
+
renew
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
listeners = @state_lock.synchronize { @listeners[message.event].dup + @listeners["*"].dup }
|
|
920
|
+
listeners.each do |listener|
|
|
921
|
+
listener.call(message)
|
|
922
|
+
rescue StandardError => e
|
|
923
|
+
warn "sillydev: a console listener failed: #{e.class}: #{e.message}"
|
|
924
|
+
end
|
|
925
|
+
if @buffer
|
|
926
|
+
@queue << message
|
|
927
|
+
@queue.pop(true) while @queue.size > QUEUE_LIMIT
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
finish(ConsoleError.new("The server has been deleted.", "deleted", transport: @transport)) if message.event == "deleted"
|
|
931
|
+
rescue ThreadError
|
|
932
|
+
nil
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# Sends a fresh token on the connection in use.
|
|
936
|
+
def renew
|
|
937
|
+
return if @renewing&.alive?
|
|
938
|
+
|
|
939
|
+
@renewing = Thread.new do
|
|
940
|
+
c = creds(force: true)
|
|
941
|
+
@current&.send_message(ConsoleMessage.new("auth", [c["token"].to_s]))
|
|
942
|
+
rescue StandardError
|
|
943
|
+
nil # The node ends a connection whose token expires; reconnecting gets a new one.
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# The connection in use dropped: connect again, or end.
|
|
948
|
+
def lost(error)
|
|
949
|
+
@current = nil
|
|
950
|
+
return if @ended
|
|
951
|
+
|
|
952
|
+
if !@reconnect || %w[suspended deleted].include?(error.reason)
|
|
953
|
+
finish(error)
|
|
954
|
+
return
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
@ready = Latch.new
|
|
958
|
+
ready = @ready
|
|
959
|
+
@reconnector = Thread.new do
|
|
960
|
+
last = error
|
|
961
|
+
@max_reconnects.times do |attempt|
|
|
962
|
+
break if @ended
|
|
963
|
+
|
|
964
|
+
sleep([2.0**attempt, 30.0].min)
|
|
965
|
+
break if @ended
|
|
966
|
+
|
|
967
|
+
begin
|
|
968
|
+
connect_once
|
|
969
|
+
last = nil
|
|
970
|
+
break
|
|
971
|
+
rescue ConsoleError => e
|
|
972
|
+
last = e
|
|
973
|
+
break if %w[suspended deleted].include?(e.reason)
|
|
974
|
+
rescue APIConnectionError, ServerError => e
|
|
975
|
+
last = e
|
|
976
|
+
rescue StandardError => e
|
|
977
|
+
last = e # The key or a permission: no use trying again.
|
|
978
|
+
break
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
if last.nil?
|
|
982
|
+
ready.set
|
|
983
|
+
else
|
|
984
|
+
reason = last.is_a?(ConsoleError) ? last : ConsoleError.new("Could not reconnect: #{last.message}", "network")
|
|
985
|
+
finish(reason)
|
|
986
|
+
ready.set(reason)
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def finish(error)
|
|
992
|
+
listeners = @state_lock.synchronize do
|
|
993
|
+
return if @ended
|
|
994
|
+
|
|
995
|
+
@ended = true
|
|
996
|
+
@close_reason = error
|
|
997
|
+
@close_listeners.dup
|
|
998
|
+
end
|
|
999
|
+
current = @current
|
|
1000
|
+
@current = nil
|
|
1001
|
+
current&.close
|
|
1002
|
+
@reconnector&.kill unless @reconnector.nil? || @reconnector == Thread.current
|
|
1003
|
+
@ready.set(error || ConsoleError.new("The console is closed.", "network")) unless @ready.done?
|
|
1004
|
+
@queue << :done
|
|
1005
|
+
listeners.each do |listener|
|
|
1006
|
+
listener.call(error)
|
|
1007
|
+
rescue StandardError => e
|
|
1008
|
+
warn "sillydev: a console close listener failed: #{e.class}: #{e.message}"
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# client.panel.console: connect to a server's live console, or read its recent output.
|
|
1014
|
+
class ConsoleApi
|
|
1015
|
+
def initialize(client)
|
|
1016
|
+
@client = client
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
def inspect
|
|
1020
|
+
"#<#{self.class.name}>"
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# Connects to a server's console, trying the websocket, then a streamed, then a polled session
|
|
1024
|
+
# on the node, then the panel's relay, unless transport: names one.
|
|
1025
|
+
#
|
|
1026
|
+
# origin: is the Origin sent with the websocket (default: the origin of the client's base_url);
|
|
1027
|
+
# connect_timeout: the seconds each way may take (a number, or a hash per way; default
|
|
1028
|
+
# websocket 6, stream 8, poll and relay 10); reconnect: whether to connect again when the
|
|
1029
|
+
# connection drops (up to max_reconnect_attempts: in a row); poll_wait: the seconds a poll
|
|
1030
|
+
# waits on the node (at most 25); relay_interval: the seconds between reads of the relay.
|
|
1031
|
+
#
|
|
1032
|
+
# With a block, yields the console and closes it afterwards. Uses operationIds
|
|
1033
|
+
# getWebsocketCredentials and openConsoleRelay.
|
|
1034
|
+
def connect(server, **options)
|
|
1035
|
+
console = Console.new(@client, server, **options).open
|
|
1036
|
+
return console unless block_given?
|
|
1037
|
+
|
|
1038
|
+
begin
|
|
1039
|
+
yield console
|
|
1040
|
+
ensure
|
|
1041
|
+
console.close
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# The server's recent console output, oldest line first (the node keeps the last 150 or so).
|
|
1046
|
+
#
|
|
1047
|
+
# An empty array while the server is offline, since the node only has the output of a running
|
|
1048
|
+
# server. Connects as connect does and closes again; timeout: is the seconds to wait in all,
|
|
1049
|
+
# and quiet: the seconds without a new line after which the history is taken as complete
|
|
1050
|
+
# (default 0.5, or 2.5 through the relay).
|
|
1051
|
+
def history(server, timeout: 15.0, quiet: nil, **options)
|
|
1052
|
+
relay = options[:transport].to_s == "relay"
|
|
1053
|
+
quiet ||= relay ? (options[:relay_interval] || 2.0) + 0.5 : 0.5
|
|
1054
|
+
console = Console.new(@client, server, **options, reconnect: false, buffer: false)
|
|
1055
|
+
History.new(console, timeout, quiet).run
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# Collects the history: waits for the first "status", asks for the logs and then the stats
|
|
1059
|
+
# (the node answers in order, so the stats come after every line), and stops once no new line
|
|
1060
|
+
# has come for a while after the stats.
|
|
1061
|
+
class History
|
|
1062
|
+
def initialize(console, timeout, quiet)
|
|
1063
|
+
@console = console
|
|
1064
|
+
@timeout = timeout
|
|
1065
|
+
@quiet = quiet
|
|
1066
|
+
@lines = []
|
|
1067
|
+
@asked = false
|
|
1068
|
+
@stats = false
|
|
1069
|
+
@last = nil
|
|
1070
|
+
@done = Console::Latch.new
|
|
1071
|
+
@lock = Mutex.new
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def run
|
|
1075
|
+
@console.on("*") { |message| receive(message) }
|
|
1076
|
+
@console.on_close { |error| @done.set(error) }
|
|
1077
|
+
deadline = Console.monotonic + @timeout
|
|
1078
|
+
@console.open
|
|
1079
|
+
loop do
|
|
1080
|
+
remaining = deadline - Console.monotonic
|
|
1081
|
+
if remaining <= 0
|
|
1082
|
+
unless @asked
|
|
1083
|
+
raise ConsoleError.new("No status from the server within #{@timeout} s.", "timeout",
|
|
1084
|
+
transport: @console.transport)
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
break
|
|
1088
|
+
end
|
|
1089
|
+
break if @done.wait([remaining, 0.05].min)
|
|
1090
|
+
|
|
1091
|
+
quiet_since = @lock.synchronize { @stats ? @last : nil }
|
|
1092
|
+
break if quiet_since && Console.monotonic - quiet_since >= @quiet
|
|
1093
|
+
end
|
|
1094
|
+
@lock.synchronize { @lines.dup }
|
|
1095
|
+
ensure
|
|
1096
|
+
@console.close
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
private
|
|
1100
|
+
|
|
1101
|
+
def receive(message)
|
|
1102
|
+
unless @asked
|
|
1103
|
+
return unless message.event == "status"
|
|
1104
|
+
|
|
1105
|
+
if message.args.first == "offline"
|
|
1106
|
+
@done.set
|
|
1107
|
+
return
|
|
1108
|
+
end
|
|
1109
|
+
@asked = true
|
|
1110
|
+
Thread.new do
|
|
1111
|
+
@console.request_logs
|
|
1112
|
+
@console.request_stats
|
|
1113
|
+
rescue StandardError => e
|
|
1114
|
+
@done.set(e)
|
|
1115
|
+
end
|
|
1116
|
+
return
|
|
1117
|
+
end
|
|
1118
|
+
@lock.synchronize do
|
|
1119
|
+
case message.event
|
|
1120
|
+
when "console output"
|
|
1121
|
+
@lines << (message.args.first || "")
|
|
1122
|
+
@last = Console.monotonic if @stats
|
|
1123
|
+
when "stats"
|
|
1124
|
+
@stats = true
|
|
1125
|
+
@last = Console.monotonic
|
|
1126
|
+
end
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
end
|
|
1130
|
+
end
|
|
1131
|
+
end
|