source: mod_gnutls/test/https-test-client.py @ 03426e0

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

https-test-client.py: Prototype of output stream filter for gnutls-cli

The filter removes a log line about loading client certificates that
is mistakenly sent to stdout. My fix for gnutls-cli has been merged,
but buggy binaries will probably be around for a while.

See: https://gitlab.com/gnutls/gnutls/merge_requests/1125

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