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

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

https-test-client.py: Cleanly shut down the connection subprocess

The call to self.sock.shutdown(socket.SHUT_WR) effectively closes the
subprocess' stdin, telling gnutls-cli to shut down. The wait allows it
and the filter process time to clean up remaining data.

  • Property mode set to 100755
File size: 11.3 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        fd = in_stream.fileno()
60        fl = fcntl.fcntl(fd, fcntl.F_GETFL)
61        fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
62        poller = select.poll()
63        poller.register(fd)
64
65        run_loop = True
66        while run_loop:
67            # Critical: "event" is a bitwise OR of the POLL* constants
68            for x, event in poller.poll():
69                if event & select.POLLIN or event & select.POLLPRI:
70                    data = in_stream.read()
71                    if cert_log in data:
72                        data = data.replace(cert_log, b'')
73                    out_stream.send(data)
74                if event & select.POLLHUP or event & select.POLLRDHUP:
75                    # Stop the loop, but process any other events that
76                    # might be in the list returned by poll() first.
77                    run_loop = False
78
79        in_stream.close()
80        out_stream.close()
81
82    def connect(self):
83        s_local, s_remote = socket.socketpair(socket.AF_UNIX,
84                                              socket.SOCK_STREAM)
85        s_local.settimeout(self.timeout)
86
87        # TODO: Maybe capture stderr?
88        self._sproc = subprocess.Popen(self.command, stdout=subprocess.PIPE,
89                                       stdin=s_remote, close_fds=True,
90                                       bufsize=0)
91        self._fproc = Process(target=HTTPSubprocessConnection._filter,
92                              args=(self._sproc.stdout, s_remote))
93        self._fproc.start()
94        s_remote.close()
95        self.sock = s_local
96
97    def close(self):
98        # close socket to subprocess for writing
99        if self.sock:
100            self.sock.shutdown(socket.SHUT_WR)
101
102        # Wait for the process to stop, send SIGTERM/SIGKILL if
103        # necessary
104        try:
105            self.returncode = self._sproc.wait(self.timeout)
106        except subprocess.TimeoutExpired:
107            try:
108                self._sproc.terminate()
109                self.returncode = self._sproc.wait(self.timeout)
110            except subprocess.TimeoutExpired:
111                self._sproc.kill()
112                self.returncode = self._sproc.wait(self.timeout)
113
114        # filter process receives HUP on pipe when the subprocess
115        # terminates
116        self._fproc.join()
117
118        # close the connection in the super class, which also calls
119        # self.sock.close()
120        super().close()
121
122
123
124class TestRequest(yaml.YAMLObject):
125    yaml_tag = '!request'
126    def __init__(self, path, method='GET', headers=dict(),
127                 expect=dict(status=200)):
128        self.method = method
129        self.path = path
130        self.headers = headers
131        self.expect = expect
132
133    def __repr__(self):
134        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
135                f'method={self.method!r}, headers={self.headers!r}, '
136                f'expect={self.expect!r})')
137
138    def _check_body(self, body):
139        """
140        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
141        >>> r1._check_body('test\\n')
142        >>> r1._check_body('xyz\\n')
143        Traceback (most recent call last):
144        ...
145        https-test-client.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
146        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
147        >>> r2._check_body('test\\n')
148        >>> r2._check_body('est\\n')
149        Traceback (most recent call last):
150        ...
151        https-test-client.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
152        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
153        >>> r3._check_body('test\\n')
154        """
155        if 'exactly' in self.expect['body'] \
156           and body != self.expect['body']['exactly']:
157            raise TestExpectationFailed(
158                f'Unexpected body: {body!r} != '
159                f'{self.expect["body"]["exactly"]!r}')
160        if 'contains' in self.expect['body']:
161            if type(self.expect['body']['contains']) is str:
162                self.expect['body']['contains'] = [
163                    self.expect['body']['contains']]
164            for s in self.expect['body']['contains']:
165                if not s in body:
166                    raise TestExpectationFailed(
167                        f'Unexpected body: {body!r} does not contain '
168                        f'{s!r}')
169
170    def check_response(self, response, body):
171        if self.expects_conn_reset():
172            raise TestExpectationFailed(
173                'Got a response, but connection should have failed!')
174        if response.status != self.expect['status']:
175            raise TestExpectationFailed(
176                f'Unexpected status: {response.status} != '
177                f'{self.expect["status"]}')
178        if 'body' in self.expect:
179            self._check_body(body)
180
181    def expects_conn_reset(self):
182        if 'reset' in self.expect:
183            return self.expect['reset']
184        return False
185
186    @classmethod
187    def _from_yaml(cls, loader, node):
188        fields = loader.construct_mapping(node)
189        req = TestRequest(**fields)
190        return req
191
192class TestConnection(yaml.YAMLObject):
193    yaml_tag = '!connection'
194
195    def __init__(self, actions, gnutls_params=[], protocol='https'):
196        self.gnutls_params = gnutls_params
197        self.actions = actions
198        self.protocol = protocol
199
200    def __repr__(self):
201        return (f'{self.__class__.__name__!s}'
202                f'(gnutls_params={self.gnutls_params!r}, '
203                f'actions={self.actions!r}, protocol={self.protocol!r})')
204
205    @classmethod
206    def _from_yaml(cls, loader, node):
207        fields = loader.construct_mapping(node)
208        conn = TestConnection(**fields)
209        return conn
210
211# Override the default constructors. Pyyaml ignores default parameters
212# otherwise.
213yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader)
214yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader)
215
216
217
218class TestExpectationFailed(Exception):
219    """Raise if a test failed. The constructor should be called with a
220    string describing the problem."""
221    pass
222
223
224
225def format_response(resp, body):
226    s = f'{resp.status} {resp.reason}\n'
227    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
228    s = s + '\n\n' + body
229    return s
230
231
232
233if __name__ == "__main__":
234    import argparse
235    parser = argparse.ArgumentParser(
236        description='Send HTTP requests through gnutls-cli',
237        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
238    parser.add_argument('host', nargs='?', help='Access the specified host',
239                        default='localhost')
240    parser.add_argument('--insecure', action='store_true',
241                        help='do not validate the server certificate')
242    parser.add_argument('-p', '--port', type=int,
243                        help='Access the specified port', default='8000')
244    parser.add_argument('--x509cafile', type=str,
245                        help='Use the specified CA to validate the '
246                        'server certificate')
247    parser.add_argument('--test-config', type=argparse.FileType('r'),
248                        help='load YAML test configuration')
249
250    # enable bash completion if argcomplete is available
251    try:
252        import argcomplete
253        argcomplete.autocomplete(parser)
254    except ImportError:
255        pass
256
257    args = parser.parse_args()
258
259    test_conn = None
260    test_actions = None
261
262    if args.test_config:
263        config = yaml.load(args.test_config, Loader=yaml.Loader)
264        if type(config) is TestConnection:
265            test_conn = config
266            print(test_conn)
267            test_actions = test_conn.actions
268    else:
269        # simple default request
270        test_actions = [TestRequest(path='/test.txt',
271                                    expect={'status': 200, 'body': 'test\n'},
272                                    method='GET')]
273
274
275    # note: "--logfile" option requires GnuTLS version >= 3.6.7
276    command = ['gnutls-cli', '--logfile=/dev/stderr']
277    if args.insecure:
278        command.append('--insecure')
279    if args.x509cafile:
280        command.append('--x509cafile')
281        command.append(args.x509cafile)
282    if test_conn != None:
283        for s in test_conn.gnutls_params:
284            command.append('--' + s)
285    command = command + ['-p', str(args.port), args.host]
286
287    conn = HTTPSubprocessConnection(command, args.host, port=args.port,
288                                    timeout=6.0)
289
290    try:
291        for act in test_actions:
292            if type(act) is TestRequest:
293                try:
294                    conn.request(act.method, act.path, headers=act.headers)
295                    resp = conn.getresponse()
296                except ConnectionResetError as err:
297                    if act.expects_conn_reset():
298                        print('connection reset as expected.')
299                        break
300                    else:
301                        raise err
302                body = resp.read().decode()
303                print(format_response(resp, body))
304                act.check_response(resp, body)
305            else:
306                raise TypeError(f'Unsupported action requested: {act!r}')
307    finally:
308        conn.close()
Note: See TracBrowser for help on using the repository browser.