• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!./python
2"""Run Python tests against multiple installations of OpenSSL and LibreSSL
3
4The script
5
6  (1) downloads OpenSSL / LibreSSL tar bundle
7  (2) extracts it to ./src
8  (3) compiles OpenSSL / LibreSSL
9  (4) installs OpenSSL / LibreSSL into ../multissl/$LIB/$VERSION/
10  (5) forces a recompilation of Python modules using the
11      header and library files from ../multissl/$LIB/$VERSION/
12  (6) runs Python's test suite
13
14The script must be run with Python's build directory as current working
15directory.
16
17The script uses LD_RUN_PATH, LD_LIBRARY_PATH, CPPFLAGS and LDFLAGS to bend
18search paths for header files and shared libraries. It's known to work on
19Linux with GCC and clang.
20
21Please keep this script compatible with Python 2.7, and 3.4 to 3.7.
22
23(c) 2013-2017 Christian Heimes <christian@python.org>
24"""
25from __future__ import print_function
26
27import argparse
28from datetime import datetime
29import logging
30import os
31try:
32    from urllib.request import urlopen
33    from urllib.error import HTTPError
34except ImportError:
35    from urllib2 import urlopen, HTTPError
36import shutil
37import string
38import subprocess
39import sys
40import tarfile
41
42
43log = logging.getLogger("multissl")
44
45OPENSSL_OLD_VERSIONS = [
46    "1.0.2u",
47    "1.1.0l",
48]
49
50OPENSSL_RECENT_VERSIONS = [
51    "1.1.1g",
52    # "3.0.0-alpha2"
53]
54
55LIBRESSL_OLD_VERSIONS = [
56    "2.9.2",
57]
58
59LIBRESSL_RECENT_VERSIONS = [
60    "3.1.0",
61]
62
63# store files in ../multissl
64HERE = os.path.dirname(os.path.abspath(__file__))
65PYTHONROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
66MULTISSL_DIR = os.path.abspath(os.path.join(PYTHONROOT, '..', 'multissl'))
67
68
69parser = argparse.ArgumentParser(
70    prog='multissl',
71    description=(
72        "Run CPython tests with multiple OpenSSL and LibreSSL "
73        "versions."
74    )
75)
76parser.add_argument(
77    '--debug',
78    action='store_true',
79    help="Enable debug logging",
80)
81parser.add_argument(
82    '--disable-ancient',
83    action='store_true',
84    help="Don't test OpenSSL and LibreSSL versions without upstream support",
85)
86parser.add_argument(
87    '--openssl',
88    nargs='+',
89    default=(),
90    help=(
91        "OpenSSL versions, defaults to '{}' (ancient: '{}') if no "
92        "OpenSSL and LibreSSL versions are given."
93    ).format(OPENSSL_RECENT_VERSIONS, OPENSSL_OLD_VERSIONS)
94)
95parser.add_argument(
96    '--libressl',
97    nargs='+',
98    default=(),
99    help=(
100        "LibreSSL versions, defaults to '{}' (ancient: '{}') if no "
101        "OpenSSL and LibreSSL versions are given."
102    ).format(LIBRESSL_RECENT_VERSIONS, LIBRESSL_OLD_VERSIONS)
103)
104parser.add_argument(
105    '--tests',
106    nargs='*',
107    default=(),
108    help="Python tests to run, defaults to all SSL related tests.",
109)
110parser.add_argument(
111    '--base-directory',
112    default=MULTISSL_DIR,
113    help="Base directory for OpenSSL / LibreSSL sources and builds."
114)
115parser.add_argument(
116    '--no-network',
117    action='store_false',
118    dest='network',
119    help="Disable network tests."
120)
121parser.add_argument(
122    '--steps',
123    choices=['library', 'modules', 'tests'],
124    default='tests',
125    help=(
126        "Which steps to perform. 'library' downloads and compiles OpenSSL "
127        "or LibreSSL. 'module' also compiles Python modules. 'tests' builds "
128        "all and runs the test suite."
129    )
130)
131parser.add_argument(
132    '--system',
133    default='',
134    help="Override the automatic system type detection."
135)
136parser.add_argument(
137    '--force',
138    action='store_true',
139    dest='force',
140    help="Force build and installation."
141)
142parser.add_argument(
143    '--keep-sources',
144    action='store_true',
145    dest='keep_sources',
146    help="Keep original sources for debugging."
147)
148
149OPENSSL_FIPS_CNF = """\
150openssl_conf = openssl_init
151
152.include {self.install_dir}/ssl/fipsinstall.cnf
153# .include {self.install_dir}/ssl/openssl.cnf
154
155[openssl_init]
156providers = provider_sect
157
158[provider_sect]
159fips = fips_sect
160default = default_sect
161
162[default_sect]
163activate = 1
164"""
165
166
167class AbstractBuilder(object):
168    library = None
169    url_templates = None
170    src_template = None
171    build_template = None
172    install_target = 'install'
173
174    module_files = ("Modules/_ssl.c",
175                    "Modules/_hashopenssl.c")
176    module_libs = ("_ssl", "_hashlib")
177
178    def __init__(self, version, args):
179        self.version = version
180        self.args = args
181        # installation directory
182        self.install_dir = os.path.join(
183            os.path.join(args.base_directory, self.library.lower()), version
184        )
185        # source file
186        self.src_dir = os.path.join(args.base_directory, 'src')
187        self.src_file = os.path.join(
188            self.src_dir, self.src_template.format(version))
189        # build directory (removed after install)
190        self.build_dir = os.path.join(
191            self.src_dir, self.build_template.format(version))
192        self.system = args.system
193
194    def __str__(self):
195        return "<{0.__class__.__name__} for {0.version}>".format(self)
196
197    def __eq__(self, other):
198        if not isinstance(other, AbstractBuilder):
199            return NotImplemented
200        return (
201            self.library == other.library
202            and self.version == other.version
203        )
204
205    def __hash__(self):
206        return hash((self.library, self.version))
207
208    @property
209    def short_version(self):
210        """Short version for OpenSSL download URL"""
211        return None
212
213    @property
214    def openssl_cli(self):
215        """openssl CLI binary"""
216        return os.path.join(self.install_dir, "bin", "openssl")
217
218    @property
219    def openssl_version(self):
220        """output of 'bin/openssl version'"""
221        cmd = [self.openssl_cli, "version"]
222        return self._subprocess_output(cmd)
223
224    @property
225    def pyssl_version(self):
226        """Value of ssl.OPENSSL_VERSION"""
227        cmd = [
228            sys.executable,
229            '-c', 'import ssl; print(ssl.OPENSSL_VERSION)'
230        ]
231        return self._subprocess_output(cmd)
232
233    @property
234    def include_dir(self):
235        return os.path.join(self.install_dir, "include")
236
237    @property
238    def lib_dir(self):
239        return os.path.join(self.install_dir, "lib")
240
241    @property
242    def has_openssl(self):
243        return os.path.isfile(self.openssl_cli)
244
245    @property
246    def has_src(self):
247        return os.path.isfile(self.src_file)
248
249    def _subprocess_call(self, cmd, env=None, **kwargs):
250        log.debug("Call '{}'".format(" ".join(cmd)))
251        return subprocess.check_call(cmd, env=env, **kwargs)
252
253    def _subprocess_output(self, cmd, env=None, **kwargs):
254        log.debug("Call '{}'".format(" ".join(cmd)))
255        if env is None:
256            env = os.environ.copy()
257            env["LD_LIBRARY_PATH"] = self.lib_dir
258        out = subprocess.check_output(cmd, env=env, **kwargs)
259        return out.strip().decode("utf-8")
260
261    def _download_src(self):
262        """Download sources"""
263        src_dir = os.path.dirname(self.src_file)
264        if not os.path.isdir(src_dir):
265            os.makedirs(src_dir)
266        data = None
267        for url_template in self.url_templates:
268            url = url_template.format(v=self.version, s=self.short_version)
269            log.info("Downloading from {}".format(url))
270            try:
271                req = urlopen(url)
272                # KISS, read all, write all
273                data = req.read()
274            except HTTPError as e:
275                log.error(
276                    "Download from {} has from failed: {}".format(url, e)
277                )
278            else:
279                log.info("Successfully downloaded from {}".format(url))
280                break
281        if data is None:
282            raise ValueError("All download URLs have failed")
283        log.info("Storing {}".format(self.src_file))
284        with open(self.src_file, "wb") as f:
285            f.write(data)
286
287    def _unpack_src(self):
288        """Unpack tar.gz bundle"""
289        # cleanup
290        if os.path.isdir(self.build_dir):
291            shutil.rmtree(self.build_dir)
292        os.makedirs(self.build_dir)
293
294        tf = tarfile.open(self.src_file)
295        name = self.build_template.format(self.version)
296        base = name + '/'
297        # force extraction into build dir
298        members = tf.getmembers()
299        for member in list(members):
300            if member.name == name:
301                members.remove(member)
302            elif not member.name.startswith(base):
303                raise ValueError(member.name, base)
304            member.name = member.name[len(base):].lstrip('/')
305        log.info("Unpacking files to {}".format(self.build_dir))
306        tf.extractall(self.build_dir, members)
307
308    def _build_src(self):
309        """Now build openssl"""
310        log.info("Running build in {}".format(self.build_dir))
311        cwd = self.build_dir
312        cmd = [
313            "./config",
314            "shared", "--debug",
315            "--prefix={}".format(self.install_dir)
316        ]
317        # cmd.extend(["no-deprecated", "--api=1.1.0"])
318        env = os.environ.copy()
319        # set rpath
320        env["LD_RUN_PATH"] = self.lib_dir
321        if self.system:
322            env['SYSTEM'] = self.system
323        self._subprocess_call(cmd, cwd=cwd, env=env)
324        # Old OpenSSL versions do not support parallel builds.
325        self._subprocess_call(["make", "-j1"], cwd=cwd, env=env)
326
327    def _make_install(self):
328        self._subprocess_call(
329            ["make", "-j1", self.install_target],
330            cwd=self.build_dir
331        )
332        self._post_install()
333        if not self.args.keep_sources:
334            shutil.rmtree(self.build_dir)
335
336    def _post_install(self):
337        pass
338
339    def install(self):
340        log.info(self.openssl_cli)
341        if not self.has_openssl or self.args.force:
342            if not self.has_src:
343                self._download_src()
344            else:
345                log.debug("Already has src {}".format(self.src_file))
346            self._unpack_src()
347            self._build_src()
348            self._make_install()
349        else:
350            log.info("Already has installation {}".format(self.install_dir))
351        # validate installation
352        version = self.openssl_version
353        if self.version not in version:
354            raise ValueError(version)
355
356    def recompile_pymods(self):
357        log.warning("Using build from {}".format(self.build_dir))
358        # force a rebuild of all modules that use OpenSSL APIs
359        for fname in self.module_files:
360            os.utime(fname, None)
361        # remove all build artefacts
362        for root, dirs, files in os.walk('build'):
363            for filename in files:
364                if filename.startswith(self.module_libs):
365                    os.unlink(os.path.join(root, filename))
366
367        # overwrite header and library search paths
368        env = os.environ.copy()
369        env["CPPFLAGS"] = "-I{}".format(self.include_dir)
370        env["LDFLAGS"] = "-L{}".format(self.lib_dir)
371        # set rpath
372        env["LD_RUN_PATH"] = self.lib_dir
373
374        log.info("Rebuilding Python modules")
375        cmd = [sys.executable, "setup.py", "build"]
376        self._subprocess_call(cmd, env=env)
377        self.check_imports()
378
379    def check_imports(self):
380        cmd = [sys.executable, "-c", "import _ssl; import _hashlib"]
381        self._subprocess_call(cmd)
382
383    def check_pyssl(self):
384        version = self.pyssl_version
385        if self.version not in version:
386            raise ValueError(version)
387
388    def run_python_tests(self, tests, network=True):
389        if not tests:
390            cmd = [sys.executable, 'Lib/test/ssltests.py', '-j0']
391        elif sys.version_info < (3, 3):
392            cmd = [sys.executable, '-m', 'test.regrtest']
393        else:
394            cmd = [sys.executable, '-m', 'test', '-j0']
395        if network:
396            cmd.extend(['-u', 'network', '-u', 'urlfetch'])
397        cmd.extend(['-w', '-r'])
398        cmd.extend(tests)
399        self._subprocess_call(cmd, stdout=None)
400
401
402class BuildOpenSSL(AbstractBuilder):
403    library = "OpenSSL"
404    url_templates = (
405        "https://www.openssl.org/source/openssl-{v}.tar.gz",
406        "https://www.openssl.org/source/old/{s}/openssl-{v}.tar.gz"
407    )
408    src_template = "openssl-{}.tar.gz"
409    build_template = "openssl-{}"
410    # only install software, skip docs
411    install_target = 'install_sw'
412
413    def _post_install(self):
414        if self.version.startswith("3.0"):
415            self._post_install_300()
416
417    def _post_install_300(self):
418        # create ssl/ subdir with example configs
419        self._subprocess_call(
420            ["make", "-j1", "install_ssldirs"],
421            cwd=self.build_dir
422        )
423        # Install FIPS module
424        # https://wiki.openssl.org/index.php/OpenSSL_3.0#Completing_the_installation_of_the_FIPS_Module
425        fipsinstall_cnf = os.path.join(
426            self.install_dir, "ssl", "fipsinstall.cnf"
427        )
428        openssl_fips_cnf = os.path.join(
429            self.install_dir, "ssl", "openssl-fips.cnf"
430        )
431        fips_mod = os.path.join(self.lib_dir, "ossl-modules/fips.so")
432        self._subprocess_call(
433            [
434                self.openssl_cli, "fipsinstall",
435                "-out", fipsinstall_cnf,
436                "-module", fips_mod,
437                "-provider_name", "fips",
438                "-mac_name", "HMAC",
439                "-macopt", "digest:SHA256",
440                "-macopt", "hexkey:00",
441                "-section_name", "fips_sect"
442            ]
443        )
444        with open(openssl_fips_cnf, "w") as f:
445            f.write(OPENSSL_FIPS_CNF.format(self=self))
446    @property
447    def short_version(self):
448        """Short version for OpenSSL download URL"""
449        short_version = self.version.rstrip(string.ascii_letters)
450        if short_version.startswith("0.9"):
451            short_version = "0.9.x"
452        return short_version
453
454
455class BuildLibreSSL(AbstractBuilder):
456    library = "LibreSSL"
457    url_templates = (
458        "https://ftp.openbsd.org/pub/OpenBSD/LibreSSL/libressl-{v}.tar.gz",
459    )
460    src_template = "libressl-{}.tar.gz"
461    build_template = "libressl-{}"
462
463
464def configure_make():
465    if not os.path.isfile('Makefile'):
466        log.info('Running ./configure')
467        subprocess.check_call([
468            './configure', '--config-cache', '--quiet',
469            '--with-pydebug'
470        ])
471
472    log.info('Running make')
473    subprocess.check_call(['make', '--quiet'])
474
475
476def main():
477    args = parser.parse_args()
478    if not args.openssl and not args.libressl:
479        args.openssl = list(OPENSSL_RECENT_VERSIONS)
480        args.libressl = list(LIBRESSL_RECENT_VERSIONS)
481        if not args.disable_ancient:
482            args.openssl.extend(OPENSSL_OLD_VERSIONS)
483            args.libressl.extend(LIBRESSL_OLD_VERSIONS)
484
485    logging.basicConfig(
486        level=logging.DEBUG if args.debug else logging.INFO,
487        format="*** %(levelname)s %(message)s"
488    )
489
490    start = datetime.now()
491
492    if args.steps in {'modules', 'tests'}:
493        for name in ['setup.py', 'Modules/_ssl.c']:
494            if not os.path.isfile(os.path.join(PYTHONROOT, name)):
495                parser.error(
496                    "Must be executed from CPython build dir"
497                )
498        if not os.path.samefile('python', sys.executable):
499            parser.error(
500                "Must be executed with ./python from CPython build dir"
501            )
502        # check for configure and run make
503        configure_make()
504
505    # download and register builder
506    builds = []
507
508    for version in args.openssl:
509        build = BuildOpenSSL(
510            version,
511            args
512        )
513        build.install()
514        builds.append(build)
515
516    for version in args.libressl:
517        build = BuildLibreSSL(
518            version,
519            args
520        )
521        build.install()
522        builds.append(build)
523
524    if args.steps in {'modules', 'tests'}:
525        for build in builds:
526            try:
527                build.recompile_pymods()
528                build.check_pyssl()
529                if args.steps == 'tests':
530                    build.run_python_tests(
531                        tests=args.tests,
532                        network=args.network,
533                    )
534            except Exception as e:
535                log.exception("%s failed", build)
536                print("{} failed: {}".format(build, e), file=sys.stderr)
537                sys.exit(2)
538
539    log.info("\n{} finished in {}".format(
540            args.steps.capitalize(),
541            datetime.now() - start
542        ))
543    print('Python: ', sys.version)
544    if args.steps == 'tests':
545        if args.tests:
546            print('Executed Tests:', ' '.join(args.tests))
547        else:
548            print('Executed all SSL tests.')
549
550    print('OpenSSL / LibreSSL versions:')
551    for build in builds:
552        print("    * {0.library} {0.version}".format(build))
553
554
555if __name__ == "__main__":
556    main()
557