diff --git a/Ruby/ReadMe.md b/Ruby/ReadMe.md index c6f4b56..61bf892 100644 --- a/Ruby/ReadMe.md +++ b/Ruby/ReadMe.md @@ -59,6 +59,25 @@ Attempt all SLP protocols, disable debug mode, and disable DNS SRV resolution: ms = MineStat.new("minecraft.frag.land", 25565, 3, MineStat::Request::SLP, false, false) ``` +### JSON status protocol selection + +Use `status_protocol` when you need to control the protocol sent in the >=1.7 JSON status handshake: + +```ruby +# Explicit protocol +ms = MineStat.new("frag.land", 25565, request_type: MineStat::Request::JSON, status_protocol: 774) + +# Auto/default mode (same as nil) +ms = MineStat.new("frag.land", 25565, request_type: MineStat::Request::JSON, status_protocol: :auto) +``` + +After polling: +- `requested_protocol`: protocol sent in handshake +- `response_protocol`: protocol from `version.protocol` in response JSON (if present) +- `protocol_mismatch`: whether request/response protocol values differ + +In proxy stacks (Velocity/Bungee) and ViaVersion setups, `version.protocol` may be rewritten or shaped by requester protocol. Do not assume `response_protocol` is always the backend server's real version. + ### Support * Discord: https://discord.frag.land * GitHub: https://github.com/FragLand/minestat diff --git a/Ruby/lib/minestat.rb b/Ruby/lib/minestat.rb index 2f804ef..ca0d04f 100644 --- a/Ruby/lib/minestat.rb +++ b/Ruby/lib/minestat.rb @@ -50,6 +50,10 @@ class MineStat # Default TCP/UDP timeout in seconds DEFAULT_TIMEOUT = 5 + # Default protocol version used for JSON status handshakes in auto mode. + # This preserves the current behavior while keeping future protocol bumps centralized. + DEFAULT_JSON_STATUS_PROTOCOL = 760 + # Bedrock/Pocket Edition packet offset in bytes (1 + 8 + 8 + 16 + 2) # Unconnected pong (0x1C) = 1 byte # Timestamp as a long = 8 bytes @@ -128,6 +132,8 @@ module Request # @param timeout [Integer] TCP/UDP timeout in seconds # @param request_type [Request] Protocol used to poll a Minecraft server # @param debug [Boolean] Enable or disable error output + # @param status_protocol [Integer, Symbol, nil] JSON status handshake protocol + # (`Integer` to force a specific value, `:auto`/`nil` to use the default) # @return [MineStat] A MineStat object # @example Simply connect to an address # ms = MineStat.new("frag.land") @@ -159,6 +165,9 @@ def initialize(address, port, options = {}) @player_list # list of players (UT3/GS4 query only) @plugin_list # list of plugins (UT3/GS4 query only) @protocol # protocol level + @requested_protocol # protocol used in JSON status handshake + @response_protocol # protocol returned by JSON status response + @protocol_mismatch # protocol mismatch between request and response? @json_data # JSON data for 1.7 queries @latency # ping time to server in milliseconds # TCP/UDP timeout @@ -168,6 +177,11 @@ def initialize(address, port, options = {}) @request_type = options[:request_type] || Request::NONE @connection_status # status of connection ("Success", "Fail", "Timeout", or "Unknown") @try_all = false # try all protocols? + # JSON status protocol mode (:auto or Integer) + @status_protocol = normalize_status_protocol(options[:status_protocol]) + @requested_protocol = nil + @response_protocol = nil + @protocol_mismatch = false # debug mode @debug = options[:debug].nil? ? false : options[:debug] # enable SRV resolution? @@ -605,6 +619,10 @@ def extended_legacy_request() # {'players': {'max': 20, 'online': 0}, # 'version': {'protocol': 404, 'name': '1.13.2'}, # 'description': {'text': 'A Minecraft Server'}} + # + # The response protocol is not always the real backend server protocol. + # Proxy stacks such as Velocity/Bungee may shape status responses from the + # requester protocol, and ViaVersion may rewrite `version.protocol`. # @return [Retval] Return value # @since 0.3.0 # @see https://wiki.vg/Server_List_Ping#Current_.281.7.2B.29 @@ -616,7 +634,8 @@ def json_request() return retval unless retval == Retval::SUCCESS # Perform handshake payload = pack_varint(0) - payload << pack_varint(760) + @requested_protocol = selected_status_protocol + payload << pack_varint(@requested_protocol) payload += [@srv_succeeded ? @srv_address.length : @address.length].pack('c') << (@srv_succeeded ? @srv_address : @address) payload += [@srv_succeeded ? @srv_port : @port].pack('n') payload += "\x01" @@ -636,7 +655,12 @@ def json_request() json_data = JSON.parse(json_data) @online = true @json_data = json_data - @protocol = json_data['version']['protocol'].to_i + @response_protocol = nil + if json_data['version'].is_a?(Hash) && !json_data['version']['protocol'].nil? + @response_protocol = json_data['version']['protocol'].to_i + end + @protocol = @response_protocol + @protocol_mismatch = !@response_protocol.nil? && @requested_protocol != @response_protocol @version = json_data['version']['name'] @motd = json_data['description'] strip_motd() @@ -722,6 +746,25 @@ def pack_varint(value) end private :pack_varint + # Normalizes JSON status protocol selection mode + # @param protocol [Integer, Symbol, nil] Protocol selection value + # @return [Integer, Symbol] Integer protocol or :auto + def normalize_status_protocol(protocol) + return :auto if protocol.nil? || protocol == :auto + return protocol if protocol.is_a?(Integer) + + raise ArgumentError, "status_protocol must be an Integer, :auto, or nil" + end + private :normalize_status_protocol + + # Resolves the protocol to use for JSON status handshake + # @return [Integer] Protocol version for the handshake + def selected_status_protocol + return DEFAULT_JSON_STATUS_PROTOCOL if @status_protocol == :auto + @status_protocol + end + private :selected_status_protocol + # Bedrock/Pocket Edition (unconnected ping request) # @note # 1. Client sends: @@ -907,6 +950,17 @@ def query_request() # @note This is arbitrary and varies by Minecraft version (may also be shared by multiple Minecraft versions) attr_reader :protocol + # Protocol used in the JSON status handshake request + # @note `:auto`/`nil` mode uses {DEFAULT_JSON_STATUS_PROTOCOL} + attr_reader :requested_protocol + + # Protocol observed in JSON status response (`version.protocol`) + # @note In proxy environments (Velocity/Bungee/ViaVersion), this may not match backend server protocol + attr_reader :response_protocol + + # Whether requested and response protocols are different + attr_reader :protocol_mismatch + # Complete JSON response data # @note Received using SLP 1.7 (JSON) queries # @since 0.3.0 diff --git a/Ruby/test/test_status_protocol.rb b/Ruby/test/test_status_protocol.rb new file mode 100644 index 0000000..0726414 --- /dev/null +++ b/Ruby/test/test_status_protocol.rb @@ -0,0 +1,159 @@ +require 'json' +require 'minitest/autorun' +require_relative '../lib/minestat' + +class FakeJsonSocket + attr_reader :writes + + def initialize(json_payload) + @writes = [] + @read_buffer = +"" + @recv_buffer = json_payload.dup + + packet_id = pack_varint(0) + json_len = pack_varint(json_payload.bytesize) + total_len = pack_varint(packet_id.bytesize + json_len.bytesize + json_payload.bytesize) + + @read_buffer << total_len + @read_buffer << packet_id + @read_buffer << json_len + end + + def write(data) + @writes << data + data.bytesize + end + + def flush + end + + def close + end + + def read(length) + return nil if @read_buffer.empty? + + chunk = @read_buffer.byteslice(0, length) + @read_buffer = @read_buffer.byteslice(length..-1) || +"" + chunk + end + + def recv(length, _flags = nil) + chunk = @recv_buffer.byteslice(0, length) || +"" + @recv_buffer = @recv_buffer.byteslice(length..-1) || +"" + chunk + end + + private + + def pack_varint(value) + value &= 0xFFFFFFFF + buf = +"" + loop do + byte = value & 0x7F + value >>= 7 + if value != 0 + buf << (byte | 0x80).chr + else + buf << byte.chr + break + end + end + buf + end +end + +class MineStatProtocolHarness < MineStat + attr_reader :captured_socket + + def initialize(options = {}, json_response_protocol: 760) + @json_response_protocol = json_response_protocol + super('example.com', 25565, { + timeout: 1, + request_type: MineStat::Request::JSON, + srv_enabled: false, + debug: false + }.merge(options)) + end + + private + + def resolve_a + true + end + + def resolve_srv + false + end + + def connect + payload = { + 'version' => { 'name' => '1.21.1', 'protocol' => @json_response_protocol }, + 'players' => { 'online' => 1, 'max' => 20 }, + 'description' => { 'text' => 'Hello world' } + } + @captured_socket = FakeJsonSocket.new(JSON.generate(payload)) + @server = @captured_socket + MineStat::Retval::SUCCESS + end +end + +class MineStatStatusProtocolTest < Minitest::Test + def decode_varint(data, offset = 0) + result = 0 + shift = 0 + consumed = 0 + + loop do + byte = data.getbyte(offset + consumed) + raise 'Invalid varint stream' if byte.nil? + + result |= (byte & 0x7F) << shift + consumed += 1 + break if (byte & 0x80).zero? + + shift += 7 + end + + [result, consumed] + end + + def extract_requested_protocol(handshake_packet) + _packet_len, packet_len_size = decode_varint(handshake_packet, 0) + packet_id = handshake_packet.getbyte(packet_len_size) + raise "Unexpected packet id: #{packet_id}" unless packet_id == 0 + + protocol, = decode_varint(handshake_packet, packet_len_size + 1) + protocol + end + + def test_explicit_status_protocol_is_sent_in_handshake + ms = MineStatProtocolHarness.new({ status_protocol: 774 }, json_response_protocol: 774) + + handshake_packet = ms.captured_socket.writes[0] + assert_equal 774, extract_requested_protocol(handshake_packet) + assert_equal 774, ms.requested_protocol + assert_equal 774, ms.response_protocol + assert_equal false, ms.protocol_mismatch + end + + def test_auto_mode_uses_default_for_auto_and_nil + ms_auto = MineStatProtocolHarness.new({ status_protocol: :auto }, json_response_protocol: 760) + ms_nil = MineStatProtocolHarness.new({ status_protocol: nil }, json_response_protocol: 760) + + assert_equal 760, extract_requested_protocol(ms_auto.captured_socket.writes[0]) + assert_equal 760, extract_requested_protocol(ms_nil.captured_socket.writes[0]) + assert_equal 760, ms_auto.requested_protocol + assert_equal 760, ms_nil.requested_protocol + end + + def test_mismatch_records_metadata_without_raising_error + ms = MineStatProtocolHarness.new({ status_protocol: 760 }, json_response_protocol: 774) + + assert_equal true, ms.online + assert_equal 'Success', ms.connection_status + assert_equal 760, ms.requested_protocol + assert_equal 774, ms.response_protocol + assert_equal true, ms.protocol_mismatch + end +end