##// END OF EJS Templates
Check time of last ping before timing out a missing pong.
Richard Everson -
Show More
@@ -1,199 +1,205 b''
1 1 """Tornado handlers for WebSocket <-> ZMQ sockets."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 try:
9 9 from urllib.parse import urlparse # Py 3
10 10 except ImportError:
11 11 from urlparse import urlparse # Py 2
12 12
13 13 try:
14 14 from http.cookies import SimpleCookie # Py 3
15 15 except ImportError:
16 16 from Cookie import SimpleCookie # Py 2
17 17 import logging
18 18
19 19 import tornado
20 20 from tornado import ioloop
21 21 from tornado import web
22 22 from tornado import websocket
23 23
24 24 from IPython.kernel.zmq.session import Session
25 25 from IPython.utils.jsonutil import date_default
26 26 from IPython.utils.py3compat import PY3, cast_unicode
27 27
28 28 from .handlers import IPythonHandler
29 29
30 30
31 31 class ZMQStreamHandler(websocket.WebSocketHandler):
32 32
33 33 def check_origin(self, origin):
34 34 """Check Origin == Host or Access-Control-Allow-Origin.
35 35
36 36 Tornado >= 4 calls this method automatically, raising 403 if it returns False.
37 37 We call it explicitly in `open` on Tornado < 4.
38 38 """
39 39 if self.allow_origin == '*':
40 40 return True
41 41
42 42 host = self.request.headers.get("Host")
43 43
44 44 # If no header is provided, assume we can't verify origin
45 45 if(origin is None or host is None):
46 46 return False
47 47
48 48 host_origin = "{0}://{1}".format(self.request.protocol, host)
49 49
50 50 # OK if origin matches host
51 51 if origin == host_origin:
52 52 return True
53 53
54 54 # Check CORS headers
55 55 if self.allow_origin:
56 56 return self.allow_origin == origin
57 57 elif self.allow_origin_pat:
58 58 return bool(self.allow_origin_pat.match(origin))
59 59 else:
60 60 # No CORS headers deny the request
61 61 return False
62 62
63 63 def clear_cookie(self, *args, **kwargs):
64 64 """meaningless for websockets"""
65 65 pass
66 66
67 67 def _reserialize_reply(self, msg_list):
68 68 """Reserialize a reply message using JSON.
69 69
70 70 This takes the msg list from the ZMQ socket, unserializes it using
71 71 self.session and then serializes the result using JSON. This method
72 72 should be used by self._on_zmq_reply to build messages that can
73 73 be sent back to the browser.
74 74 """
75 75 idents, msg_list = self.session.feed_identities(msg_list)
76 76 msg = self.session.unserialize(msg_list)
77 77 try:
78 78 msg['header'].pop('date')
79 79 except KeyError:
80 80 pass
81 81 try:
82 82 msg['parent_header'].pop('date')
83 83 except KeyError:
84 84 pass
85 85 msg.pop('buffers')
86 86 return json.dumps(msg, default=date_default)
87 87
88 88 def _on_zmq_reply(self, msg_list):
89 89 # Sometimes this gets triggered when the on_close method is scheduled in the
90 90 # eventloop but hasn't been called.
91 91 if self.stream.closed(): return
92 92 try:
93 93 msg = self._reserialize_reply(msg_list)
94 94 except Exception:
95 95 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
96 96 else:
97 97 self.write_message(msg)
98 98
99 99 def allow_draft76(self):
100 100 """Allow draft 76, until browsers such as Safari update to RFC 6455.
101 101
102 102 This has been disabled by default in tornado in release 2.2.0, and
103 103 support will be removed in later versions.
104 104 """
105 105 return True
106 106
107 107 # ping interval for keeping websockets alive (30 seconds)
108 108 WS_PING_INTERVAL = 30000
109 109
110 110 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
111 111 ping_callback = None
112 last_ping = 0
112 113 last_pong = 0
113 114
114 115 @property
115 116 def ping_interval(self):
116 117 """The interval for websocket keep-alive pings.
117 118
118 119 Set ws_ping_interval = 0 to disable pings.
119 120 """
120 121 return self.settings.get('ws_ping_interval', WS_PING_INTERVAL)
121 122
122 123 @property
123 124 def ping_timeout(self):
124 125 """If no ping is received in this many milliseconds,
125 126 close the websocket connection (VPNs, etc. can fail to cleanly close ws connections).
126 127 Default is max of 3 pings or 30 seconds.
127 128 """
128 129 return self.settings.get('ws_ping_timeout',
129 130 max(3 * self.ping_interval, WS_PING_INTERVAL)
130 131 )
131 132
132 133 def set_default_headers(self):
133 134 """Undo the set_default_headers in IPythonHandler
134 135
135 136 which doesn't make sense for websockets
136 137 """
137 138 pass
138 139
139 140 def open(self, kernel_id):
140 141 self.kernel_id = cast_unicode(kernel_id, 'ascii')
141 142 # Check to see that origin matches host directly, including ports
142 143 # Tornado 4 already does CORS checking
143 144 if tornado.version_info[0] < 4:
144 145 if not self.check_origin(self.get_origin()):
145 146 self.log.warn("Cross Origin WebSocket Attempt from %s", self.get_origin())
146 147 raise web.HTTPError(403)
147 148
148 149 self.session = Session(config=self.config)
149 150 self.save_on_message = self.on_message
150 151 self.on_message = self.on_first_message
151 152
152 153 # start the pinging
153 154 if self.ping_interval > 0:
154 self.last_pong = ioloop.IOLoop.instance().time()
155 self.last_ping = ioloop.IOLoop.instance().time() # Remember time of last ping
156 self.last_pong = self.last_ping
155 157 self.ping_callback = ioloop.PeriodicCallback(self.send_ping, self.ping_interval)
156 158 self.ping_callback.start()
157 159
158 160 def send_ping(self):
159 161 """send a ping to keep the websocket alive"""
160 162 if self.stream.closed() and self.ping_callback is not None:
161 163 self.ping_callback.stop()
162 164 return
163 165
164 # check for timeout on pong
165 since_last_pong = 1e3 * (ioloop.IOLoop.instance().time() - self.last_pong)
166 if since_last_pong > self.ping_timeout:
166 # check for timeout on pong. Make sure that we really have sent a recent ping in
167 # case the machine with both server and client has been suspended since the last ping.
168 now = ioloop.IOLoop.instance().time()
169 since_last_pong = 1e3 * (now - self.last_pong)
170 since_last_ping = 1e3 * (now - self.last_ping)
171 if since_last_ping < 2*self.ping_interval and since_last_pong > self.ping_timeout:
167 172 self.log.warn("WebSocket ping timeout after %i ms.", since_last_pong)
168 173 self.close()
169 174 return
170 175
171 176 self.ping(b'')
172
177 self.last_ping = now
178
173 179 def on_pong(self, data):
174 180 self.last_pong = ioloop.IOLoop.instance().time()
175 181
176 182 def _inject_cookie_message(self, msg):
177 183 """Inject the first message, which is the document cookie,
178 184 for authentication."""
179 185 if not PY3 and isinstance(msg, unicode):
180 186 # Cookie constructor doesn't accept unicode strings
181 187 # under Python 2.x for some reason
182 188 msg = msg.encode('utf8', 'replace')
183 189 try:
184 190 identity, msg = msg.split(':', 1)
185 191 self.session.session = cast_unicode(identity, 'ascii')
186 192 except Exception:
187 193 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
188 194
189 195 try:
190 196 self.request._cookies = SimpleCookie(msg)
191 197 except:
192 198 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
193 199
194 200 def on_first_message(self, msg):
195 201 self._inject_cookie_message(msg)
196 202 if self.get_current_user() is None:
197 203 self.log.warn("Couldn't authenticate WebSocket connection")
198 204 raise web.HTTPError(403)
199 205 self.on_message = self.save_on_message
General Comments 0
You need to be logged in to leave comments. Login now