comparison mercurial/wireprotov2server.py @ 37545:93397c4633f6

wireproto: extract HTTP version 2 code to own module wireprotoserver has generic and version 1 server code. The wireproto module also has both version 1 and version 2 command implementations. Upcoming work I want to do will make it difficult for this code to live in the current locations. Plus, it kind of makes sense for the version 2 code to live in an isolated module. This commit copies the HTTPv2 bits from wireprotoserver into a new module. We do it as a file copy to preserve history. A future commit will be copying wire protocol commands into this module as well. But there is little history of that code, so it makes sense to take history for wireprotoserver. Differential Revision: https://phab.mercurial-scm.org/D3230
author Gregory Szorc <gregory.szorc@gmail.com>
date Mon, 09 Apr 2018 19:35:04 -0700
parents mercurial/wireprotoserver.py@69e46c1834ac
children 3a2367e6c6f2
comparison
equal deleted inserted replaced
37544:55b5ba8d4e68 37545:93397c4633f6
1 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
2 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
3 #
4 # This software may be used and distributed according to the terms of the
5 # GNU General Public License version 2 or any later version.
6
7 from __future__ import absolute_import
8
9 import contextlib
10
11 from .i18n import _
12 from .thirdparty import (
13 cbor,
14 )
15 from .thirdparty.zope import (
16 interface as zi,
17 )
18 from . import (
19 error,
20 pycompat,
21 wireproto,
22 wireprotoframing,
23 wireprototypes,
24 )
25
26 FRAMINGTYPE = b'application/mercurial-exp-framing-0003'
27
28 HTTPV2 = wireprototypes.HTTPV2
29
30 def handlehttpv2request(rctx, req, res, checkperm, urlparts):
31 from .hgweb import common as hgwebcommon
32
33 # URL space looks like: <permissions>/<command>, where <permission> can
34 # be ``ro`` or ``rw`` to signal read-only or read-write, respectively.
35
36 # Root URL does nothing meaningful... yet.
37 if not urlparts:
38 res.status = b'200 OK'
39 res.headers[b'Content-Type'] = b'text/plain'
40 res.setbodybytes(_('HTTP version 2 API handler'))
41 return
42
43 if len(urlparts) == 1:
44 res.status = b'404 Not Found'
45 res.headers[b'Content-Type'] = b'text/plain'
46 res.setbodybytes(_('do not know how to process %s\n') %
47 req.dispatchpath)
48 return
49
50 permission, command = urlparts[0:2]
51
52 if permission not in (b'ro', b'rw'):
53 res.status = b'404 Not Found'
54 res.headers[b'Content-Type'] = b'text/plain'
55 res.setbodybytes(_('unknown permission: %s') % permission)
56 return
57
58 if req.method != 'POST':
59 res.status = b'405 Method Not Allowed'
60 res.headers[b'Allow'] = b'POST'
61 res.setbodybytes(_('commands require POST requests'))
62 return
63
64 # At some point we'll want to use our own API instead of recycling the
65 # behavior of version 1 of the wire protocol...
66 # TODO return reasonable responses - not responses that overload the
67 # HTTP status line message for error reporting.
68 try:
69 checkperm(rctx, req, 'pull' if permission == b'ro' else 'push')
70 except hgwebcommon.ErrorResponse as e:
71 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
72 for k, v in e.headers:
73 res.headers[k] = v
74 res.setbodybytes('permission denied')
75 return
76
77 # We have a special endpoint to reflect the request back at the client.
78 if command == b'debugreflect':
79 _processhttpv2reflectrequest(rctx.repo.ui, rctx.repo, req, res)
80 return
81
82 # Extra commands that we handle that aren't really wire protocol
83 # commands. Think extra hard before making this hackery available to
84 # extension.
85 extracommands = {'multirequest'}
86
87 if command not in wireproto.commandsv2 and command not in extracommands:
88 res.status = b'404 Not Found'
89 res.headers[b'Content-Type'] = b'text/plain'
90 res.setbodybytes(_('unknown wire protocol command: %s\n') % command)
91 return
92
93 repo = rctx.repo
94 ui = repo.ui
95
96 proto = httpv2protocolhandler(req, ui)
97
98 if (not wireproto.commandsv2.commandavailable(command, proto)
99 and command not in extracommands):
100 res.status = b'404 Not Found'
101 res.headers[b'Content-Type'] = b'text/plain'
102 res.setbodybytes(_('invalid wire protocol command: %s') % command)
103 return
104
105 # TODO consider cases where proxies may add additional Accept headers.
106 if req.headers.get(b'Accept') != FRAMINGTYPE:
107 res.status = b'406 Not Acceptable'
108 res.headers[b'Content-Type'] = b'text/plain'
109 res.setbodybytes(_('client MUST specify Accept header with value: %s\n')
110 % FRAMINGTYPE)
111 return
112
113 if req.headers.get(b'Content-Type') != FRAMINGTYPE:
114 res.status = b'415 Unsupported Media Type'
115 # TODO we should send a response with appropriate media type,
116 # since client does Accept it.
117 res.headers[b'Content-Type'] = b'text/plain'
118 res.setbodybytes(_('client MUST send Content-Type header with '
119 'value: %s\n') % FRAMINGTYPE)
120 return
121
122 _processhttpv2request(ui, repo, req, res, permission, command, proto)
123
124 def _processhttpv2reflectrequest(ui, repo, req, res):
125 """Reads unified frame protocol request and dumps out state to client.
126
127 This special endpoint can be used to help debug the wire protocol.
128
129 Instead of routing the request through the normal dispatch mechanism,
130 we instead read all frames, decode them, and feed them into our state
131 tracker. We then dump the log of all that activity back out to the
132 client.
133 """
134 import json
135
136 # Reflection APIs have a history of being abused, accidentally disclosing
137 # sensitive data, etc. So we have a config knob.
138 if not ui.configbool('experimental', 'web.api.debugreflect'):
139 res.status = b'404 Not Found'
140 res.headers[b'Content-Type'] = b'text/plain'
141 res.setbodybytes(_('debugreflect service not available'))
142 return
143
144 # We assume we have a unified framing protocol request body.
145
146 reactor = wireprotoframing.serverreactor()
147 states = []
148
149 while True:
150 frame = wireprotoframing.readframe(req.bodyfh)
151
152 if not frame:
153 states.append(b'received: <no frame>')
154 break
155
156 states.append(b'received: %d %d %d %s' % (frame.typeid, frame.flags,
157 frame.requestid,
158 frame.payload))
159
160 action, meta = reactor.onframerecv(frame)
161 states.append(json.dumps((action, meta), sort_keys=True,
162 separators=(', ', ': ')))
163
164 action, meta = reactor.oninputeof()
165 meta['action'] = action
166 states.append(json.dumps(meta, sort_keys=True, separators=(', ',': ')))
167
168 res.status = b'200 OK'
169 res.headers[b'Content-Type'] = b'text/plain'
170 res.setbodybytes(b'\n'.join(states))
171
172 def _processhttpv2request(ui, repo, req, res, authedperm, reqcommand, proto):
173 """Post-validation handler for HTTPv2 requests.
174
175 Called when the HTTP request contains unified frame-based protocol
176 frames for evaluation.
177 """
178 # TODO Some HTTP clients are full duplex and can receive data before
179 # the entire request is transmitted. Figure out a way to indicate support
180 # for that so we can opt into full duplex mode.
181 reactor = wireprotoframing.serverreactor(deferoutput=True)
182 seencommand = False
183
184 outstream = reactor.makeoutputstream()
185
186 while True:
187 frame = wireprotoframing.readframe(req.bodyfh)
188 if not frame:
189 break
190
191 action, meta = reactor.onframerecv(frame)
192
193 if action == 'wantframe':
194 # Need more data before we can do anything.
195 continue
196 elif action == 'runcommand':
197 sentoutput = _httpv2runcommand(ui, repo, req, res, authedperm,
198 reqcommand, reactor, outstream,
199 meta, issubsequent=seencommand)
200
201 if sentoutput:
202 return
203
204 seencommand = True
205
206 elif action == 'error':
207 # TODO define proper error mechanism.
208 res.status = b'200 OK'
209 res.headers[b'Content-Type'] = b'text/plain'
210 res.setbodybytes(meta['message'] + b'\n')
211 return
212 else:
213 raise error.ProgrammingError(
214 'unhandled action from frame processor: %s' % action)
215
216 action, meta = reactor.oninputeof()
217 if action == 'sendframes':
218 # We assume we haven't started sending the response yet. If we're
219 # wrong, the response type will raise an exception.
220 res.status = b'200 OK'
221 res.headers[b'Content-Type'] = FRAMINGTYPE
222 res.setbodygen(meta['framegen'])
223 elif action == 'noop':
224 pass
225 else:
226 raise error.ProgrammingError('unhandled action from frame processor: %s'
227 % action)
228
229 def _httpv2runcommand(ui, repo, req, res, authedperm, reqcommand, reactor,
230 outstream, command, issubsequent):
231 """Dispatch a wire protocol command made from HTTPv2 requests.
232
233 The authenticated permission (``authedperm``) along with the original
234 command from the URL (``reqcommand``) are passed in.
235 """
236 # We already validated that the session has permissions to perform the
237 # actions in ``authedperm``. In the unified frame protocol, the canonical
238 # command to run is expressed in a frame. However, the URL also requested
239 # to run a specific command. We need to be careful that the command we
240 # run doesn't have permissions requirements greater than what was granted
241 # by ``authedperm``.
242 #
243 # Our rule for this is we only allow one command per HTTP request and
244 # that command must match the command in the URL. However, we make
245 # an exception for the ``multirequest`` URL. This URL is allowed to
246 # execute multiple commands. We double check permissions of each command
247 # as it is invoked to ensure there is no privilege escalation.
248 # TODO consider allowing multiple commands to regular command URLs
249 # iff each command is the same.
250
251 proto = httpv2protocolhandler(req, ui, args=command['args'])
252
253 if reqcommand == b'multirequest':
254 if not wireproto.commandsv2.commandavailable(command['command'], proto):
255 # TODO proper error mechanism
256 res.status = b'200 OK'
257 res.headers[b'Content-Type'] = b'text/plain'
258 res.setbodybytes(_('wire protocol command not available: %s') %
259 command['command'])
260 return True
261
262 # TODO don't use assert here, since it may be elided by -O.
263 assert authedperm in (b'ro', b'rw')
264 wirecommand = wireproto.commandsv2[command['command']]
265 assert wirecommand.permission in ('push', 'pull')
266
267 if authedperm == b'ro' and wirecommand.permission != 'pull':
268 # TODO proper error mechanism
269 res.status = b'403 Forbidden'
270 res.headers[b'Content-Type'] = b'text/plain'
271 res.setbodybytes(_('insufficient permissions to execute '
272 'command: %s') % command['command'])
273 return True
274
275 # TODO should we also call checkperm() here? Maybe not if we're going
276 # to overhaul that API. The granted scope from the URL check should
277 # be good enough.
278
279 else:
280 # Don't allow multiple commands outside of ``multirequest`` URL.
281 if issubsequent:
282 # TODO proper error mechanism
283 res.status = b'200 OK'
284 res.headers[b'Content-Type'] = b'text/plain'
285 res.setbodybytes(_('multiple commands cannot be issued to this '
286 'URL'))
287 return True
288
289 if reqcommand != command['command']:
290 # TODO define proper error mechanism
291 res.status = b'200 OK'
292 res.headers[b'Content-Type'] = b'text/plain'
293 res.setbodybytes(_('command in frame must match command in URL'))
294 return True
295
296 rsp = wireproto.dispatch(repo, proto, command['command'])
297
298 res.status = b'200 OK'
299 res.headers[b'Content-Type'] = FRAMINGTYPE
300
301 if isinstance(rsp, wireprototypes.bytesresponse):
302 action, meta = reactor.onbytesresponseready(outstream,
303 command['requestid'],
304 rsp.data)
305 elif isinstance(rsp, wireprototypes.cborresponse):
306 encoded = cbor.dumps(rsp.value, canonical=True)
307 action, meta = reactor.onbytesresponseready(outstream,
308 command['requestid'],
309 encoded,
310 iscbor=True)
311 else:
312 action, meta = reactor.onapplicationerror(
313 _('unhandled response type from wire proto command'))
314
315 if action == 'sendframes':
316 res.setbodygen(meta['framegen'])
317 return True
318 elif action == 'noop':
319 return False
320 else:
321 raise error.ProgrammingError('unhandled event from reactor: %s' %
322 action)
323
324 @zi.implementer(wireprototypes.baseprotocolhandler)
325 class httpv2protocolhandler(object):
326 def __init__(self, req, ui, args=None):
327 self._req = req
328 self._ui = ui
329 self._args = args
330
331 @property
332 def name(self):
333 return HTTPV2
334
335 def getargs(self, args):
336 data = {}
337 for k, typ in args.items():
338 if k == '*':
339 raise NotImplementedError('do not support * args')
340 elif k in self._args:
341 # TODO consider validating value types.
342 data[k] = self._args[k]
343
344 return data
345
346 def getprotocaps(self):
347 # Protocol capabilities are currently not implemented for HTTP V2.
348 return set()
349
350 def getpayload(self):
351 raise NotImplementedError
352
353 @contextlib.contextmanager
354 def mayberedirectstdio(self):
355 raise NotImplementedError
356
357 def client(self):
358 raise NotImplementedError
359
360 def addcapabilities(self, repo, caps):
361 return caps
362
363 def checkperm(self, perm):
364 raise NotImplementedError