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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SillyDev
|
|
8
|
+
# What the client asks the transport to send.
|
|
9
|
+
Request = Struct.new(:verb, :url, :headers, :body, keyword_init: true) do
|
|
10
|
+
def header(name)
|
|
11
|
+
headers.find { |key, _| key.casecmp?(name) }&.last
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# What the transport hands back. Header names are lower case.
|
|
16
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true) do
|
|
17
|
+
def header(name)
|
|
18
|
+
headers[name.downcase]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def success?
|
|
22
|
+
status >= 200 && status < 300
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised by a transport when the connection could not be made, so nothing reached the panel and
|
|
27
|
+
# the request is safe to send again.
|
|
28
|
+
class ConnectFailed < StandardError; end
|
|
29
|
+
|
|
30
|
+
# The default transport: Net::HTTP from the standard library, one connection per request.
|
|
31
|
+
#
|
|
32
|
+
# Any object with a #call(request) that returns a Response can take its place, through
|
|
33
|
+
# Client.new(transport: ...). Tests use that to answer without a network.
|
|
34
|
+
class NetHTTPTransport
|
|
35
|
+
CONNECT_ERRORS = [
|
|
36
|
+
SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(timeout: 60, open_timeout: 10)
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
@open_timeout = open_timeout
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(request)
|
|
45
|
+
uri = URI(request.url)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = uri.scheme == "https"
|
|
48
|
+
http.open_timeout = @open_timeout
|
|
49
|
+
http.read_timeout = @timeout
|
|
50
|
+
http.write_timeout = @timeout
|
|
51
|
+
begin
|
|
52
|
+
http.start
|
|
53
|
+
rescue *CONNECT_ERRORS => e
|
|
54
|
+
raise ConnectFailed, e.message
|
|
55
|
+
end
|
|
56
|
+
begin
|
|
57
|
+
req = Net::HTTPGenericRequest.new(request.verb, !request.body.nil?, request.verb != "HEAD", uri.request_uri)
|
|
58
|
+
request.headers.each { |key, value| req[key] = value }
|
|
59
|
+
req.body = request.body unless request.body.nil?
|
|
60
|
+
res = http.request(req)
|
|
61
|
+
headers = {}
|
|
62
|
+
res.each_header { |key, value| headers[key.downcase] = value }
|
|
63
|
+
body = res.body.to_s
|
|
64
|
+
body = body.dup.force_encoding(Encoding::UTF_8) if body.encoding == Encoding::BINARY
|
|
65
|
+
Response.new(status: res.code.to_i, headers: headers, body: body)
|
|
66
|
+
ensure
|
|
67
|
+
http.finish if http.started?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module SillyDev
|
|
11
|
+
# A small websocket client (RFC 6455) on the standard library, enough for a server's console:
|
|
12
|
+
# text frames, ping and pong, and closing. Used by SillyDev::Console.
|
|
13
|
+
class WebSocket
|
|
14
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
15
|
+
MAX_MESSAGE = 16 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
# Raised when the connection fails or closes. #code and #reason come from the close frame.
|
|
18
|
+
class Closed < StandardError
|
|
19
|
+
attr_reader :code, :reason
|
|
20
|
+
|
|
21
|
+
def initialize(message, code: nil, reason: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@code = code
|
|
24
|
+
@reason = reason
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Encoding and decoding frames.
|
|
29
|
+
module Frame
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# One frame. Clients mask what they send; servers do not.
|
|
33
|
+
def encode(opcode, payload, mask: true)
|
|
34
|
+
payload = payload.to_s.b
|
|
35
|
+
head = [0x80 | opcode].pack("C")
|
|
36
|
+
length = payload.bytesize
|
|
37
|
+
mask_bit = mask ? 0x80 : 0
|
|
38
|
+
head << if length < 126
|
|
39
|
+
[mask_bit | length].pack("C")
|
|
40
|
+
elsif length < 65_536
|
|
41
|
+
[mask_bit | 126, length].pack("Cn")
|
|
42
|
+
else
|
|
43
|
+
[mask_bit | 127, length].pack("CQ>")
|
|
44
|
+
end
|
|
45
|
+
return head + payload unless mask
|
|
46
|
+
|
|
47
|
+
key = SecureRandom.random_bytes(4)
|
|
48
|
+
head + key + apply_mask(payload, key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_mask(payload, key)
|
|
52
|
+
bytes = payload.bytes
|
|
53
|
+
keys = key.bytes
|
|
54
|
+
bytes.each_index { |i| bytes[i] ^= keys[i % 4] }
|
|
55
|
+
bytes.pack("C*")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reads one frame with read.call(n), which returns exactly n bytes: [fin, opcode, payload].
|
|
59
|
+
def decode(read)
|
|
60
|
+
first, second = read.call(2).unpack("CC")
|
|
61
|
+
length = second & 0x7f
|
|
62
|
+
length = read.call(2).unpack1("n") if length == 126
|
|
63
|
+
length = read.call(8).unpack1("Q>") if length == 127
|
|
64
|
+
raise Closed, "A frame was too large." if length > MAX_MESSAGE
|
|
65
|
+
|
|
66
|
+
key = second.nobits?(0x80) ? nil : read.call(4)
|
|
67
|
+
payload = length.zero? ? "".b : read.call(length)
|
|
68
|
+
payload = apply_mask(payload, key) if key
|
|
69
|
+
[first.anybits?(0x80), first & 0x0f, payload]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :close_code, :close_reason
|
|
74
|
+
|
|
75
|
+
# Opens a websocket to a ws:// or wss:// URL, waiting at most timeout seconds for the upgrade.
|
|
76
|
+
def self.connect(url, origin: nil, headers: {}, timeout: 10)
|
|
77
|
+
new(url, origin: origin, headers: headers, timeout: timeout)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(url, origin:, headers:, timeout:)
|
|
81
|
+
@uri = URI(url)
|
|
82
|
+
@write_lock = Mutex.new
|
|
83
|
+
@closed = false
|
|
84
|
+
deadline = monotonic + timeout
|
|
85
|
+
@socket = open_socket(timeout)
|
|
86
|
+
handshake(origin, headers, deadline)
|
|
87
|
+
rescue Closed
|
|
88
|
+
@socket&.close
|
|
89
|
+
raise
|
|
90
|
+
rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError => e
|
|
91
|
+
@socket&.close
|
|
92
|
+
raise Closed, "Could not connect: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def closed?
|
|
96
|
+
@closed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Sends one text message.
|
|
100
|
+
def send_text(text)
|
|
101
|
+
write_frame(0x1, text.to_s.encode(Encoding::UTF_8))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Waits for the next text message and returns it, answering pings meanwhile. Raises Closed
|
|
105
|
+
# when the connection ends, including when #close is called from another thread.
|
|
106
|
+
def read
|
|
107
|
+
message = nil
|
|
108
|
+
loop do
|
|
109
|
+
fin, opcode, payload = Frame.decode(->(n) { read_exactly(n, nil) })
|
|
110
|
+
case opcode
|
|
111
|
+
when 0x0
|
|
112
|
+
raise Closed, "An unexpected continuation frame." if message.nil?
|
|
113
|
+
|
|
114
|
+
message << payload
|
|
115
|
+
when 0x1, 0x2
|
|
116
|
+
message = payload.dup
|
|
117
|
+
when 0x8
|
|
118
|
+
handle_close(payload)
|
|
119
|
+
when 0x9
|
|
120
|
+
write_frame(0xA, payload)
|
|
121
|
+
next
|
|
122
|
+
else
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
raise Closed, "A message was too large." if message.bytesize > MAX_MESSAGE
|
|
126
|
+
return message.force_encoding(Encoding::UTF_8) if fin
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Sends a close frame and closes the connection.
|
|
131
|
+
def close(code = 1000)
|
|
132
|
+
return if @closed
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
write_frame(0x8, [code].pack("n"))
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
@closed = true
|
|
140
|
+
@socket.close
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
class TimeoutRead < StandardError; end
|
|
148
|
+
private_constant :TimeoutRead
|
|
149
|
+
|
|
150
|
+
def monotonic
|
|
151
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def secure?
|
|
155
|
+
%w[wss https].include?(@uri.scheme)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def open_socket(timeout)
|
|
159
|
+
port = @uri.port || (secure? ? 443 : 80)
|
|
160
|
+
tcp = Socket.tcp(@uri.host, port, connect_timeout: timeout)
|
|
161
|
+
tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
162
|
+
return tcp unless secure?
|
|
163
|
+
|
|
164
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
165
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
166
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
167
|
+
ssl.hostname = @uri.host
|
|
168
|
+
ssl.sync_close = true
|
|
169
|
+
ssl.connect_nonblock(exception: false) until ssl_connected?(ssl, timeout)
|
|
170
|
+
ssl.post_connection_check(@uri.host)
|
|
171
|
+
ssl
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ssl_connected?(ssl, timeout)
|
|
175
|
+
case ssl.connect_nonblock(exception: false)
|
|
176
|
+
when :wait_readable then raise Closed, "The TLS handshake timed out." unless ssl.to_io.wait_readable(timeout)
|
|
177
|
+
when :wait_writable then raise Closed, "The TLS handshake timed out." unless ssl.to_io.wait_writable(timeout)
|
|
178
|
+
else return true
|
|
179
|
+
end
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def handshake(origin, headers, deadline)
|
|
184
|
+
key = [SecureRandom.random_bytes(16)].pack("m0")
|
|
185
|
+
path = @uri.request_uri
|
|
186
|
+
host = @uri.port && ![80, 443].include?(@uri.port) ? "#{@uri.host}:#{@uri.port}" : @uri.host
|
|
187
|
+
lines = [
|
|
188
|
+
"GET #{path} HTTP/1.1", "Host: #{host}", "Upgrade: websocket", "Connection: Upgrade",
|
|
189
|
+
"Sec-WebSocket-Key: #{key}", "Sec-WebSocket-Version: 13"
|
|
190
|
+
]
|
|
191
|
+
lines << "Origin: #{origin}" if origin
|
|
192
|
+
headers.each { |name, value| lines << "#{name}: #{value}" }
|
|
193
|
+
@socket.write("#{lines.join("\r\n")}\r\n\r\n")
|
|
194
|
+
|
|
195
|
+
response = +""
|
|
196
|
+
response << read_exactly(1, deadline) until response.end_with?("\r\n\r\n")
|
|
197
|
+
status = response[%r{\AHTTP/1\.1 (\d{3})}, 1].to_i
|
|
198
|
+
raise Closed.new("The node refused the websocket (HTTP #{status}).", code: status) unless status == 101
|
|
199
|
+
|
|
200
|
+
accept = response[/^sec-websocket-accept:\s*(\S+)/i, 1]
|
|
201
|
+
expected = [Digest::SHA1.digest(key + GUID)].pack("m0")
|
|
202
|
+
raise Closed, "The node answered the upgrade wrongly." unless accept == expected
|
|
203
|
+
rescue TimeoutRead
|
|
204
|
+
raise Closed, "The websocket upgrade timed out."
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def handle_close(payload)
|
|
208
|
+
code = payload.bytesize >= 2 ? payload.unpack1("n") : nil
|
|
209
|
+
reason = payload.bytesize > 2 ? payload.byteslice(2..).force_encoding(Encoding::UTF_8) : nil
|
|
210
|
+
@close_code = code
|
|
211
|
+
@close_reason = reason
|
|
212
|
+
close(code || 1000) unless @closed
|
|
213
|
+
raise Closed.new("The websocket closed (code #{code || 'none'}).", code: code, reason: reason)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def write_frame(opcode, payload)
|
|
217
|
+
raise Closed, "The websocket is closed." if @closed && opcode != 0x8
|
|
218
|
+
|
|
219
|
+
@write_lock.synchronize { @socket.write(Frame.encode(opcode, payload, mask: true)) }
|
|
220
|
+
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
221
|
+
raise Closed, "Could not send: #{e.message}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def read_exactly(count, deadline)
|
|
225
|
+
buffer = +"".b
|
|
226
|
+
while buffer.bytesize < count
|
|
227
|
+
chunk = @socket.read_nonblock(count - buffer.bytesize, exception: false)
|
|
228
|
+
case chunk
|
|
229
|
+
when :wait_readable, :wait_writable
|
|
230
|
+
wait = deadline ? deadline - monotonic : nil
|
|
231
|
+
raise TimeoutRead if wait && wait <= 0
|
|
232
|
+
|
|
233
|
+
io = @socket.to_io
|
|
234
|
+
ready = chunk == :wait_readable ? io.wait_readable(wait) : io.wait_writable(wait)
|
|
235
|
+
raise TimeoutRead unless ready
|
|
236
|
+
when nil
|
|
237
|
+
@closed = true
|
|
238
|
+
raise Closed.new("The connection was closed.", code: @close_code || 1006)
|
|
239
|
+
else
|
|
240
|
+
buffer << chunk
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
buffer
|
|
244
|
+
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
245
|
+
raise Closed.new("The connection failed: #{e.message}", code: 1006)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
data/lib/sillydev.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Ruby client for the Silly Development panel API.
|
|
4
|
+
#
|
|
5
|
+
# require "sillydev"
|
|
6
|
+
#
|
|
7
|
+
# client = SillyDev::Client.new # reads SILLYDEV_API_KEY
|
|
8
|
+
# client.panel.servers.list.each { |server| puts server["name"] }
|
|
9
|
+
# client.panel.servers.restart("1a7ce997")
|
|
10
|
+
#
|
|
11
|
+
# See https://sillydev.co.uk/docs/panel for the API itself.
|
|
12
|
+
module SillyDev
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "sillydev/version"
|
|
16
|
+
require_relative "sillydev/errors"
|
|
17
|
+
require_relative "sillydev/envelope"
|
|
18
|
+
require_relative "sillydev/transport"
|
|
19
|
+
require_relative "sillydev/client"
|
|
20
|
+
require_relative "sillydev/resources"
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sillydev
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Silly Development
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Start, stop and restart your servers, send console commands, manage files,
|
|
13
|
+
backups, schedules and databases, and deploy from Git on Silly Development, from
|
|
14
|
+
Ruby.
|
|
15
|
+
executables:
|
|
16
|
+
- sillydev
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- exe/sillydev
|
|
23
|
+
- lib/sillydev.rb
|
|
24
|
+
- lib/sillydev/cli.rb
|
|
25
|
+
- lib/sillydev/client.rb
|
|
26
|
+
- lib/sillydev/console.rb
|
|
27
|
+
- lib/sillydev/envelope.rb
|
|
28
|
+
- lib/sillydev/errors.rb
|
|
29
|
+
- lib/sillydev/mcp_server.rb
|
|
30
|
+
- lib/sillydev/monitoring.rb
|
|
31
|
+
- lib/sillydev/monitoring/aggregator.rb
|
|
32
|
+
- lib/sillydev/monitoring/client.rb
|
|
33
|
+
- lib/sillydev/monitoring/discordrb.rb
|
|
34
|
+
- lib/sillydev/monitoring/http.rb
|
|
35
|
+
- lib/sillydev/monitoring/rack.rb
|
|
36
|
+
- lib/sillydev/monitoring/rails.rb
|
|
37
|
+
- lib/sillydev/monitoring/runtime.rb
|
|
38
|
+
- lib/sillydev/monitoring/serialize.rb
|
|
39
|
+
- lib/sillydev/resources.rb
|
|
40
|
+
- lib/sillydev/transport.rb
|
|
41
|
+
- lib/sillydev/version.rb
|
|
42
|
+
- lib/sillydev/websocket.rb
|
|
43
|
+
homepage: https://sillydev.co.uk/docs/panel
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
homepage_uri: https://sillydev.co.uk
|
|
48
|
+
documentation_uri: https://sillydev.co.uk/docs/panel
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.1'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 3.6.9
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: Ruby client for the Silly Development panel API
|
|
67
|
+
test_files: []
|