From 7c56c69cea38b94b93d714ac6628176b9fe8f498 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 27 May 2011 17:47:35 +0000 Subject: [PATCH] Added SSH support, based on patch by hagna (issue 166). --- .../commands/examples/cmdset_red_button.py | 73 ++-- game/gamesrc/objects/examples/red_button.py | 41 +-- src/locks/lockfuncs.py | 79 ++--- src/server/server.py | 16 + src/server/ssh.py | 332 ++++++++++++++++++ src/settings_default.py | 4 + src/web/templates/prosimii/webclient.html | 2 +- 7 files changed, 445 insertions(+), 102 deletions(-) create mode 100644 src/server/ssh.py diff --git a/game/gamesrc/commands/examples/cmdset_red_button.py b/game/gamesrc/commands/examples/cmdset_red_button.py index cf14fb98e..da5a83045 100644 --- a/game/gamesrc/commands/examples/cmdset_red_button.py +++ b/game/gamesrc/commands/examples/cmdset_red_button.py @@ -1,7 +1,7 @@ """ This defines the cmdset for the red_button. Here we have defined the commands and the cmdset in the same module, but if you -have many different commands to merge it if often better +have many different commands to merge it is often better to define the cmdset separately, picking and choosing from among the available commands as to what should be included in the cmdset - this way you can often re-use the commands too. @@ -10,11 +10,13 @@ cmdset - this way you can often re-use the commands too. import random from src.commands.cmdset import CmdSet from game.gamesrc.commands.basecommand import Command +from game.gamesrc.scripts.examples import red_button_scripts as scriptexamples + # Some simple commands for the red button #------------------------------------------------------------ -# Commands defined for the red button +# Commands defined on the red button #------------------------------------------------------------ class CmdNudge(Command): @@ -33,24 +35,17 @@ class CmdNudge(Command): def func(self): """ - nudge the lid. + nudge the lid. Random chance of success to open it. """ rand = random.random() - open_ok = False - if rand < 0.5: - string = "You nudge at the lid. It seems stuck." + self.caller.msg("You nudge at the lid. It seems stuck.") elif 0.5 <= rand < 0.7: - string = "You move the lid back and forth. It won't budge." + self.caller.msg("You move the lid back and forth. It won't budge.") else: - string = "You manage to get a nail under the lid. It pops open." - open_ok = True - self.caller.msg(string) - - if open_ok: - """open_lid() does its own emits, so defer it until we speak""" - self.obj.open_lid() - + self.caller.msg("You manage to get a nail under the lid.") + self.caller.execute_cmd("open lid") + class CmdPush(Command): """ Push the red button @@ -66,7 +61,7 @@ class CmdPush(Command): """ Note that we choose to implement this with checking for if the lid is open/closed. This is because this command - is likely to be tries regardless of the state of the lid. + is likely to be tried regardless of the state of the lid. An alternative would be to make two versions of this command and tuck them into the cmdset linked to the Open and Closed @@ -75,11 +70,17 @@ class CmdPush(Command): """ if self.obj.db.lid_open: + + # assign the blind state script to the caller. + # this will assign the restricted BlindCmdset to + # the caller at startup, and remove it again + # once the time has run out + self.caller.scripts.add(scriptexamples.BlindedState) + string = "You reach out to press the big red button ..." string += "\n\nA BOOM! A bright light blinds you!" string += "\nThe world goes dark ..." - self.caller.msg(string) - self.obj.press_button(self.caller) + self.caller.msg(string) self.caller.location.msg_contents("%s presses the button. BOOM! %s is blinded by a flash!" % (self.caller.name, self.caller.name), exclude=self.caller) else: @@ -111,10 +112,8 @@ class CmdSmashGlass(Command): string = "You smash your hand against the glass" string += " with all your might. The lid won't budge" string += " but you cause quite the tremor through the button's mount." - self.caller.msg(string) # have to be called before breakage since that - # also gives a return feedback to the room. - self.obj.break_lamp() - return + string += "\nIt looks like the button's lamp stopped working for the time being." + self.obj.lamp_works = False elif rand < 0.6: string = "You hit the lid hard. It doesn't move an inch." else: @@ -143,7 +142,20 @@ class CmdOpenLid(Command): if self.obj.db.lid_locked: self.caller.msg("This lid seems locked in place for the moment.") return - + string += "\nA ticking sound is heard, like a winding mechanism. Seems " + string += "the lid will soon close again." + self.db.lid_open = False + # add the relevant cmdsets to button + self.obj.cmdset.add(LidClosedCmdsSet) + # add the lid-close ticker script + self.obj.scripts.add(scriptexamples.LidCloseTimer) + + # add more info to the button description + desc = self.obj.db.closed_desc + self.obj.db.temp_desc = desc + self.obj.db.desc = "%s\n%s" % (desc, "Its glass cover is open and the button exposed.") + self.caller.msg(string) + self.caller.location.msg_contents("%s opens the lid of the button." % (self.caller.name), exclude=self.caller) self.obj.open_lid() @@ -163,10 +175,17 @@ class CmdCloseLid(Command): def func(self): "Close the lid" - self.obj.close_lid() + + if self.db.closed_desc: + self.obj.desc = self.db.closed_desc + self.obj.db.lid_open = False + + # this will clean out scripts dependent on lid being open. + self.obj.scripts.validate() + self.caller.msg("You close the button's lid. It clicks back into place.") self.caller.location.msg_contents("%s closes the button's lid." % (self.caller.name), exclude=self.caller) - + class CmdBlindLook(Command): """ Looking around in darkness @@ -253,7 +272,7 @@ class LidClosedCmdSet(CmdSet): """ key = "LidClosedCmdSet" # default Union is used *except* if we are adding to a - # cmdset named RedButtonOpen - this one we replace + # cmdset named LidOpenCmdSet - this one we replace # completely. key_mergetype = {"LidOpenCmdSet": "Replace"} @@ -269,7 +288,7 @@ class LidOpenCmdSet(CmdSet): """ key = "LidOpenCmdSet" # default Union is used *except* if we are adding to a - # cmdset named RedButtonClose - this one we replace + # cmdset named LidClosedCmdSet - this one we replace # completely. key_mergetype = {"LidClosedCmdSet": "Replace"} diff --git a/game/gamesrc/objects/examples/red_button.py b/game/gamesrc/objects/examples/red_button.py index 51d97f3c4..873818955 100644 --- a/game/gamesrc/objects/examples/red_button.py +++ b/game/gamesrc/objects/examples/red_button.py @@ -1,21 +1,14 @@ """ -An example script parent for a nice red button object. It has -custom commands defined on itself that are only useful in relation to this -particular object. See example.py in gamesrc/commands for more info -on the pluggable command system. -Assuming this script remains in gamesrc/parents/examples, create an object -of this type using @create button:examples.red_button +This is a more advanced example object. It combines functions from +script.examples as well as commands.examples to make an interactive +button typeclass. -This file also shows the use of the Event system to make the button -send a message to the players at regular intervals. To show the use of -Events, we are tying two types of events to the red button, one which cause ALL -red buttons in the game to blink in sync (gamesrc/events/example.py) and one -event which cause the protective glass lid over the button to close -again some time after it was opened. +Create this button with -Note that if you create a test button you must drop it before you can -see its messages! + @create/drop examples.red_button.RedButton + +Note that if you must drop the button before you can see its messages! """ import random from game.gamesrc.objects.baseobjects import Object @@ -28,19 +21,17 @@ from game.gamesrc.commands.examples import cmdset_red_button as cmdsetexamples class RedButton(Object): """ - This class describes an evil red button. - It will use the script definition in - game/gamesrc/events/example.py to blink - at regular intervals until the lightbulb - breaks. It also use the EventCloselid script to - close the lid and do nasty stuff when pressed. + This class describes an evil red button. It will use the script + definition in game/gamesrc/events/example.py to blink at regular + intervals. It also uses a series of script and commands to handle + pushing the button and causing effects when doing so. """ def at_object_creation(self): """ This function is called when object is created. Use this instead of e.g. __init__. """ - # store desc + # store desc (default, you can change this at creation time) desc = "This is a large red button, inviting yet evil-looking. " desc += "A closed glass lid protects it." self.db.desc = desc @@ -52,9 +43,9 @@ class RedButton(Object): self.db.lamp_works = True self.db.lid_locked = False - # set the default cmdset to the object, permanent=True means a - # script will automatically be created to always add this. - self.cmdset.add_default(cmdsetexamples.DefaultCmdSet, permanent=True) + # set the default cmdset to the object. This will by default surivive + # a server reboot (otherwise we could have used permanent=False). + self.cmdset.add_default(cmdsetexamples.DefaultCmdSet) # since the other cmdsets relevant to the button are added 'on the fly', # we need to setup custom scripts to do this for us (also, these scripts @@ -68,7 +59,7 @@ class RedButton(Object): def open_lid(self, feedback=True): """ - Open the glass lid and start the timer so it will soon close + Opens the glass lid and start the timer so it will soon close again. """ diff --git a/src/locks/lockfuncs.py b/src/locks/lockfuncs.py index 21fba3d0a..65ac825e4 100644 --- a/src/locks/lockfuncs.py +++ b/src/locks/lockfuncs.py @@ -5,99 +5,80 @@ with Evennia's permissions system. To call these locks, make sure this module is included in the settings tuple PERMISSION_FUNC_MODULES then define a lock on the form ':func(args)' and add it to the object's lockhandler. -Run the check method of the handler to execute the lock check. +Run the access() method of the handler to execute the lock check. Note that accessing_obj and accessed_obj can be any object type with a lock variable/field, so be careful to not expect a certain object type. +Appendix: MUX locks +Below is a list nicked from the MUX help file on the locks available +in standard MUX. Most of these are not relevant to core Evennia since +locks in Evennia are considerably more flexible and can be implemented +on an individual command/typeclass basis rather than as globally +available like the MUX ones. So many of these are not available in +basic Evennia, but could all be implemented easily if needed for the +individual game. - -MUX locks - -Below is a list nicked from the MUX docs on the locks available -in MUX. These are not all necessarily relevant to an Evennia game -but to show they are all possible with Evennia, each entry is a -suggestion on how one could implement similar functionality in Evennia. - -Name: Affects: Effect: -------------------------------------------------------------------------- +MUX Name: Affects: Effect: +------------------------------------------------------------------------------- DefaultLock: Exits: controls who may traverse the exit to its destination. - Evennia: specialized permission key - 'traverse' checked in move method + Evennia: "traverse:" Rooms: controls whether the player sees the SUCC or FAIL message for the room following the room description when looking at the room. - Evennia: This is better done by implementing - a clever room class ... + Evennia: Custom typeclass Players/Things: controls who may GET the object. - Evennia: specialized permission key 'get' - defined on object, checked by get command + Evennia: "get:" ParentLock: All: controls who may make @parent links to the object. - Evennia: This is handled with typeclasses - and typeclass switching instead. + Evennia: Typeclasses and "puppet:" ReceiveLock: Players/Things: controls who may give things to the object. - Evennia: See GiveLock + Evennia: SpeechLock: All but Exits: controls who may speak in that location - Evennia: Lock function checking if there - is some special restrictions on the room - (game dependent) + Evennia: TeloutLock: All but Exits: controls who may teleport out of the location. - Evennia: See LeaveLock + Evennia: TportLock: Rooms/Things: controls who may teleport there - Evennia: See EnterLock + Evennia: UseLock: All but Exits: controls who may USE the object, GIVE the object money and have the PAY attributes run, have their messages heard and possibly acted on by LISTEN and AxHEAR, and invoke $-commands stored on the object. - Evennia: Implemented per game + Evennia: Commands and Cmdsets. DropLock: All but rooms: controls who may drop that object. - Evennia: specialized permission key 'drop' - set on room, checked by drop command. + Evennia: VisibleLock: All: Controls object visibility when the object is not dark and the looker passes the lock. In DARK locations, the object must also be set LIGHT and the viewer must pass the VisibleLock. - Evennia: Better done with Scripts implementing - a dark state/cmdset. For a single object, - use a specialized permission key 'visible' - set on object and checked by look command. - + Evennia: Room typeclass with Dark/light script """ from django.conf import settings diff --git a/src/server/server.py b/src/server/server.py index 94e8319f2..4e94a5904 100644 --- a/src/server/server.py +++ b/src/server/server.py @@ -39,9 +39,11 @@ SERVERNAME = settings.SERVERNAME VERSION = get_evennia_version() TELNET_PORTS = settings.TELNET_PORTS +SSH_PORTS = settings.SSH_PORTS WEBSERVER_PORTS = settings.WEBSERVER_PORTS TELNET_ENABLED = settings.TELNET_ENABLED and TELNET_PORTS +SSH_ENABLED = settings.SSH_ENABLED and SSH_PORTS WEBSERVER_ENABLED = settings.WEBSERVER_ENABLED and WEBSERVER_PORTS WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED IMC2_ENABLED = settings.IMC2_ENABLED @@ -149,6 +151,8 @@ class Evennia(object): print ' %s (%s) started on port(s):' % (SERVERNAME, VERSION) if TELNET_ENABLED: print " telnet: " + ", ".join([str(port) for port in TELNET_PORTS]) + if SSH_ENABLED: + print " ssh: " + ", ".join([str(port) for port in SSH_PORTS]) if WEBSERVER_ENABLED: clientstring = "" if WEBCLIENT_ENABLED: @@ -203,6 +207,18 @@ if TELNET_ENABLED: telnet_service.setName('EvenniaTelnet%s' % port) EVENNIA.services.addService(telnet_service) +if SSH_ENABLED: + + from src.server import ssh + + for port in SSH_PORTS: + factory = ssh.makeFactory({'protocolFactory':ssh.SshProtocol, + 'protocolArgs':()}) + + ssh_service = internet.TCPServer(port, factory) + ssh_service.setName('EvenniaSSH%s' % port) + EVENNIA.services.addService(ssh_service) + if WEBSERVER_ENABLED: # a django-compatible webserver. diff --git a/src/server/ssh.py b/src/server/ssh.py new file mode 100644 index 000000000..ce783763d --- /dev/null +++ b/src/server/ssh.py @@ -0,0 +1,332 @@ +""" +This module implements the ssh (Secure SHell) protocol for encrypted +connections. + +This depends on a generic session module that implements +the actual login procedure of the game, tracks +sessions etc. + +""" +import os + +from twisted.cred.checkers import credentials +from twisted.cred.portal import Portal +from twisted.conch.ssh.keys import Key +from twisted.conch.interfaces import IConchUser +from twisted.conch.ssh.userauth import SSHUserAuthServer +from twisted.conch.ssh import common +from twisted.conch.insults import insults +from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory +from twisted.conch.manhole import Manhole, recvline +from twisted.internet import defer +from django.conf import settings +from src.server import session +from src.utils import ansi, utils, logger + + +ENCODINGS = settings.ENCODINGS + +CTRL_C = '\x03' +CTRL_D = '\x04' +CTRL_BACKSLASH = '\x1c' +CTRL_L = '\x0c' + +class SshProtocol(Manhole, session.Session): + """ + Each player connecting over ssh gets this protocol assigned to + them. All communication between game and player goes through + here. + """ + + def terminalSize(self, width, height): + """ + Initialize the terminal and connect to the new session. + """ + # Clear the previous input line, redraw it at the new + # cursor position + self.terminal.eraseDisplay() + self.terminal.cursorHome() + self.width = width + self.height = height + # initialize the session + self.session_connect(self.getClientAddress()) + + + def connectionMade(self): + """ + This is called when the connection is first + established. + """ + recvline.HistoricRecvLine.connectionMade(self) + self.keyHandlers[CTRL_C] = self.handle_INT + self.keyHandlers[CTRL_D] = self.handle_EOF + self.keyHandlers[CTRL_L] = self.handle_FF + self.keyHandlers[CTRL_BACKSLASH] = self.handle_QUIT + + + def handle_INT(self): + """ + Handle ^C as an interrupt keystroke by resetting the current input + variables to their initial state. + """ + self.lineBuffer = [] + self.lineBufferIndex = 0 + + self.terminal.nextLine() + self.terminal.write("KeyboardInterrupt") + self.terminal.nextLine() + + + def handle_EOF(self): + """ + Handles EOF generally used to exit. + """ + if self.lineBuffer: + self.terminal.write('\a') + else: + self.handle_QUIT() + + + def handle_FF(self): + """ + Handle a 'form feed' byte - generally used to request a screen + refresh/redraw. + """ + self.terminal.eraseDisplay() + self.terminal.cursorHome() + + + def handle_QUIT(self): + """ + Quit, end, and lose the connection. + """ + self.terminal.loseConnection() + + + def connectionLost(self, reason=None, step=1): + """ + This is executed when the connection is lost for + whatever reason. + + Closing the connection takes two steps + + step 1 - is the default and is used when this method is + called automatically. The method should then call self.session_disconnect(). + Step 2 - means this method is called from at_disconnect(). At this point + the sessions are assumed to have been handled, and so the transport can close + without further ado. + """ + insults.TerminalProtocol.connectionLost(self, reason) + if step == 1: + self.session_disconnect() + else: + self.terminal.loseConnection() + + + def getClientAddress(self): + """ + Returns the client's address and port in a tuple. For example + ('127.0.0.1', 41917) + """ + + return self.terminal.transport.getPeer() + + + def lineReceived(self, string): + + """ + Communication Player -> Evennia. Any line return indicates a + command for the purpose of the MUD. So we take the user input + and pass it on to the game engine. + """ + self.at_data_in(string) + + def lineSend(self, string): + """ + Communication Evennia -> Player + Any string sent should already have been + properly formatted and processed + before reaching this point. + + """ + for line in string.split('\n'): + self.terminal.write(line) #this is the telnet-specific method for sending + self.terminal.nextLine() + + # session-general method hooks + + def at_connect(self): + """ + Show the banner screen. + """ + self.telnet_markup = True + # show connection screen + self.execute_cmd('look') + + def at_login(self, player): + """ + Called after authentication. self.logged_in=True at this point. + """ + if player.has_attribute('telnet_markup'): + self.telnet_markup = player.get_attribute("telnet_markup") + else: + self.telnet_markup = True + + def at_disconnect(self, reason="Connection closed. Goodbye for now."): + """ + Disconnect from server + """ + char = self.get_character() + if char: + char.at_disconnect() + self.at_data_out(reason) + self.connectionLost(step=2) + + def at_data_out(self, string, data=None): + """ + Data Evennia -> Player access hook. 'data' argument is ignored. + """ + try: + string = utils.to_str(string, encoding=self.encoding) + except Exception, e: + self.lineSend(str(e)) + return + nomarkup = not self.telnet_markup + raw = False + if type(data) == dict: + # check if we want escape codes to go through unparsed. + raw = data.get("raw", self.telnet_markup) + # check if we want to remove all markup + nomarkup = data.get("nomarkup", not self.telnet_markup) + if raw: + self.lineSend(string) + else: + self.lineSend(ansi.parse_ansi(string, strip_ansi=nomarkup)) + + def at_data_in(self, string, data=None): + """ + Line from Player -> Evennia. 'data' argument is not used. + + """ + try: + string = utils.to_unicode(string, encoding=self.encoding) + self.execute_cmd(string) + return + except Exception, e: + logger.log_errmsg(str(e)) + + +class ExtraInfoAuthServer(SSHUserAuthServer): + def auth_password(self, packet): + """ + Password authentication. + + Used mostly for setting up the transport so we can query + username and password later. + """ + password = common.getNS(packet[1:])[0] + c = credentials.UsernamePassword(self.user, password) + c.transport = self.transport + return self.portal.login(c, None, IConchUser).addErrback( + self._ebPassword) + + +class AnyAuth(object): + """ + Special auth method that accepts any credentials. + """ + credentialInterfaces = (credentials.IUsernamePassword,) + + def requestAvatarId(self, c): + "Generic credentials" + up = credentials.IUsernamePassword(c, None) + username = up.username + password = up.password + src_ip = str(up.transport.transport.getPeer().host) + return defer.succeed(username) + + +class TerminalSessionTransport_getPeer: + """ + Taken from twisted's TerminalSessionTransport which doesn't + provide getPeer to the transport. This one does. + """ + def __init__(self, proto, chainedProtocol, avatar, width, height): + self.proto = proto + self.avatar = avatar + self.chainedProtocol = chainedProtocol + + session = self.proto.session + + self.proto.makeConnection( + _Glue(write=self.chainedProtocol.dataReceived, + loseConnection=lambda: avatar.conn.sendClose(session), + name="SSH Proto Transport")) + + def loseConnection(): + self.proto.loseConnection() + + def getPeer(): + session.conn.transport.transport.getPeer() + + self.chainedProtocol.makeConnection( + _Glue(getPeer=getPeer, write=self.proto.write, + loseConnection=loseConnection, + name="Chained Proto Transport")) + + self.chainedProtocol.terminalProtocol.terminalSize(width, height) + +def getKeyPair(): + """ + This function looks for RSA keypair files in the current directory. If they + do not exist, the keypair is created. + """ + + if not (os.path.exists('ssh-public.key') and os.path.exists('ssh-private.key')): + # No keypair exists. Generate a new RSA keypair + print " Generating SSH RSA keypair (only done once) ...", + from Crypto.PublicKey import RSA + + KEY_LENGTH = 1024 + rsaKey = Key(RSA.generate(KEY_LENGTH)) + publicKeyString = rsaKey.public().toString(type="OPENSSH") + privateKeyString = rsaKey.toString(type="OPENSSH") + + # save keys for the future. + file('ssh-public.key', 'w+b').write(publicKeyString) + file('ssh-private.key', 'w+b').write(privateKeyString) + print " done." + else: + publicKeyString = file('ssh-public.key').read() + privateKeyString = file('ssh-private.key').read() + + return Key.fromString(publicKeyString), Key.fromString(privateKeyString) + +def makeFactory(configdict): + """ + Creates the ssh server factory. + """ + + def chainProtocolFactory(): + return insults.ServerProtocol( + configdict['protocolFactory'], + *configdict.get('protocolConfigdict', ()), + **configdict.get('protocolKwArgs', {})) + + rlm = TerminalRealm() + rlm.transportFactory = TerminalSessionTransport_getPeer + rlm.chainedProtocolFactory = chainProtocolFactory + factory = ConchFactory(Portal(rlm)) + + # create/get RSA keypair + publicKey, privateKey = getKeyPair() + + factory.publicKeys = {'ssh-rsa': publicKey} + factory.privateKeys = {'ssh-rsa': privateKey} + + factory.services = factory.services.copy() + factory.services['ssh-userauth'] = ExtraInfoAuthServer + + factory.portal.registerChecker(AnyAuth()) + + return factory diff --git a/src/settings_default.py b/src/settings_default.py index 98d9702e1..f63278763 100644 --- a/src/settings_default.py +++ b/src/settings_default.py @@ -36,6 +36,10 @@ WEBSERVER_PORTS = [8000] # Start the evennia ajax client on /webclient # (the webserver must also be running) WEBCLIENT_ENABLED = True +# Activate SSH protocol +SSH_ENABLED = False +# Ports to use for SSH +SSH_PORTS = [8022] # Activate full persistence if you want everything in-game to be # stored to the database. With it set, you can do typeclass.attr=value # and value will be saved to the database under the name 'attr'. diff --git a/src/web/templates/prosimii/webclient.html b/src/web/templates/prosimii/webclient.html index 85f25d56d..ddab711f4 100644 --- a/src/web/templates/prosimii/webclient.html +++ b/src/web/templates/prosimii/webclient.html @@ -9,7 +9,7 @@ - +