#!/usr/bin/env ruby

# customise these if required (currently checks ports 27900-27999) - note that
# grep uses '.' as a wildcard that matchs exactly one character

# used when called with the -p option (display pids) - requires admin perms

	$lsof_string = 'lsof -i udp -l | grep crx-ded | grep 279..'

# used when called without the -p option (no pids) - can be run by any user

	$netstat_string = 'netstat -u -l -n | grep 279..'

=begin

    ALIEN ARENA SERVER BROWSER (for server admins) V1.1
    Copyright (C) 2007 Tony Jackson

    This library is free software; you can redistribute it and/or
    modify it under the terms of the GNU Lesser General Public
    License as published by the Free Software Foundation; either
    version 2.1 of the License, or (at your option) any later version.

    This library is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    Lesser General Public License for more details.

    You should have received a copy of the GNU Lesser General Public
    License along with this library; if not, write to the Free Software
    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA

    Tony Jackson can be contacted at tonyj@cooldark.com
	
=end

require 'socket'
require 'rbconfig.rb'
include Config

$debug = false   # set to true/false to enable/disable debug output
$offline = false # set to true/false to enable/disable offline debug mode (extra data files required)

=begin
	The ServerLink class allows simple access to Alien Arena master/games servers, without
	having to worry about the details of the UDP protocols involved
	
	Before any other calls, set_protocol(<type>) should be called with either 'master' or 'game'.
	
	query(<'ip:port' string array>) then queries each server in the array, returning an
	array containing a list of servers that replied.  query() is blocking, so may take a
	little while depending on how many servers are being queried.
	
	Once a query is complete, information can be read about each server individually,
	using one of:
	
	get_master_list('ip:port')  - in the case of having queried one or more master servers
		=> returns array of 'server:ip' strings for each game server registered
	get_server_info('ip:port')  - in the case of having queried one or more games servers
		=> Returns hash of server parameters
	get_player_info('ip:port')  - in the case of having queried one or more games servers
		=> Returns array of hashs (each hash containing one players worth of information)
		
	On the next query() call, any old server/player/master data is cleared and replaced
=end

class ServerLink
	def initialize
		set_protocol('master') # default to master mode (rather than leave uninitialised)
		# default - try three times for each server
		#@tries = 3 # TODO: currently not used
		@buffers = Hash.new # to store the reply UDP packet payload from each server
		@pings = Hash.new   # to store array of ping times for each server
	end
	
	# set type of link - master or game
	def set_protocol (type = 'master')
		if type == 'master'
			@protocol = 'master'
		elsif type == 'game'
			@protocol = 'game'
		end
	end
	
	# query all servers in array (can be multiple masters, as well as games servers)
	def query(servers)
		if servers == nil
			return []
		end

		# in offline mode, return data from a set of files rather than by querying live servers
		if $offline
			return offline_query(servers)
		end

		# select the query string to put in the outboand UDP packet
		if @protocol == 'master'
			query = "query"
		elsif @protocol == 'game'
			query = "����status\n"
		else
			return []
		end
		
		connections = Hash.new
		failed = Array.new
		@buffers.clear
		@pings.clear
		
		# Get the time before sending any packets, to time server response time (ping)
		starttime = Time.now
		
		# send a UDP packet to each server in the array
		servers.each do
			| server |  # Here server is of form 'ip:port' string
			begin
				socket = UDPSocket.new  # open works in the same way
				socket.connect(server.split(':')[0], server.split(':')[1])
				socket.send(query, 0)
				#socket.send(nil, 0)  # Test failure case
				connections[socket] = server # hash keyed on socket resource, containing server string
			rescue
				# some failure making a socket or sending a message - add to list of failed servers
				#socket.close - can't use this - may not even be open
				failed << server
				next
			end
		end
		
		# remove servers from list if socket failed to create/send UDP packet
		servers -= failed
		
		# check that we have at least one open UDP socket
		if connections.length == 0
			return servers
		end
		
		selectsocketlist = connections.keys  # get list of sockets from hash
		while result = select(selectsocketlist, nil, nil, 0.5) # select() waits for one or more socket to get a read event, or times out
			ping = (Time.now - starttime)*1000
			# store the time at which this server (or multiple servers) responded, and store the replies in our array of buffers
			result[0].each do
				|socket|
				begin
					# here connections[socket] gives us the 'ip:port' string of the associated server
					@buffers[connections[socket]] = socket.recv(2048) # big enough to cover both games server replies and master reply with 256 servers (payload 12+6*256 bytes)
					@pings[connections[socket]] = ping
					selectsocketlist.delete(socket) # delete from array  now that we have a reply
					socket.close
				rescue
					next
				end
				
				# test code to dump UDP payload contents to file for offline debug mode (make sure debug/ directory exists first)
				# file = open("debug/#{connections[socket].split(':')[0]}-#{connections[socket].split(':')[1]}.dmp", 'wb')
				# file.write(@buffers[connections[socket]])
				# file.close
			end
		end
		
		# here selectsocketlist will contain an array of failed sockets
		selectsocketlist.each do
			|socket|
			servers -= [connections[socket]]
			socket.close
		end
		
		# return array of servers that responded to our queries
		return servers
	end
	
	# offline mode query (for debug), that uses local files instead of querying live servers
	def offline_query(servers)
		replied = Array.new
		if $offline != true
			return
		end

		servers.each do
			|server|
			begin
				filename = server.split(':')[0]+'-'+server.split(':')[1]+'.dmp'
				fakeserver = File.open("debug/#{filename}", 'rb')
				@buffers[server] = fakeserver.read()
				fakeserver.close
				@pings[server] = 100
				replied << server
			rescue
			end
		end
		return replied
	end
	
	# parses UDP payload received from a master server and returns array of 'ip:port' strings
	def get_master_list server
		if @protocol != 'master'
			return []
		end
		####################################
		#	Buffer is 0xFFFF long in game (65536)
		#	Data is this format:
		#	Ignore first 12 bytes '����servers '
		#	Four byte address
		#	Two byte port
		#	Four byte address
		#	Two byte port
		#	....
		#	Up to 256 servers (hard coded limit in game) 
		####################################
		
		servers = Array.new
		buff = @buffers[server]
		if buff == nil
			return servers
		end
		buff = buff[12..-1] # Chop off first 12 chars
		0.step(buff.length-6, 6) do
			|i|
			ip,port = buff.unpack("@#{i}Nn")  #  @#{i} denotes offset 'i' into buffer
			servers << inet_ntoa(ip).to_s + ':' + port.to_s
		end
		return servers # This may be an empty array
	end
	
	# return hash of server parameters from UDP packet data
	def get_server_info server
		if @protocol != 'game'
			return []
		end
		
		#example server strings
		#buffer = "����print\n\\mapname\\ctf-killbox\\needpass\\0\\gamedate\\Jan 31 2007\\gamename\\data1\\maxspectators\\4\\Admin\\Forsaken\\website\\http://www.alienarena.info\\sv_joustmode\\0\\maxclients\\16\\protocol\\34\\cheats\\0\\timelimit\\0\\fraglimit\\10\\dmflags\\2641944\\deathmatch\\1\\version\\6.03 x86 Jan  7 2007 Win32 RELEASE\\hostname\\Alienarena.info - CTF\\gamedir\\arena\\game\\arena\n"
		#buffer = "����print\n\\mapname\\DM-OMEGA\\needpass\\0\\maxspectators\\4\\gamedate\\Jan  9 2007\\gamename\\data1\\sv_joustmode\\0\\maxclients\\8\\protocol\\34\\cheats\\0\\timelimit\\0\\fraglimit\\10\\dmflags\\16\\version\\6.03 x86 Jan  7 2007 Win32 RELEASE\\hostname\\pufdogs hell\\gamedir\\arena\\game\\arena\n3 17 \"test chap\" \"loopback\"\n0 0 \"Cyborg\" \"127.0.0.1\"\n3 0 \"Squirtney\" \"127.0.0.1\"\n0 0 \"Butthead\" \"127.0.0.1\"\n"
		buffer = @buffers[server]
		buffer = buffer.split("\n")
		serverinfo = buffer[1].split('\\')[1..-1]
		if serverinfo.length % 2 == 0  # check even number of keys
			serverinfo = Hash[*serverinfo]
		else
			serverinfo = Hash.new # empty hash
		end
		
		serverinfo['numplayers'] = buffer[2..-1].length
		serverinfo['ping'] = @pings[server]

		return serverinfo
	end
	
	# returns array of hashs about each player on a server
	def get_player_info server
		if @protocol != 'game'
			return []
		end
		
		#example server strings
		#buffer = "����print\n\\mapname\\ctf-killbox\\needpass\\0\\gamedate\\Jan 31 2007\\gamename\\data1\\maxspectators\\4\\Admin\\Forsaken\\website\\http://www.alienarena.info\\sv_joustmode\\0\\maxclients\\16\\protocol\\34\\cheats\\0\\timelimit\\0\\fraglimit\\10\\dmflags\\2641944\\deathmatch\\1\\version\\6.03 x86 Jan  7 2007 Win32 RELEASE\\hostname\\Alienarena.info - CTF\\gamedir\\arena\\game\\arena\n"
		#buffer = "����print\n\\mapname\\DM-OMEGA\\needpass\\0\\maxspectators\\4\\gamedate\\Jan  9 2007\\gamename\\data1\\sv_joustmode\\0\\maxclients\\8\\protocol\\34\\cheats\\0\\timelimit\\0\\fraglimit\\10\\dmflags\\16\\version\\6.03 x86 Jan  7 2007 Win32 RELEASE\\hostname\\pufdogs hell\\gamedir\\arena\\game\\arena\n3 17 \"test chap\" \"loopback\"\n0 0 \"Cyborg\" \"127.0.0.1\"\n3 0 \"Squirtney\" \"127.0.0.1\"\n0 0 \"Butthead\" \"127.0.0.1\"\n"
		buffer = @buffers[server]
		buffer = buffer.split("\n")
		playerbuff = buffer[2..-1]
		
		playerinfo = Array.new # array of hashs
		playerbuff.each do
			| line |
			player = Hash.new
			# each line is of form   3 17 "test chap" "12.34.56.78"  (note spaces in names)
			space_delimited = line.split(' ')
			quote_delimited = line.split('"')
			player['score'] = space_delimited[0]
			player['ping'] = space_delimited[1]
			player['name'] = quote_delimited[1]
			if quote_delimited[3] != nil
				player['ip'] = quote_delimited[3]
			end
			playerinfo << player
		end
		
		return playerinfo
	end
		
	def inet_aton ip
		ip.split(/\./).map{|c| c.to_i}.pack("C*").unpack("N").first
	end
	
	def inet_ntoa n
		[n].pack("N").unpack("C*").join "."
	end
	
	# get raw UDP payload response from a particular server (debug only)
	def get_response(server)
		if @buffers.include?(server)
			return @buffers[server]
		else
			return nil
		end
	end
	
end

=begin
	This is the application class responsible for handling the GUI
	and launching games/URLS.  It uses the ServerLink class to query
	servers and get meaningful responses.
=end

# Program starts here

if $*.include?('--help') then
	puts 'Server Status for Alien Arena servers'
	puts ''
	puts '  Optional arguments:'
	puts '  -r      Resolve player IPs to addresses'
	puts '  -p      Display PIDs (requires admin perms)'
	puts '  --help  This message'
	puts ''
	puts '  Edit this file (svstat) if you use ports in a range other than 27900-27999'
	puts ''
	exit
end

localports = Array.new
localpids = Array.new
if $*.include?('-p') then

	lsof = IO.popen($lsof_string,'r')

	lsof.each { |line|
		split = line.split(' ')
		localports << split.last.split(':').last
		localpids << split[1]
	}
	lsof.close
else
	ns = IO.popen($netstat_string,'r')

	ns.each { |line|
		split = line.split(' ')
		localports << split[3].split(':').last
		localpids << '---'
	}
	ns.close
end

# Use this code to get list of hostnames configured in arena/*.cfg files
#hostnames = Dir['alienarena2007/arena/*'].collect{ |file|
#		if file.split('.').last == 'cfg'
#			File.open(file).find{
#				|line|
#				split = line.split(' ')
#				split[0] == 'set' and split[1] == 'hostname'
#			}
#		end
#	}.compact.collect{ |line| line.split('"')[1]}.compact

@serverlink = ServerLink.new
@serverlink.set_protocol('game')
servers = localports.collect{|port| 'localhost:'+port}
responsive_servers = @serverlink.query(servers)
#title = "#{responsive_servers.length}/#{servers.length} local servers responded"
#puts title
puts '-'*80
puts 'PID      Port   Name                                   Map              Pl Bot'
puts '-'*80
responsive_servers.each {
	|server|
	serverinfo = @serverlink.get_server_info(server)
	playerinfo = @serverlink.get_player_info(server)
	port = server.split(':').last
	players = 0
	bots = 0
	playerinfo.each { |player|
# can't use IP address to determin bots since 6.10 security fix - they're all 127.0.0.1
#		if player['ip'] != '127.0.0.1' then
#			players += 1
#		else
#			bots += 1
#		end

# have to use ping, which is less reliable
		if player['ping'].to_i > 0 then
			players += 1
		else
			bots += 1
		end

	}

	puts "#{localpids[localports.index(port)].concat(' '*8).slice(0,8)} #{port.concat(' '*6).slice(0,6)} #{serverinfo['hostname'].concat(' '*38).slice(0,38)} #{serverinfo['mapname'].concat(' '*16).slice(0,16)} #{players.to_s.concat(' '*2).slice(0,2)} #{bots}"
#	playerinfo.each { |player|
#		puts "    #{player['name'].concat(' '*16).slice(0,16)} ping #{player['ping'].to_str.concat(' '*8).slice(0,8)} score #{player['score'].to_str.concat(' '*8).slice(0,8)} #{player['ip']}"
#	}
}
puts '-'*80
puts 'Player name     Ping   Score IP                    Server name'
puts '-'*80
responsive_servers.each {
	|server|
	serverinfo = @serverlink.get_server_info(server)
	playerinfo = @serverlink.get_player_info(server)
	port = server.split(':').last
	players = 0
	bots = 0
	playerinfo.each { |player|
		if player['ping'].to_i > 0 then
			puts "#{player['name'].concat(' '*15).slice(0,15)} #{player['ping'].to_str.concat('ms').concat(' '*6).slice(0,6)} #{player['score'].to_str.concat(' '*5).slice(0,5)} #{player['ip'].concat(' '*21).slice(0,21)} #{serverinfo['hostname'].concat(' '*29).slice(0,29)}"
			if $*.include?('-r') then
				playerip = player['ip'].split(':')[0]
				playerport = player['ip'].split(':')[1]
				nslookup = IO.popen("nslookup #{playerip} | grep \"name =\"",'r')
				result = nslookup.gets
				if(result != nil) then
					playerip = result.split('name = ')[1].chomp[0..-2]
				else
					playerip = 'unresolvable'
				end
				nslookup.close
				puts "                            (#{playerip})"
#				puts "#{player['name'].concat(' '*14).slice(0,14)} #{player['ping'].to_str.concat('ms').concat(' '*6).slice(0,6)} #{player['score'].to_str.concat(' '*5).slice(0,5)} #{playerip.concat(':').concat(playerport).concat(' '*52).slice(0,52)}"
			end
		end
	}
}
	
#puts $*

