##// END OF EJS Templates
wireproto: define and implement HTTP handshake to upgrade protocol...
Gregory Szorc -
r37575:734515ac default
parent child Browse files
Show More
@@ -33,6 +33,9 b' from .node import ('
33 nullrev,
33 nullrev,
34 short,
34 short,
35 )
35 )
36 from .thirdparty import (
37 cbor,
38 )
36 from . import (
39 from . import (
37 bundle2,
40 bundle2,
38 changegroup,
41 changegroup,
@@ -3045,9 +3048,14 b' def debugwireproto(ui, repo, path=None, '
3045 req.get_method = lambda: method
3048 req.get_method = lambda: method
3046
3049
3047 try:
3050 try:
3048 opener.open(req).read()
3051 res = opener.open(req)
3052 body = res.read()
3049 except util.urlerr.urlerror as e:
3053 except util.urlerr.urlerror as e:
3050 e.read()
3054 e.read()
3055 continue
3056
3057 if res.headers.get('Content-Type') == 'application/mercurial-cbor':
3058 ui.write(_('cbor> %s\n') % stringutil.pprint(cbor.loads(body)))
3051
3059
3052 elif action == 'close':
3060 elif action == 'close':
3053 peer.close()
3061 peer.close()
@@ -42,8 +42,44 b' Handshake'
42 The client sends a ``capabilities`` command request (``?cmd=capabilities``)
42 The client sends a ``capabilities`` command request (``?cmd=capabilities``)
43 as soon as HTTP requests may be issued.
43 as soon as HTTP requests may be issued.
44
44
45 The server responds with a capabilities string, which the client parses to
45 By default, the server responds with a version 1 capabilities string, which
46 learn about the server's abilities.
46 the client parses to learn about the server's abilities. The ``Content-Type``
47 for this response is ``application/mercurial-0.1`` or
48 ``application/mercurial-0.2`` depending on whether the client advertised
49 support for version ``0.2`` in its request. (Clients aren't supposed to
50 advertise support for ``0.2`` until the capabilities response indicates
51 the server's support for that media type. However, a client could
52 conceivably cache this metadata and issue the capabilities request in such
53 a way to elicit an ``application/mercurial-0.2`` response.)
54
55 Clients wishing to switch to a newer API service may send an
56 ``X-HgUpgrade-<X>`` header containing a space-delimited list of API service
57 names the client is capable of speaking. The request MUST also include an
58 ``X-HgProto-<X>`` header advertising a known serialization format for the
59 response. ``cbor`` is currently the only defined serialization format.
60
61 If the request contains these headers, the response ``Content-Type`` MAY
62 be for a different media type. e.g. ``application/mercurial-cbor`` if the
63 client advertises support for CBOR.
64
65 The response MUST be deserializable to a map with the following keys:
66
67 apibase
68 URL path to API services, relative to the repository root. e.g. ``api/``.
69
70 apis
71 A map of API service names to API descriptors. An API descriptor contains
72 more details about that API. In the case of the HTTP Version 2 Transport,
73 it will be the normal response to a ``capabilities`` command.
74
75 Only the services advertised by the client that are also available on
76 the server are advertised.
77
78 v1capabilities
79 The capabilities string that would be returned by a version 1 response.
80
81 The client can then inspect the server-advertised APIs and decide which
82 API to use, including continuing to use the HTTP Version 1 Transport.
47
83
48 HTTP Version 1 Transport
84 HTTP Version 1 Transport
49 ------------------------
85 ------------------------
@@ -123,6 +159,9 b' The ``application/hg-error`` media type '
123 The content of the HTTP response body typically holds text describing the
159 The content of the HTTP response body typically holds text describing the
124 error.
160 error.
125
161
162 The ``application/mercurial-cbor`` media type indicates a CBOR payload
163 and should be interpreted as identical to ``application/cbor``.
164
126 Behavior of media types is further described in the ``Content Negotiation``
165 Behavior of media types is further described in the ``Content Negotiation``
127 section below.
166 section below.
128
167
@@ -1252,6 +1291,12 b' 0.2'
1252 Indicates the client supports receiving ``application/mercurial-0.2``
1291 Indicates the client supports receiving ``application/mercurial-0.2``
1253 responses.
1292 responses.
1254
1293
1294 cbor
1295 Indicates the client supports receiving ``application/mercurial-cbor``
1296 responses.
1297
1298 (Only intended to be used with version 2 transports.)
1299
1255 comp
1300 comp
1256 Indicates compression formats the client can decode. Value is a list of
1301 Indicates compression formats the client can decode. Value is a list of
1257 comma delimited strings identifying compression formats ordered from
1302 comma delimited strings identifying compression formats ordered from
@@ -12,6 +12,9 b' import sys'
12 import threading
12 import threading
13
13
14 from .i18n import _
14 from .i18n import _
15 from .thirdparty import (
16 cbor,
17 )
15 from .thirdparty.zope import (
18 from .thirdparty.zope import (
16 interface as zi,
19 interface as zi,
17 )
20 )
@@ -230,6 +233,18 b' def handlewsgirequest(rctx, req, res, ch'
230
233
231 return True
234 return True
232
235
236 def _availableapis(repo):
237 apis = set()
238
239 # Registered APIs are made available via config options of the name of
240 # the protocol.
241 for k, v in API_HANDLERS.items():
242 section, option = v['config']
243 if repo.ui.configbool(section, option):
244 apis.add(k)
245
246 return apis
247
233 def handlewsgiapirequest(rctx, req, res, checkperm):
248 def handlewsgiapirequest(rctx, req, res, checkperm):
234 """Handle requests to /api/*."""
249 """Handle requests to /api/*."""
235 assert req.dispatchparts[0] == b'api'
250 assert req.dispatchparts[0] == b'api'
@@ -247,13 +262,7 b' def handlewsgiapirequest(rctx, req, res,'
247 # The URL space is /api/<protocol>/*. The structure of URLs under varies
262 # The URL space is /api/<protocol>/*. The structure of URLs under varies
248 # by <protocol>.
263 # by <protocol>.
249
264
250 # Registered APIs are made available via config options of the name of
265 availableapis = _availableapis(repo)
251 # the protocol.
252 availableapis = set()
253 for k, v in API_HANDLERS.items():
254 section, option = v['config']
255 if repo.ui.configbool(section, option):
256 availableapis.add(k)
257
266
258 # Requests to /api/ list available APIs.
267 # Requests to /api/ list available APIs.
259 if req.dispatchparts == [b'api']:
268 if req.dispatchparts == [b'api']:
@@ -287,10 +296,21 b' def handlewsgiapirequest(rctx, req, res,'
287 req.dispatchparts[2:])
296 req.dispatchparts[2:])
288
297
289 # Maps API name to metadata so custom API can be registered.
298 # Maps API name to metadata so custom API can be registered.
299 # Keys are:
300 #
301 # config
302 # Config option that controls whether service is enabled.
303 # handler
304 # Callable receiving (rctx, req, res, checkperm, urlparts) that is called
305 # when a request to this API is received.
306 # apidescriptor
307 # Callable receiving (req, repo) that is called to obtain an API
308 # descriptor for this service. The response must be serializable to CBOR.
290 API_HANDLERS = {
309 API_HANDLERS = {
291 wireprotov2server.HTTPV2: {
310 wireprotov2server.HTTPV2: {
292 'config': ('experimental', 'web.api.http-v2'),
311 'config': ('experimental', 'web.api.http-v2'),
293 'handler': wireprotov2server.handlehttpv2request,
312 'handler': wireprotov2server.handlehttpv2request,
313 'apidescriptor': wireprotov2server.httpv2apidescriptor,
294 },
314 },
295 }
315 }
296
316
@@ -327,6 +347,54 b' def _httpresponsetype(ui, proto, prefer_'
327 opts = {'level': ui.configint('server', 'zliblevel')}
347 opts = {'level': ui.configint('server', 'zliblevel')}
328 return HGTYPE, util.compengines['zlib'], opts
348 return HGTYPE, util.compengines['zlib'], opts
329
349
350 def processcapabilitieshandshake(repo, req, res, proto):
351 """Called during a ?cmd=capabilities request.
352
353 If the client is advertising support for a newer protocol, we send
354 a CBOR response with information about available services. If no
355 advertised services are available, we don't handle the request.
356 """
357 # Fall back to old behavior unless the API server is enabled.
358 if not repo.ui.configbool('experimental', 'web.apiserver'):
359 return False
360
361 clientapis = decodevaluefromheaders(req, b'X-HgUpgrade')
362 protocaps = decodevaluefromheaders(req, b'X-HgProto')
363 if not clientapis or not protocaps:
364 return False
365
366 # We currently only support CBOR responses.
367 protocaps = set(protocaps.split(' '))
368 if b'cbor' not in protocaps:
369 return False
370
371 descriptors = {}
372
373 for api in sorted(set(clientapis.split()) & _availableapis(repo)):
374 handler = API_HANDLERS[api]
375
376 descriptorfn = handler.get('apidescriptor')
377 if not descriptorfn:
378 continue
379
380 descriptors[api] = descriptorfn(req, repo)
381
382 v1caps = wireproto.dispatch(repo, proto, 'capabilities')
383 assert isinstance(v1caps, wireprototypes.bytesresponse)
384
385 m = {
386 # TODO allow this to be configurable.
387 'apibase': 'api/',
388 'apis': descriptors,
389 'v1capabilities': v1caps.data,
390 }
391
392 res.status = b'200 OK'
393 res.headers[b'Content-Type'] = b'application/mercurial-cbor'
394 res.setbodybytes(cbor.dumps(m, canonical=True))
395
396 return True
397
330 def _callhttp(repo, req, res, proto, cmd):
398 def _callhttp(repo, req, res, proto, cmd):
331 # Avoid cycle involving hg module.
399 # Avoid cycle involving hg module.
332 from .hgweb import common as hgwebcommon
400 from .hgweb import common as hgwebcommon
@@ -363,6 +431,12 b' def _callhttp(repo, req, res, proto, cmd'
363
431
364 proto.checkperm(wireproto.commands[cmd].permission)
432 proto.checkperm(wireproto.commands[cmd].permission)
365
433
434 # Possibly handle a modern client wanting to switch protocols.
435 if (cmd == 'capabilities' and
436 processcapabilitieshandshake(repo, req, res, proto)):
437
438 return
439
366 rsp = wireproto.dispatch(repo, proto, cmd)
440 rsp = wireproto.dispatch(repo, proto, cmd)
367
441
368 if isinstance(rsp, bytes):
442 if isinstance(rsp, bytes):
@@ -365,6 +365,11 b' class httpv2protocolhandler(object):'
365 def checkperm(self, perm):
365 def checkperm(self, perm):
366 raise NotImplementedError
366 raise NotImplementedError
367
367
368 def httpv2apidescriptor(req, repo):
369 proto = httpv2protocolhandler(req, repo.ui)
370
371 return _capabilitiesv2(repo, proto)
372
368 def _capabilitiesv2(repo, proto):
373 def _capabilitiesv2(repo, proto):
369 """Obtain the set of capabilities for version 2 transports.
374 """Obtain the set of capabilities for version 2 transports.
370
375
@@ -1,11 +1,201 b''
1 $ . $TESTDIR/wireprotohelpers.sh
1 $ . $TESTDIR/wireprotohelpers.sh
2
2
3 $ hg init server
3 $ hg init server
4 $ enablehttpv2 server
4 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
5 $ cat hg.pid > $DAEMON_PIDS
6
7 A normal capabilities request is serviced for version 1
8
9 $ sendhttpraw << EOF
10 > httprequest GET ?cmd=capabilities
11 > user-agent: test
12 > EOF
13 using raw connection to peer
14 s> GET /?cmd=capabilities HTTP/1.1\r\n
15 s> Accept-Encoding: identity\r\n
16 s> user-agent: test\r\n
17 s> host: $LOCALIP:$HGPORT\r\n (glob)
18 s> \r\n
19 s> makefile('rb', None)
20 s> HTTP/1.1 200 Script output follows\r\n
21 s> Server: testing stub value\r\n
22 s> Date: $HTTP_DATE$\r\n
23 s> Content-Type: application/mercurial-0.1\r\n
24 s> Content-Length: 458\r\n
25 s> \r\n
26 s> batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
27
28 A proper request without the API server enabled returns the legacy response
29
30 $ sendhttpraw << EOF
31 > httprequest GET ?cmd=capabilities
32 > user-agent: test
33 > x-hgupgrade-1: foo
34 > x-hgproto-1: cbor
35 > EOF
36 using raw connection to peer
37 s> GET /?cmd=capabilities HTTP/1.1\r\n
38 s> Accept-Encoding: identity\r\n
39 s> user-agent: test\r\n
40 s> x-hgproto-1: cbor\r\n
41 s> x-hgupgrade-1: foo\r\n
42 s> host: $LOCALIP:$HGPORT\r\n (glob)
43 s> \r\n
44 s> makefile('rb', None)
45 s> HTTP/1.1 200 Script output follows\r\n
46 s> Server: testing stub value\r\n
47 s> Date: $HTTP_DATE$\r\n
48 s> Content-Type: application/mercurial-0.1\r\n
49 s> Content-Length: 458\r\n
50 s> \r\n
51 s> batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
52
53 Restart with just API server enabled. This enables serving the new format.
54
55 $ killdaemons.py
56 $ cat error.log
57
58 $ cat >> server/.hg/hgrc << EOF
59 > [experimental]
60 > web.apiserver = true
61 > EOF
62
5 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
63 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
6 $ cat hg.pid > $DAEMON_PIDS
64 $ cat hg.pid > $DAEMON_PIDS
7
65
8 capabilities request returns an array of capability strings
66 X-HgUpgrade-<N> without CBOR advertisement uses legacy response
67
68 $ sendhttpraw << EOF
69 > httprequest GET ?cmd=capabilities
70 > user-agent: test
71 > x-hgupgrade-1: foo bar
72 > EOF
73 using raw connection to peer
74 s> GET /?cmd=capabilities HTTP/1.1\r\n
75 s> Accept-Encoding: identity\r\n
76 s> user-agent: test\r\n
77 s> x-hgupgrade-1: foo bar\r\n
78 s> host: $LOCALIP:$HGPORT\r\n (glob)
79 s> \r\n
80 s> makefile('rb', None)
81 s> HTTP/1.1 200 Script output follows\r\n
82 s> Server: testing stub value\r\n
83 s> Date: $HTTP_DATE$\r\n
84 s> Content-Type: application/mercurial-0.1\r\n
85 s> Content-Length: 458\r\n
86 s> \r\n
87 s> batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
88
89 X-HgUpgrade-<N> without known serialization in X-HgProto-<N> uses legacy response
90
91 $ sendhttpraw << EOF
92 > httprequest GET ?cmd=capabilities
93 > user-agent: test
94 > x-hgupgrade-1: foo bar
95 > x-hgproto-1: some value
96 > EOF
97 using raw connection to peer
98 s> GET /?cmd=capabilities HTTP/1.1\r\n
99 s> Accept-Encoding: identity\r\n
100 s> user-agent: test\r\n
101 s> x-hgproto-1: some value\r\n
102 s> x-hgupgrade-1: foo bar\r\n
103 s> host: $LOCALIP:$HGPORT\r\n (glob)
104 s> \r\n
105 s> makefile('rb', None)
106 s> HTTP/1.1 200 Script output follows\r\n
107 s> Server: testing stub value\r\n
108 s> Date: $HTTP_DATE$\r\n
109 s> Content-Type: application/mercurial-0.1\r\n
110 s> Content-Length: 458\r\n
111 s> \r\n
112 s> batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
113
114 X-HgUpgrade-<N> + X-HgProto-<N> headers trigger new response format
115
116 $ sendhttpraw << EOF
117 > httprequest GET ?cmd=capabilities
118 > user-agent: test
119 > x-hgupgrade-1: foo bar
120 > x-hgproto-1: cbor
121 > EOF
122 using raw connection to peer
123 s> GET /?cmd=capabilities HTTP/1.1\r\n
124 s> Accept-Encoding: identity\r\n
125 s> user-agent: test\r\n
126 s> x-hgproto-1: cbor\r\n
127 s> x-hgupgrade-1: foo bar\r\n
128 s> host: $LOCALIP:$HGPORT\r\n (glob)
129 s> \r\n
130 s> makefile('rb', None)
131 s> HTTP/1.1 200 OK\r\n
132 s> Server: testing stub value\r\n
133 s> Date: $HTTP_DATE$\r\n
134 s> Content-Type: application/mercurial-cbor\r\n
135 s> Content-Length: 496\r\n
136 s> \r\n
137 s> \xa3Dapis\xa0GapibaseDapi/Nv1capabilitiesY\x01\xcabatch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
138 cbor> {b'apibase': b'api/', b'apis': {}, b'v1capabilities': b'batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash'}
139
140 Restart server to enable HTTPv2
141
142 $ killdaemons.py
143 $ enablehttpv2 server
144 $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
145
146 Only requested API services are returned
147
148 $ sendhttpraw << EOF
149 > httprequest GET ?cmd=capabilities
150 > user-agent: test
151 > x-hgupgrade-1: foo bar
152 > x-hgproto-1: cbor
153 > EOF
154 using raw connection to peer
155 s> GET /?cmd=capabilities HTTP/1.1\r\n
156 s> Accept-Encoding: identity\r\n
157 s> user-agent: test\r\n
158 s> x-hgproto-1: cbor\r\n
159 s> x-hgupgrade-1: foo bar\r\n
160 s> host: $LOCALIP:$HGPORT\r\n (glob)
161 s> \r\n
162 s> makefile('rb', None)
163 s> HTTP/1.1 200 OK\r\n
164 s> Server: testing stub value\r\n
165 s> Date: $HTTP_DATE$\r\n
166 s> Content-Type: application/mercurial-cbor\r\n
167 s> Content-Length: 496\r\n
168 s> \r\n
169 s> \xa3Dapis\xa0GapibaseDapi/Nv1capabilitiesY\x01\xcabatch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
170 cbor> {b'apibase': b'api/', b'apis': {}, b'v1capabilities': b'batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash'}
171
172 Request for HTTPv2 service returns information about it
173
174 $ sendhttpraw << EOF
175 > httprequest GET ?cmd=capabilities
176 > user-agent: test
177 > x-hgupgrade-1: exp-http-v2-0001 foo bar
178 > x-hgproto-1: cbor
179 > EOF
180 using raw connection to peer
181 s> GET /?cmd=capabilities HTTP/1.1\r\n
182 s> Accept-Encoding: identity\r\n
183 s> user-agent: test\r\n
184 s> x-hgproto-1: cbor\r\n
185 s> x-hgupgrade-1: exp-http-v2-0001 foo bar\r\n
186 s> host: $LOCALIP:$HGPORT\r\n (glob)
187 s> \r\n
188 s> makefile('rb', None)
189 s> HTTP/1.1 200 OK\r\n
190 s> Server: testing stub value\r\n
191 s> Date: $HTTP_DATE$\r\n
192 s> Content-Type: application/mercurial-cbor\r\n
193 s> Content-Length: 879\r\n
194 s> \r\n
195 s> \xa3Dapis\xa1Pexp-http-v2-0001\xa2Hcommands\xa7Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullKcompression\x82\xa1DnameDzstd\xa1DnameDzlibGapibaseDapi/Nv1capabilitiesY\x01\xcabatch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
196 cbor> {b'apibase': b'api/', b'apis': {b'exp-http-v2-0001': {b'commands': {b'branchmap': {b'args': {}, b'permissions': [b'pull']}, b'capabilities': {b'args': {}, b'permissions': [b'pull']}, b'heads': {b'args': {b'publiconly': False}, b'permissions': [b'pull']}, b'known': {b'args': {b'nodes': [b'deadbeef']}, b'permissions': [b'pull']}, b'listkeys': {b'args': {b'namespace': b'ns'}, b'permissions': [b'pull']}, b'lookup': {b'args': {b'key': b'foo'}, b'permissions': [b'pull']}, b'pushkey': {b'args': {b'key': b'key', b'namespace': b'ns', b'new': b'new', b'old': b'old'}, b'permissions': [b'push']}}, b'compression': [{b'name': b'zstd'}, {b'name': b'zlib'}]}}, b'v1capabilities': b'batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash'}
197
198 capabilities command returns expected info
9
199
10 $ sendhttpv2peer << EOF
200 $ sendhttpv2peer << EOF
11 > command capabilities
201 > command capabilities
General Comments 0
You need to be logged in to leave comments. Login now