Merge pull request #18 from cblanc/cleanup_server

Attempt to remove RCON and clean up legacy code from Server Model
This commit is contained in:
simplefl 2015-05-14 19:28:26 +02:00
commit fcf8f2111d
25 changed files with 296 additions and 2535 deletions

View file

@ -11,7 +11,6 @@ gem 'puma', '~> 2.11.1'
gem 'exceptional', '~> 2.0.33'
gem 'oj', '~> 2.5.5'
gem 'faraday', '~> 0.9.0'
gem 'gruff', '~> 0.3.6'
gem 'nokogiri', '~> 1.6.1'
gem 'bbcoder', '~> 1.0.1'
gem 'sanitize', '~> 2.1.0'

View file

@ -119,7 +119,6 @@ GEM
ffi (1.9.3)
font-awesome-sass (4.1.0)
sass (~> 3.2)
gruff (0.3.7)
haml (4.0.5)
tilt
hike (1.2.3)
@ -284,7 +283,6 @@ DEPENDENCIES
factory_girl_rails (~> 4.4.1)
faraday (~> 0.9.0)
font-awesome-sass (~> 4.1.0.0)
gruff (~> 0.3.6)
haml (~> 4.0.5)
jquery-rails (~> 2.0.2)
mysql2 (~> 0.3.15)

View file

@ -1,15 +1,9 @@
class ServersController < ApplicationController
before_filter :get_server, except: [:index, :refresh, :new, :create]
def refresh
Server.refresh
render :text => t(:servers_updated)
end
def index
@servers = Server.hlds.active.ordered.all :include => :user
@ns2 = Server.ns2.active.ordered.all :include => :user
@hltvs = Server.hltvs.active.ordered.all :include => :user
@officials = Server.ns2.active.ordered.where ["name LIKE ?", "%NSL%"]
end
@ -25,15 +19,6 @@ class ServersController < ApplicationController
raise AccessError unless @server.can_update? cuser
end
def admin
@result = @server.execute params[:query] if params[:query]
raise AccessError unless @server.can_update? cuser
if request.xhr?
render partial: 'response', layout: false
end
end
def create
@server = Server.new params[:server]
@server.user = cuser

View file

@ -6,12 +6,12 @@
# title :string(255)
# status :integer not null
# category_id :integer
# text :text
# text :text(16777215)
# user_id :integer
# created_at :datetime
# updated_at :datetime
# version :integer
# text_parsed :text
# text_parsed :text(16777215)
# text_coding :integer default(0), not null
#

View file

@ -9,6 +9,7 @@
# created_at :datetime
# updated_at :datetime
# votes :integer default(0), not null
# status :integer default(0), not null
#
class Gatherer < ActiveRecord::Base

View file

@ -25,6 +25,7 @@
# points1 :integer
# points2 :integer
# hltv_id :integer
# caster_id :string(255)
#
class Match < ActiveRecord::Base

View file

@ -52,6 +52,8 @@
# steam_profile :string(255)
# achievements_parsed :string(255)
# signature_parsed :string(255)
# stream :string(255)
# layout :string(255)
#
class Profile < ActiveRecord::Base

View file

@ -31,7 +31,6 @@
# category_id :integer
#
require "rcon"
require "yaml"
class Server < ActiveRecord::Base
@ -40,20 +39,16 @@ class Server < ActiveRecord::Base
DOMAIN_HLDS = 0
DOMAIN_HLTV = 1
DOMAIN_NS2 = 2
HLTV_IDLE = 1200
DEMOS = "/var/www/virtual/ensl.org/hlds_l/ns/demos"
QSTAT = "/usr/bin/quakestat"
TMPFILE = "tmp/server.txt"
attr_accessor :rcon_handle, :pwd
attr_accessor :pwd
attr_protected :id, :user_id, :updated_at, :created_at, :map, :players, :maxplayers, :ping, :version
validates_length_of [:name, :dns,], :in => 1..30
validates_length_of [:rcon, :password, :irc], :maximum => 30, :allow_blank => true
validates_length_of :description, :maximum => 255, :allow_blank => true
validates_format_of :ip, :with => /\A[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\z/
validates_format_of :port, :with => /\A[0-9]{1,5}\z/
validates_format_of :reservation, :with => /\A[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}:[0-9]{1,5}\z/, :allow_nil => true
validates_format_of :port, :with => /\A[0-9]{1,5}\z/
validates_format_of :reservation, :with => /\A[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}:[0-9]{1,5}\z/, :allow_nil => true
validates_format_of :pwd, :with => /\A[A-Za-z0-9_\-]*\z/, :allow_nil => true
scope :ordered, :order => "name"
@ -71,301 +66,79 @@ class Server < ActiveRecord::Base
AND match_time > '#{(time.ago(Match::MATCH_LENGTH).utc).strftime("%Y-%m-%d %H:%M:%S")}'
AND match_time < '#{(time.ago(-Match::MATCH_LENGTH).utc).strftime("%Y-%m-%d %H:%M:%S")}'",
:conditions => "matches.hltv_id IS NULL"} }
scope :of_addr,
lambda { |addr| {
:conditions => {
:ip => addr.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/)[0],
:port => addr.match(/:([0-9]{1,5})/)[1] } } }
scope :of_category,
lambda { |category| {
:conditions => {:category_id => category.id} }}
has_many :logs
has_many :matches
has_many :challenges
belongs_to :user
belongs_to :recordable, :polymorphic => true
has_many :logs
has_many :matches
has_many :challenges
belongs_to :user
belongs_to :recordable, :polymorphic => true
before_create :set_category
before_create :set_category
acts_as_versioned
non_versioned_columns << 'name'
non_versioned_columns << 'description'
non_versioned_columns << 'dns'
non_versioned_columns << 'ip'
non_versioned_columns << 'port'
non_versioned_columns << 'rcon'
non_versioned_columns << 'password'
non_versioned_columns << 'irc'
non_versioned_columns << 'user_id'
non_versioned_columns << 'official'
non_versioned_columns << 'domain'
non_versioned_columns << 'reservation'
non_versioned_columns << 'recording'
non_versioned_columns << 'idle'
non_versioned_columns << 'default_id'
non_versioned_columns << 'active'
non_versioned_columns << 'recordable_type'
non_versioned_columns << 'recordable_id'
acts_as_versioned
non_versioned_columns << 'name'
non_versioned_columns << 'description'
non_versioned_columns << 'dns'
non_versioned_columns << 'ip'
non_versioned_columns << 'port'
non_versioned_columns << 'rcon'
non_versioned_columns << 'password'
non_versioned_columns << 'irc'
non_versioned_columns << 'user_id'
non_versioned_columns << 'official'
non_versioned_columns << 'domain'
non_versioned_columns << 'reservation'
non_versioned_columns << 'recording'
non_versioned_columns << 'idle'
non_versioned_columns << 'default_id'
non_versioned_columns << 'active'
non_versioned_columns << 'recordable_type'
non_versioned_columns << 'recordable_id'
def domains
{DOMAIN_HLTV => "HLTV", DOMAIN_HLDS => "NS Server", DOMAIN_NS2 => "NS2 Server"}
end
def to_s
name
end
def addr
ip + ":" + port.to_s
end
def players_s
if players.nil? or max_players.nil?
"N/A"
else
players.to_s + " / " + max_players.to_s
end
end
def recording_s
return nil if self.domain != DOMAIN_HLTV
# recording.to_i > 0 ? Match.find(recording).to_s : recording
recording
end
def reservation_s
return nil if domain != DOMAIN_HLTV
reservation
end
def graphfile
File.join("public", "images", "servers", id.to_s + ".png")
end
def set_category
self.category_id = (domain == DOMAIN_NS2 ? 45 : 44 )
end
def after_validation_on_update
if reservation_changed?
rcon_connect
if reservation == nil
rcon_exec "stop"
self.recording = nil
self.recordable = nil
self.idle = nil
save_demos if self.recording
hltv_stop
else
if changes['reservation'][0].nil?
hltv_start
rcon_exec "stop"
self.recording = recordable.demo_name if recordable and recordable_type == "Match" or recordable_type == "Gather"
rcon_exec "record demos/" + self.recording if self.recording
end
rcon_exec "serverpassword " + pwd
rcon_exec "connect " + reservation
self.idle = DateTime.now
end
rcon_disconnect
end
end
def execute command
rcon_connect
response = rcon_exec command
rcon_disconnect
response
end
def rcon_connect
self.rcon_handle = RCon::Query::Original.new(ip, port, rcon)
end
def rcon_exec command
response = rcon_handle.command(command)
Log.transaction do
Log.add(self, Log::DOMAIN_RCON_COMMAND, command)
if response.to_s.length > 0
Log.add(self, Log::DOMAIN_RCON_RESPONSE, response)
end
end
response
end
def rcon_disconnect
rcon_handle.disconnect
end
def hltv_start
if nr = hltv_nr
`screen -d -m -S "Hltv-#{nr[1]}" -c $HOME/.screenrc-hltv $HOME/hlds_l/hltv -ip 78.46.36.107 -port 28#{nr[1]}00 +exec ns/hltv#{nr[1]}.cfg`
sleep 1
end
end
def hltv_nr
self.name.match(/Tv \#([0-9])/)
end
def hltv_stop
if nr = hltv_nr
sleep 5
rcon_exec "exit"
#`screen -S "Hltv-#{nr[1]}" -X 'quit'`
end
end
def save_demos
dir = case recordable_type
when "Match" then
recordable.contest.demos
when "Gather" then
Directory.find(Directory::DEMOS_GATHERS)
end
dir ||= Directory.find(Directory::DEMOS_DEFAULT)
zip_path = File.join(dir.path, recording + ".zip")
Zip::ZipOutputStream::open(zip_path) do |zos|
if recordable_type == "Match"
zos.put_next_entry "readme.txt"
zos.write "Team1: " + recordable.contester1.to_s + "\r\n"
zos.write "Team2: " + recordable.contester2.to_s + "\r\n"
zos.write "Date: " + recordable.match_time.to_s + "\r\n"
zos.write "Contest: " + recordable.contest.to_s + "\r\n"
zos.write "Server: " + recordable.server.addr + "\r\n" if recordable.server
zos.write "HLTV: " + addr + "\r\n"
zos.write YAML::dump(recordable.attributes).to_s
end
Dir.glob("#{DEMOS}/*").each do |file|
if File.file?(file) and file.match(/#{recording}.*\.dem/)
zos.put_next_entry File.basename(file)
zos.write(IO.read(file))
end
end
end
DataFile.transaction do
unless dbfile = DataFile.find_by_path(zip_path)
dbfile = DataFile.new
dbfile.path = zip_path
dbfile.directory = dir
dbfile.save!
DataFile.update_all({:name => File.basename(zip_path)}, {:id => dbfile.id})
end
if recordable_type == "Match"
recordable.demo = dbfile
recordable.save
end
end
end
def make_stats
graph = Gruff::Line.new
graph.title = name
pings = []
players = []
labels = {}
n = 0
for version in versions.all(:order => "updated_at DESC", :limit => 30).reverse
pings << version.ping.to_i
players << version.players.to_i
labels[n] = version.updated_at.strftime("%H:%M") if n % 3 == 0
n = n + 1
end
graph.theme_37signals
graph.data("Ping", pings, '#057fc0')
graph.data("Players", players, '#ff0000')
graph.labels = labels
graph.write(graphfile)
end
def default_record
# if self.default
# rcon_exec "record demos/auto-" + Verification.uncrap(default.name)
# rcon_exec "serverpassword " + default.password
# rcon_exec "connect " + default.addr
# end
end
def is_free time
challenges.around(time).pending.count == 0 and matches.around(time).count == 0
end
def can_create? cuser
cuser
end
def can_update? cuser
cuser and cuser.admin? or user == cuser
end
def can_destroy? cuser
cuser and cuser.admin?
end
def self.refresh
servers = ""
Server.hlds.active.all.each do |server|
servers << " -a2s " + server.ip + ":" + server.port.to_s
def domains
{DOMAIN_HLTV => "HLTV", DOMAIN_HLDS => "NS Server", DOMAIN_NS2 => "NS2 Server"}
end
file = File.join(Rails.root, TMPFILE)
system "#{QSTAT} -xml #{servers} | grep -v '<name>' > #{file}"
def to_s
name
end
doc = REXML::Document.new(File.new(file).read)
doc.elements.each('qstat/server') do |server|
hostname = server.elements['hostname'].text.split(':', 2)
if s = Server.active.first(:conditions => {:ip => hostname[0], :port => hostname[1]})
if server.elements.include? 'map'
s.map = server.elements['map'].text
s.players = server.elements['numplayers'].text.to_i
s.max_players = server.elements['maxplayers'].text.to_i
s.ping = server.elements['ping'].text
s.map = server.elements['map'].text
s.save
s.make_stats
end
def addr
ip + ":" + port.to_s
end
def set_category
self.category_id = (domain == DOMAIN_NS2 ? 45 : 44 )
end
def is_free time
challenges.around(time).pending.count == 0 and matches.around(time).count == 0
end
def can_create? cuser
cuser
end
def can_update? cuser
cuser and cuser.admin? or user == cuser
end
def can_destroy? cuser
cuser and cuser.admin?
end
def self.move addr, newaddr, newpwd
self.hltvs.all(:conditions => {:reservation => addr}).each do |hltv|
hltv.reservation = newaddr
hltv.pwd = newpwd
hltv.save!
end
end
servers = ""
Server.hltvs.reserved.each do |server|
servers << " -a2s #{server.reservation}"
end
doc = REXML::Document.new(`#{QSTAT} -xml #{servers} | grep -v '<name>'`)
doc.elements.each('qstat/server') do |server|
hostname = server.elements['hostname'].text.split(':', 2)
if s = Server.hltvs.reserved.first(:conditions => {:ip => hostname[0], :port => hostname[1]})
if server.elements['numplayers'].text.to_i > 0
s.update_attribute :idle, DateTime.now
elsif (s.idle + HLTV_IDLE).past?
s.reservation = nil
s.save
end
def self.stop addr
self.hltvs.all(:conditions => {:reservation => addr}).each do |hltv|
hltv.reservation = nil
hltv.save!
end
end
end
def self.move addr, newaddr, newpwd
self.hltvs.all(:conditions => {:reservation => addr}).each do |hltv|
hltv.reservation = newaddr
hltv.pwd = newpwd
hltv.save!
end
end
def self.stop addr
self.hltvs.all(:conditions => {:reservation => addr}).each do |hltv|
hltv.reservation = nil
hltv.save!
end
end
end

View file

@ -1,7 +0,0 @@
<% @server.logs.recent.reverse_each do |log| %>
<% if log.domain == Log::DOMAIN_RCON_COMMAND %>
<pre class="command"><%= log.text %></pre>
<% elsif log.domain == Log::DOMAIN_RCON_RESPONSE %>
<pre class="response"><%= log.text %></pre>
<% end %>
<% end %>

View file

@ -1,7 +0,0 @@
<% @server.logs.recent.reverse_each do |log| %>
<% if log.domain == Log::DOMAIN_RCON_COMMAND %>
<pre class="command"><%= log.text %></pre>
<% elsif log.domain == Log::DOMAIN_RCON_RESPONSE %>
<pre class="response"><%= log.text %></pre>
<% end %>
<% end %>

View file

@ -1,19 +0,0 @@
<h1>
RCON: <%= h @server %>
</h1>
<%= image_tag "icons/spinner.gif",
:align => "absmiddle",
:id => "spinner",
:style =>"display: none;" %>
<div class="wide box" id="serverLog">
<%= render :partial => "response" %>
</div>
<% form_tag remote: true, update: 'serverLog',
:before => "Element.show('spinner')",
:success => "Element.hide('spinner'); $('serverLog').scrollTop = $('serverLog').scrollHeight;" do %>
<%= label_tag :query, "Rcon Command:" %>
<%= text_field_tag "query", params['query'], :size => 30 %>
<% end %>

View file

@ -1,21 +0,0 @@
<h1>
Server Log: <%= h @server %>
</h1>
<div class="box wide">
<table class="data">
<tr>
<th width="10%">Date</th>
<th width="10%">Type</th>
<th width="80%">Message</th>
</tr>
<% @server.logs.each do |log| %>
<tr class="<%= cycle('even', 'odd') %>">
<td><%= shorttime log.created_at %></td>
<td><%= log.domains[log.domain] %></td>
<td><%= h log.text %></td>
</tr>
<% end %>
</table>
</div>

12
spec/factories/group.rb Normal file
View file

@ -0,0 +1,12 @@
FactoryGirl.define do
factory :group do
sequence(:id) { |n| n + 100 } # Preserve first 100
sequence(:name) { |n| "Group#{n}" }
association :founder, factory: :user
end
trait :admin do
name "Admins"
id Group::ADMINS
end
end

View file

@ -0,0 +1,5 @@
FactoryGirl.define do
factory :grouper do
sequence(:task) { |n| "Task#{n}" }
end
end

8
spec/factories/server.rb Normal file
View file

@ -0,0 +1,8 @@
FactoryGirl.define do
factory :server do
sequence(:name) { |n| "ServerName#{n}" }
sequence(:dns) { |n| "DNS#{n}" }
sequence(:ip) { |n| "192.168.#{n % 255}.#{n}" }
sequence(:port) { |n| "#{1000 + n}" }
end
end

View file

@ -13,6 +13,13 @@ FactoryGirl.define do
create(:profile, user: user)
end
trait :admin do
after(:create) do |user|
group = create(:group, :admin)
create :grouper, user: user, group: group
end
end
factory :user_with_team do
after(:create) do |user|
create(:team, founder: user)

View file

@ -0,0 +1,30 @@
require 'spec_helper'
feature 'Server Administration' do
let!(:admin) { create :user, :admin }
background do
sign_in_as admin
end
scenario 'creating a server' do
visit servers_path
expect(page).to have_content('Listing Servers')
click_link 'New server'
test_server_creation_and_editing
visit servers_path
expect(page).to have_content Server.last.name
end
feature 'Server deletion' do
let!(:server) { create :server }
scenario 'deleting a server' do
visit servers_path
expect(page).to have_content(server.name)
visit server_path(server)
click_link 'Delete Server'
visit servers_path
expect(page).to_not have_content(server.name)
end
end
end

View file

@ -0,0 +1,14 @@
require 'spec_helper'
feature 'User created servers' do
let!(:user) { create :user }
background do
sign_in_as user
end
scenario 'Creating and updating a server' do
visit new_server_path
test_server_creation_and_editing
end
end

View file

@ -0,0 +1,98 @@
# == Schema Information
#
# Table name: servers
#
# id :integer not null, primary key
# name :string(255)
# description :string(255)
# dns :string(255)
# ip :string(255)
# port :string(255)
# rcon :string(255)
# password :string(255)
# irc :string(255)
# user_id :integer
# official :boolean
# created_at :datetime
# updated_at :datetime
# map :string(255)
# players :integer
# max_players :integer
# ping :string(255)
# version :integer
# domain :integer default(0), not null
# reservation :string(255)
# recording :string(255)
# idle :datetime
# default_id :integer
# active :boolean default(TRUE), not null
# recordable_type :string(255)
# recordable_id :integer
# category_id :integer
#
require 'spec_helper'
describe Server do
describe 'create' do
it 'sets category to 45 if domain is NS2' do
server = create :server, domain: Server::DOMAIN_NS2
expect(server.category_id).to eq(45)
end
it 'sets category to 44 if domain is not NS2' do
server = create :server, domain: Server::DOMAIN_HLDS
expect(server.category_id).to eq(44)
end
end
describe 'addr' do
it 'returns properly formatted IP and port number' do
ip = '1.1.1.1'
port = '8000'
server = create :server, ip: ip, port: port
expect(server.addr).to eq('1.1.1.1:8000')
end
end
describe 'to_s' do
it 'returns server name' do
server_name = "Foo"
server = create :server, name: server_name
expect(server.to_s).to eq(server_name)
end
end
describe 'Permissions' do
let!(:user) { create :user }
let!(:admin) { create :user, :admin }
let!(:server_user) {create :user }
let!(:server) { create :server, user: server_user }
describe 'can_create?' do
it 'returns true for non-admins' do
expect(server.can_create? user).to be_true
end
end
describe 'can_destroy?' do
it 'returns true for admin' do
expect(server.can_destroy? admin).to be_true
end
it 'returns false for non-admins' do
expect(server.can_destroy? user).to be_false
end
end
describe 'can_update?' do
it 'returns true for admin' do
expect(server.can_update? admin).to be_true
end
it 'returns true if server belongs to user' do
expect(server.can_update? server_user).to be_true
end
it 'returns false for non-admins' do
expect(server.can_update? user).to be_false
end
end
end
end

View file

@ -26,6 +26,7 @@ RSpec.configure do |config|
config.include FactoryGirl::Syntax::Methods
config.include Controllers::JsonHelpers, type: :controller
config.include Features::FormHelpers, type: :feature
config.include Features::ServerHelpers, type: :feature
config.include Features::SessionHelpers, type: :feature
config.fixture_path = "#{::Rails.root}/spec/fixtures"

View file

@ -0,0 +1,49 @@
module Features
module ServerHelpers
def test_server_creation_and_editing
dns = 'ServerDns.com'
ip = '192.168.1.1'
port = '8000'
rcon = 'whatsrcon'
password = 'secret'
name = 'MyNsServer'
description = 'My NS Server'
irc = '#some_channel'
visit new_server_path
fill_in 'Dns', with: dns
fill_in 'server_ip', with: ip
fill_in 'server_port', with: port
fill_in 'Password', with: password
fill_in 'Name', with: name
fill_in 'Description', with: description
fill_in 'Irc', with: irc
check 'Available for officials?'
click_button 'Save'
expect(page).to have_content(dns)
expect(page).to have_content("#{ip}:#{port}")
expect(page).to have_content(password)
expect(page).to have_content(irc)
expect(page).to have_content(description)
click_link 'Edit Server'
fill_in 'Dns', with: "#{dns}2"
fill_in 'server_ip', with: "192.168.1.2"
fill_in 'server_port', with: "8001"
fill_in 'Password', with: "#{password}2"
fill_in 'Name', with: "#{name}2"
fill_in 'Description', with: "#{description}2"
fill_in 'Irc', with: "#{irc}2"
check 'Available for officials?'
click_button 'Save'
expect(page).to have_content("192.168.1.2:8001")
expect(page).to have_content("#{dns}2")
expect(page).to have_content("#{password}2")
expect(page).to have_content("#{irc}2")
expect(page).to have_content("#{description}2")
end
end
end

View file

@ -1,296 +0,0 @@
#!/usr/bin/env ruby
################################################################
#
# rcontool - shell interface to rcon commands
#
# (C) 2006 Erik Hollensbe, License details below
#
# Use 'rcontool -h' for usage instructions.
#
# The compilation of software known as rcontool is distributed under the
# following terms:
# Copyright (C) 2005-2006 Erik Hollensbe. All rights reserved.
#
# Redistribution and use in source form, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#
#
################################################################
#
# rubygems hack
#
begin
require 'rubygems'
rescue LoadError => e
end
begin
require 'rcon'
require 'ip'
rescue LoadError => e
$stderr.puts "rcontool requires the rcon and ip libraries be installed."
$stderr.puts "You can find them both via rubygems or at http://rubyforge.org."
exit -1
end
RCONTOOL_VERSION = '0.1.0'
require 'optparse'
require 'ostruct'
#
# Manages our options
#
def get_options
options = OpenStruct.new
# ip address (IP::Address object)
options.ip_address = nil
# port (integer)
options.port = nil
# password
options.password = nil
# protocol type (one of :hlds, :source, :oldquake, :newquake)
options.protocol_type = nil
# verbose, spit out extra information
options.verbose = false
# command to execute on the server
options.command = nil
optparse = OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename $0} <ip_address:port> <command> [options]"
opts.separator ""
opts.separator "Options:"
opts.on("--ip-address [ADDRESS]",
"Provide an IP address to connect to. Does not take a port.") do |ip_address|
if ! options.ip_address.nil?
$stderr.puts "Error: you have already provided an IP Address."
$stderr.puts opts
exit -1
end
options.ip_address = IP::Address.new(ip_address)
end
opts.on("-r", "--port [PORT]",
"Port to connect to.") do |port|
if ! options.port.nil?
$stderr.puts "Error: you have already provided a port."
$stderr.puts opts
exit -1
end
options.port = port.to_i
end
opts.on("-c", "--command [COMMAND]",
"Command to run on the server.") do |command|
if ! options.command.nil?
$stderr.puts "Error: you have already provided a command."
$stderr.puts opts
exit -1
end
options.command = command
end
opts.on("-p", "--password [PASSWORD]",
"Provide a password on the command line.") do |password|
options.password = password
end
opts.on("-f", "--password-from [FILENAME]",
"Get the password from a file (use '/dev/fd/0' or '/dev/stdin' to read from Standard Input).") do |filename|
if !filename.nil?
f = File.open(filename)
options.password = f.gets.chomp
f.close
else
$stderr.puts "Error: filename (from -f) is not valid."
$stderr.puts opts
exit -1
end
end
opts.on("-t", "--protocol-type [TYPE]", [:hlds, :source, :oldquake, :newquake],
"Type of rcon connection to make: (hlds, source, oldquake, newquake).",
" Note: oldquake is quake1/quakeworld, newquake is quake2/3.") do |protocol_type|
options.protocol_type = protocol_type
end
opts.on("-v", "--[no-]verbose",
"Run verbosely, print information about each packet recieved and turnaround times.") do |verbose|
options.verbose = verbose
end
opts.on("-h", "--help",
"This help message.") do
$stderr.puts opts
exit -1
end
opts.on("--version", "Print the version information.") do
$stderr.puts "This is rcontool version #{RCONTOOL_VERSION},"
$stderr.puts "it is located at #{File.expand_path $0}."
exit -1
end
opts.separator ""
opts.separator "Note: IP, port, protocol type, password and command are required to function."
opts.separator ""
opts.separator "Examples (all are equivalent):"
opts.separator "\t#{File.basename($0)} 10.0.0.11 status -t hlds -r 27015 -p foobar"
opts.separator "\techo 'foobar' | #{File.basename($0)} 10.0.0.11:27015 status -t hlds -f /dev/stdin"
opts.separator "\t#{File.basename($0)} --ip-address 10.0.0.11 --port 27015 -c status -t hlds -f file_with_password"
opts.separator ""
end
################################################################
#
# This hackery is to help facilitate the bareword options if
# they exist, while still allowing for the option parser
# to work properly.
#
################################################################
s1 = ARGV.shift
s2 = ARGV.shift
begin
options.ip_address = IP::Address::IPv4.new(s1)
options.command = s2
rescue IP::AddressException => e
# attempt to split it first... not sure how to best handle this situation
begin
ip,port = s1.split(/:/, 2)
options.ip_address = IP::Address::IPv4.new(ip)
options.port = port.to_i
options.command = s2
rescue Exception => e
end
if [options.ip_address, options.port].include? nil
ARGV.unshift(s2)
ARGV.unshift(s1)
end
end
optparse.parse!
if [options.ip_address, options.protocol_type, options.port, options.password, options.command].include? nil
$stderr.puts optparse
exit -1
end
return options
end
def verbose(string)
$stderr.puts string if $options.verbose
end
def dump_source_packet(packet)
if $options.verbose
verbose "Request ID: #{packet.request_id}"
verbose "Packet Size: #{packet.packet_size}"
verbose "Response Type: #{packet.command_type}"
end
end
################################################################
#
# start main block
#
################################################################
$options = get_options
################################################################
#
# Source query
#
################################################################
if $options.protocol_type == :source
verbose "Protocol type 'SOURCE' selected."
rcon = RCon::Query::Source.new($options.ip_address.ip_address, $options.port)
# if we have a verbose request, give all the information we can about
# the query, including the packet information.
rcon.return_packets = $options.verbose
verbose "Attempting authentication to #{$options.ip_address.ip_address}:#{$options.port} with password '#{$options.password}'"
value = rcon.auth $options.password
dump_source_packet value
if ($options.verbose && value.command_type == RCon::Packet::Source::RESPONSE_AUTH) || value
verbose "Authentication succeeded. Sending command: '#{$options.command}'"
value = rcon.command $options.command
dump_source_packet value
verbose ""
if $options.verbose
puts value.string1
else
puts value
end
exit 0
else
$stderr.puts "Authentication failed."
exit 1
end
################################################################
#
# Original Query
#
################################################################
else
rcon = nil
case $options.protocol_type
when :hlds
verbose "Protocol type 'HLDS' selected"
rcon = RCon::Query::Original.new($options.ip_address.ip_address, $options.port, $options.password,
RCon::Query::Original::HLDS)
when :oldquake
verbose "Protocol type 'OLDQUAKE' selected"
rcon = RCon::Query::Original.new($options.ip_address.ip_address, $options.port, $options.password,
RCon::Query::Original::QUAKEWORLD)
when :newquake
verbose "Protocol type 'NEWQUAKE' selected"
rcon = RCon::Query::Original.new($options.ip_address.ip_address, $options.port, $options.password,
RCon::Query::Original::NEWQUAKE)
end
verbose "Attempting transmission to #{$options.ip_address.ip_address}:#{$options.port}"
verbose "Using password: '#{$options.password}' and sending command: '#{$options.command}'"
verbose ""
string = rcon.command($options.command)
puts string
exit 0
end

View file

@ -1,499 +0,0 @@
# encoding: US-ASCII
require 'socket'
#
# RCon is a module to work with Quake 1/2/3, Half-Life, and Half-Life
# 2 (Source Engine) RCon (Remote Console) protocols.
#
# Version:: 0.2.0
# Author:: Erik Hollensbe <erik@hollensbe.org>
# License:: BSD
# Contact:: erik@hollensbe.org
# Copyright:: Copyright (c) 2005-2006 Erik Hollensbe
#
# The relevant modules to query RCon are in the RCon::Query namespace,
# under RCon::Query::Original (for Quake 1/2/3 and Half-Life), and
# RCon::Query::Source (for HL2 and CS: Source, and other Source Engine
# games). The RCon::Packet namespace is used to manage complex packet
# structures if required. The Original protocol does not require
# this, but Source does.
#
# Usage is fairly simple:
#
# # Note: Other classes have different constructors
#
# rcon = RCon::Query::Source.new("10.0.0.1", 27015)
#
# rcon.auth("foobar") # source only
#
# rcon.command("mp_friendlyfire") => "mp_friendlyfire = 1"
#
# rcon.cvar("mp_friendlyfire") => 1
#
#--
#
# The compilation of software known as rcon.rb is distributed under the
# following terms:
# Copyright (C) 2005-2006 Erik Hollensbe. All rights reserved.
#
# Redistribution and use in source form, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#
#++
class RCon
class Packet
# placeholder so ruby doesn't bitch
end
class Query
#
# Convenience method to scrape input from cvar output and return that data.
# Returns integers as a numeric type if possible.
#
# ex: rcon.cvar("mp_friendlyfire") => 1
#
def cvar(cvar_name)
response = command(cvar_name)
match = /^.+?\s(?:is|=)\s"([^"]+)".*$/.match response
match = match[1]
if /\D/.match match
return match
else
return match.to_i
end
end
end
end
#
# RCon::Packet::Source generates a packet structure useful for
# RCon::Query::Source protocol queries.
#
# This class is primarily used internally, but is available if you
# want to do something more advanced with the Source RCon
# protocol.
#
# Use at your own risk.
#
class RCon::Packet::Source
# execution command
COMMAND_EXEC = 2
# auth command
COMMAND_AUTH = 3
# auth response
RESPONSE_AUTH = 2
# normal response
RESPONSE_NORM = 0
# packet trailer
TRAILER = "\x00\x00"
# size of the packet (10 bytes for header + string1 length)
attr_accessor :packet_size
# Request Identifier, used in managing multiple requests at once
attr_accessor :request_id
# Type of command, normally COMMAND_AUTH or COMMAND_EXEC. In response packets, RESPONSE_AUTH or RESPONSE_NORM
attr_accessor :command_type
# First string, the only used one in the protocol, contains
# commands and responses. Null terminated.
attr_accessor :string1
# Second string, unused by the protocol. Null terminated.
attr_accessor :string2
#
# Generate a command packet to be sent to an already
# authenticated RCon connection. Takes the command as an
# argument.
#
def command(string)
@request_id = rand(1000)
@string1 = string
@string2 = TRAILER
@command_type = COMMAND_EXEC
@packet_size = build_packet.length
return self
end
#
# Generate an authentication packet to be sent to a newly
# started RCon connection. Takes the RCon password as an
# argument.
#
def auth(string)
@request_id = rand(1000)
@string1 = string
@string2 = TRAILER
@command_type = COMMAND_AUTH
@packet_size = build_packet.length
return self
end
#
# Builds a packet ready to deliver, without the size prepended.
# Used to calculate the packet size, use #to_s to get the packet
# that srcds actually needs.
#
def build_packet
return [@request_id, @command_type, @string1, @string2].pack("VVa#{@string1.length}a2")
end
# Returns a string representation of the packet, useful for
# sending and debugging. This include the packet size.
def to_s
packet = build_packet
@packet_size = packet.length
return [@packet_size].pack("V") + packet
end
end
#
# RCon::Query::Original queries Quake 1/2/3 and Half-Life servers
# with the rcon protocol. This protocol travels over UDP to the
# game server port, and requires an initial authentication step,
# the information of which is provided at construction time.
#
# Some of the work here (namely the RCon packet structure) was taken
# from the KKRcon code, which is written in perl.
#
# One query per authentication is allowed.
#
class RCon::Query::Original < RCon::Query
# HLDS-Based Servers
HLDS = "l"
# QuakeWorld/Quake 1 Servers
QUAKEWORLD = "n"
# Quake 2/3 Servers
NEWQUAKE = ""
# Request to be sent to server
attr_reader :request
# Response from server
attr_reader :response
# Challenge ID (served by server-side of connection)
attr_reader :challenge_id
# UDPSocket object
attr_reader :socket
# Host of connection
attr_reader :host
# Port of connection
attr_reader :port
# RCon password
attr_reader :password
# type of server
attr_reader :server_type
#
# Creates a RCon::Query::Original object for use.
#
# The type (the default of which is HLDS), has multiple possible
# values:
#
# HLDS - Half Life 1 (will not work with older versions of HLDS)
#
# QUAKEWORLD - QuakeWorld/Quake 1
#
# NEWQUAKE - Quake 2/3 (and many derivatives)
#
def initialize(host, port, password, type=HLDS)
@host = host
@port = port
@password = password
@server_type = type
end
#
# Sends a request given as the argument, and returns the
# response as a string.
#
def command(request)
@request = request
@challenge_id = nil
establish_connection
@socket.print "\xFF" * 4 + "challenge rcon\n\x00"
tmp = retrieve_socket_data
challenge_id = /challenge rcon (\d+)/.match tmp
if challenge_id
@challenge_id = challenge_id[1]
end
if @challenge_id.nil?
raise RCon::NetworkException.new("RCon challenge ID never returned: wrong rcon password?")
end
@socket.print "\xFF" * 4 + "rcon #{@challenge_id} \"#{@password}\" #{@request}\n\x00"
@response = retrieve_socket_data
@response.sub!(/^\xFF\xFF\xFF\xFF#{@server_type}/, "")
@response.sub!(/\x00+$/, "")
return @response
end
#
# Disconnects the RCon connection.
#
def disconnect
if @socket
@socket.close
@socket = nil
end
end
protected
#
# Establishes the connection.
#
def establish_connection
if @socket.nil?
@socket = UDPSocket.new
@socket.connect(@host, @port)
end
end
#
# Generic method to pull data from the socket.
#
def retrieve_socket_data
return "" if @socket.nil?
retval = ""
loop do
break unless IO.select([@socket], nil, nil, 10)
packet = @socket.recv(8192)
retval << packet
break if packet.length < 8192
end
return retval
end
end
#
# RCon::Query::Source sends queries to a "Source" Engine server,
# such as Half-Life 2: Deathmatch, Counter-Strike: Source, or Day
# of Defeat: Source.
#
# Note that one authentication packet needs to be sent to send
# multiple commands. Sending multiple authentication packets may
# damage the current connection and require it to be reset.
#
# Note: If the attribute 'return_packets' is set to true, the full
# RCon::Packet::Source object is returned, instead of just a string
# with the headers stripped. Useful for debugging.
#
class RCon::Query::Source < RCon::Query
# RCon::Packet::Source object that was sent as a result of the last query
attr_reader :packet
# TCPSocket object
attr_reader :socket
# Host of connection
attr_reader :host
# Port of connection
attr_reader :port
# Authentication Status
attr_reader :authed
# return full packet, or just data?
attr_accessor :return_packets
#
# Given a host and a port (dotted-quad or hostname OK), creates
# a RCon::Query::Source object. Note that this will still
# require an authentication packet (see the auth() method)
# before commands can be sent.
#
def initialize(host, port)
@host = host
@port = port
@socket = nil
@packet = nil
@authed = false
@return_packets = false
end
#
# See RCon::Query#cvar.
#
def cvar(cvar_name)
return_packets = @return_packets
@return_packets = false
response = super
@return_packets = return_packets
return response
end
#
# Sends a RCon command to the server. May be used multiple times
# after an authentication is successful.
#
# See the class-level documentation on the 'return_packet' attribute
# for return values. The default is to return a string containing
# the response.
#
def command(command)
if ! @authed
raise RCon::NetworkException.new("You must authenticate the connection successfully before sending commands.")
end
@packet = RCon::Packet::Source.new
@packet.command(command)
@socket.print @packet.to_s
rpacket = build_response_packet
if rpacket.command_type != RCon::Packet::Source::RESPONSE_NORM
raise RCon::NetworkException.new("error sending command: #{rpacket.command_type}")
end
if @return_packets
return rpacket
else
return rpacket.string1
end
end
#
# Requests authentication from the RCon server, given a
# password. Is only expected to be used once.
#
# See the class-level documentation on the 'return_packet' attribute
# for return values. The default is to return a true value if auth
# succeeded.
#
def auth(password)
establish_connection
@packet = RCon::Packet::Source.new
@packet.auth(password)
@socket.print @packet.to_s
# on auth, one junk packet is sent
rpacket = nil
2.times { rpacket = build_response_packet }
if rpacket.command_type != RCon::Packet::Source::RESPONSE_AUTH
raise RCon::NetworkException.new("error authenticating: #{rpacket.command_type}")
end
@authed = true
if @return_packets
return rpacket
else
return true
end
end
alias_method :authenticate, :auth
#
# Disconnects from the Source server.
#
def disconnect
if @socket
@socket.close
@socket = nil
@authed = false
end
end
protected
#
# Builds a RCon::Packet::Source packet based on the response
# given by the server.
#
def build_response_packet
rpacket = RCon::Packet::Source.new
total_size = 0
request_id = 0
type = 0
response = ""
message = ""
loop do
break unless IO.select([@socket], nil, nil, 10)
#
# TODO: clean this up - read everything and then unpack.
#
tmp = @socket.recv(14)
if tmp.nil?
return nil
end
size, request_id, type, message = tmp.unpack("VVVa*")
total_size += size
# special case for authentication
break if message.sub!(/\x00\x00$/, "")
response << message
# the 'size - 10' here accounts for the fact that we've snarfed 14 bytes,
# the size (which is 4 bytes) is not counted, yet represents the rest
# of the packet (which we have already taken 10 bytes from)
tmp = @socket.recv(size - 10)
response << tmp
response.sub!(/\x00\x00$/, "")
end
rpacket.packet_size = total_size
rpacket.request_id = request_id
rpacket.command_type = type
# strip nulls (this is actually the end of string1 and string2)
rpacket.string1 = response.sub(/\x00\x00$/, "")
return rpacket
end
# establishes a connection to the server.
def establish_connection
if @socket.nil?
@socket = TCPSocket.new(@host, @port)
end
end
end
# Exception class for network errors
class RCon::NetworkException < Exception
end

View file

@ -1,13 +0,0 @@
spec = Gem::Specification.new
spec.name = "rcon"
spec.version = "0.2.1"
spec.author = "Erik Hollensbe"
spec.email = "erik@hollensbe.org"
spec.summary = "Ruby class to work with Quake 1/2/3, Half-Life and Source Engine rcon (remote console)"
spec.has_rdoc = true
spec.autorequire = "rcon"
spec.bindir = 'bin'
spec.executables << 'rcontool'
spec.add_dependency('ip', '>= 0.2.1')
spec.files = Dir['lib/rcon.rb'] + Dir['bin/rcontool']
spec.rubyforge_project = 'rcon'

File diff suppressed because it is too large Load diff