gamequery 0.1.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 (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: