Changeset 6d3dc34 in mod_gnutls


Ignore:
Timestamp:
Dec 4, 2019, 12:57:11 PM (10 months ago)
Author:
Fiona Klute <fiona.klute@…>
Branches:
master, proxy-ticket
Children:
42097fb
Parents:
a2d1bb9
Message:

Split infrastructure from https-test-client.py into modules

Location:
test
Files:
3 added
3 edited

Legend:

Unmodified
Added
Removed
  • test/.gitignore

    ra2d1bb9 r6d3dc34  
    66authority/client/uid
    77authority/tofu.db
    8 /__pycache__/
     8__pycache__/
    99
    1010# generated X.509 data
  • test/Makefile.am

    ra2d1bb9 r6d3dc34  
    5454endif
    5555
     56# Python tools for tests
     57noinst_PYTHON = https-test-client.py mgstest/http.py mgstest/__init__.py \
     58        mgstest/tests.py
     59
    5660# Identities in the miniature CA, server, and client environment for
    5761# the test suite
     
    249253EXTRA_DIST = $(apache_data) $(cert_templates) $(shared_identities:=/uid.in) \
    250254        apache_service.bash common.bash runtests authority/server/crl.template \
    251         softhsm.bash https-test-client.py
     255        softhsm.bash
    252256
    253257# Lockfile for the main Apache process
  • test/https-test-client.py

    ra2d1bb9 r6d3dc34  
    1616# limitations under the License.
    1717
    18 import re
    19 import socket
    20 import subprocess
    2118import yaml
    2219
    23 from http.client import HTTPConnection
    24 from multiprocessing import Process
    25 from time import sleep
    26 
    27 class HTTPSubprocessConnection(HTTPConnection):
    28     def __init__(self, command, host, port=None,
    29                  output_filter=None,
    30                  timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
    31                  blocksize=8192):
    32         super(HTTPSubprocessConnection, self).__init__(host, port, timeout,
    33                                                        source_address=None,
    34                                                        blocksize=blocksize)
    35         # "command" must be a list containing binary and command line
    36         # parameters
    37         self.command = command
    38         # This will be the subprocess reference when connected
    39         self._sproc = None
    40         # The subprocess return code is stored here on close()
    41         self.returncode = None
    42         # The set_tunnel method of the super class is not supported
    43         # (see exception doc)
    44         self.set_tunnel = None
    45         # This method will be run in a separate process and filter the
    46         # stdout of self._sproc. Its arguments are self._sproc.stdout
    47         # and the socket back to the HTTP connection (write-only).
    48         self._output_filter = output_filter
    49         # output filter process
    50         self._fproc = None
    51 
    52     def connect(self):
    53         s_local, s_remote = socket.socketpair(socket.AF_UNIX,
    54                                               socket.SOCK_STREAM)
    55         s_local.settimeout(self.timeout)
    56 
    57         # TODO: Maybe capture stderr?
    58         if self._output_filter:
    59             self._sproc = subprocess.Popen(self.command, stdout=subprocess.PIPE,
    60                                            stdin=s_remote, close_fds=True,
    61                                            bufsize=0)
    62             self._fproc = Process(target=self._output_filter,
    63                                   args=(self._sproc.stdout, s_remote))
    64             self._fproc.start()
    65         else:
    66             self._sproc = subprocess.Popen(self.command, stdout=s_remote,
    67                                            stdin=s_remote, close_fds=True,
    68                                            bufsize=0)
    69         s_remote.close()
    70         self.sock = s_local
    71 
    72     def close(self):
    73         # close socket to subprocess for writing
    74         if self.sock:
    75             self.sock.shutdown(socket.SHUT_WR)
    76 
    77         # Wait for the process to stop, send SIGTERM/SIGKILL if
    78         # necessary
    79         if self._sproc:
    80             try:
    81                 self.returncode = self._sproc.wait(self.timeout)
    82             except subprocess.TimeoutExpired:
    83                 try:
    84                     self._sproc.terminate()
    85                     self.returncode = self._sproc.wait(self.timeout)
    86                 except subprocess.TimeoutExpired:
    87                     self._sproc.kill()
    88                     self.returncode = self._sproc.wait(self.timeout)
    89 
    90         # filter process receives HUP on pipe when the subprocess
    91         # terminates
    92         if self._fproc:
    93             self._fproc.join()
    94 
    95         # close the connection in the super class, which also calls
    96         # self.sock.close()
    97         super().close()
    98 
    99 
    100 
    101 class TestRequest(yaml.YAMLObject):
    102     yaml_tag = '!request'
    103     def __init__(self, path, method='GET', headers=dict(),
    104                  expect=dict(status=200)):
    105         self.method = method
    106         self.path = path
    107         self.headers = headers
    108         self.expect = expect
    109 
    110     def __repr__(self):
    111         return (f'{self.__class__.__name__!s}(path={self.path!r}, '
    112                 f'method={self.method!r}, headers={self.headers!r}, '
    113                 f'expect={self.expect!r})')
    114 
    115     def run(self, conn):
    116         try:
    117             conn.request(self.method, self.path, headers=self.headers)
    118             resp = conn.getresponse()
    119         except ConnectionResetError as err:
    120             if self.expects_conn_reset():
    121                 print('connection reset as expected.')
    122                 return
    123             else:
    124                 raise err
    125         body = resp.read().decode()
    126         print(format_response(resp, body))
    127         self.check_response(resp, body)
    128 
    129     def check_body(self, body):
    130         """
    131         >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
    132         >>> r1.check_body('test\\n')
    133         >>> r1.check_body('xyz\\n')
    134         Traceback (most recent call last):
    135         ...
    136         https-test-client.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
    137         >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
    138         >>> r2.check_body('test\\n')
    139         >>> r2.check_body('est\\n')
    140         Traceback (most recent call last):
    141         ...
    142         https-test-client.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
    143         >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
    144         >>> r3.check_body('test\\n')
    145         """
    146         if 'exactly' in self.expect['body'] \
    147            and body != self.expect['body']['exactly']:
    148             raise TestExpectationFailed(
    149                 f'Unexpected body: {body!r} != '
    150                 f'{self.expect["body"]["exactly"]!r}')
    151         if 'contains' in self.expect['body']:
    152             if type(self.expect['body']['contains']) is str:
    153                 self.expect['body']['contains'] = [
    154                     self.expect['body']['contains']]
    155             for s in self.expect['body']['contains']:
    156                 if not s in body:
    157                     raise TestExpectationFailed(
    158                         f'Unexpected body: {body!r} does not contain '
    159                         f'{s!r}')
    160 
    161     def check_response(self, response, body):
    162         if self.expects_conn_reset():
    163             raise TestExpectationFailed(
    164                 'Got a response, but connection should have failed!')
    165         if response.status != self.expect['status']:
    166             raise TestExpectationFailed(
    167                 f'Unexpected status: {response.status} != '
    168                 f'{self.expect["status"]}')
    169         if 'body' in self.expect:
    170             self.check_body(body)
    171 
    172     def expects_conn_reset(self):
    173         """Returns True if running this request is expected to fail due to the
    174         connection being reset. That usually means the underlying TLS
    175         connection failed.
    176 
    177         >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
    178         >>> r1.expects_conn_reset()
    179         False
    180         >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
    181         >>> r2.expects_conn_reset()
    182         True
    183         """
    184         if 'reset' in self.expect:
    185             return self.expect['reset']
    186         return False
    187 
    188     @classmethod
    189     def _from_yaml(cls, loader, node):
    190         fields = loader.construct_mapping(node)
    191         req = TestRequest(**fields)
    192         return req
    193 
    194 class TestConnection(yaml.YAMLObject):
    195     yaml_tag = '!connection'
    196 
    197     def __init__(self, actions, gnutls_params=[], transport='gnutls'):
    198         self.gnutls_params = gnutls_params
    199         self.actions = actions
    200         self.transport = transport
    201 
    202     def __repr__(self):
    203         return (f'{self.__class__.__name__!s}'
    204                 f'(gnutls_params={self.gnutls_params!r}, '
    205                 f'actions={self.actions!r}, transport={self.transport!r})')
    206 
    207     def run(self, host, port, timeout=5.0):
    208         # note: "--logfile" option requires GnuTLS version >= 3.6.7
    209         command = ['gnutls-cli', '--logfile=/dev/stderr']
    210         for s in self.gnutls_params:
    211             command.append('--' + s)
    212         command = command + ['-p', str(port), host]
    213 
    214         conn = HTTPSubprocessConnection(command, host, port,
    215                                         output_filter=filter_cert_log,
    216                                         timeout=timeout)
    217 
    218         try:
    219             for act in self.actions:
    220                 if type(act) is TestRequest:
    221                     act.run(conn)
    222                 elif type(act) is TestRaw10:
    223                     act.run(command, timeout)
    224                 else:
    225                     raise TypeError(f'Unsupported action requested: {act!r}')
    226         finally:
    227             conn.close()
    228 
    229     @classmethod
    230     def _from_yaml(cls, loader, node):
    231         fields = loader.construct_mapping(node)
    232         conn = TestConnection(**fields)
    233         return conn
    234 
    235 class TestRaw10(TestRequest):
    236     """This is a minimal (and likely incomplete) HTTP/1.0 test client for
    237     the one test case that strictly requires HTTP/1.0. All request
    238     parameters (method, path, headers) MUST be specified in the config
    239     file.
    240 
    241     """
    242     yaml_tag = '!raw10'
    243     status_re = re.compile('^HTTP/([\d\.]+) (\d+) (.*)$')
    244 
    245     def __init__(self, method, path, headers, expect):
    246         self.method = method
    247         self.path = path
    248         self.headers = headers
    249         self.expect = expect
    250 
    251     def __repr__(self):
    252         return (f'{self.__class__.__name__!s}'
    253                 f'(method={self.method!r}, path={self.path!r}, '
    254                 f'headers={self.headers!r}, expect={self.expect!r})')
    255 
    256     def run(self, command, timeout=None):
    257         req = f'{self.method} {self.path} HTTP/1.0\r\n'
    258         for name, value in self.headers.items():
    259             req = req + f'{name}: {value}\r\n'
    260         req = req + f'\r\n'
    261         proc = subprocess.Popen(command, stdout=subprocess.PIPE,
    262                                 stdin=subprocess.PIPE, close_fds=True,
    263                                 bufsize=0)
    264         try:
    265             # Note: errs will be empty because stderr is not captured
    266             outs, errs = proc.communicate(input=req.encode(),
    267                                           timeout=timeout)
    268         except TimeoutExpired:
    269             proc.kill()
    270             outs, errs = proc.communicate()
    271 
    272         # first line of the received data must be the status
    273         status, rest = outs.decode().split('\r\n', maxsplit=1)
    274         # headers and body are separated by double newline
    275         headers, body = rest.split('\r\n\r\n', maxsplit=1)
    276         # log response for debugging
    277         print(f'{status}\n{headers}\n\n{body}')
    278 
    279         m = self.status_re.match(status)
    280         if m:
    281             status_code = int(m.group(2))
    282             status_expect = self.expect.get('status')
    283             if status_expect and not status_code == status_expect:
    284                 raise TestExpectationFailed('Unexpected status code: '
    285                                             f'{status}, expected '
    286                                             f'{status_expect}')
    287         else:
    288             raise TestExpectationFailed(f'Invalid status line: "{status}"')
    289 
    290         if 'body' in self.expect:
    291             self.check_body(body)
    292 
    293 # Override the default constructors. Pyyaml ignores default parameters
    294 # otherwise.
    295 yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader)
    296 yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader)
    297 
    298 
    299 
    300 class TestExpectationFailed(Exception):
    301     """Raise if a test failed. The constructor should be called with a
    302     string describing the problem."""
    303     pass
    304 
    305 
    306 
    307 def filter_cert_log(in_stream, out_stream):
    308     import fcntl
    309     import os
    310     import select
    311     # This filters out a log line about loading client
    312     # certificates that is mistakenly sent to stdout. My fix has
    313     # been merged, but buggy binaries will probably be around for
    314     # a while.
    315     # https://gitlab.com/gnutls/gnutls/merge_requests/1125
    316     cert_log = b'Processed 1 client X.509 certificates...\n'
    317 
    318     # Set the input to non-blocking mode
    319     fd = in_stream.fileno()
    320     fl = fcntl.fcntl(fd, fcntl.F_GETFL)
    321     fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
    322 
    323     # The poll object allows waiting for events on non-blocking IO
    324     # channels.
    325     poller = select.poll()
    326     poller.register(fd)
    327 
    328     init_done = False
    329     run_loop = True
    330     while run_loop:
    331         # The returned tuples are file descriptor and event, but
    332         # we're only listening on one stream anyway, so we don't
    333         # need to check it here.
    334         for x, event in poller.poll():
    335             # Critical: "event" is a bitwise OR of the POLL* constants
    336             if event & select.POLLIN or event & select.POLLPRI:
    337                 data = in_stream.read()
    338                 if not init_done:
    339                     # If the erroneous log line shows up it's the
    340                     # first piece of data we receive. Just copy
    341                     # everything after.
    342                     init_done = True
    343                     if cert_log in data:
    344                         data = data.replace(cert_log, b'')
    345                 out_stream.send(data)
    346             if event & select.POLLHUP or event & select.POLLRDHUP:
    347                 # Stop the loop, but process any other events that
    348                 # might be in the list returned by poll() first.
    349                 run_loop = False
    350 
    351     in_stream.close()
    352     out_stream.close()
    353 
    354 
    355 
    356 def format_response(resp, body):
    357     s = f'{resp.status} {resp.reason}\n'
    358     s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
    359     s = s + '\n\n' + body
    360     return s
    361 
    362 
     20from mgstest.tests import TestConnection
    36321
    36422if __name__ == "__main__":
Note: See TracChangeset for help on using the changeset viewer.