Scripts:Modified admin script
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)