source: mod_gnutls/test/https-test-client.py @ 4dfbedd

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

https-test-client.py: Move filter out of HTTPSubprocessConnection class

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