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

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

HTTPSubprocessConnection: Ensure subprocess exists before closing

This avoids an exception if close() is called before connect() for
some reason. If there's no subprocess there's no need to stop one.

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