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.
@@ -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