Mercurial > hg
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 |