diff --git a/.pylintrc b/.pylintrc index 7fb46c0..961a526 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,12 @@ [TYPECHECK] ignored-modules = zmq -max-line-length = 150 +max-line-length = 120 disable = missing-docstring, too-few-public-methods, invalid-name + +[SIMILARITIES] + +min-similarity-lines=7 +ignore-comments=yes +ignore-docstrings=yes +ignore-imports=yes diff --git a/README.md b/README.md index 5b80f06..f5fd5f3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Dependencies: - yarn - Angular CLI - GNU coreutils +- GNU findutils +- GNU sed +- GNU grep Just copy and paste the following command in a terminal: @@ -180,7 +183,7 @@ Refer to the example in this repo. ### src -This folder contains the source code of a server running TFW and an other server running our event handlers. +This folder contains the source code of our pre-written event handlers and example FSMs. Note that this is not a part of the framework by any means, these are just simple examples. ``` @@ -207,7 +210,7 @@ A good state machine is the backbone of a good TFW challenge. There are two ways to define a state machine: - Using a YAML configuration file - - Implementing it in Python by hand + - Implementing it in Python The first option allows you to handle FSM callbacks and custom logic in any programming language (not just Python) and is generally really easy to work with (you can execute arbitrary shell commands on events). You should choose this method unless you have good reason not to. @@ -230,6 +233,8 @@ It is also possible to add preconditions to transitions. This is done by adding a `predicates` key with a list of shell commands to run. If you do this, the transition will only succeed if the return code of all predicates was `0` (as per unix convention for success). +Our `YamlFSM` implementation also supports jinja2 templates inside the `YAML` config file (examples in `test_fsm.yml`). + ## Baby steps When creating your own challenge the process should be the following: diff --git a/controller/Dockerfile b/controller/Dockerfile index ef96d41..6c1afeb 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -2,12 +2,11 @@ FROM avatao/controller:debian-buster USER root ENV PYTHONPATH="/usr/local/lib" \ - TFW_PUBLISHER_PORT=7654 \ - TFW_RECEIVER_PORT=8765 \ + TFW_PUB_PORT=7654 \ + TFW_PULL_PORT=8765 \ TFW_AUTH_KEY="/tmp/tfw-auth.key" \ CONTROLLER_PORT=5555 -RUN pip3 install watchdog transitions COPY ./controller/ / CMD ["python3", "/opt/server.py"] diff --git a/controller/opt/server.py b/controller/opt/server.py index 82a3811..6a1265e 100644 --- a/controller/opt/server.py +++ b/controller/opt/server.py @@ -4,36 +4,35 @@ import json from tornado.ioloop import IOLoop from tornado.web import RequestHandler, Application -from tfw import FSMAwareEventHandler +from tfw.event_handlers import FSMAwareEventHandler +from tfw.main import EventHandlerFactory, setup_signal_handlers class ControllerPostHandler(RequestHandler): - # pylint: disable=abstract-method - def initialize(self, **kwargs): # pylint: disable=arguments-differ + # pylint: disable=abstract-method,attribute-defined-outside-init,unused-argument + def initialize(self, **kwargs): self.controller = kwargs['controller'] def post(self, *args, **kwargs): self.set_header('Content-Type', 'application/json') self.write(json.dumps({ - 'solved': self.controller.in_accepted_state + 'solved': self.controller.fsm_in_accepted_state })) -class ControllerEventHandler(FSMAwareEventHandler): - def handle_event(self, message): - pass - - if __name__ == '__main__': - controller = ControllerEventHandler('controller') + controller_eh = EventHandlerFactory().build( + lambda *_: None, + event_handler_type=FSMAwareEventHandler + ) + + route = os.environ['SECRET'].rstrip('/') application = Application([( - f'/{os.environ["SECRET"]}', + f'/{route}/?', ControllerPostHandler, - {'controller': controller} + {'controller': controller_eh} )]) application.listen(os.environ['CONTROLLER_PORT']) - try: - IOLoop.instance().start() - finally: - controller.cleanup() + setup_signal_handlers() + IOLoop.current().start() diff --git a/hack/bootstrap.sh b/hack/bootstrap.sh index 643a9cc..a6aedfa 100755 --- a/hack/bootstrap.sh +++ b/hack/bootstrap.sh @@ -3,7 +3,7 @@ set -eu set -o pipefail set -o errtrace shopt -s expand_aliases -[ "$(uname)" == "Darwin" ] && alias sed="gsed" || : +[ "$(uname)" == "Darwin" ] && alias sed="gsed" && alias grep="ggrep" || : HERE="$(pwd)" CHALLENGE=${CHALLENGE:-test-tutorial-framework} diff --git a/hack/libhack/challenge.sh b/hack/libhack/challenge.sh index 8a7e558..4d4cd95 100644 --- a/hack/libhack/challenge.sh +++ b/hack/libhack/challenge.sh @@ -38,9 +38,9 @@ challenge::run() { local mount_volumes if [[ "${HOTRELOAD:-0}" == "1" ]]; then if [[ -d "${BASEIMAGE_PATH}" ]]; then - mount_baseimage="-v ${BASEIMAGE_PATH}/lib/tfw:/usr/local/lib/tfw" + mount_baseimage="-e HOTRELOAD=1 -v ${BASEIMAGE_PATH}/tfw:/usr/local/lib/tfw" fi - mount_challenge="-v ${CHALLENGE_PATH}/solvable/src:/srv/.tfw_builtin_ehs" + mount_challenge="-v ${CHALLENGE_PATH}/solvable/src:/.tfw/builtin_event_handlers" mount_volumes="${mount_baseimage:-} ${mount_challenge}" fi popd diff --git a/hack/tfw.sh b/hack/tfw.sh index 46a843b..f05f2b7 100755 --- a/hack/tfw.sh +++ b/hack/tfw.sh @@ -3,7 +3,7 @@ set -eu set -o pipefail set -o errtrace shopt -s expand_aliases -[ "$(uname)" == "Darwin" ] && alias readlink="greadlink" || : +[ "$(uname)" == "Darwin" ] && alias readlink="greadlink" && alias grep="ggrep" || : SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" TFW_PATH="${TFW_PATH:-$SCRIPT_DIR/../..}" diff --git a/solvable/Dockerfile b/solvable/Dockerfile index 7dd51b0..2e82771 100644 --- a/solvable/Dockerfile +++ b/solvable/Dockerfile @@ -1,15 +1,9 @@ FROM eu.gcr.io/avatao-challengestore/tutorial-framework -# Install webservice dependencies -RUN pip3 install Flask==1.0 \ - SQLAlchemy==1.2.7 \ - passlib==1.7.1 \ - git+https://github.com/avatao-content/tfwconnector.git#subdirectory=python3 - # Define variables to use later -ENV TFW_EHMAIN_DIR="/srv/.tfw_builtin_ehs" \ - TFW_WEBSERVICE_DIR="/srv/webservice" \ - TFW_IDE_WD="/home/${AVATAO_USER}/workdir" \ +ENV TFW_EHMAIN_DIR="${TFW_DIR}/builtin_event_handlers" \ + TFW_WEBSERVICE_DIR="/srv/webservice" \ + TFW_IDE_WD="/home/${AVATAO_USER}/workdir" \ TFW_TERMINADO_WD="/home/${AVATAO_USER}/workdir" # Copy TFW related stuff to a dedicated directory @@ -21,12 +15,12 @@ ADD solvable/src/webservice/frontend-deps.tar ${TFW_WEBSERVICE_DIR}/static # Create IDE directory, symlink server source and give proper permissions to AVATAO_USER RUN mkdir -p ${TFW_IDE_WD} &&\ - ln -s ${TFW_WEBSERVICE_DIR}/user_ops.py ${TFW_IDE_WD} &&\ chown -R ${AVATAO_USER}: "${TFW_IDE_WD}" "${TFW_WEBSERVICE_DIR}" &&\ chmod -R 755 "${TFW_IDE_WD}" "${TFW_WEBSERVICE_DIR}" # Hide TFW related code from user -RUN chown -R root:root ${TFW_SERVER_DIR} && chmod -R 700 ${TFW_SERVER_DIR} +RUN chown -R root:root ${TFW_SERVER_DIR} ${TFW_DIR} &&\ + chmod -R 700 ${TFW_SERVER_DIR} ${TFW_DIR} # Make AVATAO_USER's home writeable and set it as WORKDIR # Make webservice directory writable diff --git a/solvable/src/custom_handlers.py b/solvable/src/custom_handlers.py new file mode 100644 index 0000000..4c5c0d3 --- /dev/null +++ b/solvable/src/custom_handlers.py @@ -0,0 +1,45 @@ +import logging +from json import loads, JSONDecodeError + +from tfw.components.frontend import MessageSender +from tfw.components.terminal import TerminalCommandsHandler +from tfw.main import TFWUplinkConnector + +LOG = logging.getLogger(__name__) + + +class CenatorHandler: + keys = ['history.bash'] + + def handle_event(self, message, connector): # pylint: disable=no-self-use + command = message['command'] + LOG.debug('User executed command: "%s"', command) + MessageSender(connector).send(f'You\'ve executed "{command}"', originator='JOHN CENA') + + +class TestCommandsHandler(TerminalCommandsHandler): + # pylint: disable=unused-argument,attribute-defined-outside-init,no-self-use + def command_sendmessage(self, *args): + if not args: + message_template = """'{"key": ""}'""" + TFWUplinkConnector().send_message({ + 'key': 'terminal.write', + 'command': f'sendmessage {message_template}' + }) + else: + try: + TFWUplinkConnector().send_message(loads(args[0])) + except JSONDecodeError: + LOG.error('IGNORING MESSAGE: Invalid message received: %s', args[0]) + + +def messageFSMStepsHandler(message, connector): + """ + When the FSM steps this method is invoked. + Receives a 'data' field from an fsm_update message as kwargs. + """ + MessageSender(connector).send( + f'FSM has stepped from state "{message["last_event"]["from_state"]}" ' + f'to state "{message["current_state"]}" in response to trigger "{message["last_event"]["trigger"]}"', + originator='FSM info' + ) diff --git a/solvable/src/event_handler_main.py b/solvable/src/event_handler_main.py index a4f231a..bca9455 100644 --- a/solvable/src/event_handler_main.py +++ b/solvable/src/event_handler_main.py @@ -1,148 +1,94 @@ -from ast import literal_eval +import logging +from os.path import dirname, realpath, join from functools import partial -from signal import signal, SIGTERM, SIGINT from tornado.ioloop import IOLoop -from tfw import YamlFSM, FSMAwareEventHandler, EventHandlerBase -from tfw.components import IdeEventHandler, TerminalEventHandler -from tfw.components import ProcessManagingEventHandler, BashMonitor -from tfw.components import TerminalCommands, LogMonitoringEventHandler -from tfw.components import FSMManagingEventHandler -from tfw.networking import MessageSender, TFWServerConnector -from tfw.config import TFWENV -from tfw.config.logs import logging -from tao.config import TAOENV +from tfw.fsm import YamlFSM +from tfw.event_handlers import FSMAwareEventHandler +from tfw.components.ide import IdeHandler, DeployHandler +from tfw.components.terminal import TerminalHandler +from tfw.components.frontend import FrontendProxyHandler, FrontendReadyHandler +from tfw.components.frontend import ConsoleLogsHandler, MessageQueueHandler, FrontendConfigHandler +from tfw.components.process_management import ProcessHandler, ProcessLogHandler +from tfw.components.fsm import FSMHandler +from tfw.main import EventHandlerFactory, setup_logger, setup_signal_handlers +from tfw.config import TFWENV, TAOENV + +from custom_handlers import CenatorHandler, TestCommandsHandler, messageFSMStepsHandler LOG = logging.getLogger(__name__) +here = dirname(realpath(__file__)) -def cenator(history): - """ - Logs commands executed in terminal to messages. - !! Please remove from production code. !! - """ - LOG.debug('User executed command: "%s"', history[-1]) - MessageSender().send('JOHN CENA', f'You\'ve executed "{history[-1]}"') +def main(): + # pylint: disable=unused-variable,too-many-locals + setup_logger(__file__) -class TestCommands(TerminalCommands): - """ - Some example commands useful for debugging. - !! Please remove from production code !! and inherit your own - class from TerminalCommands if you need to define custom - commands in your challenge. - """ - # pylint: disable=unused-argument, attribute-defined-outside-init, no-self-use - def command_sendmessage(self, *args): - """ - Insert TFW message template as first argument if executed without args. - - Evaluate first argumen as a dict and send it to the frontend. - This is useful for playing around with frontend APIs. - """ - if not args: - message_template = """'{"key": "", "data": {"command": ""}}'""" - TFWServerConnector().send_to_eventhandler({ - 'key': 'shell', - 'data': { - 'command': 'write', - 'value': f'sendmessage {message_template}' - } - }) - else: - TFWServerConnector().send(literal_eval(args[0])) - - def command_seppuku_tfw(self, *args): - """ - Restart tfw_server.py and event_handler_main.py. - This can speed up development when combined with mounting - volumes from host to container. - """ - seppuku = ( - 'nohup sh -c "supervisorctl restart tfwserver event_handler_main" &> /dev/null & ' - 'clear && echo "Committed seppuku! :)" && sleep infinity' + eh_factory = EventHandlerFactory() + # TFW builtin EventHandlers (required for their respective functionalities) + # TFW FSM + fsm_eh = eh_factory.build(FSMHandler( + fsm_type=partial( + YamlFSM, + 'test_fsm.yml', + {} # jinja2 variables, empty dict enables jinja2 without any variables ) - uplink = TFWServerConnector() - uplink.send_to_eventhandler({ - 'key': 'shell', - 'data': { - 'command': 'write', - 'value': f'{seppuku}\n' - } - }) - uplink.send({ - 'key': 'dashboard', - 'data': { - 'command': 'reloadFrontend' - } - }) + )) + # Web IDE backend + ide_eh = eh_factory.build(IdeHandler( + patterns=['/home/user/workdir/*', '/srv/webservice/user_ops.py'] + )) + deploy_eh = eh_factory.build(DeployHandler()) + # Web shell backend + terminal_eh = eh_factory.build(TerminalHandler( + port=TFWENV.TERMINADO_PORT, + user=TAOENV.USER, + working_directory=TFWENV.TERMINADO_WD, + histfile=TFWENV.HISTFILE + )) + # Handles 'deploy' button clicks + process_eh = eh_factory.build(ProcessHandler( + supervisor_uri=TFWENV.SUPERVISOR_HTTP_URI + )) + # Sends live logs of webservice process to frontend + processlog_eh = eh_factory.build(ProcessLogHandler( + process_name='webservice', + supervisor_uri=TFWENV.SUPERVISOR_HTTP_URI, + log_tail=2000 + )) + # Proxies frontend API calls to frontend + frontendproxy_eh = eh_factory.build(FrontendProxyHandler()) + # Initiates first FSM step + frontendready = FrontendReadyHandler('step_1') + frontendready_eh = eh_factory.build(frontendready) + frontendready.stop = frontendready_eh.stop + # Configures frontend + frontendconfig_eh = eh_factory.build( + FrontendConfigHandler(join(here, 'frontend_config.yaml')) + ) + # Manages message queues + messagequeue_eh = eh_factory.build(MessageQueueHandler(25)) + # Writes live logs to console on frontend + console_logs_eh = eh_factory.build(ConsoleLogsHandler(stream='stdout')) + # Replace these with your custom event handlers + # Echoes executed commands to messages + cenator_eh = eh_factory.build(CenatorHandler()) + # Echoes FSM steps + message_fsm_steps_eh = eh_factory.build( + messageFSMStepsHandler, + event_handler_type=FSMAwareEventHandler + ) + # Catches special commands + commands_eh = eh_factory.build(TestCommandsHandler( + bashrc=f'/home/{TAOENV.USER}/.bashrc' + )) -class MessageFSMStepsEventHandler(FSMAwareEventHandler): - """ - This example EventHandler is capable of detecting FSM state. - !! Please remove from production code !! - """ - def handle_event(self, message): - pass - - def handle_fsm_step(self, from_state, to_state, trigger): - """ - When the FSM steps this method is invoked. - """ - MessageSender().send( - 'FSM info', - f'FSM has stepped from state "{from_state}" ' - f'to state "{to_state}" in response to trigger "{trigger}"' - ) + setup_signal_handlers() + IOLoop.current().start() if __name__ == '__main__': - # TFW component EventHandlers (builtins, required for their respective functionalities) - fsm = FSMManagingEventHandler( # TFW FSM - key='fsm', - fsm_type=partial(YamlFSM, 'test_fsm.yml') - ) - ide = IdeEventHandler( # Web IDE backend - key='ide', - allowed_directories=[TFWENV.IDE_WD, TFWENV.WEBSERVICE_DIR], - directory=TFWENV.IDE_WD, - exclude=['*.pyc'] - ) - terminal = TerminalEventHandler( # Web shell backend - key='shell', - monitor=BashMonitor(TFWENV.HISTFILE) - ) - processmanager = ProcessManagingEventHandler( # Handles 'deploy' button clicks - key='processmanager', - dirmonitor=ide.monitor, - log_tail=2000 - ) - logmonitor = LogMonitoringEventHandler( # Sends live logs of webservice process to frontend - key='logmonitor', - process_name='webservice', - log_tail=2000 - ) - - # Your custom event handlers - message_fsm_steps = MessageFSMStepsEventHandler( - key='test' - ) - - # Terminal command handlers - commands = TestCommands(bashrc=f'/home/{TAOENV.USER}/.bashrc') - terminal.historymonitor.subscribe_callback(commands.callback) - - # Example terminal command callback - terminal.historymonitor.subscribe_callback(cenator) - - event_handlers = EventHandlerBase.get_local_instances() - def cleanup(sig, frame): - for eh in event_handlers: - eh.cleanup() - exit(0) - signal(SIGTERM, cleanup) - signal(SIGINT, cleanup) - - IOLoop.instance().start() + main() diff --git a/solvable/src/frontend_config.yaml b/solvable/src/frontend_config.yaml new file mode 100644 index 0000000..f927833 --- /dev/null +++ b/solvable/src/frontend_config.yaml @@ -0,0 +1,27 @@ +dashboard: + layout: terminal-ide-web + hideMessages: false + iframeUrl: /webservice + showUrlBar: false + terminalMenuItem: terminal + reloadIframeOnDeploy: false + enabledLayouts: + - terminal-ide-web + - terminal-ide-vertical + - terminal-web + - ide-web-vertical + - terminal-ide-horizontal + - terminal-only + - ide-only + - web-only +ide: + autoSaveInterval: 444 + showDeployButton: true + deployButtonText: + TODEPLOY: Deploy + DEPLOYED: Deployed + DEPLOYING: Reloading app... + FAILED: Deployment failed +site: + askReloadSite: false + documentTitle: Avatao Tutorials diff --git a/solvable/src/pipe_io_auxlib.py b/solvable/src/pipe_io_auxlib.py deleted file mode 100644 index a06ce06..0000000 --- a/solvable/src/pipe_io_auxlib.py +++ /dev/null @@ -1,235 +0,0 @@ -from json import dumps, loads - -from tfw.crypto import KeyManager, sign_message, verify_message -from tfw.components import PipeIOEventHandlerBase -from tfw.components.pipe_io_event_handler import DEFAULT_PERMISSIONS - - -class SignMessagePipeIOEventHandler(PipeIOEventHandlerBase): - """ - Signs a valid TFW message with HMAC. - Note that the running process needs root permissions in order to read - the authentication key. - When forwarding is true, it will send the signed message to the TFW - server before writing it into the output pipe. - """ - - def __init__( - self, in_pipe_path, out_pipe_path, - permissions=DEFAULT_PERMISSIONS, - forwarding=True - ): - self.forwarding = forwarding - self.auth_key = KeyManager().auth_key - super().__init__(None, in_pipe_path, out_pipe_path, permissions) - - def handle_event(self, message): - pass - - def handle_pipe_event(self, message_bytes): - message = loads(message_bytes) - sign_message(self.auth_key, message) - if self.forwarding: - self.server_connector.send(message) - self.pipe_io.send_message(dumps(message).encode()) - - -class VerifyMessagePipeIOEventHandler(PipeIOEventHandlerBase): - """ - Verifies a signed TFW message. - This pipe also needs root permissions. Send the serialized JSON object - to the pipe, then wait for its boolean response. - """ - - def __init__(self, in_pipe_path, out_pipe_path, permissions=DEFAULT_PERMISSIONS): - self.auth_key = KeyManager().auth_key - super().__init__(None, in_pipe_path, out_pipe_path, permissions) - - def handle_event(self, message): - pass - - def handle_pipe_event(self, message_bytes): - message = loads(message_bytes) - validity = verify_message(self.auth_key, message) - self.pipe_io.send_message(str(validity).lower().encode()) - - -class BotPipeIOEventHandler(PipeIOEventHandlerBase): - """ - Sends bot messages to the frontend. - If you assign @originator, it will be the default message sender. - When you write a line to the pipe, it will be considered as a single - message and gets appended to the queue until an empty line is received, - which triggers forwarding the messages to the TFW server. - """ - - def __init__( - self, in_pipe_path, out_pipe_path, permissions=DEFAULT_PERMISSIONS, - originator='avataobot' - ): - self.queue = [] - self.originator = originator - super().__init__(None, in_pipe_path, out_pipe_path, permissions) - - def handle_event(self, message): - pass - - def handle_pipe_event(self, message_bytes): - if message_bytes == b"": - if self.queue: - self.server_connector.send({ - 'key': 'queueMessages', - 'data': { - 'messages': self.queue - } - }) - self.queue = [] - else: - self.queue.append({ - 'originator': self.originator, - 'message': message_bytes.decode().replace('\\n', '\n') - }) - - -class DeployPipeIOEventHandler(PipeIOEventHandlerBase): - """ - Manages deployment in the IDE. - When you receive "deploy", then you have to answer with a "true" or - "false" depending whether you are satisfied with the result or not. - The @process parameter is the name of the supervised service. - """ - - # pylint: disable=too-many-arguments - def __init__( - self, in_pipe_path, out_pipe_path, process, - permissions=DEFAULT_PERMISSIONS - ): - self.expected = False - self.process = process - - self.onsuccess = { - 'key': 'processmanager', - 'data': { - 'process_name': process, - 'command': 'restart' - } - } - self.onerror = { - 'key': 'processmanager', - 'data': { - 'process_name': process, - 'error': True - } - } - - super().__init__('processmanager', in_pipe_path, out_pipe_path, permissions) - - def handle_event(self, message): - if message == self.onsuccess: - self.expected = True - self.pipe_io.send_message(b'deploy') - - def handle_pipe_event(self, message_bytes): - if not self.expected: - raise ValueError( - f'{self.pipe_io.in_pipe}: There is nothing to deploy.' - ) - - self.expected = False - if message_bytes == b'true': - self.server_connector.send(self.onsuccess) - elif message_bytes == b'false': - self.server_connector.send(self.onerror) - else: - raise ValueError( - f'{self.pipe_io.in_pipe}: Expected "true" or "false".' - ) - - -class IdePipeIOEventHandler(PipeIOEventHandlerBase): - """ - Manipulates the content of the IDE. - You can observe a file, and when the user edits it, you will receive - the new contents where newlines are escaped as "\\n". - In order to overwrite the file, send an escaped text back to the pipe. - Since the pipe doesn't know if the file is selected initially in the IDE, - you have to provide this information by yourself with @selected, - but it will track it later on. - """ - - # pylint: disable=too-many-arguments - def __init__( - self, in_pipe_path, out_pipe_path, filename, - permissions=DEFAULT_PERMISSIONS, - selected=True - ): - self.selected = selected - self.filename = filename - super().__init__('ide', in_pipe_path, out_pipe_path, permissions) - - def handle_event(self, message): - data = message['data'] - - if data['command'] == 'select': - self.selected = data['filename'] == self.filename - elif data['command'] == 'write' and self.selected: - clean = data['content'].replace('\n', '\\n') - self.pipe_io.send_message(clean.encode()) - - def handle_pipe_event(self, message_bytes): - if not self.selected: - self.server_connector.send({ - 'key': 'mirror', - 'data': { - 'key': 'ide', - 'data': { - 'command': 'select', - 'filename': self.filename - } - } - }) - - self.server_connector.send({ - 'key': 'mirror', - 'data': { - 'key': 'ide', - 'data': { - 'command': 'write', - 'content': message_bytes.decode().replace('\\n', '\n') - } - } - }) - self.server_connector.send({ - 'key': 'mirror', - 'data': { - 'key': 'ide', - 'data': { - 'command': 'read' - } - } - }) - - -class FSMPipeIOEventHandler(PipeIOEventHandlerBase): - """ - Handles FSM steps. - When the FSM enters the next state, you will receive a line containing - its name. To trigger a state change, send the name of the transition to - the pipe. - """ - - def __init__(self, in_pipe_path, out_pipe_path, permissions=DEFAULT_PERMISSIONS): - super().__init__( - ['fsm', 'fsm_update'], - in_pipe_path, out_pipe_path, permissions - ) - - def handle_event(self, message): - if 'current_state' in message['data']: - self.pipe_io.send_message(message['data']['current_state'].encode()) - - def handle_pipe_event(self, message_bytes): - self.server_connector.send({ - 'key': '', - 'trigger': message_bytes.decode() - }) diff --git a/solvable/src/pipe_io_main.py b/solvable/src/pipe_io_main.py index 056b152..5b3bfeb 100644 --- a/solvable/src/pipe_io_main.py +++ b/solvable/src/pipe_io_main.py @@ -1,77 +1,30 @@ -from signal import signal, SIGTERM, SIGINT +import logging from tornado.ioloop import IOLoop -from tfw import EventHandlerBase -from tfw.components import PipeIOEventHandler +from tfw.config import TFWENV +from tfw.components.pipe_io import PipeIOHandler, ProxyPipeConnectorHandler +from tfw.main import EventHandlerFactory, setup_logger, setup_signal_handlers -from pipe_io_auxlib import ( - SignMessagePipeIOEventHandler, VerifyMessagePipeIOEventHandler, - BotPipeIOEventHandler, - DeployPipeIOEventHandler, IdePipeIOEventHandler, - FSMPipeIOEventHandler -) +LOG = logging.getLogger(__name__) + + +def main(): + # pylint: disable=unused-variable + setup_logger(__file__) + + eh_factory = EventHandlerFactory() + + json_pipe_eh = eh_factory.build(PipeIOHandler( + '/tmp/tfw_send', + '/tmp/tfw_recv', + permissions=0o666 + )) + proxy_pipe_eh = eh_factory.build(ProxyPipeConnectorHandler(TFWENV.PIPES_DIR)) + + setup_signal_handlers() + IOLoop.current().start() if __name__ == '__main__': - """ - Creates general purpose pipes. - The first parameter associates the receiving pipe with a key, which is - an empty string in this case. It has a special meaning, you can - subscribe to every kind of message with this key. - If you wish to filter incoming data, specify a single or more keys in - a list, eg.: processmanager, ide, key... - You can send/receive JSON messages to/from the TFW server as any user, - because we gave read+write permissions, without that parameter, only - the owner has access to the pipes. - """ - json_pipe = PipeIOEventHandler( - '', - '/tmp/tfw_json_send', - '/tmp/tfw_json_recv', - permissions=0o666 - ) - - sign_pipe = SignMessagePipeIOEventHandler( - '/tmp/tfw_sign_send', - '/tmp/tfw_sign_recv', - forwarding=True - ) - - verify_pipe = VerifyMessagePipeIOEventHandler( - '/tmp/tfw_verify_send', - '/tmp/tfw_verify_recv' - ) - - bot_pipe = BotPipeIOEventHandler( - '/tmp/tfw_bot_send', - '/tmp/tfw_bot_recv' - ) - - deploy_pipe = DeployPipeIOEventHandler( - '/tmp/tfw_deploy_send', - '/tmp/tfw_deploy_recv', - 'webservice' - ) - - ide_pipe = IdePipeIOEventHandler( - '/tmp/tfw_ide_send', - '/tmp/tfw_ide_recv', - 'user_ops.py', - selected=True - ) - - fsm_pipe = FSMPipeIOEventHandler( - '/tmp/tfw_fsm_send', - '/tmp/tfw_fsm_recv' - ) - - event_handlers = EventHandlerBase.get_local_instances() - def cleanup(sig, frame): - for eh in event_handlers: - eh.cleanup() - exit(0) - signal(SIGTERM, cleanup) - signal(SIGINT, cleanup) - - IOLoop.instance().start() + main() diff --git a/solvable/src/test_fsm.py b/solvable/src/test_fsm.py index 3dcf7c3..0d41590 100644 --- a/solvable/src/test_fsm.py +++ b/solvable/src/test_fsm.py @@ -2,8 +2,9 @@ from os.path import exists -from tfw import LinearFSM -from tfw.networking import MessageSender +from tfw.fsm import LinearFSM +from tfw.components.frontend import MessageSender +from tfw.main import TFWUplinkConnector class TestFSM(LinearFSM): @@ -11,7 +12,8 @@ class TestFSM(LinearFSM): def __init__(self): super().__init__(6) - self.message_sender = MessageSender() + self.uplink = TFWUplinkConnector() + self.message_sender = MessageSender(self.uplink) self.subscribe_predicate('step_3', self.step_3_allowed) @staticmethod diff --git a/solvable/src/test_fsm.yml b/solvable/src/test_fsm.yml index b195479..793048d 100644 --- a/solvable/src/test_fsm.yml +++ b/solvable/src/test_fsm.yml @@ -4,25 +4,21 @@ states: - name: '0' - name: '1' on_enter: | - python3 -c "from tfwconnector import MessageSender; MessageSender().send('FSM', 'Entered state 1!')" + printf "Entered state 1!\n\n" > /tmp/tfw_bot_send - name: '2' on_enter: | file=/home/user/workdir/cat.txt echo "As you can see it is possible to execute arbitrary shell commands here." >> $file - python3 -c \ - " - from tfwconnector import MessageSender - MessageSender().send('FSM', 'Entered state 2! Written stuff to $file') - " + printf "Entered state 2! Written stuff to $file\n\n" > /tmp/tfw_bot_send - name: '3' on_enter: | - python3 -c "from tfwconnector import MessageSender; MessageSender().send('FSM', 'Entered state 3!')" + printf "Entered state 3!\n\n" > /tmp/tfw_bot_send - name: '4' on_enter: | - python3 -c "from tfwconnector import MessageSender; MessageSender().send('FSM', 'Entered state 4!')" + printf "Entered state 4!\n\n" > /tmp/tfw_bot_send - name: '5' on_enter: | - python3 -c "from tfwconnector import MessageSender; MessageSender().send('FSM', 'Entered state 5!')" + printf "Entered state 5!\n\n" > /tmp/tfw_bot_send transitions: - trigger: step_1 source: '0' @@ -41,3 +37,8 @@ transitions: - trigger: step_5 source: '4' dest: '5' + {% for i in range(5) %} # you can also use jinja2 in this config file + - trigger: 'step_next' + source: '{{i}}' + dest: '{{i+1}}' + {% endfor %} diff --git a/solvable/src/webservice/crypto.py b/solvable/src/webservice/crypto.py index 057bb57..8bd034b 100644 --- a/solvable/src/webservice/crypto.py +++ b/solvable/src/webservice/crypto.py @@ -1,11 +1,24 @@ -from passlib.hash import pbkdf2_sha256 +from os import urandom +from hashlib import scrypt class PasswordHasher: - @staticmethod - def hash(password): - return pbkdf2_sha256.hash(password) + n = 16384 + r = 8 + p = 1 + dklen = 32 - @staticmethod - def verify(password, hashdigest): - return pbkdf2_sha256.verify(password, hashdigest) + @classmethod + def hash(cls, password): + salt = urandom(32) + return cls.scrypt(password, salt).hex() + salt.hex() + + @classmethod + def verify(cls, password, salted_hash): + salt = bytes.fromhex(salted_hash)[32:] + hashdigest = bytes.fromhex(salted_hash)[:32] + return cls.scrypt(password, salt) == hashdigest + + @classmethod + def scrypt(cls, password, salt): + return scrypt(password.encode(), salt=salt, n=cls.n, r=cls.r, p=cls.p, dklen=cls.dklen) diff --git a/solvable/src/webservice/model.py b/solvable/src/webservice/model.py index d722cdc..b084b1e 100644 --- a/solvable/src/webservice/model.py +++ b/solvable/src/webservice/model.py @@ -13,18 +13,29 @@ session_factory = sessionmaker( ) -@contextmanager -def Session(factory=session_factory): - session = factory() - try: - yield session - session.commit() - except: - session.rollback() - raise - # session is closed by flask - # finally: - # session.close() +class SessionWrapper: + def __init__(self): + self._session_factory = session_factory + self._session_handle = None + + @contextmanager + def session(self): + try: + yield self._session + self._session.commit() + except: + self._session.rollback() + raise + + @property + def _session(self): + if self._session_handle is None: + self._session_handle = self._session_factory() + return self._session_handle + + def teardown(self): + if self._session_handle is not None: + self._session_handle.close() Base = declarative_base() diff --git a/solvable/src/webservice/pipe_io.py b/solvable/src/webservice/pipe_io.py new file mode 100644 index 0000000..cac23b4 --- /dev/null +++ b/solvable/src/webservice/pipe_io.py @@ -0,0 +1,69 @@ +from typing import Callable + + +class PipeReader: + def __init__(self, pipe_path): + self._pipe = open(pipe_path, 'rb') + self._message_handler = lambda msg: None + + def __enter__(self): + return self + + def __exit__(self, type_, value, traceback): + self.close() + + def close(self): + self._pipe.close() + + @property + def message_handler(self): + return self._message_handler + + @message_handler.setter + def message_handler(self, value): + if not isinstance(value, Callable): + raise ValueError("message_handler must be callable!") + self._message_handler = value + + def run(self): + msg = self.recv_message() + while msg: + self._message_handler(msg) + msg = self.recv_message() + + def recv_message(self): + return self._pipe.readline()[:-1] + + +class PipeWriter: + def __init__(self, pipe_path): + self._pipe = open(pipe_path, 'wb') + + def __enter__(self): + return self + + def __exit__(self, type_, value, traceback): + self.close() + + def close(self): + self._pipe.close() + + def send_message(self, message): + self._pipe.write(message + b'\n') + self._pipe.flush() + + +class PipeIO: + def __init__(self, in_pipe_path, out_pipe_path): + self.reader = PipeReader(in_pipe_path) + self.writer = PipeWriter(out_pipe_path) + + def __enter__(self): + return self + + def __exit__(self, type_, value, traceback): + self.close() + + def close(self): + self.reader.close() + self.writer.close() diff --git a/solvable/src/webservice/server.py b/solvable/src/webservice/server.py index c359c61..1b36762 100644 --- a/solvable/src/webservice/server.py +++ b/solvable/src/webservice/server.py @@ -1,9 +1,8 @@ from os import urandom, getenv -from functools import partial from flask import Flask, render_template, request, session, url_for, g -from model import init_db, session_factory, Session +from model import init_db, SessionWrapper from user_ops import UserOps from errors import InvalidCredentialsError, UserExistsError @@ -16,25 +15,24 @@ app.jinja_env.globals.update( # pylint: disable=no-member ) -def get_db_session(): - if not hasattr(g, 'db_session'): - g.db_session = session_factory() - return g.db_session - -Session = partial(Session, get_db_session) +@app.before_request +def setup_db(): + # pylint: disable=protected-access + g._db_session_wrapper = SessionWrapper() + g.db_session = g._db_session_wrapper.session @app.teardown_appcontext -def close_db_session(err): # pylint: disable=unused-argument - if hasattr(g, 'db_session'): - g.db_session.close() +def close_db_session(_): + # pylint: disable=protected-access + g._db_session_wrapper.teardown() @app.route('/', methods=['GET', 'POST']) def index(): if request.method == 'POST': try: - with Session() as db_session: + with g.db_session() as db_session: UserOps( request.form.get('username'), request.form.get('password'), @@ -67,7 +65,7 @@ def register(): return render_template('register.html', alert='Passwords do not match! Please try again.') try: - with Session() as db_session: + with g.db_session() as db_session: UserOps( request.form.get('username'), request.form.get('password'), diff --git a/solvable/src/webservice/user_ops.py b/solvable/src/webservice/user_ops.py index 84831c1..66855e8 100644 --- a/solvable/src/webservice/user_ops.py +++ b/solvable/src/webservice/user_ops.py @@ -1,7 +1,4 @@ -from functools import partial - -from tfwconnector import MessageSender - +from pipe_io import PipeWriter from crypto import PasswordHasher from model import User from errors import InvalidCredentialsError, UserExistsError @@ -12,11 +9,8 @@ class UserOps: self.username = username self.password = password self.db_session = db_session - self.message_sender = MessageSender() - self.log = partial( - self.message_sender.send, - 'Authenticator' - ) + self.pipe = PipeWriter('/tmp/tfw_bot_send') + self.log = lambda message: self.pipe.send_message(message.encode()+b"\n") def authenticate(self): """ diff --git a/solvable/supervisor/event_handler_main.conf b/solvable/supervisor/event_handler_main.conf index 021e13c..60112be 100644 --- a/solvable/supervisor/event_handler_main.conf +++ b/solvable/supervisor/event_handler_main.conf @@ -1,4 +1,7 @@ [program:event_handler_main] user=root directory=%(ENV_TFW_EHMAIN_DIR)s -command=python3 event_handler_main.py +command=python3 -u event_handler_main.py + +[supervisord] +strip_ansi=false diff --git a/solvable/supervisor/pipe_io_main.conf b/solvable/supervisor/pipe_io_main.conf index bdf8a1a..6da22c2 100644 --- a/solvable/supervisor/pipe_io_main.conf +++ b/solvable/supervisor/pipe_io_main.conf @@ -1,4 +1,4 @@ [program:pipe_io_main] user=root directory=%(ENV_TFW_EHMAIN_DIR)s -command=python3 pipe_io_main.py +command=python3 -u pipe_io_main.py