source: mod_gnutls/test/https-test-client.py @ d9b0936

asyncioproxy-ticket
Last change on this file since d9b0936 was d9b0936, checked in by Fiona Klute <fiona.klute@…>, 2 years ago

https-test-client.py: Some more comments on the filter function

  • Property mode set to 100755
File size: 11.6 KB
Line 
1#!/usr/bin/python3
2# PYTHON_ARGCOMPLETE_OK
3
4# Copyright 2019 Fiona Klute
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10#     http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import select
19import socket
20import subprocess
21import sys
22import traceback
23import yaml
24
25from http.client import HTTPConnection
26from multiprocessing import Process
27from time import sleep
28
29class HTTPSubprocessConnection(HTTPConnection):
30    def __init__(self, command, host, port=None,
31                 timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
32                 blocksize=8192):
33        super(HTTPSubprocessConnection, self).__init__(host, port, timeout,
34                                                       source_address=None,
35                                                       blocksize=blocksize)
36        # "command" must be a list containing binary and command line
37        # parameters
38        self.command = command
39        # This will be the subprocess reference when connected
40        self._sproc = None
41        # The subprocess return code is stored here on close()
42        self.returncode = None
43        # The set_tunnel method of the super class is not supported
44        # (see exception doc)
45        self.set_tunnel = None
46        self._fproc = None
47
48    @classmethod
49    def _filter(cls, in_stream, out_stream):
50        import fcntl
51        import os
52        # This filters out a log line about loading client
53        # certificates that is mistakenly sent to stdout. My fix has
54        # been merged, but buggy binaries will probably be around for
55        # a while.
56        # https://gitlab.com/gnutls/gnutls/merge_requests/1125
57        cert_log = b'Processed 1 client X.509 certificates...\n'
58
59        # Set the input to non-blocking mode
60        fd = in_stream.fileno()
61        fl = fcntl.fcntl(fd, fcntl.F_GETFL)
62        fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
63
64        # The poll object allows waiting for events on non-blocking IO
65        # channels.
66        poller = select.poll()
67        poller.register(fd)
68
69        run_loop = True
70        while run_loop:
71            # The returned tuples are file descriptor and event, but
72            # we're only listening on one stream anyway, so we don't
73            # need to check it here.
74            for x, event in poller.poll():
75                # Critical: "event" is a bitwise OR of the POLL* constants
76                if event & select.POLLIN or event & select.POLLPRI:
77                    data = in_stream.read()
78                    if cert_log in data:
79                        data = data.replace(cert_log, b'')
80                    out_stream.send(data)
81                if event & select.POLLHUP or event & select.POLLRDHUP:
82                    # Stop the loop, but process any other events that
83                    # might be in the list returned by poll() first.
84                    run_loop = False
85
86        in_stream.close()
87        out_stream.close()
88
89    def connect(self):
90        s_local, s_remote = socket.socketpair(socket.AF_UNIX,
91                                              socket.SOCK_STREAM)
92        s_local.settimeout(self.timeout)
93
94        # TODO: Maybe capture stderr?
95        self._sproc = subprocess.Popen(self.command, stdout=subprocess.PIPE,
96                                       stdin=s_remote, close_fds=True,
97                                       bufsize=0)
98        self._fproc = Process(target=HTTPSubprocessConnection._filter,
99                              args=(self._sproc.stdout, s_remote))
100        self._fproc.start()
101        s_remote.close()
102        self.sock = s_local
103
104    def close(self):
105        # close socket to subprocess for writing
106        if self.sock:
107            self.sock.shutdown(socket.SHUT_WR)
108
109        # Wait for the process to stop, send SIGTERM/SIGKILL if
110        # necessary
111        try:
112            self.returncode = self._sproc.wait(self.timeout)
113        except subprocess.TimeoutExpired:
114            try:
115                self._sproc.terminate()
116                self.returncode = self._sproc.wait(self.timeout)
117            except subprocess.TimeoutExpired:
118                self._sproc.kill()
119                self.returncode = self._sproc.wait(self.timeout)
120
121        # filter process receives HUP on pipe when the subprocess
122        # terminates
123        self._fproc.join()
124
125        # close the connection in the super class, which also calls
126        # self.sock.close()
127        super().close()
128
129
130
131class TestRequest(yaml.YAMLObject):
132    yaml_tag = '!request'
133    def __init__(self, path, method='GET', headers=dict(),
134                 expect=dict(status=200)):
135        self.method = method
136        self.path = path
137        self.headers = headers
138        self.expect = expect
139
140    def __repr__(self):
141        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
142                f'method={self.method!r}, headers={self.headers!r}, '
143                f'expect={self.expect!r})')
144
145    def _check_body(self, body):
146        """
147        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
148        >>> r1._check_body('test\\n')
149        >>> r1._check_body('xyz\\n')
150        Traceback (most recent call last):
151        ...
152        https-test-client.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
153        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
154        >>> r2._check_body('test\\n')
155        >>> r2._check_body('est\\n')
156        Traceback (most recent call last):
157        ...
158        https-test-client.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
159        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
160        >>> r3._check_body('test\\n')
161        """
162        if 'exactly' in self.expect['body'] \
163           and body != self.expect['body']['exactly']:
164            raise TestExpectationFailed(
165                f'Unexpected body: {body!r} != '
166                f'{self.expect["body"]["exactly"]!r}')
167        if 'contains' in self.expect['body']:
168            if type(self.expect['body']['contains']) is str:
169                self.expect['body']['contains'] = [
170                    self.expect['body']['contains']]
171            for s in self.expect['body']['contains']:
172                if not s in body:
173                    raise TestExpectationFailed(
174                        f'Unexpected body: {body!r} does not contain '
175                        f'{s!r}')
176
177    def check_response(self, response, body):
178        if self.expects_conn_reset():
179            raise TestExpectationFailed(
180                'Got a response, but connection should have failed!')
181        if response.status != self.expect['status']:
182            raise TestExpectationFailed(
183                f'Unexpected status: {response.status} != '
184                f'{self.expect["status"]}')
185        if 'body' in self.expect:
186            self._check_body(body)
187
188    def expects_conn_reset(self):
189        if 'reset' in self.expect:
190            return self.expect['reset']
191        return False
192
193    @classmethod
194    def _from_yaml(cls, loader, node):
195        fields = loader.construct_mapping(node)
196        req = TestRequest(**fields)
197        return req
198
199class TestConnection(yaml.YAMLObject):
200    yaml_tag = '!connection'
201
202    def __init__(self, actions, gnutls_params=[], protocol='https'):
203        self.gnutls_params = gnutls_params
204        self.actions = actions
205        self.protocol = protocol
206
207    def __repr__(self):
208        return (f'{self.__class__.__name__!s}'
209                f'(gnutls_params={self.gnutls_params!r}, '
210                f'actions={self.actions!r}, protocol={self.protocol!r})')
211
212    @classmethod
213    def _from_yaml(cls, loader, node):
214        fields = loader.construct_mapping(node)
215        conn = TestConnection(**fields)
216        return conn
217
218# Override the default constructors. Pyyaml ignores default parameters
219# otherwise.
220yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader)
221yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader)
222
223
224
225class TestExpectationFailed(Exception):
226    """Raise if a test failed. The constructor should be called with a
227    string describing the problem."""
228    pass
229
230
231
232def format_response(resp, body):
233    s = f'{resp.status} {resp.reason}\n'
234    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
235    s = s + '\n\n' + body
236    return s
237
238
239
240if __name__ == "__main__":
241    import argparse
242    parser = argparse.ArgumentParser(
243        description='Send HTTP requests through gnutls-cli',
244        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
245    parser.add_argument('host', nargs='?', help='Access the specified host',
246                        default='localhost')
247    parser.add_argument('--insecure', action='store_true',
248                        help='do not validate the server certificate')
249    parser.add_argument('-p', '--port', type=int,
250                        help='Access the specified port', default='8000')
251    parser.add_argument('--x509cafile', type=str,
252                        help='Use the specified CA to validate the '
253                        'server certificate')
254    parser.add_argument('--test-config', type=argparse.FileType('r'),
255                        help='load YAML test configuration')
256
257    # enable bash completion if argcomplete is available
258    try:
259        import argcomplete
260        argcomplete.autocomplete(parser)
261    except ImportError:
262        pass
263
264    args = parser.parse_args()
265
266    test_conn = None
267    test_actions = None
268
269    if args.test_config:
270        config = yaml.load(args.test_config, Loader=yaml.Loader)
271        if type(config) is TestConnection:
272            test_conn = config
273            print(test_conn)
274            test_actions = test_conn.actions
275    else:
276        # simple default request
277        test_actions = [TestRequest(path='/test.txt',
278                                    expect={'status': 200, 'body': 'test\n'},
279                                    method='GET')]
280
281
282    # note: "--logfile" option requires GnuTLS version >= 3.6.7
283    command = ['gnutls-cli', '--logfile=/dev/stderr']
284    if args.insecure:
285        command.append('--insecure')
286    if args.x509cafile:
287        command.append('--x509cafile')
288        command.append(args.x509cafile)
289    if test_conn != None:
290        for s in test_conn.gnutls_params:
291            command.append('--' + s)
292    command = command + ['-p', str(args.port), args.host]
293
294    conn = HTTPSubprocessConnection(command, args.host, port=args.port,
295                                    timeout=6.0)
296
297    try:
298        for act in test_actions:
299            if type(act) is TestRequest:
300                try:
301                    conn.request(act.method, act.path, headers=act.headers)
302                    resp = conn.getresponse()
303                except ConnectionResetError as err:
304                    if act.expects_conn_reset():
305                        print('connection reset as expected.')
306                        break
307                    else:
308                        raise err
309                body = resp.read().decode()
310                print(format_response(resp, body))
311                act.check_response(resp, body)
312            else:
313                raise TypeError(f'Unsupported action requested: {act!r}')
314    finally:
315        conn.close()
Note: See TracBrowser for help on using the repository browser.