Scripts:Modified admin script

From BF2 Technical Information Wiki
Jump to navigation Jump to search


Introduction

When the BF2 server starts, it loads some code called an admin script. The purpose of the default admin script provided by Dice, is to create and start an RCON service, letting both connected BF2 clients and standalone RCON clients run RCON commands on the server. The admin script is completely open to the server operator, and can be reprogrammed at will. It is located in the admin directory of the BF2 server, and the default admin script is called default.py.

We at Battlefield.no have created a modified admin script that uses Steven Hartland's brilliant idea of making the admin script load a separate configuration file, called adminsettings.con, from the same directory that the server has loaded the map list. These settings are loaded into a Python dictionary called options, and made globally available via a reference in the 'bf2' object, calles adminoptions. The script also enables logging to both file and UDP, which are both configured using the adminsettings.con file. The adminsettings.con file is also re-read on every EndRound server event, so for most settings (RCON port and password being an exception) you do not have to restart the server to see the effect of a changed setting.

Getting Started

No we'll have a look at what you need to do, to get online with the modified admin script. Please note that this description is for doing the modification on Linux. For Windows you will need to make some minor ajustments. Please see the discussion page for details.


Modifying the Admin Script

It is not a good idea to modify the original default.py. It is much better to copy it, and start editing the copy. To enable your copy as the admin script, simply open the serversettings.con file that you are using, and edit the server setting called sv.adminScript, like this:

sv.adminScript = "<name of admin script>" 

We at Battlefield.no have called our admin script bfno.py, and hence, our serversettings.con file is modified to look like this:

sv.adminScript = "bfno" 

Note the lack of the .py extension. You will need to change 'bfno' to whatever you call your admin script.


Creating the Admin Settings File

The adminsettings.con file needs to reside in the same directory as your current maplist. So if you are starting your BF2 server without the +mapList option, you must place it in your mods/bf2/settings directory. When you have created it, you need to populate it with the following settings:

admin.rconPort 4711
admin.rconPassword "yourRconPassword"
admin.debugLogEnabled 1
admin.debugLogFile "/path/to/debug.log"
admin.udpLogEnabled 1
admin.udpLogHost "localhost"
admin.udpLogPort 29500

You will of course need to change the ports, path and password to your own, and turn off logging to file and/or UDP if you don't want that.


The Admin Script

This is the modified admin script as we use it at Battlefield.no. Feel free to copy it, and since this is a wiki, feel free to modify it. But we would be very gratefull of you appended a note in the updates section if you do.

For your convenience, we've uploaded the script to our website as well. You can download it here.

The Code

# ------------------------------------------------------------------------
# Battlefield II -- default remote console module.
# ------------------------------------------------------------------------
#
# This is a very simple, TCP based remote console which does simple MD5 digest
# password authentication. It can be configured via the admin/default.cfg file.
# Recognized options are 'port' and 'password'.
#
# Implementation guidelines for this file (and for your own rcon modules):
#
#  - All socket operations MUST be non-blocking. If your module blocks on a
#    socket the game server will hang.
#
#  - Do as little work as possible in update() as it runs in the server main
#    loop.
#
# Other notes:
#
#  - To get end-of-message markers (0x04 hex) after each reply, begin your
#    commands with an ascii 0x02 code. This module will then append the
#    end-of-message marker to all results. This is useful if you need to wait
#    for a complete response.
#
# Copyright (c)2004 Digital Illusions CE AB
# Author: Andreas `dep' Fredriksson
#
# ------------------------------------------------------------------------
#
# -----------------------------
# Changes made by the community
# -----------------------------
#
# If you make any modifications to this script that enhances its
# functionality, please post the changes to the ICCULUS mailing list.
#
# NB: Use "tab expansion", and expand to 3 spaces!
#
# Maintained by Kybber and Panic, Battlefield.no.
#
# -----------
# Version 1.1
# -----------
#
# The script has been reorganized into the following sections:
# - Import
# - Variables
# - Classes
# - Functions
# - Program
#
# adminsettings.con:
# This version of the admin script uses this file for all admin
# settings. It is read and parsed by 'parseConfig'. The file must
# be located in the same directory as the server is reading the
# maplist from.
#
# class writer:
# A heavily modified copy of the writer class has been added,
# facilitating logging to file and over udp.
#
# def parseConfig:
# The function parseConfig has been updated to load a file called
# adminsettings.con from the same directory as the maplist is being
# read. This emulates the old "+overlaypath" option. Thanks a lot to
# Steven Hartland at Multiplay.co.uk for this!
#
# def onGameStatusChanged:
# This function is built as a callback function to be registered with
# 'host.registerGameStatusHandler(onGameStatusChanged)'. It's intended
# use is to reload the adminsettings.con file.
#
# This version of the file also 'globalizez' the 'options' dictionary,
# by adding a reference called 'adminoptions' to the 'bf2' object.
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
#                          I M P O R T
# ------------------------------------------------------------------------

import socket
import errno
import host
import bf2
import types
import md5
import string
import random
import sys
import datetime

# ------------------------------------------------------------------------
#                       V A R I A B L E S
# ------------------------------------------------------------------------

# File version
version = "1.1"

# Debug flags:
a_debug = 1 # Get debug info
a_info  = 1 # Get other runtime info

# Admin options:
options = {
    'admin.rconPort': '4711',   # Overwritten by admin.rconPort in adminsettings.con
    'admin.rconPassword': None, # Overwritten by admin.rconPassword in adminsettings.con
    # True if multiple commands should be processed in one update and
    # if as many responses as possible should be sent each update.
    'admin.allowBatching': False,
}



# ------------------------------------------------------------------------
#                         C L A S S E S
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# class writer: Get a hold of stdout and stderr!
# ------------------------------------------------------------------------
class writer:
   """Class for writing output to files and sockets
  
   Arguments
   The __init__ function takes no arguments.

   Functions
   __init__(self) : Creating stream and socket objects to log to.
   __del__(self)  : Closes the streams and sockets created by __init__.
   write(self,str): Write data to the streams and sockets created by __init__.

   Description
   The intended use of the class is to override sys.stdout and sys.stderr,
   like this: sys.stdout = sys.stderr = writer().
   """

   # ---------------------------------------------------------------------
   # Constructor:
   # ---------------------------------------------------------------------
   def __init__(self):
      """Creates streams and sockets to log data to, using the write function."""
     
      # Class variables:
      self.debugLogEnabled = None
      self.debugLogFile = None
      self.udpLogEnabled = None
      self.udpLogHost = None
      self.udpLogPort = None
      self.stream = None
      self.sock = None
     
      # Try to get the debug log settings:
      try:
         if str(options['admin.debugLogEnabled']) in ['True','true','1']: self.debugLogEnabled = True
         self.debugLogFile = options['admin.debugLogFile']
      except:
         pass
      # Try to get the udp log settings:
      try:
         if str(options['admin.udpLogEnabled']) in ['True','true','1']: self.udpLogEnabled = True
         self.udpLogHost = str(options['admin.udpLogHost'])
         self.udpLogPort = int(options['admin.udpLogPort'])
      except:
         pass
     
      # Prepare for udp logging:
      if self.udpLogEnabled and self.udpLogHost and self.udpLogPort:
         self.sock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
     
      # Prepare for logging to file:
      if self.debugLogEnabled and self.debugLogFile:
         self.stream = open( self.debugLogFile, 'a+' )

   # ---------------------------------------------------------------------
  
   # ---------------------------------------------------------------------
   # Deconstructor:
   # ---------------------------------------------------------------------
   def __del__(self):
      """Closes the streams and sockets created by __init__"""
      if self.stream: self.stream.close()
      if self.sock: self.sock.close()
   # ---------------------------------------------------------------------

   # ---------------------------------------------------------------------
   # Write data:
   # ---------------------------------------------------------------------
   """Writes data to the streams and sockets defined in __init__"""
   def write(self, str):
      if '\n' != str:
         if self.stream: self.stream.write('[' + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")  + '] ' + str)
         if self.sock: self.sock.sendto(str,(self.udpLogHost,self.udpLogPort))
      else:
         if self.stream:
            self.stream.write(str)
            self.stream.flush()
   # ---------------------------------------------------------------------
# ------------------------------------------------------------------------





# ------------------------------------------------------------------------
# A stateful output buffer that knows how to enqueue data and ship it out
# without blocking.
# ------------------------------------------------------------------------
class OutputBuffer(object):

    def __init__(self, sock):
        self.sock = sock
        self.data = []
        self.index = 0

    def enqueue(self, str):
        self.data.append(str)

    def update(self):
        allowBatching = options['admin.allowBatching']
        while len(self.data) > 0:
            try:
                item = self.data[0]
                scount = self.sock.send(item[self.index:])
                self.index += scount
                if self.index == len(item):
                    del self.data[0]
                    self.index = 0
            except socket.error, detail:
                if detail[0] != errno.EWOULDBLOCK:
                    return detail[1]
            if not allowBatching:
                break
        return None
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Each TCP connection is represented by an object of this class.
# ------------------------------------------------------------------------
class AdminConnection(object):

    def __init__(self, srv, sock, addr):
        print 'new rcon/admin connection from %s:%d' % (addr[0], addr[1])
        self.server = srv
        self.sock = sock
        self.addr = addr
        self.sock.setblocking(0)
        self.buffer = ''
        self.seed = make_seed(16)
        self.correct_digest = digest(self.seed, options['admin.rconPassword'])
        self.outbuf = OutputBuffer(self.sock)
       
        # Welcome message *must* end with \n\n
        self.outbuf.enqueue('### Battlefield 2 default RCON/admin ready.\n')
        self.outbuf.enqueue('### Digest seed: %s\n' % (self.seed))
        self.outbuf.enqueue('\n') # terminate welcome message with extra LF

    def update(self):
        err = None
        try:
            allowBatching = options['admin.allowBatching']
            while not err:
                data = self.sock.recv(1024)
                if data:
                    self.buffer += data
                    while not err:
                        nlpos = self.buffer.find('\n')
                        if nlpos != -1:
                            self.server.onRemoteCommand(self, self.buffer[0:nlpos])
                            self.buffer = self.buffer[nlpos+1:] # keep rest of buffer
                        else:
                            if len(self.buffer) > 128:
                                err = 'data format error: no newline in message'
                            break
                        if not allowBatching: break
                else:
                    err = 'peer disconnected'
               
                if not allowBatching: break

        except socket.error, detail:
            if detail[0] != errno.EWOULDBLOCK:
                err = detail[1]

        if not err:
            err = self.outbuf.update()

        if err:
            print 'rcon: closing %s:%d: %s' % (self.addr[0], self.addr[1], err)
            try:
                self.sock.shutdown(2)
                self.sock.close()
            except:
                print 'rcon: warning: failed to close %s' % (self.addr)
                pass
            return 0
        else:
            return 1
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Context passed to remote command implementations for them to write output to
# either a remote tcp socket or an in-game client executing 'rcon <command>'.
# ------------------------------------------------------------------------
class CommandContext(object):
    def __init__(self):
        self.player = None
        self.socket = None
        self.output = []

    def isInGame(self): return self.player is not None
    def isSocket(self): return self.socket is not None
    def write(self, text): self.output.append(text)
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# The server itself.
# ------------------------------------------------------------------------
class AdminServer(object):

    def __init__(self, port):
        # state for tcp rcon connections
        self.port = port
        self.backlog = 1
        self.peers = []
        self.openSocket()

        # state for in-game rcon connections
        host.registerHandler('RemoteCommand', self.onRemoteCommand, 1)
        host.registerHandler('PlayerDisconnect', self.onPlayerDisconnect, 1)
        host.registerHandler('ChatMessage', self.onChatMessage, 1)

        # contains player ids for players which have successfully authenticated
        # themselves with 'rcon login <passwd>'
        self.authed_players = {}
        # contains sockets for connections which have successfully authenticated
        # themselves with 'login <passwd>'
        self.authed_sockets = {}

        # rcon commands supported in this vanilla version
        self.rcon_cmds = {
            'login': self.rcmd_login,
            'users': self.rcmd_users,
            'exec': self.rcmd_exec
        }

    # Called when a user types 'rcon ' followed by any string in a client
    # console window or when a TCP client sends a complete line to be
    # evaluated.
    def onRemoteCommand(self, playerid_or_socket, cmd):
        cmd = cmd.strip()
        interactive = True

        # Is this a non-interactive client?
        if len(cmd) > 0 and cmd[0] == '\x02':
            cmd = cmd[1:]
            interactive = False

        spacepos = cmd.find(' ')
        if spacepos == -1: spacepos=len(cmd)
        subcmd = cmd[0:spacepos]

        ctx = CommandContext()
        authed = 0
        if type(playerid_or_socket) == types.IntType:
            ctx.player = playerid_or_socket
            authed = self.authed_players.has_key(ctx.player)
        else:
            ctx.socket = playerid_or_socket
            authed = self.authed_sockets.has_key(ctx.socket)
           
        # you can only login unless you are authenticated
        if subcmd != 'login' and not authed:
            ctx.write('error: not authenticated: you can only invoke \'login\'\n')
        else:
            if self.rcon_cmds.has_key(subcmd):
                self.rcon_cmds[subcmd](ctx, cmd[spacepos+1:])
            else:
                ctx.write('unknown command: \'%s\'\n' % (subcmd))

        feedback = ''.join(ctx.output)
        if ctx.socket:
            if interactive:
                ctx.socket.outbuf.enqueue(feedback)
            else:
                ctx.socket.outbuf.enqueue(feedback + '\x04')
        else:
            host.rcon_feedback(ctx.player, feedback)

    # When players disconnect, remove them from the auth map if they were
    # authenticated so that the next user with the same id doesn't get rcon
    # access.
    def onPlayerDisconnect(self, player_id):
        if self.authed_players.has_key(player_id):
            del self.authed_players[player_id]

    # Called whenever a player issues a chat string.
    def onChatMessage(self, player_id, text, channel, flags):
        print 'chat: pid=%d text=\'%s\' channel=%s' % (player_id, text, channel)

    # Sets up the listening TCP RCON socket. This binds to 0.0.0.0, which may
    # not be what you want but it's a sane default for most installations.
    def openSocket(self):
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            #self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, 0)
            #self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            self.sock.bind(('0.0.0.0', self.port))
            self.sock.listen(self.backlog)
            self.sock.setblocking(0)
        except socket.error, detail:
            print 'failed to bind rcon socket--only in-game rcon will be enabled'

    # WARNING: update is called very frequently -- don't go crazy with logic
    # here.
    def update(self):
        # if we don't have a socket, just return
        if not self.sock: return

        # without blocking, check for new connections
        try:
            conn, peeraddr = self.sock.accept()
            self.peers.append(AdminConnection(self, conn, peeraddr))
        except socket.error, detail:
            if detail[0] != errno.EWOULDBLOCK:
                raise socket.error, detail

        # update clients and mark connections that fail their update
        disc = []
        for client in self.peers:
            if not client.update(): disc.append(client)

        # delete any auth status for closed tcp connections
        for d in disc:
            if self.authed_sockets.has_key(d): del self.authed_sockets[d]

        # now keep the remaining clients
        self.peers = filter(lambda x: x not in disc, self.peers)

    def shutdown(self):
        if self.sock:
            self.sock.close()

    # Command implementations go here (member functions of the AdminServer)

    # Allows a in-game rcon client to authenticate and get access.
    def rcmd_login(self, ctx, cmd):
        success = 0
        if ctx.isInGame():
            # We're called by an in-game rcon client, use plain-text password
            # (encoded into bf2 network stream).
            if cmd.strip() == options['admin.rconPassword']:
                    self.authed_players[ctx.player] = 1
                    success = 1
            elif self.authed_players.has_key(ctx.player):
                    del self.authed_players[ctx.player]
        else:
            # tcp client, require seeded digest to match instead of pw
            if cmd.strip() == ctx.socket.correct_digest:
                self.authed_sockets[ctx.socket] = 1
                print 'rcon: tcp client from %s:%d logged on' % ctx.socket.addr
                success = 1
            else:
                if self.authed_sockets.has_key(ctx.socket):
                    del self.authed_sockets[ctx.socket]
                print 'rcon: tcp client from %s:%d failed pw challenge' % ctx.socket.addr

        if success:
            ctx.write('Authentication successful, rcon ready.\n')
        else:
            ctx.write('Authentication failed.\n')

    # Lists rcon-authenticated players.
    def rcmd_users(self, ctx, cmd):
        ctx.write('active rcon users:\n')
        for id in self.authed_players:
            if id == -1:
                ctx.write('-1 (local server console)\n')
            else:
                try:
                    player = bf2.playerManager.getPlayerByIndex(id)
                except:
                    ctx.write('%d (no info)\n' % (id))

        for peer in self.authed_sockets:
            ctx.write('tcp: %s:%d\n' % (peer.addr[0], peer.addr[1]))

    # Executes a console command on the server.
    def rcmd_exec(self, ctx, cmd):
        ctx.write(host.rcon_invoke(cmd))
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
#                         F U N C T I O N S
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# These functions are called from the engine
# We implement them in terms of a class instance:
# ------------------------------------------------------------------------
def init():
    print 'initializing default admin/rcon module'
   
    # load (optional) admin scripts like teamkill punish and autobalance
    import standard_admin

def shutdown():
    if server:
        print 'shutting down default admin/rcon module'
        server.shutdown()

def update():
    if server: server.update()
   # NB: Use with care!
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Returns a seed string of random characters to be used as a salt
# to protect password sniffing.
# ------------------------------------------------------------------------
def make_seed(seed_length):
    return ''.join([string.ascii_letters[random.randint(0, len(string.ascii_letters)-1)] for x in xrange(0, seed_length)])
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Concats a seed string with the password and returns an ASCII-hex MD5 digest.
# ------------------------------------------------------------------------
def digest(seed, pw):
    if not pw: return None
    m = md5.new()
    m.update(seed)
    m.update(pw)
    return m.hexdigest()
# ------------------------------------------------------------------------


# ---------------------------------------------------------------------
# Convert string to boolean:
# ---------------------------------------------------------------------
def boolFromString(str):
   """Returns a true boolean value based on a boolean string"""
   str = str(str)
   if str in ['True', 'true', '1']:
      return True
   elif value in ['False', 'false', '0']:
      return False
   else:
      #raise ValueError # Never mind errors...
      return False
# ---------------------------------------------------------------------


# ------------------------------------------------------------------------
# Parses the config file, if it's there
# ------------------------------------------------------------------------
def parseConfig( file ):
   """Function for loading a settings file

   Arguments
   file: Path to the file to load

   Description
   This function load a file with key-value-pairs on the form 'key = value',
   and updates the dictionary 'options' accordingly.
   """

   if a_info: print "ADMIN: Loading '%s'" % ( file )

   try:
      config = open(file, 'r')
      lineNo = 0
      for line in config:
         lineNo += 1
         if line.strip() != '' and line.strip() != '\n':
            try:
               ( key, value ) = line.split( ' ', 1 )
               # remove white space
               value = value.strip()
               # remove outer quotes
               value = value.strip( '"' )
              
               if a_info: print "ADMIN: setting '%s' = '%s'" % ( key, value )
               if value == 'allowBatching': value = boolFromString (value)
               options[key] = value

            except ValueError:
               if a_debug or a_info: print 'ADMIN: WARNING - syntax error in "%s" on line %d' % (file, lineNo)
   except IOError, detail:
      if a_debug or a_info: print 'ADMIN: WARNING - couldn\'t read "%s": %s' % (file, detail)
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Put stuff you need done by the end of round here
# ------------------------------------------------------------------------
def onGameStatusChanged(status):
   """Handles changes in game status.

   Arguments
   status: Integer indicating game status. (see bf2.GameStatus.*)

   Description
   This function is registered with the server with host.registerGameStatusHandler(onGameStatusChanged),
   and its intended use is to reload adminsettnigs at the end of rounds, and other, similar tasks.
   """
   global adminSettingsFile
   if a_debug: print "ADMIN: Entering onGameStatusChanged (" + str(status) +")"
   if status == bf2.GameStatus.EndGame:
      parseConfig(adminSettingsFile)
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
#                        P R O G R A M
# ------------------------------------------------------------------------


# ------------------------------------------------------------------------
# Globalize the admin options:
# ------------------------------------------------------------------------
bf2.adminoptions = options
# Now other scripts might benefit from the settings stored in the
# adminsettings.con file!
# ------------------------------------------------------------------------

# ------------------------------------------------------------------------
# Load admin settings:
# ------------------------------------------------------------------------
# Figure out the "overlay" path:
mapListFileParts = host.rcon_invoke( 'mapList.configFile' ).split( '/' );
del mapListFileParts[len( mapListFileParts ) - 1]
profileDir = "/".join( mapListFileParts )
# Set the full path to our admin settings file:
adminSettingsFile = profileDir + "/adminsettings.con"
# parse the configuration file
parseConfig( adminSettingsFile )
# ------------------------------------------------------------------------

# ------------------------------------------------------------------------
# Register handlers:
# ------------------------------------------------------------------------
# Monitor game status:
host.registerGameStatusHandler(onGameStatusChanged)
# NB: 'onGameStatusChanged' uses the variable 'adminSettingsFile'!
# ------------------------------------------------------------------------

# ------------------------------------------------------------------------
# Enable debug logging:
# ------------------------------------------------------------------------
if a_debug or a_info: sys.stdout = sys.stderr = writer()
print "Logging enabled"
print "Starting rcon on port %s" % ( options['admin.rconPort'] )
# ------------------------------------------------------------------------

# ------------------------------------------------------------------------
# Start the RCON server:
# ------------------------------------------------------------------------
server = AdminServer(int(options['admin.rconPort']))
# ------------------------------------------------------------------------

Updates

Please add a note here if you update the code above.

  • 2005-06-18: Added documentation (Panic)
  • 2005-06-17: Added v1.1 of the code (Panic)