502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""
|
|
The Evennia Portal service acts as an AMP-server, handling AMP
|
|
communication to the AMP clients connecting to it (by default
|
|
these are the Evennia Server and the evennia launcher).
|
|
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from subprocess import STDOUT, Popen
|
|
|
|
from django.conf import settings
|
|
from twisted.internet import protocol
|
|
|
|
import evennia
|
|
from evennia.server.portal import amp
|
|
from evennia.utils import logger
|
|
from evennia.utils.utils import class_from_module
|
|
|
|
|
|
def _is_windows():
|
|
return os.name == "nt"
|
|
|
|
|
|
def getenv():
|
|
"""
|
|
Get current environment and add PYTHONPATH.
|
|
|
|
Returns:
|
|
env (dict): Environment global dict.
|
|
|
|
"""
|
|
sep = ";" if _is_windows() else ":"
|
|
env = os.environ.copy()
|
|
env["PYTHONPATH"] = sep.join(sys.path)
|
|
return env
|
|
|
|
|
|
class AMPServerFactory(protocol.ServerFactory):
|
|
"""
|
|
This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the
|
|
'Server' process.
|
|
|
|
"""
|
|
|
|
noisy = False
|
|
|
|
def logPrefix(self):
|
|
"""
|
|
How this is named in logs
|
|
|
|
"""
|
|
return "AMP"
|
|
|
|
def __init__(self, portal):
|
|
"""
|
|
Initialize the factory. This is called as the Portal service starts.
|
|
|
|
Args:
|
|
portal (Portal): The Evennia Portal service instance.
|
|
protocol (Protocol): The protocol the factory creates
|
|
instances of.
|
|
|
|
"""
|
|
self.portal = portal
|
|
self.protocol = class_from_module(settings.AMP_SERVER_PROTOCOL_CLASS)
|
|
self.broadcasts = []
|
|
self.server_connection = None
|
|
self.launcher_connection = None
|
|
self.disconnect_callbacks = {}
|
|
self.server_connect_callbacks = []
|
|
|
|
def buildProtocol(self, addr):
|
|
"""
|
|
Start a new connection, and store it on the service object.
|
|
|
|
Args:
|
|
addr (str): Connection address. Not used.
|
|
|
|
Returns:
|
|
protocol (Protocol): The created protocol.
|
|
|
|
"""
|
|
self.portal.amp_protocol = self.protocol()
|
|
self.portal.amp_protocol.factory = self
|
|
return self.portal.amp_protocol
|
|
|
|
|
|
class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
|
|
"""
|
|
Protocol subclass for the AMP-server run by the Portal.
|
|
|
|
"""
|
|
|
|
def connectionLost(self, reason):
|
|
"""
|
|
Set up a simple callback mechanism to let the amp-server wait for a connection to close.
|
|
|
|
"""
|
|
# wipe broadcast and data memory
|
|
super().connectionLost(reason)
|
|
if self.factory.server_connection == self:
|
|
self.factory.server_connection = None
|
|
self.factory.portal.server_info_dict = {}
|
|
if self.factory.launcher_connection == self:
|
|
self.factory.launcher_connection = None
|
|
|
|
callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None))
|
|
if callback:
|
|
try:
|
|
callback(*args, **kwargs)
|
|
except Exception:
|
|
logger.log_trace()
|
|
|
|
def get_status(self):
|
|
"""
|
|
Return status for the Evennia infrastructure.
|
|
|
|
Returns:
|
|
status (tuple): The portal/server status and pids
|
|
(portal_live, server_live, portal_PID, server_PID).
|
|
|
|
"""
|
|
server_connected = bool(
|
|
self.factory.server_connection and self.factory.server_connection.transport.connected
|
|
)
|
|
portal_info_dict = self.factory.portal.get_info_dict()
|
|
server_info_dict = self.factory.portal.server_info_dict
|
|
server_pid = self.factory.portal.server_process_id
|
|
portal_pid = os.getpid()
|
|
return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict)
|
|
|
|
def data_to_server(self, command, sessid, **kwargs):
|
|
"""
|
|
Send data across the wire to the Server.
|
|
|
|
Args:
|
|
command (AMP Command): A protocol send command.
|
|
sessid (int): A unique Session id.
|
|
kwargs (any): Data to send. This will be pickled.
|
|
|
|
Returns:
|
|
deferred (deferred or None): A deferred with an errback.
|
|
|
|
Notes:
|
|
Data will be sent across the wire pickled as a tuple
|
|
(sessid, kwargs).
|
|
|
|
"""
|
|
# print("portal data_to_server: {}, {}, {}".format(command, sessid, kwargs))
|
|
if self.factory.server_connection:
|
|
return self.factory.server_connection.callRemote(
|
|
command, packed_data=amp.dumps((sessid, kwargs))
|
|
).addErrback(self.errback, command.key)
|
|
else:
|
|
# if no server connection is available, broadcast
|
|
return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs)))
|
|
|
|
def start_server(self, server_twistd_cmd):
|
|
"""
|
|
(Re-)Launch the Evennia server.
|
|
|
|
Args:
|
|
server_twisted_cmd (list): The server start instruction
|
|
to pass to POpen to start the server.
|
|
|
|
"""
|
|
# start the Server
|
|
print("Portal starting server ... ")
|
|
process = None
|
|
with open(settings.SERVER_LOG_FILE, "a") as logfile:
|
|
# we link stdout to a file in order to catch
|
|
# eventual errors happening before the Server has
|
|
# opened its logger.
|
|
try:
|
|
if _is_windows():
|
|
# Windows requires special care
|
|
create_no_window = 0x08000000
|
|
process = Popen(
|
|
server_twistd_cmd,
|
|
env=getenv(),
|
|
bufsize=-1,
|
|
stdout=logfile,
|
|
stderr=STDOUT,
|
|
creationflags=create_no_window,
|
|
)
|
|
|
|
else:
|
|
process = Popen(
|
|
server_twistd_cmd, env=getenv(), bufsize=-1, stdout=logfile, stderr=STDOUT
|
|
)
|
|
except Exception:
|
|
logger.log_trace()
|
|
|
|
self.factory.portal.server_twistd_cmd = server_twistd_cmd
|
|
logfile.flush()
|
|
if process and not _is_windows():
|
|
# avoid zombie-process on Unix/BSD
|
|
process.wait()
|
|
return
|
|
|
|
def wait_for_disconnect(self, callback, *args, **kwargs):
|
|
"""
|
|
Add a callback for when this connection is lost.
|
|
|
|
Args:
|
|
callback (callable): Will be called with *args, **kwargs
|
|
once this protocol is disconnected.
|
|
|
|
"""
|
|
self.factory.disconnect_callbacks[self] = (callback, args, kwargs)
|
|
|
|
def wait_for_server_connect(self, callback, *args, **kwargs):
|
|
"""
|
|
Add a callback for when the Server is sure to have connected.
|
|
|
|
Args:
|
|
callback (callable): Will be called with *args, **kwargs
|
|
once the Server handshake with Portal is complete.
|
|
|
|
"""
|
|
self.factory.server_connect_callbacks.append((callback, args, kwargs))
|
|
|
|
def stop_server(self, mode="shutdown"):
|
|
"""
|
|
Shut down server in one or more modes.
|
|
|
|
Args:
|
|
mode (str): One of 'shutdown', 'reload' or 'reset'.
|
|
|
|
"""
|
|
if mode == "reload":
|
|
self.send_AdminPortal2Server(
|
|
amp.DUMMYSESSION, operation=amp.SRELOAD, server_restart_mode=mode
|
|
)
|
|
elif mode == "reset":
|
|
self.send_AdminPortal2Server(
|
|
amp.DUMMYSESSION, operation=amp.SRESET, server_restart_mode=mode
|
|
)
|
|
elif mode == "shutdown":
|
|
self.send_AdminPortal2Server(
|
|
amp.DUMMYSESSION, operation=amp.SSHUTD, server_restart_mode=mode
|
|
)
|
|
# store the mode for use once server comes back up again
|
|
self.factory.portal.server_restart_mode = mode
|
|
|
|
# sending amp data
|
|
|
|
def send_Status2Launcher(self):
|
|
"""
|
|
Send a status stanza to the launcher.
|
|
|
|
"""
|
|
# print("send status to launcher")
|
|
# print("self.get_status(): {}".format(self.get_status()))
|
|
if self.factory.launcher_connection:
|
|
self.factory.launcher_connection.callRemote(
|
|
amp.MsgStatus, status=amp.dumps(self.get_status())
|
|
).addErrback(self.errback, amp.MsgStatus.key)
|
|
|
|
def send_MsgPortal2Server(self, session, **kwargs):
|
|
"""
|
|
Access method called by the Portal and executed on the Portal.
|
|
|
|
Args:
|
|
session (session): Session
|
|
kwargs (any, optional): Optional data.
|
|
|
|
Returns:
|
|
deferred (Deferred): Asynchronous return.
|
|
|
|
"""
|
|
return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs)
|
|
|
|
def send_AdminPortal2Server(self, session, operation="", **kwargs):
|
|
"""
|
|
Send Admin instructions from the Portal to the Server.
|
|
Executed on the Portal.
|
|
|
|
Args:
|
|
session (Session): Session.
|
|
operation (char, optional): Identifier for the server operation, as defined by the
|
|
global variables in `evennia/server/amp.py`.
|
|
data (str or dict, optional): Data used in the administrative operation.
|
|
|
|
"""
|
|
return self.data_to_server(
|
|
amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs
|
|
)
|
|
|
|
# receive amp data
|
|
|
|
@amp.MsgStatus.responder
|
|
@amp.catch_traceback
|
|
def portal_receive_status(self, status):
|
|
"""
|
|
Returns run-status for the server/portal.
|
|
|
|
Args:
|
|
status (str): Not used.
|
|
Returns:
|
|
status (dict): The status is a tuple
|
|
(portal_running, server_running, portal_pid, server_pid).
|
|
|
|
"""
|
|
# print('Received PSTATUS request')
|
|
return {"status": amp.dumps(self.get_status())}
|
|
|
|
@amp.MsgLauncher2Portal.responder
|
|
@amp.catch_traceback
|
|
def portal_receive_launcher2portal(self, operation, arguments):
|
|
"""
|
|
Receives message arriving from evennia_launcher.
|
|
This method is executed on the Portal.
|
|
|
|
Args:
|
|
operation (str): The action to perform.
|
|
arguments (str): Possible argument to the instruction, or the empty string.
|
|
|
|
Returns:
|
|
result (dict): The result back to the launcher.
|
|
|
|
Notes:
|
|
This is the entrypoint for controlling the entire Evennia system from the evennia
|
|
launcher. It can obviously only accessed when the Portal is already up and running.
|
|
|
|
"""
|
|
# Since the launcher command uses amp.String() we need to convert from byte here.
|
|
operation = str(operation, "utf-8")
|
|
self.factory.launcher_connection = self
|
|
_, server_connected, _, _, _, _ = self.get_status()
|
|
|
|
# logger.log_msg("Evennia Launcher->Portal operation %s:%s received" % (ord(operation), arguments))
|
|
# logger.log_msg("operation == amp.SSTART: {}: {}".format(operation == amp.SSTART, amp.loads(arguments)))
|
|
|
|
if operation == amp.SSTART: # portal start #15
|
|
# first, check if server is already running
|
|
if not server_connected:
|
|
self.wait_for_server_connect(self.send_Status2Launcher)
|
|
self.start_server(amp.loads(arguments))
|
|
|
|
elif operation == amp.SRELOAD: # reload server #14
|
|
if server_connected:
|
|
# We let the launcher restart us once they get the signal
|
|
self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
|
|
self.stop_server(mode="reload")
|
|
else:
|
|
self.wait_for_server_connect(self.send_Status2Launcher)
|
|
self.start_server(amp.loads(arguments))
|
|
|
|
elif operation == amp.SRESET: # reload server #19
|
|
if server_connected:
|
|
self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
|
|
self.stop_server(mode="reset")
|
|
else:
|
|
self.wait_for_server_connect(self.send_Status2Launcher)
|
|
self.start_server(amp.loads(arguments))
|
|
|
|
elif operation == amp.SSHUTD: # server-only shutdown #17
|
|
if server_connected:
|
|
self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher)
|
|
self.stop_server(mode="shutdown")
|
|
|
|
elif operation == amp.PSHUTD: # portal + server shutdown #16
|
|
if server_connected:
|
|
self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown)
|
|
else:
|
|
self.factory.portal.shutdown()
|
|
|
|
else:
|
|
logger.log_err("Operation {} not recognized".format(operation))
|
|
raise Exception("operation %(op)s not recognized." % {"op": operation})
|
|
|
|
return {}
|
|
|
|
@amp.MsgServer2Portal.responder
|
|
@amp.catch_traceback
|
|
def portal_receive_server2portal(self, packed_data):
|
|
"""
|
|
Receives message arriving to Portal from Server.
|
|
This method is executed on the Portal.
|
|
|
|
Args:
|
|
packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
|
|
|
|
"""
|
|
try:
|
|
sessid, kwargs = self.data_in(packed_data)
|
|
session = evennia.PORTAL_SESSION_HANDLER.get(sessid, None)
|
|
if session:
|
|
evennia.PORTAL_SESSION_HANDLER.data_out(session, **kwargs)
|
|
except Exception:
|
|
logger.log_trace("packed_data len {}".format(len(packed_data)))
|
|
return {}
|
|
|
|
@amp.AdminServer2Portal.responder
|
|
@amp.catch_traceback
|
|
def portal_receive_adminserver2portal(self, packed_data):
|
|
"""
|
|
|
|
Receives and handles admin operations sent to the Portal
|
|
This is executed on the Portal.
|
|
|
|
Args:
|
|
packed_data (str): Data received, a pickled tuple (sessid, kwargs).
|
|
|
|
"""
|
|
self.factory.server_connection = self
|
|
|
|
sessid, kwargs = self.data_in(packed_data)
|
|
|
|
operation = kwargs.pop("operation")
|
|
portal_sessionhandler = evennia.PORTAL_SESSION_HANDLER
|
|
|
|
# logger.log_msg(f"Evennia Server->Portal admin data operation {ord(operation)}")
|
|
|
|
if operation == amp.SLOGIN: # server_session_login
|
|
# a session has authenticated; sync it.
|
|
session = portal_sessionhandler.get(sessid)
|
|
if session:
|
|
portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
|
|
|
|
elif operation == amp.SDISCONN: # server_session_disconnect
|
|
# the server is ordering to disconnect the session
|
|
session = portal_sessionhandler.get(sessid)
|
|
if session:
|
|
portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
|
|
|
|
elif operation == amp.SDISCONNALL: # server_session_disconnect_all
|
|
# server orders all sessions to disconnect
|
|
portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
|
|
|
|
elif operation == amp.SRELOAD: # server reload
|
|
# set up callback to restart server once it has disconnected
|
|
self.factory.server_connection.wait_for_disconnect(
|
|
self.start_server, self.factory.portal.server_twistd_cmd
|
|
)
|
|
# tell server to reload
|
|
self.stop_server(mode="reload")
|
|
|
|
elif operation == amp.SRESET: # server reset
|
|
# set up callback to restart server once it has disconnected
|
|
self.factory.server_connection.wait_for_disconnect(
|
|
self.start_server, self.factory.portal.server_twistd_cmd
|
|
)
|
|
# tell server to reset
|
|
self.stop_server(mode="reset")
|
|
|
|
elif operation == amp.SSHUTD: # server-only shutdown
|
|
self.stop_server(mode="shutdown")
|
|
|
|
elif operation == amp.PSHUTD: # full server+server shutdown
|
|
# set up callback to shut down portal once server has disconnected
|
|
self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown)
|
|
# tell server to shut down
|
|
self.stop_server(mode="shutdown")
|
|
|
|
elif operation == amp.PSYNC: # portal sync
|
|
# Server has (re-)connected and wants the session data from portal
|
|
self.factory.portal.server_info_dict = kwargs.get("info_dict", {})
|
|
self.factory.portal.server_process_id = kwargs.get("spid", None)
|
|
# this defaults to 'shutdown' or whatever value set in server_stop
|
|
server_restart_mode = self.factory.portal.server_restart_mode
|
|
# print("Server has connected. Sending session data to Server ... mode: {}".format(server_restart_mode))
|
|
|
|
sessdata = evennia.PORTAL_SESSION_HANDLER.get_all_sync_data()
|
|
self.send_AdminPortal2Server(
|
|
amp.DUMMYSESSION,
|
|
amp.PSYNC,
|
|
server_restart_mode=server_restart_mode,
|
|
sessiondata=sessdata,
|
|
portal_start_time=self.factory.portal.start_time,
|
|
)
|
|
evennia.PORTAL_SESSION_HANDLER.at_server_connection()
|
|
self.factory.portal.server_restart_mode = None
|
|
|
|
if self.factory.server_connection:
|
|
# this is an indication the server has successfully connected, so
|
|
# we trigger any callbacks (usually to tell the launcher server is up)
|
|
for callback, args, kwargs in self.factory.server_connect_callbacks:
|
|
try:
|
|
callback(*args, **kwargs)
|
|
except Exception:
|
|
logger.log_trace()
|
|
self.factory.server_connect_callbacks = []
|
|
|
|
elif operation == amp.SSYNC: # server_session_sync
|
|
# server wants to save session data to the portal,
|
|
# maybe because it's about to shut down.
|
|
portal_sessionhandler.server_session_sync(
|
|
kwargs.get("sessiondata"), kwargs.get("clean", True)
|
|
)
|
|
|
|
# set a flag in case we are about to shut down soon
|
|
self.factory.server_restart_mode = "shutdown"
|
|
|
|
elif operation == amp.SCONN: # server_force_connection (for irc/etc)
|
|
portal_sessionhandler.server_connect(**kwargs)
|
|
|
|
else:
|
|
raise Exception("operation %(op)s not recognized." % {"op": operation})
|
|
return {}
|