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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SillyDev
4
+ VERSION = "1.0.0"
5
+ 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: []