aboutsummaryrefslogtreecommitdiffstats
path: root/fg21sim/webui/handlers/websocket.py
blob: 577d31b24ae565cb66340bbbfc5bfb5677f6ba84 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# Copyright (c) 2016 Weitian LI <liweitianux@live.com>
# MIT license

"""
Communicate with the "fg21sim" simulation program through the Web UI using
the WebSocket_ protocol, which provides full-duplex communication channels
over a single TCP connection.

.. _WebSocket: https://en.wikipedia.org/wiki/WebSocket


References
----------
- Tornado WebSocket:
  http://www.tornadoweb.org/en/stable/websocket.html
- Can I Use: WebSocket:
  http://caniuse.com/#feat=websockets
"""

import json
import logging

import tornado.websocket
from tornado.options import options

from .console import ConsoleHandler
from .configs import ConfigsHandler
from ..utils import get_host_ip, ip_in_network


logger = logging.getLogger(__name__)


class WSHandler(tornado.websocket.WebSocketHandler):
    """
    WebSocket for bi-directional communication between the Web UI and
    the server, which can deal with the configurations and execute the
    simulation task.

    Generally, WebSocket send and receive data as *string*.  Therefore,
    the more complex data are stringified as JSON string before sending,
    which will be parsed after receive.

    Each message (as a JSON object or Python dictionary) has a ``type``
    field which will be used to determine the following action to take.

    Attributes
    ----------
    name : str
        Name to distinguish this WebSocket handle.
    from_localhost : bool
        Set to ``True`` if the access is from the localhost,
        otherwise ``False``.
    configs : `~ConfigManager`
        A ``ConfigManager`` instance, for configuration manipulations when
        communicating with the Web UI.
    """
    name = "fg21sim"
    from_localhost = None

    def check_origin(self, origin):
        """
        Check the origin of the WebSocket connection to determine whether
        the access is allowed.

        Attributes
        ----------
        from_localhost : bool
            Set to ``True`` if the access is from the "localhost" (i.e.,
            127.0.0.1), otherwise ``False``.
        """
        self.from_localhost = False
        logger.info("WebSocket: {0}: origin: {1}".format(self.name, origin))
        ip = get_host_ip(url=origin)
        network = options.hosts_allowed
        if ip == "127.0.0.1":
            self.from_localhost = True
            allow = True
            logger.info("WebSocket: %s: origin is localhost" % self.name)
        elif network.upper() == "ANY":
            # Any hosts are allowed
            allow = True
            logger.warning("WebSocket: %s: any hosts are allowed" % self.name)
        elif ip_in_network(ip, network):
            allow = True
            logger.info("WebSocket: %s: " % self.name +
                        "client is in the allowed network: %s" % network)
        else:
            allow = False
            logger.error("WebSocket: %s: " % self.name +
                         "client is NOT in the allowed network: %s" % network)
        return allow

    def open(self):
        """Invoked when a new WebSocket is opened by the client."""
        # Add to the set of current connected clients
        self.application.ws_clients.add(self)
        logger.info("Added new WebSocket client: {0}".format(self))
        # FIXME:
        # * better to move to the `Application` class ??
        self.configs = self.application.configmanager
        self.console_handler = ConsoleHandler(websocket=self)
        self.configs_handler = ConfigsHandler(configs=self.configs)
        #
        logger.info("WebSocket: {0}: opened".format(self.name))
        logger.info("Allowed hosts: {0}".format(options.hosts_allowed))
        # Push current configurations to the client
        self._push_configs()

    def on_close(self):
        """Invoked when a new WebSocket is closed by the client."""
        # Remove from the set of current connected clients
        self.application.ws_clients.remove(self)
        logger.warning("Removed WebSocket client: {0}".format(self))
        code, reason = None, None
        if hasattr(self, "close_code"):
            code = self.close_code
        if hasattr(self, "close_reason"):
            reason = self.close_reason
        logger.warning("WebSocket: {0}: closed by client: {1}, {2}".format(
            self.name, code, reason))

    # FIXME/XXX:
    # * How to be non-blocking ??
    # NOTE: WebSocket.on_message: may NOT be a coroutine at the moment (v4.3)
    # References:
    # [1] https://stackoverflow.com/a/35543856/4856091
    # [2] https://stackoverflow.com/a/33724486/4856091
    def on_message(self, message):
        """
        Handle incoming messages and dispatch task according to the
        message type.

        NOTE
        ----
        The received message (parsed to a Python dictionary) has a ``type``
        item which will be used to determine the following action to take.

        Currently supported message types are:
        ``configs``:
            Request or set the configurations
        ``console``:
            Control the simulation tasks, or request logging messages
        ``results``:
            Request the simulation results

        The sent message also has a ``type`` item of same value, which the
        client can be used to figure out the proper actions.
        There is a ``success`` item which indicates the status of the
        requested operation, and an ``error`` recording the error message
        if ``success=False``.
        """
        logger.debug("WebSocket: %s: received: %s" % (self.name, message))
        try:
            msg = json.loads(message)
            msg_type = msg["type"]
        except json.JSONDecodeError:
            logger.warning("WebSocket: {0}: ".format(self.name) +
                           "message is not a valid JSON string")
            response = {"success": False,
                        "type": None,
                        "error": "message is not a valid JSON string"}
        except (KeyError, TypeError):
            logger.warning("WebSocket: %s: skip invalid message" % self.name)
            response = {"success": False,
                        "type": None,
                        "error": "type is missing"}
        else:
            # Check the message type and dispatch task
            if msg_type == "configs":
                # Request or set the configurations
                response = self.configs_handler.handle_message(msg)
            elif msg_type == "console":
                # Control the simulation tasks, or request logging messages
                # FIXME/XXX:
                # * How to make this asynchronously ??
                response = self.console_handler.handle_message(msg)
            elif msg_type == "results":
                # Request the simulation results
                response = self._handle_results(msg)
            else:
                # Message of unknown type
                logger.warning("WebSocket: {0}: ".format(self.name) +
                               "unknown message type: {0}".format(msg_type))
                response = {"success": False,
                            "type": msg_type,
                            "error": "unknown message type %s" % msg_type}
        #
        msg_response = json.dumps(response)
        self.write_message(msg_response)

    def broadcast(self, message):
        """Broadcast/push the given message to all connected clients."""
        for ws in self.application.ws_clients:
            ws.write_message(message)

    def _push_configs(self):
        """
        Get the current configurations as well as the validation status,
        then push to the client to updates the configurations form.
        """
        data = self.configs.dump(flatten=True)
        data["userconfig"] = self.configs.userconfig
        __, errors = self.configs.check_all(raise_exception=False)
        msg = {"success": True,
               "type": "configs",
               "action": "push",
               "data": data,
               "errors": errors}
        self.write_message(json.dumps(msg))
        logger.info("WebSocket: Pushed current configurations data " +
                    "and validation errors to the client")

    def _handle_results(self, msg):
        # Got a message of supported types
        msg_type = msg["type"]
        logger.info("WebSocket: {0}: ".format(self.name) +
                    "handle message of type: {0}".format(msg_type))
        response = {"success": True, "type": msg_type}
        return response