minestat 2.3.0 → 3.0.1

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.
Files changed (8) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +8 -0
  3. data/ChangeLog.md +61 -0
  4. data/License.txt +674 -0
  5. data/ReadMe.md +64 -0
  6. data/example.rb +18 -0
  7. data/lib/minestat.rb +466 -201
  8. metadata +16 -8
data/lib/minestat.rb CHANGED
@@ -22,106 +22,178 @@ require 'resolv'
22
22
  require 'socket'
23
23
  require 'timeout'
24
24
 
25
- ##
26
- # Provides a ruby interface for polling Minecraft server status.
25
+ # @author Lloyd Dilley
26
+
27
+ # Provides a Ruby interface for polling the status of Minecraft servers
27
28
  class MineStat
28
29
  # MineStat version
29
- VERSION = "2.3.0"
30
+ VERSION = "3.0.1"
31
+
30
32
  # Number of values expected from server
31
33
  NUM_FIELDS = 6
32
- # Number of values expected from a 1.8b/1.3 server
34
+ private_constant :NUM_FIELDS
35
+
36
+ # Number of values expected from a 1.8b - 1.3 server
33
37
  NUM_FIELDS_BETA = 3
38
+ private_constant :NUM_FIELDS_BETA
39
+
34
40
  # Maximum number of bytes a varint can be
35
41
  MAX_VARINT_SIZE = 5
42
+ private_constant :MAX_VARINT_SIZE
43
+
36
44
  # Default TCP port
37
45
  DEFAULT_TCP_PORT = 25565
46
+
38
47
  # Bedrock/Pocket Edition default UDP port
39
48
  DEFAULT_BEDROCK_PORT = 19132
49
+
40
50
  # Default TCP/UDP timeout in seconds
41
51
  DEFAULT_TIMEOUT = 5
52
+
42
53
  # Bedrock/Pocket Edition packet offset in bytes (1 + 8 + 8 + 16 + 2)
43
- # Unconnected pong (0x1C) = 1 byte
44
- # Timestamp as a long = 8 bytes
45
- # Server GUID as a long = 8 bytes
46
- # Magic number = 16 bytes
47
- # String ID length = 2 bytes
54
+ # Unconnected pong (0x1C) = 1 byte
55
+ # Timestamp as a long = 8 bytes
56
+ # Server GUID as a long = 8 bytes
57
+ # Magic number = 16 bytes
58
+ # String ID length = 2 bytes
48
59
  BEDROCK_PACKET_OFFSET = 35
49
-
50
- ##
51
- # Stores constants that represent the results of a server ping
60
+ private_constant :BEDROCK_PACKET_OFFSET
61
+
62
+ # UT3/GS4 query handshake packet size in bytes (1 + 4 + 13)
63
+ # Handshake (0x09) = 1 byte
64
+ # Session ID = 4 bytes
65
+ # Challenge token = variable null-terminated string up to 13 bytes(?)
66
+ QUERY_HANDSHAKE_SIZE = 18
67
+ private_constant :QUERY_HANDSHAKE_SIZE
68
+
69
+ # UT3/GS4 query handshake packet offset for challenge token in bytes (1 + 4)
70
+ # Handshake (0x09) = 1 byte
71
+ # Session ID = 4 bytes
72
+ QUERY_HANDSHAKE_OFFSET = 5
73
+ private_constant :QUERY_HANDSHAKE_OFFSET
74
+
75
+ # UT3/GS4 query full stat packet offset in bytes (1 + 4 + 11)
76
+ # Stat (0x00) = 1 byte
77
+ # Session ID = 4 bytes
78
+ # Padding = 11 bytes
79
+ QUERY_STAT_OFFSET = 16
80
+ private_constant :QUERY_STAT_OFFSET
81
+
82
+ # These constants represent the result of a server request
52
83
  module Retval
53
84
  # The server ping completed successfully
54
85
  SUCCESS = 0
86
+
55
87
  # The server ping failed due to a connection error
56
88
  CONNFAIL = -1
89
+
57
90
  # The server ping failed due to a connection time out
58
91
  TIMEOUT = -2
92
+
59
93
  # The server ping failed for an unknown reason
60
94
  UNKNOWN = -3
61
95
  end
62
96
 
63
- ##
64
- # Stores constants that represent the different kinds of server
65
- # list pings/requests that a Minecraft server might expect when
66
- # being polled for status information.
97
+ # These constants represent the various protocols used when requesting server data
67
98
  module Request
68
99
  # Try everything
69
100
  NONE = -1
101
+
70
102
  # Server versions 1.8b to 1.3
71
103
  BETA = 0
104
+
72
105
  # Server versions 1.4 to 1.5
73
106
  LEGACY = 1
107
+
74
108
  # Server version 1.6
75
109
  EXTENDED = 2
110
+
76
111
  # Server versions 1.7 to latest
77
112
  JSON = 3
113
+
78
114
  # Bedrock/Pocket Edition
79
115
  BEDROCK = 4
116
+
117
+ # Unreal Tournament 3/GameSpy 4 query
118
+ QUERY = 5
119
+
120
+ # SLP only
121
+ # @since 3.0.1
122
+ SLP = 6
80
123
  end
81
124
 
82
- ##
83
- # Instantiate an instance of MineStat and poll the specified server for information
84
- def initialize(address, port = DEFAULT_TCP_PORT, timeout = DEFAULT_TIMEOUT, request_type = Request::NONE)
85
- @address = address # address of server
86
- @port = port # TCP/UDP port of server
87
- @online # online or offline?
88
- @version # server version
89
- @mode # game mode (Bedrock/Pocket Edition only)
90
- @motd # message of the day
91
- @stripped_motd # message of the day without formatting
92
- @current_players # current number of players online
93
- @max_players # maximum player capacity
94
- @protocol # protocol level
95
- @json_data # JSON data for 1.7 queries
96
- @favicon_b64 # base64-encoded favicon possibly contained in JSON 1.7 responses
97
- @favicon # decoded favicon data
98
- @latency # ping time to server in milliseconds
99
- @timeout = timeout # TCP/UDP timeout
100
- @server # server socket
101
- @request_type # protocol version
102
- @connection_status # status of connection ("Success", "Fail", "Timeout", or "Unknown")
103
- @try_all = false # try all protocols?
125
+ # Instantiates a MineStat object and polls the specified server for information
126
+ # @param address [String] Minecraft server address
127
+ # @param port [Integer] Minecraft server TCP or UDP port
128
+ # @param timeout [Integer] TCP/UDP timeout in seconds
129
+ # @param request_type [Request] Protocol used to poll a Minecraft server
130
+ # @param debug [Boolean] Enable or disable error output
131
+ # @return [MineStat] A MineStat object
132
+ # @example Simply connect to an address
133
+ # ms = MineStat.new("frag.land")
134
+ # @example Connect to an address on a certain TCP or UDP port
135
+ # ms = MineStat.new("frag.land", 25565)
136
+ # @example Same as above example and additionally includes a timeout in seconds
137
+ # ms = MineStat.new("frag.land", 25565, 3)
138
+ # @example Same as above example and additionally includes an explicit protocol to use
139
+ # ms = MineStat.new("frag.land", 25565, 3, MineStat::Request::QUERY)
140
+ # @example Connect to a Bedrock server and enable debug mode
141
+ # ms = MineStat.new("minecraft.frag.land", 19132, 3, MineStat::Request::BEDROCK, true)
142
+ # @example Attempt all SLP protocols, disable debug mode, and disable DNS SRV resolution
143
+ # ms = MineStat.new("minecraft.frag.land", 25565, 3, MineStat::Request::SLP, false, false)
144
+ def initialize(address, port = DEFAULT_TCP_PORT, timeout = DEFAULT_TIMEOUT, request_type = Request::NONE, debug = false, srv_enabled = true)
145
+ @address = address # address of server
146
+ @port = port # TCP/UDP port of server
147
+ @srv_address # server address from DNS SRV record
148
+ @srv_port # server TCP port from DNS SRV record
149
+ @online # online or offline?
150
+ @version # server version
151
+ @mode # game mode (Bedrock/Pocket Edition only)
152
+ @motd # message of the day
153
+ @stripped_motd # message of the day without formatting
154
+ @current_players # current number of players online
155
+ @max_players # maximum player capacity
156
+ @player_list # list of players (UT3/GS4 query only)
157
+ @plugin_list # list of plugins (UT3/GS4 query only)
158
+ @protocol # protocol level
159
+ @json_data # JSON data for 1.7 queries
160
+ @favicon_b64 # base64-encoded favicon possibly contained in JSON 1.7 responses
161
+ @favicon # decoded favicon data
162
+ @latency # ping time to server in milliseconds
163
+ @timeout = timeout # TCP/UDP timeout
164
+ @server # server socket
165
+ @request_type # protocol version
166
+ @connection_status # status of connection ("Success", "Fail", "Timeout", or "Unknown")
167
+ @try_all = false # try all protocols?
168
+ @debug = debug # debug mode
169
+ @srv_enabled = srv_enabled # enable SRV resolution?
104
170
 
105
171
  @try_all = true if request_type == Request::NONE
106
- resolve_srv(address, port)
107
- set_connection_status(attempt_protocols())
172
+ resolve_srv() if @srv_enabled
173
+ set_connection_status(attempt_protocols(request_type))
108
174
  end
109
175
 
110
- # Attempts to resolve SRV records
111
- def resolve_srv(address, port)
176
+ # Attempts to resolve DNS SRV records
177
+ # @return [Boolean] Whether or not SRV resolution was successful
178
+ # @since 2.3.0
179
+ def resolve_srv()
112
180
  begin
113
181
  resolver = Resolv::DNS.new
114
182
  res = resolver.getresource("_minecraft._tcp.#{@address}", Resolv::DNS::Resource::IN::SRV)
115
- @address = res.target.to_s # SRV target
116
- @port = res.port.to_i # SRV port
117
- rescue => exception # primarily catch Resolv::ResolvError and revert if unable to resolve SRV record(s)
118
- @address = address
119
- @port = port
183
+ @srv_address = res.target.to_s # SRV target
184
+ @srv_port = res.port.to_i # SRV port
185
+ rescue => exception # primarily catch Resolv::ResolvError and revert if unable to resolve SRV record(s)
186
+ $stderr.puts "resolve_srv(): #{exception}" if @debug
187
+ return false
120
188
  end
189
+ return true
121
190
  end
191
+ private :resolve_srv
122
192
 
123
193
  # Attempts the use of various protocols
124
- def attempt_protocols()
194
+ # @param request_type [Request] Protocol used to poll a Minecraft server
195
+ # @return [Retval] Return value
196
+ def attempt_protocols(request_type)
125
197
  case request_type
126
198
  when Request::BETA
127
199
  retval = beta_request()
@@ -133,6 +205,8 @@ class MineStat
133
205
  retval = json_request()
134
206
  when Request::BEDROCK
135
207
  retval = bedrock_request()
208
+ when Request::QUERY
209
+ retval = query_request()
136
210
  else
137
211
  # Attempt various ping requests in a particular order. If the
138
212
  # connection fails, there is no reason to continue with subsequent
@@ -153,21 +227,29 @@ class MineStat
153
227
  unless retval == Retval::CONNFAIL
154
228
  retval = json_request()
155
229
  end
230
+ return retval if request_type == Request::SLP
156
231
  # Bedrock/Pocket Edition
157
232
  unless @online || retval == Retval::SUCCESS
158
233
  retval = bedrock_request()
159
234
  end
235
+ # UT3/GS4 query
236
+ unless @online || retval == Retval::SUCCESS
237
+ retval = query_request()
238
+ end
160
239
  end
161
240
  return retval
162
241
  end
242
+ private :attempt_protocols
163
243
 
164
244
  # Sets connection status
245
+ # @param retval [Retval] Return value
165
246
  def set_connection_status(retval)
166
247
  @connection_status = "Success" if @online || retval == Retval::SUCCESS
167
248
  @connection_status = "Fail" if retval == Retval::CONNFAIL
168
249
  @connection_status = "Timeout" if retval == Retval::TIMEOUT
169
250
  @connection_status = "Unknown" if retval == Retval::UNKNOWN
170
251
  end
252
+ private :set_connection_status
171
253
 
172
254
  # Strips message of the day formatting characters
173
255
  def strip_motd()
@@ -187,33 +269,41 @@ class MineStat
187
269
  @stripped_motd = @stripped_motd.force_encoding('UTF-8')
188
270
  @stripped_motd = @stripped_motd.gsub(/§./, "")
189
271
  end
272
+ private :strip_motd
190
273
 
191
- ##
192
274
  # Establishes a connection to the Minecraft server
193
275
  def connect()
194
276
  begin
195
- if @request_type == Request::BEDROCK || @request_type == "Bedrock/Pocket Edition"
196
- @port = DEFAULT_BEDROCK_PORT if @port == DEFAULT_TCP_PORT && @try_all
277
+ if @request_type == Request::BEDROCK || @request_type == "Bedrock/Pocket Edition" || @request_type == "UT3/GS4 Query"
278
+ @port = DEFAULT_BEDROCK_PORT if @port == DEFAULT_TCP_PORT && @request_type != "UT3/GS4 Query" && @try_all
197
279
  start_time = Time.now
198
280
  @server = UDPSocket.new
199
281
  @server.connect(@address, @port)
200
282
  else
201
283
  start_time = Time.now
202
- @server = TCPSocket.new(@address, @port)
284
+ if @srv_enabled
285
+ @server = TCPSocket.new(@srv_address, @srv_port)
286
+ else
287
+ @server = TCPSocket.new(@address, @port)
288
+ end
203
289
  end
204
290
  @latency = ((Time.now - start_time) * 1000).round
205
291
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
292
+ $stderr.puts "connect(): Host unreachable or connection refused" if @debug
206
293
  return Retval::CONNFAIL
207
294
  rescue => exception
208
- #$stderr.puts exception
295
+ $stderr.puts "connect(): #{exception}" if @debug
209
296
  return Retval::UNKNOWN
210
297
  end
211
298
  return Retval::SUCCESS
212
299
  end
300
+ private :connect
213
301
 
214
- # Populates object fields after connecting
215
- def parse_data(delimiter, is_beta = false)
302
+ # Validates server response based on beginning of the packet
303
+ # @return [String, Retval] Raw data received from a Minecraft server and the return value
304
+ def check_response()
216
305
  data = nil
306
+ retval = nil
217
307
  begin
218
308
  if @request_type == "Bedrock/Pocket Edition"
219
309
  if @server.recv(1, Socket::MSG_PEEK).unpack('C').first == 0x1C # unconnected pong packet
@@ -222,7 +312,15 @@ class MineStat
222
312
  @server.close
223
313
  else
224
314
  @server.close
225
- return Retval::UNKNOWN
315
+ retval = Retval::UNKNOWN
316
+ end
317
+ elsif @request_type == "UT3/GS4 Query"
318
+ if @server.recv(1, Socket::MSG_PEEK).unpack('C').first == 0x00 # stat packet
319
+ data = @server.recv(4096)[QUERY_STAT_OFFSET..-1]
320
+ @server.close
321
+ else
322
+ @server.close
323
+ retval = Retval::UNKNOWN
226
324
  end
227
325
  else # SLP
228
326
  if @server.read(1).unpack('C').first == 0xFF # kick packet (255)
@@ -231,25 +329,36 @@ class MineStat
231
329
  @server.close
232
330
  else
233
331
  @server.close
234
- return Retval::UNKNOWN
332
+ retval = Retval::UNKNOWN
235
333
  end
236
334
  end
237
- rescue
238
- #rescue => exception
239
- #$stderr.puts exception
240
- return Retval::UNKNOWN
335
+ rescue => exception
336
+ $stderr.puts "check_response(): #{exception}" if @debug
337
+ return nil, Retval::UNKNOWN
241
338
  end
339
+ retval = Retval::UNKNOWN if data == nil || data.empty?
340
+ return data, retval
341
+ end
342
+ private :check_response
242
343
 
243
- if data == nil || data.empty?
244
- return Retval::UNKNOWN
245
- end
344
+ # Populates object fields after retrieving data from a Minecraft server
345
+ # @param delimiter [String] Delimiter used to split a string into an array
346
+ # @param is_beta [Boolean] Whether or not the Minecraft server is using version 1.8b to 1.3
347
+ def parse_data(delimiter, is_beta = false)
348
+ data, retval = check_response()
349
+ return retval if retval == Retval::UNKNOWN
246
350
 
247
- server_info = data.split(delimiter)
351
+ server_info = nil
352
+ if @request_type == "UT3/GS4 Query"
353
+ server_info = data.split("\x00\x00\x01player_\x00\x00")
354
+ else
355
+ server_info = data.split(delimiter)
356
+ end
248
357
  if is_beta
249
358
  if server_info != nil && server_info.length >= NUM_FIELDS_BETA
250
359
  @version = ">=1.8b/1.3" # since server does not return version, set it
251
360
  @motd = server_info[0]
252
- strip_motd
361
+ strip_motd()
253
362
  @current_players = server_info[1].to_i
254
363
  @max_players = server_info[2].to_i
255
364
  @online = true
@@ -262,20 +371,39 @@ class MineStat
262
371
  @version = "#{server_info[3]} #{server_info[7]} (#{server_info[0]})"
263
372
  @mode = server_info[8]
264
373
  @motd = server_info[1]
265
- strip_motd
374
+ strip_motd()
266
375
  @current_players = server_info[4].to_i
267
376
  @max_players = server_info[5].to_i
268
377
  @online = true
269
378
  else
270
379
  return Retval::UNKNOWN
271
380
  end
381
+ elsif @request_type == "UT3/GS4 Query"
382
+ if server_info != nil
383
+ @player_list = server_info[1].split(delimiter) unless server_info[1].nil? || server_info[1].empty?
384
+ server_info = Hash[*server_info[0].split(delimiter).flatten(1)]
385
+ @version = server_info["version"]
386
+ @motd = server_info["hostname"]
387
+ strip_motd()
388
+ @current_players = server_info["numplayers"].to_i
389
+ @max_players = server_info["maxplayers"].to_i
390
+ unless server_info["plugins"].nil? || server_info["plugins"].empty?
391
+ # Vanilla servers do not send a list of plugins.
392
+ # Bukkit and derivatives send plugins in the form: Paper on 1.19.3-R0.1-SNAPSHOT: Essentials 2.19.7; EssentialsChat 2.19.7
393
+ @plugin_list = server_info["plugins"].split(':')
394
+ @plugin_list = @plugin_list[1].split(';').collect(&:strip) if @plugin_list.size > 1
395
+ end
396
+ @online = true
397
+ else
398
+ return Retval::UNKNOWN
399
+ end
272
400
  else # SLP
273
401
  if server_info != nil && server_info.length >= NUM_FIELDS
274
402
  # server_info[0] contains the section symbol and 1
275
403
  @protocol = server_info[1].to_i # contains the protocol version (51 for 1.9 or 78 for 1.6.4 for example)
276
404
  @version = server_info[2]
277
405
  @motd = server_info[3]
278
- strip_motd
406
+ strip_motd()
279
407
  @current_players = server_info[4].to_i
280
408
  @max_players = server_info[5].to_i
281
409
  @online = true
@@ -285,18 +413,22 @@ class MineStat
285
413
  end
286
414
  return Retval::SUCCESS
287
415
  end
288
-
289
- ##
290
- # 1.8 beta through 1.3 servers communicate as follows for a ping request:
291
- # 1. Client sends \xFE (server list ping)
292
- # 2. Server responds with:
293
- # 2a. \xFF (kick packet)
294
- # 2b. data length
295
- # 2c. 3 fields delimited by \u00A7 (section symbol)
296
- # The 3 fields, in order, are:
297
- # * message of the day
298
- # * current players
299
- # * max players
416
+ private :parse_data
417
+
418
+ # 1.8b - 1.3 (SLP request)
419
+ # @note
420
+ # 1. Client sends 0xFE (server list ping)
421
+ # 2. Server responds with:
422
+ # 2a. 0xFF (kick packet)
423
+ # 2b. data length
424
+ # 2c. 3 fields delimited by \u00A7 (section symbol)
425
+ # The 3 fields, in order, are:
426
+ # * message of the day
427
+ # * current players
428
+ # * max players
429
+ # @return [Retval] Return value
430
+ # @since 0.2.1
431
+ # @see https://wiki.vg/Server_List_Ping#Beta_1.8_to_1.3
300
432
  def beta_request()
301
433
  retval = nil
302
434
  begin
@@ -308,9 +440,10 @@ class MineStat
308
440
  retval = parse_data("\u00A7", true) # section symbol
309
441
  end
310
442
  rescue Timeout::Error
443
+ $stderr.puts "beta_request(): Connection timed out" if @debug
311
444
  return Retval::TIMEOUT
312
445
  rescue => exception
313
- $stderr.puts exception
446
+ $stderr.puts "beta_request(): #{exception}" if @debug
314
447
  return Retval::UNKNOWN
315
448
  end
316
449
  if retval == Retval::SUCCESS
@@ -319,26 +452,29 @@ class MineStat
319
452
  end
320
453
  return retval
321
454
  end
322
-
323
- ##
324
- # 1.4 and 1.5 servers communicate as follows for a ping request:
325
- # 1. Client sends:
326
- # 1a. \xFE (server list ping)
327
- # 1b. \x01 (server list ping payload)
328
- # 2. Server responds with:
329
- # 2a. \xFF (kick packet)
330
- # 2b. data length
331
- # 2c. 6 fields delimited by \x00 (null)
332
- # The 6 fields, in order, are:
333
- # * the section symbol and 1
334
- # * protocol version
335
- # * server version
336
- # * message of the day
337
- # * current players
338
- # * max players
455
+ private :beta_request
456
+
457
+ # 1.4 and 1.5 (SLP request)
458
+ # @note
459
+ # 1. Client sends:
460
+ # 1a. 0xFE (server list ping)
461
+ # 1b. 0x01 (server list ping payload)
462
+ # 2. Server responds with:
463
+ # 2a. 0xFF (kick packet)
464
+ # 2b. data length
465
+ # 2c. 6 fields delimited by 0x00 (null)
466
+ # The 6 fields, in order, are:
467
+ # * the section symbol and 1
468
+ # * protocol version
469
+ # * server version
470
+ # * message of the day
471
+ # * current players
472
+ # * max players
339
473
  #
340
- # The protocol version corresponds with the server version and can be the
341
- # same for different server versions.
474
+ # The protocol version corresponds with the server version and can be the
475
+ # same for different server versions.
476
+ # @return [Retval] Return value
477
+ # @see https://wiki.vg/Server_List_Ping#1.4_to_1.5
342
478
  def legacy_request()
343
479
  retval = nil
344
480
  begin
@@ -350,9 +486,10 @@ class MineStat
350
486
  retval = parse_data("\x00") # null
351
487
  end
352
488
  rescue Timeout::Error
489
+ $stderr.puts "legacy_request(): Connection timed out" if @debug
353
490
  return Retval::TIMEOUT
354
491
  rescue => exception
355
- $stderr.puts exception
492
+ $stderr.puts "legacy_request(): #{exception}" if @debug
356
493
  return Retval::UNKNOWN
357
494
  end
358
495
  if retval == Retval::SUCCESS
@@ -361,34 +498,38 @@ class MineStat
361
498
  end
362
499
  return retval
363
500
  end
364
-
365
- ##
366
- # 1.6 servers communicate as follows for a ping request:
367
- # 1. Client sends:
368
- # 1a. \xFE (server list ping)
369
- # 1b. \x01 (server list ping payload)
370
- # 1c. \xFA (plugin message)
371
- # 1d. \x00\x0B (11 which is the length of "MC|PingHost")
372
- # 1e. "MC|PingHost" encoded as a UTF-16BE string
373
- # 1f. length of remaining data as a short: remote address (encoded as UTF-16BE) + 7
374
- # 1g. arbitrary 1.6 protocol version (\x4E for example for 78)
375
- # 1h. length of remote address as a short
376
- # 1i. remote address encoded as a UTF-16BE string
377
- # 1j. remote port as an int
378
- # 2. Server responds with:
379
- # 2a. \xFF (kick packet)
380
- # 2b. data length
381
- # 2c. 6 fields delimited by \x00 (null)
382
- # The 6 fields, in order, are:
383
- # * the section symbol and 1
384
- # * protocol version
385
- # * server version
386
- # * message of the day
387
- # * current players
388
- # * max players
501
+ private :legacy_request
502
+
503
+ # 1.6 (SLP request)
504
+ # @note
505
+ # 1. Client sends:
506
+ # 1a. 0xFE (server list ping)
507
+ # 1b. 0x01 (server list ping payload)
508
+ # 1c. 0xFA (plugin message)
509
+ # 1d. 0x00 0x0B (11 which is the length of "MC|PingHost")
510
+ # 1e. "MC|PingHost" encoded as a UTF-16BE string
511
+ # 1f. length of remaining data as a short: remote address (encoded as UTF-16BE) + 7
512
+ # 1g. arbitrary 1.6 protocol version (0x4E for example for 78)
513
+ # 1h. length of remote address as a short
514
+ # 1i. remote address encoded as a UTF-16BE string
515
+ # 1j. remote port as an int
516
+ # 2. Server responds with:
517
+ # 2a. 0xFF (kick packet)
518
+ # 2b. data length
519
+ # 2c. 6 fields delimited by 0x00 (null)
520
+ # The 6 fields, in order, are:
521
+ # * the section symbol and 1
522
+ # * protocol version
523
+ # * server version
524
+ # * message of the day
525
+ # * current players
526
+ # * max players
389
527
  #
390
528
  # The protocol version corresponds with the server version and can be the
391
529
  # same for different server versions.
530
+ # @return [Retval] Return value
531
+ # @since 0.2.0
532
+ # @see https://wiki.vg/Server_List_Ping#1.6
392
533
  def extended_legacy_request()
393
534
  retval = nil
394
535
  begin
@@ -407,9 +548,10 @@ class MineStat
407
548
  retval = parse_data("\x00") # null
408
549
  end
409
550
  rescue Timeout::Error
551
+ $stderr.puts "extended_legacy_request(): Connection timed out" if @debug
410
552
  return Retval::TIMEOUT
411
553
  rescue => exception
412
- $stderr.puts exception
554
+ $stderr.puts "extended_legacy_request(): #{exception}" if @debug
413
555
  return Retval::UNKNOWN
414
556
  end
415
557
  if retval == Retval::SUCCESS
@@ -418,23 +560,27 @@ class MineStat
418
560
  end
419
561
  return retval
420
562
  end
421
-
422
- ##
423
- # 1.7 to current servers communicate as follows for a ping request:
424
- # 1. Client sends:
425
- # 1a. \x00 (handshake packet containing the fields specified below)
426
- # 1b. \x00 (request)
427
- # The handshake packet contains the following fields respectively:
428
- # 1. protocol version as a varint (\x00 suffices)
429
- # 2. remote address as a string
430
- # 3. remote port as an unsigned short
431
- # 4. state as a varint (should be 1 for status)
432
- # 2. Server responds with:
433
- # 2a. \x00 (JSON response)
434
- # An example JSON string contains:
435
- # {'players': {'max': 20, 'online': 0},
436
- # 'version': {'protocol': 404, 'name': '1.13.2'},
437
- # 'description': {'text': 'A Minecraft Server'}}
563
+ private :extended_legacy_request
564
+
565
+ # >=1.7 (SLP request)
566
+ # @note
567
+ # 1. Client sends:
568
+ # 1a. 0x00 (handshake packet containing the fields specified below)
569
+ # 1b. 0x00 (request)
570
+ # The handshake packet contains the following fields respectively:
571
+ # 1. protocol version as a varint (0x00 suffices)
572
+ # 2. remote address as a string
573
+ # 3. remote port as an unsigned short
574
+ # 4. state as a varint (should be 1 for status)
575
+ # 2. Server responds with:
576
+ # 2a. 0x00 (JSON response)
577
+ # An example JSON string contains:
578
+ # {'players': {'max': 20, 'online': 0},
579
+ # 'version': {'protocol': 404, 'name': '1.13.2'},
580
+ # 'description': {'text': 'A Minecraft Server'}}
581
+ # @return [Retval] Return value
582
+ # @since 0.3.0
583
+ # @see https://wiki.vg/Server_List_Ping#Current_.281.7.2B.29
438
584
  def json_request()
439
585
  retval = nil
440
586
  begin
@@ -464,7 +610,7 @@ class MineStat
464
610
  @protocol = json_data['version']['protocol'].to_i
465
611
  @version = json_data['version']['name']
466
612
  @motd = json_data['description']
467
- strip_motd
613
+ strip_motd()
468
614
  @current_players = json_data['players']['online'].to_i
469
615
  @max_players = json_data['players']['max'].to_i
470
616
  @favicon_b64 = json_data['favicon']
@@ -479,11 +625,13 @@ class MineStat
479
625
  end
480
626
  end
481
627
  rescue Timeout::Error
628
+ $stderr.puts "json_request(): Connection timed out" if @debug
482
629
  return Retval::TIMEOUT
483
630
  rescue JSON::ParserError
631
+ $stderr.puts "json_request(): JSON parse error" if @debug
484
632
  return Retval::UNKNOWN
485
633
  rescue => exception
486
- $stderr.puts exception
634
+ $stderr.puts "json_request(): #{exception}" if @debug
487
635
  return Retval::UNKNOWN
488
636
  end
489
637
  if retval == Retval::SUCCESS
@@ -492,8 +640,11 @@ class MineStat
492
640
  end
493
641
  return retval
494
642
  end
643
+ private :json_request
495
644
 
496
645
  # Reads JSON data from the socket
646
+ # @param json_len [Integer] Length of the JSON data received from the Minecraft server
647
+ # @return [String] JSON data received from the Mincraft server
497
648
  def recv_json(json_len)
498
649
  json_data = ""
499
650
  begin
@@ -505,12 +656,15 @@ class MineStat
505
656
  break if json_data.length >= json_len
506
657
  end
507
658
  rescue => exception
508
- $stderr.puts exception
659
+ $stderr.puts "recv_json(): #{exception}" if @debug
509
660
  end
510
661
  return json_data
511
662
  end
663
+ private :recv_json
512
664
 
513
- # Returns value of varint type
665
+ # Decodes the value of a varint type
666
+ # @return [Integer] Value decoded from a varint type
667
+ # @see https://en.wikipedia.org/wiki/LEB128
514
668
  def unpack_varint()
515
669
  vint = 0
516
670
  i = 0
@@ -524,34 +678,38 @@ class MineStat
524
678
  end
525
679
  return vint
526
680
  end
527
-
528
- ##
529
- # Bedrock/Pocket Edition servers communicate as follows for an unconnected ping request:
530
- # 1. Client sends:
531
- # 1a. \x01 (unconnected ping packet containing the fields specified below)
532
- # 1b. current time as a long
533
- # 1c. magic number
534
- # 1d. client GUID as a long
535
- # 2. Server responds with:
536
- # 2a. \x1c (unconnected pong packet containing the follow fields)
537
- # 2b. current time as a long
538
- # 2c. server GUID as a long
539
- # 2d. 16-bit magic number
540
- # 2e. server ID string length
541
- # 2f. server ID as a string
542
- # The fields from the pong response, in order, are:
543
- # * edition
544
- # * MotD line 1
545
- # * protocol version
546
- # * version name
547
- # * current player count
548
- # * maximum player count
549
- # * unique server ID
550
- # * MotD line 2
551
- # * game mode as a string
552
- # * game mode as a numeric
553
- # * IPv4 port number
554
- # * IPv6 port number
681
+ private :unpack_varint
682
+
683
+ # Bedrock/Pocket Edition (unconnected ping request)
684
+ # @note
685
+ # 1. Client sends:
686
+ # 1a. 0x01 (unconnected ping packet containing the fields specified below)
687
+ # 1b. current time as a long
688
+ # 1c. magic number
689
+ # 1d. client GUID as a long
690
+ # 2. Server responds with:
691
+ # 2a. 0x1c (unconnected pong packet containing the follow fields)
692
+ # 2b. current time as a long
693
+ # 2c. server GUID as a long
694
+ # 2d. 16-bit magic number
695
+ # 2e. server ID string length
696
+ # 2f. server ID as a string
697
+ # The fields from the pong response, in order, are:
698
+ # * edition
699
+ # * MotD line 1
700
+ # * protocol version
701
+ # * version name
702
+ # * current player count
703
+ # * maximum player count
704
+ # * unique server ID
705
+ # * MotD line 2
706
+ # * game mode as a string
707
+ # * game mode as a numeric
708
+ # * IPv4 port number
709
+ # * IPv6 port number
710
+ # @return [Retval] Return value
711
+ # @since 2.2.0
712
+ # @see https://wiki.vg/Raknet_Protocol#Unconnected_Ping
555
713
  def bedrock_request()
556
714
  retval = nil
557
715
  begin
@@ -569,9 +727,10 @@ class MineStat
569
727
  retval = parse_data("\x3B") # semicolon
570
728
  end
571
729
  rescue Timeout::Error
730
+ $stderr.puts "bedrock_request(): Connection timed out" if @debug
572
731
  return Retval::TIMEOUT
573
732
  rescue => exception
574
- $stderr.puts exception
733
+ $stderr.puts "bedrock_request(): #{exception}" if @debug
575
734
  return Retval::UNKNOWN
576
735
  end
577
736
  if retval == Retval::SUCCESS
@@ -579,62 +738,168 @@ class MineStat
579
738
  end
580
739
  return retval
581
740
  end
741
+ private :bedrock_request
742
+
743
+ # Unreal Tournament 3/GameSpy 4 (UT3/GS4) query protocol
744
+ # @note
745
+ # 1. Client sends:
746
+ # 1a. 0xFE 0xFD (query identifier)
747
+ # 1b. 0x09 (handshake)
748
+ # 1c. arbitrary session ID (4 bytes)
749
+ # 2. Server responds with:
750
+ # 2a. 0x09 (handshake)
751
+ # 2b. session ID (4 bytes)
752
+ # 2c. challenge token (variable null-terminated string)
753
+ # 3. Client sends:
754
+ # 3a. 0xFE 0xFD (query identifier)
755
+ # 3b. 0x00 (stat)
756
+ # 3c. arbitrary session ID (4 bytes)
757
+ # 3d. challenge token (32-bit integer in network byte order)
758
+ # 3e. padding (4 bytes -- 0x00 0x00 0x00 0x00); omit padding for basic stat (which does not supply the version)
759
+ # 4. Server responds with:
760
+ # 4a. 0x00 (stat)
761
+ # 4b. session ID (4 bytes)
762
+ # 4c. padding (11 bytes)
763
+ # 4e. key/value pairs of multiple null-terminated strings containing the fields below:
764
+ # hostname, game type, game ID, version, plugin list, map, current players, max players, port, address
765
+ # 4f. padding (10 bytes)
766
+ # 4g. list of null-terminated strings containing player names
767
+ # @return [Retval] Return value
768
+ # @since 3.0.0
769
+ # @see https://wiki.vg/Query
770
+ def query_request()
771
+ retval = nil
772
+ begin
773
+ Timeout::timeout(@timeout) do
774
+ @request_type = "UT3/GS4 Query"
775
+ retval = connect()
776
+ return retval unless retval == Retval::SUCCESS
777
+ payload = "\xFE\xFD\x09\x0B\x03\x03\x0F"
778
+ @server.write(payload)
779
+ @server.flush
780
+ if @server.recv(1, Socket::MSG_PEEK).unpack('C').first == 0x09 # query handshake packet
781
+ # Session ID generated by the server is not used -- use a static session ID instead such as 0x0B 0x03 0x03 0x0F.
782
+ #session_id = @server.recv(QUERY_HANDSHAKE_OFFSET, Socket::MSG_PEEK)[1..-1].unpack('l>')
783
+ challenge_token = @server.recv(QUERY_HANDSHAKE_SIZE)[QUERY_HANDSHAKE_OFFSET..-1]
784
+ payload = "\xFE\xFD\x00\x0B\x03\x03\x0F".force_encoding('ASCII-8BIT')
785
+ # Use the full stat below by stripping the null terminator from the challenge token and padding the end
786
+ # of the payload with "\x00\x00\x00\x00". The basic stat response does not include the server version.
787
+ payload += [challenge_token.rstrip.to_i].pack('l>').force_encoding('ASCII-8BIT')
788
+ payload += "\x00\x00\x00\x00".force_encoding('ASCII-8BIT')
789
+ @server.write(payload)
790
+ @server.flush
791
+ else
792
+ return Retval::UNKNOWN
793
+ end
794
+ retval = parse_data("\x00") # null
795
+ end
796
+ rescue Timeout::Error
797
+ $stderr.puts "query_request(): Connection timed out" if @debug
798
+ return Retval::TIMEOUT
799
+ rescue => exception
800
+ $stderr.puts "query_request(): #{exception}" if @debug
801
+ return Retval::UNKNOWN
802
+ end
803
+ if retval == Retval::SUCCESS
804
+ set_connection_status(retval)
805
+ end
806
+ return retval
807
+ end
808
+ private :query_request
582
809
 
583
- # Returns the Minecraft server IP
810
+ # Address (hostname or IP address) of the Minecraft server
584
811
  attr_reader :address
585
812
 
586
- # Returns the Minecraft server TCP port
813
+ # Port (TCP or UDP) of the Minecraft server
587
814
  attr_reader :port
588
815
 
589
- # Returns a boolean describing whether the server is online or offline
816
+ # Address of the Minecraft server from a DNS SRV record
817
+ # @since 3.0.1
818
+ attr_reader :srv_address
819
+
820
+ # TCP port of the Minecraft server from a DNS SRV record
821
+ # @since 3.0.1
822
+ attr_reader :srv_port
823
+
824
+ # Whether or not the Minecraft server is online
590
825
  attr_reader :online
591
826
 
592
- # Returns the Minecraft version that the server is running
827
+ # Minecraft server version
593
828
  attr_reader :version
594
829
 
595
- # Returns the game mode (Bedrock/Pocket Edition only)
830
+ # Game mode
831
+ # @note Bedrock/Pocket Edition only
832
+ # @since 2.2.0
596
833
  attr_reader :mode
597
834
 
598
- # Returns the full version of the MotD
599
- #
600
- # If you just want the MotD text, use stripped_motd
835
+ # Full message of the day (MotD)
836
+ # @note If only the plain text MotD is relevant, use {#stripped_motd}
837
+ # @see #stripped_motd
601
838
  attr_reader :motd
602
839
 
603
- # Returns just the plain text contained within the MotD
840
+ # Plain text contained within the message of the day (MotD)
841
+ # @note If the full MotD is desired, use {#motd}
842
+ # @see #motd
604
843
  attr_reader :stripped_motd
605
844
 
606
- # Returns the current player count
845
+ # Current player count
607
846
  attr_reader :current_players
608
847
 
609
- # Returns the maximum player count
848
+ # Maximum player limit
610
849
  attr_reader :max_players
611
850
 
612
- # Returns the protocol level
613
- #
614
- # This is arbitrary and varies by Minecraft version.
615
- # However, multiple Minecraft versions can share the same
616
- # protocol level
851
+ # List of players
852
+ # @note UT3/GS4 query only
853
+ # @since 3.0.0
854
+ attr_reader :player_list
855
+
856
+ # List of plugins
857
+ # @note UT3/GS4 query only
858
+ # @since 3.0.0
859
+ attr_reader :plugin_list
860
+
861
+ # Protocol level
862
+ # @note This is arbitrary and varies by Minecraft version (may also be shared by multiple Minecraft versions)
617
863
  attr_reader :protocol
618
864
 
619
- # Returns the complete JSON response data for queries to Minecraft
620
- # servers with a version greater than or equal to 1.7
865
+ # Complete JSON response data
866
+ # @note Received using SLP 1.7 (JSON) queries
867
+ # @since 0.3.0
621
868
  attr_reader :json_data
622
869
 
623
- # Returns the base64-encoded favicon from JSON 1.7 queries
870
+ # Base64-encoded favicon
871
+ # @note Received using SLP 1.7 (JSON) queries
872
+ # @since 2.2.2
624
873
  attr_reader :favicon_b64
625
874
 
626
- # Returns the decoded favicon from JSON 1.7 queries
875
+ # Decoded favicon
876
+ # @note Received using SLP 1.7 (JSON) queries
877
+ # @since 2.2.2
627
878
  attr_reader :favicon
628
879
 
629
- # Returns the ping time to the server in ms
880
+ # Ping time to the server in milliseconds (ms)
881
+ # @since 0.2.1
630
882
  attr_reader :latency
631
883
 
632
- # Returns the protocol version
884
+ # TCP/UDP timeout in seconds
885
+ # @since 0.1.2
886
+ attr_accessor :timeout
887
+
888
+ # Protocol used to request data from a Minecraft server
633
889
  attr_reader :request_type
634
890
 
635
- # Returns the connection status
891
+ # Connection status
892
+ # @since 2.2.2
636
893
  attr_reader :connection_status
637
894
 
638
- # Returns whether or not all ping protocols should be attempted
895
+ # Whether or not all protocols should be attempted
639
896
  attr_reader :try_all
897
+
898
+ # Whether or not debug mode is enabled
899
+ # @since 3.0.0
900
+ attr_reader :debug
901
+
902
+ # Whether or not DNS SRV resolution is enabled
903
+ # @since 3.0.1
904
+ attr_reader :srv_enabled
640
905
  end