gamequery 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. data/bin/gamequerytool +566 -0
  2. data/lib/gamequery.rb +775 -0
  3. metadata +54 -0
data/bin/gamequerytool ADDED
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env ruby
2
+ ################################################################
3
+ #
4
+ # gamequerytool - query game servers
5
+ #
6
+ # (C) 2006 Erik Hollensbe <erik@hollensbe.org>, License details below
7
+ #
8
+ # Use 'gamequerytool -h' for usage instructions.
9
+ #
10
+ # The compilation of software known as gamequerytool is distributed under the
11
+ # following terms:
12
+ # Copyright (C) 2005-2006 Erik Hollensbe. All rights reserved.
13
+ #
14
+ # Redistribution and use in source form, with or without
15
+ # modification, are permitted provided that the following conditions
16
+ # are met:
17
+ # 1. Redistributions of source code must retain the above copyright notice,
18
+ # this list of conditions and the following disclaimer.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
21
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23
+ # ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
24
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
26
+ # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
27
+ # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
28
+ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
29
+ # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
30
+ # SUCH DAMAGE.
31
+ #
32
+ ################################################################
33
+
34
+
35
+ #
36
+ # rubygems hack
37
+ #
38
+
39
+ begin
40
+ require 'rubygems'
41
+ rescue LoadError => e
42
+ end
43
+ begin
44
+ require 'gamequery'
45
+ require 'ip'
46
+ rescue LoadError => e
47
+ $stderr.puts "gamequerytool requires the gamequery and ip libraries be installed."
48
+ $stderr.puts "You can find them both via rubygems or at http://rubyforge.org."
49
+ exit -1
50
+ end
51
+
52
+ GAMEQUERYTOOL_VERSION = "0.1.0"
53
+
54
+ require 'optparse'
55
+ require 'ostruct'
56
+ require 'rexml/document'
57
+ require 'rexml/element'
58
+ require 'rexml/xmldecl'
59
+ require 'csv'
60
+
61
+ #
62
+ # Option parser
63
+ #
64
+
65
+ def get_options
66
+ options = OpenStruct.new
67
+ # our options
68
+ options.ip_address = nil
69
+ options.port = nil
70
+ options.output_type = :standard
71
+ options.query_type = nil
72
+ options.server_type = nil
73
+ options.region = :other
74
+ options.verbose = false
75
+ options.filters = []
76
+
77
+ optparse = OptionParser.new do |opts|
78
+ opts.banner = "Usage: #{File.basename $0} <ip_address:port> [options]"
79
+ opts.separator ""
80
+ opts.separator "Required Arguments:"
81
+
82
+ opts.on("-s", "--server-type [TYPE]", [:hlds, :source],
83
+ "Type of server to contact:",
84
+ "\thlds: Half-Life 1 based servers",
85
+ "\tsource: Half-Life 2 based servers") do |server_type|
86
+ options.server_type = server_type
87
+ end
88
+
89
+ opts.on("-m", "--master-query", "Run a master server query instead of a normal one") do
90
+ unless options.query_type.nil?
91
+ $stderr.puts "Error: Master and Server queries are incompatible."
92
+ $stderr.puts opts
93
+ exit -1
94
+ end
95
+
96
+ options.query_type = :master
97
+ end
98
+
99
+ opts.on("-q", "--query-type [TYPE]", [:info, :players, :rules, :full, :ping],
100
+ "Type of (server-oriented) query (incompatible with -m):",
101
+ "\tping: get alive status and response time",
102
+ "\tinfo: server information query",
103
+ "\tplayers: player information query",
104
+ "\trules: rules information query",
105
+ "\tfull: combines info/players/rules") do |query_type|
106
+
107
+ unless options.query_type.nil?
108
+ $stderr.puts "Error: Master and Server queries are incompatible."
109
+ $stderr.puts opts
110
+ exit -1
111
+ end
112
+
113
+ options.query_type = query_type
114
+ end
115
+
116
+ opts.separator ""
117
+ opts.separator "Options:"
118
+
119
+ opts.on("-r", "--region [REGION]",
120
+ [:us_east, :us_west, :south_america,
121
+ :europe, :asia, :australia, :middle_east,
122
+ :africa, :other],
123
+ "Region for master server query:",
124
+ "\tus_east, us_west, south_america, europe, asia,",
125
+ "\taustralia, middle_east, africa, or other (the default)") do |region|
126
+ options.region = region
127
+ end
128
+
129
+ opts.on("-o", "--output-type [TYPE]", [:standard, :xml, :csv],
130
+ "Type of output to produce (standard, xml, csv)") do |output_type|
131
+ options.output_type = output_type
132
+ end
133
+
134
+ opts.on("-v", "--[no-]verbose",
135
+ "Run verbosely.") do |verbose|
136
+ options.verbose = verbose
137
+ end
138
+
139
+ opts.on("-h", "--help", "This help message.") do
140
+ $stderr.puts opts
141
+ exit -1
142
+ end
143
+
144
+ opts.on("--version", "Print the version information.") do
145
+ $stderr.puts "This is gamequerytool version #{GAMEQUERYTOOL_VERSION},"
146
+ $stderr.puts "it is located at #{File.expand_path $0}."
147
+ exit -1
148
+ end
149
+
150
+ opts.separator ""
151
+ opts.separator "Filters (only work with Master Server Queries):"
152
+
153
+ opts.on("--filter-empty", "Remove empty servers from the listing.") do
154
+ options.filters.push [:empty, true]
155
+ end
156
+
157
+ opts.on("--filter-full", "Remove full servers from the listing.") do
158
+ options.filters.push [:full, true]
159
+ end
160
+
161
+ opts.on("--filter-insecure", "Remove insecure servers from the listing") do
162
+ options.filters.push [:secure, true]
163
+ end
164
+
165
+ opts.on("--filter-dedicated-only", "Show dedicated servers only") do
166
+ options.filters.push [:type, 'd']
167
+ end
168
+
169
+ opts.on("--filter-linux-only", "Show servers running on the Linux OS") do
170
+ options.filters.push [:linux, true]
171
+ end
172
+
173
+ opts.on("--filter-proxy-only", "Show spectator proxy servers") do |spec|
174
+ options.filters.push [:proxy, spec]
175
+ end
176
+
177
+ opts.on("--filter-gamedir [GAMEDIR]", "Only show servers running under [GAMEDIR] (mod)") do |gd|
178
+ options.filters.push [:gamedir, gd]
179
+ end
180
+
181
+ opts.on("--filter-map [MAPNAME]", "Only show servers running [MAPNAME]") do |map|
182
+ options.filters.push [:map, map]
183
+ end
184
+
185
+ opts.separator ""
186
+ opts.separator "Note: the default IP:port combination will be used with master queries if one is not provided."
187
+ opts.separator ""
188
+ end
189
+
190
+ if ARGV[0].nil?
191
+ $stderr.puts optparse
192
+ exit -1
193
+ end
194
+
195
+ ip, port = [nil, nil]
196
+
197
+ #
198
+ # validate the IP:port specification if we have one.
199
+ #
200
+
201
+ unless ARGV[0].match(/^-/)
202
+ error = false
203
+ ip, port = ARGV.shift.split(":")
204
+ begin
205
+ ip = IP::Address.new(ip)
206
+ tmp = port.to_i
207
+ error = true if tmp.to_s != port
208
+ port = tmp
209
+ rescue IP::AddressException => e
210
+ error = true
211
+ end
212
+
213
+ if error
214
+ $stderr.puts "Please use a valid IP:port specification."
215
+ $stderr.puts optparse
216
+ exit -1
217
+ end
218
+ end
219
+
220
+ optparse.parse!
221
+
222
+ options.ip_address = ip ? ip.ip_address : nil
223
+ options.port = port
224
+
225
+ if (ip.nil? or port.nil?) and options.query_type != :master
226
+ $stderr.puts "Server queries require a IP:port specification."
227
+ $stderr.puts optparse
228
+ exit -1
229
+ end
230
+
231
+ if [options.query_type, options.server_type].include? nil
232
+ $stderr.puts "Query type and Server type are required."
233
+ $stderr.puts optparse
234
+ exit -1
235
+ end
236
+
237
+ return options
238
+
239
+ end
240
+
241
+ #
242
+ # Verbose output
243
+ #
244
+
245
+ def verbose(string)
246
+ $stderr.puts string if $options.verbose
247
+ end
248
+
249
+ #
250
+ # Output an assembled XML document.
251
+ #
252
+
253
+ def output_xml(root)
254
+ verbose("Writing XML")
255
+ REXML::XMLDecl.new("1.0").write($stdout, 2)
256
+ $stdout.puts
257
+ REXML::Document.new.add(root).write($stdout, 2)
258
+ end
259
+
260
+ #
261
+ # Output master server query information according to format.
262
+ #
263
+
264
+ def output_master_query(queryinfo)
265
+ case $options.output_type
266
+ when :standard
267
+ verbose("Outputting in standard mode")
268
+ queryinfo.each { |server| puts "#{server.ip}:#{server.port}" }
269
+ when :xml
270
+ verbose("Outputting in XML mode")
271
+ root = REXML::Element.new("gamequery")
272
+ master = REXML::Element.new("master")
273
+ server = REXML::Element.new("server")
274
+ ip = REXML::Element.new("ip")
275
+ port = REXML::Element.new("port")
276
+
277
+ master.add_attribute("type", $options.server_type)
278
+ root.add_element(master)
279
+
280
+ queryinfo.each do |s|
281
+ s_ip = REXML::Element.new(ip)
282
+ s_port = REXML::Element.new(port)
283
+ s_ip.text = s.ip
284
+ s_port.text = s.port
285
+ s_server = REXML::Element.new(server)
286
+ s_server.add_element(s_ip)
287
+ s_server.add_element(s_port)
288
+ master.add_element(s_server)
289
+ end
290
+
291
+ output_xml(root)
292
+ when :csv
293
+ verbose("Outputting in CSV mode")
294
+
295
+ queryinfo.each do |server|
296
+ puts CSV.generate_line([server.ip, server.port])
297
+ end
298
+ end
299
+ end
300
+
301
+ #
302
+ # Output server query information according to format.
303
+ #
304
+
305
+ def output_server_query(queryinfo)
306
+ case $options.output_type
307
+ when :standard
308
+ verbose("Outputting in standard mode")
309
+ if queryinfo[:info]
310
+ verbose("Outputting server information")
311
+ info = queryinfo[:info]
312
+ format = "%-20.20s | %37.37s"
313
+ puts "Server Information:"
314
+ puts "-" * 60
315
+ [
316
+ ["Server Name", info.server_name],
317
+ ["Game", info.game_description],
318
+ ["Mod Name (gamedir)", info.gamedir],
319
+ ["Map", info.map],
320
+ ["Players", "#{info.numplayers}/#{info.maxplayers}"],
321
+ ["Bots", info.numbots],
322
+ ["Server OS", info.os],
323
+ ["Dedicated", info.dedicated ? 'yes' : 'no'],
324
+ ["Require Password", info.password ? 'yes' : 'no'],
325
+ ["Using VAC", info.secure ? 'yes' : 'no'],
326
+ ].each { |feh| puts format % feh }
327
+ puts
328
+ end
329
+ if queryinfo[:players]
330
+ verbose("Outputting player information")
331
+ format = "%3.3s | %35.35s | %5.5s | %8.8s"
332
+ puts "Player List: #{queryinfo[:players].num_players} players"
333
+ puts "%3.3s | %35.35s | %5.5s | %8.8s " % %w(No. Name Kills Time)
334
+ puts "-" * 60
335
+ queryinfo[:players].each do |player|
336
+ seconds = player.time.ceil
337
+ minutes = seconds / 60
338
+ hours = minutes / 60
339
+
340
+ seconds -= minutes * 60
341
+ minutes -= hours * 60
342
+
343
+ time = "#{"%02d" % (hours > 0 ? hours : 0)}:#{"%02d" % (minutes > 0 ? minutes : 0)}:#{"%02d" % seconds}"
344
+
345
+ puts format % [player.index, player.name, player.kills, time]
346
+ end
347
+ puts
348
+ end
349
+ if queryinfo[:rules]
350
+ verbose("Outputting rule information")
351
+ puts "Rules: #{queryinfo[:rules].num_rules}"
352
+ puts "-" * 60
353
+ format = "%-25.25s | %32.32s"
354
+ queryinfo[:rules].rules.each_key { |rule_name| puts format % [rule_name,queryinfo[:rules].rules[rule_name]] }
355
+ puts
356
+ end
357
+ if queryinfo[:ping]
358
+ verbose("Outputting ping information")
359
+ ping = queryinfo[:ping] == -1 ? 'Unable to contact' : "#{queryinfo[:ping]}ms"
360
+ puts "Ping: #{ping}"
361
+ puts
362
+ end
363
+ when :xml
364
+ verbose("Outputting in XML mode")
365
+ root = REXML::Element.new("gamequery")
366
+ server = REXML::Element.new("server")
367
+
368
+ server.add_attribute("ip_address", $options.ip_address)
369
+ server.add_attribute("port", $options.port)
370
+
371
+ root.add_element(server)
372
+
373
+ if queryinfo[:info]
374
+ verbose("Outputting server information")
375
+ info = REXML::Element.new("info")
376
+ server.add_element(info)
377
+
378
+ [:type, :version, :server_name, :map, :gamedir, :game_description,
379
+ :appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
380
+ :secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
381
+ :mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type].each do |sym|
382
+
383
+ node = REXML::Element.new(sym.to_s)
384
+ node.text = queryinfo[:info].send(sym).to_s
385
+ info.add_element(node)
386
+ end
387
+ end
388
+ if queryinfo[:players]
389
+ verbose("Outputting player information")
390
+ players = REXML::Element.new("players")
391
+ player = REXML::Element.new("player")
392
+ name = REXML::Element.new("name")
393
+ index = REXML::Element.new("index")
394
+ time = REXML::Element.new("time")
395
+ kills = REXML::Element.new("kills")
396
+
397
+ server.add_element(players)
398
+
399
+ queryinfo[:players].each do |p|
400
+ p_player = REXML::Element.new(player)
401
+ p_name = REXML::Element.new(name)
402
+ p_index = REXML::Element.new(index)
403
+ p_time = REXML::Element.new(time)
404
+ p_kills = REXML::Element.new(kills)
405
+
406
+ p_name.text = p.name
407
+ p_index.text = p.index
408
+ p_time.text = p.time
409
+ p_kills.text = p.kills
410
+
411
+ [p_name, p_index, p_time, p_kills].each { |x| p_player.add_element(x) }
412
+ players.add_element(p_player)
413
+ end
414
+ end
415
+ if queryinfo[:rules]
416
+ verbose("Outputting rule information")
417
+ rules = REXML::Element.new("rules")
418
+ rule = REXML::Element.new("rule")
419
+ rules.add_attribute("amount", queryinfo[:rules].num_rules)
420
+
421
+ queryinfo[:rules].rules.each_key do |rule_name|
422
+ new_rule = REXML::Element.new(rule)
423
+ new_rule.add_attribute("name", rule_name)
424
+ new_rule.text = queryinfo[:rules].rules[rule_name]
425
+ rules.add_element(new_rule)
426
+ end
427
+
428
+ server.add_element(rules)
429
+ end
430
+ if queryinfo[:ping]
431
+ verbose("Outputting ping information")
432
+ ping = REXML::Element.new("ping")
433
+ ping.text = queryinfo[:ping]
434
+ server.add_element(ping)
435
+ end
436
+
437
+ output_xml(root)
438
+ when :csv
439
+ verbose("Outputting in CSV mode")
440
+
441
+ if queryinfo[:info]
442
+ [:type, :version, :server_name, :map, :gamedir, :game_description,
443
+ :appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
444
+ :secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
445
+ :mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type].each do |sym|
446
+ puts CSV.generate_line(["info", sym, queryinfo[:info].send(sym).to_s.chomp])
447
+ end
448
+ end
449
+ if queryinfo[:players]
450
+ queryinfo[:players].each do |player|
451
+ puts CSV.generate_line(["player", player.index, player.name, player.kills, player.time])
452
+ end
453
+ end
454
+ if queryinfo[:rules]
455
+ queryinfo[:rules].rules.each_key do |rule_name|
456
+ puts CSV.generate_line(["rule", rule_name, queryinfo[:rules].rules[rule_name]])
457
+ end
458
+ end
459
+ if queryinfo[:ping]
460
+ puts CSV.generate_line(["ping", queryinfo[:ping]])
461
+ end
462
+ end
463
+ end
464
+
465
+ #
466
+ # Automates server queries, for those types
467
+ # that actually use multiple queries.
468
+ #
469
+
470
+ def server_query(gq, type)
471
+ verbose("Initiating '#{type.to_s}' server query to #{$options.ip_address}:#{$options.port}")
472
+ case type
473
+ when :info
474
+ return gq.get_info
475
+ when :players
476
+ return gq.get_players
477
+ when :rules
478
+ return gq.get_rules
479
+ when :ping
480
+ t = (Time.now.to_f * 1000).ceil
481
+ if gq.ping
482
+ t2 = (Time.now.to_f * 1000).ceil
483
+ return t2 - t
484
+ end
485
+ return -1
486
+ end
487
+ end
488
+
489
+ ################################################################
490
+ #
491
+ # Start main code
492
+ #
493
+ ################################################################
494
+
495
+ $options = get_options
496
+
497
+ begin
498
+
499
+ case $options.query_type
500
+ when :master
501
+ region_map = {
502
+ :us_east => GameQuery::Master::Steam::REGION_US_EAST,
503
+ :us_west => GameQuery::Master::Steam::REGION_US_WEST,
504
+ :south_america => GameQuery::Master::Steam::REGION_SOUTH_AMERICA,
505
+ :europe => GameQuery::Master::Steam::REGION_EUROPE,
506
+ :asia => GameQuery::Master::Steam::REGION_ASIA,
507
+ :australia => GameQuery::Master::Steam::REGION_AUSTRALIA,
508
+ :middle_east => GameQuery::Master::Steam::REGION_MIDDLE_EAST,
509
+ :africa => GameQuery::Master::Steam::REGION_AFRICA,
510
+ :other => GameQuery::Master::Steam::REGION_OTHER
511
+ }
512
+
513
+ if $options.ip_address.nil?
514
+ $options.ip_address = GameQuery::Master::Steam::MASTER_SERVER
515
+
516
+ case $options.server_type
517
+ when :hlds
518
+ $options.port = GameQuery::Master::Steam::HLDS_MASTER_PORT
519
+ when :source
520
+ $options.port = GameQuery::Master::Steam::SOURCE_MASTER_PORT
521
+ end
522
+ end
523
+
524
+ verbose("Initiating #{$options.server_type} master query for region '#{$options.region}' to #{$options.ip_address}:#{$options.port}")
525
+
526
+ gq = GameQuery::Master::Steam.new($options.ip_address,
527
+ $options.port,
528
+ region_map[$options.region])
529
+ $options.filters.each do |filter|
530
+ verbose("Setting filter #{filter[0]} = #{filter[1]}")
531
+ gq.filter.send((filter[0].to_s + '=').to_sym, filter[1])
532
+ end
533
+ verbose("Outputting return data")
534
+ output_master_query(gq.send_query)
535
+ else
536
+ gq = GameQuery::Server::Steam.new($options.ip_address, $options.port)
537
+ case $options.query_type
538
+ when :full
539
+ info = server_query(gq, :info)
540
+ players = server_query(gq, :players)
541
+ rules = server_query(gq, :rules)
542
+ ping = server_query(gq, :ping)
543
+ output_server_query({
544
+ :info => info,
545
+ :players => players,
546
+ :rules => rules,
547
+ :ping => ping
548
+ })
549
+ else
550
+ output_server_query({ $options.query_type => server_query(gq, $options.query_type) })
551
+ end
552
+ end
553
+
554
+ rescue GameQuery::ConnectException => e
555
+ $stderr.puts "Error: A connection error occured (perhaps the host isn't available?)"
556
+ exit -1
557
+ rescue GameQuery::ParseException => e
558
+ $stderr.puts "Error: The tool had trouble processing the response from the server."
559
+ exit -1
560
+ rescue Exception => e
561
+ $stderr.puts "Error: An unknown error occured (#{e.class.to_s})"
562
+ verbose(e.backtrace.join("\n"))
563
+ exit -1
564
+ end
565
+
566
+ exit 0
data/lib/gamequery.rb ADDED
@@ -0,0 +1,775 @@
1
+ require 'socket'
2
+
3
+ #
4
+ # GameQuery - Query game servers
5
+ #
6
+ # Version:: 0.1.1
7
+ # Author:: Erik Hollensbe <erik@hollensbe.org>
8
+ # License:: BSD
9
+ # Contact:: erik@hollensbe.org
10
+ # Copyright:: Copyright (c) 2005-2006 Erik Hollensbe
11
+ #
12
+ # = Synopsis
13
+ #
14
+ # # returning a master server query
15
+ # gq = GameQuery::Master::Steam.new(
16
+ # GameQuery::Master::Steam::MASTER_SERVER,
17
+ # GameQuery::Master::Steam::SOURCE_MASTER_PORT,
18
+ # GameQuery::Master::Steam::REGION_OTHER)
19
+ # gq.send_query.each { |server| puts [server.ip, server.port].join(":") }
20
+ #
21
+ # # getting a query from a specific server
22
+ # gq = GameQuery::Server::Steam.new('127.0.0.1', 27015)
23
+ # gq.get_info.maxplayers => 20
24
+ #
25
+ # = Note
26
+ #
27
+ # While expansion is planned, current queries are limited
28
+ # to Valve Software's "Steam"-based games: Half-Life 1 (Goldsrc) and
29
+ # Half-Life 2 (Source) games in particular. Hunting down the little
30
+ # differences in several of the protocols not yet supported has
31
+ # produced troublesome results, especially when it comes to finding
32
+ # the equivalent of a 'master' server to query to find guinea pigs to
33
+ # test against. As a result, I will gladly accept patches that help
34
+ # with other game types.
35
+ #
36
+ # = Class Structure
37
+ #
38
+ # GameQuery has a similar class structure to my other game-related
39
+ # module, RCon. There are several important namespaces:
40
+ #
41
+ # == GameQuery::Master
42
+ #
43
+ # This contains classes that query master server databases. In game
44
+ # parlance, this means that this is a server you query to get the
45
+ # names of active, registered servers. The current classes supported
46
+ # are:
47
+ #
48
+ # * GameQuery::Master::Steam - Query Steam master servers
49
+ # * GameQuery::Master::Steam::Filter - Helper class to build
50
+ # Steam-based master query filters.
51
+ #
52
+ # == GameQuery::Server
53
+ #
54
+ # This namespace has classes to get data from individual servers.
55
+ #
56
+ # * GameQuery::Server::Steam - Query Steam servers
57
+ #
58
+ # == GameQuery::Response
59
+ #
60
+ # Contains classes to return well-formed responses, and in the right
61
+ # cases, provide operations on the response set.
62
+ #
63
+ # * GameQuery::Response::Master - Responses from master servers of any
64
+ # type.
65
+ # * GameQuery::Response::Steam - Several specific responses that
66
+ # related to querying steam-based servers.
67
+ # * GameQuery::Response::Steam::Info - Returns info about the server
68
+ # itself.
69
+ # * GameQuery::Response::Steam::Player - Returns info about the
70
+ # players that are on the server.
71
+ # * GameQuery::Response::Steam::Rules - Returns info about the rules
72
+ # that the server has set.
73
+ #
74
+ # == Exception Classes
75
+ #
76
+ # * GameQuery::ConnectException - Thrown when a connect was attempted
77
+ # but failed
78
+ # * GameQuery::ParseException - Thrown when data was improperly
79
+ # parsed.
80
+ #
81
+ # = Further Documentation
82
+ #
83
+ # Please see the individual RDoc for each listed class for more
84
+ # information on usage.
85
+ #
86
+
87
+ class GameQuery
88
+
89
+ #
90
+ # Thrown in the case of a connection attempt that resulted in an
91
+ # error. Please see individual methods to locate what throws this.
92
+ #
93
+
94
+ class ConnectException < Exception
95
+ end
96
+
97
+ #
98
+ # Thrown in the case of a connection attempt that resulted in an
99
+ # error. Please see individual methods to locate what throws this.
100
+ #
101
+
102
+ class ParseException < Exception
103
+ end
104
+
105
+ class Master #:nodoc:
106
+
107
+ #
108
+ # Used for master queries to get lists of steam-based servers
109
+ #
110
+ # See GameQuery::Master::Steam.new for more information.
111
+ #
112
+
113
+ class Steam
114
+
115
+ # master server IP addresses and ports
116
+
117
+ MASTER_SERVER = "207.173.177.11"
118
+ SOURCE_MASTER_PORT = 27011
119
+ HLDS_MASTER_PORT = 27010
120
+
121
+ # region codes
122
+
123
+ REGION_US_EAST = 0x00
124
+ REGION_US_WEST = 0x01
125
+ REGION_SOUTH_AMERICA = 0x02
126
+ REGION_EUROPE = 0x03
127
+ REGION_ASIA = 0x04
128
+ REGION_AUSTRALIA = 0x05
129
+ REGION_MIDDLE_EAST = 0x06
130
+ REGION_AFRICA = 0x07
131
+ REGION_OTHER = 0xFF
132
+
133
+ #
134
+ # Creates a new GameQuery::Master::Steam object
135
+ #
136
+ # * The region is a 8-bit integer, the accepted values are
137
+ # constants in this namespace.
138
+ # * Server is the name of a valid master server. The constant
139
+ # MASTER_SERVER contains the ip for a current working master
140
+ # server that serves both HLDS and Source master lists.
141
+ # * Port is the port to connect to. Note that different lists
142
+ # are provided on different ports. For this reason, the
143
+ # constants HLDS_MASTER_PORT and SOURCE_MASTER_PORT are provided
144
+ # to aid with connecting to the server listed in MASTER_SERVER.
145
+ #
146
+ # If you cannot connect to this server, you will need to provide
147
+ # your own server/port. The region is a part of the protocol,
148
+ # and most likely won't change.
149
+ #
150
+
151
+ attr_accessor :filter
152
+ attr_accessor :region
153
+
154
+ def initialize(server, port, region)
155
+ @server = server
156
+ @port = port
157
+ @filter = GameQuery::Master::Steam::Filter.new()
158
+ @region = region
159
+ # this is what steam sends....
160
+ @ip = "0.0.0.0:0"
161
+ self
162
+ end
163
+
164
+ #
165
+ # Sends the query and returns the output.
166
+ #
167
+ # Returns a GameQuery::Response::Master object on success.
168
+ #
169
+ # Throws a GameQuery::ConnectException if it can't read
170
+ # from the server.
171
+ #
172
+
173
+ def send_query
174
+ retval = []
175
+ packet = ""
176
+
177
+ # send the query
178
+ sock = UDPSocket.new
179
+ sock.connect(@server, @port)
180
+ sock.print [?1, @region, @ip, @filter.generate_filter_data].pack("CCZ*Z*")
181
+
182
+ raise GameQuery::ConnectException.new unless IO.select([sock], nil, nil, 10)
183
+
184
+ # get the return
185
+ loop do
186
+ tmp = sock.recv(8192)
187
+ packet << tmp
188
+ break if tmp.length < 8192
189
+ break unless IO.select([sock], nil, nil, 10)
190
+ end
191
+
192
+ # strip first 6 bytes
193
+ packet = packet[6..-1]
194
+
195
+ while packet.length > 0
196
+ tmp = packet.slice!(0..5)
197
+
198
+ # type switch: String -> Array
199
+
200
+ # NOTE: Valve uses network signed here for the port, which
201
+ # is uncharacteristic compared to other protocols
202
+ tmp = tmp.unpack("CCCCn")
203
+
204
+ port = tmp.pop.to_s
205
+ retval.push([tmp.join('.'), port])
206
+ end
207
+
208
+ return GameQuery::Response::Master.new(retval)
209
+ end
210
+
211
+ #
212
+ # Compose filters to send to the steam master servers.
213
+ #
214
+ # == Filter names and acceptable data
215
+ #
216
+ # Values that take 1/0 can also be set to true/false, the value
217
+ # being converted at set time.
218
+ #
219
+ # type:: this is either 'd' or 'l', for dedicated or listen servers.
220
+ # secure:: this is 1 or 0 depending on VAC support.
221
+ # gamedir:: this is the name of the 'game directory' or mod that you want to query specifically for.
222
+ # map:: name of map.
223
+ # linux:: set to 1 to only get linux servers back. 0 will return all servers.
224
+ #--
225
+ # TODO: really, empty/full should be fixed.
226
+ #++
227
+ # empty:: set to 1 to get servers that aren't empty.
228
+ # full:: set to 1 to get servers that aren't full.
229
+ # proxy:: set to 1 to get servers that are spectator proxies.
230
+ #
231
+ # == Usage
232
+ #
233
+ # All methods are accessor-style, meaning you can do this:
234
+ #
235
+ # f = GameQuery::Master::Steam::Filter.new
236
+ # f.empty = 1
237
+ # f.empty # returns '\empty\1'
238
+ #
239
+ # And so on.
240
+ #
241
+
242
+ class Filter
243
+
244
+ TYPES = [:type, :secure, :gamedir, :map, :linux, :empty, :full, :proxy]
245
+
246
+ TYPES.each do |type|
247
+ method_name = (type.to_s + '=').to_sym
248
+
249
+ GameQuery::Master::Steam::Filter.send(:define_method, type) do
250
+ x = nil
251
+ self.instance_eval("x = @{#type.to_s}")
252
+ x
253
+ end
254
+ GameQuery::Master::Steam::Filter.send(:define_method, method_name) do |s|
255
+ # coerce true/false to numeric equivalents
256
+ if s == true or s == false
257
+ s = s ? 1 : 0
258
+ end
259
+
260
+ s = s.to_s
261
+
262
+ if s.nil? or s.length == 0
263
+ self.instance_variable_set(("@"+type_to_s).to_sym, nil)
264
+ else
265
+ self.instance_variable_set(("@"+type.to_s).to_sym, "\\#{type.to_s}\\#{s}")
266
+ end
267
+ end
268
+ end
269
+
270
+ #
271
+ # This returns the string containing the filter definitions
272
+ # formatted for use in a Steam master query.
273
+ #
274
+ # It does not include items that are set to nil.
275
+ #
276
+
277
+ def generate_filter_data
278
+ return [@type, @secure, @gamedir,
279
+ @map, @linux, @empty,
280
+ @full, @proxy].reject { |x| x.nil? }.join("")
281
+ end
282
+
283
+ end # Filter
284
+ end # Steam
285
+ end # Master
286
+
287
+ class Server #:nodoc:
288
+
289
+ #
290
+ # Query Servers that answer to the Steam network protocols.
291
+ #
292
+ # Those familiar with the protocol know that a challenge must be
293
+ # sent: this suite does it, but the challenge protocol is not
294
+ # exposed (for a variety of reasons). The challenge value is
295
+ # treated ephemeral and is generated for each query that requires
296
+ # it.
297
+ #
298
+ # The +get_info+ routine does not require a challenge.
299
+ #
300
+ # All public methods in this class will return a
301
+ # GameQuery::ConnectException on connection failure or error.
302
+ #
303
+ #--
304
+ # valve's best docs on this are at:
305
+ # http://www.valve-erc.com/srcsdk/Code/Networking/serverqueries.html#A2S_INFO
306
+ #++
307
+
308
+ class Steam
309
+
310
+ #
311
+ # Instantiate a new GameQuery::Server::Steam object
312
+ #
313
+ # Takes a server and a port.
314
+ #
315
+
316
+ def initialize(server, port)
317
+ @server = server
318
+ @port = port
319
+ @sock = nil
320
+ end
321
+
322
+ #
323
+ # Sends a query to get all the information from a server
324
+ # regarding the players that are on it.
325
+ #
326
+ # Returns a GameQuery::Response::Steam::Player object.
327
+ #
328
+
329
+ def get_players
330
+ challenge = get_challenge
331
+
332
+ @sock.print [0xFFFFFFFF, 0x55, challenge].pack("VCV")
333
+ packet = read_socket
334
+
335
+ # response header
336
+
337
+ response_code, num_players = packet.slice!(0..1).unpack("aC")
338
+
339
+ players = []
340
+
341
+ while packet.length > 0
342
+ tmp = packet.unpack("CZ*")
343
+
344
+ len = tmp[1].length
345
+ packet.slice!(0..len+1)
346
+
347
+ # we have to do this to ensure that size is proper when decoding little-endian floats. (arg)
348
+ # this may not work at all.
349
+ tmp.push(packet.slice!(0..7).unpack("Ve"))
350
+
351
+ # BUG: the kills amount (tmp[2]) has an odd anomaly that I haven't tracked down yet.
352
+ # however, when this bug occurs, the amount filled in the kills portion is 4294967295
353
+ # I imagine it's a packing issue.
354
+
355
+ tmp.flatten!
356
+ players.push(tmp)
357
+ end
358
+
359
+ return GameQuery::Response::Steam::Player.new(num_players, players)
360
+ end
361
+
362
+ #
363
+ # Returns the rules for the server as a
364
+ # GameQuery::Response::Steam::Rules object.
365
+ #
366
+
367
+ def get_rules
368
+ challenge = get_challenge
369
+
370
+ @sock.print [0xFFFFFFFF, 0x56, challenge].pack("VCV")
371
+ packet = read_socket
372
+ require 'pp'
373
+
374
+ # HL1 responses are different here, as well.
375
+ # there is some garbage (an extra 9 bytes) that is sent along with
376
+ # HL1 responses. Since the response code is supposed to be the same,
377
+ # we can rely on that to find out if we need to remove extra bytes.
378
+
379
+ response_code, num_rules = packet.slice!(0..2).unpack("av")
380
+
381
+ if response_code != 'E'
382
+ # if we're here, HL1 will send additional data every packet.
383
+ # to the best of my knowledge, this is junk.
384
+ # HACK: THIS IS A HORRIBLE MESS
385
+ packet.sub!(/\376\377\377\377.{5}/, "")
386
+ # pull the 6 bytes off the front.
387
+ packet.slice!(0..5)
388
+ # do it again
389
+ response_code, num_rules = packet.slice!(0..2).unpack("av")
390
+ end
391
+
392
+ rules = []
393
+
394
+ while packet.length > 0
395
+ tmp = packet.unpack("Z*Z*")
396
+ # length of each, + 1 for the nulls
397
+ packet.slice!(0..(tmp[0].length + tmp[1].length + 1))
398
+ rules.push(tmp)
399
+ end
400
+
401
+ return GameQuery::Response::Steam::Rules.new(num_rules, rules)
402
+ end
403
+
404
+ #
405
+ # Gets information about the server itself. Returns a
406
+ # GameQuery::Response::Steam::Info object.
407
+ #
408
+ # Note that while the query sent is the same for both HL2 and
409
+ # HL1, the responses are quite different, and while much of the
410
+ # data overlaps, not all of it does. HL1 and HL2 responses will
411
+ # contains different sets of data as a result. The remaining
412
+ # fields will be set to 'nil'.
413
+ #
414
+
415
+ def get_info
416
+ # NOTE: no challenge is needed here
417
+ establish_connection
418
+
419
+ @sock.print [0xFFFFFFFF, ?T, "Source Engine Query"].pack("VCZ*")
420
+ packet = read_socket
421
+ #
422
+ # HL1 and HL2 take the same query, but they provide separate (?!?!) responses
423
+ # we'll have to parse out the type first, so we know how to unpack the rest of
424
+ # the output.
425
+ #
426
+ type = packet.unpack("a")[0]
427
+
428
+ ary = nil
429
+ if type == 'I'
430
+ # HL2
431
+ return GameQuery::Response::Steam::Info.new(packet.unpack("CCZ*Z*Z*Z*vCCCaaCCZ*"),
432
+ GameQuery::Response::Steam::Info::SOURCE)
433
+ elsif type == 'm'
434
+ # HL1
435
+ return GameQuery::Response::Steam::Info.new(packet.unpack("CZ*Z*Z*Z*Z*CCCaaCCZ*Z*Z*VVCCCC"),
436
+ GameQuery::Response::Steam::Info::HLDS)
437
+ end
438
+
439
+ # we don't know what it is, annoy our user with an error!
440
+ raise GameQuery::ParseException.new
441
+
442
+ end
443
+
444
+ #
445
+ # Pings the server. Merely returns a true/false depending on if
446
+ # the server responded.
447
+ #
448
+ # While HL1 still supports the legacy ping protocol, HL2 doesn't
449
+ # (and doesn't appear to have a replacement
450
+ # implementation). What we do here is send a challenge and
451
+ # return true/false depending on if we got a response that
452
+ # makes sense.
453
+ #
454
+ # Recording response time is the user's responsibility.
455
+ #
456
+
457
+ def ping
458
+ return ! get_challenge.nil?
459
+ end
460
+
461
+ ################################################################
462
+ #
463
+ # BEGIN PRIVATE METHODS
464
+ #
465
+ ################################################################
466
+
467
+ private
468
+
469
+ #
470
+ # Obtain a challenge value
471
+ #
472
+
473
+ def get_challenge
474
+ establish_connection
475
+
476
+ @sock.print [0xFFFFFFFF, 0x57].pack("VC")
477
+ return read_socket.unpack("aV")[1]
478
+ end
479
+
480
+ #
481
+ # Create a socket if it's needed
482
+ #
483
+
484
+ def establish_connection
485
+ if @sock.nil?
486
+ @sock = UDPSocket.new
487
+ @sock.connect(@server, @port)
488
+ end
489
+ end
490
+
491
+
492
+ #
493
+ # Read from the socket and return a response. Throws a
494
+ # GameQuery::ConnectException on IO.select troubles.
495
+ #
496
+
497
+ def read_socket
498
+ packet = ""
499
+
500
+ raise GameQuery::ConnectException.new unless IO.select([@sock], nil, nil, 10)
501
+
502
+ # get the return
503
+ loop do
504
+ tmp = @sock.recv(8192)
505
+ packet << tmp
506
+ break unless IO.select([@sock], nil, nil, 0.1)
507
+ end
508
+
509
+ # this is always the same value, and we never need it.
510
+ # NOTE: see additional work in get_rules and get_info for HL1
511
+ packet.slice!(0..3)
512
+
513
+ return packet
514
+ end
515
+ end
516
+ end # Server
517
+
518
+ class Response #:nodoc:
519
+
520
+ #
521
+ # Master response class.
522
+ #
523
+ # All queries which get information from something that resembles
524
+ # a 'master' server, or a server that provides information
525
+ # regarding other servers, should return an object of this type.
526
+ #
527
+ # This module mixes in the +Enumerable+ module, all methods
528
+ # contained in there should be available to it.
529
+ #
530
+ # It is not recommended that you construct these outside the query
531
+ # classes - they are merely used to provide a friendly response to
532
+ # queries.
533
+ #
534
+
535
+ class Master
536
+ include Enumerable
537
+
538
+ GameServer = Struct.new(:ip, :port)
539
+
540
+ #
541
+ # Constructor. Do not use this unless you know what you are doing.
542
+ #
543
+
544
+ def initialize(array)
545
+ @servers = []
546
+
547
+ array.each do |server|
548
+ @servers.push(GameServer.new(*server))
549
+ end
550
+ end
551
+
552
+ #
553
+ # This will return GameQuery::Response::Master::GameServer
554
+ # structures each time it is called, iterating through the list
555
+ # of them.
556
+ #
557
+ # It's primary function is to aid the +Enumerable+ module.
558
+ #
559
+
560
+ def each
561
+ @servers.each { |server| yield server }
562
+ end
563
+ end
564
+
565
+ class Steam #:nodoc:
566
+
567
+ #
568
+ # Source Info class.
569
+ #
570
+ # Lots of other protocol information is included in here that
571
+ # most people will have no use for. I have only covered what I
572
+ # have deemed "important", however, most of the parts are named
573
+ # very similarly to the protocol documentation on the web, so if
574
+ # you're curious what a specific part means, please consult that.
575
+ #
576
+ # = Attributes
577
+ #
578
+ # Attributes are coerced into boolean values or native types
579
+ # (such as integers) whenever possible.
580
+ #
581
+ # == server_name
582
+ #
583
+ # The published name of the server, normally controlled by the
584
+ # 'hostname' cvar.
585
+ #
586
+ # == map
587
+ #
588
+ # The name of the map being played.
589
+ #
590
+ # == gamedir
591
+ #
592
+ # The name of the game directory loaded. This is normally the
593
+ # 'short name' of the modification.
594
+ #
595
+ # == game_description
596
+ #
597
+ # The name of the game (or modification) being played. This is
598
+ # normally the 'long name' of the modification.
599
+ #
600
+ # == numplayers
601
+ #
602
+ # Number of people currently playing on the server.
603
+ #
604
+ # == maxplayers
605
+ #
606
+ # Maximum number of people allowed on the server.
607
+ #
608
+ # == numbots
609
+ #
610
+ # Number of bots on the server.
611
+ #
612
+ # == dedicated
613
+ #
614
+ # This is set to 'true' if this server is configured to run as a
615
+ # 'dedicated' (no client running) server.
616
+ #
617
+ # == os
618
+ #
619
+ # The Operating System the server runs on. 'Windows' or 'Linux'.
620
+ #
621
+ # == password
622
+ #
623
+ # Set to 'true' if a password is required.
624
+ #
625
+ # == secure
626
+ #
627
+ # Set to 'true' if running some form of Valve Anti-Cheat.
628
+ #
629
+ # == game_version
630
+ #
631
+ # Version of the server that is running.
632
+ #
633
+ # == host
634
+ #
635
+ # IP:port information on the server. This is HL1-only.
636
+ #
637
+ # == modded
638
+ #
639
+ # 'true' if the server is running a modification. HL1-only.
640
+ #
641
+ # == mod_info, mod_download_ftp, mod_version, mod_size, mod_server_side, mod_custom_client
642
+ #
643
+ # Various server variables which are HL1-only and will only
644
+ # exist if 'modded' is true.
645
+ #
646
+
647
+ class Info
648
+ SOURCE = 0
649
+ HLDS = 1
650
+
651
+ attr_reader(:type, :version, :server_name, :map, :gamedir, :game_description,
652
+ :appid, :numplayers, :maxplayers, :numbots, :dedicated, :os, :password,
653
+ :secure, :game_version, :host, :modded, :mod_info, :mod_download_ftp,
654
+ :mod_version, :mod_size, :mod_server_side, :mod_custom_client, :response_type)
655
+
656
+ def initialize(array, response_type=SOURCE)
657
+ case response_type
658
+ when SOURCE
659
+ @response_type = :source
660
+ (@type, @version, @server_name, @map, @gamedir, @game_description,
661
+ @appid, @numplayers, @maxplayers, @numbots, @dedicated, @os, @password,
662
+ @secure, @game_version) = array
663
+ when HLDS
664
+ @response_type = :hlds
665
+ (@type, @host, @server_name, @map, @gamedir, @game_description, @numplayers,
666
+ @maxplayers, @version, @dedicated, @os, @password, @modded, @mod_info,
667
+ @mod_download_ftp, dummy, @mod_version, @mod_size, @mod_server_side,
668
+ @mod_custom_client, @secure, @numbots) = array
669
+ end
670
+
671
+ # booleans to be coerced
672
+ @dedicated = @dedicated == 'd'
673
+ @password = @password == 1
674
+ @secure = @secure == 1
675
+ @modded = @modded == 1
676
+ @mod_server_side = @mod_server_side == 1
677
+ @mod_custom_client = @mod_custom_client == 1
678
+
679
+ # OS is Windows or Linux. Coerce.
680
+
681
+ @os = @os == 'w' ? 'Windows' : 'Linux'
682
+ end
683
+ end
684
+
685
+ #
686
+ # Source Player Info class
687
+ #
688
+ # Includes the +Enumerable+ module and all of it's
689
+ # functions. Allows iterating over a list of player
690
+ # information. See
691
+ # GameQuery::Response::Steam::Player::PlayerInfo for the
692
+ # structure of each player portion.
693
+ #
694
+ # Also, the attribute +num_players+ will return the total number
695
+ # of players currently on the server. This is also available via
696
+ # an 'info' query, if you just need that information.
697
+ #
698
+
699
+ class Player
700
+
701
+ include Enumerable
702
+
703
+ #
704
+ # Player Information struct.
705
+ #
706
+ # +index+ is the number of player they are on the server. This
707
+ # number remains with them until they disconnect.
708
+ #
709
+ # +time+ is the time they have been on the server, in floating
710
+ # point form, in _seconds_.
711
+ #
712
+ # Also note that in HL2-based (Source) games, names can be in
713
+ # UTF-8. I highly recommend using the 'iconv' module to sort
714
+ # out how you want to best represent that information.
715
+ #
716
+
717
+ PlayerInfo = Struct.new(:index, :name, :kills, :time)
718
+
719
+ attr_reader :num_players
720
+
721
+ def initialize(num_players, array)
722
+ @players = []
723
+ @num_players = num_players
724
+
725
+ array.each do |player|
726
+ pi = PlayerInfo.new(*player)
727
+ @players.push(pi)
728
+ end
729
+ end
730
+
731
+ def each
732
+ @players.each { |player| yield player }
733
+ end
734
+ end
735
+
736
+
737
+ #
738
+ # Source Rules Info Class
739
+ #
740
+ # +num_rules+ contains an integer value with the number of rules
741
+ # in the +rules+ hash
742
+ #
743
+ # +rules+ is a hash which contains the rule name as a key and
744
+ # the value as... the value. Every effort has been made to
745
+ # convert to proper types when possible.
746
+ #
747
+
748
+ class Rules
749
+ attr_reader :num_rules
750
+ attr_reader :rules
751
+
752
+ def initialize(num_rules, array)
753
+ @num_rules = num_rules
754
+ @rules = Hash.new
755
+
756
+ array.each do |rule|
757
+ # cheap integer conversion
758
+ tmp = rule[1].to_i
759
+ if tmp.to_s == rule[1]
760
+ rule[1] = tmp
761
+ else
762
+ # cheap float conversion
763
+ tmp = rule[1].to_f
764
+ if tmp.to_s == rule[1]
765
+ rule[1] = tmp
766
+ end
767
+ end
768
+
769
+ @rules[rule[0]] = rule[1]
770
+ end
771
+ end
772
+ end # Rules
773
+ end # Server
774
+ end # Response
775
+ end # GameQuery
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ rubygems_version: 0.8.11
3
+ specification_version: 1
4
+ name: gamequery
5
+ version: !ruby/object:Gem::Version
6
+ version: 0.1.1
7
+ date: 2006-02-28 00:00:00 -08:00
8
+ summary: Ruby class to work with Half-Life and Source Engine "user" queries
9
+ require_paths:
10
+ - lib
11
+ email: erik@hollensbe.org
12
+ homepage:
13
+ rubyforge_project: gamequery
14
+ description:
15
+ autorequire: gamequery
16
+ default_executable:
17
+ bindir: bin
18
+ has_rdoc: true
19
+ required_ruby_version: !ruby/object:Gem::Version::Requirement
20
+ requirements:
21
+ - - ">"
22
+ - !ruby/object:Gem::Version
23
+ version: 0.0.0
24
+ version:
25
+ platform: ruby
26
+ signing_key:
27
+ cert_chain:
28
+ authors:
29
+ - Erik Hollensbe
30
+ files:
31
+ - lib/gamequery.rb
32
+ - bin/gamequerytool
33
+ test_files: []
34
+
35
+ rdoc_options: []
36
+
37
+ extra_rdoc_files: []
38
+
39
+ executables:
40
+ - gamequerytool
41
+ extensions: []
42
+
43
+ requirements: []
44
+
45
+ dependencies:
46
+ - !ruby/object:Gem::Dependency
47
+ name: ip
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Version::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.1
54
+ version: