#####################
# Dennis MUD #
# server.py #
# Copyright 2020 #
# Michael D. Reiley #
#####################
# **********
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# **********
# Parts of codebase borrowed from https://github.com/TKeesh/WebSocketChat
import sys
# Check Python version.
if sys.version_info[0] != 3:
print("Not Starting: Dennis requires Python 3")
sys.exit(1)
import console
import database
import html
import json
import telnet
import websocket
from twisted.internet import reactor
from twisted.logger import Logger, LogLevel, LogLevelFilterPredicate, \
textFileLogObserver, FilteringLogObserver, globalLogBeginner
[docs]class Router:
"""Router
This class handles interfacing between the server backends and the user command consoles. It manages a lookup table
of connected users and their consoles, and handles passing messages between them.
:ivar users: Dictionary of connected users and their consoles, as well as the protocols they are connected by.
:ivar single_user: Whether we are running in single-user mode. Hard-coded here to False.
:ivar telnet_factory: The active Autobahn telnet server factory.
:ivar websocket_factory: The active Autobahn websocket server factory.
"""
[docs] def __init__(self, config, dbman):
"""Router Initializer
"""
self.users = {}
self.single_user = False
self.telnet_factory = None
self.websocket_factory = None
self._config = config
self._dbman = dbman
def __contains__(self, item):
"""__contains__
Check if a peer name is present in the users table.
:param item: Internal peer name.
:return: True if succeeded, False if failed.
"""
if item in self.users:
return True
return False
def __getitem__(self, item):
"""__getitem__
Get a user record by their peer name.
:param item: Internal peer name.
:return: User record if succeeded, None if failed.
"""
if self.__contains__(item):
return self.users[item]
else:
return None
def __iter__(self):
"""__iter__
"""
return self.users.items()
[docs] def register(self, peer, service):
"""Register User
:param peer: Internal peer name.
:param service: Service type. "telnet" or "websocket".
:return: True
"""
self.users[peer] = {"service": service, "console": console.Console(self._dbman, peer, self)}
self.users[peer]["console"]._disabled_commands = self._config["disabled"]
return True
[docs] def unregister(self, peer):
"""Unregister and Logout User
:param peer: Internal peer name.
:return: True if succeeded, False if no such user.
"""
if peer not in self.users:
return False
if not self.users[peer]["console"].user:
return False
self.users[peer]["console"].command("logout")
del self.users[peer]
return True
[docs] def message(self, peer, msg, _nbsp=False):
"""Message Peer
Message a user by their internal peer name.
:param peer: Internal peer name.
:param msg: Message to send.
:param _nbsp: Will insert non-breakable spaces for formatting on the websocket frontend.
:return: True
"""
if self.users[peer]["service"] == "telnet":
self.telnet_factory.communicate(peer, msg.encode())
if self.users[peer]["service"] == "websocket":
self.websocket_factory.communicate(peer, html.escape(msg).encode("utf-8"), _nbsp)
[docs] def broadcast_all(self, msg):
"""Broadcast All
Broadcast a message to all logged in users.
:param msg: Message to send.
:return: True
"""
for u in self.users:
if not self.users[u]["console"].user:
continue
if self.users[u]["service"] == "telnet":
self.telnet_factory.communicate(self.users[u]["console"].rname, msg.encode())
if self.users[u]["service"] == "websocket":
self.websocket_factory.communicate(self.users[u]["console"].rname, html.escape(msg).encode("utf-8"))
[docs] def broadcast_room(self, room, msg):
"""Broadcast Room
Broadcast a message to all logged in users in the given room.
:param room: Room ID.
:param msg: Message to send.
:return: True
"""
for u in self.users:
if not self.users[u]["console"].user:
continue
if self.users[u]["console"].user["room"] == room:
if self.users[u]["service"] == "telnet":
self.telnet_factory.communicate(self.users[u]["console"].rname, msg.encode())
if self.users[u]["service"] == "websocket":
self.websocket_factory.communicate(self.users[u]["console"].rname, html.escape(msg).encode("utf-8"))
def init_logger(config):
"""Initialize the Twisted Logger
"""
# Read log options from the server config. At least one logging method is required.
if not config["log"]["stdout"] and not config["log"]["file"]:
# No logging option is set, so force stdout.
config["log"]["stdout"] = True
elif config["log"]["file"]:
# Try to open the log file.
try:
logfile = open(config["log"]["file"], 'a')
except:
# Couldn't open the log file, so warn and fall back to STDOUT.
if config["log"]["level"] in ["warn", "info", "debug"]:
print("[server#warn] could not open log file:", config["log"]["file"])
config["log"]["file"] = None
config["log"]["stdout"] = True
# Make sure the chosen log level is valid. Otherwise force the highest log level.
if config["log"]["level"] not in ["critical", "error", "warn", "info", "debug"]:
print("[server#warn] invalid log level in config, defaulting to \"debug\"")
config["log"]["level"] = "debug"
# Configure the Twisted Logger targets.
# (Thanks to https://stackoverflow.com/a/46651223/213445 and https://stackoverflow.com/a/49111089/213445)
# This part took a while to figure out, so I'm documenting it here in detail.
# The variable "logtargets" is a list of FilteringLogObserver instances.
# Each FilteringLogObserver wraps a textFileLogObserver, and imposes a LogLevelFilterPredicate.
# The textFileLogObserver writes to STDOUT or the file we opened earlier. So, there can be one or two of them.
# The LogLevelFilterPredicate conveys a maximum LogLevel to FilteringLogObserver through the "predicates" argument.
# The "predicates" argument to FilteringLogObserver must be iterable, so we wrap LogLevelFilterPredicate in a list.
# At the end, we pass our "logtargets" list to globalLogBeginner.beginLoggingTo.
# All future Twisted Logger instances will point to both of our log targets.
# We can have multiple instances of Logger, one for each subsystem of Dennis,
# and for each one we give it a single argument, which is the namespace for log lines from that subsystem.
logtargets = []
if config["log"]["stdout"]:
logtargets.append(
FilteringLogObserver(
textFileLogObserver(sys.stdout),
predicates=[LogLevelFilterPredicate(getattr(LogLevel, config["log"]["level"]))]
)
)
if config["log"]["file"]:
logtargets.append(
FilteringLogObserver(
textFileLogObserver(logfile),
predicates=[LogLevelFilterPredicate(getattr(LogLevel, config["log"]["level"]))]
)
)
globalLogBeginner.beginLoggingTo(logtargets)
def init_services(config, dbman, log):
"""Initialize the Telnet and/or WebSocket Services
"""
# We will exit if no services are enabled.
any_enabled = False
# Create the router instance we will use.
router = Router(config, dbman)
# If telnet is enabled, initialize its service.
if config["telnet"]["enabled"]:
telnet_factory = telnet.ServerFactory(router)
reactor.listenTCP(config["telnet"]["port"], telnet_factory)
any_enabled = True
# If websocket is enabled, initialize its service.
if config["websocket"]["enabled"]:
if config["websocket"]["secure"]:
# Use secure websockets. Generally requires HTTPS for the client page. TODO: Fix.
websocket_factory = websocket.ServerFactory(router, "wss://" + config["websocket"]["host"] + ":" +
str(config["websocket"]["port"]))
else:
# Use insecure websockets.
websocket_factory = websocket.ServerFactory(router, "ws://" + config["websocket"]["host"] + ":" +
str(config["websocket"]["port"]))
websocket_factory.protocol = websocket.ServerProtocol
websocket_factory.setProtocolOptions(autoPingInterval=1, autoPingTimeout=3, autoPingSize=20)
reactor.listenTCP(config["websocket"]["port"], websocket_factory)
any_enabled = True
if not any_enabled:
# No services were enabled.
log.critical("no services enabled")
return False
return True
def main():
"""Startup tasks, mainloop entry, and shutdown tasks.
"""
print("Welcome to Dennis MUD PreAlpha, Multi-User Server.")
print("Starting up...")
# Try to read the server config file.
try:
with open("server.config.json") as f:
config = json.load(f)
except:
print("[server#critical] could not open server.config.json")
return 1
# Initialize the logger.
stdout = sys.stdout
if config["log"]["level"] in ["info", "debug"]:
print("[server#info] initializing logger")
init_logger(config)
log = Logger("server")
log.info("finished initializing logger")
# Initialize the Database Manager and load the world database.
log.info("initializing database manager")
dbman = database.DatabaseManager(config["database"]["filename"])
_dbres = dbman._startup()
if not _dbres:
# On failure, only remove the lockfile if its existence wasn't the cause.
if _dbres is not None:
dbman._unlock()
return 1
log.info("finished initializing database manager")
# Start the services.
log.info("initializing services")
if not init_services(config, dbman, log):
dbman._unlock()
return 1
log.info("finished initializing services")
# Start the Twisted Reactor.
log.info("finished startup tasks")
reactor.run()
# Shutting down.
dbman._unlock()
sys.stdout = stdout
print("End Program.")
return 0
if __name__ == "__main__":
sys.exit(main())