diff --git a/.gitignore b/.gitignore index ba843d9cc..1becea3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ letsencrypt.log # auth --cert-path --chain-path /*.pem + +# letstest +tests/letstest/letest-*/ +tests/letstest/*.pem diff --git a/.travis.yml b/.travis.yml index 8dde06ceb..67da27d00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,14 @@ language: python +cache: + directories: + - $HOME/.cache/pip + services: - rabbitmq - mariadb + # apacheconftest + #- apache2 # http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS # gimme has to be kept in sync with Boulder's Go version setting in .travis.yml @@ -17,12 +23,30 @@ env: global: - GOPATH=/tmp/go - PATH=$GOPATH/bin:$PATH - matrix: - - TOXENV=py26 BOULDER_INTEGRATION=1 - - TOXENV=py27 BOULDER_INTEGRATION=1 - - TOXENV=lint - - TOXENV=cover - +matrix: + include: + - python: "2.6" + env: TOXENV=py26 BOULDER_INTEGRATION=1 + - python: "2.6" + env: TOXENV=py26-oldest BOULDER_INTEGRATION=1 +# Disabled for now due to requiring sudo -> causing more boulder integration +# DNS timeouts :( +# - python: "2.7" +# env: TOXENV=apacheconftest + - python: "2.7" + env: TOXENV=py27 BOULDER_INTEGRATION=1 + - python: "2.7" + env: TOXENV=py27-oldest BOULDER_INTEGRATION=1 + - python: "2.7" + env: TOXENV=cover + - python: "2.7" + env: TOXENV=lint + - python: "3.3" + env: TOXENV=py33 + - python: "3.4" + env: TOXENV=py34 + - python: "3.5" + env: TOXENV=py35 # Only build pushes to the master branch, PRs, and branches beginning with # `test-`. This reduces the number of simultaneous Travis runs, which speeds @@ -44,7 +68,6 @@ addons: sources: - augeas packages: # keep in sync with bootstrap/ubuntu.sh and Boulder - - python - python-dev - python-virtualenv - gcc @@ -58,6 +81,12 @@ addons: - openssl # For Boulder integration testing - rsyslog + # for apacheconftest + #- realpath + #- apache2 + #- libapache2-mod-wsgi + #- libapache2-mod-macro + #- sudo install: "travis_retry pip install tox coveralls" script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/travis-integration.sh)' diff --git a/DISCLAIMER b/DISCLAIMER deleted file mode 120000 index e580554ff..000000000 --- a/DISCLAIMER +++ /dev/null @@ -1 +0,0 @@ -letsencrypt/DISCLAIMER \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 02aa0f0d7..da0110604 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,6 @@ COPY letsencrypt-apache /opt/letsencrypt/src/letsencrypt-apache/ COPY letsencrypt-nginx /opt/letsencrypt/src/letsencrypt-nginx/ -# py26reqs.txt not installed! RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ /opt/letsencrypt/venv/bin/pip install \ -e /opt/letsencrypt/src/acme \ diff --git a/Dockerfile-dev b/Dockerfile-dev index 838b60e8b..61908d470 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -22,8 +22,8 @@ WORKDIR /opt/letsencrypt # TODO: Install non-default Python versions for tox. # TODO: Install Apache/Nginx for plugin development. -COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ubuntu.sh -RUN /opt/letsencrypt/src/ubuntu.sh && \ +COPY letsencrypt-auto-source/letsencrypt-auto /opt/letsencrypt/src/letsencrypt-auto +RUN /opt/letsencrypt/src/letsencrypt-auto --os-packages-only && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* \ /tmp/* \ @@ -32,8 +32,7 @@ RUN /opt/letsencrypt/src/ubuntu.sh && \ # the above is not likely to change, so by putting it further up the # Dockerfile we make sure we cache as much as possible -# py26reqs.txt not installed! -COPY setup.py README.rst CHANGES.rst MANIFEST.in DISCLAIMER linter_plugin.py tox.cover.sh tox.ini pep8.travis.sh .pep8 .pylintrc /opt/letsencrypt/src/ +COPY setup.py README.rst CHANGES.rst MANIFEST.in linter_plugin.py tox.cover.sh tox.ini pep8.travis.sh .pep8 .pylintrc /opt/letsencrypt/src/ # all above files are necessary for setup.py, however, package source # code directory has to be copied separately to a subdirectory... diff --git a/LICENSE.txt b/LICENSE.txt index 2ed752521..5965ec2ef 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,7 +2,7 @@ Let's Encrypt Python Client Copyright (c) Electronic Frontier Foundation and others Licensed Apache Version 2.0 -Incorporating code from nginxparser +The nginx plugin incorporates code from nginxparser Copyright (c) 2014 Fatih Erikli Licensed MIT diff --git a/MANIFEST.in b/MANIFEST.in index 5d5b0bed4..a6f9ae2b6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,10 +1,8 @@ -include py26reqs.txt include README.rst include CHANGES.rst include CONTRIBUTING.md include LICENSE.txt include linter_plugin.py -include letsencrypt/DISCLAIMER recursive-include docs * recursive-include examples * recursive-include letsencrypt/tests/testdata * diff --git a/README.rst b/README.rst index ce0d1b686..57908e90f 100644 --- a/README.rst +++ b/README.rst @@ -3,12 +3,9 @@ Disclaimer ========== -This is a **DEVELOPER PREVIEW** intended for developers and testers only. - -**DO NOT RUN THIS CODE ON A PRODUCTION SERVER. IT WILL INSTALL CERTIFICATES -SIGNED BY A TEST CA, AND WILL CAUSE CERT WARNINGS FOR USERS.** - -Browser-trusted certificates will be available in the coming months. +The Let's Encrypt Client is **BETA SOFTWARE**. It contains plenty of bugs and +rough edges, and should be tested thoroughly in staging environments before use +on production systems. For more information regarding the status of the project, please see https://letsencrypt.org. Be sure to checkout the @@ -17,38 +14,87 @@ https://letsencrypt.org. Be sure to checkout the About the Let's Encrypt Client ============================== +The Let's Encrypt Client is a fully-featured, extensible client for the Let's +Encrypt CA (or any other CA that speaks the `ACME +`_ +protocol) that can automate the tasks of obtaining certificates and +configuring webservers to use them. + +Installation +------------ + +If ``letsencrypt`` is packaged for your OS, you can install it from there, and +run it by typing ``letsencrypt``. Because not all operating systems have +packages yet, we provide a temporary solution via the ``letsencrypt-auto`` +wrapper script, which obtains some dependencies from your OS and puts others +in a python virtual environment:: + + user@webserver:~$ git clone https://github.com/letsencrypt/letsencrypt + user@webserver:~$ cd letsencrypt + user@webserver:~/letsencrypt$ ./letsencrypt-auto --help + +Or for full command line help, type:: + + ./letsencrypt-auto --help all + +``letsencrypt-auto`` updates to the latest client release automatically. And +since ``letsencrypt-auto`` is a wrapper to ``letsencrypt``, it accepts exactly +the same command line flags and arguments. More details about this script and +other installation methods can be found `in the User Guide +`_. + +How to run the client +--------------------- + +In many cases, you can just run ``letsencrypt-auto`` or ``letsencrypt``, and the +client will guide you through the process of obtaining and installing certs +interactively. + +You can also tell it exactly what you want it to do from the command line. +For instance, if you want to obtain a cert for ``thing.com``, +``www.thing.com``, and ``otherthing.net``, using the Apache plugin to both +obtain and install the certs, you could do this:: + + ./letsencrypt-auto --apache -d thing.com -d www.thing.com -d otherthing.net + +(The first time you run the command, it will make an account, and ask for an +email and agreement to the Let's Encrypt Subscriber Agreement; you can +automate those with ``--email`` and ``--agree-tos``) + +If you want to use a webserver that doesn't have full plugin support yet, you +can still use "standalone" or "webroot" plugins to obtain a certificate:: + + ./letsencrypt-auto certonly --standalone --email admin@thing.com -d thing.com -d www.thing.com -d otherthing.net + + +Understanding the client in more depth +-------------------------------------- + +To understand what the client is doing in detail, it's important to +understand the way it uses plugins. Please see the `explanation of +plugins `_ in +the User Guide. + +Links +===== + +Documentation: https://letsencrypt.readthedocs.org + +Software project: https://github.com/letsencrypt/letsencrypt + +Notes for developers: https://letsencrypt.readthedocs.org/en/latest/contributing.html + +Main Website: https://letsencrypt.org/ + +IRC Channel: #letsencrypt on `Freenode`_ + +Community: https://community.letsencrypt.org + +Mailing list: `client-dev`_ (to subscribe without a Google account, send an +email to client-dev+subscribe@letsencrypt.org) + |build-status| |coverage| |docs| |container| -In short: getting and installing SSL/TLS certificates made easy (`watch demo video`_). - -The Let's Encrypt Client is a tool to automatically receive and install -X.509 certificates to enable TLS on servers. The client will -interoperate with the Let's Encrypt CA which will be issuing browser-trusted -certificates for free. - -It's all automated: - -* The tool will prove domain control to the CA and submit a CSR (Certificate - Signing Request). -* If domain control has been proven, a certificate will get issued and the tool - will automatically install it. - -All you need to do to sign a single domain is:: - - user@www:~$ sudo letsencrypt -d www.example.org certonly - -For multiple domains (SAN) use:: - - user@www:~$ sudo letsencrypt -d www.example.org -d example.org certonly - -and if you have a compatible web server (Apache or Nginx), Let's Encrypt can -not only get a new certificate, but also deploy it and configure your -server automatically!:: - - user@www:~$ sudo letsencrypt -d www.example.org run - - -**Encrypt ALL the things!** .. |build-status| image:: https://travis-ci.org/letsencrypt/letsencrypt.svg?branch=master @@ -72,18 +118,38 @@ server automatically!:: .. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU +System Requirements +=================== + +The Let's Encrypt Client presently only runs on Unix-ish OSes that include +Python 2.6 or 2.7; Python 3.x support will be added after the Public Beta +launch. The client requires root access in order to write to +``/etc/letsencrypt``, ``/var/log/letsencrypt``, ``/var/lib/letsencrypt``; to +bind to ports 80 and 443 (if you use the ``standalone`` plugin) and to read and +modify webserver configurations (if you use the ``apache`` or ``nginx`` +plugins). If none of these apply to you, it is theoretically possible to run +without root privileges, but for most users who want to avoid running an ACME +client as root, either `letsencrypt-nosudo +`_ or `simp_le +`_ are more appropriate choices. + +The Apache plugin currently requires a Debian-based OS with augeas version +1.0; this includes Ubuntu 12.04+ and Debian 7+. + Current Features ----------------- +================ * Supports multiple web servers: - - apache/2.x (tested and working on Ubuntu Linux) - - nginx/0.8.48+ (under development) + - apache/2.x (working on Debian 8+ and Ubuntu 12.04+) - standalone (runs its own simple webserver to prove you control a domain) + - webroot (adds files to webroot directories in order to prove control of + domains and obtain certs) + - nginx/0.8.48+ (highly experimental, not included in letsencrypt-auto) * The private key is generated locally on your system. -* Can talk to the Let's Encrypt (demo) CA or optionally to other ACME +* Can talk to the Let's Encrypt CA or optionally to other ACME compliant services. * Can get domain-validated (DV) certificates. * Can revoke certificates. @@ -92,34 +158,10 @@ Current Features runs https only (Apache only) * Fully automated. * Configuration changes are logged and can be reverted. -* Text and ncurses UI. +* Supports ncurses and text (-t) UI, or can be driven entirely from the + command line. * Free and Open Source Software, made with Python. -Installation Instructions -------------------------- - -Official **documentation**, including `installation instructions`_, is -available at https://letsencrypt.readthedocs.org. - - -Links ------ - -Documentation: https://letsencrypt.readthedocs.org - -Software project: https://github.com/letsencrypt/letsencrypt - -Notes for developers: https://letsencrypt.readthedocs.org/en/latest/contributing.html - -Main Website: https://letsencrypt.org/ - -IRC Channel: #letsencrypt on `Freenode`_ - -Community: https://community.letsencrypt.org - -Mailing list: `client-dev`_ (to subscribe without a Google account, send an -email to client-dev+subscribe@letsencrypt.org) - -.. _Freenode: https://freenode.net +.. _Freenode: https://webchat.freenode.net?channels=%23letsencrypt .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev diff --git a/Vagrantfile b/Vagrantfile index a2759440c..3b9d4dc3a 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -7,7 +7,7 @@ VAGRANTFILE_API_VERSION = "2" # Setup instructions from docs/contributing.rst $ubuntu_setup_script = <?$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,logger + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis +ignored-modules= + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject + +# When zope mode is activated, add a predefined set of Zope acquired attributes +# to generated-members. +zope=no + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST,acl_users,aq_parent + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_$|dummy|unused + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + + +[BASIC] + +# Required attributes for module, separated by a comma +required-attributes= + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter,input + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_,logger + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,40}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,49}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__|test_[A-Za-z0-9_]*|_.*|.*Test + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[CLASSES] + +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. +ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=6 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=12 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/acme/acme/__init__.py b/acme/acme/__init__.py index c38cea414..e8a0b16a8 100644 --- a/acme/acme/__init__.py +++ b/acme/acme/__init__.py @@ -1,12 +1,12 @@ """ACME protocol implementation. This module is an implementation of the `ACME protocol`_. Latest -supported version: `v02`_. +supported version: `draft-ietf-acme-01`_. -.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec -.. _`v02`: - https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4 +.. _`ACME protocol`: https://ietf-wg-acme.github.io/acme +.. _`draft-ietf-acme-01`: + https://github.com/ietf-wg-acme/acme/tree/draft-ietf-acme-acme-01 """ diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 976d7ab12..13d19d3c4 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -228,15 +228,16 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): """ + WHITESPACE_CUTSET = "\n\r\t " + """Whitespace characters which should be ignored at the end of the body.""" + def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. :param challenges.SimpleHTTP chall: Corresponding challenge. :param unicode domain: Domain name being verified. - :param account_public_key: Public key for the key pair - being authorized. If ``None`` key verification is not - performed! - :param JWK account_public_key: + :param JWK account_public_key: Public key for the key pair + being authorized. :param int port: Port used in the validation. :returns: ``True`` iff validation is successful, ``False`` @@ -266,17 +267,11 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): logger.debug("Received %s: %s. Headers: %s", http_response, http_response.text, http_response.headers) - found_ct = http_response.headers.get( - "Content-Type", chall.CONTENT_TYPE) - if found_ct != chall.CONTENT_TYPE: - logger.debug("Wrong Content-Type: found %r, expected %r", - found_ct, chall.CONTENT_TYPE) - return False - - if self.key_authorization != http_response.text: + challenge_response = http_response.text.rstrip(self.WHITESPACE_CUTSET) + if self.key_authorization != challenge_response: logger.debug("Key authorization from response (%r) doesn't match " "HTTP response (%r)", self.key_authorization, - http_response.text) + challenge_response) return False return True @@ -288,9 +283,6 @@ class HTTP01(KeyAuthorizationChallenge): response_cls = HTTP01Response typ = response_cls.typ - CONTENT_TYPE = "text/plain" - """Only valid value for Content-Type if the header is included.""" - URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" @@ -342,7 +334,7 @@ class TLSSNI01Response(KeyAuthorizationChallengeResponse): """ @property - def z(self): + def z(self): # pylint: disable=invalid-name """``z`` value used for verification. :rtype bytes: @@ -397,7 +389,14 @@ class TLSSNI01Response(KeyAuthorizationChallengeResponse): return crypto_util.probe_sni(**kwargs) def verify_cert(self, cert): - """Verify tls-sni-01 challenge certificate.""" + """Verify tls-sni-01 challenge certificate. + + :param OpensSSL.crypto.X509 cert: Challenge certificate. + + :returns: Whether the certificate was successfully verified. + :rtype: bool + + """ # pylint: disable=protected-access sans = crypto_util._pyopenssl_cert_or_req_san(cert) logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index c4f3d6c61..ef78e1eba 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -13,7 +13,7 @@ from acme import other from acme import test_util -CERT = test_util.load_cert('cert.pem') +CERT = test_util.load_comparable_cert('cert.pem') KEY = jose.JWKRSA(key=test_util.load_rsa_private_key('rsa512_key.pem')) @@ -73,7 +73,8 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase): def test_verify_wrong_form(self): from acme.challenges import KeyAuthorizationChallengeResponse response = KeyAuthorizationChallengeResponse( - key_authorization='.foo.oKGqedy-b-acd5eoybm2f-NVFxvyOoET5CNy3xnv8WY') + key_authorization='.foo.oKGqedy-b-acd5eoybm2f-' + 'NVFxvyOoET5CNy3xnv8WY') self.assertFalse(response.verify(self.chall, KEY.public_key())) @@ -92,7 +93,6 @@ class HTTP01ResponseTest(unittest.TestCase): from acme.challenges import HTTP01 self.chall = HTTP01(token=(b'x' * 16)) self.response = self.chall.response(KEY) - self.good_headers = {'Content-Type': HTTP01.CONTENT_TYPE} def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -113,24 +113,26 @@ class HTTP01ResponseTest(unittest.TestCase): @mock.patch("acme.challenges.requests.get") def test_simple_verify_good_validation(self, mock_get): validation = self.chall.validation(KEY) - mock_get.return_value = mock.MagicMock( - text=validation, headers=self.good_headers) + mock_get.return_value = mock.MagicMock(text=validation) self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_get.assert_called_once_with(self.chall.uri("local")) @mock.patch("acme.challenges.requests.get") def test_simple_verify_bad_validation(self, mock_get): - mock_get.return_value = mock.MagicMock( - text="!", headers=self.good_headers) + mock_get.return_value = mock.MagicMock(text="!") self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) @mock.patch("acme.challenges.requests.get") - def test_simple_verify_bad_content_type(self, mock_get): - mock_get().text = self.chall.token - self.assertFalse(self.response.simple_verify( + def test_simple_verify_whitespace_validation(self, mock_get): + from acme.challenges import HTTP01Response + mock_get.return_value = mock.MagicMock( + text=(self.chall.validation(KEY) + + HTTP01Response.WHITESPACE_CUTSET)) + self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) + mock_get.assert_called_once_with(self.chall.uri("local")) @mock.patch("acme.challenges.requests.get") def test_simple_verify_connection_error(self, mock_get): @@ -272,10 +274,12 @@ class TLSSNI01ResponseTest(unittest.TestCase): @mock.patch('acme.challenges.TLSSNI01Response.verify_cert', autospec=True) def test_simple_verify(self, mock_verify_cert): mock_verify_cert.return_value = mock.sentinel.verification - self.assertEqual(mock.sentinel.verification, self.response.simple_verify( - self.chall, self.domain, KEY.public_key(), - cert=mock.sentinel.cert)) - mock_verify_cert.assert_called_once_with(self.response, mock.sentinel.cert) + self.assertEqual( + mock.sentinel.verification, self.response.simple_verify( + self.chall, self.domain, KEY.public_key(), + cert=mock.sentinel.cert)) + mock_verify_cert.assert_called_once_with( + self.response, mock.sentinel.cert) @mock.patch('acme.challenges.TLSSNI01Response.probe_cert') def test_simple_verify_false_on_probe_error(self, mock_probe_cert): @@ -420,7 +424,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase): 'jwk': jwk, 'certFingerprints': cert_fingerprints, 'certs': (jose.encode_b64jose(OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, CERT)),), + OpenSSL.crypto.FILETYPE_ASN1, CERT.wrapped)),), 'subjectKeyIdentifiers': subject_key_identifiers, 'serialNumbers': serial_numbers, 'issuers': issuers, @@ -589,7 +593,8 @@ class DNSTest(unittest.TestCase): def test_check_validation_wrong_fields(self): bad_validation = jose.JWS.sign( - payload=self.msg.update(token=b'x' * 20).json_dumps().encode('utf-8'), + payload=self.msg.update( + token=b'x' * 20).json_dumps().encode('utf-8'), alg=jose.RS256, key=KEY) self.assertFalse(self.msg.check_validation( bad_validation, KEY.public_key())) diff --git a/acme/acme/client.py b/acme/acme/client.py index 0e9319f9c..478536ecc 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -1,4 +1,5 @@ """ACME client API.""" +import collections import datetime import heapq import logging @@ -20,7 +21,9 @@ from acme import messages logger = logging.getLogger(__name__) -# Python does not validate certificates by default before version 2.7.9 +# Prior to Python 2.7.9 the stdlib SSL module did not allow a user to configure +# many important security related options. On these platforms we use PyOpenSSL +# for SSL, which does allow these options to be configured. # https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning if sys.version_info < (2, 7, 9): # pragma: no cover requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() @@ -64,15 +67,13 @@ class Client(object): # pylint: disable=too-many-instance-attributes @classmethod def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, terms_of_service=None): - terms_of_service = ( - response.links['terms-of-service']['url'] - if 'terms-of-service' in response.links else terms_of_service) + if 'terms-of-service' in response.links: + terms_of_service = response.links['terms-of-service']['url'] + if 'next' in response.links: + new_authzr_uri = response.links['next']['url'] if new_authzr_uri is None: - try: - new_authzr_uri = response.links['next']['url'] - except KeyError: - raise errors.ClientError('"next" link missing') + raise errors.ClientError('"next" link missing') return messages.RegistrationResource( body=messages.Registration.from_json(response.json()), @@ -246,9 +247,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes def retry_after(cls, response, default): """Compute next `poll` time based on response ``Retry-After`` header. - :param response: Response from `poll`. - :type response: `requests.Response` - + :param requests.Response response: Response from `poll`. :param int default: Default value (in seconds), used when ``Retry-After`` header is not present or invalid. @@ -323,32 +322,40 @@ class Client(object): # pylint: disable=too-many-instance-attributes body=jose.ComparableX509(OpenSSL.crypto.load_certificate( OpenSSL.crypto.FILETYPE_ASN1, response.content))) - def poll_and_request_issuance(self, csr, authzrs, mintime=5): + def poll_and_request_issuance( + self, csr, authzrs, mintime=5, max_attempts=10): """Poll and request issuance. This function polls all provided Authorization Resource URIs until all challenges are valid, respecting ``Retry-After`` HTTP headers, and then calls `request_issuance`. - .. todo:: add `max_attempts` or `timeout` - - :param csr: CSR. - :type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` - + :param .ComparableX509 csr: CSR (`OpenSSL.crypto.X509Req` + wrapped in `.ComparableX509`) :param authzrs: `list` of `.AuthorizationResource` - :param int mintime: Minimum time before next attempt, used if ``Retry-After`` is not present in the response. + :param int max_attempts: Maximum number of attempts (per + authorization) before `PollError` with non-empty ``waiting`` + is raised. :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is - the issued certificate (`.messages.CertificateResource.), + the issued certificate (`.messages.CertificateResource`), and ``updated_authzrs`` is a `tuple` consisting of updated Authorization Resources (`.AuthorizationResource`) as present in the responses from server, and in the same order as the input ``authzrs``. :rtype: `tuple` + :raises PollError: in case of timeout or if some authorization + was marked by the CA as invalid + """ + # pylint: disable=too-many-locals + assert max_attempts > 0 + attempts = collections.defaultdict(int) + exhausted = set() + # priority queue with datetime (based on Retry-After) as key, # and original Authorization Resource as value waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs] @@ -370,11 +377,20 @@ class Client(object): # pylint: disable=too-many-instance-attributes updated_authzr, response = self.poll(updated[authzr]) updated[authzr] = updated_authzr + attempts[authzr] += 1 # pylint: disable=no-member - if updated_authzr.body.status != messages.STATUS_VALID: - # push back to the priority queue, with updated retry_after - heapq.heappush(waiting, (self.retry_after( - response, default=mintime), authzr)) + if updated_authzr.body.status not in ( + messages.STATUS_VALID, messages.STATUS_INVALID): + if attempts[authzr] < max_attempts: + # push back to the priority queue, with updated retry_after + heapq.heappush(waiting, (self.retry_after( + response, default=mintime), authzr)) + else: + exhausted.add(authzr) + + if exhausted or any(authzr.body.status == messages.STATUS_INVALID + for authzr in six.itervalues(updated)): + raise errors.PollError(exhausted, updated) updated_authzrs = tuple(updated[authzr] for authzr in authzrs) return self.request_issuance(csr, updated_authzrs), updated_authzrs @@ -475,7 +491,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes 'Successful revocation must return HTTP OK status') -class ClientNetwork(object): +class ClientNetwork(object): # pylint: disable=too-many-instance-attributes """Client network.""" JSON_CONTENT_TYPE = 'application/json' JSON_ERROR_CONTENT_TYPE = 'application/problem+json' @@ -531,7 +547,7 @@ class ClientNetwork(object): # TODO: response.json() is called twice, once here, and # once in _get and _post clients jobj = response.json() - except ValueError as error: + except ValueError: jobj = None if not response.ok: diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 2df7b5313..9abc69c7c 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -34,8 +34,10 @@ class ClientTest(unittest.TestCase): self.net.get.return_value = self.response self.directory = messages.Directory({ - messages.NewRegistration: 'https://www.letsencrypt-demo.org/acme/new-reg', - messages.Revocation: 'https://www.letsencrypt-demo.org/acme/revoke-cert', + messages.NewRegistration: + 'https://www.letsencrypt-demo.org/acme/new-reg', + messages.Revocation: + 'https://www.letsencrypt-demo.org/acme/revoke-cert', }) from acme.client import Client @@ -127,6 +129,13 @@ class ClientTest(unittest.TestCase): self.response.json.return_value = self.regr.body.to_json() self.assertEqual(self.regr, self.client.query_registration(self.regr)) + def test_query_registration_updates_new_authzr_uri(self): + self.response.json.return_value = self.regr.body.to_json() + self.response.links = {'next': {'url': 'UPDATED'}} + self.assertEqual( + 'UPDATED', + self.client.query_registration(self.regr).new_authzr_uri) + def test_agree_to_tos(self): self.client.update_registration = mock.Mock() self.client.agree_to_tos(self.regr) @@ -271,9 +280,9 @@ class ClientTest(unittest.TestCase): # result, increment clock clock.dt += datetime.timedelta(seconds=2) - if not authzr.retries: # no more retries + if len(authzr.retries) == 1: # no more retries done = mock.MagicMock(uri=authzr.uri, times=authzr.times) - done.body.status = messages.STATUS_VALID + done.body.status = authzr.retries[0] return done, [] # response (2nd result tuple element) is reduced to only @@ -289,7 +298,8 @@ class ClientTest(unittest.TestCase): mintime = 7 - def retry_after(response, default): # pylint: disable=missing-docstring + def retry_after(response, default): + # pylint: disable=missing-docstring # check that poll_and_request_issuance correctly passes mintime self.assertEqual(default, mintime) return clock.dt + datetime.timedelta(seconds=response) @@ -302,12 +312,17 @@ class ClientTest(unittest.TestCase): csr = mock.MagicMock() authzrs = ( - mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)), - mock.MagicMock(uri='b', times=[], retries=(5,)), + mock.MagicMock(uri='a', times=[], retries=( + 8, 20, 30, messages.STATUS_VALID)), + mock.MagicMock(uri='b', times=[], retries=( + 5, messages.STATUS_VALID)), ) cert, updated_authzrs = self.client.poll_and_request_issuance( - csr, authzrs, mintime=mintime) + csr, authzrs, mintime=mintime, + # make sure that max_attempts is per-authorization, rather + # than global + max_attempts=max(len(authzrs[0].retries), len(authzrs[1].retries))) self.assertTrue(cert[0] is csr) self.assertTrue(cert[1] is updated_authzrs) self.assertEqual(updated_authzrs[0].uri, 'a...') @@ -327,6 +342,18 @@ class ClientTest(unittest.TestCase): ]) self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7)) + # CA sets invalid | TODO: move to a separate test + invalid_authzr = mock.MagicMock( + times=[], retries=[messages.STATUS_INVALID]) + self.assertRaises( + errors.PollError, self.client.poll_and_request_issuance, + csr, authzrs=(invalid_authzr,), mintime=mintime) + + # exceeded max_attemps | TODO: move to a separate test + self.assertRaises( + errors.PollError, self.client.poll_and_request_issuance, + csr, authzrs, mintime=mintime, max_attempts=2) + def test_check_cert(self): self.response.headers['Location'] = self.certr.uri self.response.content = CERT_DER diff --git a/acme/acme/crypto_util.py b/acme/acme/crypto_util.py index 72a93141a..73f7f8f62 100644 --- a/acme/acme/crypto_util.py +++ b/acme/acme/crypto_util.py @@ -1,11 +1,10 @@ """Crypto utilities.""" import contextlib import logging +import re import socket import sys -from six.moves import range # pylint: disable=import-error,redefined-builtin - import OpenSSL from acme import errors @@ -70,7 +69,7 @@ class SSLSocket(object): # pylint: disable=too-few-public-methods class FakeConnection(object): """Fake OpenSSL.SSL.Connection.""" - # pylint: disable=missing-docstring + # pylint: disable=too-few-public-methods,missing-docstring def __init__(self, connection): self._wrapped = connection @@ -161,31 +160,31 @@ def _pyopenssl_cert_or_req_san(cert_or_req): :rtype: `list` of `unicode` """ - # constants based on implementation of - # OpenSSL.crypto.X509Error._subjectAltNameString - parts_separator = ", " + # This function finds SANs by dumping the certificate/CSR to text and + # searching for "X509v3 Subject Alternative Name" in the text. This method + # is used to support PyOpenSSL version 0.13 where the + # `_subjectAltNameString` and `get_extensions` methods are not available + # for CSRs. + + # constants based on PyOpenSSL certificate/CSR text dump part_separator = ":" - extension_short_name = b"subjectAltName" + parts_separator = ", " + prefix = "DNS" + part_separator - if hasattr(cert_or_req, 'get_extensions'): # X509Req - extensions = cert_or_req.get_extensions() - else: # X509 - extensions = [cert_or_req.get_extension(i) - for i in range(cert_or_req.get_extension_count())] - - # pylint: disable=protected-access,no-member - label = OpenSSL.crypto.X509Extension._prefixes[OpenSSL.crypto._lib.GEN_DNS] - assert parts_separator not in label - prefix = label + part_separator - - san_extensions = [ - ext._subjectAltNameString().split(parts_separator) - for ext in extensions if ext.get_short_name() == extension_short_name] + if isinstance(cert_or_req, OpenSSL.crypto.X509): + func = OpenSSL.crypto.dump_certificate + else: + func = OpenSSL.crypto.dump_certificate_request + text = func(OpenSSL.crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8") + # WARNING: this function does not support multiple SANs extensions. + # Multiple X509v3 extensions of the same type is disallowed by RFC 5280. + match = re.search(r"X509v3 Subject Alternative Name:\s*(.*)", text) # WARNING: this function assumes that no SAN can include # parts_separator, hence the split! + sans_parts = [] if match is None else match.group(1).split(parts_separator) - return [part.split(part_separator)[1] for parts in san_extensions - for part in parts if part.startswith(prefix)] + return [part.split(part_separator)[1] + for part in sans_parts if part.startswith(prefix)] def gen_ss_cert(key, domains, not_before=None, diff --git a/acme/acme/crypto_util_test.py b/acme/acme/crypto_util_test.py index bfd16388c..147cd5a2a 100644 --- a/acme/acme/crypto_util_test.py +++ b/acme/acme/crypto_util_test.py @@ -1,9 +1,11 @@ """Tests for acme.crypto_util.""" +import itertools import socket import threading import time import unittest +import six from six.moves import socketserver # pylint: disable=import-error from acme import errors @@ -15,10 +17,10 @@ class SSLSocketAndProbeSNITest(unittest.TestCase): """Tests for acme.crypto_util.SSLSocket/probe_sni.""" def setUp(self): - self.cert = test_util.load_cert('cert.pem') + self.cert = test_util.load_comparable_cert('cert.pem') key = test_util.load_pyopenssl_private_key('rsa512_key.pem') # pylint: disable=protected-access - certs = {b'foo': (key, self.cert._wrapped)} + certs = {b'foo': (key, self.cert.wrapped)} from acme.crypto_util import SSLSocket @@ -69,6 +71,15 @@ class PyOpenSSLCertOrReqSANTest(unittest.TestCase): from acme.crypto_util import _pyopenssl_cert_or_req_san return _pyopenssl_cert_or_req_san(loader(name)) + @classmethod + def _get_idn_names(cls): + """Returns expected names from '{cert,csr}-idnsans.pem'.""" + chars = [six.unichr(i) for i in itertools.chain(range(0x3c3, 0x400), + range(0x641, 0x6fc), + range(0x1820, 0x1877))] + return [''.join(chars[i: i + 45]) + '.invalid' + for i in range(0, len(chars), 45)] + def _call_cert(self, name): return self._call(test_util.load_cert, name) @@ -82,6 +93,14 @@ class PyOpenSSLCertOrReqSANTest(unittest.TestCase): self.assertEqual(self._call_cert('cert-san.pem'), ['example.com', 'www.example.com']) + def test_cert_hundred_sans(self): + self.assertEqual(self._call_cert('cert-100sans.pem'), + ['example{0}.com'.format(i) for i in range(1, 101)]) + + def test_cert_idn_sans(self): + self.assertEqual(self._call_cert('cert-idnsans.pem'), + self._get_idn_names()) + def test_csr_no_sans(self): self.assertEqual(self._call_csr('csr-nosans.pem'), []) @@ -94,10 +113,18 @@ class PyOpenSSLCertOrReqSANTest(unittest.TestCase): def test_csr_six_sans(self): self.assertEqual(self._call_csr('csr-6sans.pem'), - ["example.com", "example.org", "example.net", - "example.info", "subdomain.example.com", - "other.subdomain.example.com"]) + ['example.com', 'example.org', 'example.net', + 'example.info', 'subdomain.example.com', + 'other.subdomain.example.com']) + + def test_csr_hundred_sans(self): + self.assertEqual(self._call_csr('csr-100sans.pem'), + ['example{0}.com'.format(i) for i in range(1, 101)]) + + def test_csr_idn_sans(self): + self.assertEqual(self._call_csr('csr-idnsans.pem'), + self._get_idn_names()) -if __name__ == "__main__": +if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/acme/acme/errors.py b/acme/acme/errors.py index 9a96ec43a..77d47c522 100644 --- a/acme/acme/errors.py +++ b/acme/acme/errors.py @@ -51,3 +51,30 @@ class MissingNonce(NonceError): return ('Server {0} response did not include a replay ' 'nonce, headers: {1}'.format( self.response.request.method, self.response.headers)) + + +class PollError(ClientError): + """Generic error when polling for authorization fails. + + This might be caused by either timeout (`exhausted` will be non-empty) + or by some authorization being invalid. + + :ivar exhausted: Set of `.AuthorizationResource` that didn't finish + within max allowed attempts. + :ivar updated: Mapping from original `.AuthorizationResource` + to the most recently updated one + + """ + def __init__(self, exhausted, updated): + self.exhausted = exhausted + self.updated = updated + super(PollError, self).__init__() + + @property + def timeout(self): + """Was the error caused by timeout?""" + return bool(self.exhausted) + + def __repr__(self): + return '{0}(exhausted={1!r}, updated={2!r})'.format( + self.__class__.__name__, self.exhausted, self.updated) diff --git a/acme/acme/errors_test.py b/acme/acme/errors_test.py index 3790d91ed..1e5f3d479 100644 --- a/acme/acme/errors_test.py +++ b/acme/acme/errors_test.py @@ -29,5 +29,25 @@ class MissingNonceTest(unittest.TestCase): self.assertTrue("{}" in str(self.error)) +class PollErrorTest(unittest.TestCase): + """Tests for acme.errors.PollError.""" + + def setUp(self): + from acme.errors import PollError + self.timeout = PollError( + exhausted=set([mock.sentinel.AR]), + updated={}) + self.invalid = PollError(exhausted=set(), updated={ + mock.sentinel.AR: mock.sentinel.AR2}) + + def test_timeout(self): + self.assertTrue(self.timeout.timeout) + self.assertFalse(self.invalid.timeout) + + def test_repr(self): + self.assertEqual('PollError(exhausted=%s, updated={sentinel.AR: ' + 'sentinel.AR2})' % repr(set()), repr(self.invalid)) + + if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/acme/acme/jose/interfaces.py b/acme/acme/jose/interfaces.py index f85777a30..f841848b3 100644 --- a/acme/acme/jose/interfaces.py +++ b/acme/acme/jose/interfaces.py @@ -194,7 +194,7 @@ class JSONDeSerializable(object): :rtype: str """ - return self.json_dumps(sort_keys=True, indent=4) + return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': ')) @classmethod def json_dump_default(cls, python_object): diff --git a/acme/acme/jose/interfaces_test.py b/acme/acme/jose/interfaces_test.py index 84dc2a1be..cf98ff371 100644 --- a/acme/acme/jose/interfaces_test.py +++ b/acme/acme/jose/interfaces_test.py @@ -1,8 +1,6 @@ """Tests for acme.jose.interfaces.""" import unittest -import six - class JSONDeSerializableTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes @@ -92,9 +90,8 @@ class JSONDeSerializableTest(unittest.TestCase): self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps()) def test_json_dumps_pretty(self): - filler = ' ' if six.PY2 else '' self.assertEqual(self.seq.json_dumps_pretty(), - '[\n "foo1",{0}\n "foo2"\n]'.format(filler)) + '[\n "foo1",\n "foo2"\n]') def test_json_dump_default(self): from acme.jose.interfaces import JSONDeSerializable diff --git a/acme/acme/jose/json_util.py b/acme/acme/jose/json_util.py index 7b95e3fce..da38b55ba 100644 --- a/acme/acme/jose/json_util.py +++ b/acme/acme/jose/json_util.py @@ -226,7 +226,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): :param str name: Name of the field to be encoded. - :raises erors.SerializationError: if field cannot be serialized + :raises errors.SerializationError: if field cannot be serialized :raises errors.Error: if field could not be found """ @@ -373,7 +373,7 @@ def encode_cert(cert): """ return encode_b64jose(OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, cert)) + OpenSSL.crypto.FILETYPE_ASN1, cert.wrapped)) def decode_cert(b64der): @@ -398,7 +398,7 @@ def encode_csr(csr): """ return encode_b64jose(OpenSSL.crypto.dump_certificate_request( - OpenSSL.crypto.FILETYPE_ASN1, csr)) + OpenSSL.crypto.FILETYPE_ASN1, csr.wrapped)) def decode_csr(b64der): diff --git a/acme/acme/jose/json_util_test.py b/acme/acme/jose/json_util_test.py index a055f3bf7..25e36211e 100644 --- a/acme/acme/jose/json_util_test.py +++ b/acme/acme/jose/json_util_test.py @@ -12,8 +12,8 @@ from acme.jose import interfaces from acme.jose import util -CERT = test_util.load_cert('cert.pem') -CSR = test_util.load_csr('csr.pem') +CERT = test_util.load_comparable_cert('cert.pem') +CSR = test_util.load_comparable_csr('csr.pem') class FieldTest(unittest.TestCase): diff --git a/acme/acme/jose/jws.py b/acme/acme/jose/jws.py index 1a073e17d..9c14cf729 100644 --- a/acme/acme/jose/jws.py +++ b/acme/acme/jose/jws.py @@ -124,7 +124,7 @@ class Header(json_util.JSONObjectWithFields): @x5c.encoder def x5c(value): # pylint: disable=missing-docstring,no-self-argument return [base64.b64encode(OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, cert)) for cert in value] + OpenSSL.crypto.FILETYPE_ASN1, cert.wrapped)) for cert in value] @x5c.decoder def x5c(value): # pylint: disable=missing-docstring,no-self-argument diff --git a/acme/acme/jose/jws_test.py b/acme/acme/jose/jws_test.py index 69341f228..ec91f6a1b 100644 --- a/acme/acme/jose/jws_test.py +++ b/acme/acme/jose/jws_test.py @@ -13,7 +13,7 @@ from acme.jose import jwa from acme.jose import jwk -CERT = test_util.load_cert('cert.pem') +CERT = test_util.load_comparable_cert('cert.pem') KEY = jwk.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) @@ -68,13 +68,12 @@ class HeaderTest(unittest.TestCase): from acme.jose.jws import Header header = Header(x5c=(CERT, CERT)) jobj = header.to_partial_json() - cert_b64 = base64.b64encode(OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, CERT)) + cert_asn1 = OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_ASN1, CERT.wrapped) + cert_b64 = base64.b64encode(cert_asn1) self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]}) self.assertEqual(header, Header.from_json(jobj)) - jobj['x5c'][0] = base64.b64encode( - b'xxx' + OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, CERT)) + jobj['x5c'][0] = base64.b64encode(b'xxx' + cert_asn1) self.assertRaises(errors.DeserializationError, Header.from_json, jobj) def test_find_key(self): diff --git a/acme/acme/jose/util.py b/acme/acme/jose/util.py index ab3606efc..6be9a6602 100644 --- a/acme/acme/jose/util.py +++ b/acme/acme/jose/util.py @@ -29,32 +29,41 @@ class abstractclassmethod(classmethod): class ComparableX509(object): # pylint: disable=too-few-public-methods """Wrapper for OpenSSL.crypto.X509** objects that supports __eq__. - Wraps around: - - - :class:`OpenSSL.crypto.X509` - - :class:`OpenSSL.crypto.X509Req` + :ivar wrapped: Wrapped certificate or certificate request. + :type wrapped: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. """ def __init__(self, wrapped): assert isinstance(wrapped, OpenSSL.crypto.X509) or isinstance( wrapped, OpenSSL.crypto.X509Req) - self._wrapped = wrapped + self.wrapped = wrapped def __getattr__(self, name): - return getattr(self._wrapped, name) + return getattr(self.wrapped, name) def _dump(self, filetype=OpenSSL.crypto.FILETYPE_ASN1): - # pylint: disable=missing-docstring,protected-access - if isinstance(self._wrapped, OpenSSL.crypto.X509): + """Dumps the object into a buffer with the specified encoding. + + :param int filetype: The desired encoding. Should be one of + `OpenSSL.crypto.FILETYPE_ASN1`, + `OpenSSL.crypto.FILETYPE_PEM`, or + `OpenSSL.crypto.FILETYPE_TEXT`. + + :returns: Encoded X509 object. + :rtype: str + + """ + if isinstance(self.wrapped, OpenSSL.crypto.X509): func = OpenSSL.crypto.dump_certificate else: # assert in __init__ makes sure this is X509Req func = OpenSSL.crypto.dump_certificate_request - return func(filetype, self._wrapped) + return func(filetype, self.wrapped) def __eq__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self._dump() == other._dump() # pylint: disable=protected-access + # pylint: disable=protected-access + return self._dump() == other._dump() def __hash__(self): return hash((self.__class__, self._dump())) @@ -63,7 +72,7 @@ class ComparableX509(object): # pylint: disable=too-few-public-methods return not self == other def __repr__(self): - return '<{0}({1!r})>'.format(self.__class__.__name__, self._wrapped) + return '<{0}({1!r})>'.format(self.__class__.__name__, self.wrapped) class ComparableKey(object): # pylint: disable=too-few-public-methods @@ -130,7 +139,7 @@ class ImmutableMap(collections.Mapping, collections.Hashable): """Immutable key to value mapping with attribute access.""" __slots__ = () - """Must be overriden in subclasses.""" + """Must be overridden in subclasses.""" def __init__(self, **kwargs): if set(kwargs) != set(self.__slots__): diff --git a/acme/acme/jose/util_test.py b/acme/acme/jose/util_test.py index 4cdd9127f..0038a6cc1 100644 --- a/acme/acme/jose/util_test.py +++ b/acme/acme/jose/util_test.py @@ -11,14 +11,17 @@ class ComparableX509Test(unittest.TestCase): """Tests for acme.jose.util.ComparableX509.""" def setUp(self): - # test_util.load_{csr,cert} return ComparableX509 - self.req1 = test_util.load_csr('csr.pem') - self.req2 = test_util.load_csr('csr.pem') - self.req_other = test_util.load_csr('csr-san.pem') + # test_util.load_comparable_{csr,cert} return ComparableX509 + self.req1 = test_util.load_comparable_csr('csr.pem') + self.req2 = test_util.load_comparable_csr('csr.pem') + self.req_other = test_util.load_comparable_csr('csr-san.pem') - self.cert1 = test_util.load_cert('cert.pem') - self.cert2 = test_util.load_cert('cert.pem') - self.cert_other = test_util.load_cert('cert-san.pem') + self.cert1 = test_util.load_comparable_cert('cert.pem') + self.cert2 = test_util.load_comparable_cert('cert.pem') + self.cert_other = test_util.load_comparable_cert('cert-san.pem') + + def test_getattr_proxy(self): + self.assertTrue(self.cert1.has_expired()) def test_eq(self): self.assertEqual(self.req1, self.req2) @@ -41,8 +44,8 @@ class ComparableX509Test(unittest.TestCase): def test_repr(self): for x509 in self.req1, self.cert1: - self.assertTrue(repr(x509).startswith( - ''.format(x509.wrapped)) class ComparableRSAKeyTest(unittest.TestCase): diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 9d4dcbf30..06b4492d6 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -2,12 +2,13 @@ import collections from acme import challenges +from acme import errors from acme import fields from acme import jose from acme import util -class Error(jose.JSONObjectWithFields, Exception): +class Error(jose.JSONObjectWithFields, errors.Error): """ACME error. https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 @@ -17,55 +18,44 @@ class Error(jose.JSONObjectWithFields, Exception): :ivar unicode detail: """ - ERROR_TYPE_NAMESPACE = 'urn:acme:error:' - ERROR_TYPE_DESCRIPTIONS = { - 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', - 'badNonce': 'The client sent an unacceptable anti-replay nonce', - 'connection': 'The server could not connect to the client for DV', - 'dnssec': 'The server could not validate a DNSSEC signed domain', - 'malformed': 'The request message was malformed', - 'rateLimited': 'There were too many requests of a given type', - 'serverInternal': 'The server experienced an internal error', - 'tls': 'The server experienced a TLS error during DV', - 'unauthorized': 'The client lacks sufficient authorization', - 'unknownHost': 'The server could not resolve a domain name', - } + ERROR_TYPE_DESCRIPTIONS = dict( + ('urn:acme:error:' + name, description) for name, description in ( + ('badCSR', 'The CSR is unacceptable (e.g., due to a short key)'), + ('badNonce', 'The client sent an unacceptable anti-replay nonce'), + ('connection', 'The server could not connect to the client to ' + 'verify the domain'), + ('dnssec', 'The server could not validate a DNSSEC signed domain'), + ('invalidEmail', + 'The provided email for a registration was invalid'), + ('malformed', 'The request message was malformed'), + ('rateLimited', 'There were too many requests of a given type'), + ('serverInternal', 'The server experienced an internal error'), + ('tls', 'The server experienced a TLS error during domain ' + 'verification'), + ('unauthorized', 'The client lacks sufficient authorization'), + ('unknownHost', 'The server could not resolve a domain name'), + ) + ) typ = jose.Field('type') title = jose.Field('title', omitempty=True) detail = jose.Field('detail') - @typ.encoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - return Error.ERROR_TYPE_NAMESPACE + value - - @typ.decoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - # pylint thinks isinstance(value, Error), so startswith is not found - # pylint: disable=no-member - if not value.startswith(Error.ERROR_TYPE_NAMESPACE): - raise jose.DeserializationError('Missing error type prefix') - - without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] - if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: - raise jose.DeserializationError('Error type not recognized') - - return without_prefix - @property def description(self): """Hardcoded error description based on its type. + :returns: Description if standard ACME error or ``None``. :rtype: unicode """ - return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + return self.ERROR_TYPE_DESCRIPTIONS.get(self.typ) def __str__(self): - if self.typ is not None: - return ' :: '.join([self.typ, self.description, self.detail]) - else: - return str(self.detail) + return ' :: '.join( + part for part in + (self.typ, self.description, self.detail, self.title) + if part is not None) class _Constant(jose.JSONDeSerializable, collections.Hashable): @@ -140,8 +130,9 @@ class Directory(jose.JSONDeSerializable): @classmethod def register(cls, resource_body_cls): """Register resource.""" - assert resource_body_cls.resource_type not in cls._REGISTERED_TYPES - cls._REGISTERED_TYPES[resource_body_cls.resource_type] = resource_body_cls + resource_type = resource_body_cls.resource_type + assert resource_type not in cls._REGISTERED_TYPES + cls._REGISTERED_TYPES[resource_type] = resource_body_cls return resource_body_cls def __init__(self, jobj): diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 6c1c4f596..8e74826bf 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -8,8 +8,8 @@ from acme import jose from acme import test_util -CERT = test_util.load_cert('cert.der') -CSR = test_util.load_csr('csr.der') +CERT = test_util.load_comparable_cert('cert.der') +CSR = test_util.load_comparable_csr('csr.der') KEY = test_util.load_rsa_private_key('rsa512_key.pem') @@ -18,41 +18,30 @@ class ErrorTest(unittest.TestCase): def setUp(self): from acme.messages import Error - self.error = Error(detail='foo', typ='malformed', title='title') - self.jobj = {'detail': 'foo', 'title': 'some title'} - - def test_typ_prefix(self): - self.assertEqual('malformed', self.error.typ) - self.assertEqual( - 'urn:acme:error:malformed', self.error.to_partial_json()['type']) - self.assertEqual( - 'malformed', self.error.from_json(self.error.to_partial_json()).typ) - - def test_typ_decoder_missing_prefix(self): - from acme.messages import Error - self.jobj['type'] = 'malformed' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - self.jobj['type'] = 'not valid bare type' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_typ_decoder_not_recognized(self): - from acme.messages import Error - self.jobj['type'] = 'urn:acme:error:baz' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_description(self): - self.assertEqual( - 'The request message was malformed', self.error.description) + self.error = Error( + detail='foo', typ='urn:acme:error:malformed', title='title') + self.jobj = { + 'detail': 'foo', + 'title': 'some title', + 'type': 'urn:acme:error:malformed', + } + self.error_custom = Error(typ='custom', detail='bar') + self.jobj_cusom = {'type': 'custom', 'detail': 'bar'} def test_from_json_hashable(self): from acme.messages import Error hash(Error.from_json(self.error.to_json())) + def test_description(self): + self.assertEqual( + 'The request message was malformed', self.error.description) + self.assertTrue(self.error_custom.description is None) + def test_str(self): self.assertEqual( - 'malformed :: The request message was malformed :: foo', - str(self.error)) - self.assertEqual('foo', str(self.error.update(typ=None))) + 'urn:acme:error:malformed :: The request message was ' + 'malformed :: foo :: title', str(self.error)) + self.assertEqual('custom :: bar', str(self.error_custom)) class ConstantTest(unittest.TestCase): @@ -232,7 +221,7 @@ class ChallengeBodyTest(unittest.TestCase): from acme.messages import Error from acme.messages import STATUS_INVALID self.status = STATUS_INVALID - error = Error(typ='serverInternal', + error = Error(typ='urn:acme:error:serverInternal', detail='Unable to communicate with DNS server') self.challb = ChallengeBody( uri='http://challb', chall=self.chall, status=self.status, diff --git a/acme/acme/standalone.py b/acme/acme/standalone.py index 3ddb21beb..02cc2daf5 100644 --- a/acme/acme/standalone.py +++ b/acme/acme/standalone.py @@ -133,7 +133,6 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): self.log_message("Serving HTTP01 with token %r", resource.chall.encode("token")) self.send_response(http_client.OK) - self.send_header("Content-type", resource.chall.CONTENT_TYPE) self.end_headers() self.wfile.write(resource.validation.encode()) return diff --git a/acme/acme/standalone_test.py b/acme/acme/standalone_test.py index 02b1f69d3..85cd9d11d 100644 --- a/acme/acme/standalone_test.py +++ b/acme/acme/standalone_test.py @@ -32,11 +32,10 @@ class TLSSNI01ServerTest(unittest.TestCase): """Test for acme.standalone.TLSSNI01Server.""" def setUp(self): - self.certs = { - b'localhost': (test_util.load_pyopenssl_private_key('rsa512_key.pem'), - # pylint: disable=protected-access - test_util.load_cert('cert.pem')._wrapped), - } + self.certs = {b'localhost': ( + test_util.load_pyopenssl_private_key('rsa512_key.pem'), + test_util.load_cert('cert.pem'), + )} from acme.standalone import TLSSNI01Server self.server = TLSSNI01Server(("", 0), certs=self.certs) # pylint: disable=no-member @@ -49,7 +48,8 @@ class TLSSNI01ServerTest(unittest.TestCase): def test_it(self): host, port = self.server.socket.getsockname()[:2] - cert = crypto_util.probe_sni(b'localhost', host=host, port=port, timeout=1) + cert = crypto_util.probe_sni( + b'localhost', host=host, port=port, timeout=1) self.assertEqual(jose.ComparableX509(cert), jose.ComparableX509(self.certs[b'localhost'][1])) @@ -140,13 +140,14 @@ class TestSimpleTLSSNI01Server(unittest.TestCase): while max_attempts: max_attempts -= 1 try: - cert = crypto_util.probe_sni(b'localhost', b'0.0.0.0', self.port) + cert = crypto_util.probe_sni( + b'localhost', b'0.0.0.0', self.port) except errors.Error: self.assertTrue(max_attempts > 0, "Timeout!") time.sleep(1) # wait until thread starts else: self.assertEqual(jose.ComparableX509(cert), - test_util.load_cert('cert.pem')) + test_util.load_comparable_cert('cert.pem')) break diff --git a/acme/acme/test_util.py b/acme/acme/test_util.py index 2b4c6e00c..24eceff5a 100644 --- a/acme/acme/test_util.py +++ b/acme/acme/test_util.py @@ -40,16 +40,24 @@ def load_cert(*names): """Load certificate.""" loader = _guess_loader( names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return jose.ComparableX509(OpenSSL.crypto.load_certificate( - loader, load_vector(*names))) + return OpenSSL.crypto.load_certificate(loader, load_vector(*names)) + + +def load_comparable_cert(*names): + """Load ComparableX509 cert.""" + return jose.ComparableX509(load_cert(*names)) def load_csr(*names): """Load certificate request.""" loader = _guess_loader( names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return jose.ComparableX509(OpenSSL.crypto.load_certificate_request( - loader, load_vector(*names))) + return OpenSSL.crypto.load_certificate_request(loader, load_vector(*names)) + + +def load_comparable_csr(*names): + """Load ComparableX509 certificate request.""" + return jose.ComparableX509(load_csr(*names)) def load_rsa_private_key(*names): diff --git a/acme/acme/testdata/cert-100sans.pem b/acme/acme/testdata/cert-100sans.pem new file mode 100644 index 000000000..3fdc9404f --- /dev/null +++ b/acme/acme/testdata/cert-100sans.pem @@ -0,0 +1,44 @@ +-----BEGIN CERTIFICATE----- +MIIHxDCCB26gAwIBAgIJAOGrG1Un9lHiMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV +BAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMScwJQYDVQQLDB5FbGVjdHJv +bmljIEZyb250aWVyIEZvdW5kYXRpb24xFDASBgNVBAMMC2V4YW1wbGUuY29tMB4X +DTE2MDEwNjE5MDkzN1oXDTE2MDEwNzE5MDkzN1owZDELMAkGA1UECAwCQ0ExFjAU +BgNVBAcMDVNhbiBGcmFuY2lzY28xJzAlBgNVBAsMHkVsZWN0cm9uaWMgRnJvbnRp +ZXIgRm91bmRhdGlvbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG9w0B +AQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3fp580 +rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABo4IGATCCBf0wCQYDVR0T +BAIwADALBgNVHQ8EBAMCBeAwggXhBgNVHREEggXYMIIF1IIMZXhhbXBsZTEuY29t +ggxleGFtcGxlMi5jb22CDGV4YW1wbGUzLmNvbYIMZXhhbXBsZTQuY29tggxleGFt +cGxlNS5jb22CDGV4YW1wbGU2LmNvbYIMZXhhbXBsZTcuY29tggxleGFtcGxlOC5j +b22CDGV4YW1wbGU5LmNvbYINZXhhbXBsZTEwLmNvbYINZXhhbXBsZTExLmNvbYIN +ZXhhbXBsZTEyLmNvbYINZXhhbXBsZTEzLmNvbYINZXhhbXBsZTE0LmNvbYINZXhh +bXBsZTE1LmNvbYINZXhhbXBsZTE2LmNvbYINZXhhbXBsZTE3LmNvbYINZXhhbXBs +ZTE4LmNvbYINZXhhbXBsZTE5LmNvbYINZXhhbXBsZTIwLmNvbYINZXhhbXBsZTIx +LmNvbYINZXhhbXBsZTIyLmNvbYINZXhhbXBsZTIzLmNvbYINZXhhbXBsZTI0LmNv +bYINZXhhbXBsZTI1LmNvbYINZXhhbXBsZTI2LmNvbYINZXhhbXBsZTI3LmNvbYIN +ZXhhbXBsZTI4LmNvbYINZXhhbXBsZTI5LmNvbYINZXhhbXBsZTMwLmNvbYINZXhh +bXBsZTMxLmNvbYINZXhhbXBsZTMyLmNvbYINZXhhbXBsZTMzLmNvbYINZXhhbXBs +ZTM0LmNvbYINZXhhbXBsZTM1LmNvbYINZXhhbXBsZTM2LmNvbYINZXhhbXBsZTM3 +LmNvbYINZXhhbXBsZTM4LmNvbYINZXhhbXBsZTM5LmNvbYINZXhhbXBsZTQwLmNv +bYINZXhhbXBsZTQxLmNvbYINZXhhbXBsZTQyLmNvbYINZXhhbXBsZTQzLmNvbYIN +ZXhhbXBsZTQ0LmNvbYINZXhhbXBsZTQ1LmNvbYINZXhhbXBsZTQ2LmNvbYINZXhh +bXBsZTQ3LmNvbYINZXhhbXBsZTQ4LmNvbYINZXhhbXBsZTQ5LmNvbYINZXhhbXBs +ZTUwLmNvbYINZXhhbXBsZTUxLmNvbYINZXhhbXBsZTUyLmNvbYINZXhhbXBsZTUz +LmNvbYINZXhhbXBsZTU0LmNvbYINZXhhbXBsZTU1LmNvbYINZXhhbXBsZTU2LmNv +bYINZXhhbXBsZTU3LmNvbYINZXhhbXBsZTU4LmNvbYINZXhhbXBsZTU5LmNvbYIN +ZXhhbXBsZTYwLmNvbYINZXhhbXBsZTYxLmNvbYINZXhhbXBsZTYyLmNvbYINZXhh +bXBsZTYzLmNvbYINZXhhbXBsZTY0LmNvbYINZXhhbXBsZTY1LmNvbYINZXhhbXBs +ZTY2LmNvbYINZXhhbXBsZTY3LmNvbYINZXhhbXBsZTY4LmNvbYINZXhhbXBsZTY5 +LmNvbYINZXhhbXBsZTcwLmNvbYINZXhhbXBsZTcxLmNvbYINZXhhbXBsZTcyLmNv +bYINZXhhbXBsZTczLmNvbYINZXhhbXBsZTc0LmNvbYINZXhhbXBsZTc1LmNvbYIN +ZXhhbXBsZTc2LmNvbYINZXhhbXBsZTc3LmNvbYINZXhhbXBsZTc4LmNvbYINZXhh +bXBsZTc5LmNvbYINZXhhbXBsZTgwLmNvbYINZXhhbXBsZTgxLmNvbYINZXhhbXBs +ZTgyLmNvbYINZXhhbXBsZTgzLmNvbYINZXhhbXBsZTg0LmNvbYINZXhhbXBsZTg1 +LmNvbYINZXhhbXBsZTg2LmNvbYINZXhhbXBsZTg3LmNvbYINZXhhbXBsZTg4LmNv +bYINZXhhbXBsZTg5LmNvbYINZXhhbXBsZTkwLmNvbYINZXhhbXBsZTkxLmNvbYIN +ZXhhbXBsZTkyLmNvbYINZXhhbXBsZTkzLmNvbYINZXhhbXBsZTk0LmNvbYINZXhh +bXBsZTk1LmNvbYINZXhhbXBsZTk2LmNvbYINZXhhbXBsZTk3LmNvbYINZXhhbXBs +ZTk4LmNvbYINZXhhbXBsZTk5LmNvbYIOZXhhbXBsZTEwMC5jb20wDQYJKoZIhvcN +AQELBQADQQBEunJbKUXcyNKTSfA0pKRyWNiKmkoBqYgfZS6eHNrNH/hjFzHtzyDQ +XYHHK6kgEWBvHfRXGmqhFvht+b1tQKkG +-----END CERTIFICATE----- diff --git a/acme/acme/testdata/cert-idnsans.pem b/acme/acme/testdata/cert-idnsans.pem new file mode 100644 index 000000000..932649692 --- /dev/null +++ b/acme/acme/testdata/cert-idnsans.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFNjCCBOCgAwIBAgIJAP4rNqqOKifCMA0GCSqGSIb3DQEBCwUAMGQxCzAJBgNV +BAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMScwJQYDVQQLDB5FbGVjdHJv +bmljIEZyb250aWVyIEZvdW5kYXRpb24xFDASBgNVBAMMC2V4YW1wbGUuY29tMB4X +DTE2MDEwNjIwMDg1OFoXDTE2MDEwNzIwMDg1OFowZDELMAkGA1UECAwCQ0ExFjAU +BgNVBAcMDVNhbiBGcmFuY2lzY28xJzAlBgNVBAsMHkVsZWN0cm9uaWMgRnJvbnRp +ZXIgRm91bmRhdGlvbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG9w0B +AQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3fp580 +rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABo4IDczCCA28wCQYDVR0T +BAIwADALBgNVHQ8EBAMCBeAwggNTBgNVHREEggNKMIIDRoJiz4PPhM+Fz4bPh8+I +z4nPis+Lz4zPjc+Oz4/PkM+Rz5LPk8+Uz5XPls+Xz5jPmc+az5vPnM+dz57Pn8+g +z6HPos+jz6TPpc+mz6fPqM+pz6rPq8+sz63Prs+vLmludmFsaWSCYs+wz7HPss+z +z7TPtc+2z7fPuM+5z7rPu8+8z73Pvs+/2YHZgtmD2YTZhdmG2YfZiNmJ2YrZi9mM +2Y3ZjtmP2ZDZkdmS2ZPZlNmV2ZbZl9mY2ZnZmtmb2ZzZnS5pbnZhbGlkgmLZntmf +2aDZodmi2aPZpNml2abZp9mo2anZqtmr2azZrdmu2a/ZsNmx2bLZs9m02bXZttm3 +2bjZudm62bvZvNm92b7Zv9qA2oHagtqD2oTahdqG2ofaiNqJ2oouaW52YWxpZIJi +2ovajNqN2o7aj9qQ2pHaktqT2pTaldqW2pfamNqZ2pram9qc2p3antqf2qDaodqi +2qPapNql2qbap9qo2qnaqtqr2qzardqu2q/asNqx2rLas9q02rXattq3LmludmFs +aWSCYtq42rnautq72rzavdq+2r/bgNuB24Lbg9uE24XbhtuH24jbiduK24vbjNuN +247bj9uQ25HbktuT25TblduW25fbmNuZ25rbm9uc253bntuf26Dbodui26PbpC5p +bnZhbGlkgnjbpdum26fbqNup26rbq9us263brtuv27Dbsduy27PbtNu127bbt9u4 +27nbutu74aCg4aCh4aCi4aCj4aCk4aCl4aCm4aCn4aCo4aCp4aCq4aCr4aCs4aCt +4aCu4aCv4aCw4aCx4aCy4aCz4aC04aC1LmludmFsaWSCgY/hoLbhoLfhoLjhoLnh +oLrhoLvhoLzhoL3hoL7hoL/hoYDhoYHhoYLhoYPhoYThoYXhoYbhoYfhoYjhoYnh +oYrhoYvhoYzhoY3hoY7hoY/hoZDhoZHhoZLhoZPhoZThoZXhoZbhoZfhoZjhoZnh +oZrhoZvhoZzhoZ3hoZ7hoZ/hoaDhoaHhoaIuaW52YWxpZIJE4aGj4aGk4aGl4aGm +4aGn4aGo4aGp4aGq4aGr4aGs4aGt4aGu4aGv4aGw4aGx4aGy4aGz4aG04aG14aG2 +LmludmFsaWQwDQYJKoZIhvcNAQELBQADQQAzOQL/54yXxln87/YvEQbBm9ik9zoT +TxEkvnZ4kmTRhDsUPtRjMXhY2FH7LOtXKnJQ7POUB7AsJ2Z6uq2w623G +-----END CERTIFICATE----- diff --git a/acme/acme/testdata/csr-100sans.pem b/acme/acme/testdata/csr-100sans.pem new file mode 100644 index 000000000..199814126 --- /dev/null +++ b/acme/acme/testdata/csr-100sans.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIHNTCCBt8CAQAwZDELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDVNhbiBGcmFuY2lz +Y28xJzAlBgNVBAsMHkVsZWN0cm9uaWMgRnJvbnRpZXIgRm91bmRhdGlvbjEUMBIG +A1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHt +H92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3fp580rv2+6QWE30cWgdmJS86ObRz6 +lUTor4R0T+3C5QIDAQABoIIGFDCCBhAGCSqGSIb3DQEJDjGCBgEwggX9MAkGA1Ud +EwQCMAAwCwYDVR0PBAQDAgXgMIIF4QYDVR0RBIIF2DCCBdSCDGV4YW1wbGUxLmNv +bYIMZXhhbXBsZTIuY29tggxleGFtcGxlMy5jb22CDGV4YW1wbGU0LmNvbYIMZXhh +bXBsZTUuY29tggxleGFtcGxlNi5jb22CDGV4YW1wbGU3LmNvbYIMZXhhbXBsZTgu +Y29tggxleGFtcGxlOS5jb22CDWV4YW1wbGUxMC5jb22CDWV4YW1wbGUxMS5jb22C +DWV4YW1wbGUxMi5jb22CDWV4YW1wbGUxMy5jb22CDWV4YW1wbGUxNC5jb22CDWV4 +YW1wbGUxNS5jb22CDWV4YW1wbGUxNi5jb22CDWV4YW1wbGUxNy5jb22CDWV4YW1w +bGUxOC5jb22CDWV4YW1wbGUxOS5jb22CDWV4YW1wbGUyMC5jb22CDWV4YW1wbGUy +MS5jb22CDWV4YW1wbGUyMi5jb22CDWV4YW1wbGUyMy5jb22CDWV4YW1wbGUyNC5j +b22CDWV4YW1wbGUyNS5jb22CDWV4YW1wbGUyNi5jb22CDWV4YW1wbGUyNy5jb22C +DWV4YW1wbGUyOC5jb22CDWV4YW1wbGUyOS5jb22CDWV4YW1wbGUzMC5jb22CDWV4 +YW1wbGUzMS5jb22CDWV4YW1wbGUzMi5jb22CDWV4YW1wbGUzMy5jb22CDWV4YW1w +bGUzNC5jb22CDWV4YW1wbGUzNS5jb22CDWV4YW1wbGUzNi5jb22CDWV4YW1wbGUz +Ny5jb22CDWV4YW1wbGUzOC5jb22CDWV4YW1wbGUzOS5jb22CDWV4YW1wbGU0MC5j +b22CDWV4YW1wbGU0MS5jb22CDWV4YW1wbGU0Mi5jb22CDWV4YW1wbGU0My5jb22C +DWV4YW1wbGU0NC5jb22CDWV4YW1wbGU0NS5jb22CDWV4YW1wbGU0Ni5jb22CDWV4 +YW1wbGU0Ny5jb22CDWV4YW1wbGU0OC5jb22CDWV4YW1wbGU0OS5jb22CDWV4YW1w +bGU1MC5jb22CDWV4YW1wbGU1MS5jb22CDWV4YW1wbGU1Mi5jb22CDWV4YW1wbGU1 +My5jb22CDWV4YW1wbGU1NC5jb22CDWV4YW1wbGU1NS5jb22CDWV4YW1wbGU1Ni5j +b22CDWV4YW1wbGU1Ny5jb22CDWV4YW1wbGU1OC5jb22CDWV4YW1wbGU1OS5jb22C +DWV4YW1wbGU2MC5jb22CDWV4YW1wbGU2MS5jb22CDWV4YW1wbGU2Mi5jb22CDWV4 +YW1wbGU2My5jb22CDWV4YW1wbGU2NC5jb22CDWV4YW1wbGU2NS5jb22CDWV4YW1w +bGU2Ni5jb22CDWV4YW1wbGU2Ny5jb22CDWV4YW1wbGU2OC5jb22CDWV4YW1wbGU2 +OS5jb22CDWV4YW1wbGU3MC5jb22CDWV4YW1wbGU3MS5jb22CDWV4YW1wbGU3Mi5j +b22CDWV4YW1wbGU3My5jb22CDWV4YW1wbGU3NC5jb22CDWV4YW1wbGU3NS5jb22C +DWV4YW1wbGU3Ni5jb22CDWV4YW1wbGU3Ny5jb22CDWV4YW1wbGU3OC5jb22CDWV4 +YW1wbGU3OS5jb22CDWV4YW1wbGU4MC5jb22CDWV4YW1wbGU4MS5jb22CDWV4YW1w +bGU4Mi5jb22CDWV4YW1wbGU4My5jb22CDWV4YW1wbGU4NC5jb22CDWV4YW1wbGU4 +NS5jb22CDWV4YW1wbGU4Ni5jb22CDWV4YW1wbGU4Ny5jb22CDWV4YW1wbGU4OC5j +b22CDWV4YW1wbGU4OS5jb22CDWV4YW1wbGU5MC5jb22CDWV4YW1wbGU5MS5jb22C +DWV4YW1wbGU5Mi5jb22CDWV4YW1wbGU5My5jb22CDWV4YW1wbGU5NC5jb22CDWV4 +YW1wbGU5NS5jb22CDWV4YW1wbGU5Ni5jb22CDWV4YW1wbGU5Ny5jb22CDWV4YW1w +bGU5OC5jb22CDWV4YW1wbGU5OS5jb22CDmV4YW1wbGUxMDAuY29tMA0GCSqGSIb3 +DQEBCwUAA0EAW05UMFavHn2rkzMyUfzsOvWzVNlm43eO2yHu5h5TzDb23gkDnNEo +duUAbQ+CLJHYd+MvRCmPQ+3ZnaPy7l/0Hg== +-----END CERTIFICATE REQUEST----- diff --git a/acme/acme/testdata/csr-idnsans.pem b/acme/acme/testdata/csr-idnsans.pem new file mode 100644 index 000000000..d6e91a420 --- /dev/null +++ b/acme/acme/testdata/csr-idnsans.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIEpzCCBFECAQAwZDELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDVNhbiBGcmFuY2lz +Y28xJzAlBgNVBAsMHkVsZWN0cm9uaWMgRnJvbnRpZXIgRm91bmRhdGlvbjEUMBIG +A1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHt +H92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3fp580rv2+6QWE30cWgdmJS86ObRz6 +lUTor4R0T+3C5QIDAQABoIIDhjCCA4IGCSqGSIb3DQEJDjGCA3MwggNvMAkGA1Ud +EwQCMAAwCwYDVR0PBAQDAgXgMIIDUwYDVR0RBIIDSjCCA0aCYs+Dz4TPhc+Gz4fP +iM+Jz4rPi8+Mz43Pjs+Pz5DPkc+Sz5PPlM+Vz5bPl8+Yz5nPms+bz5zPnc+ez5/P +oM+hz6LPo8+kz6XPps+nz6jPqc+qz6vPrM+tz67Pry5pbnZhbGlkgmLPsM+xz7LP +s8+0z7XPts+3z7jPuc+6z7vPvM+9z77Pv9mB2YLZg9mE2YXZhtmH2YjZidmK2YvZ +jNmN2Y7Zj9mQ2ZHZktmT2ZTZldmW2ZfZmNmZ2ZrZm9mc2Z0uaW52YWxpZIJi2Z7Z +n9mg2aHZotmj2aTZpdmm2afZqNmp2arZq9ms2a3Zrtmv2bDZsdmy2bPZtNm12bbZ +t9m42bnZutm72bzZvdm+2b/agNqB2oLag9qE2oXahtqH2ojaidqKLmludmFsaWSC +YtqL2ozajdqO2o/akNqR2pLak9qU2pXaltqX2pjamdqa2pvanNqd2p7an9qg2qHa +otqj2qTapdqm2qfaqNqp2qraq9qs2q3artqv2rDasdqy2rPatNq12rbaty5pbnZh +bGlkgmLauNq52rrau9q82r3avtq/24DbgduC24PbhNuF24bbh9uI24nbituL24zb +jduO24/bkNuR25Lbk9uU25XbltuX25jbmdua25vbnNud257bn9ug26Hbotuj26Qu +aW52YWxpZIJ426Xbptun26jbqduq26vbrNut267br9uw27Hbstuz27Tbtdu227fb +uNu527rbu+GgoOGgoeGgouGgo+GgpOGgpeGgpuGgp+GgqOGgqeGgquGgq+GgrOGg +reGgruGgr+GgsOGgseGgsuGgs+GgtOGgtS5pbnZhbGlkgoGP4aC24aC34aC44aC5 +4aC64aC74aC84aC94aC+4aC/4aGA4aGB4aGC4aGD4aGE4aGF4aGG4aGH4aGI4aGJ +4aGK4aGL4aGM4aGN4aGO4aGP4aGQ4aGR4aGS4aGT4aGU4aGV4aGW4aGX4aGY4aGZ +4aGa4aGb4aGc4aGd4aGe4aGf4aGg4aGh4aGiLmludmFsaWSCROGho+GhpOGhpeGh +puGhp+GhqOGhqeGhquGhq+GhrOGhreGhruGhr+GhsOGhseGhsuGhs+GhtOGhteGh +ti5pbnZhbGlkMA0GCSqGSIb3DQEBCwUAA0EAeNkY0M0+kMnjRo6dEUoGE4dX9fEr +dfGrpPUBcwG0P5QBdZJWvZxTfRl14yuPYHbGHULXeGqRdkU6HK5pOlzpng== +-----END CERTIFICATE REQUEST----- diff --git a/acme/examples/example_client.py b/acme/examples/example_client.py index b4b5ad010..f6b0329f5 100644 --- a/acme/examples/example_client.py +++ b/acme/examples/example_client.py @@ -28,8 +28,7 @@ acme = client.Client(DIRECTORY_URL, key) regr = acme.register() logging.info('Auto-accepting TOS: %s', regr.terms_of_service) -acme.update_registration(regr.update( - body=regr.body.update(agreement=regr.terms_of_service))) +acme.agree_to_tos(regr) logging.debug(regr) authzr = acme.request_challenges( diff --git a/acme/setup.cfg b/acme/setup.cfg new file mode 100644 index 000000000..2a9acf13d --- /dev/null +++ b/acme/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/acme/setup.py b/acme/setup.py index a6551a023..b5bec3476 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -4,16 +4,17 @@ from setuptools import setup from setuptools import find_packages -version = '0.1.0.dev0' +version = '0.4.0.dev0' +# Please update tox.ini when modifying dependency version requirements install_requires = [ # load_pem_private/public_key (>=0.6) # rsa_recover_prime_factors (>=0.8) 'cryptography>=0.8', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'pyasn1', # urllib3 InsecurePlatformWarning (#304) - # Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15) - 'PyOpenSSL>=0.15', + # Connection.set_tlsext_host_name (>=0.13) + 'PyOpenSSL>=0.13', 'pyrfc3339', 'pytz', 'requests', @@ -23,6 +24,7 @@ install_requires = [ ] # env markers in extras_require cause problems with older pip: #517 +# Keep in sync with conditional_requirements.py. if sys.version_info < (2, 7): install_requires.extend([ # only some distros recognize stdlib argparse as already satisfying @@ -63,6 +65,7 @@ setup( 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', ], diff --git a/bootstrap/README b/bootstrap/README index 89fd8b6ba..d91780903 100644 --- a/bootstrap/README +++ b/bootstrap/README @@ -2,6 +2,5 @@ This directory contains scripts that install necessary OS-specific prerequisite dependencies (see docs/using.rst). General dependencies: -- git-core: py26reqs.txt git+https://* - ca-certificates: communication with demo ACMO server at - https://www.letsencrypt-demo.org, py26reqs.txt git+https://* + https://www.letsencrypt-demo.org diff --git a/bootstrap/_arch_common.sh b/bootstrap/_arch_common.sh index f66067ffb..2b512792f 100755 --- a/bootstrap/_arch_common.sh +++ b/bootstrap/_arch_common.sh @@ -8,7 +8,6 @@ # ./bootstrap/dev/_common_venv.sh deps=" - git python2 python-virtualenv gcc diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh index 4c6b91a33..c2f58db75 100755 --- a/bootstrap/_deb_common.sh +++ b/bootstrap/_deb_common.sh @@ -24,26 +24,70 @@ apt-get update # distro version (#346) virtualenv= -if apt-cache show virtualenv > /dev/null ; then +if apt-cache show virtualenv > /dev/null 2>&1; then virtualenv="virtualenv" fi -if apt-cache show python-virtualenv > /dev/null ; then +if apt-cache show python-virtualenv > /dev/null 2>&1; then virtualenv="$virtualenv python-virtualenv" fi +augeas_pkg="libaugeas0 augeas-lenses" +AUGVERSION=`apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2` + +AddBackportRepo() { + # ARGS: + BACKPORT_NAME="$1" + BACKPORT_SOURCELINE="$2" + if ! grep -v -e ' *#' /etc/apt/sources.list | grep -q "$BACKPORT_NAME" ; then + # This can theoretically error if sources.list.d is empty, but in that case we don't care. + if ! grep -v -e ' *#' /etc/apt/sources.list.d/* 2>/dev/null | grep -q "$BACKPORT_NAME"; then + /bin/echo -n "Installing augeas from $BACKPORT_NAME in 3 seconds..." + sleep 1s + /bin/echo -ne "\e[0K\rInstalling augeas from $BACKPORT_NAME in 2 seconds..." + sleep 1s + /bin/echo -e "\e[0K\rInstalling augeas from $BACKPORT_NAME in 1 second ..." + sleep 1s + if echo $BACKPORT_NAME | grep -q wheezy ; then + /bin/echo '(Backports are only installed if explicitly requested via "apt-get install -t wheezy-backports")' + fi + + echo $BACKPORT_SOURCELINE >> /etc/apt/sources.list.d/"$BACKPORT_NAME".list + apt-get update + fi + fi + apt-get install -y --no-install-recommends -t "$BACKPORT_NAME" $augeas_pkg + augeas_pkg= + +} + + +if dpkg --compare-versions 1.0 gt "$AUGVERSION" ; then + if lsb_release -a | grep -q wheezy ; then + AddBackportRepo wheezy-backports "deb http://http.debian.net/debian wheezy-backports main" + elif lsb_release -a | grep -q precise ; then + # XXX add ARM case + AddBackportRepo precise-backports "deb http://archive.ubuntu.com/ubuntu precise-backports main restricted universe multiverse" + else + echo "No libaugeas0 version is available that's new enough to run the" + echo "Let's Encrypt apache plugin..." + fi + # XXX add a case for ubuntu PPAs +fi + apt-get install -y --no-install-recommends \ - git \ python \ python-dev \ $virtualenv \ gcc \ dialog \ - libaugeas0 \ + $augeas_pkg \ libssl-dev \ libffi-dev \ ca-certificates \ + + if ! command -v virtualenv > /dev/null ; then echo Failed to install a working \"virtualenv\" command, exiting exit 1 diff --git a/bootstrap/_gentoo_common.sh b/bootstrap/_gentoo_common.sh index a718db7ff..f49dc00f0 100755 --- a/bootstrap/_gentoo_common.sh +++ b/bootstrap/_gentoo_common.sh @@ -1,6 +1,6 @@ #!/bin/sh -PACKAGES="dev-vcs/git +PACKAGES=" dev-lang/python:2.7 dev-python/virtualenv dev-util/dialog diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index 5aca13cd4..73890155e 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -2,14 +2,16 @@ # Tested with: # - Fedora 22, 23 (x64) -# - Centos 7 (x64: onD igitalOcean droplet) +# - Centos 7 (x64: on DigitalOcean droplet) +# - CentOS 7 Minimal install in a Hyper-V VM -if type yum 2>/dev/null -then - tool=yum -elif type dnf 2>/dev/null +if type dnf 2>/dev/null then tool=dnf +elif type yum 2>/dev/null +then + tool=yum + else echo "Neither yum nor dnf found. Aborting bootstrap!" exit 1 @@ -20,28 +22,39 @@ fi if ! $tool install -y \ python \ python-devel \ - python-virtualenv + python-virtualenv \ + python-tools \ + python-pip then if ! $tool install -y \ python27 \ python27-devel \ - python27-virtualenv + python27-virtualenv \ + python27-tools \ + python27-pip then echo "Could not install Python dependencies. Aborting bootstrap!" exit 1 fi fi -# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails) if ! $tool install -y \ - git-core \ gcc \ dialog \ augeas-libs \ openssl-devel \ libffi-devel \ + redhat-rpm-config \ ca-certificates then echo "Could not install additional dependencies. Aborting bootstrap!" exit 1 fi + + +if $tool list installed "httpd" >/dev/null 2>&1; then + if ! $tool install -y mod_ssl + then + echo "Apache found, but mod_ssl could not be installed." + fi +fi diff --git a/bootstrap/_suse_common.sh b/bootstrap/_suse_common.sh index 46f9d693b..efeebe4f8 100755 --- a/bootstrap/_suse_common.sh +++ b/bootstrap/_suse_common.sh @@ -2,7 +2,7 @@ # SLE12 don't have python-virtualenv -zypper -nq in -l git-core \ +zypper -nq in -l \ python \ python-devel \ python-virtualenv \ diff --git a/bootstrap/dev/venv.sh b/bootstrap/dev/venv.sh index 2bd32a89b..11ab417dd 100755 --- a/bootstrap/dev/venv.sh +++ b/bootstrap/dev/venv.sh @@ -4,7 +4,6 @@ export VENV_ARGS="--python python2" ./bootstrap/dev/_venv_common.sh \ - -r py26reqs.txt \ -e acme[testing] \ -e .[dev,docs,testing] \ -e letsencrypt-apache \ diff --git a/bootstrap/freebsd.sh b/bootstrap/freebsd.sh index 180ee21b4..4482c35cd 100755 --- a/bootstrap/freebsd.sh +++ b/bootstrap/freebsd.sh @@ -1,7 +1,6 @@ #!/bin/sh -xe pkg install -Ay \ - git \ python \ py27-virtualenv \ augeas \ diff --git a/bootstrap/venv.sh b/bootstrap/venv.sh index ff1a50c6c..5042178d9 100755 --- a/bootstrap/venv.sh +++ b/bootstrap/venv.sh @@ -20,7 +20,7 @@ fi pip install -U setuptools pip install -U pip -pip install -U -r py26reqs.txt letsencrypt letsencrypt-apache # letsencrypt-nginx +pip install -U letsencrypt letsencrypt-apache # letsencrypt-nginx echo echo "Congratulations, Let's Encrypt has been successfully installed/updated!" diff --git a/docs/ciphers.rst b/docs/ciphers.rst index 49c0824a3..fb854f307 100644 --- a/docs/ciphers.rst +++ b/docs/ciphers.rst @@ -170,7 +170,7 @@ Changing your settings This will probably look something like -..code-block: shell +.. code-block:: shell letsencrypt --cipher-recommendations mozilla-secure letsencrypt --cipher-recommendations mozilla-intermediate @@ -179,14 +179,14 @@ This will probably look something like to track Mozilla's *Secure*, *Intermediate*, or *Old* recommendations, and -..code-block: shell +.. code-block:: shell letsencrypt --update-ciphers on to enable updating ciphers with each new Let's Encrypt client release, or -..code-block: shell +.. code-block:: shell letsencrypt --update-ciphers off diff --git a/docs/contributing.rst b/docs/contributing.rst index c71aefeec..329f92461 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -22,7 +22,7 @@ once: git clone https://github.com/letsencrypt/letsencrypt cd letsencrypt - ./bootstrap/install-deps.sh + ./letsencrypt-auto-source/letsencrypt-auto --os-packages-only ./bootstrap/dev/venv.sh Then in each shell where you're working on the client, do: @@ -65,8 +65,14 @@ Testing The following tools are there to help you: -- ``tox`` starts a full set of tests. Please make sure you run it - before submitting a new pull request. +- ``tox`` starts a full set of tests. Please note that it includes + apacheconftest, which uses the system's Apache install to test config file + parsing, so it should only be run on systems that have an + experimental, non-production Apache2 install on them. ``tox -e + apacheconftest`` can be used to run those specific Apache conf tests. + +- ``tox -e py27``, ``tox -e py26`` etc, run unit tests for specific Python + versions. - ``tox -e cover`` checks the test coverage only. Calling the ``./tox.cover.sh`` script directly (or even ``./tox.cover.sh $pkg1 @@ -90,11 +96,32 @@ Integration testing with the boulder CA Generally it is sufficient to open a pull request and let Github and Travis run integration tests for you. -Mac OS X users: Run `./tests/mac-bootstrap.sh` instead of `boulder-start.sh` to -install dependencies, configure the environment, and start boulder. +Mac OS X users: Run ``./tests/mac-bootstrap.sh`` instead of +``boulder-start.sh`` to install dependencies, configure the +environment, and start boulder. -Otherwise, install `Go`_ 1.5, libtool-ltdl, mariadb-server and -rabbitmq-server and then start Boulder_, an ACME CA server:: +Otherwise, install `Go`_ 1.5, ``libtool-ltdl``, ``mariadb-server`` and +``rabbitmq-server`` and then start Boulder_, an ACME CA server. + +If you can't get packages of Go 1.5 for your Linux system, +you can execute the following commands to install it: + +.. code-block:: shell + + wget https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz -P /tmp/ + sudo tar -C /usr/local -xzf /tmp/go1.5.3.linux-amd64.tar.gz + if ! grep -Fxq "export GOROOT=/usr/local/go" ~/.profile ; then echo "export GOROOT=/usr/local/go" >> ~/.profile; fi + if ! grep -Fxq "export PATH=\\$GOROOT/bin:\\$PATH" ~/.profile ; then echo "export PATH=\\$GOROOT/bin:\\$PATH" >> ~/.profile; fi + +These commands download `Go`_ 1.5.3 to ``/tmp/``, extracts to ``/usr/local``, +and then adds the export lines required to execute ``boulder-start.sh`` to +``~/.profile`` if they were not previously added + +Make sure you execute the following command after `Go`_ finishes installing:: + + if ! grep -Fxq "export GOPATH=\\$HOME/go" ~/.profile ; then echo "export GOPATH=\\$HOME/go" >> ~/.profile; fi + +Afterwards, you'd be able to start Boulder_ using the following command:: ./tests/boulder-start.sh @@ -359,75 +386,37 @@ Now run tests inside the Docker image: Notes on OS dependencies ======================== -OS level dependencies are managed by scripts in ``bootstrap``. Some notes -are provided here mainly for the :ref:`developers ` reference. +OS-level dependencies can be installed like so: -In general: +.. code-block:: shell + + letsencrypt-auto-source/letsencrypt-auto --os-packages-only + +In general... * ``sudo`` is required as a suggested way of running privileged process +* `Python`_ 2.6/2.7 is required * `Augeas`_ is required for the Python bindings * ``virtualenv`` and ``pip`` are used for managing other python library dependencies +.. _Python: https://wiki.python.org/moin/BeginnersGuide/Download .. _Augeas: http://augeas.net/ .. _Virtualenv: https://virtualenv.pypa.io -Ubuntu ------- - -.. code-block:: shell - - sudo ./bootstrap/ubuntu.sh - Debian ------ -.. code-block:: shell - - sudo ./bootstrap/debian.sh - For squeeze you will need to: - Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``. -.. _`#280`: https://github.com/letsencrypt/letsencrypt/issues/280 - - -Mac OSX -------- - -.. code-block:: shell - - ./bootstrap/mac.sh - - -Fedora ------- - -.. code-block:: shell - - sudo ./bootstrap/fedora.sh - - -Centos 7 --------- - -.. code-block:: shell - - sudo ./bootstrap/centos.sh - - FreeBSD ------- -.. code-block:: shell - - sudo ./bootstrap/freebsd.sh - -Bootstrap script for FreeBSD uses ``pkg`` for package installation, -i.e. it does not use ports. +Package installation for FreeBSD uses ``pkg``, not ports. FreeBSD by default uses ``tcsh``. In order to activate virtualenv (see below), you will need a compatible shell, e.g. ``pkg install bash && diff --git a/docs/using.rst b/docs/using.rst index 3f04fc5fa..eb7c3962e 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -28,13 +28,13 @@ Firstly, please `install Git`_ and run the following commands: git clone https://github.com/letsencrypt/letsencrypt cd letsencrypt -.. warning:: Alternatively you could `download the ZIP archive`_ and - extract the snapshot of our repository, but it's strongly - recommended to use the above method instead. .. _`install Git`: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git -.. _`download the ZIP archive`: - https://github.com/letsencrypt/letsencrypt/archive/master.zip + +.. note:: On RedHat/CentOS 6 you will need to enable the EPEL_ + repository before install. + +.. _EPEL: http://fedoraproject.org/wiki/EPEL To install and run the client you just need to type: @@ -42,10 +42,10 @@ To install and run the client you just need to type: ./letsencrypt-auto -.. note:: On RedHat/CentOS 6 you will need to enable the EPEL_ - repository before install. - -.. _EPEL: http://fedoraproject.org/wiki/EPEL +.. hint:: During the beta phase, Let's Encrypt enforces strict rate limits on + the number of certificates issued for one domain. It is recommended to + initially use the test server via `--test-cert` until you get the desired + certificates. Throughout the documentation, whenever you see references to ``letsencrypt`` script/binary, you can substitute in @@ -61,108 +61,48 @@ or for full help, type: ./letsencrypt-auto --help all -Running with Docker -------------------- -Docker_ is an amazingly simple and quick way to obtain a -certificate. However, this mode of operation is unable to install -certificates or configure your webserver, because our installer -plugins cannot reach it from inside the Docker container. - -You should definitely read the :ref:`where-certs` section, in order to -know how to manage the certs -manually. https://github.com/letsencrypt/letsencrypt/wiki/Ciphersuite-guidance -provides some information about recommended ciphersuites. If none of -these make much sense to you, you should definitely use the -letsencrypt-auto_ method, which enables you to use installer plugins -that cover both of those hard topics. - -If you're still not convinced and have decided to use this method, -from the server that the domain you're requesting a cert for resolves -to, `install Docker`_, then issue the following command: - -.. code-block:: shell - - sudo docker run -it --rm -p 443:443 -p 80:80 --name letsencrypt \ - -v "/etc/letsencrypt:/etc/letsencrypt" \ - -v "/var/lib/letsencrypt:/var/lib/letsencrypt" \ - quay.io/letsencrypt/letsencrypt:latest auth - -and follow the instructions (note that ``auth`` command is explicitly -used - no installer plugins involved). Your new cert will be available -in ``/etc/letsencrypt/live`` on the host. - -.. _Docker: https://docker.com -.. _`install Docker`: https://docs.docker.com/userguide/ - - -Operating System Packages --------------------------- - -**FreeBSD** - - * Port: ``cd /usr/ports/security/py-letsencrypt && make install clean`` - * Package: ``pkg install py27-letsencrypt`` - -**Arch Linux** - -.. code-block:: shell - - sudo pacman -S letsencrypt letsencrypt-nginx letsencrypt-apache \ - letshelp-letsencrypt - -**Other Operating Systems** - -Unfortunately, this is an ongoing effort. If you'd like to package -Let's Encrypt client for your distribution of choice please have a -look at the :doc:`packaging`. - - -From source ------------ - -Installation from source is only supported for developers and the -whole process is described in the :doc:`contributing`. - -.. warning:: Please do **not** use ``python setup.py install`` or - ``python pip install .``. Please do **not** attempt the - installation commands as superuser/root and/or without virtual - environment, e.g. ``sudo python setup.py install``, ``sudo pip - install``, ``sudo ./venv/bin/...``. These modes of operation might - corrupt your operating system and are **not supported** by the - Let's Encrypt team! - - -Comparison of different methods -------------------------------- - -Unless you have a very specific requirements, we kindly ask you to use -the letsencrypt-auto_ method. It's the fastest, the most thoroughly -tested and the most reliable way of getting our software and the free -SSL certificates! +``letsencrypt-auto`` is the recommended method of running the Let's Encrypt +client beta releases on systems that don't have a packaged version. Debian, +Arch linux, FreeBSD, and OpenBSD now have native packages, so on those +systems you can just install ``letsencrypt`` (and perhaps +``letsencrypt-apache``). If you'd like to run the latest copy from Git, or +run your own locally modified copy of the client, follow the instructions in +the :doc:`contributing`. Some `other methods of installation`_ are discussed +below. Plugins ======= -=========== = = =============================================================== -Plugin A I Notes -=========== = = =============================================================== -apache_ Y Y Automates obtaining and installing a cert with Apache 2.4 on - Debian-based distributions with ``libaugeas0`` 1.0+. -standalone_ Y N Uses a "standalone" webserver to obtain a cert. -webroot_ Y N Obtains a cert using an already running webserver. -manual_ Y N Helps you obtain a cert by giving you instructions to perform - domain validation yourself. -nginx_ Y Y Very experimental and not included in letsencrypt-auto_. -=========== = = =============================================================== +The Let's Encrypt client supports a number of different "plugins" that can be +used to obtain and/or install certificates. Plugins that can obtain a cert +are called "authenticators" and can be used with the "certonly" command. +Plugins that can install a cert are called "installers". Plugins that do both +can be used with the "letsencrypt run" command, which is the default. + +=========== ==== ==== =============================================================== +Plugin Auth Inst Notes +=========== ==== ==== =============================================================== +apache_ Y Y Automates obtaining and installing a cert with Apache 2.4 on + Debian-based distributions with ``libaugeas0`` 1.0+. +standalone_ Y N Uses a "standalone" webserver to obtain a cert. +webroot_ Y N Obtains a cert by writing to the webroot directory of an + already running webserver. +manual_ Y N Helps you obtain a cert by giving you instructions to perform + domain validation yourself. +nginx_ Y Y Very experimental and not included in letsencrypt-auto_. +=========== ==== ==== =============================================================== + +Future plugins for IMAP servers, SMTP servers, IRC servers, etc, are likely to +be installers but not authenticators. Apache ------ If you're running Apache 2.4 on a Debian-based OS with version 1.0+ of the ``libaugeas0`` package available, you can use the Apache plugin. -This automates both obtaining and installing certs on an Apache +This automates both obtaining *and* installing certs on an Apache webserver. To specify this plugin on the command line, simply include ``--apache``. @@ -184,14 +124,34 @@ Webroot If you're running a webserver that you don't want to stop to use standalone, you can use the webroot plugin to obtain a cert by -including ``certonly`` and ``-a webroot`` on the command line. In -addition, you'll need to specify ``--webroot-path`` with the root +including ``certonly`` and ``--webroot`` on the command line. In +addition, you'll need to specify ``--webroot-path`` or ``-w`` with the root directory of the files served by your webserver. For example, ``--webroot-path /var/www/html`` or ``--webroot-path /usr/share/nginx/html`` are two common webroot paths. -If multiple domains are specified, they must all use the same path. -Additionally, your server must be configured to serve files from -hidden directories. + +If you're getting a certificate for many domains at once, each domain will use +the most recent ``--webroot-path``. So for instance: + +``letsencrypt certonly --webroot -w /var/www/example/ -d www.example.com -d example.com -w /var/www/eg -d eg.is -d www.eg.is`` + +Would obtain a single certificate for all of those names, using the +``/var/www/example`` webroot directory for the first two, and +``/var/www/eg`` for the second two. + +The webroot plugin works by creating a temporary file for each of your requested +domains in ``${webroot-path}/.well-known/acme-challenge``. Then the Let's +Encrypt validation server makes HTTP requests to validate that the DNS for each +requested domain resolves to the server running letsencrypt. An example request +made to your web server would look like: + +:: + + 66.133.109.36 - - [05/Jan/2016:20:11:24 -0500] "GET /.well-known/acme-challenge/HGr8U1IeTW4kY_Z6UIyaakzOkyQgPr_7ArlLgtZE8SX HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)" + +Note that to use the webroot plugin, your server must be configured to serve +files from hidden directories. + Manual ------ @@ -200,7 +160,7 @@ If you'd like to obtain a cert running ``letsencrypt`` on a machine other than your target webserver or perform the steps for domain validation yourself, you can use the manual plugin. While hidden from the UI, you can use the plugin to obtain a cert by specifying -``certonly`` and ``-a manual`` on the command line. This requires you +``certonly`` and ``--manual`` on the command line. This requires you to copy and paste commands into another terminal session. Nginx @@ -229,10 +189,11 @@ Renewal In order to renew certificates simply call the ``letsencrypt`` (or letsencrypt-auto_) again, and use the same values when prompted. You can automate it slightly by passing necessary flags on the CLI (see -`--help all`), or even further using the :ref:`config-file`. If you're -sure that UI doesn't prompt for any details you can add the command to -``crontab`` (make it less than every 90 days to avoid problems, say -every month). +`--help all`), or even further using the :ref:`config-file`. The +``--renew-by-default`` flag may be helpful for automating renewal. If +you're sure that UI doesn't prompt for any details you can add the +command to ``crontab`` (make it less than every 90 days to avoid +problems, say every month). Please note that the CA will send notification emails to the address you provide if you do not renew certificates that are about to expire. @@ -279,21 +240,25 @@ The following files are available: ``cert.pem`` Server certificate only. - This is what Apache needs for `SSLCertificateFile + This is what Apache < 2.4.8 needs for `SSLCertificateFile `_. ``chain.pem`` All certificates that need to be served by the browser **excluding** server certificate, i.e. root and intermediate certificates only. - This is what Apache needs for `SSLCertificateChainFile - `_. + This is what Apache < 2.4.8 needs for `SSLCertificateChainFile + `_, + and what nginx >= 1.3.7 needs for `ssl_trusted_certificate + `_. ``fullchain.pem`` All certificates, **including** server certificate. This is concatenation of ``chain.pem`` and ``cert.pem``. - This is what nginx needs for `ssl_certificate + This is what Apache >= 2.4.8 needs for `SSLCertificateFile + `_, + and what nginx needs for `ssl_certificate `_. @@ -342,7 +307,7 @@ get support on our `forums `_. If you find a bug in the software, please do report it in our `issue tracker `_. Remember to -give us us as much information as possible: +give us as much information as possible: - copy and paste exact command line used and the output (though mind that the latter might include some personally identifiable @@ -353,6 +318,112 @@ give us us as much information as possible: - your operating system, including specific version - specify which installation_ method you've chosen +Other methods of installation +============================= + +Running with Docker +------------------- + +Docker_ is an amazingly simple and quick way to obtain a +certificate. However, this mode of operation is unable to install +certificates or configure your webserver, because our installer +plugins cannot reach it from inside the Docker container. + +You should definitely read the :ref:`where-certs` section, in order to +know how to manage the certs +manually. https://github.com/letsencrypt/letsencrypt/wiki/Ciphersuite-guidance +provides some information about recommended ciphersuites. If none of +these make much sense to you, you should definitely use the +letsencrypt-auto_ method, which enables you to use installer plugins +that cover both of those hard topics. + +If you're still not convinced and have decided to use this method, +from the server that the domain you're requesting a cert for resolves +to, `install Docker`_, then issue the following command: + +.. code-block:: shell + + sudo docker run -it --rm -p 443:443 -p 80:80 --name letsencrypt \ + -v "/etc/letsencrypt:/etc/letsencrypt" \ + -v "/var/lib/letsencrypt:/var/lib/letsencrypt" \ + quay.io/letsencrypt/letsencrypt:latest auth + +and follow the instructions (note that ``auth`` command is explicitly +used - no installer plugins involved). Your new cert will be available +in ``/etc/letsencrypt/live`` on the host. + +.. _Docker: https://docker.com +.. _`install Docker`: https://docs.docker.com/userguide/ + + +Operating System Packages +-------------------------- + +**FreeBSD** + + * Port: ``cd /usr/ports/security/py-letsencrypt && make install clean`` + * Package: ``pkg install py27-letsencrypt`` + +**OpenBSD** + + * Port: ``cd /usr/ports/security/letsencrypt/client && make install clean`` + * Package: ``pkg_add letsencrypt`` + +**Arch Linux** + +.. code-block:: shell + + sudo pacman -S letsencrypt letsencrypt-apache + +**Debian** + +If you run Debian Stretch or Debian Sid, you can install letsencrypt packages. + +.. code-block:: shell + + sudo apt-get update + sudo apt-get install letsencrypt python-letsencrypt-apache + +If you don't want to use the Apache plugin, you can omit the +``python-letsencrypt-apache`` package. + +Packages for Debian Jessie are coming in the next few weeks. + +**Other Operating Systems** + +OS packaging is an ongoing effort. If you'd like to package +Let's Encrypt client for your distribution of choice please have a +look at the :doc:`packaging`. + + +From source +----------- + +Installation from source is only supported for developers and the +whole process is described in the :doc:`contributing`. + +.. warning:: Please do **not** use ``python setup.py install`` or + ``python pip install .``. Please do **not** attempt the + installation commands as superuser/root and/or without virtual + environment, e.g. ``sudo python setup.py install``, ``sudo pip + install``, ``sudo ./venv/bin/...``. These modes of operation might + corrupt your operating system and are **not supported** by the + Let's Encrypt team! + + +Comparison of different methods +------------------------------- + +Unless you have a very specific requirements, we kindly ask you to use +the letsencrypt-auto_ method. It's the fastest, the most thoroughly +tested and the most reliable way of getting our software and the free +SSL certificates! + +Beyond the methods discussed here, other methods may be possible, such as +installing Let's Encrypt directly with pip from PyPI or downloading a ZIP +archive from GitHub may be technically possible but are not presently +recommended or supported. + .. rubric:: Footnotes diff --git a/examples/cli.ini b/examples/cli.ini index a20764ed8..f0c993c57 100644 --- a/examples/cli.ini +++ b/examples/cli.ini @@ -5,12 +5,13 @@ # Use a 4096 bit RSA key instead of 2048 rsa-key-size = 4096 -# Always use the staging/testing server -server = https://acme-staging.api.letsencrypt.org/directory - # Uncomment and update to register with the specified e-mail address # email = foo@example.com +# Uncomment and update to generate certificates for the specified +# domains. +# domains = example.com, www.example.com + # Uncomment to use a text interface instead of ncurses # text = True diff --git a/examples/dev-cli.ini b/examples/dev-cli.ini index 2ea5d247d..c02038ca1 100644 --- a/examples/dev-cli.ini +++ b/examples/dev-cli.ini @@ -1,3 +1,6 @@ +# Always use the staging/testing server - avoids rate limiting +server = https://acme-staging.api.letsencrypt.org/directory + # This is an example configuration file for developers config-dir = /tmp/le/conf work-dir = /tmp/le/conf @@ -8,7 +11,6 @@ email = foo@example.com domains = example.com text = True -agree-dev-preview = True agree-tos = True debug = True # Unfortunately, it's not possible to specify "verbose" multiple times diff --git a/letsencrypt-apache/docs/api/dvsni.rst b/letsencrypt-apache/docs/api/dvsni.rst deleted file mode 100644 index 945771db8..000000000 --- a/letsencrypt-apache/docs/api/dvsni.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt_apache.dvsni` -------------------------------- - -.. automodule:: letsencrypt_apache.dvsni - :members: diff --git a/letsencrypt-apache/docs/api/tls_sni_01.rst b/letsencrypt-apache/docs/api/tls_sni_01.rst new file mode 100644 index 000000000..2c11a3394 --- /dev/null +++ b/letsencrypt-apache/docs/api/tls_sni_01.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt_apache.tls_sni_01` +------------------------------------ + +.. automodule:: letsencrypt_apache.tls_sni_01 + :members: diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py index 9e0948f12..9b51c32a9 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py @@ -120,7 +120,8 @@ class AugeasConfigurator(common.Plugin): self.reverter.add_to_temp_checkpoint( save_files, self.save_notes) else: - self.reverter.add_to_checkpoint(save_files, self.save_notes) + self.reverter.add_to_checkpoint(save_files, + self.save_notes) except errors.ReverterError as err: raise errors.PluginError(str(err)) diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_lens/README b/letsencrypt-apache/letsencrypt_apache/augeas_lens/README index fc803a776..f801efd43 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_lens/README +++ b/letsencrypt-apache/letsencrypt_apache/augeas_lens/README @@ -1,2 +1,2 @@ Let's Encrypt includes the very latest Augeas lenses in order to ship bug fixes -to Apacche configuration handling bugs as quickly as possible +to Apache configuration handling bugs as quickly as possible diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug b/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug index 9b50a8f0e..edaca3fef 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug +++ b/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug @@ -51,7 +51,7 @@ let sep_osp = Sep.opt_space let sep_eq = del /[ \t]*=[ \t]*/ "=" let nmtoken = /[a-zA-Z:_][a-zA-Z0-9:_.-]*/ -let word = /[a-zA-Z][a-zA-Z0-9._-]*/ +let word = /[a-z][a-z0-9._-]*/i let comment = Util.comment let eol = Util.doseol @@ -59,13 +59,18 @@ let empty = Util.empty_dos let indent = Util.indent (* borrowed from shellvars.aug *) -let char_arg_dir = /[^\\ '"\t\r\n]|\\\\"|\\\\'/ +let char_arg_dir = /([^\\ '"{\t\r\n]|[^ '"{\t\r\n]+[^\\ \t\r\n])|\\\\"|\\\\'/ let char_arg_sec = /[^ '"\t\r\n>]|\\\\"|\\\\'/ +let char_arg_wl = /([^\\ '"},\t\r\n]|[^ '"},\t\r\n]+[^\\ '"},\t\r\n])/ + let cdot = /\\\\./ let cl = /\\\\\n/ let dquot = let no_dquot = /[^"\\\r\n]/ in /"/ . (no_dquot|cdot|cl)* . /"/ +let dquot_msg = + let no_dquot = /([^ \t"\\\r\n]|[^"\\\r\n]+[^ \t"\\\r\n])/ + in /"/ . (no_dquot|cdot|cl)* let squot = let no_squot = /[^'\\\r\n]/ in /'/ . (no_squot|cdot|cl)* . /'/ @@ -76,12 +81,24 @@ let comp = /[<>=]?=/ *****************************************************************) let arg_dir = [ label "arg" . store (char_arg_dir+|dquot|squot) ] +(* message argument starts with " but ends at EOL *) +let arg_dir_msg = [ label "arg" . store dquot_msg ] let arg_sec = [ label "arg" . store (char_arg_sec+|comp|dquot|squot) ] +let arg_wl = [ label "arg" . store (char_arg_wl+|dquot|squot) ] + +(* comma-separated wordlist as permitted in the SSLRequire directive *) +let arg_wordlist = + let wl_start = Util.del_str "{" in + let wl_end = Util.del_str "}" in + let wl_sep = del /[ \t]*,[ \t]*/ ", " + in [ label "wordlist" . wl_start . arg_wl . (wl_sep . arg_wl)* . wl_end ] let argv (l:lens) = l . (sep_spc . l)* -let directive = [ indent . label "directive" . store word . - (sep_spc . argv arg_dir)? . eol ] +let directive = + (* arg_dir_msg may be the last or only argument *) + let dir_args = (argv (arg_dir|arg_wordlist) . (sep_spc . arg_dir_msg)?) | arg_dir_msg + in [ indent . label "directive" . store word . (sep_spc . dir_args)? . eol ] let section (body:lens) = (* opt_eol includes empty lines *) @@ -89,11 +106,17 @@ let section (body:lens) = let inner = (sep_spc . argv arg_sec)? . sep_osp . dels ">" . opt_eol . ((body|comment) . (body|empty|comment)*)? . indent . dels "" ">" . eol ] + let kword = key (word - /perl/i) in + let dword = del (word - /perl/i) "a" in + [ indent . dels "<" . square kword inner dword . del />[ \t\n\r]*/ ">\n" ] + +let perl_section = [ indent . label "Perl" . del //i "" + . store /[^<]*/ + . del /<\/perl>/i "" . eol ] + let rec content = section (content|directive) + | perl_section let lns = (content|directive|comment|empty)* @@ -104,6 +127,7 @@ let filter = (incl "/etc/apache2/apache2.conf") . (incl "/etc/apache2/conf-available/*.conf") . (incl "/etc/apache2/mods-available/*") . (incl "/etc/apache2/sites-available/*") . + (incl "/etc/apache2/vhosts.d/*.conf") . (incl "/etc/httpd/conf.d/*.conf") . (incl "/etc/httpd/httpd.conf") . (incl "/etc/httpd/conf/httpd.conf") . diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c811501a9..2d822b3a1 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -1,14 +1,14 @@ """Apache Configuration based off of Augeas Configurator.""" # pylint: disable=too-many-lines import filecmp -import itertools import logging import os import re import shutil import socket -import subprocess +import time +import zope.component import zope.interface from acme import challenges @@ -22,10 +22,11 @@ from letsencrypt.plugins import common from letsencrypt_apache import augeas_configurator from letsencrypt_apache import constants from letsencrypt_apache import display_ops -from letsencrypt_apache import dvsni +from letsencrypt_apache import tls_sni_01 from letsencrypt_apache import obj from letsencrypt_apache import parser +from collections import defaultdict logger = logging.getLogger(__name__) @@ -86,21 +87,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): @classmethod def add_parser_arguments(cls, add): - add("ctl", default=constants.CLI_DEFAULTS["ctl"], - help="Path to the 'apache2ctl' binary, used for 'configtest', " - "retrieving the Apache2 version number, and initialization " - "parameters.") - add("enmod", default=constants.CLI_DEFAULTS["enmod"], + add("enmod", default=constants.os_constant("enmod"), help="Path to the Apache 'a2enmod' binary.") - add("dismod", default=constants.CLI_DEFAULTS["dismod"], - help="Path to the Apache 'a2enmod' binary.") - add("init-script", default=constants.CLI_DEFAULTS["init_script"], - help="Path to the Apache init script (used for server " - "reload/restart).") - add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"], + add("dismod", default=constants.os_constant("dismod"), + help="Path to the Apache 'a2dismod' binary.") + add("le-vhost-ext", default=constants.os_constant("le_vhost_ext"), help="SSL vhost configuration extension.") - add("server-root", default=constants.CLI_DEFAULTS["server_root"], + add("server-root", default=constants.os_constant("server_root"), help="Apache server root directory.") + add("vhost-root", default=constants.os_constant("vhost_root"), + help="Apache server VirtualHost configuration root") + add("challenge-location", + default=constants.os_constant("challenge_location"), + help="Directory path for challenge configuration.") + add("handle-modules", default=constants.os_constant("handle_mods"), + help="Let installer handle enabling required modules for you." + + "(Only Ubuntu/Debian currently)") + add("handle-sites", default=constants.os_constant("handle_sites"), + help="Let installer handle enabling sites for you." + + "(Only Ubuntu/Debian currently)") + le_util.add_deprecated_argument(add, "init-script", 1) def __init__(self, *args, **kwargs): """Initialize an Apache Configurator. @@ -121,12 +127,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser = None self.version = version self.vhosts = None - self._enhance_func = {"redirect": self._enable_redirect} + self._enhance_func = {"redirect": self._enable_redirect, + "ensure-http-header": self._set_http_header} @property def mod_ssl_conf(self): """Full absolute path to SSL configuration file.""" - return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST) + return os.path.join(self.config.config_dir, + constants.MOD_SSL_CONF_DEST) def prepare(self): """Prepare the authenticator/installer. @@ -138,41 +146,61 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ # Verify Apache is installed - for exe in (self.conf("ctl"), self.conf("enmod"), - self.conf("dismod"), self.conf("init-script")): - if not le_util.exe_exists(exe): - raise errors.NoInstallationError + if not le_util.exe_exists(constants.os_constant("restart_cmd")[0]): + raise errors.NoInstallationError # Make sure configuration is valid self.config_test() - self.parser = parser.ApacheParser( - self.aug, self.conf("server-root"), self.conf("ctl")) - # Check for errors in parsing files with Augeas - self.check_parsing_errors("httpd.aug") - # Set Version if self.version is None: self.version = self.get_version() - if self.version < (2, 2): + if self.version < (2, 4): raise errors.NotSupportedError( "Apache Version %s not supported.", str(self.version)) + if not self._check_aug_version(): + raise errors.NotSupportedError( + "Apache plugin support requires libaugeas0 and augeas-lenses " + "version 1.2.0 or higher, please make sure you have you have " + "those installed.") + + self.parser = parser.ApacheParser( + self.aug, self.conf("server-root"), self.conf("vhost-root"), + self.version) + # Check for errors in parsing files with Augeas + self.check_parsing_errors("httpd.aug") + # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() install_ssl_options_conf(self.mod_ssl_conf) + def _check_aug_version(self): + """ Checks that we have recent enough version of libaugeas. + If augeas version is recent enough, it will support case insensitive + regexp matching""" + + self.aug.set("/test/path/testing/arg", "aRgUMeNT") + try: + matches = self.aug.match( + "/test//*[self::arg=~regexp('argument', 'i')]") + except RuntimeError: + self.aug.remove("/test/path") + return False + self.aug.remove("/test/path") + return matches + def deploy_cert(self, domain, cert_path, key_path, - chain_path=None, fullchain_path=None): # pylint: disable=unused-argument + chain_path=None, fullchain_path=None): """Deploys certificate to specified virtual host. Currently tries to find the last directives to deploy the cert in the VHost associated with the given domain. If it can't find the - directives, it searches the "included" confs. The function verifies that - it has located the three directives and finally modifies them to point - to the correct destination. After the certificate is installed, the - VirtualHost is enabled if it isn't already. + directives, it searches the "included" confs. The function verifies + that it has located the three directives and finally modifies them + to point to the correct destination. After the certificate is + installed, the VirtualHost is enabled if it isn't already. .. todo:: Might be nice to remove chain directive if none exists This shouldn't happen within letsencrypt though @@ -182,13 +210,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ vhost = self.choose_vhost(domain) + self._clean_vhost(vhost) # This is done first so that ssl module is enabled and cert_path, # cert_key... can all be parsed appropriately self.prepare_server_https("443") - path = {"cert_path": self.parser.find_dir("SSLCertificateFile", None, vhost.path), - "cert_key": self.parser.find_dir("SSLCertificateKeyFile", None, vhost.path)} + path = {"cert_path": self.parser.find_dir("SSLCertificateFile", + None, vhost.path), + "cert_key": self.parser.find_dir("SSLCertificateKeyFile", + None, vhost.path)} # Only include if a certificate chain is specified if chain_path is not None: @@ -205,16 +236,28 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "Unable to find cert and/or key directives") logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) + logger.debug("Apache version is %s", + ".".join(str(i) for i in self.version)) - # Assign the final directives; order is maintained in find_dir - self.aug.set(path["cert_path"][-1], cert_path) - self.aug.set(path["cert_key"][-1], key_path) - if chain_path is not None: - if not path["chain_path"]: - self.parser.add_dir( - vhost.path, "SSLCertificateChainFile", chain_path) + if self.version < (2, 4, 8) or (chain_path and not fullchain_path): + # install SSLCertificateFile, SSLCertificateKeyFile, + # and SSLCertificateChainFile directives + set_cert_path = cert_path + self.aug.set(path["cert_path"][-1], cert_path) + self.aug.set(path["cert_key"][-1], key_path) + if chain_path is not None: + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) else: - self.aug.set(path["chain_path"][-1], chain_path) + raise errors.PluginError("--chain-path is required for your " + "version of Apache") + else: + if not fullchain_path: + raise errors.PluginError("Please provide the --fullchain-path\ + option pointing to your full chain file") + set_cert_path = fullchain_path + self.aug.set(path["cert_path"][-1], fullchain_path) + self.aug.set(path["cert_key"][-1], key_path) # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" @@ -222,21 +265,27 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "\tSSLCertificateKeyFile %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs), - cert_path, key_path)) + set_cert_path, key_path)) if chain_path is not None: self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path - # Make sure vhost is enabled - if not vhost.enabled: - self.enable_site(vhost) + # Make sure vhost is enabled if distro with enabled / available + if self.conf("handle-sites"): + if not vhost.enabled: + self.enable_site(vhost) - def choose_vhost(self, target_name): + def choose_vhost(self, target_name, temp=False): """Chooses a virtual host based on the given domain name. If there is no clear virtual host to be selected, the user is prompted with all available choices. + The returned vhost is guaranteed to have TLS enabled unless temp is + True. If temp is True, there is no such guarantee and the result is + not cached. + :param str target_name: domain name + :param bool temp: whether the vhost is only used temporarily :returns: ssl vhost associated with name :rtype: :class:`~letsencrypt_apache.obj.VirtualHost` @@ -251,15 +300,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Try to find a reasonable vhost vhost = self._find_best_vhost(target_name) if vhost is not None: + if temp: + return vhost if not vhost.ssl: vhost = self.make_vhost_ssl(vhost) self.assoc[target_name] = vhost return vhost - return self._choose_vhost_from_list(target_name) + return self._choose_vhost_from_list(target_name, temp) - def _choose_vhost_from_list(self, target_name): + def _choose_vhost_from_list(self, target_name, temp=False): # Select a vhost from a list vhost = display_ops.select_vhost(target_name, self.vhosts) if vhost is None: @@ -268,11 +319,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "No vhost was selected. Please specify servernames " "in the Apache config", target_name) raise errors.PluginError("No vhost selected") - + elif temp: + return vhost elif not vhost.ssl: addrs = self._get_proposed_addrs(vhost, "443") # TODO: Conflicts is too conservative - if not any(vhost.enabled and vhost.conflicts(addrs) for vhost in self.vhosts): + if not any(vhost.enabled and vhost.conflicts(addrs) for + vhost in self.vhosts): vhost = self.make_vhost_ssl(vhost) else: logger.error( @@ -433,8 +486,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if self.parser.find_dir("SSLEngine", "on", start=path, exclude=False): is_ssl = True + # "SSLEngine on" might be set outside of + # Treat vhosts with port 443 as ssl vhosts + for addr in addrs: + if addr.get_port() == "443": + is_ssl = True + filename = get_file_path(path) - is_enabled = self.is_site_enabled(filename) + if self.conf("handle-sites"): + is_enabled = self.is_site_enabled(filename) + else: + is_enabled = True macro = False if "/macro/" in path.lower(): @@ -445,7 +507,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self._add_servernames(vhost) return vhost - # TODO: make "sites-available" a configurable directory def get_virtual_hosts(self): """Returns list of virtual hosts found in the Apache configuration. @@ -454,15 +515,27 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: list """ - # Search sites-available, httpd.conf for possible virtual hosts - paths = self.aug.match( - ("/files%s/sites-available//*[label()=~regexp('%s')]" % - (self.parser.root, parser.case_i("VirtualHost")))) - + # Search base config, and all included paths for VirtualHosts vhs = [] + vhost_paths = {} + for vhost_path in self.parser.parser_paths.keys(): + paths = self.aug.match( + ("/files%s//*[label()=~regexp('%s')]" % + (vhost_path, parser.case_i("VirtualHost")))) + for path in paths: + new_vhost = self._create_vhost(path) + realpath = os.path.realpath(new_vhost.filep) + if realpath not in vhost_paths.keys(): + vhs.append(new_vhost) + vhost_paths[realpath] = new_vhost.filep + elif realpath == new_vhost.filep: + # Prefer "real" vhost paths instead of symlinked ones + # ex: sites-enabled/vh.conf -> sites-available/vh.conf - for path in paths: - vhs.append(self._create_vhost(path)) + # remove old (most likely) symlinked one + vhs = [v for v in vhs if v.filep != vhost_paths[realpath]] + vhs.append(new_vhost) + vhost_paths[realpath] = realpath return vhs @@ -516,26 +589,65 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str port: Port to listen on """ - if "ssl_module" not in self.parser.modules: - self.enable_mod("ssl", temp=temp) + self.prepare_https_modules(temp) # Check for Listen # Note: This could be made to also look for ip:443 combo - if not self.parser.find_dir("Listen", port): - logger.debug("No Listen %s directive found. Setting the " - "Apache Server to Listen on port %s", port, port) - - if port == "443": - args = [port] + listens = [self.parser.get_arg(x).split()[0] for + x in self.parser.find_dir("Listen")] + # In case no Listens are set (which really is a broken apache config) + if not listens: + listens = ["80"] + if port in listens: + return + for listen in listens: + # For any listen statement, check if the machine also listens on + # Port 443. If not, add such a listen statement. + if len(listen.split(":")) == 1: + # Its listening to all interfaces + if port not in listens: + if port == "443": + args = [port] + else: + # Non-standard ports should specify https protocol + args = [port, "https"] + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path( + self.parser.loc["listen"]), "Listen", args) + self.save_notes += "Added Listen %s directive to %s\n" % ( + port, self.parser.loc["listen"]) + listens.append(port) else: - # Non-standard ports should specify https protocol - args = [port, "https"] + # The Listen statement specifies an ip + _, ip = listen[::-1].split(":", 1) + ip = ip[::-1] + if "%s:%s" % (ip, port) not in listens: + if port == "443": + args = ["%s:%s" % (ip, port)] + else: + # Non-standard ports should specify https protocol + args = ["%s:%s" % (ip, port), "https"] + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path( + self.parser.loc["listen"]), "Listen", args) + self.save_notes += ("Added Listen %s:%s directive to " + "%s\n") % (ip, port, + self.parser.loc["listen"]) + listens.append("%s:%s" % (ip, port)) - self.parser.add_dir_to_ifmodssl( - parser.get_aug_path( - self.parser.loc["listen"]), "Listen", args) - self.save_notes += "Added Listen %s directive to %s\n" % ( - port, self.parser.loc["listen"]) + def prepare_https_modules(self, temp): + """Helper method for prepare_server_https, taking care of enabling + needed modules + + :param boolean temp: If the change is temporary + """ + + if self.conf("handle-modules"): + if "ssl_module" not in self.parser.modules: + self.enable_mod("ssl", temp=temp) + if self.version >= (2, 4) and ("socache_shmcb_module" not in + self.parser.modules): + self.enable_mod("socache_shmcb", temp=temp) def make_addrs_sni_ready(self, addrs): """Checks to see if the server is ready for SNI challenges. @@ -559,7 +671,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Duplicates vhost and adds default ssl options New vhost will reside as (nonssl_vhost.path) + - ``letsencrypt_apache.constants.CLI_DEFAULTS["le_vhost_ext"]`` + ``letsencrypt_apache.constants.os_constant("le_vhost_ext")`` .. note:: This function saves the configuration @@ -586,7 +698,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): (ssl_fp, parser.case_i("VirtualHost"))) if len(vh_p) != 1: logger.error("Error: should only be one vhost in %s", avail_fp) - raise errors.PluginError("Only one vhost per file is allowed") + raise errors.PluginError("Currently, we only support " + "configurations with one vhost per file") else: # This simplifies the process vh_p = vh_p[0] @@ -625,6 +738,39 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): else: return non_ssl_vh_fp + self.conf("le_vhost_ext") + def _sift_line(self, line): + """Decides whether a line should be copied to a SSL vhost. + + A canonical example of when sifting a line is required: + When the http vhost contains a RewriteRule that unconditionally + redirects any request to the https version of the same site. + e.g: + RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [L,QSA,R=permanent] + Copying the above line to the ssl vhost would cause a + redirection loop. + + :param str line: a line extracted from the http vhost. + + :returns: True - don't copy line from http vhost to SSL vhost. + :rtype: bool + + """ + if not line.lstrip().startswith("RewriteRule"): + return False + + # According to: http://httpd.apache.org/docs/2.4/rewrite/flags.html + # The syntax of a RewriteRule is: + # RewriteRule pattern target [Flag1,Flag2,Flag3] + # i.e. target is required, so it must exist. + target = line.split()[2].strip() + + # target may be surrounded with quotes + if target[0] in ("'", '"') and target[0] == target[-1]: + target = target[1:-1] + + # Sift line if it redirects the request to a HTTPS site + return target.startswith("https://") + def _copy_create_ssl_vhost_skeleton(self, avail_fp, ssl_fp): """Copies over existing Vhost with IfModule mod_ssl.c> skeleton. @@ -637,18 +783,38 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # First register the creation so that it is properly removed if # configuration is rolled back self.reverter.register_file_creation(False, ssl_fp) + sift = False try: with open(avail_fp, "r") as orig_file: with open(ssl_fp, "w") as new_file: new_file.write("\n") for line in orig_file: - new_file.write(line) + if self._sift_line(line): + if not sift: + new_file.write( + "# Some rewrite rules in this file were " + "were disabled on your HTTPS site,\n" + "# because they have the potential to " + "create redirection loops.\n") + sift = True + new_file.write("# " + line) + else: + new_file.write(line) new_file.write("\n") except IOError: logger.fatal("Error writing/reading to file in make_vhost_ssl") raise errors.PluginError("Unable to write/read in make_vhost_ssl") + if sift: + reporter = zope.component.getUtility(interfaces.IReporter) + reporter.add_message( + "Some rewrite rules copied from {0} were disabled in the " + "vhost for your HTTPS site located at {1} because they have " + "the potential to create redirection loops.".format(avail_fp, + ssl_fp), + reporter.MEDIUM_PRIORITY) + def _update_ssl_vhosts_addrs(self, vh_path): ssl_addrs = set() ssl_addr_p = self.aug.match(vh_path + "/arg") @@ -662,6 +828,30 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs + def _clean_vhost(self, vhost): + # remove duplicated or conflicting ssl directives + self._deduplicate_directives(vhost.path, + ["SSLCertificateFile", + "SSLCertificateKeyFile"]) + # remove all problematic directives + self._remove_directives(vhost.path, ["SSLCertificateChainFile"]) + + def _deduplicate_directives(self, vh_path, directives): + for directive in directives: + while len(self.parser.find_dir(directive, None, + vh_path, False)) > 1: + directive_path = self.parser.find_dir(directive, None, + vh_path, False) + self.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) + + def _remove_directives(self, vh_path, directives): + for directive in directives: + while len(self.parser.find_dir(directive, None, + vh_path, False)) > 0: + directive_path = self.parser.find_dir(directive, None, + vh_path, False) + self.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) + def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", "insert_cert_file_path") @@ -686,7 +876,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): for addr in vhost.addrs: for test_vh in self.vhosts: if (vhost.filep != test_vh.filep and - any(test_addr == addr for test_addr in test_vh.addrs) and + any(test_addr == addr for + test_addr in test_vh.addrs) and not self.is_name_vhost(addr)): self.add_name_vhost(addr) logger.info("Enabling NameVirtualHosts on %s", addr) @@ -695,12 +886,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if need_to_save: self.save() - ############################################################################ + ###################################################################### # Enhancements - ############################################################################ + ###################################################################### def supported_enhancements(self): # pylint: disable=no-self-use """Returns currently supported enhancements.""" - return ["redirect"] + return ["redirect", "ensure-http-header"] def enhance(self, domain, enhancement, options=None): """Enhance configuration. @@ -727,6 +918,74 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.warn("Failed %s for %s", enhancement, domain) raise + def _set_http_header(self, ssl_vhost, header_substring): + """Enables header that is identified by header_substring on ssl_vhost. + + If the header identified by header_substring is not already set, + a new Header directive is placed in ssl_vhost's configuration with + arguments from: constants.HTTP_HEADER[header_substring] + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. + :type str + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, :class:`~letsencrypt_apache.obj.VirtualHost`) + + :raises .errors.PluginError: If no viable HTTP host can be created or + set with header header_substring. + + """ + if "headers_module" not in self.parser.modules: + self.enable_mod("headers") + + # Check if selected header is already set + self._verify_no_matching_http_header(ssl_vhost, header_substring) + + # Add directives to server + self.parser.add_dir(ssl_vhost.path, "Header", + constants.HEADER_ARGS[header_substring]) + + self.save_notes += ("Adding %s header to ssl vhost in %s\n" % + (header_substring, ssl_vhost.filep)) + + self.save() + logger.info("Adding %s header to ssl vhost in %s", header_substring, + ssl_vhost.filep) + + def _verify_no_matching_http_header(self, ssl_vhost, header_substring): + """Checks to see if an there is an existing Header directive that + contains the string header_substring. + + :param ssl_vhost: vhost to check + :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. + :type str + + :returns: boolean + :rtype: (bool) + + :raises errors.PluginEnhancementAlreadyPresent When header + header_substring exists + + """ + header_path = self.parser.find_dir("Header", None, + start=ssl_vhost.path) + if header_path: + # "Existing Header directive for virtualhost" + pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower()) + for match in header_path: + if re.search(pat, self.aug.get(match).lower()): + raise errors.PluginEnhancementAlreadyPresent( + "Existing %s header" % (header_substring)) + def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. @@ -771,15 +1030,32 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "redirection") self._create_redirect_vhost(ssl_vhost) else: - # Check if redirection already exists - self._verify_no_redirects(general_vh) + # Check if LetsEncrypt redirection already exists + self._verify_no_letsencrypt_redirect(general_vh) + + # Note: if code flow gets here it means we didn't find the exact + # letsencrypt RewriteRule config for redirection. Finding + # another RewriteRule is likely to be fine in most or all cases, + # but redirect loops are possible in very obscure cases; see #1620 + # for reasoning. + if self._is_rewrite_exists(general_vh): + logger.warn("Added an HTTP->HTTPS rewrite in addition to " + "other RewriteRules; you may wish to check for " + "overall consistency.") # Add directives to server # Note: These are not immediately searchable in sites-enabled # even with save() and load() - self.parser.add_dir(general_vh.path, "RewriteEngine", "on") - self.parser.add_dir(general_vh.path, "RewriteRule", - constants.REWRITE_HTTPS_ARGS) + if not self._is_rewrite_engine_on(general_vh): + self.parser.add_dir(general_vh.path, "RewriteEngine", "on") + + if self.get_version() >= (2, 3, 9): + self.parser.add_dir(general_vh.path, "RewriteRule", + constants.REWRITE_HTTPS_ARGS_WITH_END) + else: + self.parser.add_dir(general_vh.path, "RewriteRule", + constants.REWRITE_HTTPS_ARGS) + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % (general_vh.filep, ssl_vhost.filep)) self.save() @@ -787,35 +1063,67 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.info("Redirecting vhost in %s to ssl vhost in %s", general_vh.filep, ssl_vhost.filep) - def _verify_no_redirects(self, vhost): - """Checks to see if existing redirect is in place. + def _verify_no_letsencrypt_redirect(self, vhost): + """Checks to see if a redirect was already installed by letsencrypt. - Checks to see if virtualhost already contains a rewrite or redirect - returns boolean, integer + Checks to see if virtualhost already contains a rewrite rule that is + identical to Letsencrypt's redirection rewrite rule. :param vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :raises errors.PluginError: When another redirection exists + :raises errors.PluginEnhancementAlreadyPresent: When the exact + letsencrypt redirection WriteRule exists in virtual host. + """ + rewrite_path = self.parser.find_dir( + "RewriteRule", None, start=vhost.path) + + # There can be other RewriteRule directive lines in vhost config. + # rewrite_args_dict keys are directive ids and the corresponding value + # for each is a list of arguments to that directive. + rewrite_args_dict = defaultdict(list) + pat = r'.*(directive\[\d+\]).*' + for match in rewrite_path: + m = re.match(pat, match) + if m: + dir_id = m.group(1) + rewrite_args_dict[dir_id].append(match) + + if rewrite_args_dict: + redirect_args = [constants.REWRITE_HTTPS_ARGS, + constants.REWRITE_HTTPS_ARGS_WITH_END] + + for matches in rewrite_args_dict.values(): + if [self.aug.get(x) for x in matches] in redirect_args: + raise errors.PluginEnhancementAlreadyPresent( + "Let's Encrypt has already enabled redirection") + + def _is_rewrite_exists(self, vhost): + """Checks if there exists a RewriteRule directive in vhost + + :param vhost: vhost to check + :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :returns: True if a RewriteRule directive exists. + :rtype: bool """ rewrite_path = self.parser.find_dir( "RewriteRule", None, start=vhost.path) - redirect_path = self.parser.find_dir("Redirect", None, start=vhost.path) + return bool(rewrite_path) - if redirect_path: - # "Existing Redirect directive for virtualhost" - raise errors.PluginError("Existing Redirect present on HTTP vhost.") - if rewrite_path: - # "No existing redirection for virtualhost" - if len(rewrite_path) != len(constants.REWRITE_HTTPS_ARGS): - raise errors.PluginError("Unknown Existing RewriteRule") - for match, arg in itertools.izip( - rewrite_path, constants.REWRITE_HTTPS_ARGS): - if self.aug.get(match) != arg: - raise errors.PluginError("Unknown Existing RewriteRule") - raise errors.PluginError( - "Let's Encrypt has already enabled redirection") + def _is_rewrite_engine_on(self, vhost): + """Checks if a RewriteEngine directive is on + + :param vhost: vhost to check + :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + """ + rewrite_engine_path = self.parser.find_dir("RewriteEngine", "on", + start=vhost.path) + if rewrite_engine_path: + return self.parser.get_arg(rewrite_engine_path[0]) + return False def _create_redirect_vhost(self, ssl_vhost): """Creates an http_vhost specifically to redirect for the ssl_vhost. @@ -852,6 +1160,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if ssl_vhost.aliases: serveralias = "ServerAlias " + " ".join(ssl_vhost.aliases) + rewrite_rule_args = [] + if self.get_version() >= (2, 3, 9): + rewrite_rule_args = constants.REWRITE_HTTPS_ARGS_WITH_END + else: + rewrite_rule_args = constants.REWRITE_HTTPS_ARGS + return ("\n" "%s \n" "%s \n" @@ -863,9 +1177,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "ErrorLog /var/log/apache2/redirect.error.log\n" "LogLevel warn\n" "\n" - % (" ".join(str(addr) for addr in self._get_proposed_addrs(ssl_vhost)), + % (" ".join(str(addr) for + addr in self._get_proposed_addrs(ssl_vhost)), servername, serveralias, - " ".join(constants.REWRITE_HTTPS_ARGS))) + " ".join(rewrite_rule_args))) def _write_out_redirect(self, ssl_vhost, text): # This is the default name @@ -877,8 +1192,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if len(ssl_vhost.name) < (255 - (len(redirect_filename) + 1)): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.name - redirect_filepath = os.path.join( - self.parser.root, "sites-available", redirect_filename) + redirect_filepath = os.path.join(self.conf("vhost-root"), + redirect_filename) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -906,7 +1221,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return None - def _get_proposed_addrs(self, vhost, port="80"): # pylint: disable=no-self-use + def _get_proposed_addrs(self, vhost, port="80"): """Return all addrs of vhost with the port replaced with the specified. :param obj.VirtualHost ssl_vhost: Original Vhost @@ -964,7 +1279,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: bool """ + enabled_dir = os.path.join(self.parser.root, "sites-enabled") + if not os.path.isdir(enabled_dir): + error_msg = ("Directory '{0}' does not exist. Please ensure " + "that the values for --apache-handle-sites and " + "--apache-server-root are correct for your " + "environment.".format(enabled_dir)) + raise errors.ConfigurationError(error_msg) for entry in os.listdir(enabled_dir): try: if filecmp.cmp(avail_fp, os.path.join(enabled_dir, entry)): @@ -974,12 +1296,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return False def enable_site(self, vhost): - """Enables an available site, Apache restart required. + """Enables an available site, Apache reload required. .. note:: Does not make sure that the site correctly works or that all modules are enabled appropriately. - .. todo:: This function should number subdomains before the domain vhost + .. todo:: This function should number subdomains before the domain + vhost .. todo:: Make sure link is not broken... @@ -1009,7 +1332,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def enable_mod(self, mod_name, temp=False): """Enables module in Apache. - Both enables and restarts Apache so module is active. + Both enables and reloads Apache so module is active. :param str mod_name: Name of the module to enable. (e.g. 'ssl') :param bool temp: Whether or not this is a temporary action. @@ -1051,8 +1374,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Modules can enable additional config files. Variables may be defined # within these new configuration sections. - # Restart is not necessary as DUMP_RUN_CFG uses latest config. - self.parser.update_runtime_variables(self.conf("ctl")) + # Reload is not necessary as DUMP_RUN_CFG uses latest config. + self.parser.update_runtime_variables() def _add_parser_mod(self, mod_name): """Shortcut for updating parser modules.""" @@ -1074,16 +1397,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): le_util.run_script([self.conf("enmod"), mod_name]) def restart(self): - """Restarts apache server. + """Runs a config test and reloads the Apache server. - .. todo:: This function will be converted to using reload - - :raises .errors.MisconfigurationError: If unable to restart due - to a configuration problem, or if the restart subprocess - cannot be run. + :raises .errors.MisconfigurationError: If either the config test + or reload fails. """ - return apache_restart(self.conf("init-script")) + self.config_test() + logger.debug(self.reverter.view_config_changes(for_logging=True)) + self._reload() + + def _reload(self): + """Reloads the Apache server. + + :raises .errors.MisconfigurationError: If reload fails + + """ + try: + le_util.run_script(constants.os_constant("restart_cmd")) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Apache for errors. @@ -1092,7 +1425,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: - le_util.run_script([self.conf("ctl"), "configtest"]) + le_util.run_script(constants.os_constant("conftest_cmd")) except errors.SubprocessError as err: raise errors.MisconfigurationError(str(err)) @@ -1108,10 +1441,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: - stdout, _ = le_util.run_script([self.conf("ctl"), "-v"]) + stdout, _ = le_util.run_script( + constants.os_constant("version_cmd")) except errors.SubprocessError: raise errors.PluginError( - "Unable to run %s -v" % self.conf("ctl")) + "Unable to run %s -v" % + constants.os_constant("version_cmd")) regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) matches = regex.findall(stdout) @@ -1148,26 +1483,30 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self._chall_out.update(achalls) responses = [None] * len(achalls) - apache_dvsni = dvsni.ApacheDvsni(self) + chall_doer = tls_sni_01.ApacheTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - apache_dvsni.add_chall(achall, i) + # Currently also have chall_doer hold associated index of the + # challenge. This helps to put all of the responses back together + # when they are all complete. + chall_doer.add_chall(achall, i) - sni_response = apache_dvsni.perform() + sni_response = chall_doer.perform() if sni_response: - # Must restart in order to activate the challenges. + # Must reload in order to activate the challenges. # Handled here because we may be able to load up other challenge # types self.restart() + # TODO: Remove this dirty hack. We need to determine a reliable way + # of identifying when the new configuration is being used. + time.sleep(3) + # Go through all of the challenges and assign them to the proper # place in the responses return value. All responses must be in the # same order as the original challenges. for i, resp in enumerate(sni_response): - responses[apache_dvsni.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses @@ -1196,49 +1535,11 @@ def _get_mod_deps(mod_name): """ deps = { - "ssl": ["setenvif", "mime", "socache_shmcb"] + "ssl": ["setenvif", "mime"] } return deps.get(mod_name, []) -def apache_restart(apache_init_script): - """Restarts the Apache Server. - - :param str apache_init_script: Path to the Apache init script. - - .. todo:: Try to use reload instead. (This caused timing problems before) - - .. todo:: On failure, this should be a recovery_routine call with another - restart. This will confuse and inhibit developers from testing code - though. This change should happen after - the ApacheConfigurator has been thoroughly tested. The function will - need to be moved into the class again. Perhaps - this version can live on... for testing purposes. - - :raises .errors.MisconfigurationError: If unable to restart due to a - configuration problem, or if the restart subprocess cannot be run. - - """ - try: - proc = subprocess.Popen([apache_init_script, "restart"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - - except (OSError, ValueError): - logger.fatal( - "Unable to restart the Apache process with %s", apache_init_script) - raise errors.MisconfigurationError( - "Unable to restart Apache process with %s" % apache_init_script) - - stdout, stderr = proc.communicate() - - if proc.returncode != 0: - # Enter recovery routine... - logger.error("Apache Restart Failed!\n%s\n%s", stdout, stderr) - raise errors.MisconfigurationError( - "Error while restarting Apache:\n%s\n%s" % (stdout, stderr)) - - def get_file_path(vhost_path): """Get file path from augeas_vhost_path. diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 1c17eacc3..fe5ef3335 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -1,15 +1,62 @@ """Apache plugin constants.""" import pkg_resources +from letsencrypt import le_util -CLI_DEFAULTS = dict( +CLI_DEFAULTS_DEBIAN = dict( server_root="/etc/apache2", - ctl="apache2ctl", + vhost_root="/etc/apache2/sites-available", + vhost_files="*", + version_cmd=['apache2ctl', '-v'], + define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], enmod="a2enmod", dismod="a2dismod", - init_script="/etc/init.d/apache2", le_vhost_ext="-le-ssl.conf", + handle_mods=True, + handle_sites=True, + challenge_location="/etc/apache2" ) +CLI_DEFAULTS_CENTOS = dict( + server_root="/etc/httpd", + vhost_root="/etc/httpd/conf.d", + vhost_files="*.conf", + version_cmd=['apachectl', '-v'], + define_cmd=['apachectl', '-t', '-D', 'DUMP_RUN_CFG'], + restart_cmd=['apachectl', 'graceful'], + conftest_cmd=['apachectl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_mods=False, + handle_sites=False, + challenge_location="/etc/httpd/conf.d" +) +CLI_DEFAULTS_GENTOO = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/vhosts.d", + vhost_files="*.conf", + version_cmd=['/usr/sbin/apache2', '-v'], + define_cmd=['/usr/sbin/apache2', '-t', '-D', 'DUMP_RUN_CFG'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_mods=False, + handle_sites=False, + challenge_location="/etc/apache2/vhosts.d" +) +CLI_DEFAULTS = { + "debian": CLI_DEFAULTS_DEBIAN, + "ubuntu": CLI_DEFAULTS_DEBIAN, + "centos": CLI_DEFAULTS_CENTOS, + "centos linux": CLI_DEFAULTS_CENTOS, + "fedora": CLI_DEFAULTS_CENTOS, + "red hat enterprise linux server": CLI_DEFAULTS_CENTOS, + "gentoo base system": CLI_DEFAULTS_GENTOO +} """CLI defaults.""" MOD_SSL_CONF_DEST = "options-ssl-apache.conf" @@ -26,4 +73,33 @@ AUGEAS_LENS_DIR = pkg_resources.resource_filename( REWRITE_HTTPS_ARGS = [ "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"] -"""Apache rewrite rule arguments used for redirections to https vhost""" +"""Apache version<2.3.9 rewrite rule arguments used for redirections to +https vhost""" + +REWRITE_HTTPS_ARGS_WITH_END = [ + "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[END,QSA,R=permanent]"] +"""Apache version >= 2.3.9 rewrite rule arguments used for redirections to + https vhost""" + +HSTS_ARGS = ["always", "set", "Strict-Transport-Security", + "\"max-age=31536000\""] +"""Apache header arguments for HSTS""" + +UIR_ARGS = ["always", "set", "Content-Security-Policy", + "upgrade-insecure-requests"] + +HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS, + "Upgrade-Insecure-Requests": UIR_ARGS} + + +def os_constant(key): + """Get a constant value for operating system + :param key: name of cli constant + :return: value of constant for active os + """ + os_info = le_util.get_os_info() + try: + constants = CLI_DEFAULTS[os_info[0].lower()] + except KeyError: + constants = CLI_DEFAULTS["debian"] + return constants[key] diff --git a/letsencrypt-apache/letsencrypt_apache/display_ops.py b/letsencrypt-apache/letsencrypt_apache/display_ops.py index 45c55f49a..ef09ef6b4 100644 --- a/letsencrypt-apache/letsencrypt_apache/display_ops.py +++ b/letsencrypt-apache/letsencrypt_apache/display_ops.py @@ -4,6 +4,7 @@ import os import zope.component +from letsencrypt import errors from letsencrypt import interfaces import letsencrypt.display.util as display_util @@ -78,11 +79,18 @@ def _vhost_menu(domain, vhosts): name_size=disp_name_size) ) - code, tag = zope.component.getUtility(interfaces.IDisplay).menu( - "We were unable to find a vhost with a ServerName or Address of {0}.{1}" - "Which virtual host would you like to choose?".format( - domain, os.linesep), - choices, help_label="More Info", ok_label="Select") + try: + code, tag = zope.component.getUtility(interfaces.IDisplay).menu( + "We were unable to find a vhost with a ServerName " + "or Address of {0}.{1}Which virtual host would you " + "like to choose?".format(domain, os.linesep), + choices, help_label="More Info", ok_label="Select") + except errors.MissingCommandlineFlag, e: + msg = ("Failed to run Apache plugin non-interactively{1}{0}{1}" + "(The best solution is to add ServerName or ServerAlias " + "entries to the VirtualHost directives of your apache " + "configuration files.)".format(e, os.linesep)) + raise errors.MissingCommandlineFlag, msg return code, tag diff --git a/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf b/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf index 2a724d7ec..ec07a4ba3 100644 --- a/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf +++ b/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf @@ -14,9 +14,9 @@ SSLOptions +StrictRequire LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" vhost_combined LogFormat "%v %h %l %u %t \"%r\" %>s %b" vhost_common -CustomLog /var/log/apache2/access.log vhost_combined -LogLevel warn -ErrorLog /var/log/apache2/error.log +#CustomLog /var/log/apache2/access.log vhost_combined +#LogLevel warn +#ErrorLog /var/log/apache2/error.log # Always ensure Cookies have "Secure" set (JAH 2012/1) #Header edit Set-Cookie (?i)^(.*)(;\s*secure)??((\s*;)?(.*)) "$1; Secure$3$4" diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index ec5211ae4..cc7f2ec42 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -8,6 +8,7 @@ import subprocess from letsencrypt import errors +from letsencrypt_apache import constants logger = logging.getLogger(__name__) @@ -19,7 +20,6 @@ class ApacheParser(object): :ivar str root: Normalized absolute path to the server root directory. Without trailing slash. - :ivar str root: Server root :ivar set modules: All module names that are currently enabled. :ivar dict loc: Location to place directives, root - configuration origin, default - user config file, name - NameVirtualHost, @@ -28,15 +28,17 @@ class ApacheParser(object): arg_var_interpreter = re.compile(r"\$\{[^ \}]*}") fnmatch_chars = set(["*", "?", "\\", "[", "]"]) - def __init__(self, aug, root, ctl): + def __init__(self, aug, root, vhostroot, version=(2, 4)): # Note: Order is important here. # This uses the binary, so it can be done first. # https://httpd.apache.org/docs/2.4/mod/core.html#define # https://httpd.apache.org/docs/2.4/mod/core.html#ifdefine # This only handles invocation parameters and Define directives! + self.parser_paths = {} self.variables = {} - self.update_runtime_variables(ctl) + if version >= (2, 4): + self.update_runtime_variables() self.aug = aug # Find configuration root and make sure augeas can parse it. @@ -44,6 +46,8 @@ class ApacheParser(object): self.loc = {"root": self._find_config_root()} self._parse_file(self.loc["root"]) + self.vhostroot = os.path.abspath(vhostroot) + # This problem has been fixed in Augeas 1.0 self.standardize_excl() @@ -56,9 +60,14 @@ class ApacheParser(object): # Set up rest of locations self.loc.update(self._set_locations()) - # Must also attempt to parse sites-available or equivalent - # Sites-available is not included naturally in configuration - self._parse_file(os.path.join(self.root, "sites-available") + "/*") + # Must also attempt to parse virtual host root + self._parse_file(self.vhostroot + "/" + + constants.os_constant("vhost_files")) + + # check to see if there were unparsed define statements + if version < (2, 4): + if self.find_dir("Define", exclude=False): + raise errors.PluginError("Error parsing runtime variables") def init_modules(self): """Iterates on the configuration until no new modules are loaded. @@ -84,29 +93,30 @@ class ApacheParser(object): self.modules.add( os.path.basename(self.get_arg(match_filename))[:-2] + "c") - def update_runtime_variables(self, ctl): + def update_runtime_variables(self): """" - .. note:: Compile time variables (apache2ctl -V) are not used within the - dynamic configuration files. These should not be parsed or + .. note:: Compile time variables (apache2ctl -V) are not used within + the dynamic configuration files. These should not be parsed or interpreted. - .. todo:: Create separate compile time variables... simply for arg_get() + .. todo:: Create separate compile time variables... + simply for arg_get() """ - stdout = self._get_runtime_cfg(ctl) + stdout = self._get_runtime_cfg() variables = dict() matches = re.compile(r"Define: ([^ \n]*)").findall(stdout) try: matches.remove("DUMP_RUN_CFG") except ValueError: - raise errors.PluginError("Unable to parse runtime variables") + return for match in matches: if match.count("=") > 1: logger.error("Unexpected number of equal signs in " - "apache2ctl -D DUMP_RUN_CFG") + "runtime config dump.") raise errors.PluginError( "Error parsing Apache runtime variables") parts = match.partition("=") @@ -114,7 +124,7 @@ class ApacheParser(object): self.variables = variables - def _get_runtime_cfg(self, ctl): # pylint: disable=no-self-use + def _get_runtime_cfg(self): # pylint: disable=no-self-use """Get runtime configuration info. :returns: stdout from DUMP_RUN_CFG @@ -122,16 +132,18 @@ class ApacheParser(object): """ try: proc = subprocess.Popen( - [ctl, "-t", "-D", "DUMP_RUN_CFG"], + constants.os_constant("define_cmd"), stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() except (OSError, ValueError): logger.error( - "Error accessing %s for runtime parameters!%s", ctl, os.linesep) + "Error running command %s for runtime parameters!%s", + constants.os_constant("define_cmd"), os.linesep) raise errors.MisconfigurationError( - "Error accessing loaded Apache parameters: %s", ctl) + "Error accessing loaded Apache parameters: %s", + constants.os_constant("define_cmd")) # Small errors that do not impede if proc.returncode != 0: logger.warn("Error in checking parameter list: %s", stderr) @@ -166,7 +178,8 @@ class ApacheParser(object): # Make sure we don't cause an IndexError (end of list) # Check to make sure arg + 1 doesn't exist if (i == (len(matches) - 1) or - not matches[i + 1].endswith("/arg[%d]" % (args + 1))): + not matches[i + 1].endswith("/arg[%d]" % + (args + 1))): filtered.append(matches[i][:-len("/arg[%d]" % args)]) return filtered @@ -300,8 +313,6 @@ class ApacheParser(object): for match in matches: dir_ = self.aug.get(match).lower() if dir_ == "include" or dir_ == "includeoptional": - # start[6:] to strip off /files - #print self._get_include_path(self.get_arg(match +"/arg")), directive, arg ordered_matches.extend(self.find_dir( directive, arg, self._get_include_path(self.get_arg(match + "/arg")), @@ -320,8 +331,8 @@ class ApacheParser(object): """ value = self.aug.get(match) - # No need to strip quotes for variables, as apache2ctl already does this - # but we do need to strip quotes for all normal arguments. + # No need to strip quotes for variables, as apache2ctl already does + # this, but we do need to strip quotes for all normal arguments. # Note: normal argument may be a quoted variable # e.g. strip now, not later @@ -443,7 +454,7 @@ class ApacheParser(object): https://apr.apache.org/docs/apr/2.0/apr__fnmatch_8h_source.html http://apache2.sourcearchive.com/documentation/2.2.16-6/apr__fnmatch_8h_source.html - :param str clean_fn_match: Apache style filename match, similar to globs + :param str clean_fn_match: Apache style filename match, like globs :returns: regex suitable for augeas :rtype: str @@ -461,16 +472,63 @@ class ApacheParser(object): :param str filepath: Apache config file path """ + use_new, remove_old = self._check_path_actions(filepath) # Test if augeas included file for Httpd.lens # Note: This works for augeas globs, ie. *.conf - inc_test = self.aug.match( - "/augeas/load/Httpd/incl [. ='%s']" % filepath) - if not inc_test: - # Load up files - # This doesn't seem to work on TravisCI - # self.aug.add_transform("Httpd.lns", [filepath]) - self._add_httpd_transform(filepath) - self.aug.load() + if use_new: + inc_test = self.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % filepath) + if not inc_test: + # Load up files + # This doesn't seem to work on TravisCI + # self.aug.add_transform("Httpd.lns", [filepath]) + if remove_old: + self._remove_httpd_transform(filepath) + self._add_httpd_transform(filepath) + self.aug.load() + + def _check_path_actions(self, filepath): + """Determine actions to take with a new augeas path + + This helper function will return a tuple that defines + if we should try to append the new filepath to augeas + parser paths, and / or remove the old one with more + narrow matching. + + :param str filepath: filepath to check the actions for + + """ + + try: + new_file_match = os.path.basename(filepath) + existing_matches = self.parser_paths[os.path.dirname(filepath)] + if "*" in existing_matches: + use_new = False + else: + use_new = True + if new_file_match == "*": + remove_old = True + else: + remove_old = False + except KeyError: + use_new = True + remove_old = False + return use_new, remove_old + + def _remove_httpd_transform(self, filepath): + """Remove path from Augeas transform + + :param str filepath: filepath to remove + """ + + remove_basenames = self.parser_paths[os.path.dirname(filepath)] + remove_dirname = os.path.dirname(filepath) + for name in remove_basenames: + remove_path = remove_dirname + "/" + name + remove_inc = self.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % remove_path) + self.aug.remove(remove_inc[0]) + self.parser_paths.pop(remove_dirname) def _add_httpd_transform(self, incl): """Add a transform to Augeas. @@ -492,6 +550,13 @@ class ApacheParser(object): # Augeas uses base 1 indexing... insert at beginning... self.aug.set("/augeas/load/Httpd/lens", "Httpd.lns") self.aug.set("/augeas/load/Httpd/incl", incl) + # Add included path to paths dictionary + try: + self.parser_paths[os.path.dirname(incl)].append( + os.path.basename(incl)) + except KeyError: + self.parser_paths[os.path.dirname(incl)] = [ + os.path.basename(incl)] def standardize_excl(self): """Standardize the excl arguments for the Httpd lens in Augeas. @@ -546,8 +611,7 @@ class ApacheParser(object): def _find_config_root(self): """Find the Apache Configuration Root file.""" - location = ["apache2.conf", "httpd.conf"] - + location = ["apache2.conf", "httpd.conf", "conf/httpd.conf"] for name in location: if os.path.isfile(os.path.join(self.root, name)): return os.path.join(self.root, name) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/NEEDED.txt b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/NEEDED.txt new file mode 100644 index 000000000..b51956b0c --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/NEEDED.txt @@ -0,0 +1,6 @@ +Issues for which some kind of test case should be constructable, but we do not +currently have one: + +https://github.com/letsencrypt/letsencrypt/issues/1213 +https://github.com/letsencrypt/letsencrypt/issues/1602 + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/apache-conf-test b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/apache-conf-test new file mode 100755 index 000000000..7b3f83d13 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/apache-conf-test @@ -0,0 +1,79 @@ +#!/bin/bash + +# A hackish script to see if the client is behaving as expected +# with each of the "passing" conf files. + +export EA=/etc/apache2/ +TESTDIR="`dirname $0`" +LEROOT="`realpath \"$TESTDIR/../../../../\"`" +cd $TESTDIR/passing +LETSENCRYPT="${LETSENCRYPT:-$LEROOT/venv/bin/letsencrypt}" + +function CleanupExit() { + echo control c, exiting tests... + if [ "$f" != "" ] ; then + Cleanup + fi + exit 1 +} + +function Setup() { + if [ "$APPEND_APACHECONF" = "" ] ; then + sudo cp "$f" "$EA"/sites-available/ + sudo ln -sf "$EA/sites-available/$f" "$EA/sites-enabled/$f" + sudo echo """ + + ServerName example.com + DocumentRoot /tmp/ + ErrorLog /tmp/error.log + CustomLog /tmp/requests.log combined +""" >> $EA/sites-available/throwaway-example.conf + else + TMP="/tmp/`basename \"$APPEND_APACHECONF\"`.$$" + sudo cp -a "$APPEND_APACHECONF" "$TMP" + sudo bash -c "cat \"$f\" >> \"$APPEND_APACHECONF\"" + fi +} + +function Cleanup() { + if [ "$APPEND_APACHECONF" = "" ] ; then + sudo rm /etc/apache2/sites-{enabled,available}/"$f" + sudo rm $EA/sites-available/throwaway-example.conf + else + sudo mv "$TMP" "$APPEND_APACHECONF" + fi +} + +# if our environment asks us to enable modules, do our best! +if [ "$1" = --debian-modules ] ; then + sudo apt-get install -y libapache2-mod-wsgi + sudo apt-get install -y libapache2-mod-macro + + for mod in ssl rewrite macro wsgi deflate userdir version mime setenvif ; do + echo -n enabling $mod + sudo a2enmod $mod + done +fi + + +FAILS=0 +trap CleanupExit INT +for f in *.conf ; do + echo -n testing "$f"... + Setup + RESULT=`echo c | sudo "$LETSENCRYPT" -vvvv --debug --staging --apache --register-unsafely-without-email --agree-tos certonly -t 2>&1` + if echo $RESULT | grep -Eq \("Which names would you like"\|"mod_macro is not yet"\) ; then + echo passed + else + echo failed + echo $RESULT + echo + echo + FAILS=`expr $FAILS + 1` + fi + Cleanup +done +if [ "$FAILS" -ne 0 ] ; then + exit 1 +fi +exit 0 diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf new file mode 100644 index 000000000..ab4ed412e --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf @@ -0,0 +1,9 @@ + +DocumentRoot /xxxx/ +ServerName noodles.net.nz +ServerAlias www.noodles.net.nz +CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined + + AllowOverride All + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf new file mode 100644 index 000000000..25655a07c --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf @@ -0,0 +1,21 @@ + + +DocumentRoot /xxxx/ +ServerName noodles.net.nz +ServerAlias www.noodles.net.nz +CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined + + AllowOverride All + + + SSLEngine on + + SSLHonorCipherOrder On + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH +aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS" + + SSLCertificateFile /xxxx/noodles.net.nz.crt + SSLCertificateKeyFile /xxxx/noodles.net.nz.key + + Header set Strict-Transport-Security "max-age=31536000; preload" + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/missing-double-quote-1724.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/missing-double-quote-1724.conf new file mode 100644 index 000000000..7d97b23d0 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/missing-double-quote-1724.conf @@ -0,0 +1,52 @@ + + ServerAdmin webmaster@localhost + ServerAlias www.example.com + ServerName example.com + DocumentRoot /var/www/example.com/www/ + SSLEngine on + + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRS$ + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + + + Options FollowSymLinks + AllowOverride All + + + Options Indexes FollowSymLinks MultiViews + AllowOverride All + Order allow,deny + allow from all + # This directive allows us to have apache2's default start page + # in /apache2-default/, but still have / go to the right place + + + ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ + + AllowOverride None + Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch + Order allow,deny + Allow from all + + + ErrorLog /var/log/apache2/error.log + + # Possible values include: debug, info, notice, warn, error, crit, + # alert, emerg. + LogLevel warn + + CustomLog /var/log/apache2/access.log combined + ServerSignature On + + Alias /apache_doc/ "/usr/share/doc/" + + Options Indexes MultiViews FollowSymLinks + AllowOverride None + Order deny,allow + Deny from all + Allow from 127.0.0.0/255.0.0.0 ::1/128 + + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093.conf new file mode 100644 index 000000000..444f0dade --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093.conf @@ -0,0 +1,295 @@ + + AllowOverride None + Require all denied + + + + DocumentRoot /var/www/sjau.ch/web + + ServerName sjau.ch + ServerAlias www.sjau.ch + ServerAdmin webmaster@sjau.ch + + ErrorLog /var/log/ispconfig/httpd/sjau.ch/error.log + + Alias /error/ "/var/www/sjau.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client1/web2/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web2 client1 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web2 client1 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client1/web2/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/sjau.ch/web + + ServerName sjau.ch + ServerAlias www.sjau.ch + ServerAdmin webmaster@sjau.ch + + ErrorLog /var/log/ispconfig/httpd/sjau.ch/error.log + + Alias /error/ "/var/www/sjau.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client1/web2/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web2 client1 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web2 client1 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client1/web2/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093b.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093b.conf new file mode 100644 index 000000000..0388abc2c --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/multivhost-1093b.conf @@ -0,0 +1,593 @@ + + AllowOverride None + Require all denied + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + SSLEngine on + SSLProtocol All -SSLv2 -SSLv3 + SSLCertificateFile /var/www/clients/client4/web17/ssl/ensemen.ch.crt + SSLCertificateKeyFile /var/www/clients/client4/web17/ssl/ensemen.ch.key + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + SSLEngine on + SSLProtocol All -SSLv2 -SSLv3 + SSLCertificateFile /var/www/clients/client4/web17/ssl/ensemen.ch.crt + SSLCertificateKeyFile /var/www/clients/client4/web17/ssl/ensemen.ch.key + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/1626-1531.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/1626-1531.conf new file mode 100644 index 000000000..1622a57df --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/1626-1531.conf @@ -0,0 +1,37 @@ + + ServerAdmin denver@ossguy.com + ServerName c-beta.ossguy.com + + Alias /robots.txt /home/denver/www/c-beta.ossguy.com/static/robots.txt + Alias /favicon.ico /home/denver/www/c-beta.ossguy.com/static/favicon.ico + + AliasMatch /(.*\.css) /home/denver/www/c-beta.ossguy.com/static/$1 + AliasMatch /(.*\.js) /home/denver/www/c-beta.ossguy.com/static/$1 + AliasMatch /(.*\.png) /home/denver/www/c-beta.ossguy.com/static/$1 + AliasMatch /(.*\.gif) /home/denver/www/c-beta.ossguy.com/static/$1 + AliasMatch /(.*\.jpg) /home/denver/www/c-beta.ossguy.com/static/$1 + + WSGIScriptAlias / /home/denver/www/c-beta.ossguy.com/django.wsgi + WSGIDaemonProcess c-beta-ossguy user=www-data group=www-data home=/var/www processes=5 threads=10 maximum-requests=1000 umask=0007 display-name=c-beta-ossguy + WSGIProcessGroup c-beta-ossguy + WSGIApplicationGroup %{GLOBAL} + + DocumentRoot /home/denver/www/c-beta.ossguy.com/static + + + Options -Indexes +FollowSymLinks -MultiViews + Require all granted + AllowOverride None + + + + Options +Indexes +FollowSymLinks -MultiViews + Require all granted + AllowOverride None + + + # Custom log file locations + LogLevel warn + ErrorLog /tmp/error.log + CustomLog /tmp/access.log combined + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/README.modules b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/README.modules new file mode 100644 index 000000000..32c3ef019 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/README.modules @@ -0,0 +1,6 @@ +# Modules required to parse these conf files: +ssl +rewrite +macro +wsgi +deflate diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/anarcat-1531.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/anarcat-1531.conf new file mode 100644 index 000000000..73a9b746c --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/anarcat-1531.conf @@ -0,0 +1,14 @@ + + ServerAdmin root@localhost + ServerName anarcat.wiki.orangeseeds.org:80 + + + UserDir disabled + + RewriteEngine On + RewriteRule ^/(.*) http\:\/\/anarc\.at\/$1 [L,R,NE] + + ErrorLog /var/log/apache2/1531error.log + LogLevel warn + CustomLog /var/log/apache2/1531access.log combined + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-errordocument-arg-1724.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-errordocument-arg-1724.conf new file mode 100644 index 000000000..4733ffa4a --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-errordocument-arg-1724.conf @@ -0,0 +1,116 @@ +# +# Apache/PHP/Drupal settings: +# + +# Protect files and directories from prying eyes. + + Order allow,deny + + +# Don't show directory listings for URLs which map to a directory. +Options -Indexes + +# Follow symbolic links in this directory. +Options +FollowSymLinks + +# Make Drupal handle any 404 errors. +ErrorDocument 404 /index.php + +# Force simple error message for requests for non-existent favicon.ico. + + # There is no end quote below, for compatibility with Apache 1.3. + ErrorDocument 404 "The requested file favicon.ico was not found. + + +# Set the default handler. +DirectoryIndex index.php + +# Override PHP settings. More in sites/default/settings.php +# but the following cannot be changed at runtime. + +# PHP 4, Apache 1. + + php_value magic_quotes_gpc 0 + php_value register_globals 0 + php_value session.auto_start 0 + php_value mbstring.http_input pass + php_value mbstring.http_output pass + php_value mbstring.encoding_translation 0 + + +# PHP 4, Apache 2. + + php_value magic_quotes_gpc 0 + php_value register_globals 0 + php_value session.auto_start 0 + php_value mbstring.http_input pass + php_value mbstring.http_output pass + php_value mbstring.encoding_translation 0 + + +# PHP 5, Apache 1 and 2. + + php_value magic_quotes_gpc 0 + php_value register_globals 0 + php_value session.auto_start 0 + php_value mbstring.http_input pass + php_value mbstring.http_output pass + php_value mbstring.encoding_translation 0 + + +# Requires mod_expires to be enabled. + + # Enable expirations. + ExpiresActive On + + # Cache all files for 2 weeks after access (A). + ExpiresDefault A1209600 + + + # Do not allow PHP scripts to be cached unless they explicitly send cache + # headers themselves. Otherwise all scripts would have to overwrite the + # headers set by mod_expires if they want another caching behavior. This may + # fail if an error occurs early in the bootstrap process, and it may cause + # problems if a non-Drupal PHP file is installed in a subdirectory. + ExpiresActive Off + + + +# Various rewrite rules. + + RewriteEngine on + + # If your site can be accessed both with and without the 'www.' prefix, you + # can use one of the following settings to redirect users to your preferred + # URL, either WITH or WITHOUT the 'www.' prefix. Choose ONLY one option: + # + # To redirect all users to access the site WITH the 'www.' prefix, + # (http://example.com/... will be redirected to http://www.example.com/...) + # adapt and uncomment the following: + # RewriteCond %{HTTP_HOST} ^example\.com$ [NC] + # RewriteRule ^(.*)$ http://www.example.com/$1 [L,R=301] + # + # To redirect all users to access the site WITHOUT the 'www.' prefix, + # (http://www.example.com/... will be redirected to http://example.com/...) + # uncomment and adapt the following: + # RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC] + # RewriteRule ^(.*)$ http://example.com/$1 [L,R=301] + + # Modify the RewriteBase if you are using Drupal in a subdirectory or in a + # VirtualDocumentRoot and the rewrite rules are not working properly. + # For example if your site is at http://example.com/drupal uncomment and + # modify the following line: + # RewriteBase /drupal + # + # If your site is running in a VirtualDocumentRoot at http://example.com/, + # uncomment the following line: + # RewriteBase / + + # Rewrite URLs of the form 'x' to the form 'index.php?q=x'. + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !=/favicon.ico + RewriteRule ^(.*)$ index.php?q=$1 [L,QSA] + + +# $Id$ diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-htaccess-1531.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-htaccess-1531.conf new file mode 100644 index 000000000..a1aab7a39 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/drupal-htaccess-1531.conf @@ -0,0 +1,149 @@ +# +# Apache/PHP/Drupal settings: +# + +# Protect files and directories from prying eyes. + + Order allow,deny + + +# Don't show directory listings for URLs which map to a directory. +Options -Indexes + +# Follow symbolic links in this directory. +Options +FollowSymLinks + +# Make Drupal handle any 404 errors. +ErrorDocument 404 /index.php + +# Set the default handler. +DirectoryIndex index.php index.html index.htm + +# Override PHP settings that cannot be changed at runtime. See +# sites/default/default.settings.php and drupal_environment_initialize() in +# includes/bootstrap.inc for settings that can be changed at runtime. + +# PHP 5, Apache 1 and 2. + + php_flag magic_quotes_gpc off + php_flag magic_quotes_sybase off + php_flag register_globals off + php_flag session.auto_start off + php_value mbstring.http_input pass + php_value mbstring.http_output pass + php_flag mbstring.encoding_translation off + + +# Requires mod_expires to be enabled. + + # Enable expirations. + ExpiresActive On + + # Cache all files for 2 weeks after access (A). + ExpiresDefault A1209600 + + + # Do not allow PHP scripts to be cached unless they explicitly send cache + # headers themselves. Otherwise all scripts would have to overwrite the + # headers set by mod_expires if they want another caching behavior. This may + # fail if an error occurs early in the bootstrap process, and it may cause + # problems if a non-Drupal PHP file is installed in a subdirectory. + ExpiresActive Off + + + +# Various rewrite rules. + + RewriteEngine on + + # Set "protossl" to "s" if we were accessed via https://. This is used later + # if you enable "www." stripping or enforcement, in order to ensure that + # you don't bounce between http and https. + RewriteRule ^ - [E=protossl] + RewriteCond %{HTTPS} on + RewriteRule ^ - [E=protossl:s] + + # Make sure Authorization HTTP header is available to PHP + # even when running as CGI or FastCGI. + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Block access to "hidden" directories whose names begin with a period. This + # includes directories used by version control systems such as Subversion or + # Git to store control files. Files whose names begin with a period, as well + # as the control files used by CVS, are protected by the FilesMatch directive + # above. + # + # NOTE: This only works when mod_rewrite is loaded. Without mod_rewrite, it is + # not possible to block access to entire directories from .htaccess, because + # is not allowed here. + # + # If you do not have mod_rewrite installed, you should remove these + # directories from your webroot or otherwise protect them from being + # downloaded. + RewriteRule "(^|/)\." - [F] + + # If your site can be accessed both with and without the 'www.' prefix, you + # can use one of the following settings to redirect users to your preferred + # URL, either WITH or WITHOUT the 'www.' prefix. Choose ONLY one option: + # + # To redirect all users to access the site WITH the 'www.' prefix, + # (http://example.com/... will be redirected to http://www.example.com/...) + # uncomment the following: + # RewriteCond %{HTTP_HOST} . + # RewriteCond %{HTTP_HOST} !^www\. [NC] + # RewriteRule ^ http%{ENV:protossl}://www.%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + # + # To redirect all users to access the site WITHOUT the 'www.' prefix, + # (http://www.example.com/... will be redirected to http://example.com/...) + # uncomment the following: + # RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + # RewriteRule ^ http%{ENV:protossl}://%1%{REQUEST_URI} [L,R=301] + + # Modify the RewriteBase if you are using Drupal in a subdirectory or in a + # VirtualDocumentRoot and the rewrite rules are not working properly. + # For example if your site is at http://example.com/drupal uncomment and + # modify the following line: + # RewriteBase /drupal + # + # If your site is running in a VirtualDocumentRoot at http://example.com/, + # uncomment the following line: + # RewriteBase / + + # Pass all requests not referring directly to files in the filesystem to + # index.php. Clean URLs are handled in drupal_environment_initialize(). + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !=/favicon.ico + RewriteRule ^ index.php [L] + + # Rules to correctly serve gzip compressed CSS and JS files. + # Requires both mod_rewrite and mod_headers to be enabled. + + # Serve gzip compressed CSS files if they exist and the client accepts gzip. + RewriteCond %{HTTP:Accept-encoding} gzip + RewriteCond %{REQUEST_FILENAME}\.gz -s + RewriteRule ^(.*)\.css $1\.css\.gz [QSA] + + # Serve gzip compressed JS files if they exist and the client accepts gzip. + RewriteCond %{HTTP:Accept-encoding} gzip + RewriteCond %{REQUEST_FILENAME}\.gz -s + RewriteRule ^(.*)\.js $1\.js\.gz [QSA] + + # Serve correct content types, and prevent mod_deflate double gzip. + RewriteRule .css.gz$ - [T=text/css,E=no-gzip:1] + RewriteRule .js.gz$ - [T=text/javascript,E=no-gzip:1] + + + # Serve correct encoding type. + Header set Content-Encoding gzip + # Force proxies to cache gzipped & non-gzipped css/js files separately. + Header append Vary Accept-Encoding + + + + +# Add headers to all responses. + + # Disable content sniffing, since it's an attack vector. + Header always set X-Content-Type-Options nosniff + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-1755.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-1755.conf new file mode 100644 index 000000000..260029576 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-1755.conf @@ -0,0 +1,36 @@ + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName www.example.com + ServerAlias example.com +SetOutputFilter DEFLATE +# Do not attempt to compress the following extensions +SetEnvIfNoCase Request_URI \ +\.(?:gif|jpe?g|png|swf|flv|zip|gz|tar|mp3|mp4|m4v)$ no-gzip dont-vary + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/proof + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-ssl.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-ssl.conf new file mode 100644 index 000000000..466ac9ce3 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example-ssl.conf @@ -0,0 +1,136 @@ + + ServerName example.com + ServerAlias www.example.com + ServerAdmin webmaster@localhost + + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + # SSL Engine Switch: + # Enable/Disable SSL for this virtual host. + SSLEngine on + + # A self-signed (snakeoil) certificate can be created by installing + # the ssl-cert package. See + # /usr/share/doc/apache2/README.Debian.gz for more info. + # If both key and certificate are stored in the same file, only the + # SSLCertificateFile directive is needed. + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + + # Server Certificate Chain: + # Point SSLCertificateChainFile at a file containing the + # concatenation of PEM encoded CA certificates which form the + # certificate chain for the server certificate. Alternatively + # the referenced file can be the same as SSLCertificateFile + # when the CA certificates are directly appended to the server + # certificate for convinience. + #SSLCertificateChainFile /etc/apache2/ssl.crt/server-ca.crt + + # Certificate Authority (CA): + # Set the CA certificate verification path where to find CA + # certificates for client authentication or alternatively one + # huge file containing all of them (file must be PEM encoded) + # Note: Inside SSLCACertificatePath you need hash symlinks + # to point to the certificate files. Use the provided + # Makefile to update the hash symlinks after changes. + #SSLCACertificatePath /etc/ssl/certs/ + #SSLCACertificateFile /etc/apache2/ssl.crt/ca-bundle.crt + + # Certificate Revocation Lists (CRL): + # Set the CA revocation path where to find CA CRLs for client + # authentication or alternatively one huge file containing all + # of them (file must be PEM encoded) + # Note: Inside SSLCARevocationPath you need hash symlinks + # to point to the certificate files. Use the provided + # Makefile to update the hash symlinks after changes. + #SSLCARevocationPath /etc/apache2/ssl.crl/ + #SSLCARevocationFile /etc/apache2/ssl.crl/ca-bundle.crl + + # Client Authentication (Type): + # Client certificate verification type and depth. Types are + # none, optional, require and optional_no_ca. Depth is a + # number which specifies how deeply to verify the certificate + # issuer chain before deciding the certificate is not valid. + #SSLVerifyClient require + #SSLVerifyDepth 10 + + # SSL Engine Options: + # Set various options for the SSL engine. + # o FakeBasicAuth: + # Translate the client X.509 into a Basic Authorisation. This means that + # the standard Auth/DBMAuth methods can be used for access control. The + # user name is the `one line' version of the client's X.509 certificate. + # Note that no password is obtained from the user. Every entry in the user + # file needs this password: `xxj31ZMTZzkVA'. + # o ExportCertData: + # This exports two additional environment variables: SSL_CLIENT_CERT and + # SSL_SERVER_CERT. These contain the PEM-encoded certificates of the + # server (always existing) and the client (only existing when client + # authentication is used). This can be used to import the certificates + # into CGI scripts. + # o StdEnvVars: + # This exports the standard SSL/TLS related `SSL_*' environment variables. + # Per default this exportation is switched off for performance reasons, + # because the extraction step is an expensive operation and is usually + # useless for serving static content. So one usually enables the + # exportation for CGI and SSI requests only. + # o OptRenegotiate: + # This enables optimized SSL connection renegotiation handling when SSL + # directives are used in per-directory context. + #SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + + # SSL Protocol Adjustments: + # The safe and default but still SSL/TLS standard compliant shutdown + # approach is that mod_ssl sends the close notify alert but doesn't wait for + # the close notify alert from client. When you need a different shutdown + # approach you can use one of the following variables: + # o ssl-unclean-shutdown: + # This forces an unclean shutdown when the connection is closed, i.e. no + # SSL close notify alert is send or allowed to received. This violates + # the SSL/TLS standard but is needed for some brain-dead browsers. Use + # this when you receive I/O errors because of the standard approach where + # mod_ssl sends the close notify alert. + # o ssl-accurate-shutdown: + # This forces an accurate shutdown when the connection is closed, i.e. a + # SSL close notify alert is send and mod_ssl waits for the close notify + # alert of the client. This is 100% SSL/TLS standard compliant, but in + # practice often causes hanging connections with brain-dead browsers. Use + # this only for browsers where you know that their SSL implementation + # works correctly. + # Notice: Most problems of broken clients are also related to the HTTP + # keep-alive facility, so you usually additionally want to disable + # keep-alive for those clients, too. Use variable "nokeepalive" for this. + # Similarly, one has to force some clients to use HTTP/1.0 to workaround + # their broken HTTP/1.1 implementation. Use variables "downgrade-1.0" and + # "force-response-1.0" for this. + BrowserMatch "MSIE [2-6]" \ + nokeepalive ssl-unclean-shutdown \ + downgrade-1.0 force-response-1.0 + # MSIE 7 and newer should be able to use keepalive + BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example.conf new file mode 100644 index 000000000..60bdeead6 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/example.conf @@ -0,0 +1,32 @@ + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName www.example.com + ServerAlias example.com + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt new file mode 100644 index 000000000..73dc64223 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt @@ -0,0 +1,222 @@ +# This is the main Apache server configuration file. It contains the +# configuration directives that give the server its instructions. +# See http://httpd.apache.org/docs/2.4/ for detailed information about +# the directives and /usr/share/doc/apache2/README.Debian about Debian specific +# hints. +# +# +# Summary of how the Apache 2 configuration works in Debian: +# The Apache 2 web server configuration in Debian is quite different to +# upstream's suggested way to configure the web server. This is because Debian's +# default Apache2 installation attempts to make adding and removing modules, +# virtual hosts, and extra configuration directives as flexible as possible, in +# order to make automating the changes and administering the server as easy as +# possible. + +# It is split into several files forming the configuration hierarchy outlined +# below, all located in the /etc/apache2/ directory: +# +# /etc/apache2/ +# |-- apache2.conf +# | `-- ports.conf +# |-- mods-enabled +# | |-- *.load +# | `-- *.conf +# |-- conf-enabled +# | `-- *.conf +# `-- sites-enabled +# `-- *.conf +# +# +# * apache2.conf is the main configuration file (this file). It puts the pieces +# together by including all remaining configuration files when starting up the +# web server. +# +# * ports.conf is always included from the main configuration file. It is +# supposed to determine listening ports for incoming connections which can be +# customized anytime. +# +# * Configuration files in the mods-enabled/, conf-enabled/ and sites-enabled/ +# directories contain particular configuration snippets which manage modules, +# global configuration fragments, or virtual host configurations, +# respectively. +# +# They are activated by symlinking available configuration files from their +# respective *-available/ counterparts. These should be managed by using our +# helpers a2enmod/a2dismod, a2ensite/a2dissite and a2enconf/a2disconf. See +# their respective man pages for detailed information. +# +# * The binary is called apache2. Due to the use of environment variables, in +# the default configuration, apache2 needs to be started/stopped with +# /etc/init.d/apache2 or apache2ctl. Calling /usr/bin/apache2 directly will not +# work with the default configuration. + + +# Global configuration +# + +# +# ServerRoot: The top of the directory tree under which the server's +# configuration, error, and log files are kept. +# +# NOTE! If you intend to place this on an NFS (or otherwise network) +# mounted filesystem then please read the Mutex documentation (available +# at ); +# you will save yourself a lot of trouble. +# +# Do NOT add a slash at the end of the directory path. +# +#ServerRoot "/etc/apache2" + +# +# The accept serialization lock file MUST BE STORED ON A LOCAL DISK. +# +Mutex file:${APACHE_LOCK_DIR} default + +# +# PidFile: The file in which the server should record its process +# identification number when it starts. +# This needs to be set in /etc/apache2/envvars +# +PidFile ${APACHE_PID_FILE} + +# +# Timeout: The number of seconds before receives and sends time out. +# +Timeout 300 + +# +# KeepAlive: Whether or not to allow persistent connections (more than +# one request per connection). Set to "Off" to deactivate. +# +KeepAlive On + +# +# MaxKeepAliveRequests: The maximum number of requests to allow +# during a persistent connection. Set to 0 to allow an unlimited amount. +# We recommend you leave this number high, for maximum performance. +# +MaxKeepAliveRequests 100 + +# +# KeepAliveTimeout: Number of seconds to wait for the next request from the +# same client on the same connection. +# +KeepAliveTimeout 5 + + +# These need to be set in /etc/apache2/envvars +User ${APACHE_RUN_USER} +Group ${APACHE_RUN_GROUP} + +# +# HostnameLookups: Log the names of clients or just their IP addresses +# e.g., www.apache.org (on) or 204.62.129.132 (off). +# The default is off because it'd be overall better for the net if people +# had to knowingly turn this feature on, since enabling it means that +# each client request will result in AT LEAST one lookup request to the +# nameserver. +# +HostnameLookups Off + +# ErrorLog: The location of the error log file. +# If you do not specify an ErrorLog directive within a +# container, error messages relating to that virtual host will be +# logged here. If you *do* define an error logfile for a +# container, that host's errors will be logged there and not here. +# +ErrorLog ${APACHE_LOG_DIR}/error.log + +# +# LogLevel: Control the severity of messages logged to the error_log. +# Available values: trace8, ..., trace1, debug, info, notice, warn, +# error, crit, alert, emerg. +# It is also possible to configure the log level for particular modules, e.g. +# "LogLevel info ssl:warn" +# +LogLevel warn + +# Include module configuration: +IncludeOptional mods-enabled/*.load +IncludeOptional mods-enabled/*.conf + +# Include list of ports to listen on +Include ports.conf + + +# Sets the default security model of the Apache2 HTTPD server. It does +# not allow access to the root filesystem outside of /usr/share and /var/www. +# The former is used by web applications packaged in Debian, +# the latter may be used for local directories served by the web server. If +# your system is serving content from a sub-directory in /srv you must allow +# access here, or in any related virtual host. + + Options FollowSymLinks + AllowOverride None + Require all denied + + + + AllowOverride None + Require all granted + + + + Options Indexes FollowSymLinks + AllowOverride None + Require all granted + + +# +# Options Indexes FollowSymLinks +# AllowOverride None +# Require all granted +# + +# AccessFileName: The name of the file to look for in each directory +# for additional configuration directives. See also the AllowOverride +# directive. +# +AccessFileName .htaccess + +# +# The following lines prevent .htaccess and .htpasswd files from being +# viewed by Web clients. +# + + Require all denied + + + +# +# The following directives define some format nicknames for use with +# a CustomLog directive. +# +# These deviate from the Common Log Format definitions in that they use %O +# (the actual bytes sent including headers) instead of %b (the size of the +# requested file), because the latter makes it impossible to detect partial +# requests. +# +# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended. +# Use mod_remoteip instead. +# +#LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined +LogFormat "%t \"%r\" %>s %O \"%{User-Agent}i\"" vhost_combined + +#LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined +#LogFormat "%h %l %u %t \"%r\" %>s %O" common +LogFormat "- %t \"%r\" %>s %b" noip + +LogFormat "%{Referer}i -> %U" referer +LogFormat "%{User-agent}i" agent + +# Include of directories ignores editors' and dpkg's backup files, +# see README.Debian for details. + +# Include generic snippets of statements +IncludeOptional conf-enabled/*.conf + +# Include the virtual host configurations: +#IncludeOptional sites-enabled/*.conf + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.conf new file mode 100644 index 000000000..0918e5669 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/finalize-1243.conf @@ -0,0 +1,67 @@ +#LoadModule ssl_module modules/mod_ssl.so + +Listen 443 + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName www.eiserneketten.de + + SSLEngine on + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log noip + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + Options FollowSymLinks + AllowOverride None + Order Deny,Allow + #Deny from All + + + Alias / /eiserneketten/pages/eiserneketten.html +SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem +SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key +SSLCertificateChainFile /etc/ssl/certs/ssl-cert-snakeoil.pem +Include /etc/letsencrypt/options-ssl-apache.conf + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet + +# +# Directives to allow use of AWStats as a CGI +# +Alias /awstatsclasses "/usr/local/awstats/wwwroot/classes/" +Alias /awstatscss "/usr/local/awstats/wwwroot/css/" +Alias /awstatsicons "/usr/local/awstats/wwwroot/icon/" +ScriptAlias /awstats/ "/usr/local/awstats/wwwroot/cgi-bin/" + +# +# This is to permit URL access to scripts/files in AWStats directory. +# + + Options None + AllowOverride None + Order allow,deny + Allow from all + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/graphite-quote-1934.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/graphite-quote-1934.conf new file mode 100644 index 000000000..f257dd9a8 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/graphite-quote-1934.conf @@ -0,0 +1,21 @@ + + + WSGIDaemonProcess _graphite processes=5 threads=5 display-name='%{GROUP}' inactivity-timeout=120 user=www-data group=www-data + WSGIProcessGroup _graphite + WSGIImportScript /usr/share/graphite-web/graphite.wsgi process-group=_graphite application-group=%{GLOBAL} + WSGIScriptAlias / /usr/share/graphite-web/graphite.wsgi + + Alias /content/ /usr/share/graphite-web/static/ + + SetHandler None + + + ErrorLog ${APACHE_LOG_DIR}/graphite-web_error.log + + # Possible values include: debug, info, notice, warn, error, crit, + # alert, emerg. + LogLevel warn + + CustomLog ${APACHE_LOG_DIR}/graphite-web_access.log combined + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/missing-quote-1724.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/missing-quote-1724.conf new file mode 100644 index 000000000..7d97b23d0 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/missing-quote-1724.conf @@ -0,0 +1,52 @@ + + ServerAdmin webmaster@localhost + ServerAlias www.example.com + ServerName example.com + DocumentRoot /var/www/example.com/www/ + SSLEngine on + + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRS$ + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + + + Options FollowSymLinks + AllowOverride All + + + Options Indexes FollowSymLinks MultiViews + AllowOverride All + Order allow,deny + allow from all + # This directive allows us to have apache2's default start page + # in /apache2-default/, but still have / go to the right place + + + ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ + + AllowOverride None + Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch + Order allow,deny + Allow from all + + + ErrorLog /var/log/apache2/error.log + + # Possible values include: debug, info, notice, warn, error, crit, + # alert, emerg. + LogLevel warn + + CustomLog /var/log/apache2/access.log combined + ServerSignature On + + Alias /apache_doc/ "/usr/share/doc/" + + Options Indexes MultiViews FollowSymLinks + AllowOverride None + Order deny,allow + Deny from all + Allow from 127.0.0.0/255.0.0.0 ::1/128 + + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/modmacro-1385.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/modmacro-1385.conf new file mode 100644 index 000000000..d327c9421 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/modmacro-1385.conf @@ -0,0 +1,33 @@ + + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName $host + + ServerAdmin webmaster@localhost + DocumentRoot $dir + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + +Use Vhost goxogle.com 80 /var/www/goxogle/ +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/owncloud-1264.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/owncloud-1264.conf new file mode 100644 index 000000000..d0ac81fa3 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/owncloud-1264.conf @@ -0,0 +1,13 @@ +Alias /owncloud /usr/share/owncloud + + + Options +FollowSymLinks + AllowOverride All + + order allow,deny + allow from all + + = 2.3> + Require all granted + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/rewrite-quote-1960.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/rewrite-quote-1960.conf new file mode 100644 index 000000000..26214e7b0 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/rewrite-quote-1960.conf @@ -0,0 +1,7 @@ + + RewriteEngine On + RewriteCond %{REQUEST_URI} ^.*(,|;|:|<|>|">|"<|/|\\\.\.\\).* [NC,OR] + RewriteCond %{REQUEST_URI} ^.*(\=|\@|\[|\]|\^|\`|\{|\}|\~).* [NC,OR] + RewriteCond %{REQUEST_URI} ^.*(\'|%0A|%0D|%27|%3C|%3E|%00).* [NC] + RewriteRule ^(.*)$ - [F,L] + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/roundcube-1222.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/roundcube-1222.conf new file mode 100644 index 000000000..72ced7fb3 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/roundcube-1222.conf @@ -0,0 +1,61 @@ +# Those aliases do not work properly with several hosts on your apache server +# Uncomment them to use it or adapt them to your configuration +# Alias /roundcube/program/js/tiny_mce/ /usr/share/tinymce/www/ +# Alias /roundcube /var/lib/roundcube + +# Access to tinymce files + + Options Indexes MultiViews FollowSymLinks + AllowOverride None + = 2.3> + Require all granted + + + Order allow,deny + Allow from all + + + + + Options +FollowSymLinks + # This is needed to parse /var/lib/roundcube/.htaccess. See its + # content before setting AllowOverride to None. + AllowOverride All + = 2.3> + Require all granted + + + Order allow,deny + Allow from all + + + +# Protecting basic directories: + + Options -FollowSymLinks + AllowOverride None + + + + Options -FollowSymLinks + AllowOverride None + = 2.3> + Require all denied + + + Order allow,deny + Deny from all + + + + + Options -FollowSymLinks + AllowOverride None + = 2.3> + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/semacode-1598.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/semacode-1598.conf new file mode 100644 index 000000000..89e2fb25c --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/semacode-1598.conf @@ -0,0 +1,44 @@ + + ServerName semacode.com + ServerAlias www.semacode.com + DocumentRoot /tmp/ + TransferLog /tmp/access + ErrorLog /tmp/error + Redirect /posts/rss http://semacode.com/feed + Redirect permanent /weblog http://semacode.com/blog + +#ProxyPreserveHost On +# ProxyPass /past http://old.semacode.com + #ProxyPassReverse /past http://old.semacode.com +# + # Order allow,deny + #Allow from all +# + + Redirect /stylesheets/inside.css http://old.semacode.com/stylesheets/inside.css + RedirectMatch /images/portal/(.*) http://old.semacode.com/images/portal/$1 + Redirect /images/invisible.gif http://old.semacode.com/images/invisible.gif + RedirectMatch /javascripts/(.*) http://old.semacode.com/javascripts/$1 + + RewriteEngine on + RewriteRule ^/past/(.*) http://old.semacode.com/past/$1 [L,P] + RewriteCond %{HTTP_HOST} !^semacode\.com$ [NC] + RewriteCond %{HTTP_HOST} !^$ + RewriteRule ^/(.*) http://semacode.com/$1 [L,R] + + + + + + ServerName old.semacode.com + ServerAlias www.old.semacode.com + DocumentRoot /home/simon/semacode-server/semacode/website/trunk/public + TransferLog /tmp/access-old + ErrorLog /tmp/error-old + + Options FollowSymLinks + AllowOverride None + Order allow,deny + Allow from all + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/sslrequire-wordlist-1827.htaccess b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/sslrequire-wordlist-1827.htaccess new file mode 100644 index 000000000..1c06d5497 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/sslrequire-wordlist-1827.htaccess @@ -0,0 +1 @@ +SSLRequire %{SSL_CLIENT_S_DN_CN} in {"foo@bar.com", "bar@foo.com"} diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/two-blocks-one-line-1693.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/two-blocks-one-line-1693.conf new file mode 100644 index 000000000..5d3cef423 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/two-blocks-one-line-1693.conf @@ -0,0 +1,28 @@ + + + ServerAdmin info@somethingnewentertainment.com + ServerName somethingnewentertainment.com + DocumentRoot /var/www/html + + ErrorLog /var/log/apache2/error.log + CustomLog /var/log/apache2/access.log combined + + SSLEngine on + SSLProtocol all -SSLv2 -SSLv3 + SSLHonorCipherOrder on + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EEC DH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRS A RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4" + + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + BrowserMatch "MSIE [2-6]" \ + nokeepalive ssl-unclean-shutdown \ + downgrade-1.0 force-response-1.0 + BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/augeas_configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/augeas_configurator_test.py index 815e6fc44..b70e1c7f1 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/augeas_configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/augeas_configurator_test.py @@ -17,7 +17,7 @@ class AugeasConfiguratorTest(util.ApacheTest): super(AugeasConfiguratorTest, self).setUp() self.config = util.get_apache_configurator( - self.config_path, self.config_dir, self.work_dir) + self.config_path, self.vhost_path, self.config_dir, self.work_dir) self.vh_truth = util.get_vh_truth( self.temp_dir, "debian_apache_2_4/two_vhost_80") diff --git a/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py b/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py index 7099c388f..1fc5281c1 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py @@ -96,7 +96,8 @@ class ComplexParserTest(util.ParserTest): else: self.assertFalse(self.parser.find_dir("FNMATCH_DIRECTIVE")) - # NOTE: Only run one test per function otherwise you will have inf recursion + # NOTE: Only run one test per function otherwise you will have + # inf recursion def test_include(self): self.verify_fnmatch("test_fnmatch.?onf") @@ -104,7 +105,8 @@ class ComplexParserTest(util.ParserTest): self.verify_fnmatch("../complex_parsing/[te][te]st_*.?onf") def test_include_fullpath(self): - self.verify_fnmatch(os.path.join(self.config_path, "test_fnmatch.conf")) + self.verify_fnmatch(os.path.join(self.config_path, + "test_fnmatch.conf")) def test_include_fullpath_trailing_slash(self): self.verify_fnmatch(self.config_path + "//") diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 0350a32ec..00a98e33a 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -27,11 +27,22 @@ class TwoVhost80Test(util.ApacheTest): super(TwoVhost80Test, self).setUp() self.config = util.get_apache_configurator( - self.config_path, self.config_dir, self.work_dir) - + self.config_path, self.vhost_path, self.config_dir, self.work_dir) + self.config = self.mock_deploy_cert(self.config) self.vh_truth = util.get_vh_truth( self.temp_dir, "debian_apache_2_4/two_vhost_80") + def mock_deploy_cert(self, config): + """A test for a mock deploy cert""" + self.config.real_deploy_cert = self.config.deploy_cert + + def mocked_deploy_cert(*args, **kwargs): + """a helper to mock a deployed cert""" + with mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.enable_mod"): + config.real_deploy_cert(*args, **kwargs) + self.config.deploy_cert = mocked_deploy_cert + return self.config + def tearDown(self): shutil.rmtree(self.temp_dir) shutil.rmtree(self.config_dir) @@ -54,6 +65,16 @@ class TwoVhost80Test(util.ApacheTest): self.assertRaises( errors.NotSupportedError, self.config.prepare) + @mock.patch("letsencrypt_apache.parser.ApacheParser") + @mock.patch("letsencrypt_apache.configurator.le_util.exe_exists") + def test_prepare_old_aug(self, mock_exe_exists, _): + mock_exe_exists.return_value = True + self.config.config_test = mock.Mock() + # pylint: disable=protected-access + self.config._check_aug_version = mock.Mock(return_value=False) + self.assertRaises( + errors.NotSupportedError, self.config.prepare) + def test_add_parser_arguments(self): # pylint: disable=no-self-use from letsencrypt_apache.configurator import ApacheConfigurator # Weak test.. @@ -90,8 +111,8 @@ class TwoVhost80Test(util.ApacheTest): def test_add_servernames_alias(self): self.config.parser.add_dir( self.vh_truth[2].path, "ServerAlias", ["*.le.co"]) - self.config._add_servernames(self.vh_truth[2]) # pylint: disable=protected-access - + # pylint: disable=protected-access + self.config._add_servernames(self.vh_truth[2]) self.assertEqual( self.vh_truth[2].get_names(), set(["*.le.co", "ip-172-30-0-17"])) @@ -103,7 +124,7 @@ class TwoVhost80Test(util.ApacheTest): """ vhs = self.config.get_virtual_hosts() - self.assertEqual(len(vhs), 5) + self.assertEqual(len(vhs), 6) found = 0 for vhost in vhs: @@ -114,7 +135,15 @@ class TwoVhost80Test(util.ApacheTest): else: raise Exception("Missed: %s" % vhost) # pragma: no cover - self.assertEqual(found, 5) + self.assertEqual(found, 6) + + # Handle case of non-debian layout get_virtual_hosts + with mock.patch( + "letsencrypt_apache.configurator.ApacheConfigurator.conf" + ) as mock_conf: + mock_conf.return_value = False + vhs = self.config.get_virtual_hosts() + self.assertEqual(len(vhs), 6) @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_none_avail(self, mock_select): @@ -139,11 +168,18 @@ class TwoVhost80Test(util.ApacheTest): self.assertFalse(self.vh_truth[0].ssl) self.assertTrue(chosen_vhost.ssl) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") + def test_choose_vhost_select_vhost_with_temp(self, mock_select): + mock_select.return_value = self.vh_truth[0] + chosen_vhost = self.config.choose_vhost("none.com", temp=True) + self.assertEqual(self.vh_truth[0], chosen_vhost) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_select_vhost_conflicting_non_ssl(self, mock_select): mock_select.return_value = self.vh_truth[3] conflicting_vhost = obj.VirtualHost( - "path", "aug_path", set([obj.Addr.fromstring("*:443")]), True, True) + "path", "aug_path", set([obj.Addr.fromstring("*:443")]), + True, True) self.config.vhosts.append(conflicting_vhost) self.assertRaises( @@ -162,7 +198,8 @@ class TwoVhost80Test(util.ApacheTest): def test_find_best_vhost_variety(self): # pylint: disable=protected-access ssl_vh = obj.VirtualHost( - "fp", "ap", set([obj.Addr(("*", "443")), obj.Addr(("zombo.com",))]), + "fp", "ap", set([obj.Addr(("*", "443")), + obj.Addr(("zombo.com",))]), True, False) self.config.vhosts.append(ssl_vh) self.assertEqual(self.config._find_best_vhost("zombo.com"), ssl_vh) @@ -195,6 +232,11 @@ class TwoVhost80Test(util.ApacheTest): self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) + with mock.patch("os.path.isdir") as mock_isdir: + mock_isdir.return_value = False + self.assertRaises(errors.ConfigurationError, + self.config.is_site_enabled, + "irrelevant") @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") @@ -236,6 +278,73 @@ class TwoVhost80Test(util.ApacheTest): self.config.enable_site, obj.VirtualHost("asdf", "afsaf", set(), False, False)) + def test_deploy_cert_newssl(self): + self.config = util.get_apache_configurator( + self.config_path, self.vhost_path, self.config_dir, + self.work_dir, version=(2, 4, 16)) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.config = self.mock_deploy_cert(self.config) + self.config.deploy_cert( + "random.demo", "example/cert.pem", "example/key.pem", + "example/cert_chain.pem", "example/fullchain.pem") + self.config.save() + + # Verify ssl_module was enabled. + self.assertTrue(self.vh_truth[1].enabled) + self.assertTrue("ssl_module" in self.config.parser.modules) + + loc_cert = self.config.parser.find_dir( + "sslcertificatefile", "example/fullchain.pem", + self.vh_truth[1].path) + loc_key = self.config.parser.find_dir( + "sslcertificateKeyfile", "example/key.pem", self.vh_truth[1].path) + + # Verify one directive was found in the correct file + self.assertEqual(len(loc_cert), 1) + self.assertEqual(configurator.get_file_path(loc_cert[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_key), 1) + self.assertEqual(configurator.get_file_path(loc_key[0]), + self.vh_truth[1].filep) + + def test_deploy_cert_newssl_no_fullchain(self): + self.config = util.get_apache_configurator( + self.config_path, self.vhost_path, self.config_dir, + self.work_dir, version=(2, 4, 16)) + self.config = self.mock_deploy_cert(self.config) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.assertRaises(errors.PluginError, + lambda: self.config.deploy_cert( + "random.demo", "example/cert.pem", + "example/key.pem")) + + def test_deploy_cert_old_apache_no_chain(self): + self.config = util.get_apache_configurator( + self.config_path, self.vhost_path, self.config_dir, + self.work_dir, version=(2, 4, 7)) + self.config = self.mock_deploy_cert(self.config) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.assertRaises(errors.PluginError, + lambda: self.config.deploy_cert( + "random.demo", "example/cert.pem", + "example/key.pem")) + def test_deploy_cert(self): self.config.parser.modules.add("ssl_module") self.config.parser.modules.add("mod_ssl.c") @@ -327,6 +436,63 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_add_dir.call_count, 2) + def test_prepare_server_https_named_listen(self): + mock_find = mock.Mock() + mock_find.return_value = ["test1", "test2", "test3"] + mock_get = mock.Mock() + mock_get.side_effect = ["1.2.3.4:80", "[::1]:80", "1.1.1.1:443"] + mock_add_dir = mock.Mock() + mock_enable = mock.Mock() + + self.config.parser.find_dir = mock_find + self.config.parser.get_arg = mock_get + self.config.parser.add_dir_to_ifmodssl = mock_add_dir + self.config.enable_mod = mock_enable + + # Test Listen statements with specific ip listeed + self.config.prepare_server_https("443") + # Should only be 2 here, as the third interface + # already listens to the correct port + self.assertEqual(mock_add_dir.call_count, 2) + + # Check argument to new Listen statements + self.assertEqual(mock_add_dir.call_args_list[0][0][2], ["1.2.3.4:443"]) + self.assertEqual(mock_add_dir.call_args_list[1][0][2], ["[::1]:443"]) + + # Reset return lists and inputs + mock_add_dir.reset_mock() + mock_get.side_effect = ["1.2.3.4:80", "[::1]:80", "1.1.1.1:443"] + + # Test + self.config.prepare_server_https("8080", temp=True) + self.assertEqual(mock_add_dir.call_count, 3) + self.assertEqual(mock_add_dir.call_args_list[0][0][2], + ["1.2.3.4:8080", "https"]) + self.assertEqual(mock_add_dir.call_args_list[1][0][2], + ["[::1]:8080", "https"]) + self.assertEqual(mock_add_dir.call_args_list[2][0][2], + ["1.1.1.1:8080", "https"]) + + def test_prepare_server_https_mixed_listen(self): + + mock_find = mock.Mock() + mock_find.return_value = ["test1", "test2"] + mock_get = mock.Mock() + mock_get.side_effect = ["1.2.3.4:8080", "443"] + mock_add_dir = mock.Mock() + mock_enable = mock.Mock() + + self.config.parser.find_dir = mock_find + self.config.parser.get_arg = mock_get + self.config.parser.add_dir_to_ifmodssl = mock_add_dir + self.config.enable_mod = mock_enable + + # Test Listen statements with specific ip listeed + self.config.prepare_server_https("443") + # Should only be 2 here, as the third interface + # already listens to the correct port + self.assertEqual(mock_add_dir.call_count, 0) + def test_make_vhost_ssl(self): ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) @@ -351,7 +517,67 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), self.config.is_name_vhost(ssl_vhost)) - self.assertEqual(len(self.config.vhosts), 6) + self.assertEqual(len(self.config.vhosts), 7) + + def test_clean_vhost_ssl(self): + # pylint: disable=protected-access + for directive in ["SSLCertificateFile", "SSLCertificateKeyFile", + "SSLCertificateChainFile", "SSLCACertificatePath"]: + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, + directive, ["bogus"]) + self.config.save() + + self.config._clean_vhost(self.vh_truth[1]) + self.config.save() + + loc_cert = self.config.parser.find_dir( + 'SSLCertificateFile', None, self.vh_truth[1].path, False) + loc_key = self.config.parser.find_dir( + 'SSLCertificateKeyFile', None, self.vh_truth[1].path, False) + loc_chain = self.config.parser.find_dir( + 'SSLCertificateChainFile', None, self.vh_truth[1].path, False) + loc_cacert = self.config.parser.find_dir( + 'SSLCACertificatePath', None, self.vh_truth[1].path, False) + + self.assertEqual(len(loc_cert), 1) + self.assertEqual(len(loc_key), 1) + + self.assertEqual(len(loc_chain), 0) + + self.assertEqual(len(loc_cacert), 10) + + def test_deduplicate_directives(self): + # pylint: disable=protected-access + DIRECTIVE = "Foo" + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, + DIRECTIVE, ["bar"]) + self.config.save() + + self.config._deduplicate_directives(self.vh_truth[1].path, [DIRECTIVE]) + self.config.save() + + self.assertEqual( + len(self.config.parser.find_dir( + DIRECTIVE, None, self.vh_truth[1].path, False)), 1) + + def test_remove_directives(self): + # pylint: disable=protected-access + DIRECTIVES = ["Foo", "Bar"] + for directive in DIRECTIVES: + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, + directive, ["baz"]) + self.config.save() + + self.config._remove_directives(self.vh_truth[1].path, DIRECTIVES) + self.config.save() + + for directive in DIRECTIVES: + self.assertEqual( + len(self.config.parser.find_dir( + directive, None, self.vh_truth[1].path, False)), 0) def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) @@ -380,23 +606,23 @@ class TwoVhost80Test(util.ApacheTest): self.config._add_name_vhost_if_necessary(self.vh_truth[0]) self.assertTrue(self.config.save.called) - @mock.patch("letsencrypt_apache.configurator.dvsni.ApacheDvsni.perform") + @mock.patch("letsencrypt_apache.configurator.tls_sni_01.ApacheTlsSni01.perform") @mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart") - def test_perform(self, mock_restart, mock_dvsni_perform): + def test_perform(self, mock_restart, mock_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded account_key, achall1, achall2 = self.get_achalls() - dvsni_ret_val = [ + expected = [ achall1.response(account_key), achall2.response(account_key), ] - mock_dvsni_perform.return_value = dvsni_ret_val + mock_perform.return_value = expected responses = self.config.perform([achall1, achall2]) - self.assertEqual(mock_dvsni_perform.call_count, 1) - self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_perform.call_count, 1) + self.assertEqual(responses, expected) self.assertEqual(mock_restart.call_count, 1) @@ -440,30 +666,20 @@ class TwoVhost80Test(util.ApacheTest): self.assertRaises(errors.PluginError, self.config.get_version) mock_script.return_value = ( - "Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "") + "Server Version: Apache/2.3{0} Apache/2.4.7".format( + os.linesep), "") self.assertRaises(errors.PluginError, self.config.get_version) mock_script.side_effect = errors.SubprocessError("Can't find program") self.assertRaises(errors.PluginError, self.config.get_version) - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart(self, mock_popen): - """These will be changed soon enough with reload.""" - mock_popen().returncode = 0 - mock_popen().communicate.return_value = ("", "") - + @mock.patch("letsencrypt_apache.configurator.le_util.run_script") + def test_restart(self, _): self.config.restart() - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart_bad_process(self, mock_popen): - mock_popen.side_effect = OSError - - self.assertRaises(errors.MisconfigurationError, self.config.restart) - - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart_failure(self, mock_popen): - mock_popen().communicate.return_value = ("", "") - mock_popen().returncode = 1 + @mock.patch("letsencrypt_apache.configurator.le_util.run_script") + def test_restart_bad_process(self, mock_run_script): + mock_run_script.side_effect = [None, errors.SubprocessError] self.assertRaises(errors.MisconfigurationError, self.config.restart) @@ -475,19 +691,21 @@ class TwoVhost80Test(util.ApacheTest): def test_config_test_bad_process(self, mock_run_script): mock_run_script.side_effect = errors.SubprocessError - self.assertRaises(errors.MisconfigurationError, self.config.config_test) + self.assertRaises(errors.MisconfigurationError, + self.config.config_test) def test_get_all_certs_keys(self): c_k = self.config.get_all_certs_keys() - self.assertEqual(len(c_k), 1) + self.assertEqual(len(c_k), 2) cert, key, path = next(iter(c_k)) self.assertTrue("cert" in cert) self.assertTrue("key" in key) - self.assertTrue("default-ssl.conf" in path) + self.assertTrue("default-ssl" in path) def test_get_all_certs_keys_malformed_conf(self): - self.config.parser.find_dir = mock.Mock(side_effect=[["path"], []]) + self.config.parser.find_dir = mock.Mock( + side_effect=[["path"], [], ["path"], []]) c_k = self.config.get_all_certs_keys() self.assertFalse(c_k) @@ -508,16 +726,107 @@ class TwoVhost80Test(util.ApacheTest): def test_supported_enhancements(self): self.assertTrue(isinstance(self.config.supported_enhancements(), list)) + @mock.patch("letsencrypt.le_util.exe_exists") + def test_enhance_unknown_vhost(self, mock_exe): + self.config.parser.modules.add("rewrite_module") + mock_exe.return_value = True + ssl_vh = obj.VirtualHost( + "fp", "ap", set([obj.Addr(("*", "443")), + obj.Addr(("satoshi.com",))]), + True, False) + self.config.vhosts.append(ssl_vh) + self.assertRaises( + errors.PluginError, + self.config.enhance, "satoshi.com", "redirect") + def test_enhance_unknown_enhancement(self): self.assertRaises( errors.PluginError, self.config.enhance, "letsencrypt.demo", "unknown_enhancement") + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_http_header_hsts(self, mock_exe, _): + self.config.parser.update_runtime_variables = mock.Mock() + self.config.parser.modules.add("mod_ssl.c") + mock_exe.return_value = True + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("letsencrypt.demo", "ensure-http-header", + "Strict-Transport-Security") + + self.assertTrue("headers_module" in self.config.parser.modules) + + # Get the ssl vhost for letsencrypt.demo + ssl_vhost = self.config.assoc["letsencrypt.demo"] + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + hsts_header = self.config.parser.find_dir( + "Header", None, ssl_vhost.path) + + # four args to HSTS header + self.assertEqual(len(hsts_header), 4) + + def test_http_header_hsts_twice(self): + self.config.parser.modules.add("mod_ssl.c") + # skip the enable mod + self.config.parser.modules.add("headers_module") + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("encryption-example.demo", "ensure-http-header", + "Strict-Transport-Security") + + self.assertRaises( + errors.PluginEnhancementAlreadyPresent, + self.config.enhance, "encryption-example.demo", + "ensure-http-header", "Strict-Transport-Security") + + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_http_header_uir(self, mock_exe, _): + self.config.parser.update_runtime_variables = mock.Mock() + self.config.parser.modules.add("mod_ssl.c") + mock_exe.return_value = True + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("letsencrypt.demo", "ensure-http-header", + "Upgrade-Insecure-Requests") + + self.assertTrue("headers_module" in self.config.parser.modules) + + # Get the ssl vhost for letsencrypt.demo + ssl_vhost = self.config.assoc["letsencrypt.demo"] + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + uir_header = self.config.parser.find_dir( + "Header", None, ssl_vhost.path) + + # four args to HSTS header + self.assertEqual(len(uir_header), 4) + + def test_http_header_uir_twice(self): + self.config.parser.modules.add("mod_ssl.c") + # skip the enable mod + self.config.parser.modules.add("headers_module") + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("encryption-example.demo", "ensure-http-header", + "Upgrade-Insecure-Requests") + + self.assertRaises( + errors.PluginEnhancementAlreadyPresent, + self.config.enhance, "encryption-example.demo", + "ensure-http-header", "Upgrade-Insecure-Requests") + @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") def test_redirect_well_formed_http(self, mock_exe, _): self.config.parser.update_runtime_variables = mock.Mock() mock_exe.return_value = True + self.config.get_version = mock.Mock(return_value=(2, 2)) + # This will create an ssl vhost for letsencrypt.demo self.config.enhance("letsencrypt.demo", "redirect") @@ -537,10 +846,61 @@ class TwoVhost80Test(util.ApacheTest): self.assertTrue("rewrite_module" in self.config.parser.modules) + def test_rewrite_rule_exists(self): + # Skip the enable mod + self.config.parser.modules.add("rewrite_module") + self.config.get_version = mock.Mock(return_value=(2, 3, 9)) + self.config.parser.add_dir( + self.vh_truth[3].path, "RewriteRule", ["Unknown"]) + # pylint: disable=protected-access + self.assertTrue(self.config._is_rewrite_exists(self.vh_truth[3])) + + def test_rewrite_engine_exists(self): + # Skip the enable mod + self.config.parser.modules.add("rewrite_module") + self.config.get_version = mock.Mock(return_value=(2, 3, 9)) + self.config.parser.add_dir( + self.vh_truth[3].path, "RewriteEngine", "on") + # pylint: disable=protected-access + self.assertTrue(self.config._is_rewrite_engine_on(self.vh_truth[3])) + + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_redirect_with_existing_rewrite(self, mock_exe, _): + self.config.parser.update_runtime_variables = mock.Mock() + mock_exe.return_value = True + self.config.get_version = mock.Mock(return_value=(2, 2)) + + # Create a preexisting rewrite rule + self.config.parser.add_dir( + self.vh_truth[3].path, "RewriteRule", ["UnknownPattern", + "UnknownTarget"]) + self.config.save() + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("letsencrypt.demo", "redirect") + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + rw_engine = self.config.parser.find_dir( + "RewriteEngine", "on", self.vh_truth[3].path) + rw_rule = self.config.parser.find_dir( + "RewriteRule", None, self.vh_truth[3].path) + + self.assertEqual(len(rw_engine), 1) + # three args to rw_rule + 1 arg for the pre existing rewrite + self.assertEqual(len(rw_rule), 5) + + self.assertTrue(rw_engine[0].startswith(self.vh_truth[3].path)) + self.assertTrue(rw_rule[0].startswith(self.vh_truth[3].path)) + + self.assertTrue("rewrite_module" in self.config.parser.modules) + def test_redirect_with_conflict(self): self.config.parser.modules.add("rewrite_module") ssl_vh = obj.VirtualHost( - "fp", "ap", set([obj.Addr(("*", "443")), obj.Addr(("zombo.com",))]), + "fp", "ap", set([obj.Addr(("*", "443")), + obj.Addr(("zombo.com",))]), True, False) # No names ^ this guy should conflict. @@ -551,49 +911,74 @@ class TwoVhost80Test(util.ApacheTest): def test_redirect_twice(self): # Skip the enable mod self.config.parser.modules.add("rewrite_module") + self.config.get_version = mock.Mock(return_value=(2, 3, 9)) + self.config.enhance("encryption-example.demo", "redirect") self.assertRaises( - errors.PluginError, + errors.PluginEnhancementAlreadyPresent, self.config.enhance, "encryption-example.demo", "redirect") - def test_unknown_rewrite(self): - # Skip the enable mod - self.config.parser.modules.add("rewrite_module") - self.config.parser.add_dir( - self.vh_truth[3].path, "RewriteRule", ["Unknown"]) - self.config.save() - self.assertRaises( - errors.PluginError, - self.config.enhance, "letsencrypt.demo", "redirect") - - def test_unknown_rewrite2(self): - # Skip the enable mod - self.config.parser.modules.add("rewrite_module") - self.config.parser.add_dir( - self.vh_truth[3].path, "RewriteRule", ["Unknown", "2", "3"]) - self.config.save() - self.assertRaises( - errors.PluginError, - self.config.enhance, "letsencrypt.demo", "redirect") - - def test_unknown_redirect(self): - # Skip the enable mod - self.config.parser.modules.add("rewrite_module") - self.config.parser.add_dir( - self.vh_truth[3].path, "Redirect", ["Unknown"]) - self.config.save() - self.assertRaises( - errors.PluginError, - self.config.enhance, "letsencrypt.demo", "redirect") - def test_create_own_redirect(self): self.config.parser.modules.add("rewrite_module") + self.config.get_version = mock.Mock(return_value=(2, 3, 9)) # For full testing... give names... self.vh_truth[1].name = "default.com" self.vh_truth[1].aliases = set(["yes.default.com"]) - self.config._enable_redirect(self.vh_truth[1], "") # pylint: disable=protected-access - self.assertEqual(len(self.config.vhosts), 6) + # pylint: disable=protected-access + self.config._enable_redirect(self.vh_truth[1], "") + self.assertEqual(len(self.config.vhosts), 7) + + def test_create_own_redirect_for_old_apache_version(self): + self.config.parser.modules.add("rewrite_module") + self.config.get_version = mock.Mock(return_value=(2, 2)) + # For full testing... give names... + self.vh_truth[1].name = "default.com" + self.vh_truth[1].aliases = set(["yes.default.com"]) + + # pylint: disable=protected-access + self.config._enable_redirect(self.vh_truth[1], "") + self.assertEqual(len(self.config.vhosts), 7) + + def test_sift_line(self): + # pylint: disable=protected-access + small_quoted_target = "RewriteRule ^ \"http://\"" + self.assertFalse(self.config._sift_line(small_quoted_target)) + + https_target = "RewriteRule ^ https://satoshi" + self.assertTrue(self.config._sift_line(https_target)) + + normal_target = "RewriteRule ^/(.*) http://www.a.com:1234/$1 [L,R]" + self.assertFalse(self.config._sift_line(normal_target)) + + @mock.patch("letsencrypt_apache.configurator.zope.component.getUtility") + def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_get_utility): + self.config.parser.modules.add("rewrite_module") + + http_vhost = self.vh_truth[0] + + self.config.parser.add_dir( + http_vhost.path, "RewriteEngine", "on") + + self.config.parser.add_dir( + http_vhost.path, "RewriteRule", + ["^", + "https://%{SERVER_NAME}%{REQUEST_URI}", + "[L,QSA,R=permanent]"]) + self.config.save() + + ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) + + self.assertTrue(self.config.parser.find_dir( + "RewriteEngine", "on", ssl_vhost.path, False)) + + conf_text = open(ssl_vhost.filep).read() + commented_rewrite_rule = ("# RewriteRule ^ " + "https://%{SERVER_NAME}%{REQUEST_URI} " + "[L,QSA,R=permanent]") + self.assertTrue(commented_rewrite_rule in conf_text) + mock_get_utility().add_message.assert_called_once_with(mock.ANY, + mock.ANY) def get_achalls(self): """Return testing achallenges.""" @@ -622,6 +1007,15 @@ class TwoVhost80Test(util.ApacheTest): self.assertTrue(self.config.parser.find_dir( "NameVirtualHost", "*:443", exclude=False)) + def test_aug_version(self): + mock_match = mock.Mock(return_value=["something"]) + self.config.aug.match = mock_match + # pylint: disable=protected-access + self.assertEquals(self.config._check_aug_version(), + ["something"]) + self.config.aug.match.side_effect = RuntimeError + self.assertFalse(self.config._check_aug_version()) + if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/letsencrypt-apache/letsencrypt_apache/tests/constants_test.py b/letsencrypt-apache/letsencrypt_apache/tests/constants_test.py new file mode 100644 index 000000000..289b61bb1 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/constants_test.py @@ -0,0 +1,27 @@ +"""Test for letsencrypt_apache.configurator.""" + +import mock +import unittest + +from letsencrypt_apache import constants + + +class ConstantsTest(unittest.TestCase): + + @mock.patch("letsencrypt.le_util.get_os_info") + def test_get_debian_value(self, os_info): + os_info.return_value = ('Debian', '', '') + self.assertEqual(constants.os_constant("vhost_root"), + "/etc/apache2/sites-available") + + @mock.patch("letsencrypt.le_util.get_os_info") + def test_get_centos_value(self, os_info): + os_info.return_value = ('CentOS Linux', '', '') + self.assertEqual(constants.os_constant("vhost_root"), + "/etc/httpd/conf.d") + + @mock.patch("letsencrypt.le_util.get_os_info") + def test_get_default_value(self, os_info): + os_info.return_value = ('Nonexistent Linux', '', '') + self.assertEqual(constants.os_constant("vhost_root"), + "/etc/apache2/sites-available") diff --git a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py index 6db319d87..590144372 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py @@ -6,6 +6,7 @@ import mock import zope.component from letsencrypt.display import util as display_util +from letsencrypt import errors from letsencrypt_apache import obj @@ -31,6 +32,14 @@ class SelectVhostTest(unittest.TestCase): mock_util().menu.return_value = (display_util.OK, 3) self.assertEqual(self.vhosts[3], self._call(self.vhosts)) + @mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility") + def test_noninteractive(self, mock_util): + mock_util().menu.side_effect = errors.MissingCommandlineFlag("no vhost default") + try: + self._call(self.vhosts) + except errors.MissingCommandlineFlag, e: + self.assertTrue("VirtualHost directives" in e.message) + @mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility") def test_more_info_cancel(self, mock_util): mock_util().menu.side_effect = [ diff --git a/letsencrypt-apache/letsencrypt_apache/tests/obj_test.py b/letsencrypt-apache/letsencrypt_apache/tests/obj_test.py index 13eddaddf..a469702f1 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/obj_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/obj_test.py @@ -47,7 +47,8 @@ class VirtualHostTest(unittest.TestCase): self.assertTrue(self.vhost1.conflicts([self.addr2])) self.assertFalse(self.vhost1.conflicts([self.addr_default])) - self.assertFalse(self.vhost2.conflicts([self.addr1, self.addr_default])) + self.assertFalse(self.vhost2.conflicts([self.addr1, + self.addr_default])) def test_same_server(self): from letsencrypt_apache.obj import VirtualHost diff --git a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py index bc1f316f9..e976bc9f6 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py @@ -36,7 +36,7 @@ class BasicParserTest(util.ParserTest): """ file_path = os.path.join( - self.config_path, "sites-available", "letsencrypt.conf") + self.config_path, "not-parsed-by-default", "letsencrypt.conf") self.parser._parse_file(file_path) # pylint: disable=protected-access @@ -118,7 +118,8 @@ class BasicParserTest(util.ParserTest): # pylint: disable=protected-access path = os.path.join(self.parser.root, "httpd.conf") open(path, 'w').close() - self.parser.add_dir(self.parser.loc["default"], "Include", "httpd.conf") + self.parser.add_dir(self.parser.loc["default"], "Include", + "httpd.conf") self.assertEqual( path, self.parser._set_user_config_file()) @@ -145,25 +146,26 @@ class BasicParserTest(util.ParserTest): expected_vars = {"TEST": "", "U_MICH": "", "TLS": "443", "example_path": "Documents/path"} - self.parser.update_runtime_variables("ctl") + self.parser.update_runtime_variables() self.assertEqual(self.parser.variables, expected_vars) @mock.patch("letsencrypt_apache.parser.ApacheParser._get_runtime_cfg") def test_update_runtime_vars_bad_output(self, mock_cfg): mock_cfg.return_value = "Define: TLS=443=24" - self.assertRaises( - errors.PluginError, self.parser.update_runtime_variables, "ctl") + self.parser.update_runtime_variables() mock_cfg.return_value = "Define: DUMP_RUN_CFG\nDefine: TLS=443=24" self.assertRaises( - errors.PluginError, self.parser.update_runtime_variables, "ctl") + errors.PluginError, self.parser.update_runtime_variables) + @mock.patch("letsencrypt_apache.constants.os_constant") @mock.patch("letsencrypt_apache.parser.subprocess.Popen") - def test_update_runtime_vars_bad_ctl(self, mock_popen): + def test_update_runtime_vars_bad_ctl(self, mock_popen, mock_const): mock_popen.side_effect = OSError + mock_const.return_value = "nonexistent" self.assertRaises( errors.MisconfigurationError, - self.parser.update_runtime_variables, "ctl") + self.parser.update_runtime_variables) @mock.patch("letsencrypt_apache.parser.subprocess.Popen") def test_update_runtime_vars_bad_exit(self, mock_popen): @@ -171,7 +173,7 @@ class BasicParserTest(util.ParserTest): mock_popen.returncode = -1 self.assertRaises( errors.MisconfigurationError, - self.parser.update_runtime_variables, "ctl") + self.parser.update_runtime_variables) class ParserInitTest(util.ApacheTest): @@ -185,6 +187,15 @@ class ParserInitTest(util.ApacheTest): shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + @mock.patch("letsencrypt_apache.parser.ApacheParser._get_runtime_cfg") + def test_unparsable(self, mock_cfg): + from letsencrypt_apache.parser import ApacheParser + mock_cfg.return_value = ('Define: TEST') + self.assertRaises( + errors.PluginError, + ApacheParser, self.aug, os.path.relpath(self.config_path), + "/dummy/vhostpath", version=(2, 2, 22)) + def test_root_normalized(self): from letsencrypt_apache.parser import ApacheParser @@ -193,7 +204,9 @@ class ParserInitTest(util.ApacheTest): path = os.path.join( self.temp_dir, "debian_apache_2_4/////two_vhost_80/../two_vhost_80/apache2") - parser = ApacheParser(self.aug, path, "dummy_ctl") + + parser = ApacheParser(self.aug, path, + "/dummy/vhostpath") self.assertEqual(parser.root, self.config_path) @@ -202,7 +215,8 @@ class ParserInitTest(util.ApacheTest): with mock.patch("letsencrypt_apache.parser.ApacheParser." "update_runtime_variables"): parser = ApacheParser( - self.aug, os.path.relpath(self.config_path), "dummy_ctl") + self.aug, os.path.relpath(self.config_path), + "/dummy/vhostpath") self.assertEqual(parser.root, self.config_path) @@ -211,7 +225,8 @@ class ParserInitTest(util.ApacheTest): with mock.patch("letsencrypt_apache.parser.ApacheParser." "update_runtime_variables"): parser = ApacheParser( - self.aug, self.config_path + os.path.sep, "dummy_ctl") + self.aug, self.config_path + os.path.sep, + "/dummy/vhostpath") self.assertEqual(parser.root, self.config_path) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/bad_conf_file.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/bad_conf_file.conf index 1aad6a9f4..8e9178803 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/bad_conf_file.conf +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/bad_conf_file.conf @@ -1,5 +1,3 @@ ServerName invalid.net - - diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf new file mode 100644 index 000000000..5a50c536e --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf @@ -0,0 +1,36 @@ + + + ServerAdmin webmaster@localhost + + DocumentRoot /var/www/html + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # A self-signed (snakeoil) certificate can be created by installing + # the ssl-cert package. See + # /usr/share/doc/apache2/README.Debian.gz for more info. + # If both key and certificate are stored in the same file, only the + # SSLCertificateFile directive is needed. + SSLCertificateFile /etc/apache2/certs/letsencrypt-cert_5.pem + SSLCertificateKeyFile /etc/apache2/ssl/key-letsencrypt_15.pem + + + #SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + + BrowserMatch "MSIE [2-6]" \ + nokeepalive ssl-unclean-shutdown \ + downgrade-1.0 force-response-1.0 + # MSIE 7 and newer should be able to use keepalive + BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown + + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py similarity index 83% rename from letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py rename to letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py index 911c2a36b..9681bf9fc 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt_apache.dvsni.""" +"""Test for letsencrypt_apache.tls_sni_01.""" import unittest import shutil @@ -9,22 +9,24 @@ from letsencrypt.plugins import common_test from letsencrypt_apache import obj from letsencrypt_apache.tests import util +from six.moves import xrange # pylint: disable=redefined-builtin, import-error -class DvsniPerformTest(util.ApacheTest): - """Test the ApacheDVSNI challenge.""" + +class TlsSniPerformTest(util.ApacheTest): + """Test the ApacheTlsSni01 challenge.""" auth_key = common_test.TLSSNI01Test.auth_key achalls = common_test.TLSSNI01Test.achalls def setUp(self): # pylint: disable=arguments-differ - super(DvsniPerformTest, self).setUp() + super(TlsSniPerformTest, self).setUp() config = util.get_apache_configurator( - self.config_path, self.config_dir, self.work_dir) + self.config_path, self.vhost_path, self.config_dir, self.work_dir) config.config.tls_sni_01_port = 443 - from letsencrypt_apache import dvsni - self.sni = dvsni.ApacheDvsni(config) + from letsencrypt_apache import tls_sni_01 + self.sni = tls_sni_01.ApacheTlsSni01(config) def tearDown(self): shutil.rmtree(self.temp_dir) @@ -58,7 +60,7 @@ class DvsniPerformTest(util.ApacheTest): mock_setup_cert.assert_called_once_with(achall) - # Check to make sure challenge config path is included in apache config. + # Check to make sure challenge config path is included in apache config self.assertEqual( len(self.sni.configurator.parser.find_dir( "Include", self.sni.challenge_conf)), 1) @@ -78,7 +80,8 @@ class DvsniPerformTest(util.ApacheTest): # pylint: disable=protected-access self.sni._setup_challenge_cert = mock_setup_cert - sni_responses = self.sni.perform() + with mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.enable_mod"): + sni_responses = self.sni.perform() self.assertEqual(mock_setup_cert.call_count, 2) @@ -121,16 +124,18 @@ class DvsniPerformTest(util.ApacheTest): names = vhost.get_names() self.assertTrue(names in z_domains) - def test_get_dvsni_addrs_default(self): + def test_get_addrs_default(self): self.sni.configurator.choose_vhost = mock.Mock( return_value=obj.VirtualHost( - "path", "aug_path", set([obj.Addr.fromstring("_default_:443")]), + "path", "aug_path", + set([obj.Addr.fromstring("_default_:443")]), False, False) ) + # pylint: disable=protected-access self.assertEqual( set([obj.Addr.fromstring("*:443")]), - self.sni.get_dvsni_addrs(self.achalls[0])) + self.sni._get_addrs(self.achalls[0])) if __name__ == "__main__": diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index a8bfe0e4b..fb86d2320 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -23,7 +23,8 @@ from letsencrypt_apache import obj class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods def setUp(self, test_dir="debian_apache_2_4/two_vhost_80", - config_root="debian_apache_2_4/two_vhost_80/apache2"): + config_root="debian_apache_2_4/two_vhost_80/apache2", + vhost_root="debian_apache_2_4/two_vhost_80/apache2/sites-available"): # pylint: disable=arguments-differ super(ApacheTest, self).setUp() @@ -36,16 +37,32 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods constants.MOD_SSL_CONF_DEST) self.config_path = os.path.join(self.temp_dir, config_root) + self.vhost_path = os.path.join(self.temp_dir, vhost_root) self.rsa512jwk = jose.JWKRSA.load(test_util.load_vector( "rsa512_key.pem")) + # Make sure all vhosts in sites-enabled are symlinks (Python packaging + # does not preserve symlinks) + sites_enabled = os.path.join(self.config_path, "sites-enabled") + if not os.path.exists(sites_enabled): + return + + for vhost_basename in os.listdir(sites_enabled): + vhost = os.path.join(sites_enabled, vhost_basename) + if not os.path.islink(vhost): # pragma: no cover + os.remove(vhost) + target = os.path.join( + os.path.pardir, "sites-available", vhost_basename) + os.symlink(target, vhost) + class ParserTest(ApacheTest): # pytlint: disable=too-few-public-methods def setUp(self, test_dir="debian_apache_2_4/two_vhost_80", - config_root="debian_apache_2_4/two_vhost_80/apache2"): - super(ParserTest, self).setUp(test_dir, config_root) + config_root="debian_apache_2_4/two_vhost_80/apache2", + vhost_root="debian_apache_2_4/two_vhost_80/apache2/sites-available"): + super(ParserTest, self).setUp(test_dir, config_root, vhost_root) zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) @@ -55,11 +72,12 @@ class ParserTest(ApacheTest): # pytlint: disable=too-few-public-methods with mock.patch("letsencrypt_apache.parser.ApacheParser." "update_runtime_variables"): self.parser = ApacheParser( - self.aug, self.config_path, "dummy_ctl_path") + self.aug, self.config_path, self.vhost_path) def get_apache_configurator( - config_path, config_dir, work_dir, version=(2, 4, 7), conf=None): + config_path, vhost_path, + config_dir, work_dir, version=(2, 4, 7), conf=None): """Create an Apache Configurator with the specified options. :param conf: Function that returns binary paths. self.conf in Configurator @@ -68,18 +86,16 @@ def get_apache_configurator( backups = os.path.join(work_dir, "backups") mock_le_config = mock.MagicMock( apache_server_root=config_path, - apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"], + apache_vhost_root=vhost_path, + apache_le_vhost_ext=constants.os_constant("le_vhost_ext"), + apache_challenge_location=config_path, backup_dir=backups, config_dir=config_dir, temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), in_progress_dir=os.path.join(backups, "IN_PROGRESS"), work_dir=work_dir) - with mock.patch("letsencrypt_apache.configurator." - "subprocess.Popen") as mock_popen: - # This indicates config_test passes - mock_popen().communicate.return_value = ("Fine output", "No problems") - mock_popen().returncode = 0 + with mock.patch("letsencrypt_apache.configurator.le_util.run_script"): with mock.patch("letsencrypt_apache.configurator.le_util." "exe_exists") as mock_exe_exists: mock_exe_exists.return_value = True @@ -128,7 +144,13 @@ def get_vh_truth(temp_dir, config_name): os.path.join(prefix, "mod_macro-example.conf"), os.path.join(aug_pre, "mod_macro-example.conf/Macro/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), False, True, modmacro=True) + set([obj.Addr.fromstring("*:80")]), False, True, + modmacro=True), + obj.VirtualHost( + os.path.join(prefix, "default-ssl-port-only.conf"), + os.path.join(aug_pre, ("default-ssl-port-only.conf/" + "IfModule/VirtualHost")), + set([obj.Addr.fromstring("_default_:443")]), True, False), ] return vh_truth diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py similarity index 71% rename from letsencrypt-apache/letsencrypt_apache/dvsni.py rename to letsencrypt-apache/letsencrypt_apache/tls_sni_01.py index 2f9e9ed18..cc1d749a0 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py @@ -1,28 +1,32 @@ -"""ApacheDVSNI""" +"""A class that performs TLS-SNI-01 challenges for Apache""" + import os +import logging from letsencrypt.plugins import common from letsencrypt_apache import obj from letsencrypt_apache import parser +logger = logging.getLogger(__name__) -class ApacheDvsni(common.TLSSNI01): - """Class performs DVSNI challenges within the Apache configurator. + +class ApacheTlsSni01(common.TLSSNI01): + """Class that performs TLS-SNI-01 challenges within the Apache configurator :ivar configurator: ApacheConfigurator object :type configurator: :class:`~apache.configurator.ApacheConfigurator` - :ivar list achalls: Annotated tls-sni-01 + :ivar list achalls: Annotated TLS-SNI-01 (`.KeyAuthorizationAnnotatedChallenge`) challenges. :param list indices: Meant to hold indices of challenges in a - larger array. ApacheDvsni is capable of solving many challenges + larger array. ApacheTlsSni01 is capable of solving many challenges at once which causes an indexing issue within ApacheConfigurator who must return all responses in order. Imagine ApacheConfigurator maintaining state about where all of the http-01 Challenges, - Dvsni Challenges belong in the response array. This is an optional - utility. + TLS-SNI-01 Challenges belong in the response array. This is an + optional utility. :param str challenge_conf: location of the challenge config file @@ -46,14 +50,14 @@ class ApacheDvsni(common.TLSSNI01): """ def __init__(self, *args, **kwargs): - super(ApacheDvsni, self).__init__(*args, **kwargs) + super(ApacheTlsSni01, self).__init__(*args, **kwargs) self.challenge_conf = os.path.join( - self.configurator.conf("server-root"), - "le_dvsni_cert_challenge.conf") + self.configurator.conf("challenge-location"), + "le_tls_sni_01_cert_challenge.conf") def perform(self): - """Perform a DVSNI challenge.""" + """Perform a TLS-SNI-01 challenge.""" if not self.achalls: return [] # Save any changes to the configuration as a precaution @@ -71,8 +75,9 @@ class ApacheDvsni(common.TLSSNI01): responses.append(self._setup_challenge_cert(achall)) # Setup the configuration - dvsni_addrs = self._mod_config() - self.configurator.make_addrs_sni_ready(dvsni_addrs) + addrs = self._mod_config() + self.configurator.save("Don't lose mod_config changes", True) + self.configurator.make_addrs_sni_ready(addrs) # Save reversible changes self.configurator.save("SNI Challenge", True) @@ -84,16 +89,16 @@ class ApacheDvsni(common.TLSSNI01): Result: Apache config includes virtual servers for issued challs - :returns: All DVSNI addresses used + :returns: All TLS-SNI-01 addresses used :rtype: set """ - dvsni_addrs = set() + addrs = set() config_text = "\n" for achall in self.achalls: - achall_addrs = self.get_dvsni_addrs(achall) - dvsni_addrs.update(achall_addrs) + achall_addrs = self._get_addrs(achall) + addrs.update(achall_addrs) config_text += self._get_config_text(achall, achall_addrs) @@ -103,33 +108,34 @@ class ApacheDvsni(common.TLSSNI01): self.configurator.reverter.register_file_creation( True, self.challenge_conf) + logger.debug("writing a config file with text: %s", config_text) with open(self.challenge_conf, "w") as new_conf: new_conf.write(config_text) - return dvsni_addrs - - def get_dvsni_addrs(self, achall): - """Return the Apache addresses needed for DVSNI.""" - vhost = self.configurator.choose_vhost(achall.domain) + return addrs + def _get_addrs(self, achall): + """Return the Apache addresses needed for TLS-SNI-01.""" + vhost = self.configurator.choose_vhost(achall.domain, temp=True) # TODO: Checkout _default_ rules. - dvsni_addrs = set() + addrs = set() default_addr = obj.Addr(("*", str( self.configurator.config.tls_sni_01_port))) for addr in vhost.addrs: if "_default_" == addr.get_addr(): - dvsni_addrs.add(default_addr) + addrs.add(default_addr) else: - dvsni_addrs.add( - addr.get_sni_addr(self.configurator.config.tls_sni_01_port)) + addrs.add( + addr.get_sni_addr( + self.configurator.config.tls_sni_01_port)) - return dvsni_addrs + return addrs def _conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. + """Add TLS-SNI-01 challenge conf file into configuration. - Adds DVSNI challenge include file if it does not already exist + Adds TLS-SNI-01 challenge include file if it does not already exist within mainConfig :param str main_config: file path to main user apache config file @@ -146,7 +152,7 @@ class ApacheDvsni(common.TLSSNI01): """Chocolate virtual server configuration text :param .KeyAuthorizationAnnotatedChallenge achall: Annotated - DVSNI challenge. + TLS-SNI-01 challenge. :param list ip_addrs: addresses of challenged domain :class:`list` of type `~.obj.Addr` @@ -157,7 +163,7 @@ class ApacheDvsni(common.TLSSNI01): """ ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( - self.configurator.config.work_dir, "dvsni_page/") + self.configurator.config.work_dir, "tls_sni_01_page/") # TODO: Python docs is not clear how mutliline string literal # newlines are parsed on different platforms. At least on # Linux (Debian sid), when source file uses CRLF, Python still diff --git a/letsencrypt-apache/setup.py b/letsencrypt-apache/setup.py index e4dd11935..a6553d890 100644 --- a/letsencrypt-apache/setup.py +++ b/letsencrypt-apache/setup.py @@ -4,8 +4,9 @@ from setuptools import setup from setuptools import find_packages -version = '0.1.0.dev0' +version = '0.4.0.dev0' +# Please update tox.ini when modifying dependency version requirements install_requires = [ 'acme=={0}'.format(version), 'letsencrypt=={0}'.format(version), @@ -62,4 +63,5 @@ setup( 'apache = letsencrypt_apache.configurator:ApacheConfigurator', ], }, + test_suite='letsencrypt_apache', ) diff --git a/letsencrypt-auto b/letsencrypt-auto index a3009fe52..20465dbb1 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -14,6 +14,10 @@ XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} VENV_NAME="letsencrypt" VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} VENV_BIN=${VENV_PATH}/bin +# The path to the letsencrypt-auto script. Everything that uses these might +# at some point be inlined... +LEA_PATH=`dirname "$0"` +BOOTSTRAP=${LEA_PATH}/bootstrap # This script takes the same arguments as the main letsencrypt program, but it # additionally responds to --verbose (more output) and --debug (allow support @@ -43,13 +47,13 @@ if test "`id -u`" -ne "0" ; then args="" # This `while` loop iterates over all parameters given to this function. # For each parameter, all `'` will be replace by `'"'"'`, and the escaped string - # will be wrap in a pair of `'`, then append to `$args` string + # will be wrapped in a pair of `'`, then appended to `$args` string # For example, `echo "It's only 1\$\!"` will be escaped to: # 'echo' 'It'"'"'s only 1$!' # │ │└┼┘│ # │ │ │ └── `'s only 1$!'` the literal string # │ │ └── `\"'\"` is a single quote (as a string) - # │ └── `'It'`, to be concatenated with the strings followed it + # │ └── `'It'`, to be concatenated with the strings following it # └── `echo` wrapped in a pair of `'`, it's totally fine for the shell command itself while [ $# -ne 0 ]; do args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " @@ -93,6 +97,7 @@ DeterminePythonVersion() { export LE_PYTHON=${LE_PYTHON:-python} else echo "Cannot find any Pythons... please install one!" + exit 1 fi PYVER=`$LE_PYTHON --version 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'` @@ -100,7 +105,7 @@ DeterminePythonVersion() { ExperimentalBootstrap "Python 2.6" elif [ $PYVER -lt 26 ] ; then echo "You have an ancient version of Python entombed in your operating system..." - echo "This isn't going to work." + echo "This isn't going to work; you'll need at least version 2.6." exit 1 fi } @@ -110,7 +115,6 @@ DeterminePythonVersion() { # later steps, causing "ImportError: cannot import name unpack_url" if [ ! -d $VENV_PATH ] then - BOOTSTRAP=`dirname $0`/bootstrap if [ ! -f $BOOTSTRAP/debian.sh ] ; then echo "Cannot find the letsencrypt bootstrap scripts in $BOOTSTRAP" exit 1 @@ -126,8 +130,17 @@ then echo "Bootstrapping dependencies for openSUSE-based OSes..." $SUDO $BOOTSTRAP/_suse_common.sh elif [ -f /etc/arch-release ] ; then - echo "Bootstrapping dependencies for Archlinux..." - $SUDO $BOOTSTRAP/archlinux.sh + if [ "$DEBUG" = 1 ] ; then + echo "Bootstrapping dependencies for Archlinux..." + $SUDO $BOOTSTRAP/archlinux.sh + else + echo "Please use pacman to install letsencrypt packages:" + echo "# pacman -S letsencrypt letsencrypt-apache" + echo + echo "If you would like to use the virtualenv way, please run the script again with the" + echo "--debug flag." + exit 1 + fi elif [ -f /etc/manjaro-release ] ; then ExperimentalBootstrap "Manjaro Linux" manjaro.sh "$SUDO" elif [ -f /etc/gentoo-release ] ; then @@ -135,9 +148,9 @@ then elif uname | grep -iq FreeBSD ; then ExperimentalBootstrap "FreeBSD" freebsd.sh "$SUDO" elif uname | grep -iq Darwin ; then - ExperimentalBootstrap "Mac OS X" mac.sh + ExperimentalBootstrap "Mac OS X" mac.sh # homebrew doesn't normally run as root elif grep -iq "Amazon Linux" /etc/issue ; then - ExperimentalBootstrap "Amazon Linux" _rpm_common.sh + ExperimentalBootstrap "Amazon Linux" _rpm_common.sh "$SUDO" else echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!" echo @@ -163,7 +176,7 @@ if [ "$VERBOSE" = 1 ] ; then echo $VENV_BIN/pip install -U setuptools $VENV_BIN/pip install -U pip - $VENV_BIN/pip install -r py26reqs.txt -U letsencrypt letsencrypt-apache + $VENV_BIN/pip install -U letsencrypt letsencrypt-apache # nginx is buggy / disabled for now, but upgrade it if the user has # installed it manually if $VENV_BIN/pip freeze | grep -q letsencrypt-nginx ; then @@ -175,8 +188,6 @@ else $VENV_BIN/pip install -U pip > /dev/null printf . # nginx is buggy / disabled for now... - $VENV_BIN/pip install -r py26reqs.txt > /dev/null - printf . $VENV_BIN/pip install -U letsencrypt > /dev/null printf . $VENV_BIN/pip install -U letsencrypt-apache > /dev/null @@ -189,5 +200,5 @@ fi # Explain what's about to happen, for the benefit of those getting sudo # password prompts... -echo "Running with virtualenv:" $SUDO $VENV_BIN/letsencrypt "$@" +echo "Requesting root privileges to run with virtualenv:" $SUDO $VENV_BIN/letsencrypt "$@" $SUDO $VENV_BIN/letsencrypt "$@" diff --git a/letsencrypt-auto-source/Dockerfile b/letsencrypt-auto-source/Dockerfile new file mode 100644 index 000000000..667acfe5a --- /dev/null +++ b/letsencrypt-auto-source/Dockerfile @@ -0,0 +1,33 @@ +# For running tests, build a docker image with a passwordless sudo and a trust +# store we can manipulate. + +FROM ubuntu:trusty + +# Add an unprivileged user: +RUN useradd --create-home --home-dir /home/lea --shell /bin/bash --groups sudo --uid 1000 lea + +# Let that user sudo: +RUN adduser lea sudo +RUN sed -i.bkp -e \ + 's/%sudo\s\+ALL=(ALL\(:ALL\)\?)\s\+ALL/%sudo ALL=NOPASSWD:ALL/g' \ + /etc/sudoers + +# Install pip and nose: +RUN apt-get update && \ + apt-get -q -y install python-pip && \ + apt-get clean +RUN pip install nose + +RUN mkdir -p /home/lea/letsencrypt/letsencrypt + +# Install fake testing CA: +COPY ./tests/certs/ca/my-root-ca.crt.pem /usr/local/share/ca-certificates/ +RUN update-ca-certificates + +# Copy code: +COPY . /home/lea/letsencrypt/letsencrypt-auto-source + +USER lea +WORKDIR /home/lea + +CMD ["nosetests", "-v", "-s", "letsencrypt/letsencrypt-auto-source/tests"] diff --git a/letsencrypt-auto-source/build.py b/letsencrypt-auto-source/build.py new file mode 100755 index 000000000..9a5fc46a7 --- /dev/null +++ b/letsencrypt-auto-source/build.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +"""Stitch together the letsencrypt-auto script. + +Implement a simple templating language in which {{ some/file }} turns into the +contents of the file at ./pieces/some/file except for certain tokens which have +other, special definitions. + +""" +from os.path import abspath, dirname, join +import re +from sys import argv + + +DIR = dirname(abspath(__file__)) + + +def le_version(build_script_dir): + """Return the version number stamped in letsencrypt/__init__.py.""" + return re.search('''^__version__ = ['"](.+)['"].*''', + file_contents(join(dirname(build_script_dir), + 'letsencrypt', + '__init__.py')), + re.M).group(1) + + +def file_contents(path): + with open(path) as file: + return file.read() + + +def build(version=None, requirements=None): + """Return the built contents of the letsencrypt-auto script. + + :arg version: The version to attach to the script. Default: the version of + the letsencrypt package + :arg requirements: The contents of the requirements file to embed. Default: + contents of letsencrypt-auto-requirements.txt + + """ + special_replacements = { + 'LE_AUTO_VERSION': version or le_version(DIR) + } + if requirements: + special_replacements['letsencrypt-auto-requirements.txt'] = requirements + + def replacer(match): + token = match.group(1) + if token in special_replacements: + return special_replacements[token] + else: + return file_contents(join(DIR, 'pieces', token)) + + return re.sub(r'{{\s*([A-Za-z0-9_./-]+)\s*}}', + replacer, + file_contents(join(DIR, 'letsencrypt-auto.template'))) + + +def main(): + with open(join(DIR, 'letsencrypt-auto'), 'w') as out: + out.write(build()) + + +if __name__ == '__main__': + main() diff --git a/letsencrypt-auto-source/letsencrypt-auto b/letsencrypt-auto-source/letsencrypt-auto new file mode 100755 index 000000000..e0812501c --- /dev/null +++ b/letsencrypt-auto-source/letsencrypt-auto @@ -0,0 +1,1799 @@ +#!/bin/sh +# +# Download and run the latest release version of the Let's Encrypt client. +# +# NOTE: THIS SCRIPT IS AUTO-GENERATED AND SELF-UPDATING +# +# IF YOU WANT TO EDIT IT LOCALLY, *ALWAYS* RUN YOUR COPY WITH THE +# "--no-self-upgrade" FLAG +# +# IF YOU WANT TO SEND PULL REQUESTS, THE REAL SOURCE FOR THIS FILE IS +# letsencrypt-auto-source/letsencrypt-auto.template AND +# letsencrypt-auto-source/pieces/bootstrappers/* + +set -e # Work even if somebody does "sh thisscript.sh". + +# Note: you can set XDG_DATA_HOME or VENV_PATH before running this script, +# if you want to change where the virtual environment will be installed +XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} +VENV_NAME="letsencrypt" +VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} +VENV_BIN=${VENV_PATH}/bin +LE_AUTO_VERSION="0.3.0" + +# This script takes the same arguments as the main letsencrypt program, but it +# additionally responds to --verbose (more output) and --debug (allow support +# for experimental platforms) +for arg in "$@" ; do + # This first clause is redundant with the third, but hedging on portability + if [ "$arg" = "-v" ] || [ "$arg" = "--verbose" ] || echo "$arg" | grep -E -- "-v+$" ; then + VERBOSE=1 + elif [ "$arg" = "--no-self-upgrade" ] ; then + # Do not upgrade this script (also prevents client upgrades, because each + # copy of the script pins a hash of the python client) + NO_SELF_UPGRADE=1 + elif [ "$arg" = "--os-packages-only" ] ; then + OS_PACKAGES_ONLY=1 + elif [ "$arg" = "--debug" ]; then + DEBUG=1 + fi +done + +# letsencrypt-auto needs root access to bootstrap OS dependencies, and +# letsencrypt itself needs root access for almost all modes of operation +# The "normal" case is that sudo is used for the steps that need root, but +# this script *can* be run as root (not recommended), or fall back to using +# `su` +if test "`id -u`" -ne "0" ; then + if command -v sudo 1>/dev/null 2>&1; then + SUDO=sudo + else + echo \"sudo\" is not available, will use \"su\" for installation steps... + # Because the parameters in `su -c` has to be a string, + # we need properly escape it + su_sudo() { + args="" + # This `while` loop iterates over all parameters given to this function. + # For each parameter, all `'` will be replace by `'"'"'`, and the escaped string + # will be wrapped in a pair of `'`, then appended to `$args` string + # For example, `echo "It's only 1\$\!"` will be escaped to: + # 'echo' 'It'"'"'s only 1$!' + # │ │└┼┘│ + # │ │ │ └── `'s only 1$!'` the literal string + # │ │ └── `\"'\"` is a single quote (as a string) + # │ └── `'It'`, to be concatenated with the strings following it + # └── `echo` wrapped in a pair of `'`, it's totally fine for the shell command itself + while [ $# -ne 0 ]; do + args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " + shift + done + su root -c "$args" + } + SUDO=su_sudo + fi +else + SUDO= +fi + +ExperimentalBootstrap() { + # Arguments: Platform name, bootstrap function name + if [ "$DEBUG" = 1 ]; then + if [ "$2" != "" ]; then + echo "Bootstrapping dependencies via $1..." + $2 + fi + else + echo "WARNING: $1 support is very experimental at present..." + echo "if you would like to work on improving it, please ensure you have backups" + echo "and then run this script again with the --debug flag!" + exit 1 + fi +} + +DeterminePythonVersion() { + if command -v python2.7 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python2.7} + elif command -v python27 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python27} + elif command -v python2 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python2} + elif command -v python > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python} + else + echo "Cannot find any Pythons... please install one!" + exit 1 + fi + + PYVER=`"$LE_PYTHON" --version 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'` + if [ $PYVER -eq 26 ]; then + ExperimentalBootstrap "Python 2.6" + elif [ $PYVER -lt 26 ]; then + echo "You have an ancient version of Python entombed in your operating system..." + echo "This isn't going to work; you'll need at least version 2.6." + exit 1 + fi +} + +BootstrapDebCommon() { + # Current version tested with: + # + # - Ubuntu + # - 14.04 (x64) + # - 15.04 (x64) + # - Debian + # - 7.9 "wheezy" (x64) + # - sid (2015-10-21) (x64) + + # Past versions tested with: + # + # - Debian 8.0 "jessie" (x64) + # - Raspbian 7.8 (armhf) + + # Believed not to work: + # + # - Debian 6.0.10 "squeeze" (x64) + + $SUDO apt-get update || echo apt-get update hit problems but continuing anyway... + + # virtualenv binary can be found in different packages depending on + # distro version (#346) + + virtualenv= + if apt-cache show virtualenv > /dev/null 2>&1; then + virtualenv="virtualenv" + fi + + if apt-cache show python-virtualenv > /dev/null 2>&1; then + virtualenv="$virtualenv python-virtualenv" + fi + + augeas_pkg="libaugeas0 augeas-lenses" + AUGVERSION=`apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2` + + AddBackportRepo() { + # ARGS: + BACKPORT_NAME="$1" + BACKPORT_SOURCELINE="$2" + if ! grep -v -e ' *#' /etc/apt/sources.list | grep -q "$BACKPORT_NAME" ; then + # This can theoretically error if sources.list.d is empty, but in that case we don't care. + if ! grep -v -e ' *#' /etc/apt/sources.list.d/* 2>/dev/null | grep -q "$BACKPORT_NAME"; then + /bin/echo -n "Installing augeas from $BACKPORT_NAME in 3 seconds..." + sleep 1s + /bin/echo -ne "\e[0K\rInstalling augeas from $BACKPORT_NAME in 2 seconds..." + sleep 1s + /bin/echo -e "\e[0K\rInstalling augeas from $BACKPORT_NAME in 1 second ..." + sleep 1s + if echo $BACKPORT_NAME | grep -q wheezy ; then + /bin/echo '(Backports are only installed if explicitly requested via "apt-get install -t wheezy-backports")' + fi + + sudo sh -c "echo $BACKPORT_SOURCELINE >> /etc/apt/sources.list.d/$BACKPORT_NAME.list" + $SUDO apt-get update + fi + fi + $SUDO apt-get install -y --no-install-recommends -t "$BACKPORT_NAME" $augeas_pkg + augeas_pkg= + + } + + + if dpkg --compare-versions 1.0 gt "$AUGVERSION" ; then + if lsb_release -a | grep -q wheezy ; then + AddBackportRepo wheezy-backports "deb http://http.debian.net/debian wheezy-backports main" + elif lsb_release -a | grep -q precise ; then + # XXX add ARM case + AddBackportRepo precise-backports "deb http://archive.ubuntu.com/ubuntu precise-backports main restricted universe multiverse" + else + echo "No libaugeas0 version is available that's new enough to run the" + echo "Let's Encrypt apache plugin..." + fi + # XXX add a case for ubuntu PPAs + fi + + $SUDO apt-get install -y --no-install-recommends \ + python \ + python-dev \ + $virtualenv \ + gcc \ + dialog \ + $augeas_pkg \ + libssl-dev \ + libffi-dev \ + ca-certificates \ + + + + if ! command -v virtualenv > /dev/null ; then + echo Failed to install a working \"virtualenv\" command, exiting + exit 1 + fi +} + +BootstrapRpmCommon() { + # Tested with: + # - Fedora 22, 23 (x64) + # - Centos 7 (x64: on DigitalOcean droplet) + # - CentOS 7 Minimal install in a Hyper-V VM + + if type dnf 2>/dev/null + then + tool=dnf + elif type yum 2>/dev/null + then + tool=yum + + else + echo "Neither yum nor dnf found. Aborting bootstrap!" + exit 1 + fi + + # Some distros and older versions of current distros use a "python27" + # instead of "python" naming convention. Try both conventions. + if ! $SUDO $tool install -y \ + python \ + python-devel \ + python-virtualenv \ + python-tools \ + python-pip + then + if ! $SUDO $tool install -y \ + python27 \ + python27-devel \ + python27-virtualenv \ + python27-tools \ + python27-pip + then + echo "Could not install Python dependencies. Aborting bootstrap!" + exit 1 + fi + fi + + if ! $SUDO $tool install -y \ + gcc \ + dialog \ + augeas-libs \ + openssl \ + openssl-devel \ + libffi-devel \ + redhat-rpm-config \ + ca-certificates + then + echo "Could not install additional dependencies. Aborting bootstrap!" + exit 1 + fi + + + if $SUDO $tool list installed "httpd" >/dev/null 2>&1; then + if ! $SUDO $tool install -y mod_ssl + then + echo "Apache found, but mod_ssl could not be installed." + fi + fi +} + +BootstrapSuseCommon() { + # SLE12 don't have python-virtualenv + + $SUDO zypper -nq in -l \ + python \ + python-devel \ + python-virtualenv \ + gcc \ + dialog \ + augeas-lenses \ + libopenssl-devel \ + libffi-devel \ + ca-certificates +} + +BootstrapArchCommon() { + # Tested with: + # - ArchLinux (x86_64) + # + # "python-virtualenv" is Python3, but "python2-virtualenv" provides + # only "virtualenv2" binary, not "virtualenv" necessary in + # ./bootstrap/dev/_common_venv.sh + + deps=" + python2 + python-virtualenv + gcc + dialog + augeas + openssl + libffi + ca-certificates + pkg-config + " + + missing=$("$SUDO" pacman -T $deps) + + if [ "$missing" ]; then + "$SUDO" pacman -S --needed $missing + fi +} + +BootstrapGentooCommon() { + PACKAGES=" + dev-lang/python:2.7 + dev-python/virtualenv + dev-util/dialog + app-admin/augeas + dev-libs/openssl + dev-libs/libffi + app-misc/ca-certificates + virtual/pkgconfig" + + case "$PACKAGE_MANAGER" in + (paludis) + "$SUDO" cave resolve --keep-targets if-possible $PACKAGES -x + ;; + (pkgcore) + "$SUDO" pmerge --noreplace $PACKAGES + ;; + (portage|*) + "$SUDO" emerge --noreplace $PACKAGES + ;; + esac +} + +BootstrapFreeBsd() { + "$SUDO" pkg install -Ay \ + python \ + py27-virtualenv \ + augeas \ + libffi +} + +BootstrapMac() { + if ! hash brew 2>/dev/null; then + echo "Homebrew Not Installed\nDownloading..." + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" + fi + + brew install augeas + brew install dialog + + if ! hash pip 2>/dev/null; then + echo "pip Not Installed\nInstalling python from Homebrew..." + brew install python + fi + + if ! hash virtualenv 2>/dev/null; then + echo "virtualenv Not Installed\nInstalling with pip" + pip install virtualenv + fi +} + + +# Install required OS packages: +Bootstrap() { + if [ -f /etc/debian_version ]; then + echo "Bootstrapping dependencies for Debian-based OSes..." + BootstrapDebCommon + elif [ -f /etc/redhat-release ]; then + echo "Bootstrapping dependencies for RedHat-based OSes..." + BootstrapRpmCommon + elif `grep -q openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE-based OSes..." + BootstrapSuseCommon + elif [ -f /etc/arch-release ]; then + if [ "$DEBUG" = 1 ]; then + echo "Bootstrapping dependencies for Archlinux..." + BootstrapArchCommon + else + echo "Please use pacman to install letsencrypt packages:" + echo "# pacman -S letsencrypt letsencrypt-apache" + echo + echo "If you would like to use the virtualenv way, please run the script again with the" + echo "--debug flag." + exit 1 + fi + elif [ -f /etc/manjaro-release ]; then + ExperimentalBootstrap "Manjaro Linux" BootstrapArchCommon + elif [ -f /etc/gentoo-release ]; then + ExperimentalBootstrap "Gentoo" BootstrapGentooCommon + elif uname | grep -iq FreeBSD ; then + ExperimentalBootstrap "FreeBSD" BootstrapFreeBsd + elif uname | grep -iq Darwin ; then + ExperimentalBootstrap "Mac OS X" BootstrapMac + elif grep -iq "Amazon Linux" /etc/issue ; then + ExperimentalBootstrap "Amazon Linux" BootstrapRpmCommon + else + echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!" + echo + echo "You will need to bootstrap, configure virtualenv, and run a peep install manually." + echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites" + echo "for more info." + fi +} + +TempDir() { + mktemp -d 2>/dev/null || mktemp -d -t 'le' # Linux || OS X +} + + + +if [ "$NO_SELF_UPGRADE" = 1 ]; then + # Phase 2: Create venv, install LE, and run. + + if [ -f "$VENV_BIN/letsencrypt" ]; then + INSTALLED_VERSION=$("$VENV_BIN/letsencrypt" --version 2>&1 | cut -d " " -f 2) + else + INSTALLED_VERSION="none" + fi + if [ "$LE_AUTO_VERSION" != "$INSTALLED_VERSION" ]; then + echo "Creating virtual environment..." + DeterminePythonVersion + rm -rf "$VENV_PATH" + if [ "$VERBOSE" = 1 ]; then + virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" + else + virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" > /dev/null + fi + + echo "Installing Python packages..." + TEMP_DIR=$(TempDir) + # There is no $ interpolation due to quotes on starting heredoc delimiter. + # ------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/letsencrypt-auto-requirements.txt" +# This is the flattened list of packages letsencrypt-auto installs. To generate +# this, do `pip install -e acme -e . -e letsencrypt-apache`, `pip freeze`, +# and then gather the hashes. + +# sha256: wxZH7baf09RlqEfqMVfTe-0flfGXYLEaR6qRwEtmYxQ +# sha256: YrCJpVvh2JSc0rx-DfC9254Cj678jDIDjMhIYq791uQ +argparse==1.4.0 + +# sha256: U8HJ3bMEMVE-t_PN7wo-BrDxJSGIqqd0SvD1pM1F268 +# sha256: pWj0nfyhKo2fNwGHJX78WKOBCeHu5xTZKFYdegGKZPg +# sha256: gJxsqM-8ruv71DK0V2ABtA04_yRjdzy1dXfXXhoCC8M +# sha256: hs3KLNnLpBQiIwOQ3xff6qnzRKkR45dci-naV7NVSOk +# sha256: JLE9uErsOFyiPHuN7YPvi7QXe8GB0UdY-fl1vl0CDYY +# sha256: lprv_XwOCX9r4e_WgsFWriJlkaB5OpS2wtXkKT9MjU4 +# sha256: AA81jUsPokn-qrnBzn1bL-fgLnvfaAbCZBhQX8aF4mg +# sha256: qdhvRgu9g1ii1ROtd54_P8h447k6ALUAL66_YW_-a5w +# sha256: MSezqzPrI8ysBx-aCAJ0jlz3xcvNAkgrsGPjW0HbsLA +# sha256: 4rLUIjZGmkAiTTnntsYFdfOIsvQj81TH7pClt_WMgGU +# sha256: jC3Mr-6JsbQksL7GrS3ZYiyUnSAk6Sn12h7YAerHXx0 +# sha256: pN56TRGu1Ii6tPsU9JiFh6gpvs5aIEM_eA1uM7CAg8s +# sha256: XKj-MEJSZaSSdOSwITobyY9LE0Sa5elvmEdx5dg-WME +# sha256: pP04gC9Z5xTrqBoCT2LbcQsn2-J6fqEukRU3MnqoTTA +# sha256: hs1pErvIPpQF1Kc81_S07oNTZS0tvHyCAQbtW00bqzo +# sha256: jx0XfTZOo1kAQVriTKPkcb49UzTtBBkpQGjEn0WROZg +cffi==1.4.2 + +# sha256: O1CoPdWBSd_O6Yy2VlJl0QtT6cCivKfu73-19VJIkKc +ConfigArgParse==0.10.0 + +# sha256: ovVlB3DhyH-zNa8Zqbfrc_wFzPIhROto230AzSvLCQI +configobj==5.0.6 + +# sha256: 1U_hszrB4J8cEj4vl0948z6V1h1PSALdISIKXD6MEX0 +# sha256: B1X2aE4RhSAFs2MTdh7ctbqEOmTNAizhrC3L1JqTYG0 +# sha256: zjhNo4lZlluh90VKJfVp737yqxRd8ueiml4pS3TgRnc +# sha256: GvQDkV3LmWHDB2iuZRr6tpKC0dpaut-mN1IhrBGHdQM +# sha256: ag08d91PH-W8ZfJ--3fsjQSjiNpesl66DiBAwJgZ30o +# sha256: KdelgcO6_wTh--IAaltHjZ7cfPmib8ijWUkkf09lA3k +# sha256: IPAWEKpAh_bVadjMIMR4uB8DhIYnWqqx3Dx12VAsZ-A +# sha256: l9hGUIulDVomml82OK4cFmWbNTFaH0B_oVF2cH2j0Jc +# sha256: djfqRMLL1NsvLKccsmtmPRczORqnafi8g2xZVilbd5g +# sha256: gR-eqJVbPquzLgQGU0XDB4Ui5rPuPZLz0n08fNcWpjM +# sha256: DXCMjYz97Qm4fCoLqHY856ZjWG4EPmrEL9eDHpKQHLY +# sha256: Efnq11YqPgATWGytM5o_em9Yg8zhw7S5jhrGnft3p_Y +# sha256: dNhnm55-0ePs-wq1NNyTUruxz3PTYsmQkJTAlyivqJY +# sha256: z1Hd-123eBaiB1OKZgEUuC4w4IAD_uhJmwILi4SA2sU +# sha256: 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU +# sha256: dITvgYGUFB3_eUdf-74vd6-FHiw7v-Lk1ZEjEi-KTjM +# sha256: 7gLB6J7l7pUBV6VK1YTXN8Ec83putMCFPozz8n6WLcA +# sha256: pfGPaxhQpVVKV9v2YsrSUSpGBW5paHJqmFjngN1bnQo +# sha256: 26GA8xrb5xi6qdbPirY0hJSwlLK4GAL_8zvVDSfRPnM +# sha256: 5RinlLjzjoOC9_B3kUGBPOtIE6z9MRVBwNsOGJ69eN4 +# sha256: f1FFn4TWcERCdeYVg59FQsk1R6Euk4oKSQba_l994VM +cryptography==1.1.2 + +# sha256: JHXX_N31lR6S_1RpcnWIAt5SYL9Akxmp8ZNOa7yLHcc +# sha256: NZB977D5krdat3iPZf7cHPIP-iJojg5vbxKvwGs-pQE +enum34==1.1.2 + +# sha256: _1rZ4vjZ5dHou_vPR3IqtSfPDVHK7u2dptD0B5k4P94 +# sha256: 2Dzm3wsOpmGHAP4ds1NSY5Goo62ht6ulL-16Ydp3IDM +funcsigs==0.4 + +# sha256: my_FC9PEujBrllG2lBHvIgJtTYM1uTr8IhTO8SRs5wc +# sha256: FhmarZOLKQ9b4QV8Dh78ZUYik5HCPOphypQMEV99PTs +idna==2.0 + +# sha256: k1cSgAzkdgcB2JrWd2Zs1SaR_S9vCzQMi0I5o8F5iKU +# sha256: WjGCsyKnBlJcRigspvBk0noCz_vUSfn0dBbx3JaqcbA +ipaddress==1.0.16 + +# sha256: 6MFV_evZxLywgQtO0BrhmHVUse4DTddTLXuP2uOKYnQ +ndg-httpsclient==0.4.0 + +# sha256: HDW0rCBs7y0kgWyJ-Jzyid09OM98RJuz-re_bUPwGx8 +ordereddict==1.1 + +# sha256: OnTxAPkNZZGDFf5kkHca0gi8PxOv0y01_P5OjQs7gSs +# sha256: Paa-K-UG9ZzOMuGeMOIBBT4btNB-JWaJGOAPikmtQKs +parsedatetime==1.5 + +# sha256: Rsjbda51oFa9HMB_ohc0_i5gPRGgeDPswe63TDXHLgw +# sha256: 4hJ2JqkebIhduJZol22zECDwry2nKJJLVkgPx8zwlkk +pbr==1.8.1 + +# sha256: WE8LKfzF1SO0M8uJGLL8dNZ-MO4LRKlbrwMVKPQkYZ8 +# sha256: KMoLbp2Zqo3Chuh0ekRxNitpgSolKR3im2qNcKFUWg0 +# sha256: FnrV__UqZyxN3BwaCyUUbWgT67CKmqsKOsRfiltmnDs +# sha256: 5t6mFzqYhye7Ij00lzSa1c3vXAsoLv8tg-X5BlxT-F8 +# sha256: KvXgpKrWYEmVXQc0qk49yMqhep6vi0waJ6Xx7m5A9vw +# sha256: 2YhNwNwuVeJEjklXeNyYmcHIvzeusvQ0wb6nSvk8JoM +# sha256: 4nwv5t_Mhzi-PSxaAi94XrcpcQV-Gp4eNPunO86KcaY +# sha256: Za_W_syPOu0J7kvmNYO8jrRy8GzqpP4kxNHVoaPA4T8 +# sha256: uhxVj7_N-UUVwjlLEVXB3FbivCqcF9MDSYJ8ntimfkY +# sha256: upXqACLctk028MEzXAYF-uNb3z4P6o2S9dD2RWo15Vs +# sha256: QhtlkdFrUJqqjYwVgh1mu5TLSo3EOFytXFG4XUoJbYU +# sha256: MmswXL22-U2vv-LCaxHaiLCrB7igf4GIq511_wxuhBo +# sha256: mu3lsrb-RrN0jqjlIURDiQ0WNAJ77z0zt9rRZVaDAng +# sha256: c77R24lNGqnDx-YR0wLN6reuig3A7q92cnh42xrFzYc +# sha256: k1td1tVYr1EvQlAafAj0HXr_E5rxuzlZ2qOruFkjTWw +# sha256: TKARHPFX3MDy9poyPFtUeHGNaNRfyUNdhL4OwPGGIVs +# sha256: tvE8lTmKP88CJsTc-kSFYLpYZSWc2W7CgQZYZR6TIYk +# sha256: 7mvjDRY1u96kxDJdUH3IoNu95-HBmL1i3bn0MZi54hQ +# sha256: 36eGhYwmjX-74bYXXgAewCc418-uCnzne_m2Ua9nZyk +# sha256: qnf53nKvnBbMKIzUokz1iCQ4j1fXqB5ADEYWRXYphw4 +# sha256: 9QAJM1fQTagUDYeTLKwuVO9ZKlTKinQ6uyhQ9gwsIus +psutil==3.3.0 + +# sha256: YfnZnjzvZf6xv-Oi7vepPrk4GdNFv1S81C9OY9UgTa4 +# sha256: GAKm3TIEXkcqQZ2xRBrsq0adM-DSdJ4ZKr3sUhAXJK8 +# sha256: NQJc2UIsllBJEvBOLxX-eTkKhZe0MMLKXQU0z5MJ_6A +# sha256: L5btWgwynKFiMLMmyhK3Rh7I9l4L4-T5l1FvNr-Co0U +# sha256: KP7kQheZHPrZ5qC59-PyYEHiHryWYp6U5YXM0F1J-mU +# sha256: Mm56hUoX-rB2kSBHR2lfj2ktZ0WIo1XEQfsU9mC_Tmg +# sha256: zaWpBIVwnKZ5XIYFbD5f5yZgKLBeU_HVJ_35OmNlprg +# sha256: DLKhR0K1Q_3Wj5MaFM44KRhu0rGyJnoGeHOIyWst2b4 +# sha256: UZH_a5Em0sA53Yf4_wJb7SdLrwf6eK-kb1VrGtcmXW4 +# sha256: gyPgNjey0HLMcEEwC6xuxEjDwolQq0A3YDZ4jpoa9ik +# sha256: hTys2W0fcB3dZ6oD7MBfUYkBNbcmLpInEBEvEqLtKn8 +pyasn1==0.1.9 + +# sha256: eVm0p0q9wnsxL-0cIebK-TCc4LKeqGtZH9Lpns3yf3M +pycparser==2.14 + +# sha256: iORea7Jd_tJyoe8ucoRh1EtjTCzWiemJtuVqNJxaOuU +# sha256: 8KJgcNbbCIHei8x4RpNLfDyTDY-cedRYg-5ImEvA1nI +pyOpenSSL==0.15.1 + +# sha256: 7qMYNcVuIJavQ2OldFp4SHimHQQ-JH06bWoKMql0H1Y +# sha256: jfvGxFi42rocDzYgqMeACLMjomiye3NZ6SpK5BMl9TU +pyRFC3339==1.0 + +# sha256: Z9WdZs26jWJOA4m4eyqDoXbyHxaodVO1D1cDsj8pusI +python-augeas==0.5.0 + +# sha256: BOk_JJlcQ92Q8zjV2GXKcs4_taU1jU2qSWVXHbNfw-w +# sha256: Pm9ZP-rZj4pSa8PjBpM1MyNuM3KfVS9SiW6lBPVTE_o +python2-pythondialog==3.3.0 + +# sha256: Or5qbT_C-75MYBRCEfRdou2-MYKm9lEa9ru6BZix-ZI +# sha256: k575weEiTZgEBWial__PeCjFbRUXsx1zRkNWwfK3dp4 +# sha256: 6tSu-nAHJJ4F5RsBCVcZ1ajdlXYAifVzCqxWmLGTKRg +# sha256: PMoN8IvQ7ZhDI5BJTOPe0AP15mGqRgvnpzS__jWYNgU +# sha256: Pt5HDT0XujwHY436DRBFK8G25a0yYSemW6d-aq6xG-w +# sha256: aMR5ZPcYbuwwaxNilidyK5B5zURH7Z5eyuzU6shMpzQ +# sha256: 3V05kZUKrkCmyB3hV4lC5z1imAjO_FHRLNFXmA5s_Bg +# sha256: p3xSBiwH63x7MFRdvHPjKZW34Rfup1Axe1y1x6RhjxQ +# sha256: ga-a7EvJYKmgEnxIjxh3La5GNGiSM_BvZUQ-exHr61E +# sha256: 4Hmx2txcBiRswbtv4bI6ULHRFz8u3VEE79QLtzoo9AY +# sha256: -9JnRncsJMuTyLl8va1cueRshrvbG52KdD7gDi-x_F0 +# sha256: mSZu8wo35Dky3uwrfKc-g8jbw7n_cD7HPsprHa5r7-o +# sha256: i2zhyZOQl4O8luC0806iI7_3pN8skL25xODxrJKGieM +pytz==2015.7 + +# sha256: ET-7pVManjSUW302szoIToul0GZLcDyBp8Vy2RkZpbg +# sha256: xXeBXdAPE5QgP8ROuXlySwmPiCZKnviY7kW45enPWH8 +requests==2.9.1 + +# sha256: D_eMQD2bzPWkJabTGhKqa0fxwhyk3CVzp-LzKpczXrE +# sha256: EF-NaGFvgkjiS_DpNy7wTTzBAQTxmA9U1Xss5zpa1Wo +six==1.10.0 + +# sha256: aUkbUwUVfDxuDwSnAZhNaud_1yn8HJrNJQd_HfOFMms +# sha256: 619wCpv8lkILBVY1r5AC02YuQ9gMP_0x8iTCW8DV9GI +Werkzeug==0.11.3 + +# sha256: KCwRK1XdjjyGmjVx-GdnwVCrEoSprOK97CJsWSrK-Bo +zope.component==4.2.2 + +# sha256: 3HpZov2Rcw03kxMaXSYbKek-xOKpfxvEh86N7-4v54Y +zope.event==4.1.0 + +# sha256: 8HtjH3pgHNjL0zMtVPQxQscIioMpn4WTVvCNHU1CWbM +# sha256: 3lzKCDuUOdgAL7drvmtJmMWlpyH6sluEKYln8ALfTJQ +# sha256: Z4hBb36n9bipe-lIJTd6ol6L3HNGPge6r5hYsp5zcHc +# sha256: bzIw9yVFGCAeWjcIy7LemMhIME8G497Yv7OeWCXLouE +# sha256: X6V1pSQPBCAMMIhCfQ1Le3N_bpAYgYpR2ND5J6aiUXo +# sha256: UiGUrWpUVzXt11yKg_SNZdGvBk5DKn0yDWT1a6_BLpk +# sha256: 6Mey1AlD9xyZFIyX9myqf1E0FH9XQj-NtbSCUJnOmgk +# sha256: J5Ak8CCGAcPKqQfFOHbjetiGJffq8cs4QtvjYLIocBc +# sha256: LiIanux8zFiImieOoT3P7V75OdgLB4Gamos8scaBSE8 +# sha256: aRGJZUEOyG1E3GuQF-4929WC4MCr7vYrOhnb9sitEys +# sha256: 0E34aG7IZNDK3ozxmff4OuzUFhCaIINNVo-DEN7RLeo +# sha256: 51qUfhXul-fnHgLqMC_rL8YtOiu0Zov5377UOlBqx-c +# sha256: TkXSL7iDIipaufKCoRb-xe4ujRpWjM_2otdbvQ62vPw +# sha256: vOkzm7PHpV4IA7Y9IcWDno5Hm8hcSt9CrkFbcvlPrLI +# sha256: koE4NlJFoOiGmlmZ-8wqRUdaCm7VKklNYNvcVAM1_t0 +# sha256: DYQbobuEDuoOZIncXsr6YSVVSXH1O1rLh3ZEQeYbzro +# sha256: sJyMHUezUxxADgGVaX8UFKYyId5u9HhZik8UYPfZo5I +zope.interface==4.1.3 + +# sha256: QMIkIvGF3mcJhGLAKRX7n5EVIPjOrfLtklN6ePjbJes +# sha256: fNFWiij6VxfG5o7u3oNbtrYKQ4q9vhzOLATfxNlozvQ +acme==0.3.0 + +# sha256: qdnzpoRf_44QXKoktNoAKs2RBAxUta2Sr6GS0t_tAKo +# sha256: ELWJaHNvBZIqVPJYkla8yXLtXIuamqAf6f_VAFv16Uk +letsencrypt==0.3.0 + +# sha256: EypLpEw3-Tr8unw4aSFsHXgRiU8ZYLrJKOJohP2tC9M +# sha256: HYvP13GzA-DDJYwlfOoaraJO0zuYO48TCSAyTUAGCqA +letsencrypt-apache==0.3.0 + +# sha256: uDndLZwRfHAUMMFJlWkYpCOphjtIsJyQ4wpgE-fS9E8 +# sha256: j4MIDaoknQNsvM-4rlzG_wB7iNbZN1ITca-r57Gbrbw +mock==1.0.1 + +UNLIKELY_EOF + # ------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/peep.py" +#!/usr/bin/env python +"""peep ("prudently examine every package") verifies that packages conform to a +trusted, locally stored hash and only then installs them:: + + peep install -r requirements.txt + +This makes your deployments verifiably repeatable without having to maintain a +local PyPI mirror or use a vendor lib. Just update the version numbers and +hashes in requirements.txt, and you're all set. + +""" +# This is here so embedded copies of peep.py are MIT-compliant: +# Copyright (c) 2013 Erik Rose +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +from __future__ import print_function +try: + xrange = xrange +except NameError: + xrange = range +from base64 import urlsafe_b64encode, urlsafe_b64decode +from binascii import hexlify +import cgi +from collections import defaultdict +from functools import wraps +from hashlib import sha256 +from itertools import chain, islice +import mimetypes +from optparse import OptionParser +from os.path import join, basename, splitext, isdir +from pickle import dumps, loads +import re +import sys +from shutil import rmtree, copy +from sys import argv, exit +from tempfile import mkdtemp +import traceback +try: + from urllib2 import build_opener, HTTPHandler, HTTPSHandler, HTTPError +except ImportError: + from urllib.request import build_opener, HTTPHandler, HTTPSHandler + from urllib.error import HTTPError +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse # 3.4 +# TODO: Probably use six to make urllib stuff work across 2/3. + +from pkg_resources import require, VersionConflict, DistributionNotFound + +# We don't admit our dependency on pip in setup.py, lest a naive user simply +# say `pip install peep.tar.gz` and thus pull down an untrusted copy of pip +# from PyPI. Instead, we make sure it's installed and new enough here and spit +# out an error message if not: + + +def activate(specifier): + """Make a compatible version of pip importable. Raise a RuntimeError if we + couldn't.""" + try: + for distro in require(specifier): + distro.activate() + except (VersionConflict, DistributionNotFound): + raise RuntimeError('The installed version of pip is too old; peep ' + 'requires ' + specifier) + +# Before 0.6.2, the log module wasn't there, so some +# of our monkeypatching fails. It probably wouldn't be +# much work to support even earlier, though. +activate('pip>=0.6.2') + +import pip +from pip.commands.install import InstallCommand +try: + from pip.download import url_to_path # 1.5.6 +except ImportError: + try: + from pip.util import url_to_path # 0.7.0 + except ImportError: + from pip.util import url_to_filename as url_to_path # 0.6.2 +from pip.index import PackageFinder, Link +try: + from pip.log import logger +except ImportError: + from pip import logger # 6.0 +from pip.req import parse_requirements +try: + from pip.utils.ui import DownloadProgressBar, DownloadProgressSpinner +except ImportError: + class NullProgressBar(object): + def __init__(self, *args, **kwargs): + pass + + def iter(self, ret, *args, **kwargs): + return ret + + DownloadProgressBar = DownloadProgressSpinner = NullProgressBar + +__version__ = 3, 0, 0 + +try: + from pip.index import FormatControl # noqa + FORMAT_CONTROL_ARG = 'format_control' + + # The line-numbering bug will be fixed in pip 8. All 7.x releases had it. + PIP_MAJOR_VERSION = int(pip.__version__.split('.')[0]) + PIP_COUNTS_COMMENTS = PIP_MAJOR_VERSION >= 8 +except ImportError: + FORMAT_CONTROL_ARG = 'use_wheel' # pre-7 + PIP_COUNTS_COMMENTS = True + + +ITS_FINE_ITS_FINE = 0 +SOMETHING_WENT_WRONG = 1 +# "Traditional" for command-line errors according to optparse docs: +COMMAND_LINE_ERROR = 2 + +ARCHIVE_EXTENSIONS = ('.tar.bz2', '.tar.gz', '.tgz', '.tar', '.zip') + +MARKER = object() + + +class PipException(Exception): + """When I delegated to pip, it exited with an error.""" + + def __init__(self, error_code): + self.error_code = error_code + + +class UnsupportedRequirementError(Exception): + """An unsupported line was encountered in a requirements file.""" + + +class DownloadError(Exception): + def __init__(self, link, exc): + self.link = link + self.reason = str(exc) + + def __str__(self): + return 'Downloading %s failed: %s' % (self.link, self.reason) + + +def encoded_hash(sha): + """Return a short, 7-bit-safe representation of a hash. + + If you pass a sha256, this results in the hash algorithm that the Wheel + format (PEP 427) uses, except here it's intended to be run across the + downloaded archive before unpacking. + + """ + return urlsafe_b64encode(sha.digest()).decode('ascii').rstrip('=') + + +def path_and_line(req): + """Return the path and line number of the file from which an + InstallRequirement came. + + """ + path, line = (re.match(r'-r (.*) \(line (\d+)\)$', + req.comes_from).groups()) + return path, int(line) + + +def hashes_above(path, line_number): + """Yield hashes from contiguous comment lines before line ``line_number``. + + """ + def hash_lists(path): + """Yield lists of hashes appearing between non-comment lines. + + The lists will be in order of appearance and, for each non-empty + list, their place in the results will coincide with that of the + line number of the corresponding result from `parse_requirements` + (which changed in pip 7.0 to not count comments). + + """ + hashes = [] + with open(path) as file: + for lineno, line in enumerate(file, 1): + match = HASH_COMMENT_RE.match(line) + if match: # Accumulate this hash. + hashes.append(match.groupdict()['hash']) + if not IGNORED_LINE_RE.match(line): + yield hashes # Report hashes seen so far. + hashes = [] + elif PIP_COUNTS_COMMENTS: + # Comment: count as normal req but have no hashes. + yield [] + + return next(islice(hash_lists(path), line_number - 1, None)) + + +def run_pip(initial_args): + """Delegate to pip the given args (starting with the subcommand), and raise + ``PipException`` if something goes wrong.""" + status_code = pip.main(initial_args) + + # Clear out the registrations in the pip "logger" singleton. Otherwise, + # loggers keep getting appended to it with every run. Pip assumes only one + # command invocation will happen per interpreter lifetime. + logger.consumers = [] + + if status_code: + raise PipException(status_code) + + +def hash_of_file(path): + """Return the hash of a downloaded file.""" + with open(path, 'rb') as archive: + sha = sha256() + while True: + data = archive.read(2 ** 20) + if not data: + break + sha.update(data) + return encoded_hash(sha) + + +def is_git_sha(text): + """Return whether this is probably a git sha""" + # Handle both the full sha as well as the 7-character abbreviation + if len(text) in (40, 7): + try: + int(text, 16) + return True + except ValueError: + pass + return False + + +def filename_from_url(url): + parsed = urlparse(url) + path = parsed.path + return path.split('/')[-1] + + +def requirement_args(argv, want_paths=False, want_other=False): + """Return an iterable of filtered arguments. + + :arg argv: Arguments, starting after the subcommand + :arg want_paths: If True, the returned iterable includes the paths to any + requirements files following a ``-r`` or ``--requirement`` option. + :arg want_other: If True, the returned iterable includes the args that are + not a requirement-file path or a ``-r`` or ``--requirement`` flag. + + """ + was_r = False + for arg in argv: + # Allow for requirements files named "-r", don't freak out if there's a + # trailing "-r", etc. + if was_r: + if want_paths: + yield arg + was_r = False + elif arg in ['-r', '--requirement']: + was_r = True + else: + if want_other: + yield arg + +# any line that is a comment or just whitespace +IGNORED_LINE_RE = re.compile(r'^(\s*#.*)?\s*$') + +HASH_COMMENT_RE = re.compile( + r""" + \s*\#\s+ # Lines that start with a '#' + (?Psha256):\s+ # Hash type is hardcoded to be sha256 for now. + (?P[^\s]+) # Hashes can be anything except '#' or spaces. + \s* # Suck up whitespace before the comment or + # just trailing whitespace if there is no + # comment. Also strip trailing newlines. + (?:\#(?P.*))? # Comments can be anything after a whitespace+# + # and are optional. + $""", re.X) + + +def peep_hash(argv): + """Return the peep hash of one or more files, returning a shell status code + or raising a PipException. + + :arg argv: The commandline args, starting after the subcommand + + """ + parser = OptionParser( + usage='usage: %prog hash file [file ...]', + description='Print a peep hash line for one or more files: for ' + 'example, "# sha256: ' + 'oz42dZy6Gowxw8AelDtO4gRgTW_xPdooH484k7I5EOY".') + _, paths = parser.parse_args(args=argv) + if paths: + for path in paths: + print('# sha256:', hash_of_file(path)) + return ITS_FINE_ITS_FINE + else: + parser.print_usage() + return COMMAND_LINE_ERROR + + +class EmptyOptions(object): + """Fake optparse options for compatibility with pip<1.2 + + pip<1.2 had a bug in parse_requirements() in which the ``options`` kwarg + was required. We work around that by passing it a mock object. + + """ + default_vcs = None + skip_requirements_regex = None + isolated_mode = False + + +def memoize(func): + """Memoize a method that should return the same result every time on a + given instance. + + """ + @wraps(func) + def memoizer(self): + if not hasattr(self, '_cache'): + self._cache = {} + if func.__name__ not in self._cache: + self._cache[func.__name__] = func(self) + return self._cache[func.__name__] + return memoizer + + +def package_finder(argv): + """Return a PackageFinder respecting command-line options. + + :arg argv: Everything after the subcommand + + """ + # We instantiate an InstallCommand and then use some of its private + # machinery--its arg parser--for our own purposes, like a virus. This + # approach is portable across many pip versions, where more fine-grained + # ones are not. Ignoring options that don't exist on the parser (for + # instance, --use-wheel) gives us a straightforward method of backward + # compatibility. + try: + command = InstallCommand() + except TypeError: + # This is likely pip 1.3.0's "__init__() takes exactly 2 arguments (1 + # given)" error. In that version, InstallCommand takes a top=level + # parser passed in from outside. + from pip.baseparser import create_main_parser + command = InstallCommand(create_main_parser()) + # The downside is that it essentially ruins the InstallCommand class for + # further use. Calling out to pip.main() within the same interpreter, for + # example, would result in arguments parsed this time turning up there. + # Thus, we deepcopy the arg parser so we don't trash its singletons. Of + # course, deepcopy doesn't work on these objects, because they contain + # uncopyable regex patterns, so we pickle and unpickle instead. Fun! + options, _ = loads(dumps(command.parser)).parse_args(argv) + + # Carry over PackageFinder kwargs that have [about] the same names as + # options attr names: + possible_options = [ + 'find_links', + FORMAT_CONTROL_ARG, + ('allow_all_prereleases', 'pre'), + 'process_dependency_links' + ] + kwargs = {} + for option in possible_options: + kw, attr = option if isinstance(option, tuple) else (option, option) + value = getattr(options, attr, MARKER) + if value is not MARKER: + kwargs[kw] = value + + # Figure out index_urls: + index_urls = [options.index_url] + options.extra_index_urls + if options.no_index: + index_urls = [] + index_urls += getattr(options, 'mirrors', []) + + # If pip is new enough to have a PipSession, initialize one, since + # PackageFinder requires it: + if hasattr(command, '_build_session'): + kwargs['session'] = command._build_session(options) + + return PackageFinder(index_urls=index_urls, **kwargs) + + +class DownloadedReq(object): + """A wrapper around InstallRequirement which offers additional information + based on downloading and examining a corresponding package archive + + These are conceptually immutable, so we can get away with memoizing + expensive things. + + """ + def __init__(self, req, argv, finder): + """Download a requirement, compare its hashes, and return a subclass + of DownloadedReq depending on its state. + + :arg req: The InstallRequirement I am based on + :arg argv: The args, starting after the subcommand + + """ + self._req = req + self._argv = argv + self._finder = finder + + # We use a separate temp dir for each requirement so requirements + # (from different indices) that happen to have the same archive names + # don't overwrite each other, leading to a security hole in which the + # latter is a hash mismatch, the former has already passed the + # comparison, and the latter gets installed. + self._temp_path = mkdtemp(prefix='peep-') + # Think of DownloadedReq as a one-shot state machine. It's an abstract + # class that ratchets forward to being one of its own subclasses, + # depending on its package status. Then it doesn't move again. + self.__class__ = self._class() + + def dispose(self): + """Delete temp files and dirs I've made. Render myself useless. + + Do not call further methods on me after calling dispose(). + + """ + rmtree(self._temp_path) + + def _version(self): + """Deduce the version number of the downloaded package from its filename.""" + # TODO: Can we delete this method and just print the line from the + # reqs file verbatim instead? + def version_of_archive(filename, package_name): + # Since we know the project_name, we can strip that off the left, strip + # any archive extensions off the right, and take the rest as the + # version. + for ext in ARCHIVE_EXTENSIONS: + if filename.endswith(ext): + filename = filename[:-len(ext)] + break + # Handle github sha tarball downloads. + if is_git_sha(filename): + filename = package_name + '-' + filename + if not filename.lower().replace('_', '-').startswith(package_name.lower()): + # TODO: Should we replace runs of [^a-zA-Z0-9.], not just _, with -? + give_up(filename, package_name) + return filename[len(package_name) + 1:] # Strip off '-' before version. + + def version_of_wheel(filename, package_name): + # For Wheel files (http://legacy.python.org/dev/peps/pep-0427/#file- + # name-convention) we know the format bits are '-' separated. + whl_package_name, version, _rest = filename.split('-', 2) + # Do the alteration to package_name from PEP 427: + our_package_name = re.sub(r'[^\w\d.]+', '_', package_name, re.UNICODE) + if whl_package_name != our_package_name: + give_up(filename, whl_package_name) + return version + + def give_up(filename, package_name): + raise RuntimeError("The archive '%s' didn't start with the package name " + "'%s', so I couldn't figure out the version number. " + "My bad; improve me." % + (filename, package_name)) + + get_version = (version_of_wheel + if self._downloaded_filename().endswith('.whl') + else version_of_archive) + return get_version(self._downloaded_filename(), self._project_name()) + + def _is_always_unsatisfied(self): + """Returns whether this requirement is always unsatisfied + + This would happen in cases where we can't determine the version + from the filename. + + """ + # If this is a github sha tarball, then it is always unsatisfied + # because the url has a commit sha in it and not the version + # number. + url = self._url() + if url: + filename = filename_from_url(url) + if filename.endswith(ARCHIVE_EXTENSIONS): + filename, ext = splitext(filename) + if is_git_sha(filename): + return True + return False + + @memoize # Avoid hitting the file[cache] over and over. + def _expected_hashes(self): + """Return a list of known-good hashes for this package.""" + return hashes_above(*path_and_line(self._req)) + + def _download(self, link): + """Download a file, and return its name within my temp dir. + + This does no verification of HTTPS certs, but our checking hashes + makes that largely unimportant. It would be nice to be able to use the + requests lib, which can verify certs, but it is guaranteed to be + available only in pip >= 1.5. + + This also drops support for proxies and basic auth, though those could + be added back in. + + """ + # Based on pip 1.4.1's URLOpener but with cert verification removed + def opener(is_https): + if is_https: + opener = build_opener(HTTPSHandler()) + # Strip out HTTPHandler to prevent MITM spoof: + for handler in opener.handlers: + if isinstance(handler, HTTPHandler): + opener.handlers.remove(handler) + else: + opener = build_opener() + return opener + + # Descended from unpack_http_url() in pip 1.4.1 + def best_filename(link, response): + """Return the most informative possible filename for a download, + ideally with a proper extension. + + """ + content_type = response.info().get('content-type', '') + filename = link.filename # fallback + # Have a look at the Content-Disposition header for a better guess: + content_disposition = response.info().get('content-disposition') + if content_disposition: + type, params = cgi.parse_header(content_disposition) + # We use ``or`` here because we don't want to use an "empty" value + # from the filename param: + filename = params.get('filename') or filename + ext = splitext(filename)[1] + if not ext: + ext = mimetypes.guess_extension(content_type) + if ext: + filename += ext + if not ext and link.url != response.geturl(): + ext = splitext(response.geturl())[1] + if ext: + filename += ext + return filename + + # Descended from _download_url() in pip 1.4.1 + def pipe_to_file(response, path, size=0): + """Pull the data off an HTTP response, shove it in a new file, and + show progress. + + :arg response: A file-like object to read from + :arg path: The path of the new file + :arg size: The expected size, in bytes, of the download. 0 for + unknown or to suppress progress indication (as for cached + downloads) + + """ + def response_chunks(chunk_size): + while True: + chunk = response.read(chunk_size) + if not chunk: + break + yield chunk + + print('Downloading %s%s...' % ( + self._req.req, + (' (%sK)' % (size / 1000)) if size > 1000 else '')) + progress_indicator = (DownloadProgressBar(max=size).iter if size + else DownloadProgressSpinner().iter) + with open(path, 'wb') as file: + for chunk in progress_indicator(response_chunks(4096), 4096): + file.write(chunk) + + url = link.url.split('#', 1)[0] + try: + response = opener(urlparse(url).scheme != 'http').open(url) + except (HTTPError, IOError) as exc: + raise DownloadError(link, exc) + filename = best_filename(link, response) + try: + size = int(response.headers['content-length']) + except (ValueError, KeyError, TypeError): + size = 0 + pipe_to_file(response, join(self._temp_path, filename), size=size) + return filename + + # Based on req_set.prepare_files() in pip bb2a8428d4aebc8d313d05d590f386fa3f0bbd0f + @memoize # Avoid re-downloading. + def _downloaded_filename(self): + """Download the package's archive if necessary, and return its + filename. + + --no-deps is implied, as we have reimplemented the bits that would + ordinarily do dependency resolution. + + """ + # Peep doesn't support requirements that don't come down as a single + # file, because it can't hash them. Thus, it doesn't support editable + # requirements, because pip itself doesn't support editable + # requirements except for "local projects or a VCS url". Nor does it + # support VCS requirements yet, because we haven't yet come up with a + # portable, deterministic way to hash them. In summary, all we support + # is == requirements and tarballs/zips/etc. + + # TODO: Stop on reqs that are editable or aren't ==. + + # If the requirement isn't already specified as a URL, get a URL + # from an index: + link = self._link() or self._finder.find_requirement(self._req, upgrade=False) + + if link: + lower_scheme = link.scheme.lower() # pip lower()s it for some reason. + if lower_scheme == 'http' or lower_scheme == 'https': + file_path = self._download(link) + return basename(file_path) + elif lower_scheme == 'file': + # The following is inspired by pip's unpack_file_url(): + link_path = url_to_path(link.url_without_fragment) + if isdir(link_path): + raise UnsupportedRequirementError( + "%s: %s is a directory. So that it can compute " + "a hash, peep supports only filesystem paths which " + "point to files" % + (self._req, link.url_without_fragment)) + else: + copy(link_path, self._temp_path) + return basename(link_path) + else: + raise UnsupportedRequirementError( + "%s: The download link, %s, would not result in a file " + "that can be hashed. Peep supports only == requirements, " + "file:// URLs pointing to files (not folders), and " + "http:// and https:// URLs pointing to tarballs, zips, " + "etc." % (self._req, link.url)) + else: + raise UnsupportedRequirementError( + "%s: couldn't determine where to download this requirement from." + % (self._req,)) + + def install(self): + """Install the package I represent, without dependencies. + + Obey typical pip-install options passed in on the command line. + + """ + other_args = list(requirement_args(self._argv, want_other=True)) + archive_path = join(self._temp_path, self._downloaded_filename()) + # -U so it installs whether pip deems the requirement "satisfied" or + # not. This is necessary for GitHub-sourced zips, which change without + # their version numbers changing. + run_pip(['install'] + other_args + ['--no-deps', '-U', archive_path]) + + @memoize + def _actual_hash(self): + """Download the package's archive if necessary, and return its hash.""" + return hash_of_file(join(self._temp_path, self._downloaded_filename())) + + def _project_name(self): + """Return the inner Requirement's "unsafe name". + + Raise ValueError if there is no name. + + """ + name = getattr(self._req.req, 'project_name', '') + if name: + return name + raise ValueError('Requirement has no project_name.') + + def _name(self): + return self._req.name + + def _link(self): + try: + return self._req.link + except AttributeError: + # The link attribute isn't available prior to pip 6.1.0, so fall + # back to the now deprecated 'url' attribute. + return Link(self._req.url) if self._req.url else None + + def _url(self): + link = self._link() + return link.url if link else None + + @memoize # Avoid re-running expensive check_if_exists(). + def _is_satisfied(self): + self._req.check_if_exists() + return (self._req.satisfied_by and + not self._is_always_unsatisfied()) + + def _class(self): + """Return the class I should be, spanning a continuum of goodness.""" + try: + self._project_name() + except ValueError: + return MalformedReq + if self._is_satisfied(): + return SatisfiedReq + if not self._expected_hashes(): + return MissingReq + if self._actual_hash() not in self._expected_hashes(): + return MismatchedReq + return InstallableReq + + @classmethod + def foot(cls): + """Return the text to be printed once, after all of the errors from + classes of my type are printed. + + """ + return '' + + +class MalformedReq(DownloadedReq): + """A requirement whose package name could not be determined""" + + @classmethod + def head(cls): + return 'The following requirements could not be processed:\n' + + def error(self): + return '* Unable to determine package name from URL %s; add #egg=' % self._url() + + +class MissingReq(DownloadedReq): + """A requirement for which no hashes were specified in the requirements file""" + + @classmethod + def head(cls): + return ('The following packages had no hashes specified in the requirements file, which\n' + 'leaves them open to tampering. Vet these packages to your satisfaction, then\n' + 'add these "sha256" lines like so:\n\n') + + def error(self): + if self._url(): + # _url() always contains an #egg= part, or this would be a + # MalformedRequest. + line = self._url() + else: + line = '%s==%s' % (self._name(), self._version()) + return '# sha256: %s\n%s\n' % (self._actual_hash(), line) + + +class MismatchedReq(DownloadedReq): + """A requirement for which the downloaded file didn't match any of my hashes.""" + @classmethod + def head(cls): + return ("THE FOLLOWING PACKAGES DIDN'T MATCH THE HASHES SPECIFIED IN THE REQUIREMENTS\n" + "FILE. If you have updated the package versions, update the hashes. If not,\n" + "freak out, because someone has tampered with the packages.\n\n") + + def error(self): + preamble = ' %s: expected' % self._project_name() + if len(self._expected_hashes()) > 1: + preamble += ' one of' + padding = '\n' + ' ' * (len(preamble) + 1) + return '%s %s\n%s got %s' % (preamble, + padding.join(self._expected_hashes()), + ' ' * (len(preamble) - 4), + self._actual_hash()) + + @classmethod + def foot(cls): + return '\n' + + +class SatisfiedReq(DownloadedReq): + """A requirement which turned out to be already installed""" + + @classmethod + def head(cls): + return ("These packages were already installed, so we didn't need to download or build\n" + "them again. If you installed them with peep in the first place, you should be\n" + "safe. If not, uninstall them, then re-attempt your install with peep.\n") + + def error(self): + return ' %s' % (self._req,) + + +class InstallableReq(DownloadedReq): + """A requirement whose hash matched and can be safely installed""" + + +# DownloadedReq subclasses that indicate an error that should keep us from +# going forward with installation, in the order in which their errors should +# be reported: +ERROR_CLASSES = [MismatchedReq, MissingReq, MalformedReq] + + +def bucket(things, key): + """Return a map of key -> list of things.""" + ret = defaultdict(list) + for thing in things: + ret[key(thing)].append(thing) + return ret + + +def first_every_last(iterable, first, every, last): + """Execute something before the first item of iter, something else for each + item, and a third thing after the last. + + If there are no items in the iterable, don't execute anything. + + """ + did_first = False + for item in iterable: + if not did_first: + did_first = True + first(item) + every(item) + if did_first: + last(item) + + +def _parse_requirements(path, finder): + try: + # list() so the generator that is parse_requirements() actually runs + # far enough to report a TypeError + return list(parse_requirements( + path, options=EmptyOptions(), finder=finder)) + except TypeError: + # session is a required kwarg as of pip 6.0 and will raise + # a TypeError if missing. It needs to be a PipSession instance, + # but in older versions we can't import it from pip.download + # (nor do we need it at all) so we only import it in this except block + from pip.download import PipSession + return list(parse_requirements( + path, options=EmptyOptions(), session=PipSession(), finder=finder)) + + +def downloaded_reqs_from_path(path, argv): + """Return a list of DownloadedReqs representing the requirements parsed + out of a given requirements file. + + :arg path: The path to the requirements file + :arg argv: The commandline args, starting after the subcommand + + """ + finder = package_finder(argv) + return [DownloadedReq(req, argv, finder) for req in + _parse_requirements(path, finder)] + + +def peep_install(argv): + """Perform the ``peep install`` subcommand, returning a shell status code + or raising a PipException. + + :arg argv: The commandline args, starting after the subcommand + + """ + output = [] + out = output.append + reqs = [] + try: + req_paths = list(requirement_args(argv, want_paths=True)) + if not req_paths: + out("You have to specify one or more requirements files with the -r option, because\n" + "otherwise there's nowhere for peep to look up the hashes.\n") + return COMMAND_LINE_ERROR + + # We're a "peep install" command, and we have some requirement paths. + reqs = list(chain.from_iterable( + downloaded_reqs_from_path(path, argv) + for path in req_paths)) + buckets = bucket(reqs, lambda r: r.__class__) + + # Skip a line after pip's "Cleaning up..." so the important stuff + # stands out: + if any(buckets[b] for b in ERROR_CLASSES): + out('\n') + + printers = (lambda r: out(r.head()), + lambda r: out(r.error() + '\n'), + lambda r: out(r.foot())) + for c in ERROR_CLASSES: + first_every_last(buckets[c], *printers) + + if any(buckets[b] for b in ERROR_CLASSES): + out('-------------------------------\n' + 'Not proceeding to installation.\n') + return SOMETHING_WENT_WRONG + else: + for req in buckets[InstallableReq]: + req.install() + + first_every_last(buckets[SatisfiedReq], *printers) + + return ITS_FINE_ITS_FINE + except (UnsupportedRequirementError, DownloadError) as exc: + out(str(exc)) + return SOMETHING_WENT_WRONG + finally: + for req in reqs: + req.dispose() + print(''.join(output)) + + +def peep_port(paths): + """Convert a peep requirements file to one compatble with pip-8 hashing. + + Loses comments and tromps on URLs, so the result will need a little manual + massaging, but the hard part--the hash conversion--is done for you. + + """ + if not paths: + print('Please specify one or more requirements files so I have ' + 'something to port.\n') + return COMMAND_LINE_ERROR + for req in chain.from_iterable( + _parse_requirements(path, package_finder(argv)) for path in paths): + hashes = [hexlify(urlsafe_b64decode((hash + '=').encode('ascii'))).decode('ascii') + for hash in hashes_above(*path_and_line(req))] + if not hashes: + print(req.req) + elif len(hashes) == 1: + print('%s --hash=sha256:%s' % (req.req, hashes[0])) + else: + print('%s' % req.req, end='') + for hash in hashes: + print(' \\') + print(' --hash=sha256:%s' % hash, end='') + print() + + +def main(): + """Be the top-level entrypoint. Return a shell status code.""" + commands = {'hash': peep_hash, + 'install': peep_install, + 'port': peep_port} + try: + if len(argv) >= 2 and argv[1] in commands: + return commands[argv[1]](argv[2:]) + else: + # Fall through to top-level pip main() for everything else: + return pip.main() + except PipException as exc: + return exc.error_code + + +def exception_handler(exc_type, exc_value, exc_tb): + print('Oh no! Peep had a problem while trying to do stuff. Please write up a bug report') + print('with the specifics so we can fix it:') + print() + print('https://github.com/erikrose/peep/issues/new') + print() + print('Here are some particulars you can copy and paste into the bug report:') + print() + print('---') + print('peep:', repr(__version__)) + print('python:', repr(sys.version)) + print('pip:', repr(getattr(pip, '__version__', 'no __version__ attr'))) + print('Command line: ', repr(sys.argv)) + print( + ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))) + print('---') + + +if __name__ == '__main__': + try: + exit(main()) + except Exception: + exception_handler(*sys.exc_info()) + exit(SOMETHING_WENT_WRONG) + +UNLIKELY_EOF + # ------------------------------------------------------------------------- + set +e + PEEP_OUT=`"$VENV_BIN/python" "$TEMP_DIR/peep.py" install -r "$TEMP_DIR/letsencrypt-auto-requirements.txt"` + PEEP_STATUS=$? + set -e + rm -rf "$TEMP_DIR" + if [ "$PEEP_STATUS" != 0 ]; then + # Report error. (Otherwise, be quiet.) + echo "Had a problem while downloading and verifying Python packages:" + echo "$PEEP_OUT" + exit 1 + fi + fi + echo "Requesting root privileges to run letsencrypt..." + echo " " $SUDO "$VENV_BIN/letsencrypt" "$@" + $SUDO "$VENV_BIN/letsencrypt" "$@" +else + # Phase 1: Upgrade letsencrypt-auto if neceesary, then self-invoke. + # + # Each phase checks the version of only the thing it is responsible for + # upgrading. Phase 1 checks the version of the latest release of + # letsencrypt-auto (which is always the same as that of the letsencrypt + # package). Phase 2 checks the version of the locally installed letsencrypt. + + if [ ! -f "$VENV_BIN/letsencrypt" ]; then + # If it looks like we've never bootstrapped before, bootstrap: + Bootstrap + fi + if [ "$OS_PACKAGES_ONLY" = 1 ]; then + echo "OS packages installed." + exit 0 + fi + + echo "Checking for new version..." + TEMP_DIR=$(TempDir) + # --------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/fetch.py" +"""Do downloading and JSON parsing without additional dependencies. :: + + # Print latest released version of LE to stdout: + python fetch.py --latest-version + + # Download letsencrypt-auto script from git tag v1.2.3 into the folder I'm + # in, and make sure its signature verifies: + python fetch.py --le-auto-script v1.2.3 + +On failure, return non-zero. + +""" +from distutils.version import LooseVersion +from json import loads +from os import devnull, environ +from os.path import dirname, join +import re +from subprocess import check_call, CalledProcessError +from sys import argv, exit +from urllib2 import build_opener, HTTPHandler, HTTPSHandler, HTTPError + +PUBLIC_KEY = environ.get('LE_AUTO_PUBLIC_KEY', """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6MR8W/galdxnpGqBsYbq +OzQb2eyW15YFjDDEMI0ZOzt8f504obNs920lDnpPD2/KqgsfjOgw2K7xWDJIj/18 +xUvWPk3LDkrnokNiRkA3KOx3W6fHycKL+zID7zy+xZYBuh2fLyQtWV1VGQ45iNRp +9+Zo7rH86cdfgkdnWTlNSHyTLW9NbXvyv/E12bppPcEvgCTAQXgnDVJ0/sqmeiij +n9tTFh03aM+R2V/21h8aTraAS24qiPCz6gkmYGC8yr6mglcnNoYbsLNYZ69zF1XH +cXPduCPdPdfLlzVlKK1/U7hkA28eG3BIAMh6uJYBRJTpiGgaGdPd7YekUB8S6cy+ +CQIDAQAB +-----END PUBLIC KEY----- +""") + +class ExpectedError(Exception): + """A novice-readable exception that also carries the original exception for + debugging""" + + +class HttpsGetter(object): + def __init__(self): + """Build an HTTPS opener.""" + # Based on pip 1.4.1's URLOpener + # This verifies certs on only Python >=2.7.9. + self._opener = build_opener(HTTPSHandler()) + # Strip out HTTPHandler to prevent MITM spoof: + for handler in self._opener.handlers: + if isinstance(handler, HTTPHandler): + self._opener.handlers.remove(handler) + + def get(self, url): + """Return the document contents pointed to by an HTTPS URL. + + If something goes wrong (404, timeout, etc.), raise ExpectedError. + + """ + try: + return self._opener.open(url).read() + except (HTTPError, IOError) as exc: + raise ExpectedError("Couldn't download %s." % url, exc) + + +def write(contents, dir, filename): + """Write something to a file in a certain directory.""" + with open(join(dir, filename), 'w') as file: + file.write(contents) + + +def latest_stable_version(get): + """Return the latest stable release of letsencrypt.""" + metadata = loads(get( + environ.get('LE_AUTO_JSON_URL', + 'https://pypi.python.org/pypi/letsencrypt/json'))) + # metadata['info']['version'] actually returns the latest of any kind of + # release release, contrary to https://wiki.python.org/moin/PyPIJSON. + # The regex is a sufficient regex for picking out prereleases for most + # packages, LE included. + return str(max(LooseVersion(r) for r + in metadata['releases'].iterkeys() + if re.match('^[0-9.]+$', r))) + + +def verified_new_le_auto(get, tag, temp_dir): + """Return the path to a verified, up-to-date letsencrypt-auto script. + + If the download's signature does not verify or something else goes wrong + with the verification process, raise ExpectedError. + + """ + le_auto_dir = environ.get( + 'LE_AUTO_DIR_TEMPLATE', + 'https://raw.githubusercontent.com/letsencrypt/letsencrypt/%s/' + 'letsencrypt-auto-source/') % tag + write(get(le_auto_dir + 'letsencrypt-auto'), temp_dir, 'letsencrypt-auto') + write(get(le_auto_dir + 'letsencrypt-auto.sig'), temp_dir, 'letsencrypt-auto.sig') + write(PUBLIC_KEY, temp_dir, 'public_key.pem') + try: + with open(devnull, 'w') as dev_null: + check_call(['openssl', 'dgst', '-sha256', '-verify', + join(temp_dir, 'public_key.pem'), + '-signature', + join(temp_dir, 'letsencrypt-auto.sig'), + join(temp_dir, 'letsencrypt-auto')], + stdout=dev_null, + stderr=dev_null) + except CalledProcessError as exc: + raise ExpectedError("Couldn't verify signature of downloaded " + "letsencrypt-auto.", exc) + + +def main(): + get = HttpsGetter().get + flag = argv[1] + try: + if flag == '--latest-version': + print latest_stable_version(get) + elif flag == '--le-auto-script': + tag = argv[2] + verified_new_le_auto(get, tag, dirname(argv[0])) + except ExpectedError as exc: + print exc.args[0], exc.args[1] + return 1 + else: + return 0 + + +if __name__ == '__main__': + exit(main()) + +UNLIKELY_EOF + # --------------------------------------------------------------------------- + DeterminePythonVersion + REMOTE_VERSION=`"$LE_PYTHON" "$TEMP_DIR/fetch.py" --latest-version` + if [ "$LE_AUTO_VERSION" != "$REMOTE_VERSION" ]; then + echo "Upgrading letsencrypt-auto $LE_AUTO_VERSION to $REMOTE_VERSION..." + + # Now we drop into Python so we don't have to install even more + # dependencies (curl, etc.), for better flow control, and for the option of + # future Windows compatibility. + "$LE_PYTHON" "$TEMP_DIR/fetch.py" --le-auto-script "v$REMOTE_VERSION" + + # Install new copy of letsencrypt-auto. This preserves permissions and + # ownership from the old copy. + # TODO: Deal with quotes in pathnames. + echo "Replacing letsencrypt-auto..." + echo " " $SUDO cp "$TEMP_DIR/letsencrypt-auto" "$0" + $SUDO cp "$TEMP_DIR/letsencrypt-auto" "$0" + # TODO: Clean up temp dir safely, even if it has quotes in its path. + rm -rf "$TEMP_DIR" + fi # should upgrade + "$0" --no-self-upgrade "$@" +fi diff --git a/letsencrypt-auto-source/letsencrypt-auto.sig b/letsencrypt-auto-source/letsencrypt-auto.sig new file mode 100644 index 000000000..4bb5f97ea --- /dev/null +++ b/letsencrypt-auto-source/letsencrypt-auto.sig @@ -0,0 +1,3 @@ +ßûf3oÐP*ëëŒûÅ]ÈJé3©%ÒuE’ª‘M½æuþ`újR:y¿‡ÌŇiënF¡ N¡|8tuØÏlÆÀ8A jâñ’ +Æ]¢÷IÍì\+Qã2„O¯ÕßF$³v4ËHÆh1ÿ½}EI¼crW)v_㕬cŒ‹ðÓé +Iërò—Â|ԥţ$O5Vè ç ®„²OžýqVÎÄ®ŒS®éªó$Kê¶åb3êh¢Â¨éz¥¹ÂwglH†W+Ë& X}ç<ödðïxkSZ3Qf§Û°¶ŠÍòŸ3ý•aµ¨Æ®…·7˜Õ÷´pÕf \ No newline at end of file diff --git a/letsencrypt-auto-source/letsencrypt-auto.template b/letsencrypt-auto-source/letsencrypt-auto.template new file mode 100755 index 000000000..8118a5f69 --- /dev/null +++ b/letsencrypt-auto-source/letsencrypt-auto.template @@ -0,0 +1,262 @@ +#!/bin/sh +# +# Download and run the latest release version of the Let's Encrypt client. +# +# NOTE: THIS SCRIPT IS AUTO-GENERATED AND SELF-UPDATING +# +# IF YOU WANT TO EDIT IT LOCALLY, *ALWAYS* RUN YOUR COPY WITH THE +# "--no-self-upgrade" FLAG +# +# IF YOU WANT TO SEND PULL REQUESTS, THE REAL SOURCE FOR THIS FILE IS +# letsencrypt-auto-source/letsencrypt-auto.template AND +# letsencrypt-auto-source/pieces/bootstrappers/* + +set -e # Work even if somebody does "sh thisscript.sh". + +# Note: you can set XDG_DATA_HOME or VENV_PATH before running this script, +# if you want to change where the virtual environment will be installed +XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} +VENV_NAME="letsencrypt" +VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} +VENV_BIN=${VENV_PATH}/bin +LE_AUTO_VERSION="{{ LE_AUTO_VERSION }}" + +# This script takes the same arguments as the main letsencrypt program, but it +# additionally responds to --verbose (more output) and --debug (allow support +# for experimental platforms) +for arg in "$@" ; do + # This first clause is redundant with the third, but hedging on portability + if [ "$arg" = "-v" ] || [ "$arg" = "--verbose" ] || echo "$arg" | grep -E -- "-v+$" ; then + VERBOSE=1 + elif [ "$arg" = "--no-self-upgrade" ] ; then + # Do not upgrade this script (also prevents client upgrades, because each + # copy of the script pins a hash of the python client) + NO_SELF_UPGRADE=1 + elif [ "$arg" = "--os-packages-only" ] ; then + OS_PACKAGES_ONLY=1 + elif [ "$arg" = "--debug" ]; then + DEBUG=1 + fi +done + +# letsencrypt-auto needs root access to bootstrap OS dependencies, and +# letsencrypt itself needs root access for almost all modes of operation +# The "normal" case is that sudo is used for the steps that need root, but +# this script *can* be run as root (not recommended), or fall back to using +# `su` +if test "`id -u`" -ne "0" ; then + if command -v sudo 1>/dev/null 2>&1; then + SUDO=sudo + else + echo \"sudo\" is not available, will use \"su\" for installation steps... + # Because the parameters in `su -c` has to be a string, + # we need properly escape it + su_sudo() { + args="" + # This `while` loop iterates over all parameters given to this function. + # For each parameter, all `'` will be replace by `'"'"'`, and the escaped string + # will be wrapped in a pair of `'`, then appended to `$args` string + # For example, `echo "It's only 1\$\!"` will be escaped to: + # 'echo' 'It'"'"'s only 1$!' + # │ │└┼┘│ + # │ │ │ └── `'s only 1$!'` the literal string + # │ │ └── `\"'\"` is a single quote (as a string) + # │ └── `'It'`, to be concatenated with the strings following it + # └── `echo` wrapped in a pair of `'`, it's totally fine for the shell command itself + while [ $# -ne 0 ]; do + args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " + shift + done + su root -c "$args" + } + SUDO=su_sudo + fi +else + SUDO= +fi + +ExperimentalBootstrap() { + # Arguments: Platform name, bootstrap function name + if [ "$DEBUG" = 1 ]; then + if [ "$2" != "" ]; then + echo "Bootstrapping dependencies via $1..." + $2 + fi + else + echo "WARNING: $1 support is very experimental at present..." + echo "if you would like to work on improving it, please ensure you have backups" + echo "and then run this script again with the --debug flag!" + exit 1 + fi +} + +DeterminePythonVersion() { + if command -v python2.7 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python2.7} + elif command -v python27 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python27} + elif command -v python2 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python2} + elif command -v python > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python} + else + echo "Cannot find any Pythons... please install one!" + exit 1 + fi + + PYVER=`"$LE_PYTHON" --version 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'` + if [ $PYVER -eq 26 ]; then + ExperimentalBootstrap "Python 2.6" + elif [ $PYVER -lt 26 ]; then + echo "You have an ancient version of Python entombed in your operating system..." + echo "This isn't going to work; you'll need at least version 2.6." + exit 1 + fi +} + +{{ bootstrappers/deb_common.sh }} +{{ bootstrappers/rpm_common.sh }} +{{ bootstrappers/suse_common.sh }} +{{ bootstrappers/arch_common.sh }} +{{ bootstrappers/gentoo_common.sh }} +{{ bootstrappers/free_bsd.sh }} +{{ bootstrappers/mac.sh }} + +# Install required OS packages: +Bootstrap() { + if [ -f /etc/debian_version ]; then + echo "Bootstrapping dependencies for Debian-based OSes..." + BootstrapDebCommon + elif [ -f /etc/redhat-release ]; then + echo "Bootstrapping dependencies for RedHat-based OSes..." + BootstrapRpmCommon + elif `grep -q openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE-based OSes..." + BootstrapSuseCommon + elif [ -f /etc/arch-release ]; then + if [ "$DEBUG" = 1 ]; then + echo "Bootstrapping dependencies for Archlinux..." + BootstrapArchCommon + else + echo "Please use pacman to install letsencrypt packages:" + echo "# pacman -S letsencrypt letsencrypt-apache" + echo + echo "If you would like to use the virtualenv way, please run the script again with the" + echo "--debug flag." + exit 1 + fi + elif [ -f /etc/manjaro-release ]; then + ExperimentalBootstrap "Manjaro Linux" BootstrapArchCommon + elif [ -f /etc/gentoo-release ]; then + ExperimentalBootstrap "Gentoo" BootstrapGentooCommon + elif uname | grep -iq FreeBSD ; then + ExperimentalBootstrap "FreeBSD" BootstrapFreeBsd + elif uname | grep -iq Darwin ; then + ExperimentalBootstrap "Mac OS X" BootstrapMac + elif grep -iq "Amazon Linux" /etc/issue ; then + ExperimentalBootstrap "Amazon Linux" BootstrapRpmCommon + else + echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!" + echo + echo "You will need to bootstrap, configure virtualenv, and run a peep install manually." + echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites" + echo "for more info." + fi +} + +TempDir() { + mktemp -d 2>/dev/null || mktemp -d -t 'le' # Linux || OS X +} + + + +if [ "$NO_SELF_UPGRADE" = 1 ]; then + # Phase 2: Create venv, install LE, and run. + + if [ -f "$VENV_BIN/letsencrypt" ]; then + INSTALLED_VERSION=$("$VENV_BIN/letsencrypt" --version 2>&1 | cut -d " " -f 2) + else + INSTALLED_VERSION="none" + fi + if [ "$LE_AUTO_VERSION" != "$INSTALLED_VERSION" ]; then + echo "Creating virtual environment..." + DeterminePythonVersion + rm -rf "$VENV_PATH" + if [ "$VERBOSE" = 1 ]; then + virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" + else + virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" > /dev/null + fi + + echo "Installing Python packages..." + TEMP_DIR=$(TempDir) + # There is no $ interpolation due to quotes on starting heredoc delimiter. + # ------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/letsencrypt-auto-requirements.txt" +{{ letsencrypt-auto-requirements.txt }} +UNLIKELY_EOF + # ------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/peep.py" +{{ peep.py }} +UNLIKELY_EOF + # ------------------------------------------------------------------------- + set +e + PEEP_OUT=`"$VENV_BIN/python" "$TEMP_DIR/peep.py" install -r "$TEMP_DIR/letsencrypt-auto-requirements.txt"` + PEEP_STATUS=$? + set -e + rm -rf "$TEMP_DIR" + if [ "$PEEP_STATUS" != 0 ]; then + # Report error. (Otherwise, be quiet.) + echo "Had a problem while downloading and verifying Python packages:" + echo "$PEEP_OUT" + exit 1 + fi + fi + echo "Requesting root privileges to run letsencrypt..." + echo " " $SUDO "$VENV_BIN/letsencrypt" "$@" + $SUDO "$VENV_BIN/letsencrypt" "$@" +else + # Phase 1: Upgrade letsencrypt-auto if neceesary, then self-invoke. + # + # Each phase checks the version of only the thing it is responsible for + # upgrading. Phase 1 checks the version of the latest release of + # letsencrypt-auto (which is always the same as that of the letsencrypt + # package). Phase 2 checks the version of the locally installed letsencrypt. + + if [ ! -f "$VENV_BIN/letsencrypt" ]; then + # If it looks like we've never bootstrapped before, bootstrap: + Bootstrap + fi + if [ "$OS_PACKAGES_ONLY" = 1 ]; then + echo "OS packages installed." + exit 0 + fi + + echo "Checking for new version..." + TEMP_DIR=$(TempDir) + # --------------------------------------------------------------------------- + cat << "UNLIKELY_EOF" > "$TEMP_DIR/fetch.py" +{{ fetch.py }} +UNLIKELY_EOF + # --------------------------------------------------------------------------- + DeterminePythonVersion + REMOTE_VERSION=`"$LE_PYTHON" "$TEMP_DIR/fetch.py" --latest-version` + if [ "$LE_AUTO_VERSION" != "$REMOTE_VERSION" ]; then + echo "Upgrading letsencrypt-auto $LE_AUTO_VERSION to $REMOTE_VERSION..." + + # Now we drop into Python so we don't have to install even more + # dependencies (curl, etc.), for better flow control, and for the option of + # future Windows compatibility. + "$LE_PYTHON" "$TEMP_DIR/fetch.py" --le-auto-script "v$REMOTE_VERSION" + + # Install new copy of letsencrypt-auto. This preserves permissions and + # ownership from the old copy. + # TODO: Deal with quotes in pathnames. + echo "Replacing letsencrypt-auto..." + echo " " $SUDO cp "$TEMP_DIR/letsencrypt-auto" "$0" + $SUDO cp "$TEMP_DIR/letsencrypt-auto" "$0" + # TODO: Clean up temp dir safely, even if it has quotes in its path. + rm -rf "$TEMP_DIR" + fi # should upgrade + "$0" --no-self-upgrade "$@" +fi diff --git a/letsencrypt-auto-source/pieces/bootstrappers/arch_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/arch_common.sh new file mode 100755 index 000000000..ad752634c --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/arch_common.sh @@ -0,0 +1,26 @@ +BootstrapArchCommon() { + # Tested with: + # - ArchLinux (x86_64) + # + # "python-virtualenv" is Python3, but "python2-virtualenv" provides + # only "virtualenv2" binary, not "virtualenv" necessary in + # ./bootstrap/dev/_common_venv.sh + + deps=" + python2 + python-virtualenv + gcc + dialog + augeas + openssl + libffi + ca-certificates + pkg-config + " + + missing=$("$SUDO" pacman -T $deps) + + if [ "$missing" ]; then + "$SUDO" pacman -S --needed $missing + fi +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh new file mode 100644 index 000000000..b45e43ba9 --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh @@ -0,0 +1,94 @@ +BootstrapDebCommon() { + # Current version tested with: + # + # - Ubuntu + # - 14.04 (x64) + # - 15.04 (x64) + # - Debian + # - 7.9 "wheezy" (x64) + # - sid (2015-10-21) (x64) + + # Past versions tested with: + # + # - Debian 8.0 "jessie" (x64) + # - Raspbian 7.8 (armhf) + + # Believed not to work: + # + # - Debian 6.0.10 "squeeze" (x64) + + $SUDO apt-get update || echo apt-get update hit problems but continuing anyway... + + # virtualenv binary can be found in different packages depending on + # distro version (#346) + + virtualenv= + if apt-cache show virtualenv > /dev/null 2>&1; then + virtualenv="virtualenv" + fi + + if apt-cache show python-virtualenv > /dev/null 2>&1; then + virtualenv="$virtualenv python-virtualenv" + fi + + augeas_pkg="libaugeas0 augeas-lenses" + AUGVERSION=`apt-cache show --no-all-versions libaugeas0 | grep ^Version: | cut -d" " -f2` + + AddBackportRepo() { + # ARGS: + BACKPORT_NAME="$1" + BACKPORT_SOURCELINE="$2" + if ! grep -v -e ' *#' /etc/apt/sources.list | grep -q "$BACKPORT_NAME" ; then + # This can theoretically error if sources.list.d is empty, but in that case we don't care. + if ! grep -v -e ' *#' /etc/apt/sources.list.d/* 2>/dev/null | grep -q "$BACKPORT_NAME"; then + /bin/echo -n "Installing augeas from $BACKPORT_NAME in 3 seconds..." + sleep 1s + /bin/echo -ne "\e[0K\rInstalling augeas from $BACKPORT_NAME in 2 seconds..." + sleep 1s + /bin/echo -e "\e[0K\rInstalling augeas from $BACKPORT_NAME in 1 second ..." + sleep 1s + if echo $BACKPORT_NAME | grep -q wheezy ; then + /bin/echo '(Backports are only installed if explicitly requested via "apt-get install -t wheezy-backports")' + fi + + sudo sh -c "echo $BACKPORT_SOURCELINE >> /etc/apt/sources.list.d/$BACKPORT_NAME.list" + $SUDO apt-get update + fi + fi + $SUDO apt-get install -y --no-install-recommends -t "$BACKPORT_NAME" $augeas_pkg + augeas_pkg= + + } + + + if dpkg --compare-versions 1.0 gt "$AUGVERSION" ; then + if lsb_release -a | grep -q wheezy ; then + AddBackportRepo wheezy-backports "deb http://http.debian.net/debian wheezy-backports main" + elif lsb_release -a | grep -q precise ; then + # XXX add ARM case + AddBackportRepo precise-backports "deb http://archive.ubuntu.com/ubuntu precise-backports main restricted universe multiverse" + else + echo "No libaugeas0 version is available that's new enough to run the" + echo "Let's Encrypt apache plugin..." + fi + # XXX add a case for ubuntu PPAs + fi + + $SUDO apt-get install -y --no-install-recommends \ + python \ + python-dev \ + $virtualenv \ + gcc \ + dialog \ + $augeas_pkg \ + libssl-dev \ + libffi-dev \ + ca-certificates \ + + + + if ! command -v virtualenv > /dev/null ; then + echo Failed to install a working \"virtualenv\" command, exiting + exit 1 + fi +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/free_bsd.sh b/letsencrypt-auto-source/pieces/bootstrappers/free_bsd.sh new file mode 100755 index 000000000..c9abd22eb --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/free_bsd.sh @@ -0,0 +1,7 @@ +BootstrapFreeBsd() { + "$SUDO" pkg install -Ay \ + python \ + py27-virtualenv \ + augeas \ + libffi +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/gentoo_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/gentoo_common.sh new file mode 100755 index 000000000..418d9b9d9 --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/gentoo_common.sh @@ -0,0 +1,23 @@ +BootstrapGentooCommon() { + PACKAGES=" + dev-lang/python:2.7 + dev-python/virtualenv + dev-util/dialog + app-admin/augeas + dev-libs/openssl + dev-libs/libffi + app-misc/ca-certificates + virtual/pkgconfig" + + case "$PACKAGE_MANAGER" in + (paludis) + "$SUDO" cave resolve --keep-targets if-possible $PACKAGES -x + ;; + (pkgcore) + "$SUDO" pmerge --noreplace $PACKAGES + ;; + (portage|*) + "$SUDO" emerge --noreplace $PACKAGES + ;; + esac +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/mac.sh b/letsencrypt-auto-source/pieces/bootstrappers/mac.sh new file mode 100755 index 000000000..9318d18c8 --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/mac.sh @@ -0,0 +1,19 @@ +BootstrapMac() { + if ! hash brew 2>/dev/null; then + echo "Homebrew Not Installed\nDownloading..." + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" + fi + + brew install augeas + brew install dialog + + if ! hash pip 2>/dev/null; then + echo "pip Not Installed\nInstalling python from Homebrew..." + brew install python + fi + + if ! hash virtualenv 2>/dev/null; then + echo "virtualenv Not Installed\nInstalling with pip" + pip install virtualenv + fi +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/rpm_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/rpm_common.sh new file mode 100755 index 000000000..68a11a531 --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/rpm_common.sh @@ -0,0 +1,61 @@ +BootstrapRpmCommon() { + # Tested with: + # - Fedora 22, 23 (x64) + # - Centos 7 (x64: on DigitalOcean droplet) + # - CentOS 7 Minimal install in a Hyper-V VM + + if type dnf 2>/dev/null + then + tool=dnf + elif type yum 2>/dev/null + then + tool=yum + + else + echo "Neither yum nor dnf found. Aborting bootstrap!" + exit 1 + fi + + # Some distros and older versions of current distros use a "python27" + # instead of "python" naming convention. Try both conventions. + if ! $SUDO $tool install -y \ + python \ + python-devel \ + python-virtualenv \ + python-tools \ + python-pip + then + if ! $SUDO $tool install -y \ + python27 \ + python27-devel \ + python27-virtualenv \ + python27-tools \ + python27-pip + then + echo "Could not install Python dependencies. Aborting bootstrap!" + exit 1 + fi + fi + + if ! $SUDO $tool install -y \ + gcc \ + dialog \ + augeas-libs \ + openssl \ + openssl-devel \ + libffi-devel \ + redhat-rpm-config \ + ca-certificates + then + echo "Could not install additional dependencies. Aborting bootstrap!" + exit 1 + fi + + + if $SUDO $tool list installed "httpd" >/dev/null 2>&1; then + if ! $SUDO $tool install -y mod_ssl + then + echo "Apache found, but mod_ssl could not be installed." + fi + fi +} diff --git a/letsencrypt-auto-source/pieces/bootstrappers/suse_common.sh b/letsencrypt-auto-source/pieces/bootstrappers/suse_common.sh new file mode 100755 index 000000000..46c60f992 --- /dev/null +++ b/letsencrypt-auto-source/pieces/bootstrappers/suse_common.sh @@ -0,0 +1,14 @@ +BootstrapSuseCommon() { + # SLE12 don't have python-virtualenv + + $SUDO zypper -nq in -l \ + python \ + python-devel \ + python-virtualenv \ + gcc \ + dialog \ + augeas-lenses \ + libopenssl-devel \ + libffi-devel \ + ca-certificates +} diff --git a/letsencrypt-auto-source/pieces/fetch.py b/letsencrypt-auto-source/pieces/fetch.py new file mode 100644 index 000000000..39ff7777c --- /dev/null +++ b/letsencrypt-auto-source/pieces/fetch.py @@ -0,0 +1,126 @@ +"""Do downloading and JSON parsing without additional dependencies. :: + + # Print latest released version of LE to stdout: + python fetch.py --latest-version + + # Download letsencrypt-auto script from git tag v1.2.3 into the folder I'm + # in, and make sure its signature verifies: + python fetch.py --le-auto-script v1.2.3 + +On failure, return non-zero. + +""" +from distutils.version import LooseVersion +from json import loads +from os import devnull, environ +from os.path import dirname, join +import re +from subprocess import check_call, CalledProcessError +from sys import argv, exit +from urllib2 import build_opener, HTTPHandler, HTTPSHandler, HTTPError + +PUBLIC_KEY = environ.get('LE_AUTO_PUBLIC_KEY', """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6MR8W/galdxnpGqBsYbq +OzQb2eyW15YFjDDEMI0ZOzt8f504obNs920lDnpPD2/KqgsfjOgw2K7xWDJIj/18 +xUvWPk3LDkrnokNiRkA3KOx3W6fHycKL+zID7zy+xZYBuh2fLyQtWV1VGQ45iNRp +9+Zo7rH86cdfgkdnWTlNSHyTLW9NbXvyv/E12bppPcEvgCTAQXgnDVJ0/sqmeiij +n9tTFh03aM+R2V/21h8aTraAS24qiPCz6gkmYGC8yr6mglcnNoYbsLNYZ69zF1XH +cXPduCPdPdfLlzVlKK1/U7hkA28eG3BIAMh6uJYBRJTpiGgaGdPd7YekUB8S6cy+ +CQIDAQAB +-----END PUBLIC KEY----- +""") + +class ExpectedError(Exception): + """A novice-readable exception that also carries the original exception for + debugging""" + + +class HttpsGetter(object): + def __init__(self): + """Build an HTTPS opener.""" + # Based on pip 1.4.1's URLOpener + # This verifies certs on only Python >=2.7.9. + self._opener = build_opener(HTTPSHandler()) + # Strip out HTTPHandler to prevent MITM spoof: + for handler in self._opener.handlers: + if isinstance(handler, HTTPHandler): + self._opener.handlers.remove(handler) + + def get(self, url): + """Return the document contents pointed to by an HTTPS URL. + + If something goes wrong (404, timeout, etc.), raise ExpectedError. + + """ + try: + return self._opener.open(url).read() + except (HTTPError, IOError) as exc: + raise ExpectedError("Couldn't download %s." % url, exc) + + +def write(contents, dir, filename): + """Write something to a file in a certain directory.""" + with open(join(dir, filename), 'w') as file: + file.write(contents) + + +def latest_stable_version(get): + """Return the latest stable release of letsencrypt.""" + metadata = loads(get( + environ.get('LE_AUTO_JSON_URL', + 'https://pypi.python.org/pypi/letsencrypt/json'))) + # metadata['info']['version'] actually returns the latest of any kind of + # release release, contrary to https://wiki.python.org/moin/PyPIJSON. + # The regex is a sufficient regex for picking out prereleases for most + # packages, LE included. + return str(max(LooseVersion(r) for r + in metadata['releases'].iterkeys() + if re.match('^[0-9.]+$', r))) + + +def verified_new_le_auto(get, tag, temp_dir): + """Return the path to a verified, up-to-date letsencrypt-auto script. + + If the download's signature does not verify or something else goes wrong + with the verification process, raise ExpectedError. + + """ + le_auto_dir = environ.get( + 'LE_AUTO_DIR_TEMPLATE', + 'https://raw.githubusercontent.com/letsencrypt/letsencrypt/%s/' + 'letsencrypt-auto-source/') % tag + write(get(le_auto_dir + 'letsencrypt-auto'), temp_dir, 'letsencrypt-auto') + write(get(le_auto_dir + 'letsencrypt-auto.sig'), temp_dir, 'letsencrypt-auto.sig') + write(PUBLIC_KEY, temp_dir, 'public_key.pem') + try: + with open(devnull, 'w') as dev_null: + check_call(['openssl', 'dgst', '-sha256', '-verify', + join(temp_dir, 'public_key.pem'), + '-signature', + join(temp_dir, 'letsencrypt-auto.sig'), + join(temp_dir, 'letsencrypt-auto')], + stdout=dev_null, + stderr=dev_null) + except CalledProcessError as exc: + raise ExpectedError("Couldn't verify signature of downloaded " + "letsencrypt-auto.", exc) + + +def main(): + get = HttpsGetter().get + flag = argv[1] + try: + if flag == '--latest-version': + print latest_stable_version(get) + elif flag == '--le-auto-script': + tag = argv[2] + verified_new_le_auto(get, tag, dirname(argv[0])) + except ExpectedError as exc: + print exc.args[0], exc.args[1] + return 1 + else: + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt b/letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt new file mode 100644 index 000000000..739e19f20 --- /dev/null +++ b/letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt @@ -0,0 +1,206 @@ +# This is the flattened list of packages letsencrypt-auto installs. To generate +# this, do `pip install -e acme -e . -e letsencrypt-apache`, `pip freeze`, +# and then gather the hashes. + +# sha256: wxZH7baf09RlqEfqMVfTe-0flfGXYLEaR6qRwEtmYxQ +# sha256: YrCJpVvh2JSc0rx-DfC9254Cj678jDIDjMhIYq791uQ +argparse==1.4.0 + +# sha256: U8HJ3bMEMVE-t_PN7wo-BrDxJSGIqqd0SvD1pM1F268 +# sha256: pWj0nfyhKo2fNwGHJX78WKOBCeHu5xTZKFYdegGKZPg +# sha256: gJxsqM-8ruv71DK0V2ABtA04_yRjdzy1dXfXXhoCC8M +# sha256: hs3KLNnLpBQiIwOQ3xff6qnzRKkR45dci-naV7NVSOk +# sha256: JLE9uErsOFyiPHuN7YPvi7QXe8GB0UdY-fl1vl0CDYY +# sha256: lprv_XwOCX9r4e_WgsFWriJlkaB5OpS2wtXkKT9MjU4 +# sha256: AA81jUsPokn-qrnBzn1bL-fgLnvfaAbCZBhQX8aF4mg +# sha256: qdhvRgu9g1ii1ROtd54_P8h447k6ALUAL66_YW_-a5w +# sha256: MSezqzPrI8ysBx-aCAJ0jlz3xcvNAkgrsGPjW0HbsLA +# sha256: 4rLUIjZGmkAiTTnntsYFdfOIsvQj81TH7pClt_WMgGU +# sha256: jC3Mr-6JsbQksL7GrS3ZYiyUnSAk6Sn12h7YAerHXx0 +# sha256: pN56TRGu1Ii6tPsU9JiFh6gpvs5aIEM_eA1uM7CAg8s +# sha256: XKj-MEJSZaSSdOSwITobyY9LE0Sa5elvmEdx5dg-WME +# sha256: pP04gC9Z5xTrqBoCT2LbcQsn2-J6fqEukRU3MnqoTTA +# sha256: hs1pErvIPpQF1Kc81_S07oNTZS0tvHyCAQbtW00bqzo +# sha256: jx0XfTZOo1kAQVriTKPkcb49UzTtBBkpQGjEn0WROZg +cffi==1.4.2 + +# sha256: O1CoPdWBSd_O6Yy2VlJl0QtT6cCivKfu73-19VJIkKc +ConfigArgParse==0.10.0 + +# sha256: ovVlB3DhyH-zNa8Zqbfrc_wFzPIhROto230AzSvLCQI +configobj==5.0.6 + +# sha256: 1U_hszrB4J8cEj4vl0948z6V1h1PSALdISIKXD6MEX0 +# sha256: B1X2aE4RhSAFs2MTdh7ctbqEOmTNAizhrC3L1JqTYG0 +# sha256: zjhNo4lZlluh90VKJfVp737yqxRd8ueiml4pS3TgRnc +# sha256: GvQDkV3LmWHDB2iuZRr6tpKC0dpaut-mN1IhrBGHdQM +# sha256: ag08d91PH-W8ZfJ--3fsjQSjiNpesl66DiBAwJgZ30o +# sha256: KdelgcO6_wTh--IAaltHjZ7cfPmib8ijWUkkf09lA3k +# sha256: IPAWEKpAh_bVadjMIMR4uB8DhIYnWqqx3Dx12VAsZ-A +# sha256: l9hGUIulDVomml82OK4cFmWbNTFaH0B_oVF2cH2j0Jc +# sha256: djfqRMLL1NsvLKccsmtmPRczORqnafi8g2xZVilbd5g +# sha256: gR-eqJVbPquzLgQGU0XDB4Ui5rPuPZLz0n08fNcWpjM +# sha256: DXCMjYz97Qm4fCoLqHY856ZjWG4EPmrEL9eDHpKQHLY +# sha256: Efnq11YqPgATWGytM5o_em9Yg8zhw7S5jhrGnft3p_Y +# sha256: dNhnm55-0ePs-wq1NNyTUruxz3PTYsmQkJTAlyivqJY +# sha256: z1Hd-123eBaiB1OKZgEUuC4w4IAD_uhJmwILi4SA2sU +# sha256: 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU +# sha256: dITvgYGUFB3_eUdf-74vd6-FHiw7v-Lk1ZEjEi-KTjM +# sha256: 7gLB6J7l7pUBV6VK1YTXN8Ec83putMCFPozz8n6WLcA +# sha256: pfGPaxhQpVVKV9v2YsrSUSpGBW5paHJqmFjngN1bnQo +# sha256: 26GA8xrb5xi6qdbPirY0hJSwlLK4GAL_8zvVDSfRPnM +# sha256: 5RinlLjzjoOC9_B3kUGBPOtIE6z9MRVBwNsOGJ69eN4 +# sha256: f1FFn4TWcERCdeYVg59FQsk1R6Euk4oKSQba_l994VM +cryptography==1.1.2 + +# sha256: JHXX_N31lR6S_1RpcnWIAt5SYL9Akxmp8ZNOa7yLHcc +# sha256: NZB977D5krdat3iPZf7cHPIP-iJojg5vbxKvwGs-pQE +enum34==1.1.2 + +# sha256: _1rZ4vjZ5dHou_vPR3IqtSfPDVHK7u2dptD0B5k4P94 +# sha256: 2Dzm3wsOpmGHAP4ds1NSY5Goo62ht6ulL-16Ydp3IDM +funcsigs==0.4 + +# sha256: my_FC9PEujBrllG2lBHvIgJtTYM1uTr8IhTO8SRs5wc +# sha256: FhmarZOLKQ9b4QV8Dh78ZUYik5HCPOphypQMEV99PTs +idna==2.0 + +# sha256: k1cSgAzkdgcB2JrWd2Zs1SaR_S9vCzQMi0I5o8F5iKU +# sha256: WjGCsyKnBlJcRigspvBk0noCz_vUSfn0dBbx3JaqcbA +ipaddress==1.0.16 + +# sha256: 6MFV_evZxLywgQtO0BrhmHVUse4DTddTLXuP2uOKYnQ +ndg-httpsclient==0.4.0 + +# sha256: HDW0rCBs7y0kgWyJ-Jzyid09OM98RJuz-re_bUPwGx8 +ordereddict==1.1 + +# sha256: OnTxAPkNZZGDFf5kkHca0gi8PxOv0y01_P5OjQs7gSs +# sha256: Paa-K-UG9ZzOMuGeMOIBBT4btNB-JWaJGOAPikmtQKs +parsedatetime==1.5 + +# sha256: Rsjbda51oFa9HMB_ohc0_i5gPRGgeDPswe63TDXHLgw +# sha256: 4hJ2JqkebIhduJZol22zECDwry2nKJJLVkgPx8zwlkk +pbr==1.8.1 + +# sha256: WE8LKfzF1SO0M8uJGLL8dNZ-MO4LRKlbrwMVKPQkYZ8 +# sha256: KMoLbp2Zqo3Chuh0ekRxNitpgSolKR3im2qNcKFUWg0 +# sha256: FnrV__UqZyxN3BwaCyUUbWgT67CKmqsKOsRfiltmnDs +# sha256: 5t6mFzqYhye7Ij00lzSa1c3vXAsoLv8tg-X5BlxT-F8 +# sha256: KvXgpKrWYEmVXQc0qk49yMqhep6vi0waJ6Xx7m5A9vw +# sha256: 2YhNwNwuVeJEjklXeNyYmcHIvzeusvQ0wb6nSvk8JoM +# sha256: 4nwv5t_Mhzi-PSxaAi94XrcpcQV-Gp4eNPunO86KcaY +# sha256: Za_W_syPOu0J7kvmNYO8jrRy8GzqpP4kxNHVoaPA4T8 +# sha256: uhxVj7_N-UUVwjlLEVXB3FbivCqcF9MDSYJ8ntimfkY +# sha256: upXqACLctk028MEzXAYF-uNb3z4P6o2S9dD2RWo15Vs +# sha256: QhtlkdFrUJqqjYwVgh1mu5TLSo3EOFytXFG4XUoJbYU +# sha256: MmswXL22-U2vv-LCaxHaiLCrB7igf4GIq511_wxuhBo +# sha256: mu3lsrb-RrN0jqjlIURDiQ0WNAJ77z0zt9rRZVaDAng +# sha256: c77R24lNGqnDx-YR0wLN6reuig3A7q92cnh42xrFzYc +# sha256: k1td1tVYr1EvQlAafAj0HXr_E5rxuzlZ2qOruFkjTWw +# sha256: TKARHPFX3MDy9poyPFtUeHGNaNRfyUNdhL4OwPGGIVs +# sha256: tvE8lTmKP88CJsTc-kSFYLpYZSWc2W7CgQZYZR6TIYk +# sha256: 7mvjDRY1u96kxDJdUH3IoNu95-HBmL1i3bn0MZi54hQ +# sha256: 36eGhYwmjX-74bYXXgAewCc418-uCnzne_m2Ua9nZyk +# sha256: qnf53nKvnBbMKIzUokz1iCQ4j1fXqB5ADEYWRXYphw4 +# sha256: 9QAJM1fQTagUDYeTLKwuVO9ZKlTKinQ6uyhQ9gwsIus +psutil==3.3.0 + +# sha256: YfnZnjzvZf6xv-Oi7vepPrk4GdNFv1S81C9OY9UgTa4 +# sha256: GAKm3TIEXkcqQZ2xRBrsq0adM-DSdJ4ZKr3sUhAXJK8 +# sha256: NQJc2UIsllBJEvBOLxX-eTkKhZe0MMLKXQU0z5MJ_6A +# sha256: L5btWgwynKFiMLMmyhK3Rh7I9l4L4-T5l1FvNr-Co0U +# sha256: KP7kQheZHPrZ5qC59-PyYEHiHryWYp6U5YXM0F1J-mU +# sha256: Mm56hUoX-rB2kSBHR2lfj2ktZ0WIo1XEQfsU9mC_Tmg +# sha256: zaWpBIVwnKZ5XIYFbD5f5yZgKLBeU_HVJ_35OmNlprg +# sha256: DLKhR0K1Q_3Wj5MaFM44KRhu0rGyJnoGeHOIyWst2b4 +# sha256: UZH_a5Em0sA53Yf4_wJb7SdLrwf6eK-kb1VrGtcmXW4 +# sha256: gyPgNjey0HLMcEEwC6xuxEjDwolQq0A3YDZ4jpoa9ik +# sha256: hTys2W0fcB3dZ6oD7MBfUYkBNbcmLpInEBEvEqLtKn8 +pyasn1==0.1.9 + +# sha256: eVm0p0q9wnsxL-0cIebK-TCc4LKeqGtZH9Lpns3yf3M +pycparser==2.14 + +# sha256: iORea7Jd_tJyoe8ucoRh1EtjTCzWiemJtuVqNJxaOuU +# sha256: 8KJgcNbbCIHei8x4RpNLfDyTDY-cedRYg-5ImEvA1nI +pyOpenSSL==0.15.1 + +# sha256: 7qMYNcVuIJavQ2OldFp4SHimHQQ-JH06bWoKMql0H1Y +# sha256: jfvGxFi42rocDzYgqMeACLMjomiye3NZ6SpK5BMl9TU +pyRFC3339==1.0 + +# sha256: Z9WdZs26jWJOA4m4eyqDoXbyHxaodVO1D1cDsj8pusI +python-augeas==0.5.0 + +# sha256: BOk_JJlcQ92Q8zjV2GXKcs4_taU1jU2qSWVXHbNfw-w +# sha256: Pm9ZP-rZj4pSa8PjBpM1MyNuM3KfVS9SiW6lBPVTE_o +python2-pythondialog==3.3.0 + +# sha256: Or5qbT_C-75MYBRCEfRdou2-MYKm9lEa9ru6BZix-ZI +# sha256: k575weEiTZgEBWial__PeCjFbRUXsx1zRkNWwfK3dp4 +# sha256: 6tSu-nAHJJ4F5RsBCVcZ1ajdlXYAifVzCqxWmLGTKRg +# sha256: PMoN8IvQ7ZhDI5BJTOPe0AP15mGqRgvnpzS__jWYNgU +# sha256: Pt5HDT0XujwHY436DRBFK8G25a0yYSemW6d-aq6xG-w +# sha256: aMR5ZPcYbuwwaxNilidyK5B5zURH7Z5eyuzU6shMpzQ +# sha256: 3V05kZUKrkCmyB3hV4lC5z1imAjO_FHRLNFXmA5s_Bg +# sha256: p3xSBiwH63x7MFRdvHPjKZW34Rfup1Axe1y1x6RhjxQ +# sha256: ga-a7EvJYKmgEnxIjxh3La5GNGiSM_BvZUQ-exHr61E +# sha256: 4Hmx2txcBiRswbtv4bI6ULHRFz8u3VEE79QLtzoo9AY +# sha256: -9JnRncsJMuTyLl8va1cueRshrvbG52KdD7gDi-x_F0 +# sha256: mSZu8wo35Dky3uwrfKc-g8jbw7n_cD7HPsprHa5r7-o +# sha256: i2zhyZOQl4O8luC0806iI7_3pN8skL25xODxrJKGieM +pytz==2015.7 + +# sha256: ET-7pVManjSUW302szoIToul0GZLcDyBp8Vy2RkZpbg +# sha256: xXeBXdAPE5QgP8ROuXlySwmPiCZKnviY7kW45enPWH8 +requests==2.9.1 + +# sha256: D_eMQD2bzPWkJabTGhKqa0fxwhyk3CVzp-LzKpczXrE +# sha256: EF-NaGFvgkjiS_DpNy7wTTzBAQTxmA9U1Xss5zpa1Wo +six==1.10.0 + +# sha256: aUkbUwUVfDxuDwSnAZhNaud_1yn8HJrNJQd_HfOFMms +# sha256: 619wCpv8lkILBVY1r5AC02YuQ9gMP_0x8iTCW8DV9GI +Werkzeug==0.11.3 + +# sha256: KCwRK1XdjjyGmjVx-GdnwVCrEoSprOK97CJsWSrK-Bo +zope.component==4.2.2 + +# sha256: 3HpZov2Rcw03kxMaXSYbKek-xOKpfxvEh86N7-4v54Y +zope.event==4.1.0 + +# sha256: 8HtjH3pgHNjL0zMtVPQxQscIioMpn4WTVvCNHU1CWbM +# sha256: 3lzKCDuUOdgAL7drvmtJmMWlpyH6sluEKYln8ALfTJQ +# sha256: Z4hBb36n9bipe-lIJTd6ol6L3HNGPge6r5hYsp5zcHc +# sha256: bzIw9yVFGCAeWjcIy7LemMhIME8G497Yv7OeWCXLouE +# sha256: X6V1pSQPBCAMMIhCfQ1Le3N_bpAYgYpR2ND5J6aiUXo +# sha256: UiGUrWpUVzXt11yKg_SNZdGvBk5DKn0yDWT1a6_BLpk +# sha256: 6Mey1AlD9xyZFIyX9myqf1E0FH9XQj-NtbSCUJnOmgk +# sha256: J5Ak8CCGAcPKqQfFOHbjetiGJffq8cs4QtvjYLIocBc +# sha256: LiIanux8zFiImieOoT3P7V75OdgLB4Gamos8scaBSE8 +# sha256: aRGJZUEOyG1E3GuQF-4929WC4MCr7vYrOhnb9sitEys +# sha256: 0E34aG7IZNDK3ozxmff4OuzUFhCaIINNVo-DEN7RLeo +# sha256: 51qUfhXul-fnHgLqMC_rL8YtOiu0Zov5377UOlBqx-c +# sha256: TkXSL7iDIipaufKCoRb-xe4ujRpWjM_2otdbvQ62vPw +# sha256: vOkzm7PHpV4IA7Y9IcWDno5Hm8hcSt9CrkFbcvlPrLI +# sha256: koE4NlJFoOiGmlmZ-8wqRUdaCm7VKklNYNvcVAM1_t0 +# sha256: DYQbobuEDuoOZIncXsr6YSVVSXH1O1rLh3ZEQeYbzro +# sha256: sJyMHUezUxxADgGVaX8UFKYyId5u9HhZik8UYPfZo5I +zope.interface==4.1.3 + +# sha256: QMIkIvGF3mcJhGLAKRX7n5EVIPjOrfLtklN6ePjbJes +# sha256: fNFWiij6VxfG5o7u3oNbtrYKQ4q9vhzOLATfxNlozvQ +acme==0.3.0 + +# sha256: qdnzpoRf_44QXKoktNoAKs2RBAxUta2Sr6GS0t_tAKo +# sha256: ELWJaHNvBZIqVPJYkla8yXLtXIuamqAf6f_VAFv16Uk +letsencrypt==0.3.0 + +# sha256: EypLpEw3-Tr8unw4aSFsHXgRiU8ZYLrJKOJohP2tC9M +# sha256: HYvP13GzA-DDJYwlfOoaraJO0zuYO48TCSAyTUAGCqA +letsencrypt-apache==0.3.0 + +# sha256: uDndLZwRfHAUMMFJlWkYpCOphjtIsJyQ4wpgE-fS9E8 +# sha256: j4MIDaoknQNsvM-4rlzG_wB7iNbZN1ITca-r57Gbrbw +mock==1.0.1 diff --git a/letsencrypt-auto-source/pieces/peep.py b/letsencrypt-auto-source/pieces/peep.py new file mode 100755 index 000000000..c4e51f483 --- /dev/null +++ b/letsencrypt-auto-source/pieces/peep.py @@ -0,0 +1,961 @@ +#!/usr/bin/env python +"""peep ("prudently examine every package") verifies that packages conform to a +trusted, locally stored hash and only then installs them:: + + peep install -r requirements.txt + +This makes your deployments verifiably repeatable without having to maintain a +local PyPI mirror or use a vendor lib. Just update the version numbers and +hashes in requirements.txt, and you're all set. + +""" +# This is here so embedded copies of peep.py are MIT-compliant: +# Copyright (c) 2013 Erik Rose +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +from __future__ import print_function +try: + xrange = xrange +except NameError: + xrange = range +from base64 import urlsafe_b64encode, urlsafe_b64decode +from binascii import hexlify +import cgi +from collections import defaultdict +from functools import wraps +from hashlib import sha256 +from itertools import chain, islice +import mimetypes +from optparse import OptionParser +from os.path import join, basename, splitext, isdir +from pickle import dumps, loads +import re +import sys +from shutil import rmtree, copy +from sys import argv, exit +from tempfile import mkdtemp +import traceback +try: + from urllib2 import build_opener, HTTPHandler, HTTPSHandler, HTTPError +except ImportError: + from urllib.request import build_opener, HTTPHandler, HTTPSHandler + from urllib.error import HTTPError +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse # 3.4 +# TODO: Probably use six to make urllib stuff work across 2/3. + +from pkg_resources import require, VersionConflict, DistributionNotFound + +# We don't admit our dependency on pip in setup.py, lest a naive user simply +# say `pip install peep.tar.gz` and thus pull down an untrusted copy of pip +# from PyPI. Instead, we make sure it's installed and new enough here and spit +# out an error message if not: + + +def activate(specifier): + """Make a compatible version of pip importable. Raise a RuntimeError if we + couldn't.""" + try: + for distro in require(specifier): + distro.activate() + except (VersionConflict, DistributionNotFound): + raise RuntimeError('The installed version of pip is too old; peep ' + 'requires ' + specifier) + +# Before 0.6.2, the log module wasn't there, so some +# of our monkeypatching fails. It probably wouldn't be +# much work to support even earlier, though. +activate('pip>=0.6.2') + +import pip +from pip.commands.install import InstallCommand +try: + from pip.download import url_to_path # 1.5.6 +except ImportError: + try: + from pip.util import url_to_path # 0.7.0 + except ImportError: + from pip.util import url_to_filename as url_to_path # 0.6.2 +from pip.index import PackageFinder, Link +try: + from pip.log import logger +except ImportError: + from pip import logger # 6.0 +from pip.req import parse_requirements +try: + from pip.utils.ui import DownloadProgressBar, DownloadProgressSpinner +except ImportError: + class NullProgressBar(object): + def __init__(self, *args, **kwargs): + pass + + def iter(self, ret, *args, **kwargs): + return ret + + DownloadProgressBar = DownloadProgressSpinner = NullProgressBar + +__version__ = 3, 0, 0 + +try: + from pip.index import FormatControl # noqa + FORMAT_CONTROL_ARG = 'format_control' + + # The line-numbering bug will be fixed in pip 8. All 7.x releases had it. + PIP_MAJOR_VERSION = int(pip.__version__.split('.')[0]) + PIP_COUNTS_COMMENTS = PIP_MAJOR_VERSION >= 8 +except ImportError: + FORMAT_CONTROL_ARG = 'use_wheel' # pre-7 + PIP_COUNTS_COMMENTS = True + + +ITS_FINE_ITS_FINE = 0 +SOMETHING_WENT_WRONG = 1 +# "Traditional" for command-line errors according to optparse docs: +COMMAND_LINE_ERROR = 2 + +ARCHIVE_EXTENSIONS = ('.tar.bz2', '.tar.gz', '.tgz', '.tar', '.zip') + +MARKER = object() + + +class PipException(Exception): + """When I delegated to pip, it exited with an error.""" + + def __init__(self, error_code): + self.error_code = error_code + + +class UnsupportedRequirementError(Exception): + """An unsupported line was encountered in a requirements file.""" + + +class DownloadError(Exception): + def __init__(self, link, exc): + self.link = link + self.reason = str(exc) + + def __str__(self): + return 'Downloading %s failed: %s' % (self.link, self.reason) + + +def encoded_hash(sha): + """Return a short, 7-bit-safe representation of a hash. + + If you pass a sha256, this results in the hash algorithm that the Wheel + format (PEP 427) uses, except here it's intended to be run across the + downloaded archive before unpacking. + + """ + return urlsafe_b64encode(sha.digest()).decode('ascii').rstrip('=') + + +def path_and_line(req): + """Return the path and line number of the file from which an + InstallRequirement came. + + """ + path, line = (re.match(r'-r (.*) \(line (\d+)\)$', + req.comes_from).groups()) + return path, int(line) + + +def hashes_above(path, line_number): + """Yield hashes from contiguous comment lines before line ``line_number``. + + """ + def hash_lists(path): + """Yield lists of hashes appearing between non-comment lines. + + The lists will be in order of appearance and, for each non-empty + list, their place in the results will coincide with that of the + line number of the corresponding result from `parse_requirements` + (which changed in pip 7.0 to not count comments). + + """ + hashes = [] + with open(path) as file: + for lineno, line in enumerate(file, 1): + match = HASH_COMMENT_RE.match(line) + if match: # Accumulate this hash. + hashes.append(match.groupdict()['hash']) + if not IGNORED_LINE_RE.match(line): + yield hashes # Report hashes seen so far. + hashes = [] + elif PIP_COUNTS_COMMENTS: + # Comment: count as normal req but have no hashes. + yield [] + + return next(islice(hash_lists(path), line_number - 1, None)) + + +def run_pip(initial_args): + """Delegate to pip the given args (starting with the subcommand), and raise + ``PipException`` if something goes wrong.""" + status_code = pip.main(initial_args) + + # Clear out the registrations in the pip "logger" singleton. Otherwise, + # loggers keep getting appended to it with every run. Pip assumes only one + # command invocation will happen per interpreter lifetime. + logger.consumers = [] + + if status_code: + raise PipException(status_code) + + +def hash_of_file(path): + """Return the hash of a downloaded file.""" + with open(path, 'rb') as archive: + sha = sha256() + while True: + data = archive.read(2 ** 20) + if not data: + break + sha.update(data) + return encoded_hash(sha) + + +def is_git_sha(text): + """Return whether this is probably a git sha""" + # Handle both the full sha as well as the 7-character abbreviation + if len(text) in (40, 7): + try: + int(text, 16) + return True + except ValueError: + pass + return False + + +def filename_from_url(url): + parsed = urlparse(url) + path = parsed.path + return path.split('/')[-1] + + +def requirement_args(argv, want_paths=False, want_other=False): + """Return an iterable of filtered arguments. + + :arg argv: Arguments, starting after the subcommand + :arg want_paths: If True, the returned iterable includes the paths to any + requirements files following a ``-r`` or ``--requirement`` option. + :arg want_other: If True, the returned iterable includes the args that are + not a requirement-file path or a ``-r`` or ``--requirement`` flag. + + """ + was_r = False + for arg in argv: + # Allow for requirements files named "-r", don't freak out if there's a + # trailing "-r", etc. + if was_r: + if want_paths: + yield arg + was_r = False + elif arg in ['-r', '--requirement']: + was_r = True + else: + if want_other: + yield arg + +# any line that is a comment or just whitespace +IGNORED_LINE_RE = re.compile(r'^(\s*#.*)?\s*$') + +HASH_COMMENT_RE = re.compile( + r""" + \s*\#\s+ # Lines that start with a '#' + (?Psha256):\s+ # Hash type is hardcoded to be sha256 for now. + (?P[^\s]+) # Hashes can be anything except '#' or spaces. + \s* # Suck up whitespace before the comment or + # just trailing whitespace if there is no + # comment. Also strip trailing newlines. + (?:\#(?P.*))? # Comments can be anything after a whitespace+# + # and are optional. + $""", re.X) + + +def peep_hash(argv): + """Return the peep hash of one or more files, returning a shell status code + or raising a PipException. + + :arg argv: The commandline args, starting after the subcommand + + """ + parser = OptionParser( + usage='usage: %prog hash file [file ...]', + description='Print a peep hash line for one or more files: for ' + 'example, "# sha256: ' + 'oz42dZy6Gowxw8AelDtO4gRgTW_xPdooH484k7I5EOY".') + _, paths = parser.parse_args(args=argv) + if paths: + for path in paths: + print('# sha256:', hash_of_file(path)) + return ITS_FINE_ITS_FINE + else: + parser.print_usage() + return COMMAND_LINE_ERROR + + +class EmptyOptions(object): + """Fake optparse options for compatibility with pip<1.2 + + pip<1.2 had a bug in parse_requirements() in which the ``options`` kwarg + was required. We work around that by passing it a mock object. + + """ + default_vcs = None + skip_requirements_regex = None + isolated_mode = False + + +def memoize(func): + """Memoize a method that should return the same result every time on a + given instance. + + """ + @wraps(func) + def memoizer(self): + if not hasattr(self, '_cache'): + self._cache = {} + if func.__name__ not in self._cache: + self._cache[func.__name__] = func(self) + return self._cache[func.__name__] + return memoizer + + +def package_finder(argv): + """Return a PackageFinder respecting command-line options. + + :arg argv: Everything after the subcommand + + """ + # We instantiate an InstallCommand and then use some of its private + # machinery--its arg parser--for our own purposes, like a virus. This + # approach is portable across many pip versions, where more fine-grained + # ones are not. Ignoring options that don't exist on the parser (for + # instance, --use-wheel) gives us a straightforward method of backward + # compatibility. + try: + command = InstallCommand() + except TypeError: + # This is likely pip 1.3.0's "__init__() takes exactly 2 arguments (1 + # given)" error. In that version, InstallCommand takes a top=level + # parser passed in from outside. + from pip.baseparser import create_main_parser + command = InstallCommand(create_main_parser()) + # The downside is that it essentially ruins the InstallCommand class for + # further use. Calling out to pip.main() within the same interpreter, for + # example, would result in arguments parsed this time turning up there. + # Thus, we deepcopy the arg parser so we don't trash its singletons. Of + # course, deepcopy doesn't work on these objects, because they contain + # uncopyable regex patterns, so we pickle and unpickle instead. Fun! + options, _ = loads(dumps(command.parser)).parse_args(argv) + + # Carry over PackageFinder kwargs that have [about] the same names as + # options attr names: + possible_options = [ + 'find_links', + FORMAT_CONTROL_ARG, + ('allow_all_prereleases', 'pre'), + 'process_dependency_links' + ] + kwargs = {} + for option in possible_options: + kw, attr = option if isinstance(option, tuple) else (option, option) + value = getattr(options, attr, MARKER) + if value is not MARKER: + kwargs[kw] = value + + # Figure out index_urls: + index_urls = [options.index_url] + options.extra_index_urls + if options.no_index: + index_urls = [] + index_urls += getattr(options, 'mirrors', []) + + # If pip is new enough to have a PipSession, initialize one, since + # PackageFinder requires it: + if hasattr(command, '_build_session'): + kwargs['session'] = command._build_session(options) + + return PackageFinder(index_urls=index_urls, **kwargs) + + +class DownloadedReq(object): + """A wrapper around InstallRequirement which offers additional information + based on downloading and examining a corresponding package archive + + These are conceptually immutable, so we can get away with memoizing + expensive things. + + """ + def __init__(self, req, argv, finder): + """Download a requirement, compare its hashes, and return a subclass + of DownloadedReq depending on its state. + + :arg req: The InstallRequirement I am based on + :arg argv: The args, starting after the subcommand + + """ + self._req = req + self._argv = argv + self._finder = finder + + # We use a separate temp dir for each requirement so requirements + # (from different indices) that happen to have the same archive names + # don't overwrite each other, leading to a security hole in which the + # latter is a hash mismatch, the former has already passed the + # comparison, and the latter gets installed. + self._temp_path = mkdtemp(prefix='peep-') + # Think of DownloadedReq as a one-shot state machine. It's an abstract + # class that ratchets forward to being one of its own subclasses, + # depending on its package status. Then it doesn't move again. + self.__class__ = self._class() + + def dispose(self): + """Delete temp files and dirs I've made. Render myself useless. + + Do not call further methods on me after calling dispose(). + + """ + rmtree(self._temp_path) + + def _version(self): + """Deduce the version number of the downloaded package from its filename.""" + # TODO: Can we delete this method and just print the line from the + # reqs file verbatim instead? + def version_of_archive(filename, package_name): + # Since we know the project_name, we can strip that off the left, strip + # any archive extensions off the right, and take the rest as the + # version. + for ext in ARCHIVE_EXTENSIONS: + if filename.endswith(ext): + filename = filename[:-len(ext)] + break + # Handle github sha tarball downloads. + if is_git_sha(filename): + filename = package_name + '-' + filename + if not filename.lower().replace('_', '-').startswith(package_name.lower()): + # TODO: Should we replace runs of [^a-zA-Z0-9.], not just _, with -? + give_up(filename, package_name) + return filename[len(package_name) + 1:] # Strip off '-' before version. + + def version_of_wheel(filename, package_name): + # For Wheel files (http://legacy.python.org/dev/peps/pep-0427/#file- + # name-convention) we know the format bits are '-' separated. + whl_package_name, version, _rest = filename.split('-', 2) + # Do the alteration to package_name from PEP 427: + our_package_name = re.sub(r'[^\w\d.]+', '_', package_name, re.UNICODE) + if whl_package_name != our_package_name: + give_up(filename, whl_package_name) + return version + + def give_up(filename, package_name): + raise RuntimeError("The archive '%s' didn't start with the package name " + "'%s', so I couldn't figure out the version number. " + "My bad; improve me." % + (filename, package_name)) + + get_version = (version_of_wheel + if self._downloaded_filename().endswith('.whl') + else version_of_archive) + return get_version(self._downloaded_filename(), self._project_name()) + + def _is_always_unsatisfied(self): + """Returns whether this requirement is always unsatisfied + + This would happen in cases where we can't determine the version + from the filename. + + """ + # If this is a github sha tarball, then it is always unsatisfied + # because the url has a commit sha in it and not the version + # number. + url = self._url() + if url: + filename = filename_from_url(url) + if filename.endswith(ARCHIVE_EXTENSIONS): + filename, ext = splitext(filename) + if is_git_sha(filename): + return True + return False + + @memoize # Avoid hitting the file[cache] over and over. + def _expected_hashes(self): + """Return a list of known-good hashes for this package.""" + return hashes_above(*path_and_line(self._req)) + + def _download(self, link): + """Download a file, and return its name within my temp dir. + + This does no verification of HTTPS certs, but our checking hashes + makes that largely unimportant. It would be nice to be able to use the + requests lib, which can verify certs, but it is guaranteed to be + available only in pip >= 1.5. + + This also drops support for proxies and basic auth, though those could + be added back in. + + """ + # Based on pip 1.4.1's URLOpener but with cert verification removed + def opener(is_https): + if is_https: + opener = build_opener(HTTPSHandler()) + # Strip out HTTPHandler to prevent MITM spoof: + for handler in opener.handlers: + if isinstance(handler, HTTPHandler): + opener.handlers.remove(handler) + else: + opener = build_opener() + return opener + + # Descended from unpack_http_url() in pip 1.4.1 + def best_filename(link, response): + """Return the most informative possible filename for a download, + ideally with a proper extension. + + """ + content_type = response.info().get('content-type', '') + filename = link.filename # fallback + # Have a look at the Content-Disposition header for a better guess: + content_disposition = response.info().get('content-disposition') + if content_disposition: + type, params = cgi.parse_header(content_disposition) + # We use ``or`` here because we don't want to use an "empty" value + # from the filename param: + filename = params.get('filename') or filename + ext = splitext(filename)[1] + if not ext: + ext = mimetypes.guess_extension(content_type) + if ext: + filename += ext + if not ext and link.url != response.geturl(): + ext = splitext(response.geturl())[1] + if ext: + filename += ext + return filename + + # Descended from _download_url() in pip 1.4.1 + def pipe_to_file(response, path, size=0): + """Pull the data off an HTTP response, shove it in a new file, and + show progress. + + :arg response: A file-like object to read from + :arg path: The path of the new file + :arg size: The expected size, in bytes, of the download. 0 for + unknown or to suppress progress indication (as for cached + downloads) + + """ + def response_chunks(chunk_size): + while True: + chunk = response.read(chunk_size) + if not chunk: + break + yield chunk + + print('Downloading %s%s...' % ( + self._req.req, + (' (%sK)' % (size / 1000)) if size > 1000 else '')) + progress_indicator = (DownloadProgressBar(max=size).iter if size + else DownloadProgressSpinner().iter) + with open(path, 'wb') as file: + for chunk in progress_indicator(response_chunks(4096), 4096): + file.write(chunk) + + url = link.url.split('#', 1)[0] + try: + response = opener(urlparse(url).scheme != 'http').open(url) + except (HTTPError, IOError) as exc: + raise DownloadError(link, exc) + filename = best_filename(link, response) + try: + size = int(response.headers['content-length']) + except (ValueError, KeyError, TypeError): + size = 0 + pipe_to_file(response, join(self._temp_path, filename), size=size) + return filename + + # Based on req_set.prepare_files() in pip bb2a8428d4aebc8d313d05d590f386fa3f0bbd0f + @memoize # Avoid re-downloading. + def _downloaded_filename(self): + """Download the package's archive if necessary, and return its + filename. + + --no-deps is implied, as we have reimplemented the bits that would + ordinarily do dependency resolution. + + """ + # Peep doesn't support requirements that don't come down as a single + # file, because it can't hash them. Thus, it doesn't support editable + # requirements, because pip itself doesn't support editable + # requirements except for "local projects or a VCS url". Nor does it + # support VCS requirements yet, because we haven't yet come up with a + # portable, deterministic way to hash them. In summary, all we support + # is == requirements and tarballs/zips/etc. + + # TODO: Stop on reqs that are editable or aren't ==. + + # If the requirement isn't already specified as a URL, get a URL + # from an index: + link = self._link() or self._finder.find_requirement(self._req, upgrade=False) + + if link: + lower_scheme = link.scheme.lower() # pip lower()s it for some reason. + if lower_scheme == 'http' or lower_scheme == 'https': + file_path = self._download(link) + return basename(file_path) + elif lower_scheme == 'file': + # The following is inspired by pip's unpack_file_url(): + link_path = url_to_path(link.url_without_fragment) + if isdir(link_path): + raise UnsupportedRequirementError( + "%s: %s is a directory. So that it can compute " + "a hash, peep supports only filesystem paths which " + "point to files" % + (self._req, link.url_without_fragment)) + else: + copy(link_path, self._temp_path) + return basename(link_path) + else: + raise UnsupportedRequirementError( + "%s: The download link, %s, would not result in a file " + "that can be hashed. Peep supports only == requirements, " + "file:// URLs pointing to files (not folders), and " + "http:// and https:// URLs pointing to tarballs, zips, " + "etc." % (self._req, link.url)) + else: + raise UnsupportedRequirementError( + "%s: couldn't determine where to download this requirement from." + % (self._req,)) + + def install(self): + """Install the package I represent, without dependencies. + + Obey typical pip-install options passed in on the command line. + + """ + other_args = list(requirement_args(self._argv, want_other=True)) + archive_path = join(self._temp_path, self._downloaded_filename()) + # -U so it installs whether pip deems the requirement "satisfied" or + # not. This is necessary for GitHub-sourced zips, which change without + # their version numbers changing. + run_pip(['install'] + other_args + ['--no-deps', '-U', archive_path]) + + @memoize + def _actual_hash(self): + """Download the package's archive if necessary, and return its hash.""" + return hash_of_file(join(self._temp_path, self._downloaded_filename())) + + def _project_name(self): + """Return the inner Requirement's "unsafe name". + + Raise ValueError if there is no name. + + """ + name = getattr(self._req.req, 'project_name', '') + if name: + return name + raise ValueError('Requirement has no project_name.') + + def _name(self): + return self._req.name + + def _link(self): + try: + return self._req.link + except AttributeError: + # The link attribute isn't available prior to pip 6.1.0, so fall + # back to the now deprecated 'url' attribute. + return Link(self._req.url) if self._req.url else None + + def _url(self): + link = self._link() + return link.url if link else None + + @memoize # Avoid re-running expensive check_if_exists(). + def _is_satisfied(self): + self._req.check_if_exists() + return (self._req.satisfied_by and + not self._is_always_unsatisfied()) + + def _class(self): + """Return the class I should be, spanning a continuum of goodness.""" + try: + self._project_name() + except ValueError: + return MalformedReq + if self._is_satisfied(): + return SatisfiedReq + if not self._expected_hashes(): + return MissingReq + if self._actual_hash() not in self._expected_hashes(): + return MismatchedReq + return InstallableReq + + @classmethod + def foot(cls): + """Return the text to be printed once, after all of the errors from + classes of my type are printed. + + """ + return '' + + +class MalformedReq(DownloadedReq): + """A requirement whose package name could not be determined""" + + @classmethod + def head(cls): + return 'The following requirements could not be processed:\n' + + def error(self): + return '* Unable to determine package name from URL %s; add #egg=' % self._url() + + +class MissingReq(DownloadedReq): + """A requirement for which no hashes were specified in the requirements file""" + + @classmethod + def head(cls): + return ('The following packages had no hashes specified in the requirements file, which\n' + 'leaves them open to tampering. Vet these packages to your satisfaction, then\n' + 'add these "sha256" lines like so:\n\n') + + def error(self): + if self._url(): + # _url() always contains an #egg= part, or this would be a + # MalformedRequest. + line = self._url() + else: + line = '%s==%s' % (self._name(), self._version()) + return '# sha256: %s\n%s\n' % (self._actual_hash(), line) + + +class MismatchedReq(DownloadedReq): + """A requirement for which the downloaded file didn't match any of my hashes.""" + @classmethod + def head(cls): + return ("THE FOLLOWING PACKAGES DIDN'T MATCH THE HASHES SPECIFIED IN THE REQUIREMENTS\n" + "FILE. If you have updated the package versions, update the hashes. If not,\n" + "freak out, because someone has tampered with the packages.\n\n") + + def error(self): + preamble = ' %s: expected' % self._project_name() + if len(self._expected_hashes()) > 1: + preamble += ' one of' + padding = '\n' + ' ' * (len(preamble) + 1) + return '%s %s\n%s got %s' % (preamble, + padding.join(self._expected_hashes()), + ' ' * (len(preamble) - 4), + self._actual_hash()) + + @classmethod + def foot(cls): + return '\n' + + +class SatisfiedReq(DownloadedReq): + """A requirement which turned out to be already installed""" + + @classmethod + def head(cls): + return ("These packages were already installed, so we didn't need to download or build\n" + "them again. If you installed them with peep in the first place, you should be\n" + "safe. If not, uninstall them, then re-attempt your install with peep.\n") + + def error(self): + return ' %s' % (self._req,) + + +class InstallableReq(DownloadedReq): + """A requirement whose hash matched and can be safely installed""" + + +# DownloadedReq subclasses that indicate an error that should keep us from +# going forward with installation, in the order in which their errors should +# be reported: +ERROR_CLASSES = [MismatchedReq, MissingReq, MalformedReq] + + +def bucket(things, key): + """Return a map of key -> list of things.""" + ret = defaultdict(list) + for thing in things: + ret[key(thing)].append(thing) + return ret + + +def first_every_last(iterable, first, every, last): + """Execute something before the first item of iter, something else for each + item, and a third thing after the last. + + If there are no items in the iterable, don't execute anything. + + """ + did_first = False + for item in iterable: + if not did_first: + did_first = True + first(item) + every(item) + if did_first: + last(item) + + +def _parse_requirements(path, finder): + try: + # list() so the generator that is parse_requirements() actually runs + # far enough to report a TypeError + return list(parse_requirements( + path, options=EmptyOptions(), finder=finder)) + except TypeError: + # session is a required kwarg as of pip 6.0 and will raise + # a TypeError if missing. It needs to be a PipSession instance, + # but in older versions we can't import it from pip.download + # (nor do we need it at all) so we only import it in this except block + from pip.download import PipSession + return list(parse_requirements( + path, options=EmptyOptions(), session=PipSession(), finder=finder)) + + +def downloaded_reqs_from_path(path, argv): + """Return a list of DownloadedReqs representing the requirements parsed + out of a given requirements file. + + :arg path: The path to the requirements file + :arg argv: The commandline args, starting after the subcommand + + """ + finder = package_finder(argv) + return [DownloadedReq(req, argv, finder) for req in + _parse_requirements(path, finder)] + + +def peep_install(argv): + """Perform the ``peep install`` subcommand, returning a shell status code + or raising a PipException. + + :arg argv: The commandline args, starting after the subcommand + + """ + output = [] + out = output.append + reqs = [] + try: + req_paths = list(requirement_args(argv, want_paths=True)) + if not req_paths: + out("You have to specify one or more requirements files with the -r option, because\n" + "otherwise there's nowhere for peep to look up the hashes.\n") + return COMMAND_LINE_ERROR + + # We're a "peep install" command, and we have some requirement paths. + reqs = list(chain.from_iterable( + downloaded_reqs_from_path(path, argv) + for path in req_paths)) + buckets = bucket(reqs, lambda r: r.__class__) + + # Skip a line after pip's "Cleaning up..." so the important stuff + # stands out: + if any(buckets[b] for b in ERROR_CLASSES): + out('\n') + + printers = (lambda r: out(r.head()), + lambda r: out(r.error() + '\n'), + lambda r: out(r.foot())) + for c in ERROR_CLASSES: + first_every_last(buckets[c], *printers) + + if any(buckets[b] for b in ERROR_CLASSES): + out('-------------------------------\n' + 'Not proceeding to installation.\n') + return SOMETHING_WENT_WRONG + else: + for req in buckets[InstallableReq]: + req.install() + + first_every_last(buckets[SatisfiedReq], *printers) + + return ITS_FINE_ITS_FINE + except (UnsupportedRequirementError, DownloadError) as exc: + out(str(exc)) + return SOMETHING_WENT_WRONG + finally: + for req in reqs: + req.dispose() + print(''.join(output)) + + +def peep_port(paths): + """Convert a peep requirements file to one compatble with pip-8 hashing. + + Loses comments and tromps on URLs, so the result will need a little manual + massaging, but the hard part--the hash conversion--is done for you. + + """ + if not paths: + print('Please specify one or more requirements files so I have ' + 'something to port.\n') + return COMMAND_LINE_ERROR + for req in chain.from_iterable( + _parse_requirements(path, package_finder(argv)) for path in paths): + hashes = [hexlify(urlsafe_b64decode((hash + '=').encode('ascii'))).decode('ascii') + for hash in hashes_above(*path_and_line(req))] + if not hashes: + print(req.req) + elif len(hashes) == 1: + print('%s --hash=sha256:%s' % (req.req, hashes[0])) + else: + print('%s' % req.req, end='') + for hash in hashes: + print(' \\') + print(' --hash=sha256:%s' % hash, end='') + print() + + +def main(): + """Be the top-level entrypoint. Return a shell status code.""" + commands = {'hash': peep_hash, + 'install': peep_install, + 'port': peep_port} + try: + if len(argv) >= 2 and argv[1] in commands: + return commands[argv[1]](argv[2:]) + else: + # Fall through to top-level pip main() for everything else: + return pip.main() + except PipException as exc: + return exc.error_code + + +def exception_handler(exc_type, exc_value, exc_tb): + print('Oh no! Peep had a problem while trying to do stuff. Please write up a bug report') + print('with the specifics so we can fix it:') + print() + print('https://github.com/erikrose/peep/issues/new') + print() + print('Here are some particulars you can copy and paste into the bug report:') + print() + print('---') + print('peep:', repr(__version__)) + print('python:', repr(sys.version)) + print('pip:', repr(getattr(pip, '__version__', 'no __version__ attr'))) + print('Command line: ', repr(sys.argv)) + print( + ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))) + print('---') + + +if __name__ == '__main__': + try: + exit(main()) + except Exception: + exception_handler(*sys.exc_info()) + exit(SOMETHING_WENT_WRONG) diff --git a/letsencrypt-auto-source/tests/__init__.py b/letsencrypt-auto-source/tests/__init__.py new file mode 100644 index 000000000..45db90444 --- /dev/null +++ b/letsencrypt-auto-source/tests/__init__.py @@ -0,0 +1,7 @@ +"""Tests for letsencrypt-auto + +Run these locally by saying... :: + + ./build.py && docker build -t lea . && docker run --rm -t -i lea + +""" diff --git a/letsencrypt-auto-source/tests/auto_test.py b/letsencrypt-auto-source/tests/auto_test.py new file mode 100644 index 000000000..32d591190 --- /dev/null +++ b/letsencrypt-auto-source/tests/auto_test.py @@ -0,0 +1,343 @@ +"""Tests for letsencrypt-auto""" + +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +from contextlib import contextmanager +from functools import partial +from json import dumps +from os import chmod, environ +from os.path import abspath, dirname, join +import re +from shutil import copy, rmtree +import socket +import ssl +from stat import S_IRUSR, S_IXUSR +from subprocess import CalledProcessError, check_output, Popen, PIPE +import sys +from tempfile import mkdtemp +from threading import Thread +from unittest import TestCase + +from nose.tools import eq_, nottest, ok_ + + +@nottest +def tests_dir(): + """Return a path to the "tests" directory.""" + return dirname(abspath(__file__)) + + +sys.path.insert(0, dirname(tests_dir())) +from build import build as build_le_auto + + +class RequestHandler(BaseHTTPRequestHandler): + """An HTTPS request handler which is quiet and serves a specific folder.""" + + def __init__(self, resources, *args, **kwargs): + """ + :arg resources: A dict of resource paths pointing to content bytes + + """ + self.resources = resources + BaseHTTPRequestHandler.__init__(self, *args, **kwargs) + + def log_message(self, format, *args): + """Don't log each request to the terminal.""" + + def do_GET(self): + """Serve a GET request.""" + content = self.send_head() + if content is not None: + self.wfile.write(content) + + def send_head(self): + """Common code for GET and HEAD commands + + This sends the response code and MIME headers and returns either a + bytestring of content or, if none is found, None. + + """ + path = self.path[1:] # Strip leading slash. + content = self.resources.get(path) + if content is None: + self.send_error(404, 'Path "%s" not found in self.resources' % path) + else: + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.send_header('Content-Length', str(len(content))) + self.end_headers() + return content + + +def server_and_port(resources): + """Return an unstarted HTTPS server and the port it will use.""" + # Find a port, and bind to it. I can't get the OS to close the socket + # promptly after we shut down the server, so we typically need to try + # a couple ports after the first test case. Setting + # TCPServer.allow_reuse_address = True seems to have nothing to do + # with this behavior. + worked = False + for port in xrange(4443, 4543): + try: + server = HTTPServer(('localhost', port), + partial(RequestHandler, resources)) + except socket.error: + pass + else: + worked = True + server.socket = ssl.wrap_socket( + server.socket, + certfile=join(tests_dir(), 'certs', 'localhost', 'server.pem'), + server_side=True) + break + if not worked: + raise RuntimeError("Couldn't find an unused socket for the testing HTTPS server.") + return server, port + + +@contextmanager +def serving(resources): + """Spin up a local HTTPS server, and yield its base URL. + + Use a self-signed cert generated as outlined by + https://coolaj86.com/articles/create-your-own-certificate-authority-for- + testing/. + + """ + server, port = server_and_port(resources) + thread = Thread(target=server.serve_forever) + try: + thread.start() + yield 'https://localhost:{port}/'.format(port=port) + finally: + server.shutdown() + thread.join() + + +LE_AUTO_PATH = join(dirname(tests_dir()), 'letsencrypt-auto') + + +@contextmanager +def ephemeral_dir(): + dir = mkdtemp(prefix='le-test-') + try: + yield dir + finally: + rmtree(dir) + + +def out_and_err(command, input=None, shell=False, env=None): + """Run a shell command, and return stderr and stdout as string. + + If the command returns nonzero, raise CalledProcessError. + + :arg command: A list of commandline args + :arg input: Data to pipe to stdin. Omit for none. + + Remaining args have the same meaning as for Popen. + + """ + process = Popen(command, + stdout=PIPE, + stdin=PIPE, + stderr=PIPE, + shell=shell, + env=env) + out, err = process.communicate(input=input) + status = process.poll() # same as in check_output(), though wait() sounds better + if status: + raise CalledProcessError(status, command, output=out) + return out, err + + +def signed(content, private_key_name='signing.key'): + """Return the signed SHA-256 hash of ``content``, using the given key file.""" + command = ['openssl', 'dgst', '-sha256', '-sign', + join(tests_dir(), private_key_name)] + out, err = out_and_err(command, input=content) + return out + + +def install_le_auto(contents, venv_dir): + """Install some given source code as the letsencrypt-auto script at the + root level of a virtualenv. + + :arg contents: The contents of the built letsencrypt-auto script + :arg venv_dir: The path under which to install the script + + """ + venv_le_auto_path = join(venv_dir, 'letsencrypt-auto') + with open(venv_le_auto_path, 'w') as le_auto: + le_auto.write(contents) + chmod(venv_le_auto_path, S_IRUSR | S_IXUSR) + + +def run_le_auto(venv_dir, base_url, **kwargs): + """Run the prebuilt version of letsencrypt-auto, returning stdout and + stderr strings. + + If the command returns other than 0, raise CalledProcessError. + + """ + env = environ.copy() + d = dict(XDG_DATA_HOME=venv_dir, + # URL to PyPI-style JSON that tell us the latest released version + # of LE: + LE_AUTO_JSON_URL=base_url + 'letsencrypt/json', + # URL to dir containing letsencrypt-auto and letsencrypt-auto.sig: + LE_AUTO_DIR_TEMPLATE=base_url + '%s/', + # The public key corresponding to signing.key: + LE_AUTO_PUBLIC_KEY="""-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMoSzLYQ7E1sdSOkwelg +tzKIh2qi3bpXuYtcfFC0XrvWig071NwIj+dZiT0OLZ2hPispEH0B7ISuuWg1ll7G +hFW0VdbxL6JdGzS2ShNWkX9hE9z+j8VqwDPOBn3ZHm03qwpYkBDwQib3KqOdYbTT +uUtJmmGcuk3a9Aq/sCT6DdfmTSdP5asdQYwIcaQreDrOosaS84DTWI3IU+UYJVgl +LsIVPBuy9IcgHidUQ96hJnoPsDCWsHwX62495QKEarauyKQrJzFes0EY95orDM47 +Z5o/NDiQB11m91yNB0MmPYY9QSbnOA9j7IaaC97AwRLuwXY+/R2ablTcxurWou68 +iQIDAQAB +-----END PUBLIC KEY-----""", + **kwargs) + env.update(d) + return out_and_err( + join(venv_dir, 'letsencrypt-auto') + ' --version', + shell=True, + env=env) + + +def set_le_script_version(venv_dir, version): + """Tell the letsencrypt script to report a certain version. + + We actually replace the script with a dummy version that knows only how to + print its version. + + """ + with open(join(venv_dir, 'letsencrypt', 'bin', 'letsencrypt'), 'w') as script: + script.write("#!/usr/bin/env python\n" + "from sys import stderr\n" + "stderr.write('letsencrypt %s\\n')" % version) + + +class AutoTests(TestCase): + """Test the major branch points of letsencrypt-auto: + + * An le-auto upgrade is needed. + * An le-auto upgrade is not needed. + * There was an out-of-date LE script installed. + * There was a current LE script installed. + * There was no LE script installed (less important). + * Peep verification passes. + * Peep has a hash mismatch. + * The OpenSSL sig matches. + * The OpenSSL sig mismatches. + + For tests which get to the end, we run merely ``letsencrypt --version``. + The functioning of the rest of the letsencrypt script is covered by other + test suites. + + """ + def test_successes(self): + """Exercise most branches of letsencrypt-auto. + + They just happen to be the branches in which everything goes well. + + I violate my usual rule of having small, decoupled tests, because... + + 1. We shouldn't need to run a Cartesian product of the branches: the + phases run in separate shell processes, containing state leakage + pretty effectively. The only shared state is FS state, and it's + limited to a temp dir, assuming (if we dare) all functions properly. + 2. One combination of branches happens to set us up nicely for testing + the next, saving code. + + """ + NEW_LE_AUTO = build_le_auto( + version='99.9.9', + requirements='# sha256: HMFNYatCTN7kRvUeUPESP4SC7HQFh_54YmyTO7ooc6A\n' + 'letsencrypt==99.9.9') + NEW_LE_AUTO_SIG = signed(NEW_LE_AUTO) + + with ephemeral_dir() as venv_dir: + # This serves a PyPI page with a higher version, a GitHub-alike + # with a corresponding le-auto script, and a matching signature. + resources = {'letsencrypt/json': dumps({'releases': {'99.9.9': None}}), + 'v99.9.9/letsencrypt-auto': NEW_LE_AUTO, + 'v99.9.9/letsencrypt-auto.sig': NEW_LE_AUTO_SIG} + with serving(resources) as base_url: + run_letsencrypt_auto = partial( + run_le_auto, + venv_dir, + base_url, + PIP_FIND_LINKS=join(tests_dir(), + 'fake-letsencrypt', + 'dist')) + + # Test when a phase-1 upgrade is needed, there's no LE binary + # installed, and peep verifies: + install_le_auto(build_le_auto(version='50.0.0'), venv_dir) + out, err = run_letsencrypt_auto() + ok_(re.match(r'letsencrypt \d+\.\d+\.\d+', + err.strip().splitlines()[-1])) + # Make a few assertions to test the validity of the next tests: + self.assertIn('Upgrading letsencrypt-auto ', out) + self.assertIn('Creating virtual environment...', out) + + # Now we have le-auto 99.9.9 and LE 99.9.9 installed. This + # conveniently sets us up to test the next 2 cases. + + # Test when neither phase-1 upgrade nor phase-2 upgrade is + # needed (probably a common case): + out, err = run_letsencrypt_auto() + self.assertNotIn('Upgrading letsencrypt-auto ', out) + self.assertNotIn('Creating virtual environment...', out) + + # Test when a phase-1 upgrade is not needed but a phase-2 + # upgrade is: + set_le_script_version(venv_dir, '0.0.1') + out, err = run_letsencrypt_auto() + self.assertNotIn('Upgrading letsencrypt-auto ', out) + self.assertIn('Creating virtual environment...', out) + + def test_openssl_failure(self): + """Make sure we stop if the openssl signature check fails.""" + with ephemeral_dir() as venv_dir: + # Serve an unrelated hash signed with the good key (easier than + # making a bad key, and a mismatch is a mismatch): + resources = {'': 'letsencrypt/', + 'letsencrypt/json': dumps({'releases': {'99.9.9': None}}), + 'v99.9.9/letsencrypt-auto': build_le_auto(version='99.9.9'), + 'v99.9.9/letsencrypt-auto.sig': signed('something else')} + with serving(resources) as base_url: + copy(LE_AUTO_PATH, venv_dir) + try: + out, err = run_le_auto(venv_dir, base_url) + except CalledProcessError as exc: + eq_(exc.returncode, 1) + self.assertIn("Couldn't verify signature of downloaded " + "letsencrypt-auto.", + exc.output) + else: + self.fail('Signature check on letsencrypt-auto erroneously passed.') + + def test_peep_failure(self): + """Make sure peep stops us if there is a hash mismatch.""" + with ephemeral_dir() as venv_dir: + resources = {'': 'letsencrypt/', + 'letsencrypt/json': dumps({'releases': {'99.9.9': None}})} + with serving(resources) as base_url: + # Build a le-auto script embedding a bad requirements file: + install_le_auto( + build_le_auto( + version='99.9.9', + requirements='# sha256: badbadbadbadbadbadbadbadbadbadbadbadbadbadb\n' + 'configobj==5.0.6'), + venv_dir) + try: + out, err = run_le_auto(venv_dir, base_url) + except CalledProcessError as exc: + eq_(exc.returncode, 1) + self.assertIn("THE FOLLOWING PACKAGES DIDN'T MATCH THE " + "HASHES SPECIFIED IN THE REQUIREMENTS", + exc.output) + else: + self.fail("Peep didn't detect a bad hash and stop the " + "installation.") diff --git a/letsencrypt-auto-source/tests/certs/ca/my-root-ca.crt.pem b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.crt.pem new file mode 100644 index 000000000..4e4d29bd2 --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.crt.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID5jCCAs6gAwIBAgIJAI1Qkfyw88REMA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMRswGQYDVQQKExJNeSBCb2d1cyBS +b290IENlcnQxFDASBgNVBAMTC2V4YW1wbGUuY29tMB4XDTE1MTIwNDIwNTIxNVoX +DTQwMTIwMzIwNTIxNVowVTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3Rh +dGUxGzAZBgNVBAoTEk15IEJvZ3VzIFJvb3QgQ2VydDEUMBIGA1UEAxMLZXhhbXBs +ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQVQpQ2EH4gTJB +NJP6+ocT3xJwT8mSXYUnvzjj6iv+JxZiXRGzAPziNzrrSRKY0yDHF+UiJwuOerLa +n8laZkLb1Ogqzs2u64rKeb0xWv90Qp+eXG0J/1xb4dw+GExqe5QFo1JUJzO/eK7m +1S04SeFkN1qV9mD5yJUy7DGiTUzDHgCxM2tXMLusXYqkxsQQ9+2EJ7BEOK4YJGEx +Sign5FuSxb64PiNow6OA97CaLl7tV4INP4w195ueDRIaS4poeOep4s8U7IAdMjIZ +EryJgKNCij50xK92vPBBJSj0NOitltBlwoEqkOZpQCOZamFd6nvt78LQ6W8Am+l6 +y6oCON5JAgMBAAGjgbgwgbUwHQYDVR0OBBYEFAlrdStDhaayLLj89Whe3Gc+HE8y +MIGFBgNVHSMEfjB8gBQJa3UrQ4Wmsiy4/PVoXtxnPhxPMqFZpFcwVTELMAkGA1UE +BhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxGzAZBgNVBAoTEk15IEJvZ3VzIFJv +b3QgQ2VydDEUMBIGA1UEAxMLZXhhbXBsZS5jb22CCQCNUJH8sPPERDAMBgNVHRME +BTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQC7KAQfDTiNM3QO8Ic3x21CAPJUavkH +zshifN+Ei0+nmseHDTCTgsGfGDOToLUpUEZ4PuiHnz08UwRfd9wotc3SgY9ZaXMe +vRs8KUAF9EoyTvESzPyv2b6cS9NNMpj5y7KyXSyP17VoGbNavtiGQ4dwgEH6VgNl +0RtBvcSBv/tqxIIx1tWzL74tVEm0Kbd9BAZsYpQNKL8e6WXP35/j0PvCCvtofGrA +E8LTqMz4kCwnX+QaJIMJhBophRCsjXdAkvFbFxX0DGPztQtzIwBPcdMjsft7AFeE +0XchhDDXxw8YsbpvPfCvrD8XiiVuBycbnB1zt0LLVwB/QsCzUW9ImpLC +-----END CERTIFICATE----- diff --git a/letsencrypt-auto-source/tests/certs/ca/my-root-ca.key.pem b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.key.pem new file mode 100644 index 000000000..9caa7ddaa --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0FUKUNhB+IEyQTST+vqHE98ScE/Jkl2FJ7844+or/icWYl0R +swD84jc660kSmNMgxxflIicLjnqy2p/JWmZC29ToKs7NruuKynm9MVr/dEKfnlxt +Cf9cW+HcPhhManuUBaNSVCczv3iu5tUtOEnhZDdalfZg+ciVMuwxok1Mwx4AsTNr +VzC7rF2KpMbEEPfthCewRDiuGCRhMUooJ+RbksW+uD4jaMOjgPewmi5e7VeCDT+M +Nfebng0SGkuKaHjnqeLPFOyAHTIyGRK8iYCjQoo+dMSvdrzwQSUo9DTorZbQZcKB +KpDmaUAjmWphXep77e/C0OlvAJvpesuqAjjeSQIDAQABAoIBAH+qbVzneV3wxjwh +HUHi/p3VyHXc3xh7iNq3mwRH/1eK2nPCttLsGwwBbnC64dOXJfH7maWZKcLRPAMv +gfOM0RHn4bJB8tdrbizv91lke0DihvBDkWpb+1wvB4lh2Io0Wpwt3ojFUTfXm87G ++iQRWjbQmQlm5zyKh6uiBDSCjDTQdb9omZEBMAwlGPTZwt8TRUEtWd8QgW8FCHoB +iLER2WBwXdvn3PBtocI3VE6IYDSeZ81Xv+d7925RtVintT8Suk4toYwX+jfSz+wZ +sgHd5V6PSv9a7GUlWoUihD99D9wqDZE8IvMDZ5ofSAUd1KfICDtmsEyugY7u2yYZ +tYt49AECgYEA73f7ITMHg8JsUipqb6eG10gCRtRhkqrrO1g/TNeTBh3CTrQGb56e +y6kmUivn5gK46t3T2N4Ht4IR8fpLcJcbPYPQNulSjmWm5y6WduafXW/VCW1NA9Lc +FyGPkMxFCIVJTLFxfLFepBVvtUzLLDKGGtQxru/GNbBzjdtmVfDPIoECgYEA3rbM +cTfvj+jWrV1YsRbphyjy+k3OJEIVx6KA4s5d7Tp12UfYQp/B3HPhXXm5wqeo1Nos +UAEWZIMi1VoE8iu6jjeJ6uERtbKKQVed25Us/ff0jUPbxlXgiBOtRcllq9d9Srjm +ybHUgfjLsZ2/xpIcOl+oI5pDM9JvD8Sq4ZCFR8kCgYBK/H0tFjeiML2OtS2DLShy +PWBJIbQ0I0Vp3eZkf5TQc30m/ASP61G6YItZa9pAElYpZbEy1cQA2MAZz9DTvt2O +07ndmA57/KTY+6OuM+Vvctd5DjrxmZPFwoKcSvrLAkHDvETXUQtbwkKquRNeEawg +tpWgPAELSufEYhGXk8KpAQKBgBDCqPgMQZcO6rj5QWdyVfi5+C8mE9Fet8ziSdjH +twHXWG8VnQzGgQxaHCewtW4Ut/vsv1D2A/1kcQalU6H18IArZdGrRm3qFcV9FoAj +5dLnChxncu6mH9Odx3htA52/BcrNx3B+VYPCeXHQcVI8RKuP71NelJgdygXhwwpe +mekhAoGBAOUovnqylciYa9HRqo+xZk59eyX+ehhnlV8SeJ2K0PwaQkzQ0KYtCmE7 +kdSdhcv8h/IQKGaFfc/LyFMM/a26PfAeY5bj41UjkT0K5hQrYuL/52xaT401YLcb +Xo+bZz9K0hrdP7TdZFuTY/WxojXgjsVAuAN1NwnJumqxhzPh+hfl +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt-auto-source/tests/certs/ca/my-root-ca.srl b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.srl new file mode 100644 index 000000000..ad6d262b4 --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/ca/my-root-ca.srl @@ -0,0 +1 @@ +D613482D0EF95DD0 diff --git a/letsencrypt-auto-source/tests/certs/localhost/cert.pem b/letsencrypt-auto-source/tests/certs/localhost/cert.pem new file mode 100644 index 000000000..ac83535ce --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/localhost/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhICCQDWE0gtDvld0DANBgkqhkiG9w0BAQUFADBVMQswCQYDVQQGEwJB +VTETMBEGA1UECBMKU29tZS1TdGF0ZTEbMBkGA1UEChMSTXkgQm9ndXMgUm9vdCBD +ZXJ0MRQwEgYDVQQDEwtleGFtcGxlLmNvbTAeFw0xNTEyMDQyMDU0MzFaFw00MDEy +MDMyMDU0MzFaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEw +HwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2Fs +aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK2WIIi86Mis4UQH +a5PrFbX2PBtQHbI3t3ekN1CewRsgQ/2X3lCeWhKmr3CJYXVgA7q/23PORQAiuV6y +DG2dQIrjeahWCXaCptTi49ljfVRTW2IxrHke/iA8TkDuZbWGzVLb8TB83ipBOD41 +SjuomoN4A/ktnIfbNqRqgjjHs2wwJHDfxPiCQlwyOayjHmdlh8cqfVE8rWEm5/3T +Iu0X1J53SammR1SbUmsLJNofxFYMK1ogHb0CaFEG9QuuUDPJl5K74Rr6InMQZKPn +ne4W3cGoALxPHAca7yicpSMSmdsmd6pqylc2Fdua7o/wf0SwShxS4A1DqA/HWLEM +V6MSEF8CAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAz5sMAFG6W/ZEULZITkBTCU6P +NttpGiKufnqyBW5HyNylaczfnHnClvQjr8f/84xvKVcfC3xP0lz+92aIQqo+5L/n +v7gLhBFR4Vr2XwMt2qz2FpkaxmVwnhVAHaaC05WIKQ6W2gDwWT0u1K8YdTh+7mvN +AT9FW4vDgtNZWq4W/PePh9QCiOOQhGOuBYj/7zqLtz4XPifhi66ILIRDHiu0kond +3YMFcECIAf4MPT9vT0iNcWX+c8CfAixPt8nMD6bzOo3oTcfuZh/2enfgLbMqOlOi +uk72FM5VVPXTWAckJvL/vVjqsvDuJQKqbr0oUc3bdWbS36xtWZUycp4IQLguAQ== +-----END CERTIFICATE----- diff --git a/letsencrypt-auto-source/tests/certs/localhost/localhost.csr.pem b/letsencrypt-auto-source/tests/certs/localhost/localhost.csr.pem new file mode 100644 index 000000000..8a6189f88 --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/localhost/localhost.csr.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICnjCCAYYCAQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUx +ITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAxMJbG9j +YWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArZYgiLzoyKzh +RAdrk+sVtfY8G1Adsje3d6Q3UJ7BGyBD/ZfeUJ5aEqavcIlhdWADur/bc85FACK5 +XrIMbZ1AiuN5qFYJdoKm1OLj2WN9VFNbYjGseR7+IDxOQO5ltYbNUtvxMHzeKkE4 +PjVKO6iag3gD+S2ch9s2pGqCOMezbDAkcN/E+IJCXDI5rKMeZ2WHxyp9UTytYSbn +/dMi7RfUnndJqaZHVJtSawsk2h/EVgwrWiAdvQJoUQb1C65QM8mXkrvhGvoicxBk +o+ed7hbdwagAvE8cBxrvKJylIxKZ2yZ3qmrKVzYV25ruj/B/RLBKHFLgDUOoD8dY +sQxXoxIQXwIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAFbg3WrAokoPx7iAYG6z +PqeDd4/XanXjeL4Ryxv6LoGhu69mmBAd3N5ILPyQJjnkWpIjEmJDzEcPMzhQjRh5 +GlWTyvKWO4zClYU840KZk7crVkpzNZ+HP0YeM/Agz6sab00ffRcq5m1wEF9MCvDE +8FUXk1HBHRAb/6t9QV/7axsPOkGT8SjQ1v2SCaiB0HQL3sYChYLi5zu4dfmQNPGq +ar9Xm5a0YqOQIFfmy8RSwxk0Q/ipNFTGN1uvlIRkgbT9zPnodxjWZsSI9BF+q5Af +uiE/oAk7MxfJ0LyLfhOWB+T98bKIOVtFT3wMLS1IIgMogwqCEXFf30Q9p2iTEzqT +6UE= +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt-auto-source/tests/certs/localhost/privkey.pem b/letsencrypt-auto-source/tests/certs/localhost/privkey.pem new file mode 100644 index 000000000..18feba403 --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/localhost/privkey.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEArZYgiLzoyKzhRAdrk+sVtfY8G1Adsje3d6Q3UJ7BGyBD/Zfe +UJ5aEqavcIlhdWADur/bc85FACK5XrIMbZ1AiuN5qFYJdoKm1OLj2WN9VFNbYjGs +eR7+IDxOQO5ltYbNUtvxMHzeKkE4PjVKO6iag3gD+S2ch9s2pGqCOMezbDAkcN/E ++IJCXDI5rKMeZ2WHxyp9UTytYSbn/dMi7RfUnndJqaZHVJtSawsk2h/EVgwrWiAd +vQJoUQb1C65QM8mXkrvhGvoicxBko+ed7hbdwagAvE8cBxrvKJylIxKZ2yZ3qmrK +VzYV25ruj/B/RLBKHFLgDUOoD8dYsQxXoxIQXwIDAQABAoIBAG8bVJ+xKt6nqVg9 +16HKKw9ZGIfy888K0qgFuFImCzwtntdGycmYUdb2Uf0aMgNK/ZgfDXxGXuwDTdtK +46GVsaY0i74vs8bjQZ2pzGVsxN+gqzFi0h6Es+w2LXBqJzfVnL6YgPykMB+jtzg6 +K9Wbyaq0uvZXN4XNzl/WvJtTV4i7Cff1MOd5EhKFdqxrZvB/SRBCr/SMMafRtB9P +EvMneNKzhmlrutHAxuyxEKZR32Kkx7ydAdTjGgn+rE+NL5BweXfeWhLU4Bv14bn9 +Mkneu3w5o1ryJfE2YnVajUP//jeopUT0nTQ3MpEusBQCLBlvFXjjM9uCaFX+5+MP +0H4xVcECgYEA1Q+wR3GHbk37vIGSlbENyUsri5WlMt8IVAHsDsTOpxAjYB0yyo+x +h9RS+RJZQECJlA6H72peUl3GM7RgdWIcKOT3nZ12XqYKG57rr/N5zlUuxbdS8KBk +JhyZeJdYjq/Jrno1ZP+OSmc7VvBLcM7irY7LHlvK0o8W1W0TNJ8jrZkCgYEA0JHX +lJd+fiezcUS7g4moHtzJp0JKquQiXLX+c2urmpyhb3ZrTuQ8OUjSy6DlwHlgDx8K +Hg2sdx/ZCuDaGjR4IY/Qs5RFt9WUqlK9gi9V3nYVrzBOQkdFOf/Ad3j4pQ8/aeCX +nP6snHXz1WqPpbCXG6l6GzFGbQU473GfuKsDuLcCgYAWQaNKc0OQdDj9whNL68ji +5CVSWXl+TOoTzHeaO1jS/s6TNbmei1AiPj3EovQL0DIO802j5tqfhAg2UntZB7yl +UPXE0zQQQwv/QqSgJrDsqt1N7g6N8FNF3+rwO+8WSKqqvT1ipYd5ojsCo+tdh18K +fkYdj70qLaRW+yPsdUtG0QKBgEYc8NqbvsML94+ZKmwCh4iwcf2PFGi0PjTqXTpR +tKNKCh7dMR+ZLAGZ0HrxgKqeYsNSjOUjdZmqFB1LDyaGAuhNXzwvGOy+mLZVEC3G +Wdhp28pDs9sl+EiSCBJhkTxzjr656F23YzFJmYlhxB5P6cw7wbeIbgNSIRylFqtO +mfarAoGBAICsAEWypOctxtmtOcjxgJ7jMbOA7rrsGlXpiy1/WlwIwRGF5LMvIIFX +qFAfiPcZn05ZgdAGzaFYowdjmQB10FW0jZbDf+nIHfOF5YmfmfWjsaweEGALJmqB +okGu/lGNGf3XoYzy0/hC3WAqk3znSZtQLUq8jEWF7dLNUizUeUow +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt-auto-source/tests/certs/localhost/server.pem b/letsencrypt-auto-source/tests/certs/localhost/server.pem new file mode 100644 index 000000000..c5765dd89 --- /dev/null +++ b/letsencrypt-auto-source/tests/certs/localhost/server.pem @@ -0,0 +1,46 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEArZYgiLzoyKzhRAdrk+sVtfY8G1Adsje3d6Q3UJ7BGyBD/Zfe +UJ5aEqavcIlhdWADur/bc85FACK5XrIMbZ1AiuN5qFYJdoKm1OLj2WN9VFNbYjGs +eR7+IDxOQO5ltYbNUtvxMHzeKkE4PjVKO6iag3gD+S2ch9s2pGqCOMezbDAkcN/E ++IJCXDI5rKMeZ2WHxyp9UTytYSbn/dMi7RfUnndJqaZHVJtSawsk2h/EVgwrWiAd +vQJoUQb1C65QM8mXkrvhGvoicxBko+ed7hbdwagAvE8cBxrvKJylIxKZ2yZ3qmrK +VzYV25ruj/B/RLBKHFLgDUOoD8dYsQxXoxIQXwIDAQABAoIBAG8bVJ+xKt6nqVg9 +16HKKw9ZGIfy888K0qgFuFImCzwtntdGycmYUdb2Uf0aMgNK/ZgfDXxGXuwDTdtK +46GVsaY0i74vs8bjQZ2pzGVsxN+gqzFi0h6Es+w2LXBqJzfVnL6YgPykMB+jtzg6 +K9Wbyaq0uvZXN4XNzl/WvJtTV4i7Cff1MOd5EhKFdqxrZvB/SRBCr/SMMafRtB9P +EvMneNKzhmlrutHAxuyxEKZR32Kkx7ydAdTjGgn+rE+NL5BweXfeWhLU4Bv14bn9 +Mkneu3w5o1ryJfE2YnVajUP//jeopUT0nTQ3MpEusBQCLBlvFXjjM9uCaFX+5+MP +0H4xVcECgYEA1Q+wR3GHbk37vIGSlbENyUsri5WlMt8IVAHsDsTOpxAjYB0yyo+x +h9RS+RJZQECJlA6H72peUl3GM7RgdWIcKOT3nZ12XqYKG57rr/N5zlUuxbdS8KBk +JhyZeJdYjq/Jrno1ZP+OSmc7VvBLcM7irY7LHlvK0o8W1W0TNJ8jrZkCgYEA0JHX +lJd+fiezcUS7g4moHtzJp0JKquQiXLX+c2urmpyhb3ZrTuQ8OUjSy6DlwHlgDx8K +Hg2sdx/ZCuDaGjR4IY/Qs5RFt9WUqlK9gi9V3nYVrzBOQkdFOf/Ad3j4pQ8/aeCX +nP6snHXz1WqPpbCXG6l6GzFGbQU473GfuKsDuLcCgYAWQaNKc0OQdDj9whNL68ji +5CVSWXl+TOoTzHeaO1jS/s6TNbmei1AiPj3EovQL0DIO802j5tqfhAg2UntZB7yl +UPXE0zQQQwv/QqSgJrDsqt1N7g6N8FNF3+rwO+8WSKqqvT1ipYd5ojsCo+tdh18K +fkYdj70qLaRW+yPsdUtG0QKBgEYc8NqbvsML94+ZKmwCh4iwcf2PFGi0PjTqXTpR +tKNKCh7dMR+ZLAGZ0HrxgKqeYsNSjOUjdZmqFB1LDyaGAuhNXzwvGOy+mLZVEC3G +Wdhp28pDs9sl+EiSCBJhkTxzjr656F23YzFJmYlhxB5P6cw7wbeIbgNSIRylFqtO +mfarAoGBAICsAEWypOctxtmtOcjxgJ7jMbOA7rrsGlXpiy1/WlwIwRGF5LMvIIFX +qFAfiPcZn05ZgdAGzaFYowdjmQB10FW0jZbDf+nIHfOF5YmfmfWjsaweEGALJmqB +okGu/lGNGf3XoYzy0/hC3WAqk3znSZtQLUq8jEWF7dLNUizUeUow +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDKjCCAhICCQDWE0gtDvld0DANBgkqhkiG9w0BAQUFADBVMQswCQYDVQQGEwJB +VTETMBEGA1UECBMKU29tZS1TdGF0ZTEbMBkGA1UEChMSTXkgQm9ndXMgUm9vdCBD +ZXJ0MRQwEgYDVQQDEwtleGFtcGxlLmNvbTAeFw0xNTEyMDQyMDU0MzFaFw00MDEy +MDMyMDU0MzFaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEw +HwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2Fs +aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK2WIIi86Mis4UQH +a5PrFbX2PBtQHbI3t3ekN1CewRsgQ/2X3lCeWhKmr3CJYXVgA7q/23PORQAiuV6y +DG2dQIrjeahWCXaCptTi49ljfVRTW2IxrHke/iA8TkDuZbWGzVLb8TB83ipBOD41 +SjuomoN4A/ktnIfbNqRqgjjHs2wwJHDfxPiCQlwyOayjHmdlh8cqfVE8rWEm5/3T +Iu0X1J53SammR1SbUmsLJNofxFYMK1ogHb0CaFEG9QuuUDPJl5K74Rr6InMQZKPn +ne4W3cGoALxPHAca7yicpSMSmdsmd6pqylc2Fdua7o/wf0SwShxS4A1DqA/HWLEM +V6MSEF8CAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAz5sMAFG6W/ZEULZITkBTCU6P +NttpGiKufnqyBW5HyNylaczfnHnClvQjr8f/84xvKVcfC3xP0lz+92aIQqo+5L/n +v7gLhBFR4Vr2XwMt2qz2FpkaxmVwnhVAHaaC05WIKQ6W2gDwWT0u1K8YdTh+7mvN +AT9FW4vDgtNZWq4W/PePh9QCiOOQhGOuBYj/7zqLtz4XPifhi66ILIRDHiu0kond +3YMFcECIAf4MPT9vT0iNcWX+c8CfAixPt8nMD6bzOo3oTcfuZh/2enfgLbMqOlOi +uk72FM5VVPXTWAckJvL/vVjqsvDuJQKqbr0oUc3bdWbS36xtWZUycp4IQLguAQ== +-----END CERTIFICATE----- diff --git a/letsencrypt-auto-source/tests/fake-letsencrypt/dist/letsencrypt-99.9.9.tar.gz b/letsencrypt-auto-source/tests/fake-letsencrypt/dist/letsencrypt-99.9.9.tar.gz new file mode 100644 index 000000000..5f9a48a34 Binary files /dev/null and b/letsencrypt-auto-source/tests/fake-letsencrypt/dist/letsencrypt-99.9.9.tar.gz differ diff --git a/letsencrypt-auto-source/tests/fake-letsencrypt/letsencrypt.py b/letsencrypt-auto-source/tests/fake-letsencrypt/letsencrypt.py new file mode 100755 index 000000000..9d811fab5 --- /dev/null +++ b/letsencrypt-auto-source/tests/fake-letsencrypt/letsencrypt.py @@ -0,0 +1,8 @@ +from sys import argv, stderr + + +def main(): + """Act like letsencrypt --version insofar as printing the version number to + stderr.""" + if '--version' in argv: + stderr.write('letsencrypt 99.9.9\n') diff --git a/letsencrypt-auto-source/tests/fake-letsencrypt/setup.py b/letsencrypt-auto-source/tests/fake-letsencrypt/setup.py new file mode 100644 index 000000000..e5f7fde35 --- /dev/null +++ b/letsencrypt-auto-source/tests/fake-letsencrypt/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup + + +setup( + name='letsencrypt', + version='99.9.9', + description='A mock version of letsencrypt that just prints its version', + py_modules=['letsencrypt'], + entry_points={ + 'console_scripts': ['letsencrypt = letsencrypt:main'] + } +) diff --git a/letsencrypt-auto-source/tests/signing.key b/letsencrypt-auto-source/tests/signing.key new file mode 100644 index 000000000..b9964d00c --- /dev/null +++ b/letsencrypt-auto-source/tests/signing.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAsMoSzLYQ7E1sdSOkwelgtzKIh2qi3bpXuYtcfFC0XrvWig07 +1NwIj+dZiT0OLZ2hPispEH0B7ISuuWg1ll7GhFW0VdbxL6JdGzS2ShNWkX9hE9z+ +j8VqwDPOBn3ZHm03qwpYkBDwQib3KqOdYbTTuUtJmmGcuk3a9Aq/sCT6DdfmTSdP +5asdQYwIcaQreDrOosaS84DTWI3IU+UYJVglLsIVPBuy9IcgHidUQ96hJnoPsDCW +sHwX62495QKEarauyKQrJzFes0EY95orDM47Z5o/NDiQB11m91yNB0MmPYY9QSbn +OA9j7IaaC97AwRLuwXY+/R2ablTcxurWou68iQIDAQABAoIBAQCJE3W2Mqk2f+XL +geKa1BjAkzcXQJCduYGRhUQlw/HGzoBPtGki56Tf53MeHTAkIGfIq3CAr1zRhiNv +8SQzvrLQIx/buvhxhcQJdzqsfwgNcqXT3/OliF34P3LMx8GUfPy/6xq2Qdv4fvwA +nLJH8wyDTKP6RxtdvUY7GSZ+Ln2QQv/3Nco7tax4GHNGom8iSgeH/YKTDnvitdqh +a0fr930QzU39TfOftLmasdmKUOIg8G2wr4Sy6Kn060+OUoQr1fZF5mnLvvQeILCK +uav91JkIeMLggzk+t88IJUFWdOoxv5hWTnNzHyt+/GYfovyRz2fKQMwzdh1F8iM5 ++867rEb9AoGBANn1ncemJBedDshStdCBUH0+2ExPrawveaXOZKnx8/VGFXNi0hAf +KzkntMWd5g5kB077FtKO9CYTBvK4pZBWIFLcJEqAz88JeXME6dfUbRucDr72ko+l +rcLHXj7F0IDVzj/9CphMGAhC9J/4YW9SPcSbMw6dQ6xOk73f1Vowve0DAoGBAM+k +/F+hVqCS3f22Bg9KuDtx+zCydaZxC842DgIkV1SO2iFhNHjnpQ5EIR0WrSYeV2n+ +rD7kVs5OH1HvnGScHaQKtAVqZClSwF14jzE+Aj8XDwxiHLSOhJgKlzfVX7h1ymMh +7fsslDl6xNGQ+40gubhkCLT5qABFKy1mrZ8b+3yDAoGAGLGUI6d2FVrM7vM3+Bx+ +gwIYvWSVl5l1XcypaPupmRNMoNsEU6FEY2BVQcJm6yB4F4GpD0f0709ejSdQUq7/ +UIPydKJtaNZ49QgMelBt4B/pJ8eFyVKLAjNWQSRmQAJ5MJS5m5Gbc2wqjOk2GMen +idvPiAtXPHFWmb9/S42UJwMCgYEAjymAe2qgcGtyNNfIC8kHhqzKdEPGi/ALJKzu +MZnewEURrcv4QpfrnA9rCUQ2Mz7eJA1bsqz6EJmaTIK4wEFGynA6uDUnQ7pzOL7D +cz7+i4MZc/89LVvJnY5Hvk4WBfboiDq/etq8g3jatGaSmTYD9la6DhTHORB3eYD+ +meHQHYMCgYEA18y9hnx2k4vNeBei4YXF4pAvKdwKLQD+CcP9ljb3VT+kXktjRA1C +aWj3HhMwvcxtttfkQzEnwwGRAkTEtNewJ8KFxhmc9nYElZTNZ+SuHD5Dkv8xqoj8 +NvG8rU1eiEyPwE2wQxpM5JLqbo7IWtR0dmptjKoF1gRxn6Wh4TwEiHA= +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index 5765003b9..ee679bdb7 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -15,11 +15,12 @@ from acme import crypto_util from acme import messages from letsencrypt import achallenges from letsencrypt import errors as le_errors -from letsencrypt import validator from letsencrypt.tests import acme_util from letsencrypt_compatibility_test import errors from letsencrypt_compatibility_test import util +from letsencrypt_compatibility_test import validator + from letsencrypt_compatibility_test.configurators.apache import apache24 diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz new file mode 100644 index 000000000..7323acf74 Binary files /dev/null and b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz differ diff --git a/letsencrypt/validator.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/validator.py similarity index 100% rename from letsencrypt/validator.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/validator.py diff --git a/letsencrypt/tests/validator_test.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/validator_test.py similarity index 77% rename from letsencrypt/tests/validator_test.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/validator_test.py index c7416dc46..3a3bbc4b2 100644 --- a/letsencrypt/tests/validator_test.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/validator_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.validator.""" +"""Tests for letsencrypt_compatibility_test.validator.""" import requests import unittest @@ -6,28 +6,31 @@ import mock import OpenSSL from acme import errors as acme_errors -from letsencrypt import validator +from letsencrypt_compatibility_test import validator class ValidatorTest(unittest.TestCase): def setUp(self): self.validator = validator.Validator() - @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + @mock.patch( + "letsencrypt_compatibility_test.validator.crypto_util.probe_sni") def test_certificate_success(self, mock_probe_sni): cert = OpenSSL.crypto.X509() mock_probe_sni.return_value = cert self.assertTrue(self.validator.certificate( cert, "test.com", "127.0.0.1")) - @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + @mock.patch( + "letsencrypt_compatibility_test.validator.crypto_util.probe_sni") def test_certificate_error(self, mock_probe_sni): cert = OpenSSL.crypto.X509() mock_probe_sni.side_effect = [acme_errors.Error] self.assertFalse(self.validator.certificate( cert, "test.com", "127.0.0.1")) - @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + @mock.patch( + "letsencrypt_compatibility_test.validator.crypto_util.probe_sni") def test_certificate_failure(self, mock_probe_sni): cert = OpenSSL.crypto.X509() cert.set_serial_number(1337) @@ -35,67 +38,67 @@ class ValidatorTest(unittest.TestCase): self.assertFalse(self.validator.certificate( cert, "test.com", "127.0.0.1")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_succesful_redirect(self, mock_get_request): mock_get_request.return_value = create_response( 301, {"location": "https://test.com"}) self.assertTrue(self.validator.redirect("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_redirect_with_headers(self, mock_get_request): mock_get_request.return_value = create_response( 301, {"location": "https://test.com"}) self.assertTrue(self.validator.redirect( "test.com", headers={"Host": "test.com"})) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_redirect_missing_location(self, mock_get_request): mock_get_request.return_value = create_response(301) self.assertFalse(self.validator.redirect("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_redirect_wrong_status_code(self, mock_get_request): mock_get_request.return_value = create_response( 201, {"location": "https://test.com"}) self.assertFalse(self.validator.redirect("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_redirect_wrong_redirect_code(self, mock_get_request): mock_get_request.return_value = create_response( 303, {"location": "https://test.com"}) self.assertFalse(self.validator.redirect("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts_empty(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": ""}) self.assertFalse(self.validator.hsts("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts_malformed(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "sdfal"}) self.assertFalse(self.validator.hsts("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts_bad_max_age(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "max-age=not-an-int"}) self.assertFalse(self.validator.hsts("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts_expire(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "max-age=3600"}) self.assertFalse(self.validator.hsts("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "max-age=31536000"}) self.assertTrue(self.validator.hsts("test.com")) - @mock.patch("letsencrypt.validator.requests.get") + @mock.patch("letsencrypt_compatibility_test.validator.requests.get") def test_hsts_include_subdomains(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": diff --git a/letsencrypt-compatibility-test/setup.py b/letsencrypt-compatibility-test/setup.py index c791d51c4..b7f448e83 100644 --- a/letsencrypt-compatibility-test/setup.py +++ b/letsencrypt-compatibility-test/setup.py @@ -4,12 +4,13 @@ from setuptools import setup from setuptools import find_packages -version = '0.1.0.dev0' +version = '0.4.0.dev0' install_requires = [ 'letsencrypt=={0}'.format(version), 'letsencrypt-apache=={0}'.format(version), 'docker-py', + 'requests', 'zope.interface', ] @@ -18,6 +19,11 @@ if sys.version_info < (2, 7): else: install_requires.append('mock') +if sys.version_info < (2, 7, 9): + # For secure SSL connexion with Python 2.7 (InsecurePlatformWarning) + install_requires.append('ndg-httpsclient') + install_requires.append('pyasn1') + docs_extras = [ 'repoze.sphinx.autointerface', 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags diff --git a/letsencrypt-nginx/LICENSE.txt b/letsencrypt-nginx/LICENSE.txt index 981c46c9f..02a1459be 100644 --- a/letsencrypt-nginx/LICENSE.txt +++ b/letsencrypt-nginx/LICENSE.txt @@ -12,6 +12,13 @@ See the License for the specific language governing permissions and limitations under the License. + Incorporating code from nginxparser + Copyright 2014 Fatih Erikli + Licensed MIT + + +Text of Apache License +====================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -188,3 +195,22 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +Text of MIT License +=================== +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/letsencrypt-nginx/docs/api/dvsni.rst b/letsencrypt-nginx/docs/api/dvsni.rst deleted file mode 100644 index 4f5f9d7e3..000000000 --- a/letsencrypt-nginx/docs/api/dvsni.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt_nginx.dvsni` ------------------------------- - -.. automodule:: letsencrypt_nginx.dvsni - :members: diff --git a/letsencrypt-nginx/docs/api/tls_sni_01.rst b/letsencrypt-nginx/docs/api/tls_sni_01.rst new file mode 100644 index 000000000..f9f584b0c --- /dev/null +++ b/letsencrypt-nginx/docs/api/tls_sni_01.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt_nginx.tls_sni_01` +----------------------------------- + +.. automodule:: letsencrypt_nginx.tls_sni_01 + :members: diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 29445a9d4..efa7e08b4 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -5,7 +5,6 @@ import re import shutil import socket import subprocess -import sys import time import OpenSSL @@ -24,7 +23,7 @@ from letsencrypt import reverter from letsencrypt.plugins import common from letsencrypt_nginx import constants -from letsencrypt_nginx import dvsni +from letsencrypt_nginx import tls_sni_01 from letsencrypt_nginx import obj from letsencrypt_nginx import parser @@ -106,11 +105,18 @@ class NginxConfigurator(common.Plugin): # This is called in determine_authenticator and determine_installer def prepare(self): - """Prepare the authenticator/installer.""" + """Prepare the authenticator/installer. + + :raises .errors.NoInstallationError: If Nginx ctl cannot be found + :raises .errors.MisconfigurationError: If Nginx is misconfigured + """ # Verify Nginx is installed if not le_util.exe_exists(self.conf('ctl')): raise errors.NoInstallationError + # Make sure configuration is valid + self.config_test() + self.parser = parser.NginxParser( self.conf('server-root'), self.mod_ssl_conf) @@ -122,7 +128,7 @@ class NginxConfigurator(common.Plugin): # Entry point in main.py for installing cert def deploy_cert(self, domain, cert_path, key_path, - chain_path, fullchain_path): + chain_path=None, fullchain_path=None): # pylint: disable=unused-argument """Deploys certificate to specified virtual host. @@ -136,7 +142,15 @@ class NginxConfigurator(common.Plugin): .. note:: This doesn't save the config files! + :raises errors.PluginError: When unable to deploy certificate due to + a lack of directives or configuration + """ + if not fullchain_path: + raise errors.PluginError( + "The nginx plugin currently requires --fullchain-path to " + "install a cert.") + vhost = self.choose_vhost(domain) cert_directives = [['ssl_certificate', fullchain_path], ['ssl_certificate_key', key_path]] @@ -150,6 +164,12 @@ class NginxConfigurator(common.Plugin): ['ssl_stapling', 'on'], ['ssl_stapling_verify', 'on']] + if len(stapling_directives) != 0 and not chain_path: + raise errors.PluginError( + "--chain-path is required to enable " + "Online Certificate Status Protocol (OCSP) stapling " + "on nginx >= 1.3.7.") + try: self.parser.add_server_directives(vhost.filep, vhost.names, cert_directives, replace=True) @@ -168,7 +188,7 @@ class NginxConfigurator(common.Plugin): self.save_notes += ("Changed vhost at %s with addresses of %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs))) - self.save_notes += "\tssl_certificate %s\n" % cert_path + self.save_notes += "\tssl_certificate %s\n" % fullchain_path self.save_notes += "\tssl_certificate_key %s\n" % key_path ####################### @@ -311,17 +331,11 @@ class NginxConfigurator(common.Plugin): """ snakeoil_cert, snakeoil_key = self._get_snakeoil_paths() ssl_block = [['listen', '{0} ssl'.format(self.config.tls_sni_01_port)], - # access and error logs necessary for integration - # testing (non-root) - ['access_log', os.path.join( - self.config.work_dir, 'access.log')], - ['error_log', os.path.join( - self.config.work_dir, 'error.log')], ['ssl_certificate', snakeoil_cert], ['ssl_certificate_key', snakeoil_key], ['include', self.parser.loc["ssl_options"]]] self.parser.add_server_directives( - vhost.filep, vhost.names, ssl_block) + vhost.filep, vhost.names, ssl_block, replace=False) vhost.ssl = True vhost.raw.extend(ssl_block) vhost.addrs.add(obj.Addr( @@ -379,11 +393,12 @@ class NginxConfigurator(common.Plugin): :param unused_options: Not currently used :type unused_options: Not Available """ - redirect_block = [[['if', '($scheme != "https")'], + redirect_block = [[ + ['if', '($scheme != "https")'], [['return', '301 https://$host$request_uri']] ]] - self.parser.add_server_directives(vhost.filep, vhost.names, - redirect_block) + self.parser.add_server_directives( + vhost.filep, vhost.names, redirect_block, replace=False) logger.info("Redirecting all traffic to ssl in %s", vhost.filep) ###################################### @@ -392,35 +407,21 @@ class NginxConfigurator(common.Plugin): def restart(self): """Restarts nginx server. - :returns: Success - :rtype: bool + :raises .errors.MisconfigurationError: If either the reload fails. """ - return nginx_restart(self.conf('ctl'), self.nginx_conf) + nginx_restart(self.conf('ctl'), self.nginx_conf) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Nginx for errors. - :returns: Success - :rtype: bool + :raises .errors.MisconfigurationError: If config_test fails """ try: - proc = subprocess.Popen( - [self.conf('ctl'), "-c", self.nginx_conf, "-t"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - stdout, stderr = proc.communicate() - except (OSError, ValueError): - logger.fatal("Unable to run nginx config test") - sys.exit(1) - - if proc.returncode != 0: - # Enter recovery routine... - logger.error("Config test failed\n%s\n%s", stdout, stderr) - return False - - return True + le_util.run_script([self.conf('ctl'), "-c", self.nginx_conf, "-t"]) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) def _verify_setup(self): """Verify the setup to ensure safe operating environment. @@ -573,15 +574,15 @@ class NginxConfigurator(common.Plugin): """ self._chall_out += len(achalls) responses = [None] * len(achalls) - nginx_dvsni = dvsni.NginxDvsni(self) + chall_doer = tls_sni_01.NginxTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - nginx_dvsni.add_chall(achall, i) + # Currently also have chall_doer hold associated index of the + # challenge. This helps to put all of the responses back together + # when they are all complete. + chall_doer.add_chall(achall, i) - sni_response = nginx_dvsni.perform() + sni_response = chall_doer.perform() # Must restart in order to activate the challenges. # Handled here because we may be able to load up other challenge types self.restart() @@ -590,7 +591,7 @@ class NginxConfigurator(common.Plugin): # in the responses return value. All responses must be in the same order # as the original challenges. for i, resp in enumerate(sni_response): - responses[nginx_dvsni.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses @@ -630,19 +631,16 @@ def nginx_restart(nginx_ctl, nginx_conf="/etc/nginx.conf"): if nginx_proc.returncode != 0: # Enter recovery routine... - logger.error("Nginx Restart Failed!\n%s\n%s", stdout, stderr) - return False + raise errors.MisconfigurationError( + "nginx restart failed:\n%s\n%s" % (stdout, stderr)) except (OSError, ValueError): - logger.fatal("Nginx Restart Failed - Please Check the Configuration") - sys.exit(1) + raise errors.MisconfigurationError("nginx restart failed") # Nginx can take a moment to recognize a newly added TLS SNI servername, so sleep # for a second. TODO: Check for expected servername and loop until it # appears or return an error if looping too long. time.sleep(1) - return True - def temp_install(options_ssl): """Temporary install for convenience.""" diff --git a/letsencrypt-nginx/letsencrypt_nginx/parser.py b/letsencrypt-nginx/letsencrypt_nginx/parser.py index e5c6be0e6..3b1dd049e 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/parser.py +++ b/letsencrypt-nginx/letsencrypt_nginx/parser.py @@ -113,7 +113,7 @@ class NginxParser(object): for filename in servers: for server in servers[filename]: # Parse the server block into a VirtualHost object - parsed_server = _parse_server(server) + parsed_server = parse_server(server) vhost = obj.VirtualHost(filename, parsed_server['addrs'], parsed_server['ssl'], @@ -213,6 +213,7 @@ class NginxParser(object): if ext: filename = filename + os.path.extsep + ext try: + logger.debug('Dumping to %s:\n%s', filename, nginxparser.dumps(tree)) with open(filename, 'w') as _file: nginxparser.dump(tree, _file) except IOError: @@ -252,7 +253,7 @@ class NginxParser(object): return server_names == names def add_server_directives(self, filename, names, directives, - replace=False): + replace): """Add or replace directives in the first server block with names. ..note :: If replace is True, this raises a misconfiguration error @@ -269,20 +270,27 @@ class NginxParser(object): :param bool replace: Whether to only replace existing directives """ - _do_for_subarray(self.parsed[filename], - lambda x: self._has_server_names(x, names), - lambda x: _add_directives(x, directives, replace)) + try: + _do_for_subarray(self.parsed[filename], + lambda x: self._has_server_names(x, names), + lambda x: _add_directives(x, directives, replace)) + except errors.MisconfigurationError as err: + raise errors.MisconfigurationError("Problem in %s: %s" % (filename, err.message)) def add_http_directives(self, filename, directives): """Adds directives to the first encountered HTTP block in filename. + We insert new directives at the top of the block to work around + https://trac.nginx.org/nginx/ticket/810: If the first server block + doesn't enable OCSP stapling, stapling is broken for all blocks. + :param str filename: The absolute filename of the config file :param list directives: The directives to add """ _do_for_subarray(self.parsed[filename], lambda x: x[0] == ['http'], - lambda x: _add_directives(x[1], [directives], False)) + lambda x: x[1].insert(0, directives)) def get_all_certs_keys(self): """Gets all certs and keys in the nginx config. @@ -413,7 +421,7 @@ def _regex_match(target_name, name): return True else: return False - except re.error: # pragma: no cover + except re.error: # pragma: no cover # perl-compatible regexes are sometimes not recognized by python return False @@ -443,7 +451,7 @@ def _get_servernames(names): return names.split(' ') -def _parse_server(server): +def parse_server(server): """Parses a list of server directives. :param list server: list of directives in a server block @@ -463,13 +471,20 @@ def _parse_server(server): elif directive[0] == 'server_name': parsed_server['names'].update( _get_servernames(directive[1])) + elif directive[0] == 'ssl' and directive[1] == 'on': + parsed_server['ssl'] = True return parsed_server -def _add_directives(block, directives, replace=False): - """Adds or replaces directives in a block. If the directive doesn't exist in - the entry already, raises a misconfiguration error. +def _add_directives(block, directives, replace): + """Adds or replaces directives in a config block. + + When replace=False, it's an error to try and add a directive that already + exists in the config block with a conflicting value. + + When replace=True, a directive with the same name MUST already exist in the + config block, and the first instance will be replaced. ..todo :: Find directives that are in included files. @@ -478,21 +493,43 @@ def _add_directives(block, directives, replace=False): """ for directive in directives: - if not replace: - # We insert new directives at the top of the block, mostly - # to work around https://trac.nginx.org/nginx/ticket/810 - # Only add directive if its not already in the block - if directive not in block: - block.insert(0, directive) - else: - changed = False - if len(directive) == 0: - continue - for index, line in enumerate(block): - if len(line) > 0 and line[0] == directive[0]: - block[index] = directive - changed = True - if not changed: + _add_directive(block, directive, replace) + +repeatable_directives = set(['server_name', 'listen', 'include']) + +def _add_directive(block, directive, replace): + """Adds or replaces a single directive in a config block. + + See _add_directives for more documentation. + + """ + location = -1 + # Find the index of a config line where the name of the directive matches + # the name of the directive we want to add. + for index, line in enumerate(block): + if len(line) > 0 and line[0] == directive[0]: + location = index + break + if replace: + if location == -1: + raise errors.MisconfigurationError( + 'expected directive for %s in the Nginx ' + 'config but did not find it.' % directive[0]) + block[location] = directive + else: + # Append directive. Fail if the name is not a repeatable directive name, + # and there is already a copy of that directive with a different value + # in the config file. + directive_name = directive[0] + directive_value = directive[1] + if location != -1 and directive_name.__str__() not in repeatable_directives: + if block[location][1] == directive_value: + # There's a conflict, but the existing value matches the one we + # want to insert, so it's fine. + pass + else: raise errors.MisconfigurationError( - 'Let\'s Encrypt expected directive for %s in the Nginx ' - 'config but did not find it.' % directive[0]) + 'tried to insert directive "%s" but found conflicting "%s".' % ( + directive, block[location])) + else: + block.append(directive) diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py index ff720ea85..4fce33079 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py @@ -40,6 +40,24 @@ class NginxConfiguratorTest(util.NginxTest): self.assertEquals((1, 6, 2), self.config.version) self.assertEquals(5, len(self.config.parser.parsed)) + @mock.patch("letsencrypt_nginx.configurator.le_util.exe_exists") + @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") + def test_prepare_initializes_version(self, mock_popen, mock_exe_exists): + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/1.6.2", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "TLS SNI support enabled", + "configure arguments: --prefix=/usr/local/Cellar/" + "nginx/1.6.2 --with-http_ssl_module"])) + + mock_exe_exists.return_value = True + + self.config.version = None + self.config.config_test = mock.Mock() + self.config.prepare() + self.assertEquals((1, 6, 2), self.config.version) + @mock.patch("letsencrypt_nginx.configurator.socket.gethostbyaddr") def test_get_all_names(self, mock_gethostbyaddr): mock_gethostbyaddr.return_value = ('155.225.50.69.nephoscale.net', [], []) @@ -65,16 +83,19 @@ class NginxConfiguratorTest(util.NginxTest): filep = self.config.parser.abs_path('sites-enabled/example.com') self.config.parser.add_server_directives( filep, set(['.example.com', 'example.*']), - [['listen', '5001 ssl']]) + [['listen', '5001 ssl']], + replace=False) self.config.save() # pylint: disable=protected-access parsed = self.config.parser._parse_files(filep, override=True) - self.assertEqual([[['server'], [['listen', '5001 ssl'], + self.assertEqual([[['server'], [ ['listen', '69.50.225.155:9000'], ['listen', '127.0.0.1'], ['server_name', '.example.com'], - ['server_name', 'example.*']]]], + ['server_name', 'example.*'], + ['listen', '5001 ssl'] + ]]], parsed[0]) def test_choose_vhost(self): @@ -91,12 +112,26 @@ class NginxConfiguratorTest(util.NginxTest): 'test.www.example.com': foo_conf, 'abc.www.foo.com': foo_conf, 'www.bar.co.uk': localhost_conf} + + conf_path = {'localhost': "etc_nginx/nginx.conf", + 'alias': "etc_nginx/nginx.conf", + 'example.com': "etc_nginx/sites-enabled/example.com", + 'example.com.uk.test': "etc_nginx/sites-enabled/example.com", + 'www.example.com': "etc_nginx/sites-enabled/example.com", + 'test.www.example.com': "etc_nginx/foo.conf", + 'abc.www.foo.com': "etc_nginx/foo.conf", + 'www.bar.co.uk': "etc_nginx/nginx.conf"} + bad_results = ['www.foo.com', 'example', 't.www.bar.co', '69.255.225.155'] for name in results: - self.assertEqual(results[name], - self.config.choose_vhost(name).names) + vhost = self.config.choose_vhost(name) + path = os.path.relpath(vhost.filep, self.temp_dir) + + self.assertEqual(results[name], vhost.names) + self.assertEqual(conf_path[name], path) + for name in bad_results: self.assertEqual(set([name]), self.config.choose_vhost(name).names) @@ -125,6 +160,24 @@ class NginxConfiguratorTest(util.NginxTest): self.assertTrue(util.contains_at_depth(generated_conf, ['ssl_trusted_certificate', 'example/chain.pem'], 2)) + def test_deploy_cert_stapling_requires_chain_path(self): + self.config.version = (1, 3, 7) + self.assertRaises(errors.PluginError, self.config.deploy_cert, + "www.example.com", + "example/cert.pem", + "example/key.pem", + None, + "example/fullchain.pem") + + def test_deploy_cert_requires_fullchain_path(self): + self.config.version = (1, 3, 1) + self.assertRaises(errors.PluginError, self.config.deploy_cert, + "www.example.com", + "example/cert.pem", + "example/key.pem", + "example/chain.pem", + None) + def test_deploy_cert(self): server_conf = self.config.parser.abs_path('server.conf') nginx_conf = self.config.parser.abs_path('nginx.conf') @@ -154,38 +207,36 @@ class NginxConfiguratorTest(util.NginxTest): parsed_server_conf = util.filter_comments(self.config.parser.parsed[server_conf]) parsed_nginx_conf = util.filter_comments(self.config.parser.parsed[nginx_conf]) - access_log = os.path.join(self.work_dir, "access.log") - error_log = os.path.join(self.work_dir, "error.log") self.assertEqual([[['server'], - [['include', self.config.parser.loc["ssl_options"]], - ['ssl_certificate_key', 'example/key.pem'], - ['ssl_certificate', 'example/fullchain.pem'], - ['error_log', error_log], - ['access_log', access_log], - - ['listen', '5001 ssl'], + [ ['listen', '69.50.225.155:9000'], ['listen', '127.0.0.1'], ['server_name', '.example.com'], - ['server_name', 'example.*']]]], + ['server_name', 'example.*'], + + ['listen', '5001 ssl'], + ['ssl_certificate', 'example/fullchain.pem'], + ['ssl_certificate_key', 'example/key.pem'], + ['include', self.config.parser.loc["ssl_options"]] + ]]], parsed_example_conf) self.assertEqual([['server_name', 'somename alias another.alias']], parsed_server_conf) - self.assertTrue(util.contains_at_depth(parsed_nginx_conf, - [['server'], - [['include', self.config.parser.loc["ssl_options"]], - ['ssl_certificate_key', '/etc/nginx/key.pem'], - ['ssl_certificate', '/etc/nginx/fullchain.pem'], - ['error_log', error_log], - ['access_log', access_log], - ['listen', '5001 ssl'], - ['listen', '8000'], - ['listen', 'somename:8080'], - ['include', 'server.conf'], - [['location', '/'], - [['root', 'html'], - ['index', 'index.html index.htm']]]]], - 2)) + self.assertTrue(util.contains_at_depth( + parsed_nginx_conf, + [['server'], + [ + ['listen', '8000'], + ['listen', 'somename:8080'], + ['include', 'server.conf'], + [['location', '/'], + [['root', 'html'], + ['index', 'index.html index.htm']]], + ['listen', '5001 ssl'], + ['ssl_certificate', '/etc/nginx/fullchain.pem'], + ['ssl_certificate_key', '/etc/nginx/key.pem'], + ['include', self.config.parser.loc["ssl_options"]]]], + 2)) def test_get_all_certs_keys(self): nginx_conf = self.config.parser.abs_path('nginx.conf') @@ -212,9 +263,9 @@ class NginxConfiguratorTest(util.NginxTest): ('/etc/nginx/fullchain.pem', '/etc/nginx/key.pem', nginx_conf), ]), self.config.get_all_certs_keys()) - @mock.patch("letsencrypt_nginx.configurator.dvsni.NginxDvsni.perform") + @mock.patch("letsencrypt_nginx.configurator.tls_sni_01.NginxTlsSni01.perform") @mock.patch("letsencrypt_nginx.configurator.NginxConfigurator.restart") - def test_perform(self, mock_restart, mock_dvsni_perform): + def test_perform(self, mock_restart, mock_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded achall1 = achallenges.KeyAuthorizationAnnotatedChallenge( @@ -230,16 +281,16 @@ class NginxConfiguratorTest(util.NginxTest): status=messages.Status("pending"), ), domain="example.com", account_key=self.rsa512jwk) - dvsni_ret_val = [ + expected = [ achall1.response(self.rsa512jwk), achall2.response(self.rsa512jwk), ] - mock_dvsni_perform.return_value = dvsni_ret_val + mock_perform.return_value = expected responses = self.config.perform([achall1, achall2]) - self.assertEqual(mock_dvsni_perform.call_count, 1) - self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_perform.call_count, 1) + self.assertEqual(responses, expected) self.assertEqual(mock_restart.call_count, 1) @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") @@ -297,26 +348,28 @@ class NginxConfiguratorTest(util.NginxTest): mocked = mock_popen() mocked.communicate.return_value = ('', '') mocked.returncode = 0 - self.assertTrue(self.config.restart()) + self.config.restart() @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") def test_nginx_restart_fail(self, mock_popen): mocked = mock_popen() mocked.communicate.return_value = ('', '') mocked.returncode = 1 - self.assertFalse(self.config.restart()) + self.assertRaises(errors.MisconfigurationError, self.config.restart) @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") def test_no_nginx_start(self, mock_popen): mock_popen.side_effect = OSError("Can't find program") - self.assertRaises(SystemExit, self.config.restart) + self.assertRaises(errors.MisconfigurationError, self.config.restart) - @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") - def test_config_test(self, mock_popen): - mocked = mock_popen() - mocked.communicate.return_value = ('', '') - mocked.returncode = 0 - self.assertTrue(self.config.config_test()) + @mock.patch("letsencrypt.le_util.run_script") + def test_config_test(self, _): + self.config.config_test() + + @mock.patch("letsencrypt.le_util.run_script") + def test_config_test_bad_process(self, mock_run_script): + mock_run_script.side_effect = errors.SubprocessError + self.assertRaises(errors.MisconfigurationError, self.config.config_test) def test_get_snakeoil_paths(self): # pylint: disable=protected-access @@ -330,6 +383,17 @@ class NginxConfiguratorTest(util.NginxTest): OpenSSL.crypto.load_privatekey( OpenSSL.crypto.FILETYPE_PEM, key_file.read()) + def test_redirect_enhance(self): + expected = [ + ['if', '($scheme != "https")'], + [['return', '301 https://$host$request_uri']] + ] + + example_conf = self.config.parser.abs_path('sites-enabled/example.com') + self.config.enhance("www.example.com", "redirect") + + generated_conf = self.config.parser.parsed[example_conf] + self.assertTrue(util.contains_at_depth(generated_conf, expected, 2)) if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/parser_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/parser_test.py index 2d6156429..b64f1dee3 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/parser_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/parser_test.py @@ -127,7 +127,8 @@ class NginxParserTest(util.NginxTest): set(['localhost', r'~^(www\.)?(example|bar)\.']), [['foo', 'bar'], ['ssl_certificate', - '/etc/ssl/cert.pem']]) + '/etc/ssl/cert.pem']], + replace=False) ssl_re = re.compile(r'\n\s+ssl_certificate /etc/ssl/cert.pem') dump = nginxparser.dumps(nparser.parsed[nparser.abs_path('nginx.conf')]) self.assertEqual(1, len(re.findall(ssl_re, dump))) @@ -136,12 +137,15 @@ class NginxParserTest(util.NginxTest): names = set(['alias', 'another.alias', 'somename']) nparser.add_server_directives(server_conf, names, [['foo', 'bar'], ['ssl_certificate', - '/etc/ssl/cert2.pem']]) - nparser.add_server_directives(server_conf, names, [['foo', 'bar']]) + '/etc/ssl/cert2.pem']], + replace=False) + nparser.add_server_directives(server_conf, names, [['foo', 'bar']], + replace=False) self.assertEqual(nparser.parsed[server_conf], - [['ssl_certificate', '/etc/ssl/cert2.pem'], + [['server_name', 'somename alias another.alias'], ['foo', 'bar'], - ['server_name', 'somename alias another.alias']]) + ['ssl_certificate', '/etc/ssl/cert2.pem'] + ]) def test_add_http_directives(self): nparser = parser.NginxParser(self.config_path, self.ssl_options) @@ -165,17 +169,19 @@ class NginxParserTest(util.NginxTest): target = set(['.example.com', 'example.*']) filep = nparser.abs_path('sites-enabled/example.com') nparser.add_server_directives( - filep, target, [['server_name', 'foo bar']], True) + filep, target, [['server_name', 'foobar.com']], replace=True) self.assertEqual( nparser.parsed[filep], [[['server'], [['listen', '69.50.225.155:9000'], ['listen', '127.0.0.1'], - ['server_name', 'foo bar'], - ['server_name', 'foo bar']]]]) + ['server_name', 'foobar.com'], + ['server_name', 'example.*'], + ]]]) self.assertRaises(errors.MisconfigurationError, nparser.add_server_directives, - filep, set(['foo', 'bar']), - [['ssl_certificate', 'cert.pem']], True) + filep, set(['foobar.com', 'example.*']), + [['ssl_certificate', 'cert.pem']], + replace=True) def test_get_best_match(self): target_name = 'www.eff.org' @@ -217,10 +223,31 @@ class NginxParserTest(util.NginxTest): set(['.example.com', 'example.*']), [['ssl_certificate', 'foo.pem'], ['ssl_certificate_key', 'bar.key'], - ['listen', '443 ssl']]) + ['listen', '443 ssl']], + replace=False) c_k = nparser.get_all_certs_keys() self.assertEqual(set([('foo.pem', 'bar.key', filep)]), c_k) + def test_parse_server_ssl(self): + server = parser.parse_server([ + ['listen', '443'] + ]) + self.assertFalse(server['ssl']) + + server = parser.parse_server([ + ['listen', '443 ssl'] + ]) + self.assertTrue(server['ssl']) + + server = parser.parse_server([ + ['listen', '443'], ['ssl', 'off'] + ]) + self.assertFalse(server['ssl']) + + server = parser.parse_server([ + ['listen', '443'], ['ssl', 'on'] + ]) + self.assertTrue(server['ssl']) if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py similarity index 95% rename from letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py rename to letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py index d32e3d98f..04fe01bc4 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt_nginx.dvsni.""" +"""Tests for letsencrypt_nginx.tls_sni_01""" import unittest import shutil @@ -16,8 +16,8 @@ from letsencrypt_nginx import obj from letsencrypt_nginx.tests import util -class DvsniPerformTest(util.NginxTest): - """Test the NginxDVSNI challenge.""" +class TlsSniPerformTest(util.NginxTest): + """Test the NginxTlsSni01 challenge.""" account_key = common_test.TLSSNI01Test.auth_key achalls = [ @@ -42,13 +42,13 @@ class DvsniPerformTest(util.NginxTest): ] def setUp(self): - super(DvsniPerformTest, self).setUp() + super(TlsSniPerformTest, self).setUp() config = util.get_nginx_configurator( self.config_path, self.config_dir, self.work_dir) - from letsencrypt_nginx import dvsni - self.sni = dvsni.NginxDvsni(config) + from letsencrypt_nginx import tls_sni_01 + self.sni = tls_sni_01.NginxTlsSni01(config) def tearDown(self): shutil.rmtree(self.temp_dir) diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py index e60feb3d3..7a16e3738 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py @@ -49,25 +49,26 @@ def get_nginx_configurator( backups = os.path.join(work_dir, "backups") - with mock.patch("letsencrypt_nginx.configurator.le_util." - "exe_exists") as mock_exe_exists: - mock_exe_exists.return_value = True - - config = configurator.NginxConfigurator( - config=mock.MagicMock( - nginx_server_root=config_path, - le_vhost_ext="-le-ssl.conf", - config_dir=config_dir, - work_dir=work_dir, - backup_dir=backups, - temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), - in_progress_dir=os.path.join(backups, "IN_PROGRESS"), - server="https://acme-server.org:443/new", - tls_sni_01_port=5001, - ), - name="nginx", - version=version) - config.prepare() + with mock.patch("letsencrypt_nginx.configurator.NginxConfigurator." + "config_test"): + with mock.patch("letsencrypt_nginx.configurator.le_util." + "exe_exists") as mock_exe_exists: + mock_exe_exists.return_value = True + config = configurator.NginxConfigurator( + config=mock.MagicMock( + nginx_server_root=config_path, + le_vhost_ext="-le-ssl.conf", + config_dir=config_dir, + work_dir=work_dir, + backup_dir=backups, + temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), + in_progress_dir=os.path.join(backups, "IN_PROGRESS"), + server="https://acme-server.org:443/new", + tls_sni_01_port=5001, + ), + name="nginx", + version=version) + config.prepare() # Provide general config utility. nsconfig = configuration.NamespaceConfig(config.config) diff --git a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py similarity index 82% rename from letsencrypt-nginx/letsencrypt_nginx/dvsni.py rename to letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py index 8fd705f08..e59281c4c 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py @@ -1,4 +1,5 @@ -"""NginxDVSNI""" +"""A class that performs TLS-SNI-01 challenges for Nginx""" + import itertools import logging import os @@ -13,31 +14,32 @@ from letsencrypt_nginx import nginxparser logger = logging.getLogger(__name__) -class NginxDvsni(common.TLSSNI01): - """Class performs DVSNI challenges within the Nginx configurator. +class NginxTlsSni01(common.TLSSNI01): + """TLS-SNI-01 authenticator for Nginx :ivar configurator: NginxConfigurator object :type configurator: :class:`~nginx.configurator.NginxConfigurator` - :ivar list achalls: Annotated :class:`~letsencrypt.achallenges.DVSNI` - challenges. + :ivar list achalls: Annotated + class:`~letsencrypt.achallenges.KeyAuthorizationAnnotatedChallenge` + challenges :param list indices: Meant to hold indices of challenges in a - larger array. NginxDvsni is capable of solving many challenges + larger array. NginxTlsSni01 is capable of solving many challenges at once which causes an indexing issue within NginxConfigurator who must return all responses in order. Imagine NginxConfigurator maintaining state about where all of the http-01 Challenges, - Dvsni Challenges belong in the response array. This is an optional - utility. + TLS-SNI-01 Challenges belong in the response array. This is an + optional utility. :param str challenge_conf: location of the challenge config file """ def perform(self): - """Perform a DVSNI challenge on Nginx. + """Perform a challenge on Nginx. - :returns: list of :class:`letsencrypt.acme.challenges.DVSNIResponse` + :returns: list of :class:`letsencrypt.acme.challenges.TLSSNI01Response` :rtype: list """ @@ -84,7 +86,8 @@ class NginxDvsni(common.TLSSNI01): :class:`letsencrypt_nginx.obj.Addr` to apply :raises .MisconfigurationError: - Unable to find a suitable HTTP block to include DVSNI hosts. + Unable to find a suitable HTTP block in which to include + authenticator hosts. """ # Add the 'include' statement for the challenges if it doesn't exist @@ -110,8 +113,8 @@ class NginxDvsni(common.TLSSNI01): break if not included: raise errors.MisconfigurationError( - 'LetsEncrypt could not find an HTTP block to include DVSNI ' - 'challenges in %s.' % root) + 'LetsEncrypt could not find an HTTP block to include ' + 'TLS-SNI-01 challenges in %s.' % root) config = [self._make_server_block(pair[0], pair[1]) for pair in itertools.izip(self.achalls, ll_addrs)] @@ -123,10 +126,11 @@ class NginxDvsni(common.TLSSNI01): nginxparser.dump(config, new_conf) def _make_server_block(self, achall, addrs): - """Creates a server block for a DVSNI challenge. + """Creates a server block for a challenge. - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` + :param achall: Annotated TLS-SNI-01 challenge + :type achall: + :class:`letsencrypt.achallenges.KeyAuthorizationAnnotatedChallenge` :param list addrs: addresses of challenged domain :class:`list` of type :class:`~nginx.obj.Addr` @@ -136,7 +140,7 @@ class NginxDvsni(common.TLSSNI01): """ document_root = os.path.join( - self.configurator.config.work_dir, "dvsni_page") + self.configurator.config.work_dir, "tls_sni_01_page") block = [['listen', str(addr)] for addr in addrs] diff --git a/letsencrypt-nginx/setup.py b/letsencrypt-nginx/setup.py index a669ad841..c1ff85185 100644 --- a/letsencrypt-nginx/setup.py +++ b/letsencrypt-nginx/setup.py @@ -4,8 +4,9 @@ from setuptools import setup from setuptools import find_packages -version = '0.1.0.dev0' +version = '0.4.0.dev0' +# Please update tox.ini when modifying dependency version requirements install_requires = [ 'acme=={0}'.format(version), 'letsencrypt=={0}'.format(version), @@ -62,4 +63,5 @@ setup( 'nginx = letsencrypt_nginx.configurator:NginxConfigurator', ], }, + test_suite='letsencrypt_nginx', ) diff --git a/letsencrypt-nginx/tests/boulder-integration.conf.sh b/letsencrypt-nginx/tests/boulder-integration.conf.sh index 12610d895..d77669a76 100755 --- a/letsencrypt-nginx/tests/boulder-integration.conf.sh +++ b/letsencrypt-nginx/tests/boulder-integration.conf.sh @@ -20,13 +20,14 @@ events { } http { - # Set an array of temp and cache file options that will otherwise default to + # Set an array of temp, cache and log file options that will otherwise default to # restricted locations accessible only to root. client_body_temp_path $root/client_body; fastcgi_temp_path $root/fastcgi_temp; proxy_temp_path $root/proxy_temp; #scgi_temp_path $root/scgi_temp; #uwsgi_temp_path $root/uwsgi_temp; + access_log $root/error.log; # This should be turned off in a Virtualbox VM, as it can cause some # interesting issues with data corruption in delivered files. @@ -54,9 +55,6 @@ http { root $root/webroot; - access_log $root/access.log; - error_log $root/error.log; - location / { # First attempt to serve request as file, then as directory, then fall # back to index.html. diff --git a/letsencrypt/DISCLAIMER b/letsencrypt/DISCLAIMER deleted file mode 100644 index dd7759361..000000000 --- a/letsencrypt/DISCLAIMER +++ /dev/null @@ -1,5 +0,0 @@ -This is a PREVIEW RELEASE of a client application for the Let's Encrypt certificate authority and other services using the ACME protocol. The Let's Encrypt certificate authority is NOT YET ISSUING CERTIFICATES TO THE PUBLIC. - -Until publicly-trusted certificates can be issued by Let's Encrypt, this software CANNOT OBTAIN A PUBLICLY-TRUSTED CERTIFICATE FOR YOUR WEB SERVER. You should only use this program if you are a developer interested in experimenting with the ACME protocol or in helping to improve this software. If you want to configure your web site with HTTPS in the meantime, please obtain a certificate from a different authority. - -For updates on the status of Let's Encrypt, please visit the Let's Encrypt home page at https://letsencrypt.org/. diff --git a/letsencrypt/__init__.py b/letsencrypt/__init__.py index 1155a5b0c..1dd7d7eba 100644 --- a/letsencrypt/__init__.py +++ b/letsencrypt/__init__.py @@ -1,4 +1,4 @@ """Let's Encrypt client.""" # version number like 1.2.3a0, must have at least 2 parts, like 1.2 -__version__ = '0.1.0.dev0' +__version__ = '0.4.0.dev0' diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 027c11158..c63d8c8d4 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -540,16 +540,11 @@ def _generate_failed_chall_msg(failed_achalls): """ typ = failed_achalls[0].error.typ - msg = [ - "The following '{0}' errors were reported by the server:".format(typ)] + msg = ["The following errors were reported by the server:"] - problems = dict() for achall in failed_achalls: - problems.setdefault(achall.error.description, set()).add(achall.domain) - for problem in problems: - msg.append("\n\nDomains: ") - msg.append(", ".join(sorted(problems[problem]))) - msg.append("\nError: {0}".format(problem)) + msg.append("\n\nDomain: %s\nType: %s\nDetail: %s" % ( + achall.domain, achall.error.typ, achall.error.detail)) if typ in _ERROR_HELP: msg.append("\n\n") diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 19cfc76f9..c15c9e6a6 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1,5 +1,9 @@ """Let's Encrypt CLI.""" +from __future__ import print_function + # TODO: Sanity check all input. Be sure to avoid shell code etc... +# pylint: disable=too-many-lines +# (TODO: split this file into main.py and cli.py) import argparse import atexit import functools @@ -7,8 +11,6 @@ import json import logging import logging.handlers import os -import pkg_resources -import string import sys import time import traceback @@ -42,6 +44,15 @@ from letsencrypt.plugins import disco as plugins_disco logger = logging.getLogger(__name__) +# For help strings, figure out how the user ran us. +# When invoked from letsencrypt-auto, sys.argv[0] is something like: +# "/home/user/.local/share/letsencrypt/bin/letsencrypt" +# Note that this won't work if the user set VENV_PATH or XDG_DATA_HOME before running +# letsencrypt-auto (and sudo stops us from seeing if they did), so it should only be used +# for purposes where inability to detect letsencrypt-auto fails safely + +fragment = os.path.join(".local", "share", "letsencrypt") +cli_command = "letsencrypt-auto" if fragment in sys.argv[0] else "letsencrypt" # Argparse's help formatting has a lot of unhelpful peculiarities, so we want # to replace as much of it as we can... @@ -49,7 +60,7 @@ logger = logging.getLogger(__name__) # This is the stub to include in help generated by argparse SHORT_USAGE = """ - letsencrypt [SUBCOMMAND] [options] [-d domain] [-d domain] ... + {0} [SUBCOMMAND] [options] [-d domain] [-d domain] ... The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates. By default, it will attempt to use a webserver both for obtaining and installing @@ -61,8 +72,9 @@ the cert. Major SUBCOMMANDS are: revoke Revoke a previously obtained certificate rollback Rollback server configuration changes made during install config_changes Show changes made to server config during installation + plugins Display information about installed plugins -""" +""".format(cli_command) # This is the short help for letsencrypt --help, where we disable argparse # altogether @@ -73,7 +85,7 @@ USAGE = SHORT_USAGE + """Choice of server plugins for obtaining and installing c %s --webroot Place files in a server's webroot folder for authentication -OR use different servers to obtain (authenticate) the cert and then install it: +OR use different plugins to obtain (authenticate) the cert and then install it: --authenticator standalone --installer apache @@ -158,12 +170,14 @@ def _determine_account(args, config): "must agree in order to register with the ACME " "server at {1}".format( regr.terms_of_service, config.server)) - return zope.component.getUtility(interfaces.IDisplay).yesno( - msg, "Agree", "Cancel") + obj = zope.component.getUtility(interfaces.IDisplay) + return obj.yesno(msg, "Agree", "Cancel", cli_flag="--agree-tos") try: acc, acme = client.register( config, account_storage, tos_cb=_tos_cb) + except errors.MissingCommandlineFlag: + raise except errors.Error as error: logger.debug(error, exc_info=True) raise errors.Error( @@ -197,6 +211,8 @@ def _find_duplicative_certs(config, domains): le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid()) for renewal_file in os.listdir(configs_dir): + if not renewal_file.endswith(".conf"): + continue try: full_path = os.path.join(configs_dir, renewal_file) candidate_lineage = storage.RenewableCert(full_path, cli_config) @@ -210,76 +226,132 @@ def _find_duplicative_certs(config, domains): if candidate_names == set(domains): identical_names_cert = candidate_lineage elif candidate_names.issubset(set(domains)): - subset_names_cert = candidate_lineage + # This logic finds and returns the largest subset-names cert + # in the case where there are several available. + if subset_names_cert is None: + subset_names_cert = candidate_lineage + elif len(candidate_names) > len(subset_names_cert.names()): + subset_names_cert = candidate_lineage return identical_names_cert, subset_names_cert def _treat_as_renewal(config, domains): - """Determine whether or not the call should be treated as a renewal. + """Determine whether there are duplicated names and how to handle them. - :returns: RenewableCert or None if renewal shouldn't occur. - :rtype: :class:`.storage.RenewableCert` + :returns: Two-element tuple containing desired new-certificate behavior as + a string token ("reinstall", "renew", or "newcert"), plus either + a RenewableCert instance or None if renewal shouldn't occur. :raises .Error: If the user would like to rerun the client again. """ - renewal = False - # Considering the possibility that the requested certificate is # related to an existing certificate. (config.duplicate, which # is set with --duplicate, skips all of this logic and forces any # kind of certificate to be obtained with renewal = False.) - if not config.duplicate: - ident_names_cert, subset_names_cert = _find_duplicative_certs( - config, domains) - # I am not sure whether that correctly reads the systemwide - # configuration file. - question = None - if ident_names_cert is not None: - question = ( - "You have an existing certificate that contains exactly the " - "same domains you requested (ref: {0}){br}{br}Do you want to " - "renew and replace this certificate with a newly-issued one?" - ).format(ident_names_cert.configfile.filename, br=os.linesep) - elif subset_names_cert is not None: - question = ( - "You have an existing certificate that contains a portion of " - "the domains you requested (ref: {0}){br}{br}It contains these " - "names: {1}{br}{br}You requested these names for the new " - "certificate: {2}.{br}{br}Do you want to replace this existing " - "certificate with the new certificate?" - ).format(subset_names_cert.configfile.filename, - ", ".join(subset_names_cert.names()), - ", ".join(domains), - br=os.linesep) - if question is None: - # We aren't in a duplicative-names situation at all, so we don't - # have to tell or ask the user anything about this. - pass - elif config.renew_by_default or zope.component.getUtility( - interfaces.IDisplay).yesno(question, "Replace", "Cancel"): - renewal = True - else: - reporter_util = zope.component.getUtility(interfaces.IReporter) - reporter_util.add_message( - "To obtain a new certificate that {0} an existing certificate " - "in its domain-name coverage, you must use the --duplicate " - "option.{br}{br}For example:{br}{br}{1} --duplicate {2}".format( - "duplicates" if ident_names_cert is not None else - "overlaps with", - sys.argv[0], " ".join(sys.argv[1:]), - br=os.linesep - ), - reporter_util.HIGH_PRIORITY) - raise errors.Error( - "User did not use proper CLI and would like " - "to reinvoke the client.") + if config.duplicate: + return "newcert", None + # TODO: Also address superset case + ident_names_cert, subset_names_cert = _find_duplicative_certs(config, domains) + # XXX ^ schoen is not sure whether that correctly reads the systemwide + # configuration file. + if ident_names_cert is None and subset_names_cert is None: + return "newcert", None - if renewal: - return ident_names_cert if ident_names_cert is not None else subset_names_cert + if ident_names_cert is not None: + return _handle_identical_cert_request(config, ident_names_cert) + elif subset_names_cert is not None: + return _handle_subset_cert_request(config, domains, subset_names_cert) - return None +def _handle_identical_cert_request(config, cert): + """Figure out what to do if a cert has the same names as a perviously obtained one + + :param storage.RenewableCert cert: + + :returns: Tuple of (string, cert_or_None) as per _treat_as_renewal + :rtype: tuple + + """ + if config.renew_by_default: + logger.info("Auto-renewal forced with --renew-by-default...") + return "renew", cert + if cert.should_autorenew(interactive=True): + logger.info("Cert is due for renewal, auto-renewing...") + return "renew", cert + if config.reinstall: + # Set with --reinstall, force an identical certificate to be + # reinstalled without further prompting. + return "reinstall", cert + + question = ( + "You have an existing certificate that contains exactly the same " + "domains you requested and isn't close to expiry." + "{br}(ref: {0}){br}{br}What would you like to do?" + ).format(cert.configfile.filename, br=os.linesep) + + if config.verb == "run": + keep_opt = "Attempt to reinstall this existing certificate" + elif config.verb == "certonly": + keep_opt = "Keep the existing certificate for now" + choices = [keep_opt, + "Renew & replace the cert (limit ~5 per 7 days)", + "Cancel this operation and do nothing"] + + display = zope.component.getUtility(interfaces.IDisplay) + response = display.menu(question, choices, "OK", "Cancel", default=0) + if response[0] == "cancel" or response[1] == 2: + # TODO: Add notification related to command-line options for + # skipping the menu for this case. + raise errors.Error( + "User chose to cancel the operation and may " + "reinvoke the client.") + elif response[1] == 0: + return "reinstall", cert + elif response[1] == 1: + return "renew", cert + else: + assert False, "This is impossible" + +def _handle_subset_cert_request(config, domains, cert): + """Figure out what to do if a previous cert had a subset of the names now requested + + :param storage.RenewableCert cert: + + :returns: Tuple of (string, cert_or_None) as per _treat_as_renewal + :rtype: tuple + + """ + existing = ", ".join(cert.names()) + question = ( + "You have an existing certificate that contains a portion of " + "the domains you requested (ref: {0}){br}{br}It contains these " + "names: {1}{br}{br}You requested these names for the new " + "certificate: {2}.{br}{br}Do you want to expand and replace this existing " + "certificate with the new certificate?" + ).format(cert.configfile.filename, + existing, + ", ".join(domains), + br=os.linesep) + if config.expand or config.renew_by_default or zope.component.getUtility( + interfaces.IDisplay).yesno(question, "Expand", "Cancel", + cli_flag="--expand (or in some cases, --duplicate)"): + return "renew", cert + else: + reporter_util = zope.component.getUtility(interfaces.IReporter) + reporter_util.add_message( + "To obtain a new certificate that contains these names without " + "replacing your existing certificate for {0}, you must use the " + "--duplicate option.{br}{br}" + "For example:{br}{br}{1} --duplicate {2}".format( + existing, + sys.argv[0], " ".join(sys.argv[1:]), + br=os.linesep + ), + reporter_util.HIGH_PRIORITY) + raise errors.Error( + "User chose to cancel the operation and may " + "reinvoke the client.") def _report_new_cert(cert_path, fullchain_path): @@ -308,27 +380,46 @@ def _report_new_cert(cert_path, fullchain_path): .format(and_chain, path, expiry)) reporter_util.add_message(msg, reporter_util.MEDIUM_PRIORITY) +def _suggest_donate(): + "Suggest a donation to support Let's Encrypt" + reporter_util = zope.component.getUtility(interfaces.IReporter) + msg = ("If you like Let's Encrypt, please consider supporting our work by:\n\n" + "Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate\n" + "Donating to EFF: https://eff.org/donate-le\n\n") + reporter_util.add_message(msg, reporter_util.LOW_PRIORITY) + def _auth_from_domains(le_client, config, domains): """Authenticate and enroll certificate.""" - # Note: This can raise errors... caught above us though. - lineage = _treat_as_renewal(config, domains) + # Note: This can raise errors... caught above us though. This is now + # a three-way case: reinstall (which results in a no-op here because + # although there is a relevant lineage, we don't do anything to it + # inside this function -- we don't obtain a new certificate), renew + # (which results in treating the request as a renewal), or newcert + # (which results in treating the request as a new certificate request). - if lineage is not None: + action, lineage = _treat_as_renewal(config, domains) + if action == "reinstall": + # The lineage already exists; allow the caller to try installing + # it without getting a new certificate at all. + return lineage, "reinstall" + elif action == "renew": + original_server = lineage.configuration["renewalparams"]["server"] + _avoid_invalidating_lineage(config, lineage, original_server) # TODO: schoen wishes to reuse key - discussion # https://github.com/letsencrypt/letsencrypt/pull/777/files#r40498574 new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains) # TODO: Check whether it worked! <- or make sure errors are thrown (jdk) lineage.save_successor( lineage.latest_common_version(), OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, new_certr.body), + OpenSSL.crypto.FILETYPE_PEM, new_certr.body.wrapped), new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain)) lineage.update_all_links_to(lineage.latest_common_version()) # TODO: Check return value of save_successor # TODO: Also update lineage renewal config with any relevant # configuration values from this attempt? <- Absolutely (jdkasten) - else: + elif action == "newcert": # TREAT AS NEW REQUEST lineage = le_client.obtain_and_enroll_certificate(domains) if not lineage: @@ -336,23 +427,29 @@ def _auth_from_domains(le_client, config, domains): _report_new_cert(lineage.cert, lineage.fullchain) - return lineage + return lineage, action +def _avoid_invalidating_lineage(config, lineage, original_server): + "Do not renew a valid cert with one from a staging server!" + def _is_staging(srv): + return srv == constants.STAGING_URI or "staging" in srv -def set_configurator(previously, now): - """ - Setting configurators multiple ways is okay, as long as they all agree - :param str previously: previously identified request for the installer/authenticator - :param str requested: the request currently being processed - """ - if now is None: - # we're not actually setting anything - return previously - if previously: - if previously != now: - msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}" - raise errors.PluginSelectionError(msg.format(repr(previously), repr(now))) - return now + # Some lineages may have begun with --staging, but then had production certs + # added to them + latest_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + open(lineage.cert).read()) + # all our test certs are from happy hacker fake CA, though maybe one day + # we should test more methodically + now_valid = not "fake" in repr(latest_cert.get_issuer()).lower() + + if _is_staging(config.server): + if not _is_staging(original_server) or now_valid: + if not config.break_my_certs: + names = ", ".join(lineage.names()) + raise errors.Error( + "You've asked to renew/replace a seemingly valid certificate with " + "a test certificate (domains: {0}). We will not do that " + "unless you use the --break-my-certs flag!".format(names)) def diagnose_configurator_problem(cfg_type, requested, plugins): @@ -387,23 +484,28 @@ def diagnose_configurator_problem(cfg_type, requested, plugins): raise errors.PluginSelectionError(msg) -def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches +def set_configurator(previously, now): """ - Figure out which configurator we're going to use - - :raises error.PluginSelectionError if there was a problem + Setting configurators multiple ways is okay, as long as they all agree + :param str previously: previously identified request for the installer/authenticator + :param str requested: the request currently being processed """ + if now is None: + # we're not actually setting anything + return previously + if previously: + if previously != now: + msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}" + raise errors.PluginSelectionError(msg.format(repr(previously), repr(now))) + return now - # Which plugins do we need? - need_inst = need_auth = (verb == "run") - if verb == "certonly": - need_auth = True - if verb == "install": - need_inst = True - if args.authenticator: - logger.warn("Specifying an authenticator doesn't make sense in install mode") +def cli_plugin_requests(args): + """ + Figure out which plugins the user requested with CLI and config options - # Which plugins did the user request? + :returns: (requested authenticator string or None, requested installer string or None) + :rtype: tuple + """ req_inst = req_auth = args.configurator req_inst = set_configurator(req_inst, args.installer) req_auth = set_configurator(req_auth, args.authenticator) @@ -420,6 +522,40 @@ def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable= if args.manual: req_auth = set_configurator(req_auth, "manual") logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst) + return req_auth, req_inst + + +noninstaller_plugins = ["webroot", "manual", "standalone"] + +def choose_configurator_plugins(args, config, plugins, verb): + """ + Figure out which configurator we're going to use + :raises errors.PluginSelectionError if there was a problem + """ + + req_auth, req_inst = cli_plugin_requests(args) + + # Which plugins do we need? + if verb == "run": + need_inst = need_auth = True + if req_auth in noninstaller_plugins and not req_inst: + msg = ('With the {0} plugin, you probably want to use the "certonly" command, eg:{1}' + '{1} {2} certonly --{0}{1}{1}' + '(Alternatively, add a --installer flag. See https://eff.org/letsencrypt-plugins' + '{1} and "--help plugins" for more information.)'.format( + req_auth, os.linesep, cli_command)) + + raise errors.MissingCommandlineFlag, msg + else: + need_inst = need_auth = False + if verb == "certonly": + need_auth = True + if verb == "install": + need_inst = True + if args.authenticator: + logger.warn("Specifying an authenticator doesn't make sense in install mode") + + # Try to meet the user's request and/or ask them to pick plugins authenticator = installer = None @@ -465,24 +601,26 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo # TODO: Handle errors from _init_le_client? le_client = _init_le_client(args, config, authenticator, installer) - lineage = _auth_from_domains(le_client, config, domains) + lineage, action = _auth_from_domains(le_client, config, domains) le_client.deploy_certificate( domains, lineage.privkey, lineage.cert, lineage.chain, lineage.fullchain) - le_client.enhance_config(domains, args.redirect) + le_client.enhance_config(domains, config) if len(lineage.available_versions("cert")) == 1: display_ops.success_installation(domains) else: - display_ops.success_renewal(domains) + display_ops.success_renewal(domains, action) + + _suggest_donate() def obtain_cert(args, config, plugins): """Implements "certonly": authenticate & obtain cert, but do not install it.""" - if args.domains is not None and args.csr is not None: + if args.domains and args.csr is not None: # TODO: --csr could have a priority, when --domains is # supplied, check if CSR matches given domains? return "--domains and --csr are mutually exclusive" @@ -507,10 +645,14 @@ def obtain_cert(args, config, plugins): domains = _find_domains(args, installer) _auth_from_domains(le_client, config, domains) + _suggest_donate() + def install(args, config, plugins): """Install a previously obtained cert in a server.""" # XXX: Update for renewer/RenewableCert + # FIXME: be consistent about whether errors are raised or returned from + # this function ... try: installer, _ = choose_configurator_plugins(args, config, @@ -525,7 +667,7 @@ def install(args, config, plugins): le_client.deploy_certificate( domains, args.key_path, args.cert_path, args.chain_path, args.fullchain_path) - le_client.enhance_config(domains, args.redirect) + le_client.enhance_config(domains, config) def revoke(args, config, unused_plugins): # TODO: coop with renewal config @@ -568,7 +710,7 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print logger.debug("Filtered plugins: %r", filtered) if not args.init and not args.prepare: - print str(filtered) + print(str(filtered)) return filtered.init(config) @@ -576,13 +718,13 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print logger.debug("Verified plugins: %r", verified) if not args.prepare: - print str(verified) + print(str(verified)) return verified.prepare() available = verified.available() logger.debug("Prepared plugins: %s", available) - print str(available) + print(str(available)) def read_file(filename, mode="rb"): @@ -675,7 +817,7 @@ class HelpfulArgumentParser(object): self.help_arg = max(help1, help2) if self.help_arg is True: # just --help with no topic; avoid argparse altogether - print usage + print(usage) sys.exit(0) self.visible_topics = self.determine_help_topics(self.help_arg) self.groups = {} # elements are added by .add_group() @@ -689,6 +831,15 @@ class HelpfulArgumentParser(object): """ parsed_args = self.parser.parse_args(self.args) parsed_args.func = self.VERBS[self.verb] + parsed_args.verb = self.verb + + # Do any post-parsing homework here + + # argparse seemingly isn't flexible enough to give us this behaviour easily... + if parsed_args.staging: + if parsed_args.server not in (flag_default("server"), constants.STAGING_URI): + raise errors.Error("--server value conflicts with --staging") + parsed_args.server = constants.STAGING_URI return parsed_args @@ -755,6 +906,20 @@ class HelpfulArgumentParser(object): kwargs["help"] = argparse.SUPPRESS self.parser.add_argument(*args, **kwargs) + def add_deprecated_argument(self, argument_name, num_args): + """Adds a deprecated argument with the name argument_name. + + Deprecated arguments are not shown in the help. If they are used + on the command line, a warning is shown stating that the + argument is deprecated and no other action is taken. + + :param str argument_name: Name of deprecated argument. + :param int nargs: Number of arguments the option takes. + + """ + le_util.add_deprecated_argument( + self.parser.add_argument, argument_name, num_args) + def add_group(self, topic, **kwargs): """ @@ -765,12 +930,12 @@ class HelpfulArgumentParser(object): """ if self.visible_topics[topic]: - #print "Adding visible group " + topic + #print("Adding visible group " + topic) group = self.parser.add_argument_group(topic, **kwargs) self.groups[topic] = group return group else: - #print "Invisible group " + topic + #print("Invisible group " + topic) return self.silent_parser def add_plugin_args(self, plugins): @@ -782,7 +947,7 @@ class HelpfulArgumentParser(object): """ for name, plugin_ep in plugins.iteritems(): parser_or_group = self.add_group(name, description=plugin_ep.description) - #print parser_or_group + #print(parser_or_group) plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name) def determine_help_topics(self, chosen_topic): @@ -808,7 +973,6 @@ class HelpfulArgumentParser(object): return dict([(t, t == chosen_topic) for t in self.help_topics]) - def prepare_and_parse_args(plugins, args): """Returns parsed command line arguments. @@ -830,33 +994,45 @@ def prepare_and_parse_args(plugins, args): helpful.add( None, "-t", "--text", dest="text_mode", action="store_true", help="Use the text output instead of the curses UI.") + helpful.add( + None, "-n", "--non-interactive", "--noninteractive", + dest="noninteractive_mode", action="store_true", + help="Run without ever asking for user input. This may require " + "additional command line flags; the client will try to explain " + "which ones are required if it finds one missing") helpful.add( None, "--register-unsafely-without-email", action="store_true", help="Specifying this flag enables registering an account with no " "email address. This is strongly discouraged, because in the " "event of key loss or account compromise you will irrevocably " "lose access to your account. You will also be unable to receive " - "notice about impending expiration of revocation of your " + "notice about impending expiration or revocation of your " "certificates. Updates to the Subscriber Agreement will still " - "affect you, and will be effective N days after posting an " + "affect you, and will be effective 14 days after posting an " "update to the web site.") helpful.add(None, "-m", "--email", help=config_help("email")) # positional arg shadows --domains, instead of appending, and # --domains is useful, because it can be stored in config #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") - helpful.add(None, "-d", "--domains", dest="domains", default=[], - metavar="DOMAIN", action=DomainFlagProcessor, + helpful.add(None, "-d", "--domains", "--domain", dest="domains", + metavar="DOMAIN", action=DomainFlagProcessor, default=[], help="Domain names to apply. For multiple domains you can use " "multiple -d flags or enter a comma separated list of domains " "as a parameter.") - helpful.add( - None, "--duplicate", dest="duplicate", action="store_true", - help="Allow getting a certificate that duplicates an existing one") - helpful.add_group( "automation", description="Arguments for automating execution & other tweaks") + helpful.add( + "automation", "--keep-until-expiring", "--keep", "--reinstall", + dest="reinstall", action="store_true", + help="If the requested cert matches an existing cert, always keep the " + "existing one until it is due for renewal (for the " + "'run' subcommand this means reinstall the existing cert)") + helpful.add( + "automation", "--expand", action="store_true", + help="If an existing cert covers some subset of the requested names, " + "always expand and replace it with the additional names.") helpful.add( "automation", "--version", action="version", version="%(prog)s {0}".format(letsencrypt.__version__), @@ -864,16 +1040,25 @@ def prepare_and_parse_args(plugins, args): helpful.add( "automation", "--renew-by-default", action="store_true", help="Select renewal by default when domains are a superset of a " - "a previously attained cert") - helpful.add( - "automation", "--agree-dev-preview", action="store_true", - help="Agree to the Let's Encrypt Developer Preview Disclaimer") + "previously attained cert (often --keep-until-expiring is " + "more appropriate). Implies --expand.") helpful.add( "automation", "--agree-tos", dest="tos", action="store_true", help="Agree to the Let's Encrypt Subscriber Agreement") helpful.add( "automation", "--account", metavar="ACCOUNT_ID", help="Account ID to use") + helpful.add( + "automation", "--duplicate", dest="duplicate", action="store_true", + help="Allow making a certificate lineage that duplicates an existing one " + "(both can be renewed in parallel)") + helpful.add( + "automation", "--os-packages-only", action="store_true", + help="(letsencrypt-auto only) install OS package dependencies and then stop") + helpful.add( + "automation", "--no-self-upgrade", action="store_true", + help="(letsencrypt-auto only) prevent the letsencrypt-auto script from" + " upgrading itself to newer released versions") helpful.add_group( "testing", description="The following flags are meant for " @@ -894,7 +1079,10 @@ def prepare_and_parse_args(plugins, args): helpful.add( "testing", "--http-01-port", type=int, dest="http01_port", default=flag_default("http01_port"), help=config_help("http01_port")) - + helpful.add( + "testing", "--break-my-certs", action="store_true", + help="Be willing to replace or renew valid certs with invalid " + "(testing/staging) certs") helpful.add_group( "security", description="Security parameters & server settings") helpful.add( @@ -908,11 +1096,32 @@ def prepare_and_parse_args(plugins, args): "security", "--no-redirect", action="store_false", help="Do not automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.", dest="redirect", default=None) + helpful.add( + "security", "--hsts", action="store_true", + help="Add the Strict-Transport-Security header to every HTTP response." + " Forcing browser to use always use SSL for the domain." + " Defends against SSL Stripping.", dest="hsts", default=False) + helpful.add( + "security", "--no-hsts", action="store_false", + help="Do not automatically add the Strict-Transport-Security header" + " to every HTTP response.", dest="hsts", default=False) + helpful.add( + "security", "--uir", action="store_true", + help="Add the \"Content-Security-Policy: upgrade-insecure-requests\"" + " header to every HTTP response. Forcing the browser to use" + " https:// for every http:// resource.", dest="uir", default=None) + helpful.add( + "security", "--no-uir", action="store_false", + help=" Do not automatically set the \"Content-Security-Policy:" + " upgrade-insecure-requests\" header to every HTTP response.", + dest="uir", default=None) helpful.add( "security", "--strict-permissions", action="store_true", help="Require that all configuration files are owned by the current " "user; only needed if your config is somewhere unsafe like /tmp/") + helpful.add_deprecated_argument("--agree-dev-preview", 0) + _create_subparsers(helpful) _paths_parser(helpful) # _plugins_parsing should be the last thing to act upon the main @@ -933,13 +1142,13 @@ def _create_subparsers(helpful): help="Set a custom user agent string for the client. User agent strings allow " "the CA to collect high level statistics about success rates by OS and " "plugin. If you wish to hide your server OS version from the Let's " - 'Encrypt server, set this to "".' - ) + 'Encrypt server, set this to "".') helpful.add("certonly", "--csr", type=read_file, help="Path to a Certificate Signing Request (CSR) in DER" " format; note that the .csr file *must* contain a Subject" - " Alternative Name field for each domain you want certified.") + " Alternative Name field for each domain you want certified." + " Currently --csr only works with the 'certonly' subcommand'") helpful.add("rollback", "--checkpoints", type=int, metavar="N", default=flag_default("rollback_checkpoints"), @@ -1001,16 +1210,19 @@ def _paths_parser(helpful): help="Logs directory.") add("paths", "--server", default=flag_default("server"), help=config_help("server")) + # overwrites server, handled in HelpfulArgumentParser.parse_args() + add("testing", "--test-cert", "--staging", action='store_true', dest='staging', + help='Use the staging server to obtain test (invalid) certs; equivalent' + ' to --server ' + constants.STAGING_URI) def _plugins_parsing(helpful, plugins): helpful.add_group( "plugins", description="Let's Encrypt client supports an " "extensible plugins architecture. See '%(prog)s plugins' for a " - "list of all available plugins and their names. You can force " - "a particular plugin by setting options provided below. Further " - "down this help message you will find plugin-specific options " - "(prefixed by --{plugin_name}).") + "list of all installed plugins and their names. You can force " + "a particular plugin by setting options provided below. Running " + "--help will list flags specific to that plugin.") helpful.add( "plugins", "-a", "--authenticator", help="Authenticator plugin name.") helpful.add( @@ -1038,30 +1250,43 @@ def _plugins_parsing(helpful, plugins): # These would normally be a flag within the webroot plugin, but because # they are parsed in conjunction with --domains, they live here for - # legibiility. helpful.add_plugin_ags must be called first to add the + # legibility. helpful.add_plugin_ags must be called first to add the # "webroot" topic helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, - help="public_html / webroot path") + help="public_html / webroot path. This can be specified multiple times to " + "handle different domains; each domain will have the webroot path that" + " preceded it. For instance: `-w /var/www/example -d example.com -d " + "www.example.com -w /var/www/thing -d thing.net -d m.thing.net`") parse_dict = lambda s: dict(json.loads(s)) + # --webroot-map still has some awkward properties, so it is undocumented helpful.add("webroot", "--webroot-map", default={}, type=parse_dict, help="JSON dictionary mapping domains to webroot paths; this implies -d for each entry.") class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring + def __init__(self, *args, **kwargs): + self.domain_before_webroot = False + argparse.Action.__init__(self, *args, **kwargs) + def __call__(self, parser, config, webroot, option_string=None): """ Keep a record of --webroot-path / -w flags during processing, so that we know which apply to which -d flags """ - if config.webroot_path is None: # first -w flag encountered + if config.webroot_path is None: # first -w flag encountered config.webroot_path = [] # if any --domain flags preceded the first --webroot-path flag, # apply that webroot path to those; subsequent entries in # config.webroot_map are filled in by cli.DomainFlagProcessor if config.domains: + self.domain_before_webroot = True for d in config.domains: config.webroot_map.setdefault(d, webroot) - + elif self.domain_before_webroot: + # FIXME if you set domains in a config file, you should get a different error + # here, pointing you to --webroot-map + raise errors.Error("If you specify multiple webroot paths, one of " + "them must precede all domain flags") config.webroot_path.append(webroot) @@ -1071,13 +1296,13 @@ class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring Process a new -d flag, helping the webroot plugin construct a map of {domain : webrootpath} if -w / --webroot-path is in use """ - for d in map(string.strip, domain_arg.split(",")): # pylint: disable=bad-builtin - if d not in config.domains: - config.domains.append(d) + for domain in (d.strip() for d in domain_arg.split(",")): + if domain not in config.domains: + config.domains.append(domain) # Each domain has a webroot_path of the most recent -w flag # unless it was explicitly included in webroot_map if config.webroot_path: - config.webroot_map.setdefault(d, config.webroot_path[-1]) + config.webroot_map.setdefault(domain, config.webroot_path[-1]) def setup_log_file_handler(args, logfile, fmt): @@ -1157,11 +1382,19 @@ def _handle_exception(exc_type, exc_value, trace, args): if issubclass(exc_type, errors.Error): sys.exit(exc_value) else: + # Here we're passing a client or ACME error out to the client at the shell # Tell the user a bit about what happened, without overwhelming # them with a full traceback - msg = ("An unexpected error occurred.\n" + - traceback.format_exception_only(exc_type, exc_value)[0] + - "Please see the ") + err = traceback.format_exception_only(exc_type, exc_value)[0] + # Typical error from the ACME module: + # acme.messages.Error: urn:acme:error:malformed :: The request message was + # malformed :: Error creating new registration :: Validation of contact + # mailto:none@longrandomstring.biz failed: Server failure at resolver + if ("urn:acme" in err and ":: " in err + and args.verbose_count <= flag_default("verbose_count")): + # prune ACME error code, we have a human description + _code, _sep, err = err.partition(":: ") + msg = "An unexpected error occurred:\n" + err + "Please see the " if args is None: msg += "logfile '{0}' for more details.".format(logfile) else: @@ -1202,7 +1435,9 @@ def main(cli_args=sys.argv[1:]): sys.excepthook = functools.partial(_handle_exception, args=args) # Displayer - if args.text_mode: + if args.noninteractive_mode: + displayer = display_util.NoninteractiveDisplay(sys.stdout) + elif args.text_mode: displayer = display_util.FileDisplay(sys.stdout) else: displayer = display_util.NcursesDisplay() @@ -1213,13 +1448,6 @@ def main(cli_args=sys.argv[1:]): zope.component.provideUtility(report) atexit.register(report.atexit_print_messages) - # TODO: remove developer preview prompt for the launch - if not config.agree_dev_preview: - disclaimer = pkg_resources.resource_string("letsencrypt", "DISCLAIMER") - if not zope.component.getUtility(interfaces.IDisplay).yesno( - disclaimer, "Agree", "Cancel"): - raise errors.Error("Must agree to TOS") - if not os.geteuid() == 0: logger.warning( "Root (sudo) is required to run most of letsencrypt functionality.") diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e8cd71d6d..c2dfca1bf 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -300,7 +300,7 @@ class Client(object): lineage = storage.RenewableCert.new_lineage( domains[0], OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, certr.body), + OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped), key.pem, crypto_util.dump_pyopenssl_chain(chain), params, config, cli_config) return lineage @@ -330,7 +330,7 @@ class Client(object): self.config.strict_permissions) cert_pem = OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, certr.body) + OpenSSL.crypto.FILETYPE_PEM, certr.body.wrapped) cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) try: cert_file.write(cert_pem) @@ -383,57 +383,87 @@ class Client(object): with error_handler.ErrorHandler(self._rollback_and_restart, msg): # sites may have been enabled / final cleanup self.installer.restart() - - def enhance_config(self, domains, redirect=None): + def enhance_config(self, domains, config): """Enhance the configuration. - .. todo:: This needs to handle the specific enhancements offered by the - installer. We will also have to find a method to pass in the chosen - values efficiently. - :param list domains: list of domains to configure - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None + :ivar config: Namespace typically produced by + :meth:`argparse.ArgumentParser.parse_args`. + it must have the redirect, hsts and uir attributes. + :type namespace: :class:`argparse.Namespace` :raises .errors.Error: if no installer is specified in the client. """ + if self.installer is None: logger.warning("No installer is specified, there isn't any " "configuration to enhance.") raise errors.Error("No installer available") + if config is None: + logger.warning("No config is specified.") + raise errors.Error("No config available") + + supported = self.installer.supported_enhancements() + redirect = config.redirect if "redirect" in supported else False + hsts = config.hsts if "ensure-http-header" in supported else False + uir = config.uir if "ensure-http-header" in supported else False + if redirect is None: redirect = enhancements.ask("redirect") - # When support for more enhancements are added, the call to the - # plugin's `enhance` function should be wrapped by an ErrorHandler if redirect: - self.redirect_to_ssl(domains) + self.apply_enhancement(domains, "redirect") - def redirect_to_ssl(self, domains): - """Redirect all traffic from HTTP to HTTPS + if hsts: + self.apply_enhancement(domains, "ensure-http-header", + "Strict-Transport-Security") + if uir: + self.apply_enhancement(domains, "ensure-http-header", + "Upgrade-Insecure-Requests") + + msg = ("We were unable to restart web server") + if redirect or hsts or uir: + with error_handler.ErrorHandler(self._rollback_and_restart, msg): + self.installer.restart() + + def apply_enhancement(self, domains, enhancement, options=None): + """Applies an enhacement on all domains. + + :param domains: list of ssl_vhosts + :type list of str + + :param enhancement: name of enhancement, e.g. ensure-http-header + :type str + + .. note:: when more options are need make options a list. + :param options: options to enhancement, e.g. Strict-Transport-Security + :type str + + :raises .errors.PluginError: If Enhancement is not supported, or if + there is any other problem with the enhancement. - :param vhost: list of ssl_vhosts - :type vhost: :class:`letsencrypt.interfaces.IInstaller` """ - msg = ("We were unable to set up a redirect for your server, " - "however, we successfully installed your certificate.") + msg = ("We were unable to set up enhancement %s for your server, " + "however, we successfully installed your certificate." + % (enhancement)) with error_handler.ErrorHandler(self._recovery_routine_with_msg, msg): for dom in domains: try: - self.installer.enhance(dom, "redirect") + self.installer.enhance(dom, enhancement, options) + except errors.PluginEnhancementAlreadyPresent: + logger.warn("Enhancement %s was already set.", + enhancement) except errors.PluginError: - logger.warn("Unable to perform redirect for %s", dom) + logger.warn("Unable to set enhancement %s for %s", + enhancement, dom) raise - self.installer.save("Add Redirects") - - with error_handler.ErrorHandler(self._rollback_and_restart, msg): - self.installer.restart() + self.installer.save("Add enhancement %s" % (enhancement)) def _recovery_routine_with_msg(self, success_msg): """Calls the installer's recovery routine and prints success_msg diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index a2a54d2d0..afd5edbe4 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -1,13 +1,13 @@ """Let's Encrypt user-supplied configuration.""" import os import urlparse -import re import zope.interface from letsencrypt import constants from letsencrypt import errors from letsencrypt import interfaces +from letsencrypt import le_util class NamespaceConfig(object): @@ -123,31 +123,5 @@ def check_config_sanity(config): # Domain checks if config.namespace.domains is not None: - _check_config_domain_sanity(config.namespace.domains) - - -def _check_config_domain_sanity(domains): - """Helper method for check_config_sanity which validates - domain flag values and errors out if the requirements are not met. - - :param domains: List of domains - :type domains: `list` of `string` - :raises ConfigurationError: for invalid domains and cases where Let's - Encrypt currently will not issue certificates - - """ - # Check if there's a wildcard domain - if any(d.startswith("*.") for d in domains): - raise errors.ConfigurationError( - "Wildcard domains are not supported") - # Punycode - if any("xn--" in d for d in domains): - raise errors.ConfigurationError( - "Punycode domains are not supported") - # FQDN checks from - # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ - # Characters used, domain parts < 63 chars, tld > 1 < 64 chars - # first and last char is not "-" - fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? flags") + raise errors.MissingCommandlineFlag, msg if code == display_util.OK: if le_util.safe_email(email): @@ -186,7 +203,8 @@ def choose_names(installer): logger.debug("No installer, picking names manually") return _choose_names_manually() - names = list(installer.get_all_names()) + domains = list(installer.get_all_names()) + names = get_valid_domains(domains) if not names: manual = util(interfaces.IDisplay).yesno( @@ -194,7 +212,8 @@ def choose_names(installer): "specify ServerNames in your config files in order to allow for " "accurate installation of your certificate.{0}" "If you do use the default vhost, you may specify the name " - "manually. Would you like to continue?{0}".format(os.linesep)) + "manually. Would you like to continue?{0}".format(os.linesep), + default=True) if manual: return _choose_names_manually() @@ -208,6 +227,24 @@ def choose_names(installer): return [] +def get_valid_domains(domains): + """Helper method for choose_names that implements basic checks + on domain names + + :param list domains: Domain names to validate + :return: List of valid domains + :rtype: list + """ + valid_domains = [] + for domain in domains: + try: + le_util.check_domain_sanity(domain) + valid_domains.append(domain) + except errors.ConfigurationError: + continue + return valid_domains + + def _filter_names(names): """Determine which names the user would like to select from a list. @@ -221,7 +258,7 @@ def _filter_names(names): """ code, names = util(interfaces.IDisplay).checklist( "Which names would you like to activate HTTPS for?", - tags=names) + tags=names, cli_flag="--domains") return code, [str(s) for s in names] @@ -229,10 +266,45 @@ def _choose_names_manually(): """Manually input names for those without an installer.""" code, input_ = util(interfaces.IDisplay).input( - "Please enter in your domain name(s) (comma and/or space separated) ") + "Please enter in your domain name(s) (comma and/or space separated) ", + cli_flag="--domains") if code == display_util.OK: - return display_util.separate_list_input(input_) + invalid_domains = dict() + retry_message = "" + try: + domain_list = display_util.separate_list_input(input_) + except UnicodeEncodeError: + domain_list = [] + retry_message = ( + "Internationalized domain names are not presently " + "supported.{0}{0}Would you like to re-enter the " + "names?{0}").format(os.linesep) + + for domain in domain_list: + try: + le_util.check_domain_sanity(domain) + except errors.ConfigurationError as e: + invalid_domains[domain] = e.message + + if len(invalid_domains): + retry_message = ( + "One or more of the entered domain names was not valid:" + "{0}{0}").format(os.linesep) + for domain in invalid_domains: + retry_message = retry_message + "{1}: {2}{0}".format( + os.linesep, domain, invalid_domains[domain]) + retry_message = retry_message + ( + "{0}Would you like to re-enter the names?{0}").format( + os.linesep) + + if retry_message: + # We had error in input + retry = util(interfaces.IDisplay).yesno(retry_message) + if retry: + return _choose_names_manually() + else: + return domain_list return [] @@ -245,7 +317,7 @@ def success_installation(domains): """ util(interfaces.IDisplay).notification( - "Congratulations! You have successfully enabled {0}!{1}{1}" + "Congratulations! You have successfully enabled {0}{1}{1}" "You should test your configuration at:{1}{2}".format( _gen_https_names(domains), os.linesep, @@ -254,22 +326,24 @@ def success_installation(domains): pause=False) -def success_renewal(domains): +def success_renewal(domains, action): """Display a box confirming the renewal of an existing certificate. .. todo:: This should be centered on the screen :param list domains: domain names which were renewed + :param str action: can be "reinstall" or "renew" """ util(interfaces.IDisplay).notification( - "Your existing certificate has been successfully renewed, and the " + "Your existing certificate has been successfully {3}ed, and the " "new certificate has been installed.{1}{1}" "The new certificate covers the following domains: {0}{1}{1}" "You should test your configuration at:{1}{2}".format( _gen_https_names(domains), os.linesep, - os.linesep.join(_gen_ssl_lab_urls(domains))), + os.linesep.join(_gen_ssl_lab_urls(domains)), + action), height=(14 + len(domains)), pause=False) diff --git a/letsencrypt/display/util.py b/letsencrypt/display/util.py index 01a8cbc92..12c32ff05 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -6,7 +6,7 @@ import dialog import zope.interface from letsencrypt import interfaces - +from letsencrypt import errors WIDTH = 72 HEIGHT = 20 @@ -21,6 +21,20 @@ CANCEL = "cancel" HELP = "help" """Display exit code when for when the user requests more help.""" +def _wrap_lines(msg): + """Format lines nicely to 80 chars. + + :param str msg: Original message + + :returns: Formatted message respecting newlines in message + :rtype: str + + """ + lines = msg.splitlines() + fixed_l = [] + for line in lines: + fixed_l.append(textwrap.fill(line, 80)) + return os.linesep.join(fixed_l) class NcursesDisplay(object): """Ncurses-based display.""" @@ -49,8 +63,8 @@ class NcursesDisplay(object): """ self.dialog.msgbox(message, height, width=self.width) - def menu(self, message, choices, - ok_label="OK", cancel_label="Cancel", help_label=""): + def menu(self, message, choices, ok_label="OK", cancel_label="Cancel", + help_label="", **unused_kwargs): """Display a menu. :param str message: title of menu @@ -61,10 +75,11 @@ class NcursesDisplay(object): :param str ok_label: label of the OK button :param str help_label: label of the help button + :param dict unused_kwargs: absorbs default / cli_args - :returns: tuple of the form (`code`, `tag`) where - `code` - `str` display_util exit code - `tag` - `int` index corresponding to the item chosen + :returns: tuple of the form (`code`, `index`) where + `code` - int display exit code + `int` - index of the selected item :rtype: tuple """ @@ -97,20 +112,21 @@ class NcursesDisplay(object): (str(i), choice) for i, choice in enumerate(choices, 1) ] # pylint: disable=star-args - code, tag = self.dialog.menu(message, **menu_options) + code, index = self.dialog.menu(message, **menu_options) if code == CANCEL: return code, -1 - return code, int(tag) - 1 + return code, int(index) - 1 - def input(self, message): + def input(self, message, **unused_kwargs): """Display an input box to the user. :param str message: Message to display that asks for input. + :param dict _kwargs: absorbs default / cli_args - :returns: tuple of the form (code, string) where + :returns: tuple of the form (`code`, `string`) where `code` - int display exit code `string` - input entered by the user @@ -122,7 +138,7 @@ class NcursesDisplay(object): return self.dialog.inputbox(message, width=self.width, height=height) - def yesno(self, message, yes_label="Yes", no_label="No"): + def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs): """Display a Yes/No dialog box. Yes and No label must begin with different letters. @@ -130,6 +146,7 @@ class NcursesDisplay(object): :param str message: message to display to user :param str yes_label: label on the "yes" button :param str no_label: label on the "no" button + :param dict _kwargs: absorbs default / cli_args :returns: if yes_label was selected :rtype: bool @@ -139,16 +156,17 @@ class NcursesDisplay(object): message, self.height, self.width, yes_label=yes_label, no_label=no_label) - def checklist(self, message, tags, default_status=True): + def checklist(self, message, tags, default_status=True, **unused_kwargs): """Displays a checklist. :param message: Message to display before choices :param list tags: where each is of type :class:`str` len(tags) > 0 :param bool default_status: If True, items are in a selected state by default. + :param dict _kwargs: absorbs default / cli_args - :returns: tuple of the form (code, list_tags) where + :returns: tuple of the form (`code`, `list_tags`) where `code` - int display exit code `list_tags` - list of str tags selected by the user @@ -178,15 +196,15 @@ class FileDisplay(object): """ side_frame = "-" * 79 - message = self._wrap_lines(message) + message = _wrap_lines(message) self.outfile.write( "{line}{frame}{line}{msg}{line}{frame}{line}".format( line=os.linesep, frame=side_frame, msg=message)) if pause: raw_input("Press Enter to Continue") - def menu(self, message, choices, - ok_label="", cancel_label="", help_label=""): + def menu(self, message, choices, ok_label="", cancel_label="", + help_label="", **unused_kwargs): # pylint: disable=unused-argument """Display a menu. @@ -197,10 +215,12 @@ class FileDisplay(object): :param choices: Menu lines, len must be > 0 :type choices: list of tuples (tag, item) or list of descriptions (tags will be enumerated) + :param dict _kwargs: absorbs default / cli_args + + :returns: tuple of (`code`, `index`) where + `code` - str display exit code + `index` - int index of the user's selection - :returns: tuple of the form (code, tag) where - code - int display exit code - tag - str corresponding to the item chosen :rtype: tuple """ @@ -210,11 +230,12 @@ class FileDisplay(object): return code, selection - 1 - def input(self, message): + def input(self, message, **unused_kwargs): # pylint: disable=no-self-use """Accept input from the user. :param str message: message to display to the user + :param dict _kwargs: absorbs default / cli_args :returns: tuple of (`code`, `input`) where `code` - str display exit code @@ -230,7 +251,7 @@ class FileDisplay(object): else: return OK, ans - def yesno(self, message, yes_label="Yes", no_label="No"): + def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs): """Query the user with a yes/no question. Yes and No label must begin with different letters, and must contain at @@ -239,6 +260,7 @@ class FileDisplay(object): :param str message: question for the user :param str yes_label: Label of the "Yes" parameter :param str no_label: Label of the "No" parameter + :param dict _kwargs: absorbs default / cli_args :returns: True for "Yes", False for "No" :rtype: bool @@ -246,7 +268,7 @@ class FileDisplay(object): """ side_frame = ("-" * 79) + os.linesep - message = self._wrap_lines(message) + message = _wrap_lines(message) self.outfile.write("{0}{frame}{msg}{0}{frame}".format( os.linesep, frame=side_frame, msg=message)) @@ -265,13 +287,14 @@ class FileDisplay(object): ans.startswith(no_label[0].upper())): return False - def checklist(self, message, tags, default_status=True): + def checklist(self, message, tags, default_status=True, **unused_kwargs): # pylint: disable=unused-argument """Display a checklist. :param str message: Message to display to user :param list tags: `str` tags to select, len(tags) > 0 :param bool default_status: Not used for FileDisplay + :param dict _kwargs: absorbs default / cli_args :returns: tuple of (`code`, `tags`) where `code` - str display exit code @@ -352,21 +375,6 @@ class FileDisplay(object): self.outfile.write(side_frame) - def _wrap_lines(self, msg): # pylint: disable=no-self-use - """Format lines nicely to 80 chars. - - :param str msg: Original message - - :returns: Formatted message respecting newlines in message - :rtype: str - - """ - lines = msg.splitlines() - fixed_l = [] - for line in lines: - fixed_l.append(textwrap.fill(line, 80)) - - return os.linesep.join(fixed_l) def _get_valid_int_ans(self, max_): """Get a numerical selection. @@ -403,6 +411,118 @@ class FileDisplay(object): return OK, selection +class NoninteractiveDisplay(object): + """An iDisplay implementation that never asks for interactive user input""" + + zope.interface.implements(interfaces.IDisplay) + + def __init__(self, outfile): + super(NoninteractiveDisplay, self).__init__() + self.outfile = outfile + + def _interaction_fail(self, message, cli_flag, extra=""): + "Error out in case of an attempt to interact in noninteractive mode" + msg = "Missing command line flag or config entry for this setting:\n" + msg += message + if extra: + msg += "\n" + extra + if cli_flag: + msg += "\n\n(You can set this with the {0} flag)".format(cli_flag) + raise errors.MissingCommandlineFlag, msg + + def notification(self, message, height=10, pause=False): + # pylint: disable=unused-argument + """Displays a notification without waiting for user acceptance. + + :param str message: Message to display to stdout + :param int height: No effect for NoninteractiveDisplay + :param bool pause: The NoninteractiveDisplay waits for no keyboard + + """ + side_frame = "-" * 79 + message = _wrap_lines(message) + self.outfile.write( + "{line}{frame}{line}{msg}{line}{frame}{line}".format( + line=os.linesep, frame=side_frame, msg=message)) + + def menu(self, message, choices, ok_label=None, cancel_label=None, + default=None, cli_flag=None): + # pylint: disable=unused-argument,too-many-arguments + """Avoid displaying a menu. + + :param str message: title of menu + :param choices: Menu lines, len must be > 0 + :type choices: list of tuples (tag, item) or + list of descriptions (tags will be enumerated) + :param int default: the default choice + :param dict kwargs: absorbs various irrelevant labelling arguments + + :returns: tuple of (`code`, `index`) where + `code` - str display exit code + `index` - int index of the user's selection + :rtype: tuple + :raises errors.MissingCommandlineFlag: if there was no default + + """ + if default is None: + self._interaction_fail(message, cli_flag, "Choices: " + repr(choices)) + + return OK, default + + def input(self, message, default=None, cli_flag=None): + """Accept input from the user. + + :param str message: message to display to the user + + :returns: tuple of (`code`, `input`) where + `code` - str display exit code + `input` - str of the user's input + :rtype: tuple + :raises errors.MissingCommandlineFlag: if there was no default + + """ + if default is None: + self._interaction_fail(message, cli_flag) + else: + return OK, default + + + def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None): + # pylint: disable=unused-argument + """Decide Yes or No, without asking anybody + + :param str message: question for the user + :param dict kwargs: absorbs yes_label, no_label + + :raises errors.MissingCommandlineFlag: if there was no default + :returns: True for "Yes", False for "No" + :rtype: bool + + """ + if default is None: + self._interaction_fail(message, cli_flag) + else: + return default + + def checklist(self, message, tags, default=None, cli_flag=None, **kwargs): + # pylint: disable=unused-argument + """Display a checklist. + + :param str message: Message to display to user + :param list tags: `str` tags to select, len(tags) > 0 + :param dict kwargs: absorbs default_status arg + + :returns: tuple of (`code`, `tags`) where + `code` - str display exit code + `tags` - list of selected tags + :rtype: tuple + + """ + if default is None: + self._interaction_fail(message, cli_flag, "? ".join(tags)) + else: + return OK, default + def separate_list_input(input_): """Separate a comma or space separated list. diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index 0df544b0d..99bb29d9d 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -66,6 +66,10 @@ class PluginError(Error): """Let's Encrypt Plugin error.""" +class PluginEnhancementAlreadyPresent(Error): + """ Enhancement was already set """ + + class PluginSelectionError(Error): """A problem with plugin/configurator selection or setup""" @@ -98,3 +102,8 @@ class StandaloneBindError(Error): class ConfigurationError(Error): """Configuration sanity error.""" + +# NoninteractiveDisplay iDisplay plugin error: + +class MissingCommandlineFlag(Error): + """A command line argument was missing in noninteractive usage""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index c8a725fde..db5d2c5e8 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -365,8 +365,8 @@ class IDisplay(zope.interface.Interface): """ - def menu(message, choices, - ok_label="OK", cancel_label="Cancel", help_label=""): + def menu(message, choices, ok_label="OK", # pylint: disable=too-many-arguments + cancel_label="Cancel", help_label="", default=None, cli_flag=None): """Displays a generic menu. :param str message: message to display @@ -377,14 +377,19 @@ class IDisplay(zope.interface.Interface): :param str ok_label: label for OK button :param str cancel_label: label for Cancel button :param str help_label: label for Help button + :param int default: default (non-interactive) choice from the menu + :param str cli_flag: to automate choice from the menu, eg "--keep" :returns: tuple of (`code`, `index`) where `code` - str display exit code `index` - int index of the user's selection + :raises errors.MissingCommandlineFlag: if called in non-interactive + mode without a default set + """ - def input(message): + def input(message, default=None, cli_args=None): """Accept input from the user. :param str message: message to display to the user @@ -394,27 +399,45 @@ class IDisplay(zope.interface.Interface): `input` - str of the user's input :rtype: tuple + :raises errors.MissingCommandlineFlag: if called in non-interactive + mode without a default set + """ - def yesno(message, yes_label="Yes", no_label="No"): + def yesno(message, yes_label="Yes", no_label="No", default=None, + cli_args=None): """Query the user with a yes/no question. Yes and No label must begin with different letters. :param str message: question for the user + :param str default: default (non-interactive) choice from the menu + :param str cli_flag: to automate choice from the menu, eg "--redirect / --no-redirect" :returns: True for "Yes", False for "No" :rtype: bool + :raises errors.MissingCommandlineFlag: if called in non-interactive + mode without a default set + """ - def checklist(message, tags, default_state): + def checklist(message, tags, default_state, default=None, cli_args=None): """Allow for multiple selections from a menu. :param str message: message to display to the user :param list tags: where each is of type :class:`str` len(tags) > 0 - :param bool default_status: If True, items are in a selected state by - default. + :param bool default_status: If True, items are in a selected state by default. + :param str default: default (non-interactive) state of the checklist + :param str cli_flag: to automate choice from the menu, eg "--domains" + + :returns: tuple of the form (code, list_tags) where + `code` - int display exit code + `list_tags` - list of str tags selected by the user + :rtype: tuple + + :raises errors.MissingCommandlineFlag: if called in non-interactive + mode without a default set """ diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 25260d755..64295a80f 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -1,12 +1,16 @@ """Utilities for all Let's Encrypt.""" +import argparse import collections import errno import logging import os import platform import re -import subprocess import stat +import subprocess +import sys + +import configargparse from letsencrypt import errors @@ -255,3 +259,62 @@ def safe_email(email): else: logger.warn("Invalid email address: %s.", email) return False + + +def add_deprecated_argument(add_argument, argument_name, nargs): + """Adds a deprecated argument with the name argument_name. + + Deprecated arguments are not shown in the help. If they are used on + the command line, a warning is shown stating that the argument is + deprecated and no other action is taken. + + :param callable add_argument: Function that adds arguments to an + argument parser/group. + :param str argument_name: Name of deprecated argument. + :param nargs: Value for nargs when adding the argument to argparse. + + """ + class ShowWarning(argparse.Action): + """Action to log a warning when an argument is used.""" + def __call__(self, unused1, unused2, unused3, option_string=None): + sys.stderr.write( + "Use of {0} is deprecated.\n".format(option_string)) + + configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE.add(ShowWarning) + add_argument(argument_name, action=ShowWarning, + help=argparse.SUPPRESS, nargs=nargs) + + +def check_domain_sanity(domain): + """Method which validates domain value and errors out if + the requirements are not met. + + :param domain: Domain to check + :type domains: `string` + :raises ConfigurationError: for invalid domains and cases where Let's + Encrypt currently will not issue certificates + + """ + # Check if there's a wildcard domain + if domain.startswith("*."): + raise errors.ConfigurationError( + "Wildcard domains are not supported") + # Punycode + if "xn--" in domain: + raise errors.ConfigurationError( + "Punycode domains are not presently supported") + + # Unicode + try: + domain.encode('ascii') + except UnicodeDecodeError: + raise errors.ConfigurationError( + "Internationalized domain names are not presently supported") + + # FQDN checks from + # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ + # Characters used, domain parts < 63 chars, tld > 1 < 64 chars + # first and last char is not "-" + fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? {achall.URI_ROOT_PATH}/{encoded_token} # run only once per server: $(command -v python2 || command -v python2.7 || command -v python2.6) -c \\ "import BaseHTTPServer, SimpleHTTPServer; \\ -SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ s = BaseHTTPServer.HTTPServer(('', {port}), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ s.serve_forever()" """ """Command template.""" @@ -90,6 +87,8 @@ s.serve_forever()" """ def add_parser_arguments(cls, add): add("test-mode", action="store_true", help="Test mode. Executes the manual command in subprocess.") + add("public-ip-logging-ok", action="store_true", + help="Automatically allows public IP logging.") def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover @@ -140,7 +139,7 @@ s.serve_forever()" """ # TODO(kuba): pipes still necessary? validation=pipes.quote(validation), encoded_token=achall.chall.encode("token"), - ct=achall.CONTENT_TYPE, port=port) + port=port) if self.conf("test-mode"): logger.debug("Test mode. Executing the manual command: %s", command) # sh shipped with OS X does't support echo -n, but supports printf @@ -164,26 +163,23 @@ s.serve_forever()" """ if self._httpd.poll() is not None: raise errors.Error("Couldn't execute manual command") else: - if not zope.component.getUtility(interfaces.IDisplay).yesno( - self.IP_DISCLAIMER, "Yes", "No"): - raise errors.PluginError("Must agree to IP logging to proceed") + if not self.conf("public-ip-logging-ok"): + if not zope.component.getUtility(interfaces.IDisplay).yesno( + self.IP_DISCLAIMER, "Yes", "No", + cli_flag="--manual-public-ip-logging-ok"): + raise errors.PluginError("Must agree to IP logging to proceed") self._notify_and_wait(self.MESSAGE_TEMPLATE.format( validation=validation, response=response, uri=achall.chall.uri(achall.domain), - ct=achall.CONTENT_TYPE, command=command)) + command=command)) - if response.simple_verify( + if not response.simple_verify( achall.chall, achall.domain, achall.account_key.public_key(), self.config.http01_port): - return response - else: - logger.error( - "Self-verify of challenge failed, authorization abandoned.") - if self.conf("test-mode") and self._httpd.poll() is not None: - # simply verify cause command failure... - return False - return None + logger.warning("Self-verify of challenge failed.") + + return response def _notify_and_wait(self, message): # pylint: disable=no-self-use # TODO: IDisplay wraps messages, breaking the command diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py index a9281902f..e16fadd13 100644 --- a/letsencrypt/plugins/manual_test.py +++ b/letsencrypt/plugins/manual_test.py @@ -23,7 +23,7 @@ class AuthenticatorTest(unittest.TestCase): def setUp(self): from letsencrypt.plugins.manual import Authenticator self.config = mock.MagicMock( - http01_port=8080, manual_test_mode=False) + http01_port=8080, manual_test_mode=False, manual_public_ip_logging_ok=False) self.auth = Authenticator(config=self.config, name="manual") self.achalls = [achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)] @@ -61,7 +61,9 @@ class AuthenticatorTest(unittest.TestCase): self.assertTrue(self.achalls[0].chall.encode("token") in message) mock_verify.return_value = False - self.assertEqual([None], self.auth.perform(self.achalls)) + with mock.patch("letsencrypt.plugins.manual.logger") as mock_logger: + self.auth.perform(self.achalls) + mock_logger.warning.assert_called_once_with(mock.ANY) @mock.patch("letsencrypt.plugins.manual.zope.component.getUtility") @mock.patch("letsencrypt.plugins.manual.Authenticator._notify_and_wait") @@ -87,20 +89,6 @@ class AuthenticatorTest(unittest.TestCase): self.assertRaises( errors.Error, self.auth_test_mode.perform, self.achalls) - @mock.patch("letsencrypt.plugins.manual.socket.socket") - @mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True) - @mock.patch("acme.challenges.HTTP01Response.simple_verify", - autospec=True) - @mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True) - def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep, - mock_socket): - mock_popen.return_value.poll.side_effect = [None, 10] - mock_popen.return_value.pid = 1234 - mock_verify.return_value = False - self.assertEqual([False], self.auth_test_mode.perform(self.achalls)) - self.assertEqual(1, mock_sleep.call_count) - self.assertEqual(1, mock_socket.call_count) - def test_cleanup_test_mode_already_terminated(self): # pylint: disable=protected-access self.auth_test_mode._httpd = httpd = mock.Mock() diff --git a/letsencrypt/plugins/standalone.py b/letsencrypt/plugins/standalone.py index 8b8612fd1..cde7041d8 100644 --- a/letsencrypt/plugins/standalone.py +++ b/letsencrypt/plugins/standalone.py @@ -2,7 +2,6 @@ import argparse import collections import logging -import random import socket import threading @@ -108,7 +107,7 @@ class ServerManager(object): in six.iteritems(self._instances)) -SUPPORTED_CHALLENGES = set([challenges.TLSSNI01, challenges.HTTP01]) +SUPPORTED_CHALLENGES = [challenges.TLSSNI01, challenges.HTTP01] def supported_challenges_validator(data): @@ -151,7 +150,7 @@ class Authenticator(common.Plugin): # one self-signed key for all tls-sni-01 certificates self.key = OpenSSL.crypto.PKey() - self.key.generate_key(OpenSSL.crypto.TYPE_RSA, bits=2048) + self.key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) self.served = collections.defaultdict(set) @@ -166,16 +165,16 @@ class Authenticator(common.Plugin): @classmethod def add_parser_arguments(cls, add): - add("supported-challenges", help="Supported challenges, " - "order preferences are randomly chosen.", - type=supported_challenges_validator, default=",".join( - sorted(chall.typ for chall in SUPPORTED_CHALLENGES))) + add("supported-challenges", + help="Supported challenges. Preferred in the order they are listed.", + type=supported_challenges_validator, + default=",".join(chall.typ for chall in SUPPORTED_CHALLENGES)) @property def supported_challenges(self): """Challenges supported by this plugin.""" - return set(challenges.Challenge.TYPES[name] for name in - self.conf("supported-challenges").split(",")) + return [challenges.Challenge.TYPES[name] for name in + self.conf("supported-challenges").split(",")] @property def _necessary_ports(self): @@ -198,9 +197,7 @@ class Authenticator(common.Plugin): def get_chall_pref(self, domain): # pylint: disable=unused-argument,missing-docstring - chall_pref = list(self.supported_challenges) - random.shuffle(chall_pref) # 50% for each challenge - return chall_pref + return self.supported_challenges def perform(self, achalls): # pylint: disable=missing-docstring if any(util.already_listening(port) for port in self._necessary_ports): diff --git a/letsencrypt/plugins/standalone_test.py b/letsencrypt/plugins/standalone_test.py index 26a040c2e..1e39dee57 100644 --- a/letsencrypt/plugins/standalone_test.py +++ b/letsencrypt/plugins/standalone_test.py @@ -98,17 +98,27 @@ class AuthenticatorTest(unittest.TestCase): def test_supported_challenges(self): self.assertEqual(self.auth.supported_challenges, - set([challenges.TLSSNI01, challenges.HTTP01])) + [challenges.TLSSNI01, challenges.HTTP01]) + + def test_supported_challenges_configured(self): + self.config.standalone_supported_challenges = "tls-sni-01" + self.assertEqual(self.auth.supported_challenges, + [challenges.TLSSNI01]) def test_more_info(self): self.assertTrue(isinstance(self.auth.more_info(), six.string_types)) def test_get_chall_pref(self): - self.assertEqual(set(self.auth.get_chall_pref(domain=None)), - set([challenges.TLSSNI01, challenges.HTTP01])) + self.assertEqual(self.auth.get_chall_pref(domain=None), + [challenges.TLSSNI01, challenges.HTTP01]) + + def test_get_chall_pref_configured(self): + self.config.standalone_supported_challenges = "tls-sni-01" + self.assertEqual(self.auth.get_chall_pref(domain=None), + [challenges.TLSSNI01]) @mock.patch("letsencrypt.plugins.standalone.util") - def test_perform_alredy_listening(self, mock_util): + def test_perform_already_listening(self, mock_util): for chall, port in ((challenges.TLSSNI01.typ, 1234), (challenges.HTTP01.typ, 4321)): mock_util.already_listening.return_value = True diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 4e18f5ca2..f8176417c 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -1,43 +1,4 @@ -"""Webroot plugin. - -Content-Type ------------- - -This plugin requires your webserver to use a specific `Content-Type` -header in the HTTP response. - -Apache2 -~~~~~~~ - -.. note:: Instructions written and tested for Debian Jessie. Other - operating systems might use something very similar, but you might - still need to readjust some commands. - -Create ``/etc/apache2/conf-available/letsencrypt.conf``, with -the following contents:: - - - - Header set Content-Type "text/plain" - - - -and then run ``a2enmod headers; a2enconf letsencrypt``; depending on the -output you will have to either ``service apache2 restart`` or ``service -apache2 reload``. - -nginx -~~~~~ - -Use the following snippet in your ``server{...}`` stanza:: - - location ~ /.well-known/acme-challenge/(.*) { - default_type text/plain; - } - -and reload your daemon. - -""" +"""Webroot plugin.""" import errno import logging import os @@ -54,7 +15,6 @@ from letsencrypt.plugins import common logger = logging.getLogger(__name__) - class Authenticator(common.Plugin): """Webroot Authenticator.""" zope.interface.implements(interfaces.IAuthenticator) @@ -73,6 +33,8 @@ to serve all files under specified web root ({0}).""" @classmethod def add_parser_arguments(cls, add): + # --webroot-path and --webroot-map are added in cli.py because they + # are parsed in conjunction with --domains pass def get_chall_pref(self, domain): # pragma: no cover @@ -96,31 +58,69 @@ to serve all files under specified web root ({0}).""" logger.debug("Creating root challenges validation dir at %s", self.full_roots[name]) + + # Change the permissions to be writable (GH #1389) + # Umask is used instead of chmod to ensure the client can also + # run as non-root (GH #1795) + old_umask = os.umask(0o022) + try: - os.makedirs(self.full_roots[name]) + # This is coupled with the "umask" call above because + # os.makedirs's "mode" parameter may not always work: + # https://stackoverflow.com/questions/5231901/permission-problems-when-creating-a-dir-with-os-makedirs-python + os.makedirs(self.full_roots[name], 0o0755) + + # Set owner as parent directory if possible + try: + stat_path = os.stat(path) + os.chown(self.full_roots[name], stat_path.st_uid, + stat_path.st_gid) + except OSError as exception: + if exception.errno == errno.EACCES: + logger.debug("Insufficient permissions to change owner and uid - ignoring") + else: + raise errors.PluginError( + "Couldn't create root for {0} http-01 " + "challenge responses: {1}", name, exception) + except OSError as exception: if exception.errno != errno.EEXIST: raise errors.PluginError( "Couldn't create root for {0} http-01 " "challenge responses: {1}", name, exception) + finally: + os.umask(old_umask) def perform(self, achalls): # pylint: disable=missing-docstring assert self.full_roots, "Webroot plugin appears to be missing webroot map" return [self._perform_single(achall) for achall in achalls] def _path_for_achall(self, achall): - path = self.full_roots[achall.domain] - if not path: - raise errors.PluginError("Cannot find path {0} for domain: {1}" - .format(path, achall.domain)) + try: + path = self.full_roots[achall.domain] + except KeyError: + raise errors.PluginError("Missing --webroot-path for domain: {0}" + .format(achall.domain)) + if not os.path.exists(path): + raise errors.PluginError("Mysteriously missing path {0} for domain: {1}" + .format(path, achall.domain)) return os.path.join(path, achall.chall.encode("token")) def _perform_single(self, achall): response, validation = achall.response_and_validation() + path = self._path_for_achall(achall) logger.debug("Attempting to save validation to %s", path) - with open(path, "w") as validation_file: - validation_file.write(validation.encode()) + + # Change permissions to be world-readable, owner-writable (GH #1795) + old_umask = os.umask(0o022) + + try: + with open(path, "w") as validation_file: + validation_file.write(validation.encode()) + finally: + os.umask(old_umask) + return response def cleanup(self, achalls): # pylint: disable=missing-docstring diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 902f74e9f..e3f926c7f 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -1,6 +1,8 @@ """Tests for letsencrypt.plugins.webroot.""" +import errno import os import shutil +import stat import tempfile import unittest @@ -34,7 +36,6 @@ class AuthenticatorTest(unittest.TestCase): self.config = mock.MagicMock(webroot_path=self.path, webroot_map={"thing.com": self.path}) self.auth = Authenticator(self.config, "webroot") - self.auth.prepare() def tearDown(self): shutil.rmtree(self.path) @@ -47,7 +48,7 @@ class AuthenticatorTest(unittest.TestCase): def test_add_parser_arguments(self): add = mock.MagicMock() self.auth.add_parser_arguments(add) - self.assertEqual(0, add.call_count) # became 0 when we moved the args to cli.py! + self.assertEqual(0, add.call_count) # args moved to cli.py! def test_prepare_bad_root(self): self.config.webroot_path = os.path.join(self.path, "null") @@ -65,11 +66,65 @@ class AuthenticatorTest(unittest.TestCase): def test_prepare_reraises_other_errors(self): self.auth.full_path = os.path.join(self.path, "null") + permission_canary = os.path.join(self.path, "rnd") + with open(permission_canary, "w") as f: + f.write("thingimy") os.chmod(self.path, 0o000) - self.assertRaises(errors.PluginError, self.auth.prepare) + try: + open(permission_canary, "r") + print "Warning, running tests as root skips permissions tests..." + except IOError: + # ok, permissions work, test away... + self.assertRaises(errors.PluginError, self.auth.prepare) os.chmod(self.path, 0o700) + @mock.patch("letsencrypt.plugins.webroot.os.chown") + def test_failed_chown_eacces(self, mock_chown): + mock_chown.side_effect = OSError(errno.EACCES, "msg") + self.auth.prepare() # exception caught and logged + + @mock.patch("letsencrypt.plugins.webroot.os.chown") + def test_failed_chown_not_eacces(self, mock_chown): + mock_chown.side_effect = OSError() + self.assertRaises(errors.PluginError, self.auth.prepare) + + def test_prepare_permissions(self): + self.auth.prepare() + + # Remove exec bit from permission check, so that it + # matches the file + self.auth.perform([self.achall]) + path_permissions = stat.S_IMODE(os.stat(self.validation_path).st_mode) + self.assertEqual(path_permissions, 0o644) + + # Check permissions of the directories + + for dirpath, dirnames, _ in os.walk(self.path): + for directory in dirnames: + full_path = os.path.join(dirpath, directory) + dir_permissions = stat.S_IMODE(os.stat(full_path).st_mode) + self.assertEqual(dir_permissions, 0o755) + + parent_gid = os.stat(self.path).st_gid + parent_uid = os.stat(self.path).st_uid + + self.assertEqual(os.stat(self.validation_path).st_gid, parent_gid) + self.assertEqual(os.stat(self.validation_path).st_uid, parent_uid) + + def test_perform_missing_path(self): + self.auth.prepare() + + missing_achall = achallenges.KeyAuthorizationAnnotatedChallenge( + challb=acme_util.HTTP01_P, domain="thing2.com", account_key=KEY) + self.assertRaises( + errors.PluginError, self.auth.perform, [missing_achall]) + + self.auth.full_roots[self.achall.domain] = 'null' + self.assertRaises( + errors.PluginError, self.auth.perform, [self.achall]) + def test_perform_cleanup(self): + self.auth.prepare() responses = self.auth.perform([self.achall]) self.assertEqual(1, len(responses)) self.assertTrue(os.path.exists(self.validation_path)) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 0a490d447..83c6106c0 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -7,6 +7,8 @@ within lineages of successor certificates, according to configuration. .. todo:: Call new installer API to restart servers after deployment """ +from __future__ import print_function + import argparse import logging import os @@ -101,7 +103,7 @@ def renew(cert, old_version): # already understands this distinction!) return cert.save_successor( old_version, OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, new_certr.body), + OpenSSL.crypto.FILETYPE_PEM, new_certr.body.wrapped), new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain)) # TODO: Notify results else: @@ -114,6 +116,7 @@ def renew(cert, old_version): def _cli_log_handler(args, level, fmt): # pylint: disable=unused-argument handler = colored_logging.StreamHandler() handler.setFormatter(logging.Formatter(fmt)) + handler.setLevel(level) return handler @@ -169,7 +172,9 @@ def main(cli_args=sys.argv[1:]): constants.CONFIG_DIRS_MODE, uid) for renewal_file in os.listdir(cli_config.renewal_configs_dir): - print "Processing", renewal_file + if not renewal_file.endswith(".conf"): + continue + print("Processing " + renewal_file) try: # TODO: Before trying to initialize the RenewableCert object, # we could check here whether the combination of the config @@ -179,7 +184,9 @@ def main(cli_args=sys.argv[1:]): # RenewableCert object for this cert at all, which could # dramatically improve performance for large deployments # where autorenewal is widely turned off. - cert = storage.RenewableCert(renewal_file, cli_config) + cert = storage.RenewableCert( + os.path.join(cli_config.renewal_configs_dir, renewal_file), + cli_config) except errors.CertStorageError: # This indicates an invalid renewal configuration file, such # as one missing a required parameter (in the future, perhaps diff --git a/letsencrypt/reporter.py b/letsencrypt/reporter.py index 0905dfa54..c0c7856a7 100644 --- a/letsencrypt/reporter.py +++ b/letsencrypt/reporter.py @@ -1,4 +1,6 @@ """Collects and displays information to the user.""" +from __future__ import print_function + import collections import logging import os @@ -75,8 +77,8 @@ class Reporter(object): no_exception = sys.exc_info()[0] is None bold_on = sys.stdout.isatty() if bold_on: - print le_util.ANSI_SGR_BOLD - print 'IMPORTANT NOTES:' + print(le_util.ANSI_SGR_BOLD) + print('IMPORTANT NOTES:') first_wrapper = textwrap.TextWrapper( initial_indent=' - ', subsequent_indent=(' ' * 3)) next_wrapper = textwrap.TextWrapper( @@ -89,9 +91,9 @@ class Reporter(object): sys.stdout.write(le_util.ANSI_SGR_RESET) bold_on = False lines = msg.text.splitlines() - print first_wrapper.fill(lines[0]) + print(first_wrapper.fill(lines[0])) if len(lines) > 1: - print "\n".join( - next_wrapper.fill(line) for line in lines[1:]) + print("\n".join( + next_wrapper.fill(line) for line in lines[1:])) if bold_on: sys.stdout.write(le_util.ANSI_SGR_RESET) diff --git a/letsencrypt/reverter.py b/letsencrypt/reverter.py index d5114ae71..863074374 100644 --- a/letsencrypt/reverter.py +++ b/letsencrypt/reverter.py @@ -94,7 +94,7 @@ class Reverter(object): "Unable to load checkpoint during rollback") rollback -= 1 - def view_config_changes(self): + def view_config_changes(self, for_logging=False): """Displays all saved checkpoints. All checkpoints are printed by @@ -144,6 +144,8 @@ class Reverter(object): output.append(os.linesep) + if for_logging: + return os.linesep.join(output) zope.component.getUtility(interfaces.IDisplay).notification( os.linesep.join(output), display_util.HEIGHT) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 52be94f68..08b48ff5e 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -1,5 +1,6 @@ """Renewable certificates storage.""" import datetime +import logging import os import re @@ -13,6 +14,8 @@ from letsencrypt import errors from letsencrypt import error_handler from letsencrypt import le_util +logger = logging.getLogger(__name__) + ALL_FOUR = ("cert", "privkey", "chain", "fullchain") @@ -136,14 +139,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes """ # Each element must be referenced with an absolute path - if any(not os.path.isabs(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False + for x in (self.cert, self.privkey, self.chain, self.fullchain): + if not os.path.isabs(x): + logger.debug("Element %s is not referenced with an " + "absolute path.", x) + return False # Each element must exist and be a symbolic link - if any(not os.path.islink(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False + for x in (self.cert, self.privkey, self.chain, self.fullchain): + if not os.path.islink(x): + logger.debug("Element %s is not a symbolic link.", x) + return False for kind in ALL_FOUR: link = getattr(self, kind) where = os.path.dirname(link) @@ -157,16 +163,26 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): + logger.debug("Element's link does not point within the " + "cert lineage's directory within the " + "official archive directory. Link: %s, " + "target directory: %s, " + "archive directory: %s.", + link, os.path.dirname(target), desired_directory) return False # The link must point to a file that exists if not os.path.exists(target): + logger.debug("Link %s points to file %s that does not exist.", + link, target) return False # The link must point to a file that follows the archive # naming convention pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): + logger.debug("%s does not follow the archive naming " + "convention.", target) return False # It is NOT required that the link's target be a regular @@ -244,13 +260,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :returns: The path to the current version of the specified member. - :rtype: str + :rtype: str or None """ if kind not in ALL_FOUR: raise errors.CertStorageError("unknown kind of item") link = getattr(self, kind) if not os.path.exists(link): + logger.debug("Expected symlink %s for %s does not exist.", + link, kind) return None target = os.readlink(link) if not os.path.isabs(target): @@ -275,11 +293,14 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) target = self.current_target(kind) if target is None or not os.path.exists(target): + logger.debug("Current-version target for %s " + "does not exist at %s.", kind, target) target = "" matches = pattern.match(os.path.basename(target)) if matches: return int(matches.groups()[0]) else: + logger.debug("No matches for target %s.", kind) return None def version(self, kind, version): @@ -429,12 +450,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :param int version: the desired version number :returns: the subject names :rtype: `list` of `str` + :raises .CertStorageError: if could not find cert file. """ if version is None: target = self.current_target("cert") else: target = self.version("cert", version) + if target is None: + raise errors.CertStorageError("could not find cert file") with open(target) as f: return crypto_util.get_sans_from_cert(f.read()) @@ -450,7 +474,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return ("autodeploy" not in self.configuration or self.configuration.as_bool("autodeploy")) - def should_autodeploy(self): + def should_autodeploy(self, interactive=False): """Should this lineage now automatically deploy a newer version? This is a policy question and does not only depend on whether @@ -459,12 +483,16 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes exists, and whether the time interval for autodeployment has been reached.) + :param bool interactive: set to True to examine the question + regardless of whether the renewal configuration allows + automated deployment (for interactive use). Default False. + :returns: whether the lineage now ought to autodeploy an existing newer cert version :rtype: bool """ - if self.autodeployment_is_enabled(): + if interactive or self.autodeployment_is_enabled(): if self.has_pending_deployment(): interval = self.configuration.get("deploy_before_expiry", "5 days") @@ -508,7 +536,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return ("autorenew" not in self.configuration or self.configuration.as_bool("autorenew")) - def should_autorenew(self): + def should_autorenew(self, interactive=False): """Should we now try to autorenew the most recent cert version? This is a policy question and does not only depend on whether @@ -519,24 +547,33 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes Note that this examines the numerically most recent cert version, not the currently deployed version. + :param bool interactive: set to True to examine the question + regardless of whether the renewal configuration allows + automated renewal (for interactive use). Default False. + :returns: whether an attempt should now be made to autorenew the most current cert version in this lineage :rtype: bool """ - if self.autorenewal_is_enabled(): + if interactive or self.autorenewal_is_enabled(): # Consider whether to attempt to autorenew this cert now # Renewals on the basis of revocation if self.ocsp_revoked(self.latest_common_version()): + logger.debug("Should renew, certificate is revoked.") return True - # Renewals on the basis of expiry time - interval = self.configuration.get("renew_before_expiry", "10 days") + # Renews some period before expiry time + default_interval = constants.RENEWER_DEFAULTS["renew_before_expiry"] + interval = self.configuration.get("renew_before_expiry", default_interval) expiry = crypto_util.notAfter(self.version( "cert", self.latest_common_version())) now = pytz.UTC.fromutc(datetime.datetime.utcnow()) if expiry < add_time_interval(now, interval): + logger.debug("Should renew, less than %s before certificate " + "expiry %s.", interval, + expiry.strftime("%Y-%m-%d %H:%M:%S %Z")) return True return False @@ -588,6 +625,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) + logger.debug("Creating directory %s.", i) config_file, config_filename = le_util.unique_lineage_name( cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): @@ -608,6 +646,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "live directory exists for " + lineagename) os.mkdir(archive) os.mkdir(live_dir) + logger.debug("Archive directory %s and live " + "directory %s created.", archive, live_dir) relative_archive = os.path.join("..", "..", "archive", lineagename) # Put the data into the appropriate files on disk @@ -617,15 +657,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(os.path.join(relative_archive, kind + "1.pem"), target[kind]) with open(target["cert"], "w") as f: + logger.debug("Writing certificate to %s.", target["cert"]) f.write(cert) with open(target["privkey"], "w") as f: + logger.debug("Writing private key to %s.", target["privkey"]) f.write(privkey) # XXX: Let's make sure to get the file permissions right here with open(target["chain"], "w") as f: + logger.debug("Writing chain to %s.", target["chain"]) f.write(chain) with open(target["fullchain"], "w") as f: # assumes that OpenSSL.crypto.dump_certificate includes # ending newline character + logger.debug("Writing full chain to %s.", target["fullchain"]) f.write(cert + chain) # Document what we've done in a new renewal config file @@ -640,6 +684,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes " in the renewal process"] # TODO: add human-readable comments explaining other available # parameters + logger.debug("Writing new config %s.", config_filename) new_config.write() return cls(new_config.filename, cli_config) @@ -690,16 +735,21 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes old_privkey = os.readlink(old_privkey) else: old_privkey = "privkey{0}.pem".format(prior_version) + logger.debug("Writing symlink to old private key, %s.", old_privkey) os.symlink(old_privkey, target["privkey"]) else: with open(target["privkey"], "w") as f: + logger.debug("Writing new private key to %s.", target["privkey"]) f.write(new_privkey) # Save everything else with open(target["cert"], "w") as f: + logger.debug("Writing certificate to %s.", target["cert"]) f.write(new_cert) with open(target["chain"], "w") as f: + logger.debug("Writing chain to %s.", target["chain"]) f.write(new_chain) with open(target["fullchain"], "w") as f: + logger.debug("Writing full chain to %s.", target["fullchain"]) f.write(new_cert + new_chain) return target_version diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index be19ab036..5b4c2bfc7 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -467,7 +467,7 @@ class ReportFailedChallsTest(unittest.TestCase): auth_handler._report_failed_challs([self.http01, self.tls_sni_same]) call_list = mock_zope().add_message.call_args_list self.assertTrue(len(call_list) == 1) - self.assertTrue("Domains: example.com\n" in call_list[0][0][0]) + self.assertTrue("Domain: example.com\nType: tls\nDetail: detail" in call_list[0][0][0]) @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") def test_different_errors_and_domains(self, mock_zope): diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 60f3c245a..ad74c5c0a 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -15,6 +15,7 @@ from acme import jose from letsencrypt import account from letsencrypt import cli from letsencrypt import configuration +from letsencrypt import constants from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import le_util @@ -39,25 +40,27 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.config_dir = os.path.join(self.tmp_dir, 'config') self.work_dir = os.path.join(self.tmp_dir, 'work') self.logs_dir = os.path.join(self.tmp_dir, 'logs') - self.standard_args = ['--text', '--config-dir', self.config_dir, - '--work-dir', self.work_dir, '--logs-dir', self.logs_dir, - '--agree-dev-preview'] + self.standard_args = ['--config-dir', self.config_dir, + '--work-dir', self.work_dir, + '--logs-dir', self.logs_dir, '--text'] def tearDown(self): shutil.rmtree(self.tmp_dir) def _call(self, args): "Run the cli with output streams and actual client mocked out" - with mock.patch('letsencrypt.cli.client') as client: - ret, stdout, stderr = self._call_no_clientmock(args) - return ret, stdout, stderr, client + with mock.patch('letsencrypt.cli._suggest_donate'): + with mock.patch('letsencrypt.cli.client') as client: + ret, stdout, stderr = self._call_no_clientmock(args) + return ret, stdout, stderr, client def _call_no_clientmock(self, args): "Run the client with output streams mocked out" args = self.standard_args + args - with mock.patch('letsencrypt.cli.sys.stdout') as stdout: - with mock.patch('letsencrypt.cli.sys.stderr') as stderr: - ret = cli.main(args[:]) # NOTE: parser can alter its args! + with mock.patch('letsencrypt.cli._suggest_donate'): + with mock.patch('letsencrypt.cli.sys.stdout') as stdout: + with mock.patch('letsencrypt.cli.sys.stderr') as stderr: + ret = cli.main(args[:]) # NOTE: parser can alter its args! return ret, stdout, stderr def _call_stdout(self, args): @@ -66,9 +69,10 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods caller. """ args = self.standard_args + args - with mock.patch('letsencrypt.cli.sys.stderr') as stderr: - with mock.patch('letsencrypt.cli.client') as client: - ret = cli.main(args[:]) # NOTE: parser can alter its args! + with mock.patch('letsencrypt.cli._suggest_donate'): + with mock.patch('letsencrypt.cli.sys.stderr') as stderr: + with mock.patch('letsencrypt.cli.client') as client: + ret = cli.main(args[:]) # NOTE: parser can alter its args! return ret, None, stderr, client def test_no_flags(self): @@ -77,7 +81,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.assertEqual(1, mock_run.call_count) def _help_output(self, args): - "Run a help command, and return the help string for scrutiny" + "Run a command, and return the ouput string for scrutiny" output = StringIO.StringIO() with mock.patch('letsencrypt.cli.sys.stdout', new=output): self.assertRaises(SystemExit, self._call_stdout, args) @@ -101,6 +105,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.assertTrue("--checkpoints" not in out) out = self._help_output(['-h']) + self.assertTrue("letsencrypt-auto" not in out) # test cli.cli_command if "nginx" in plugins: self.assertTrue("Use the Nginx plugin" in out) else: @@ -126,16 +131,39 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods out = self._help_output(['-h']) self.assertTrue(cli.usage_strings(plugins)[0] in out) + + def _cli_missing_flag(self, args, message): + "Ensure that a particular error raises a missing cli flag error containing message" + exc = None + try: + with mock.patch('letsencrypt.cli.sys.stderr'): + cli.main(self.standard_args + args[:]) # NOTE: parser can alter its args! + except errors.MissingCommandlineFlag, exc: + self.assertTrue(message in str(exc)) + self.assertTrue(exc is not None) + + def test_noninteractive(self): + args = ['-n', 'certonly'] + self._cli_missing_flag(args, "specify a plugin") + args.extend(['--standalone', '-d', 'eg.is']) + self._cli_missing_flag(args, "register before running") + with mock.patch('letsencrypt.cli._auth_from_domains'): + with mock.patch('letsencrypt.cli.client.acme_from_config_key'): + args.extend(['--email', 'io@io.is']) + self._cli_missing_flag(args, "--agree-tos") + @mock.patch('letsencrypt.cli.client.acme_client.Client') @mock.patch('letsencrypt.cli._determine_account') @mock.patch('letsencrypt.cli.client.Client.obtain_and_enroll_certificate') @mock.patch('letsencrypt.cli._auth_from_domains') - def test_user_agent(self, _afd, _obt, det, _client): + def test_user_agent(self, afd, _obt, det, _client): # Normally the client is totally mocked out, but here we need more # arguments to automate it... args = ["--standalone", "certonly", "-m", "none@none.com", "-d", "example.com", '--agree-tos'] + self.standard_args det.return_value = mock.MagicMock(), None + afd.return_value = mock.MagicMock(), "newcert" + with mock.patch('letsencrypt.cli.client.acme_client.ClientNetwork') as acme_net: self._call_no_clientmock(args) os_ver = " ".join(le_util.get_os_info()) @@ -180,8 +208,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods def test_configurator_selection(self, mock_exe_exists): mock_exe_exists.return_value = True real_plugins = disco.PluginsRegistry.find_all() - args = ['--agree-dev-preview', '--apache', - '--authenticator', 'standalone'] + args = ['--apache', '--authenticator', 'standalone'] # This needed two calls to find_all(), which we're avoiding for now # because of possible side effects: @@ -199,13 +226,14 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods # (we can only do that if letsencrypt-nginx is actually present) ret, _, _, _ = self._call(args) self.assertTrue("The nginx plugin is not working" in ret) - self.assertTrue("Could not find configuration root" in ret) - self.assertTrue("NoInstallationError" in ret) + self.assertTrue("MisconfigurationError" in ret) args = ["certonly", "--webroot"] ret, _, _, _ = self._call(args) self.assertTrue("--webroot-path must be set" in ret) + self._cli_missing_flag(["--standalone"], "With the standalone plugin, you probably") + with mock.patch("letsencrypt.cli._init_le_client") as mock_init: with mock.patch("letsencrypt.cli._auth_from_domains"): self._call(["certonly", "--manual", "-d", "foo.bar"]) @@ -341,29 +369,44 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods namespace = cli.prepare_and_parse_args(plugins, long_args) self.assertEqual(namespace.domains, ['example.com', 'another.net']) + def test_parse_server(self): + plugins = disco.PluginsRegistry.find_all() + short_args = ['--server', 'example.com'] + namespace = cli.prepare_and_parse_args(plugins, short_args) + self.assertEqual(namespace.server, 'example.com') + + short_args = ['--staging'] + namespace = cli.prepare_and_parse_args(plugins, short_args) + self.assertEqual(namespace.server, constants.STAGING_URI) + + short_args = ['--staging', '--server', 'example.com'] + self.assertRaises(errors.Error, cli.prepare_and_parse_args, plugins, short_args) def test_parse_webroot(self): plugins = disco.PluginsRegistry.find_all() - webroot_args = ['--webroot', '-d', 'stray.example.com', '-w', - '/var/www/example', '-d', 'example.com,www.example.com', '-w', - '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] + webroot_args = ['--webroot', '-w', '/var/www/example', + '-d', 'example.com,www.example.com', '-w', '/var/www/superfluous', + '-d', 'superfluo.us', '-d', 'www.superfluo.us'] namespace = cli.prepare_and_parse_args(plugins, webroot_args) self.assertEqual(namespace.webroot_map, { 'example.com': '/var/www/example', - 'stray.example.com': '/var/www/example', 'www.example.com': '/var/www/example', 'www.superfluo.us': '/var/www/superfluous', 'superfluo.us': '/var/www/superfluous'}) + webroot_args = ['-d', 'stray.example.com'] + webroot_args + self.assertRaises(errors.Error, cli.prepare_and_parse_args, plugins, webroot_args) + webroot_map_args = ['--webroot-map', '{"eg.com" : "/tmp"}'] namespace = cli.prepare_and_parse_args(plugins, webroot_map_args) domains = cli._find_domains(namespace, mock.MagicMock()) self.assertEqual(namespace.webroot_map, {u"eg.com": u"/tmp"}) self.assertEqual(domains, ["eg.com"]) + @mock.patch('letsencrypt.cli._suggest_donate') @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.zope.component.getUtility') - def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter): + def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter, _suggest): cert_path = '/etc/letsencrypt/live/foo.bar' date = '1970-01-01' mock_notAfter().date.return_value = date @@ -387,24 +430,25 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods def _certonly_new_request_common(self, mock_client): with mock.patch('letsencrypt.cli._treat_as_renewal') as mock_renewal: - mock_renewal.return_value = None + mock_renewal.return_value = ("newcert", None) with mock.patch('letsencrypt.cli._init_le_client') as mock_init: mock_init.return_value = mock_client self._call(['-d', 'foo.bar', '-a', 'standalone', 'certonly']) + @mock.patch('letsencrypt.cli._suggest_donate') @mock.patch('letsencrypt.cli.zope.component.getUtility') @mock.patch('letsencrypt.cli._treat_as_renewal') @mock.patch('letsencrypt.cli._init_le_client') - def test_certonly_renewal(self, mock_init, mock_renewal, mock_get_utility): - cert_path = '/etc/letsencrypt/live/foo.bar/cert.pem' + def test_certonly_renewal(self, mock_init, mock_renewal, mock_get_utility, _suggest): + cert_path = 'letsencrypt/tests/testdata/cert.pem' chain_path = '/etc/letsencrypt/live/foo.bar/fullchain.pem' mock_lineage = mock.MagicMock(cert=cert_path, fullchain=chain_path) - mock_cert = mock.MagicMock(body='body') + mock_certr = mock.MagicMock() mock_key = mock.MagicMock(pem='pem_key') - mock_renewal.return_value = mock_lineage + mock_renewal.return_value = ("renew", mock_lineage) mock_client = mock.MagicMock() - mock_client.obtain_certificate.return_value = (mock_cert, 'chain', + mock_client.obtain_certificate.return_value = (mock_certr, 'chain', mock_key, 'csr') mock_init.return_value = mock_client with mock.patch('letsencrypt.cli.OpenSSL'): @@ -417,13 +461,14 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.assertTrue( chain_path in mock_get_utility().add_message.call_args[0][0]) + @mock.patch('letsencrypt.cli._suggest_donate') @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.display_ops.pick_installer') @mock.patch('letsencrypt.cli.zope.component.getUtility') @mock.patch('letsencrypt.cli._init_le_client') @mock.patch('letsencrypt.cli.record_chosen_plugins') def test_certonly_csr(self, _rec, mock_init, mock_get_utility, - mock_pick_installer, mock_notAfter): + mock_pick_installer, mock_notAfter, _suggest): cert_path = '/etc/letsencrypt/live/blahcert.pem' date = '1970-01-01' mock_notAfter().date.return_value = date @@ -472,9 +517,14 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods @mock.patch('letsencrypt.cli.sys') def test_handle_exception(self, mock_sys): # pylint: disable=protected-access + from acme import messages + + args = mock.MagicMock() mock_open = mock.mock_open() + with mock.patch('letsencrypt.cli.open', mock_open, create=True): exception = Exception('detail') + args.verbose_count = 1 cli._handle_exception( Exception, exc_value=exception, trace=None, args=None) mock_open().write.assert_called_once_with(''.join( @@ -491,11 +541,23 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods mock_sys.exit.assert_any_call(''.join( traceback.format_exception_only(errors.Error, error))) - args = mock.MagicMock(debug=False) + exception = messages.Error(detail='alpha', typ='urn:acme:error:triffid', + title='beta') + args = mock.MagicMock(debug=False, verbose_count=-3) cli._handle_exception( - Exception, exc_value=Exception('detail'), trace=None, args=args) + messages.Error, exc_value=exception, trace=None, args=args) error_msg = mock_sys.exit.call_args_list[-1][0][0] self.assertTrue('unexpected error' in error_msg) + self.assertTrue('acme:error' not in error_msg) + self.assertTrue('alpha' in error_msg) + self.assertTrue('beta' in error_msg) + args = mock.MagicMock(debug=False, verbose_count=1) + cli._handle_exception( + messages.Error, exc_value=exception, trace=None, args=args) + error_msg = mock_sys.exit.call_args_list[-1][0][0] + self.assertTrue('unexpected error' in error_msg) + self.assertTrue('acme:error' in error_msg) + self.assertTrue('alpha' in error_msg) interrupt = KeyboardInterrupt('detail') cli._handle_exception( @@ -516,6 +578,11 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.assertEqual(path, os.path.abspath(path)) self.assertEqual(contents, test_contents) + def test_agree_dev_preview_config(self): + with MockedVerb('run') as mocked_run: + self._call(['-c', test_util.vector_path('cli.ini')]) + self.assertTrue(mocked_run.called) + class DetermineAccountTest(unittest.TestCase): """Tests for letsencrypt.cli._determine_account.""" diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 160dd55c1..2f117f80c 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -20,6 +20,15 @@ KEY = test_util.load_vector("rsa512_key.pem") CSR_SAN = test_util.load_vector("csr-san.der") +class ConfigHelper(object): + """Creates a dummy object to imitate a namespace object + + Example: cfg = ConfigHelper(redirect=True, hsts=False, uir=False) + will result in: cfg.redirect=True, cfg.hsts=False, etc. + """ + def __init__(self, **kwds): + self.__dict__.update(kwds) + class RegisterTest(unittest.TestCase): """Tests for letsencrypt.client.register.""" @@ -132,9 +141,9 @@ class ClientTest(unittest.TestCase): tmp_path = tempfile.mkdtemp() os.chmod(tmp_path, 0o755) # TODO: really?? - certr = mock.MagicMock(body=test_util.load_cert(certs[0])) - chain_cert = [test_util.load_cert(certs[1]), - test_util.load_cert(certs[2])] + certr = mock.MagicMock(body=test_util.load_comparable_cert(certs[0])) + chain_cert = [test_util.load_comparable_cert(certs[1]), + test_util.load_comparable_cert(certs[2])] candidate_cert_path = os.path.join(tmp_path, "certs", "cert.pem") candidate_chain_path = os.path.join(tmp_path, "chains", "chain.pem") candidate_fullchain_path = os.path.join(tmp_path, "chains", "fullchain.pem") @@ -224,21 +233,63 @@ class ClientTest(unittest.TestCase): @mock.patch("letsencrypt.client.enhancements") def test_enhance_config(self, mock_enhancements): + config = ConfigHelper(redirect=True, hsts=False, uir=False) self.assertRaises(errors.Error, - self.client.enhance_config, ["foo.bar"]) + self.client.enhance_config, ["foo.bar"], config) mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect"] - self.client.enhance_config(["foo.bar"]) - installer.enhance.assert_called_once_with("foo.bar", "redirect") + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_once_with("foo.bar", "redirect", None) self.assertEqual(installer.save.call_count, 1) installer.restart.assert_called_once_with() - def test_enhance_config_no_installer(self): + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_no_ask(self, mock_enhancements): + config = ConfigHelper(redirect=True, hsts=False, uir=False) self.assertRaises(errors.Error, - self.client.enhance_config, ["foo.bar"]) + self.client.enhance_config, ["foo.bar"], config) + + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect", "ensure-http-header"] + + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "redirect", None) + + config = ConfigHelper(redirect=False, hsts=True, uir=False) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "ensure-http-header", + "Strict-Transport-Security") + + config = ConfigHelper(redirect=False, hsts=False, uir=True) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "ensure-http-header", + "Upgrade-Insecure-Requests") + + self.assertEqual(installer.save.call_count, 3) + self.assertEqual(installer.restart.call_count, 3) + + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_unsupported(self, mock_enhancements): + installer = mock.MagicMock() + self.client.installer = installer + installer.supported_enhancements.return_value = [] + + config = ConfigHelper(redirect=None, hsts=True, uir=True) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_not_called() + mock_enhancements.ask.assert_not_called() + + def test_enhance_config_no_installer(self): + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.Error, + self.client.enhance_config, ["foo.bar"], config) @mock.patch("letsencrypt.client.zope.component.getUtility") @mock.patch("letsencrypt.client.enhancements") @@ -247,10 +298,13 @@ class ClientTest(unittest.TestCase): mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect"] installer.enhance.side_effect = errors.PluginError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) installer.recovery_routine.assert_called_once_with() self.assertEqual(mock_get_utility().add_message.call_count, 1) @@ -261,10 +315,13 @@ class ClientTest(unittest.TestCase): mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect"] installer.save.side_effect = errors.PluginError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) installer.recovery_routine.assert_called_once_with() self.assertEqual(mock_get_utility().add_message.call_count, 1) @@ -275,10 +332,14 @@ class ClientTest(unittest.TestCase): mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect"] installer.restart.side_effect = [errors.PluginError, None] + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) + self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 2) @@ -290,11 +351,14 @@ class ClientTest(unittest.TestCase): mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer + installer.supported_enhancements.return_value = ["redirect"] installer.restart.side_effect = errors.PluginError installer.rollback_checkpoints.side_effect = errors.ReverterError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 1) diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index b0b905c33..9a39e1538 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -1,3 +1,4 @@ +# coding=utf-8 """Test letsencrypt.display.ops.""" import os import sys @@ -68,7 +69,7 @@ class PickPluginTest(unittest.TestCase): """Tests for letsencrypt.display.ops.pick_plugin.""" def setUp(self): - self.config = mock.Mock() + self.config = mock.Mock(noninteractive_mode=False) self.default = None self.reg = mock.MagicMock() self.question = "Question?" @@ -385,6 +386,56 @@ class ChooseNamesTest(unittest.TestCase): self.assertEqual(self._call(self.mock_install), []) + def test_get_valid_domains(self): + from letsencrypt.display.ops import get_valid_domains + all_valid = ["example.com", "second.example.com", + "also.example.com"] + all_invalid = ["xn--ls8h.tld", "*.wildcard.com", "notFQDN", + "uniçodé.com"] + two_valid = ["example.com", "xn--ls8h.tld", "also.example.com"] + self.assertEqual(get_valid_domains(all_valid), all_valid) + self.assertEqual(get_valid_domains(all_invalid), []) + self.assertEqual(len(get_valid_domains(two_valid)), 2) + + @mock.patch("letsencrypt.display.ops.util") + def test_choose_manually(self, mock_util): + from letsencrypt.display.ops import _choose_names_manually + # No retry + mock_util().yesno.return_value = False + # IDN and no retry + mock_util().input.return_value = (display_util.OK, + "uniçodé.com") + self.assertEqual(_choose_names_manually(), []) + # IDN exception with previous mocks + with mock.patch( + "letsencrypt.display.ops.display_util.separate_list_input" + ) as mock_sli: + unicode_error = UnicodeEncodeError('mock', u'', 0, 1, 'mock') + mock_sli.side_effect = unicode_error + self.assertEqual(_choose_names_manually(), []) + # Punycode and no retry + mock_util().input.return_value = (display_util.OK, + "xn--ls8h.tld") + self.assertEqual(_choose_names_manually(), []) + # non-FQDN and no retry + mock_util().input.return_value = (display_util.OK, + "notFQDN") + self.assertEqual(_choose_names_manually(), []) + # Two valid domains + mock_util().input.return_value = (display_util.OK, + ("example.com," + "valid.example.com")) + self.assertEqual(_choose_names_manually(), + ["example.com", "valid.example.com"]) + # Three iterations + mock_util().input.return_value = (display_util.OK, + "notFQDN") + yn = mock.MagicMock() + yn.side_effect = [True, True, False] + mock_util().yesno = yn + _choose_names_manually() + self.assertEqual(mock_util().yesno.call_count, 3) + class SuccessInstallationTest(unittest.TestCase): # pylint: disable=too-few-public-methods @@ -414,7 +465,7 @@ class SuccessRenewalTest(unittest.TestCase): @classmethod def _call(cls, names): from letsencrypt.display.ops import success_renewal - success_renewal(names) + success_renewal(names, "renew") @mock.patch("letsencrypt.display.ops.util") def test_success_renewal(self, mock_util): diff --git a/letsencrypt/tests/display/util_test.py b/letsencrypt/tests/display/util_test.py index 001a9e578..a16eb544e 100644 --- a/letsencrypt/tests/display/util_test.py +++ b/letsencrypt/tests/display/util_test.py @@ -4,6 +4,8 @@ import unittest import mock +import letsencrypt.errors as errors + from letsencrypt.display import util as display_util @@ -250,7 +252,7 @@ class FileOutputDisplayTest(unittest.TestCase): "This function is only meant to be for easy viewing{0}" "Test a really really really really really really really really " "really really really really long line...".format(os.linesep)) - text = self.displayer._wrap_lines(msg) + text = display_util._wrap_lines(msg) self.assertEqual(text.count(os.linesep), 3) @@ -278,6 +280,46 @@ class FileOutputDisplayTest(unittest.TestCase): self.displayer._get_valid_int_ans(3), (display_util.CANCEL, -1)) +class NoninteractiveDisplayTest(unittest.TestCase): + """Test non-interactive display. + + These tests are pretty easy! + + """ + def setUp(self): + super(NoninteractiveDisplayTest, self).setUp() + self.mock_stdout = mock.MagicMock() + self.displayer = display_util.NoninteractiveDisplay(self.mock_stdout) + + def test_notification_no_pause(self): + self.displayer.notification("message", 10) + string = self.mock_stdout.write.call_args[0][0] + + self.assertTrue("message" in string) + + def test_input(self): + d = "an incomputable value" + ret = self.displayer.input("message", default=d) + self.assertEqual(ret, (display_util.OK, d)) + self.assertRaises(errors.MissingCommandlineFlag, self.displayer.input, "message") + + def test_menu(self): + ret = self.displayer.menu("message", CHOICES, default=1) + self.assertEqual(ret, (display_util.OK, 1)) + self.assertRaises(errors.MissingCommandlineFlag, self.displayer.menu, "message", CHOICES) + + def test_yesno(self): + d = False + ret = self.displayer.yesno("message", default=d) + self.assertEqual(ret, d) + self.assertRaises(errors.MissingCommandlineFlag, self.displayer.yesno, "message") + + def test_checklist(self): + d = [1, 3] + ret = self.displayer.checklist("message", TAGS, default=d) + self.assertEqual(ret, (display_util.OK, d)) + self.assertRaises(errors.MissingCommandlineFlag, self.displayer.checklist, "message", TAGS) + class SeparateListInputTest(unittest.TestCase): """Test Module functions.""" diff --git a/letsencrypt/tests/le_util_test.py b/letsencrypt/tests/le_util_test.py index ed976f72d..87894f837 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -1,8 +1,10 @@ """Tests for letsencrypt.le_util.""" +import argparse import errno import os import shutil import stat +import StringIO import tempfile import unittest @@ -284,5 +286,42 @@ class SafeEmailTest(unittest.TestCase): self.assertFalse(self._call(addr), "%s failed." % addr) +class AddDeprecatedArgumentTest(unittest.TestCase): + """Test add_deprecated_argument.""" + def setUp(self): + self.parser = argparse.ArgumentParser() + + def _call(self, argument_name, nargs): + from letsencrypt.le_util import add_deprecated_argument + + add_deprecated_argument(self.parser.add_argument, argument_name, nargs) + + def test_warning_no_arg(self): + self._call("--old-option", 0) + stderr = self._get_argparse_warnings(["--old-option"]) + self.assertTrue("--old-option is deprecated" in stderr) + + def test_warning_with_arg(self): + self._call("--old-option", 1) + stderr = self._get_argparse_warnings(["--old-option", "42"]) + self.assertTrue("--old-option is deprecated" in stderr) + + def _get_argparse_warnings(self, args): + stderr = StringIO.StringIO() + with mock.patch("letsencrypt.le_util.sys.stderr", new=stderr): + self.parser.parse_args(args) + return stderr.getvalue() + + def test_help(self): + self._call("--old-option", 2) + stdout = StringIO.StringIO() + with mock.patch("letsencrypt.le_util.sys.stdout", new=stdout): + try: + self.parser.parse_args(["-h"]) + except SystemExit: + pass + self.assertTrue("--old-option" not in stdout.getvalue()) + + if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index daec9678f..7030e4998 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -9,6 +9,8 @@ import unittest import configobj import mock +from acme import jose + from letsencrypt import configuration from letsencrypt import errors from letsencrypt.storage import ALL_FOUR @@ -66,6 +68,13 @@ class BaseRenewableCertTest(unittest.TestCase): config.write() self.config = config + # We also create a file that isn't a renewal config in the same + # location to test that logic that reads in all-and-only renewal + # configs will ignore it and NOT attempt to parse it. + junk = open(os.path.join(self.tempdir, "renewal", "IGNORE.THIS"), "w") + junk.write("This file should be ignored!") + junk.close() + self.defaults = configobj.ConfigObj() self.test_rc = storage.RenewableCert(config.filename, self.cli_config) @@ -379,6 +388,10 @@ class RenewableCertTests(BaseRenewableCertTest): self.assertEqual(self.test_rc.names(12), ["example.com", "www.example.com"]) + # Trying missing cert + os.unlink(self.test_rc.cert) + self.assertRaises(errors.CertStorageError, self.test_rc.names) + @mock.patch("letsencrypt.storage.datetime") def test_time_interval_judgments(self, mock_datetime): """Test should_autodeploy() and should_autorenew() on the basis @@ -702,9 +715,10 @@ class RenewableCertTests(BaseRenewableCertTest): self.test_rc.configfile["renewalparams"]["authenticator"] = "apache" mock_client = mock.MagicMock() # pylint: disable=star-args + comparable_cert = jose.ComparableX509(CERT) mock_client.obtain_certificate.return_value = ( - mock.MagicMock(body=CERT), [CERT], mock.Mock(pem="key"), - mock.sentinel.csr) + mock.MagicMock(body=comparable_cert), [comparable_cert], + mock.Mock(pem="key"), mock.sentinel.csr) mock_c.return_value = mock_client self.assertEqual(2, renewer.renew(self.test_rc, 1)) # TODO: We could also make several assertions about calls that should @@ -764,6 +778,8 @@ class RenewableCertTests(BaseRenewableCertTest): def test_bad_config_file(self): from letsencrypt import renewer + os.unlink(os.path.join(self.cli_config.renewal_configs_dir, + "example.org.conf")) with open(os.path.join(self.cli_config.renewal_configs_dir, "bad.conf"), "w") as f: f.write("incomplete = configfile\n") diff --git a/letsencrypt/tests/reverter_test.py b/letsencrypt/tests/reverter_test.py index d31b6f2cc..aafd3b041 100644 --- a/letsencrypt/tests/reverter_test.py +++ b/letsencrypt/tests/reverter_test.py @@ -385,6 +385,15 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.assertRaises( errors.ReverterError, self.reverter.view_config_changes) + def test_view_config_changes_for_logging(self): + self._setup_three_checkpoints() + + config_changes = self.reverter.view_config_changes(for_logging=True) + + self.assertTrue("First Checkpoint" in config_changes) + self.assertTrue("Second Checkpoint" in config_changes) + self.assertTrue("Third Checkpoint" in config_changes) + def _setup_three_checkpoints(self): """Generate some finalized checkpoints.""" # Checkpoint1 - config1 diff --git a/letsencrypt/tests/test_util.py b/letsencrypt/tests/test_util.py index 2b4c6e00c..24eceff5a 100644 --- a/letsencrypt/tests/test_util.py +++ b/letsencrypt/tests/test_util.py @@ -40,16 +40,24 @@ def load_cert(*names): """Load certificate.""" loader = _guess_loader( names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return jose.ComparableX509(OpenSSL.crypto.load_certificate( - loader, load_vector(*names))) + return OpenSSL.crypto.load_certificate(loader, load_vector(*names)) + + +def load_comparable_cert(*names): + """Load ComparableX509 cert.""" + return jose.ComparableX509(load_cert(*names)) def load_csr(*names): """Load certificate request.""" loader = _guess_loader( names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return jose.ComparableX509(OpenSSL.crypto.load_certificate_request( - loader, load_vector(*names))) + return OpenSSL.crypto.load_certificate_request(loader, load_vector(*names)) + + +def load_comparable_csr(*names): + """Load ComparableX509 certificate request.""" + return jose.ComparableX509(load_csr(*names)) def load_rsa_private_key(*names): diff --git a/letsencrypt/tests/testdata/cli.ini b/letsencrypt/tests/testdata/cli.ini new file mode 100644 index 000000000..8ef506071 --- /dev/null +++ b/letsencrypt/tests/testdata/cli.ini @@ -0,0 +1 @@ +agree-dev-preview = True diff --git a/letshelp-letsencrypt/setup.py b/letshelp-letsencrypt/setup.py index 04b879e14..000f86c31 100644 --- a/letshelp-letsencrypt/setup.py +++ b/letshelp-letsencrypt/setup.py @@ -4,7 +4,7 @@ from setuptools import setup from setuptools import find_packages -version = '0.1.0.dev0' +version = '0.4.0.dev0' install_requires = [ 'setuptools', # pkg_resources @@ -55,4 +55,5 @@ setup( 'letshelp-letsencrypt-apache = letshelp_letsencrypt.apache:main', ], }, + test_suite='letshelp_letsencrypt', ) diff --git a/py26reqs.txt b/py26reqs.txt deleted file mode 100644 index a94b22c0c..000000000 --- a/py26reqs.txt +++ /dev/null @@ -1,2 +0,0 @@ -# https://github.com/bw2/ConfigArgParse/issues/17 -git+https://github.com/kuba/ConfigArgParse.git@python2.6-0.9.3#egg=ConfigArgParse diff --git a/setup.py b/setup.py index 40c6ac16c..b51b53b18 100644 --- a/setup.py +++ b/setup.py @@ -30,9 +30,13 @@ readme = read_file(os.path.join(here, 'README.rst')) changes = read_file(os.path.join(here, 'CHANGES.rst')) version = meta['version'] +# Please update tox.ini when modifying dependency version requirements install_requires = [ 'acme=={0}'.format(version), - 'ConfigArgParse', + # We technically need ConfigArgParse 0.10.0 for Python 2.6 support, but + # saying so here causes a runtime error against our temporary fork of 0.9.3 + # in which we added 2.6 support (see #2243), so we relax the requirement. + 'ConfigArgParse>=0.9.3', 'configobj', 'cryptography>=0.7', # load_pem_x509_certificate 'parsedatetime', @@ -41,7 +45,6 @@ install_requires = [ 'pyrfc3339', 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 'pytz', - 'requests', 'setuptools', # pkg_resources 'six', 'zope.component', @@ -49,6 +52,7 @@ install_requires = [ ] # env markers in extras_require cause problems with older pip: #517 +# Keep in sync with conditional_requirements.py. if sys.version_info < (2, 7): install_requires.extend([ # only some distros recognize stdlib argparse as already satisfying @@ -119,7 +123,6 @@ setup( 'testing': testing_extras, }, - tests_require=install_requires, # to test all packages run "python setup.py test -s # {acme,letsencrypt_apache,letsencrypt_nginx}" test_suite='letsencrypt', diff --git a/tests/boulder-fetch.sh b/tests/boulder-fetch.sh index a2c31b1d9..0d8a3de38 100755 --- a/tests/boulder-fetch.sh +++ b/tests/boulder-fetch.sh @@ -33,6 +33,7 @@ wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \ zcat goose.gz > $GOPATH/bin/goose && \ chmod +x $GOPATH/bin/goose ./test/create_db.sh +go run cmd/rabbitmq-setup/main.go -server amqp://localhost # listenbuddy is needed for ./start.py go get github.com/jsha/listenbuddy cd - diff --git a/tests/integration/_common.sh b/tests/integration/_common.sh index 71d745d93..4572b0fb3 100755 --- a/tests/integration/_common.sh +++ b/tests/integration/_common.sh @@ -21,7 +21,6 @@ letsencrypt_test () { $store_flags \ --text \ --no-redirect \ - --agree-dev-preview \ --agree-tos \ --register-unsafely-without-email \ --renew-by-default \ diff --git a/tests/letstest/README.md b/tests/letstest/README.md new file mode 100644 index 000000000..a085e9d91 --- /dev/null +++ b/tests/letstest/README.md @@ -0,0 +1,44 @@ +# letstest +simple aws testfarm scripts for letsencrypt client testing + +- Configures (canned) boulder server +- Launches EC2 instances with a given list of AMIs for different distros +- Copies letsencrypt repo and puts it on the instances +- Runs letsencrypt tests (bash scripts) on all of these +- Logs execution and success/fail for debugging + +## Notes + - Some AWS images, e.g. official CentOS and FreeBSD images + require acceptance of user terms on the AWS marketplace + website. This can't be automated. + - AWS EC2 has a default limit of 20 t2/t1 instances, if more + are needed, they need to be requested via online webform. + +## Usage + - Requires AWS IAM secrets to be set up with aws cli + - Requires an AWS associated keyfile .pem + +``` +>aws configure --profile HappyHacker +[interactive: enter secrets for IAM role] +>aws ec2 create-key-pair --profile HappyHacker --key-name MyKeyPair --query 'KeyMaterial' --output text > MyKeyPair.pem +``` +then: +``` +>python multitester.py targets.yaml MyKeyPair.pem HappyHacker scripts/test_apache2.sh +``` + +## Scripts +example scripts are in the 'scripts' directory, these are just bash scripts that have a few parameters passed +to them at runtime via environment variables. test_apache2.sh is a useful reference. + +Note that the
test_letsencrypt_auto_*
scripts pull code from PyPI using the letsencrypt-auto script, +__not__ the local python code. test_apache2 runs the dev venv and does local tests. + +see: +- https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html +- https://docs.aws.amazon.com/cli/latest/userguide/cli-ec2-keypairs.html + +main repos: +- https://github.com/letsencrypt/boulder +- https://github.com/letsencrypt/letsencrypt diff --git a/tests/letstest/apache2_targets.yaml b/tests/letstest/apache2_targets.yaml new file mode 100644 index 000000000..e707b8636 --- /dev/null +++ b/tests/letstest/apache2_targets.yaml @@ -0,0 +1,57 @@ +targets: + #----------------------------------------------------------------------------- + # Apache 2.4 + - ami: ami-26d5af4c + name: ubuntu15.10 + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-d92e6bb3 + name: ubuntu15.04LTS + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-7b89cc11 + name: ubuntu14.04LTS + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-9295d0f8 + name: ubuntu14.04LTS_32bit + type: ubuntu + virt: pv + user: ubuntu + - ami: ami-116d857a + name: debian8.1 + type: debian + virt: hvm + user: admin + userdata: | + #cloud-init + runcmd: + - [ apt-get, install, -y, curl ] + #----------------------------------------------------------------------------- + # Apache 2.2 + # - ami: ami-0611546c + # name: ubuntu12.04LTS + # type: ubuntu + # virt: hvm + # user: ubuntu + # - ami: ami-e0efab88 + # name: debian7.8.aws.1 + # type: debian + # virt: hvm + # user: admin + # userdata: | + # #cloud-init + # runcmd: + # - [ apt-get, install, -y, curl ] + # - ami: ami-e6eeaa8e + # name: debian7.8.aws.1_32bit + # type: debian + # virt: pv + # user: admin + # userdata: | + # #cloud-init + # runcmd: + # - [ apt-get, install, -y, curl ] \ No newline at end of file diff --git a/tests/letstest/multitester.py b/tests/letstest/multitester.py new file mode 100644 index 000000000..19a6aad1a --- /dev/null +++ b/tests/letstest/multitester.py @@ -0,0 +1,536 @@ +""" +Letsencrypt Integration Test Tool + +- Configures (canned) boulder server +- Launches EC2 instances with a given list of AMIs for different distros +- Copies letsencrypt repo and puts it on the instances +- Runs letsencrypt tests (bash scripts) on all of these +- Logs execution and success/fail for debugging + +Notes: + - Some AWS images, e.g. official CentOS and FreeBSD images + require acceptance of user terms on the AWS marketplace + website. This can't be automated. + - AWS EC2 has a default limit of 20 t2/t1 instances, if more + are needed, they need to be requested via online webform. + +Usage: + - Requires AWS IAM secrets to be set up with aws cli + - Requires an AWS associated keyfile .pem + +>aws configure --profile HappyHacker +[interactive: enter secrets for IAM role] +>aws ec2 create-key-pair --profile HappyHacker --key-name MyKeyPair \ + --query 'KeyMaterial' --output text > MyKeyPair.pem +then: +>python multitester.py targets.yaml MyKeyPair.pem HappyHacker scripts/test_letsencrypt_auto_venv_only.sh +see: + https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html + https://docs.aws.amazon.com/cli/latest/userguide/cli-ec2-keypairs.html +""" + +from __future__ import print_function +from __future__ import with_statement + +import sys, os, time, argparse, socket +import multiprocessing as mp +from multiprocessing import Manager +import urllib2 +import yaml +import boto3 +import fabric +from fabric.api import run, execute, local, env, sudo, cd, lcd +from fabric.operations import get, put +from fabric.context_managers import shell_env + +# Command line parser +#------------------------------------------------------------------------------- +parser = argparse.ArgumentParser(description='Builds EC2 cluster for testing.') +parser.add_argument('config_file', + help='yaml configuration file for AWS server cluster') +parser.add_argument('key_file', + help='key file (.pem) for AWS') +parser.add_argument('aws_profile', + help='profile for AWS (i.e. as in ~/.aws/certificates)') +parser.add_argument('test_script', + default='test_letsencrypt_auto_certonly_standalone.sh', + help='path of bash script in to deploy and run') +#parser.add_argument('--script_args', +# nargs='+', +# help='space-delimited list of arguments to pass to the bash test script', +# required=False) +parser.add_argument('--repo', + default='https://github.com/letsencrypt/letsencrypt.git', + help='letsencrypt git repo to use') +parser.add_argument('--branch', + default='~', + help='letsencrypt git branch to trial') +parser.add_argument('--pull_request', + default='~', + help='letsencrypt/letsencrypt pull request to trial') +parser.add_argument('--merge_master', + action='store_true', + help="if set merges PR into master branch of letsencrypt/letsencrypt") +parser.add_argument('--saveinstances', + action='store_true', + help="don't kill EC2 instances after run, useful for debugging") +parser.add_argument('--alt_pip', + default='', + help="server from which to pull candidate release packages") +parser.add_argument('--killboulder', + action='store_true', + help="do not leave a persistent boulder server running") +parser.add_argument('--boulderonly', + action='store_true', + help="only make a boulder server") +parser.add_argument('--fast', + action='store_true', + help="use larger instance types to run faster (saves about a minute, probably not worth it)") +cl_args = parser.parse_args() + +# Credential Variables +#------------------------------------------------------------------------------- +# assumes naming: = .pem +KEYFILE = cl_args.key_file +KEYNAME = os.path.split(cl_args.key_file)[1].split('.pem')[0] +PROFILE = cl_args.aws_profile + +# Globals +#------------------------------------------------------------------------------- +BOULDER_AMI = 'ami-5f490b35' # premade shared boulder AMI 14.04LTS us-east-1 +LOGDIR = "" #points to logging / working directory +# boto3/AWS api globals +AWS_SESSION = None +EC2 = None + +# Boto3/AWS automation functions +#------------------------------------------------------------------------------- +def make_security_group(): + # will fail if security group of GroupName already exists + # cannot have duplicate SGs of the same name + mysg = EC2.create_security_group(GroupName="letsencrypt_test", + Description='security group for automated testing') + mysg.authorize_ingress(IpProtocol="tcp", CidrIp="0.0.0.0/0", FromPort=22, ToPort=22) + mysg.authorize_ingress(IpProtocol="tcp", CidrIp="0.0.0.0/0", FromPort=80, ToPort=80) + mysg.authorize_ingress(IpProtocol="tcp", CidrIp="0.0.0.0/0", FromPort=443, ToPort=443) + # for boulder wfe (http) server + mysg.authorize_ingress(IpProtocol="tcp", CidrIp="0.0.0.0/0", FromPort=4000, ToPort=4000) + # for mosh + mysg.authorize_ingress(IpProtocol="udp", CidrIp="0.0.0.0/0", FromPort=60000, ToPort=61000) + return mysg + +def make_instance(instance_name, + ami_id, + keyname, + machine_type='t2.micro', + security_groups=['letsencrypt_test'], + userdata=""): #userdata contains bash or cloud-init script + + new_instance = EC2.create_instances( + ImageId=ami_id, + SecurityGroups=security_groups, + KeyName=keyname, + MinCount=1, + MaxCount=1, + UserData=userdata, + InstanceType=machine_type)[0] + + # brief pause to prevent rare error on EC2 delay, should block until ready instead + time.sleep(1.0) + + # give instance a name + try: + new_instance.create_tags(Tags=[{'Key': 'Name', 'Value': instance_name}]) + except botocore.exceptions.ClientError, e: + if "InvalidInstanceID.NotFound" in str(e): + # This seems to be ephemeral... retry + time.sleep(1) + new_instance.create_tags(Tags=[{'Key': 'Name', 'Value': instance_name}]) + else: + raise + return new_instance + +def terminate_and_clean(instances): + """ + Some AMIs specify EBS stores that won't delete on instance termination. + These must be manually deleted after shutdown. + """ + volumes_to_delete = [] + for instance in instances: + for bdmap in instance.block_device_mappings: + if 'Ebs' in bdmap.keys(): + if not bdmap['Ebs']['DeleteOnTermination']: + volumes_to_delete.append(bdmap['Ebs']['VolumeId']) + + for instance in instances: + instance.terminate() + + # can't delete volumes until all attaching instances are terminated + _ids = [instance.id for instance in instances] + all_terminated = False + while not all_terminated: + all_terminated = True + for _id in _ids: + # necessary to reinit object for boto3 to get true state + inst = EC2.Instance(id=_id) + if inst.state['Name'] != 'terminated': + all_terminated = False + time.sleep(5) + + for vol_id in volumes_to_delete: + volume = EC2.Volume(id=vol_id) + volume.delete() + + return volumes_to_delete + + +# Helper Routines +#------------------------------------------------------------------------------- +def block_until_http_ready(urlstring, wait_time=10, timeout=240): + "Blocks until server at urlstring can respond to http requests" + server_ready = False + t_elapsed = 0 + while not server_ready and t_elapsed < timeout: + try: + sys.stdout.write('.') + sys.stdout.flush() + req = urllib2.Request(urlstring) + response = urllib2.urlopen(req) + #if response.code == 200: + server_ready = True + except urllib2.URLError: + pass + time.sleep(wait_time) + t_elapsed += wait_time + +def block_until_ssh_open(ipstring, wait_time=10, timeout=120): + "Blocks until server at ipstring has an open port 22" + reached = False + t_elapsed = 0 + while not reached and t_elapsed < timeout: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((ipstring, 22)) + reached = True + except socket.error as err: + time.sleep(wait_time) + t_elapsed += wait_time + sock.close() + +def block_until_instance_ready(booting_instance, wait_time=5, extra_wait_time=20): + "Blocks booting_instance until AWS EC2 instance is ready to accept SSH connections" + # the reinstantiation from id is necessary to force boto3 + # to correctly update the 'state' variable during init + _id = booting_instance.id + _instance = EC2.Instance(id=_id) + _state = _instance.state['Name'] + _ip = _instance.public_ip_address + while _state != 'running' or _ip is None: + time.sleep(wait_time) + _instance = EC2.Instance(id=_id) + _state = _instance.state['Name'] + _ip = _instance.public_ip_address + block_until_ssh_open(_ip) + time.sleep(extra_wait_time) + return _instance + + +# Fabric Routines +#------------------------------------------------------------------------------- +def local_git_clone(repo_url): + "clones master of repo_url" + with lcd(LOGDIR): + local('if [ -d letsencrypt ]; then rm -rf letsencrypt; fi') + local('git clone %s'% repo_url) + local('tar czf le.tar.gz letsencrypt') + +def local_git_branch(repo_url, branch_name): + "clones branch of repo_url" + with lcd(LOGDIR): + local('if [ -d letsencrypt ]; then rm -rf letsencrypt; fi') + local('git clone %s --branch %s --single-branch'%(repo_url, branch_name)) + local('tar czf le.tar.gz letsencrypt') + +def local_git_PR(repo_url, PRnumstr, merge_master=True): + "clones specified pull request from repo_url and optionally merges into master" + with lcd(LOGDIR): + local('if [ -d letsencrypt ]; then rm -rf letsencrypt; fi') + local('git clone %s'% repo_url) + local('cd letsencrypt && git fetch origin pull/%s/head:lePRtest'%PRnumstr) + local('cd letsencrypt && git co lePRtest') + if merge_master: + local('cd letsencrypt && git remote update origin') + local('cd letsencrypt && git merge origin/master -m "testmerge"') + local('tar czf le.tar.gz letsencrypt') + +def local_repo_to_remote(): + "copies local tarball of repo to remote" + with lcd(LOGDIR): + put(local_path='le.tar.gz', remote_path='') + run('tar xzf le.tar.gz') + +def local_repo_clean(): + "delete tarball" + with lcd(LOGDIR): + local('rm le.tar.gz') + +def deploy_script(scriptpath, *args): + "copies to remote and executes local script" + #with lcd('scripts'): + put(local_path=scriptpath, remote_path='', mirror_local_mode=True) + scriptfile = os.path.split(scriptpath)[1] + args_str = ' '.join(args) + run('./'+scriptfile+' '+args_str) + +def run_boulder(): + with cd('$GOPATH/src/github.com/letsencrypt/boulder'): + run('go run cmd/rabbitmq-setup/main.go -server amqp://localhost') + run('nohup ./start.py >& /dev/null < /dev/null &') + +def config_and_launch_boulder(instance): + execute(deploy_script, 'scripts/boulder_config.sh') + execute(run_boulder) + +def install_and_launch_letsencrypt(instance, boulder_url, target): + execute(local_repo_to_remote) + with shell_env(BOULDER_URL=boulder_url, + PUBLIC_IP=instance.public_ip_address, + PRIVATE_IP=instance.private_ip_address, + PUBLIC_HOSTNAME=instance.public_dns_name, + PIP_EXTRA_INDEX_URL=cl_args.alt_pip, + OS_TYPE=target['type']): + execute(deploy_script, cl_args.test_script) + +def grab_letsencrypt_log(): + "grabs letsencrypt.log via cat into logged stdout" + sudo('if [ -f /var/log/letsencrypt/letsencrypt.log ]; then \ + cat /var/log/letsencrypt/letsencrypt.log; else echo "[novarlog]"; fi') + # fallback file if /var/log is unwriteable...? correct? + sudo('if [ -f ./letsencrypt.log ]; then \ + cat ./letsencrypt.log; else echo "[nolocallog]"; fi') + +def create_client_instances(targetlist): + "Create a fleet of client instances" + instances = [] + print("Creating instances: ", end="") + for target in targetlist: + if target['virt'] == 'hvm': + machine_type = 't2.medium' if cl_args.fast else 't2.micro' + else: + # 32 bit systems + machine_type = 'c1.medium' if cl_args.fast else 't1.micro' + if 'userdata' in target.keys(): + userdata = target['userdata'] + else: + userdata = '' + name = 'le-%s'%target['name'] + print(name, end=" ") + instances.append(make_instance(name, + target['ami'], + KEYNAME, + machine_type=machine_type, + userdata=userdata)) + print() + return instances + +#------------------------------------------------------------------------------- +# SCRIPT BEGINS +#------------------------------------------------------------------------------- + +# Fabric library controlled through global env parameters +env.key_filename = KEYFILE +env.shell = '/bin/bash -l -i -c' +env.connection_attempts = 5 +env.timeout = 10 +# replace default SystemExit thrown by fabric during trouble +class FabricException(Exception): + pass +env['abort_exception'] = FabricException + +# Set up local copy of git repo +#------------------------------------------------------------------------------- +LOGDIR = "letest-%d"%int(time.time()) +print("Making local dir for test repo and logs: %s"%LOGDIR) +local('mkdir %s'%LOGDIR) + +# figure out what git object to test and locally create it in LOGDIR +print("Making local git repo") +try: + if cl_args.pull_request != '~': + print('Testing PR %s '%cl_args.pull_request, + "MERGING into master" if cl_args.merge_master else "") + execute(local_git_PR, cl_args.repo, cl_args.pull_request, cl_args.merge_master) + elif cl_args.branch != '~': + print('Testing branch %s of %s'%(cl_args.branch, cl_args.repo)) + execute(local_git_branch, cl_args.repo, cl_args.branch) + else: + print('Testing master of %s'%cl_args.repo) + execute(local_git_clone, cl_args.repo) +except FabricException: + print("FAIL: trouble with git repo") + exit() + + +# Set up EC2 instances +#------------------------------------------------------------------------------- +configdata = yaml.load(open(cl_args.config_file, 'r')) +targetlist = configdata['targets'] +print('Testing against these images: [%d total]'%len(targetlist)) +for target in targetlist: + print(target['ami'], target['name']) + +print("Connecting to EC2 using\n profile %s\n keyname %s\n keyfile %s"%(PROFILE, KEYNAME, KEYFILE)) +AWS_SESSION = boto3.session.Session(profile_name=PROFILE) +EC2 = AWS_SESSION.resource('ec2') + +print("Making Security Group") +sg_exists = False +for sg in EC2.security_groups.all(): + if sg.group_name == 'letsencrypt_test': + sg_exists = True + print(" %s already exists"%'letsencrypt_test') +if not sg_exists: + make_security_group() + time.sleep(30) + +boulder_preexists = False +boulder_servers = EC2.instances.filter(Filters=[ + {'Name': 'tag:Name', 'Values': ['le-boulderserver']}, + {'Name': 'instance-state-name', 'Values': ['running']}]) + +boulder_server = next(iter(boulder_servers), None) + +print("Requesting Instances...") +if boulder_server: + print("Found existing boulder server:", boulder_server) + boulder_preexists = True +else: + print("Can't find a boulder server, starting one...") + boulder_server = make_instance('le-boulderserver', + BOULDER_AMI, + KEYNAME, + machine_type='t2.micro', + #machine_type='t2.medium', + security_groups=['letsencrypt_test']) + +if not cl_args.boulderonly: + instances = create_client_instances(targetlist) + +# Configure and launch boulder server +#------------------------------------------------------------------------------- +print("Waiting on Boulder Server") +boulder_server = block_until_instance_ready(boulder_server) +print(" server %s"%boulder_server) + + +# env.host_string defines the ssh user and host for connection +env.host_string = "ubuntu@%s"%boulder_server.public_ip_address +print("Boulder Server at (SSH):", env.host_string) +if not boulder_preexists: + print("Configuring and Launching Boulder") + config_and_launch_boulder(boulder_server) + # blocking often unnecessary, but cheap EC2 VMs can get very slow + block_until_http_ready('http://%s:4000'%boulder_server.public_ip_address, + wait_time=10, timeout=500) + +boulder_url = "http://%s:4000/directory"%boulder_server.private_ip_address +print("Boulder Server at (public ip): http://%s:4000/directory"%boulder_server.public_ip_address) +print("Boulder Server at (EC2 private ip): %s"%boulder_url) + +if cl_args.boulderonly: + sys.exit(0) + +# Install and launch client scripts in parallel +#------------------------------------------------------------------------------- +print("Uploading and running test script in parallel: %s"%cl_args.test_script) +print("Output routed to log files in %s"%LOGDIR) +# (Advice: always use Manager.Queue, never regular multiprocessing.Queue +# the latter has implementation flaws that deadlock it in some circumstances) +manager = Manager() +outqueue = manager.Queue() +inqueue = manager.Queue() +SENTINEL = None #queue kill signal + +# launch as many processes as clients to test +num_processes = len(targetlist) +jobs = [] #keep a reference to current procs + +def test_client_process(inqueue, outqueue): + cur_proc = mp.current_process() + for inreq in iter(inqueue.get, SENTINEL): + ii, target = inreq + + #save all stdout to log file + sys.stdout = open(LOGDIR+'/'+'%d_%s.log'%(ii,target['name']), 'w') + + print("[%s : client %d %s %s]" % (cur_proc.name, ii, target['ami'], target['name'])) + instances[ii] = block_until_instance_ready(instances[ii]) + print("server %s at %s"%(instances[ii], instances[ii].public_ip_address)) + env.host_string = "%s@%s"%(target['user'], instances[ii].public_ip_address) + print(env.host_string) + + try: + install_and_launch_letsencrypt(instances[ii], boulder_url, target) + outqueue.put((ii, target, 'pass')) + print("%s - %s SUCCESS"%(target['ami'], target['name'])) + except: + outqueue.put((ii, target, 'fail')) + print("%s - %s FAIL"%(target['ami'], target['name'])) + pass + + # append server letsencrypt.log to each per-machine output log + print("\n\nletsencrypt.log\n" + "-"*80 + "\n") + try: + execute(grab_letsencrypt_log) + except: + print("log fail\n") + pass + +# initiate process execution +for i in range(num_processes): + p = mp.Process(target=test_client_process, args=(inqueue, outqueue)) + jobs.append(p) + p.daemon = True # kills subprocesses if parent is killed + p.start() + +# fill up work queue +for ii, target in enumerate(targetlist): + inqueue.put((ii, target)) + +# add SENTINELs to end client processes +for i in range(num_processes): + inqueue.put(SENTINEL) +# wait on termination of client processes +for p in jobs: + p.join() +# add SENTINEL to output queue +outqueue.put(SENTINEL) + +# clean up +execute(local_repo_clean) + +# print and save summary results +results_file = open(LOGDIR+'/results', 'w') +outputs = [outq for outq in iter(outqueue.get, SENTINEL)] +outputs.sort(key=lambda x: x[0]) +for outq in outputs: + ii, target, status = outq + print('%d %s %s'%(ii, target['name'], status)) + results_file.write('%d %s %s\n'%(ii, target['name'], status)) +results_file.close() + +if not cl_args.saveinstances: + print('Logs in ', LOGDIR) + print('Terminating EC2 Instances and Cleaning Dangling EBS Volumes') + if cl_args.killboulder: + boulder_server.terminate() + terminate_and_clean(instances) +else: + # print login information for the boxes for debugging + for ii, target in enumerate(targetlist): + print(target['name'], + target['ami'], + "%s@%s"%(target['user'], instances[ii].public_ip_address)) + +# kill any connections +fabric.network.disconnect_all() diff --git a/tests/letstest/scripts/boulder_config.sh b/tests/letstest/scripts/boulder_config.sh new file mode 100755 index 000000000..1ef63ca10 --- /dev/null +++ b/tests/letstest/scripts/boulder_config.sh @@ -0,0 +1,32 @@ +#!/bin/bash -x + +# Configures and Launches Boulder Server installed on +# us-east-1 ami-5f490b35 bouldertestserver (boulder commit 8b433f54dab) + +# fetch instance data from EC2 metadata service +public_host=$(curl -s http://169.254.169.254/2014-11-05/meta-data/public-hostname) +public_ip=$(curl -s http://169.254.169.254/2014-11-05/meta-data/public-ipv4) +private_ip=$(curl -s http://169.254.169.254/2014-11-05/meta-data/local-ipv4) + +# get local DNS resolver for VPC +resolver_ip=$(grep nameserver /etc/resolv.conf |cut -d" " -f2 |head -1) +resolver=$resolver_ip':53' + +# modifies integration testing boulder setup for local AWS VPC network +# connections instead of localhost +cd $GOPATH/src/github.com/letsencrypt/boulder +# configure boulder to receive outside connection on 4000 +sed -i '/listenAddress/ s/127.0.0.1:4000/'$private_ip':4000/' ./test/boulder-config.json +sed -i '/baseURL/ s/127.0.0.1:4000/'$private_ip':4000/' ./test/boulder-config.json +# change test ports to real +sed -i '/httpPort/ s/5002/80/' ./test/boulder-config.json +sed -i '/httpsPort/ s/5001/443/' ./test/boulder-config.json +sed -i '/tlsPort/ s/5001/443/' ./test/boulder-config.json +# set local dns resolver +sed -i '/dnsResolver/ s/127.0.0.1:8053/'$resolver'/' ./test/boulder-config.json + +# start rabbitMQ +#go run cmd/rabbitmq-setup/main.go -server amqp://localhost +# start acme services +#nohup ./start.py >& /dev/null < /dev/null & +#./start.py diff --git a/tests/letstest/scripts/boulder_install.sh b/tests/letstest/scripts/boulder_install.sh new file mode 100755 index 000000000..b5ddf2c5b --- /dev/null +++ b/tests/letstest/scripts/boulder_install.sh @@ -0,0 +1,28 @@ +#!/bin/bash -x + +# >>>> only tested on Ubuntu 14.04LTS <<<< + +# non-interactive install of mariadb and other dependencies +export DEBIAN_FRONTEND=noninteractive +sudo debconf-set-selections <<< 'mariadb-server mysql-server/root_password password PASS' +sudo debconf-set-selections <<< 'mariadb-server mysql-server/root_password_again password PASS' +apt-get -y --no-upgrade install git make libltdl3-dev mariadb-server rabbitmq-server +sudo mysql -uroot -pPASS -e "SET PASSWORD = PASSWORD(\'\');" + +# install go +wget https://storage.googleapis.com/golang/go1.5.1.linux-amd64.tar.gz +tar xzvf go1.5.1.linux-amd64.tar.gz +mkdir gocode +echo "export GOROOT=/home/ubuntu/go \n\ + export GOPATH=/home/ubuntu/gocode\n\ + export PATH=/home/ubuntu/go/bin:/home/ubuntu/gocode/bin:$PATH" >> .bashrc + +# install boulder and its go dependencies +go get -d github.com/letsencrypt/boulder/... +cd $GOPATH/src/github.com/letsencrypt/boulder +wget https://github.com/jsha/boulder-tools/raw/master/goose.gz +mkdir $GOPATH/bin +zcat goose.gz > $GOPATH/bin/goose +chmod +x $GOPATH/bin/goose +./test/create_db.sh +go get github.com/jsha/listenbuddy diff --git a/tests/letstest/scripts/test_apache2.sh b/tests/letstest/scripts/test_apache2.sh new file mode 100755 index 000000000..4032e2195 --- /dev/null +++ b/tests/letstest/scripts/test_apache2.sh @@ -0,0 +1,74 @@ +#!/bin/bash -x + +# $OS_TYPE $PUBLIC_IP $PRIVATE_IP $PUBLIC_HOSTNAME $BOULDER_URL +# are dynamically set at execution + +if [ "$OS_TYPE" = "ubuntu" ] +then + CONFFILE=/etc/apache2/sites-available/000-default.conf + sudo apt-get update + sudo apt-get -y --no-upgrade install apache2 #curl + sudo apt-get -y install realpath # needed for test-apache-conf + # For apache 2.4, set up ServerName + sudo sed -i '/ServerName/ s/#ServerName/ServerName/' $CONFFILE + sudo sed -i '/ServerName/ s/www.example.com/'$PUBLIC_HOSTNAME'/' $CONFFILE +elif [ "$OS_TYPE" = "centos" ] +then + CONFFILE=/etc/httpd/conf/httpd.conf + sudo setenforce 0 || true #disable selinux + sudo yum -y install httpd + sudo service httpd start + sudo mkdir -p /var/www/$PUBLIC_HOSTNAME/public_html + sudo chmod -R oug+rwx /var/www + sudo chmod -R oug+rw /etc/httpd + sudo echo 'foobar' > /var/www/$PUBLIC_HOSTNAME/public_html/index.html + sudo mkdir /etc/httpd/sites-available #letsencrypt requires this... + sudo mkdir /etc/httpd/sites-enabled #letsencrypt requires this... + #sudo echo "IncludeOptional sites-enabled/*.conf" >> /etc/httpd/conf/httpd.conf + sudo echo """ + + ServerName $PUBLIC_HOSTNAME + DocumentRoot /var/www/$PUBLIC_HOSTNAME/public_html + ErrorLog /var/www/$PUBLIC_HOSTNAME/error.log + CustomLog /var/www/$PUBLIC_HOSTNAME/requests.log combined +""" >> /etc/httpd/conf.d/$PUBLIC_HOSTNAME.conf + #sudo cp /etc/httpd/sites-available/$PUBLIC_HOSTNAME.conf /etc/httpd/sites-enabled/ +fi + +# run letsencrypt-apache2 via letsencrypt-auto +cd letsencrypt + +export SUDO=sudo +if [ -f /etc/debian_version ] ; then + echo "Bootstrapping dependencies for Debian-based OSes..." + $SUDO bootstrap/_deb_common.sh +elif [ -f /etc/redhat-release ] ; then + echo "Bootstrapping dependencies for RedHat-based OSes..." + $SUDO bootstrap/_rpm_common.sh +else + echo "Dont have bootstrapping for this OS!" + exit 1 +fi + +bootstrap/dev/venv.sh +sudo venv/bin/letsencrypt -v --debug --text --agree-dev-preview --agree-tos \ + --renew-by-default --redirect --register-unsafely-without-email \ + --domain $PUBLIC_HOSTNAME --server $BOULDER_URL +if [ $? -ne 0 ] ; then + FAIL=1 +fi + +if [ "$OS_TYPE" = "ubuntu" ] ; then + venv/bin/tox -e apacheconftest +else + echo Not running hackish apache tests on $OS_TYPE +fi + +if [ $? -ne 0 ] ; then + FAIL=1 +fi + +# return error if any of the subtests failed +if [ "$FAIL" = 1 ] ; then + exit 1 +fi diff --git a/tests/letstest/scripts/test_leauto_upgrades.sh b/tests/letstest/scripts/test_leauto_upgrades.sh new file mode 100755 index 000000000..0ad0d6081 --- /dev/null +++ b/tests/letstest/scripts/test_leauto_upgrades.sh @@ -0,0 +1,36 @@ +#!/bin/bash -xe + +# $OS_TYPE $PUBLIC_IP $PRIVATE_IP $PUBLIC_HOSTNAME $BOULDER_URL +# are dynamically set at execution + +cd letsencrypt +#git checkout v0.1.0 use --branch instead +SAVE="$PIP_EXTRA_INDEX_URL" +unset PIP_EXTRA_INDEX_URL +export PIP_INDEX_URL="https://isnot.org/pip/0.1.0/" + +#OLD_LEAUTO="https://raw.githubusercontent.com/letsencrypt/letsencrypt/5747ab7fd9641986833bad474d71b46a8c589247/letsencrypt-auto" + + +if ! command -v git ; then + if [ "$OS_TYPE" = "ubuntu" ] ; then + sudo apt-get update + fi + if ! ( sudo apt-get install -y git || sudo yum install -y git-all || sudo yum install -y git || sudo dnf install -y git ) ; then + echo git installation failed! + exit 1 + fi +fi +BRANCH=`git rev-parse --abbrev-ref HEAD` +git checkout v0.1.0 +./letsencrypt-auto -v --debug --version +unset PIP_INDEX_URL + +export PIP_EXTRA_INDEX_URL="$SAVE" + +git checkout -f "$BRANCH" +if ! ./letsencrypt-auto -v --debug --version | grep 0.3.0 ; then + echo upgrade appeared to fail + exit 1 +fi +echo upgrade appeared to be successful diff --git a/tests/letstest/scripts/test_letsencrypt_auto_certonly_standalone.sh b/tests/letstest/scripts/test_letsencrypt_auto_certonly_standalone.sh new file mode 100755 index 000000000..10d7c3b5e --- /dev/null +++ b/tests/letstest/scripts/test_letsencrypt_auto_certonly_standalone.sh @@ -0,0 +1,15 @@ +#!/bin/bash -x + +# $PUBLIC_IP $PRIVATE_IP $PUBLIC_HOSTNAME $BOULDER_URL are dynamically set at execution + +# with curl, instance metadata available from EC2 metadata service: +#public_host=$(curl -s http://169.254.169.254/2014-11-05/meta-data/public-hostname) +#public_ip=$(curl -s http://169.254.169.254/2014-11-05/meta-data/public-ipv4) +#private_ip=$(curl -s http://169.254.169.254/2014-11-05/meta-data/local-ipv4) + +cd letsencrypt +./letsencrypt-auto certonly -v --standalone --debug \ + --text --agree-dev-preview --agree-tos \ + --renew-by-default --redirect \ + --register-unsafely-without-email \ + --domain $PUBLIC_HOSTNAME --server $BOULDER_URL diff --git a/tests/letstest/scripts/test_letsencrypt_auto_venv_only.sh b/tests/letstest/scripts/test_letsencrypt_auto_venv_only.sh new file mode 100755 index 000000000..234e70f68 --- /dev/null +++ b/tests/letstest/scripts/test_letsencrypt_auto_venv_only.sh @@ -0,0 +1,7 @@ +#!/bin/bash -x + +# $PUBLIC_IP $PRIVATE_IP $PUBLIC_HOSTNAME $BOULDER_URL are dynamically set at execution + +cd letsencrypt +# help installs virtualenv and does nothing else +./letsencrypt-auto -v --debug --help all diff --git a/tests/letstest/scripts/test_tox.sh b/tests/letstest/scripts/test_tox.sh new file mode 100755 index 000000000..f7f325d5c --- /dev/null +++ b/tests/letstest/scripts/test_tox.sh @@ -0,0 +1,80 @@ +#!/bin/bash -x +XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} +VENV_NAME="venv" +# The path to the letsencrypt-auto script. Everything that uses these might +# at some point be inlined... +LEA_PATH=./letsencrypt/ +VENV_PATH=${LEA_PATH/$VENV_NAME} +VENV_BIN=${VENV_PATH}/bin +BOOTSTRAP=${LEA_PATH}/bootstrap + +SUDO=sudo + +ExperimentalBootstrap() { + # Arguments: Platform name, boostrap script name, SUDO command (iff needed) + if [ "$2" != "" ] ; then + echo "Bootstrapping dependencies for $1..." + if [ "$3" != "" ] ; then + "$3" "$BOOTSTRAP/$2" + else + "$BOOTSTRAP/$2" + fi + fi +} + +# virtualenv call is not idempotent: it overwrites pip upgraded in +# later steps, causing "ImportError: cannot import name unpack_url" +if [ ! -f $BOOTSTRAP/debian.sh ] ; then + echo "Cannot find the letsencrypt bootstrap scripts in $BOOTSTRAP" + exit 1 +fi + +if [ -f /etc/debian_version ] ; then + echo "Bootstrapping dependencies for Debian-based OSes..." + $SUDO $BOOTSTRAP/_deb_common.sh +elif [ -f /etc/redhat-release ] ; then + echo "Bootstrapping dependencies for RedHat-based OSes..." + $SUDO $BOOTSTRAP/_rpm_common.sh +elif `grep -q openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE-based OSes..." + $SUDO $BOOTSTRAP/_suse_common.sh +elif [ -f /etc/arch-release ] ; then + if [ "$DEBUG" = 1 ] ; then + echo "Bootstrapping dependencies for Archlinux..." + $SUDO $BOOTSTRAP/archlinux.sh + else + echo "Please use pacman to install letsencrypt packages:" + echo "# pacman -S letsencrypt letsencrypt-apache" + echo + echo "If you would like to use the virtualenv way, please run the script again with the" + echo "--debug flag." + exit 1 + fi +elif [ -f /etc/manjaro-release ] ; then + ExperimentalBootstrap "Manjaro Linux" manjaro.sh "$SUDO" +elif [ -f /etc/gentoo-release ] ; then + ExperimentalBootstrap "Gentoo" _gentoo_common.sh "$SUDO" +elif uname | grep -iq FreeBSD ; then + ExperimentalBootstrap "FreeBSD" freebsd.sh "$SUDO" +elif uname | grep -iq Darwin ; then + ExperimentalBootstrap "Mac OS X" mac.sh # homebrew doesn't normally run as root +elif grep -iq "Amazon Linux" /etc/issue ; then + ExperimentalBootstrap "Amazon Linux" _rpm_common.sh "$SUDO" +else + echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!" + echo + echo "You will need to bootstrap, configure virtualenv, and run a pip install manually" + echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites" + echo "for more info" +fi +echo "Bootstrapped!" + +cd letsencrypt +./bootstrap/dev/venv.sh +PYVER=`python --version 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'` + +if [ $PYVER -eq 26 ] ; then + venv/bin/tox -e py26 +else + venv/bin/tox -e py27 +fi diff --git a/tests/letstest/targets.yaml b/tests/letstest/targets.yaml new file mode 100644 index 000000000..506225f86 --- /dev/null +++ b/tests/letstest/targets.yaml @@ -0,0 +1,99 @@ +targets: + #----------------------------------------------------------------------------- + #Ubuntu + - ami: ami-26d5af4c + name: ubuntu15.10 + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-d92e6bb3 + name: ubuntu15.04LTS + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-7b89cc11 + name: ubuntu14.04LTS + type: ubuntu + virt: hvm + user: ubuntu + - ami: ami-9295d0f8 + name: ubuntu14.04LTS_32bit + type: ubuntu + virt: pv + user: ubuntu + - ami: ami-0611546c + name: ubuntu12.04LTS + type: ubuntu + virt: hvm + user: ubuntu + #----------------------------------------------------------------------------- + # Debian + - ami: ami-116d857a + name: debian8.1 + type: ubuntu + virt: hvm + user: admin + # userdata: | + # #cloud-init + # runcmd: + # - [ apt-get, install, -y, curl ] + - ami: ami-e0efab88 + name: debian7.8.aws.1 + type: ubuntu + virt: hvm + user: admin + # userdata: | + # #cloud-init + # runcmd: + # - [ apt-get, install, -y, curl ] + - ami: ami-e6eeaa8e + name: debian7.8.aws.1_32bit + type: ubuntu + virt: pv + user: admin + # userdata: | + # #cloud-init + # runcmd: + # - [ apt-get, install, -y, curl ] + #----------------------------------------------------------------------------- + # Other Redhat Distros + - ami: ami-60b6c60a + name: amazonlinux-2015.09.1 + type: centos + virt: hvm + user: ec2-user + - ami: ami-0d4cfd66 + name: amazonlinux-2015.03.1 + type: centos + virt: hvm + user: ec2-user + - ami: ami-a8d369c0 + name: RHEL7 + type: centos + virt: hvm + user: ec2-user + - ami: ami-518bfb3b + name: fedora23 + type: centos + virt: hvm + user: fedora + #----------------------------------------------------------------------------- + # CentOS + # These Marketplace AMIs must, irritatingly, have their terms manually + # agreed to on the AWS marketplace site for any new AWS account using them... + - ami: ami-61bbf104 + name: centos7 + type: centos + virt: hvm + user: centos + # centos6 requires EPEL repo added + - ami: ami-57cd8732 + name: centos6 + type: centos + virt: hvm + user: centos + userdata: | + #cloud-config + runcmd: + - yum install -y epel-release + - iptables -F diff --git a/tools/dev-release.sh b/tools/dev-release.sh deleted file mode 100755 index bd86bff44..000000000 --- a/tools/dev-release.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/sh -xe -# Release dev packages to PyPI - -version="0.0.0.dev$(date +%Y%m%d)" -DEV_RELEASE_BRANCH="dev-release" -# TODO: create a real release key instead of using Kuba's personal one -RELEASE_GPG_KEY="${RELEASE_GPG_KEY:-148C30F6F7E429337A72D992B00B9CC82D7ADF2C}" - -# port for a local Python Package Index (used in testing) -PORT=${PORT:-1234} - -# subpackages to be released -SUBPKGS=${SUBPKGS:-"acme letsencrypt-apache letsencrypt-nginx letshelp-letsencrypt"} -subpkgs_modules="$(echo $SUBPKGS | sed s/-/_/g)" -# letsencrypt_compatibility_test is not packaged because: -# - it is not meant to be used by anyone else than Let's Encrypt devs -# - it causes problems when running nosetests - the latter tries to -# run everything that matches test*, while there are no unittests -# there - -tag="v$version" -mv "dist.$version" "dist.$version.$(date +%s).bak" || true -git tag --delete "$tag" || true - -tmpvenv=$(mktemp -d) -virtualenv --no-site-packages -p python2 $tmpvenv -. $tmpvenv/bin/activate -# update setuptools/pip just like in other places in the repo -pip install -U setuptools -pip install -U pip # latest pip => no --pre for dev releases -pip install -U wheel # setup.py bdist_wheel - -# newer versions of virtualenv inherit setuptools/pip/wheel versions -# from current env when creating a child env -pip install -U virtualenv - -root="$(mktemp -d -t le.$version.XXX)" -echo "Cloning into fresh copy at $root" # clean repo = no artificats -git clone . $root -git rev-parse HEAD -cd $root -git branch -f "$DEV_RELEASE_BRANCH" -git checkout "$DEV_RELEASE_BRANCH" - -for pkg_dir in $SUBPKGS -do - sed -i $x "s/^version.*/version = '$version'/" $pkg_dir/setup.py -done -sed -i "s/^__version.*/__version__ = '$version'/" letsencrypt/__init__.py - -git add -p # interactive user input -git commit --gpg-sign="$RELEASE_GPG_KEY" -m "Release $version" -git tag --local-user "$RELEASE_GPG_KEY" \ - --sign --message "Release $version" "$tag" - -echo "Preparing sdists and wheels" -for pkg_dir in . $SUBPKGS -do - cd $pkg_dir - - python setup.py clean - rm -rf build dist - python setup.py sdist - python setup.py bdist_wheel - - echo "Signing ($pkg_dir)" - for x in dist/*.tar.gz dist/*.whl - do - gpg2 --detach-sign --armor --sign $x - done - - cd - -done - -mkdir "dist.$version" -mv dist "dist.$version/letsencrypt" -for pkg_dir in $SUBPKGS -do - mv $pkg_dir/dist "dist.$version/$pkg_dir/" -done - -echo "Testing packages" -cd "dist.$version" -# start local PyPI -python -m SimpleHTTPServer $PORT & -# cd .. is NOT done on purpose: we make sure that all subpackages are -# installed from local PyPI rather than current directory (repo root) -virtualenv --no-site-packages ../venv -. ../venv/bin/activate -pip install -U setuptools -pip install -U pip -# Now, use our local PyPI -pip install \ - --extra-index-url http://localhost:$PORT \ - letsencrypt $SUBPKGS -# stop local PyPI -kill $! - -# freeze before installing anything else, so that we know end-user KGS -# make sure "twine upload" doesn't catch "kgs" -mkdir ../kgs -kgs="../kgs/$version" -pip freeze | tee $kgs -pip install nose -nosetests letsencrypt $subpkgs_modules - -echo "New root: $root" -echo "KGS is at $root/kgs" -echo "In order to upload packages run the following command:" -echo twine upload "$root/dist.$version/*/*" diff --git a/tools/half-sign.c b/tools/half-sign.c new file mode 100644 index 000000000..e56bc397c --- /dev/null +++ b/tools/half-sign.c @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include +#include +#include + +// This program can be used to perform RSA public key signatures given only +// the hash of the file to be signed as input. + +// To compile: +// gcc half-sign.c -lssl -lcrypto -o half-sign + +// Sign with SHA256 +#define HASH_SIZE 32 + +void usage() { + printf("half-sign [binary hash file]\n"); + printf("\n"); + printf(" Computes and prints a binary RSA signature over data given the SHA256 hash of\n"); + printf(" the data as input.\n"); + printf("\n"); + printf(" should be PEM encoded.\n"); + printf("\n"); + printf(" The input SHA256 hash should be %d bytes in length. If no binary hash file is\n", HASH_SIZE); + printf(" specified, it will be read from stdin.\n"); + exit(1); +} + +void sign_hashed_data(EVP_PKEY *signing_key, unsigned char *md, size_t mdlen) { + // cribbed from the openssl EVP_PKEY_sign man page + EVP_PKEY_CTX *ctx; + unsigned char *sig; + size_t siglen; + + /* NB: assumes signing_key, md and mdlen are already set up + * and that signing_key is an RSA private key + */ + ctx = EVP_PKEY_CTX_new(signing_key, NULL); + if ((!ctx) + || (EVP_PKEY_sign_init(ctx) <= 0) + || (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING) <= 0) + || (EVP_PKEY_CTX_set_signature_md(ctx, EVP_sha256()) <= 0)) { + fprintf(stderr, "Failure establishing ctx for signature\n"); + exit(1); + } + + /* Determine buffer length */ + if (EVP_PKEY_sign(ctx, NULL, &siglen, md, mdlen) <= 0) { + fprintf(stderr, "Unable to determine buffer length for signature\n"); + exit(1); + } + + sig = OPENSSL_malloc(siglen); + + if (!sig) { + fprintf(stderr, "Malloc failed\n"); + exit(1); + } + + if (EVP_PKEY_sign(ctx, sig, &siglen, md, mdlen) <= 0) { + fprintf(stderr, "Signature error\n"); + exit(1); + } + + /* Signature is siglen bytes written to buffer sig */ + fwrite(sig, siglen, 1, stdout); +} + +EVP_PKEY *read_private_key(char *filename) { + FILE *keyfile; + EVP_PKEY *privkey; + keyfile = fopen(filename, "r"); + if (!keyfile) { + fprintf(stderr, "Failed to open private key.pem file %s\n", filename); + exit(1); + } + privkey = PEM_read_PrivateKey(keyfile, NULL, NULL, NULL); + if (!privkey) { + fprintf(stderr, "Failed to read PEM private key from %s\n", filename); + exit(1); + } + if (EVP_PKEY_type(privkey->type) != EVP_PKEY_RSA) { + fprintf(stderr, "%s was a non-RSA key\n", filename); + exit(1); + } + return privkey; +} + +int main(int argc, char *argv[]) { + FILE *input; + unsigned char *buffer; + int test; + EVP_PKEY *privkey; + if (argc > 3 || argc < 2) + usage(); + if (argc < 3 || strcmp(argv[2],"-") == 0) + input = stdin; + else { + input = fopen(argv[2], "r"); + if (!input) usage(); + } + privkey = read_private_key(argv[1]); + buffer = malloc(HASH_SIZE); + if (!buffer) { + fprintf(stderr, "Argh, malloc failed\n"); + exit(1); + } + if (fread(buffer, HASH_SIZE, 1, input) != 1) { + perror("half-sign: Failed to read SHA256 from input\n"); + exit(1); + } + + test = fgetc(input); + if (test != EOF && test != '\n') { + fprintf(stderr,"Error, more than %d bytes fed to half-sign\n", HASH_SIZE); + fprintf(stderr,"Last byte was :%d\n" , (int) test); + exit(1); + } + sign_hashed_data(privkey, buffer, HASH_SIZE); + return 0; +} diff --git a/tools/offline-sigrequest.sh b/tools/offline-sigrequest.sh new file mode 100755 index 000000000..ca349f629 --- /dev/null +++ b/tools/offline-sigrequest.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -o errexit + +if ! `which festival > /dev/null` ; then + echo Please install \'festival\'! + exit 1 +fi + +function sayhash { # $1 <-- HASH ; $2 <---SIGFILEBALL + while read -p "Press Enter to read the hash aloud or type 'done': " INP && [ "$INP" = "" ] ; do + cat $1 | (echo "(Parameter.set 'Duration_Stretch 1.5)"; \ + echo -n '(SayText "'; \ + sha1sum | cut -c1-40 | fold -1 | sed 's/^a$/alpha/; s/^b$/bravo/; s/^c$/charlie/; s/^d$/delta/; s/^e$/echo/; s/^f$/foxtrot/'; \ + echo '")' ) | festival + done + + echo 'Paste in the data from the QR code, then type Ctrl-D:' + cat > $2 +} + +function offlinesign { # $1 <-- INPFILE ; $2 <---SIGFILE + echo HASH FOR SIGNING: + SIGFILEBALL="$2.lzma.base64" + #echo "(place the resulting raw binary signature in $SIGFILEBALL)" + sha1sum $1 + echo metahash for confirmation only $(sha1sum $1 |cut -d' ' -f1 | tr -d '\n' | sha1sum | cut -c1-6) ... + echo + sayhash $1 $SIGFILEBALL +} + +function oncesigned { # $1 <-- INPFILE ; $2 <--SIGFILE + SIGFILEBALL="$2.lzma.base64" + cat $SIGFILEBALL | tr -d '\r' | base64 -d | unlzma -c > $2 || exit 1 + if ! [ -f $2 ] ; then + echo "Failed to find $2"'!' + exit 1 + fi + + if file $2 | grep -qv " data" ; then + echo "WARNING WARNING $2 does not look like a binary signature:" + echo `file $2` + exit 1 + fi +} + +HERE=`dirname $0` +LEAUTO="`realpath $HERE`/../letsencrypt-auto-source/letsencrypt-auto" +SIGFILE="$LEAUTO".sig +offlinesign $LEAUTO $SIGFILE +oncesigned $LEAUTO $SIGFILE diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 000000000..9d625191e --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,197 @@ +#!/bin/bash -xe +# Release dev packages to PyPI + +Usage() { + echo Usage: + echo "$0 [ --production ]" + exit 1 +} + +if [ "`dirname $0`" != "tools" ] ; then + echo Please run this script from the repo root + exit 1 +fi + +CheckVersion() { + # Args: + if ! echo "$2" | grep -q -e '[0-9]\+.[0-9]\+.[0-9]\+' ; then + echo "$1 doesn't look like 1.2.3" + exit 1 + fi +} + +if [ "$1" = "--production" ] ; then + version="$2" + CheckVersion Version "$version" + echo Releasing production version "$version"... + nextversion="$3" + CheckVersion "Next version" "$nextversion" + RELEASE_BRANCH="candidate-$version" +else + version=`grep "__version__" letsencrypt/__init__.py | cut -d\' -f2 | sed s/\.dev0//` + version="$version.dev$(date +%Y%m%d)1" + RELEASE_BRANCH="dev-release" + echo Releasing developer version "$version"... +fi + +if [ "$RELEASE_OPENSSL_PUBKEY" = "" ] ; then + RELEASE_OPENSSL_PUBKEY="`realpath \`dirname $0\``/eff-pubkey.pem" +fi +RELEASE_GPG_KEY=${RELEASE_GPG_KEY:-A2CFB51FA275A7286234E7B24D17C995CD9775F2} +# Needed to fix problems with git signatures and pinentry +export GPG_TTY=$(tty) + +# port for a local Python Package Index (used in testing) +PORT=${PORT:-1234} + +# subpackages to be released +SUBPKGS=${SUBPKGS:-"acme letsencrypt-apache letsencrypt-nginx letshelp-letsencrypt"} +subpkgs_modules="$(echo $SUBPKGS | sed s/-/_/g)" +# letsencrypt_compatibility_test is not packaged because: +# - it is not meant to be used by anyone else than Let's Encrypt devs +# - it causes problems when running nosetests - the latter tries to +# run everything that matches test*, while there are no unittests +# there + +tag="v$version" +mv "dist.$version" "dist.$version.$(date +%s).bak" || true +git tag --delete "$tag" || true + +tmpvenv=$(mktemp -d) +virtualenv --no-site-packages -p python2 $tmpvenv +. $tmpvenv/bin/activate +# update setuptools/pip just like in other places in the repo +pip install -U setuptools +pip install -U pip # latest pip => no --pre for dev releases +pip install -U wheel # setup.py bdist_wheel + +# newer versions of virtualenv inherit setuptools/pip/wheel versions +# from current env when creating a child env +pip install -U virtualenv + +root_without_le="$version.$$" +root="./releases/le.$root_without_le" + +echo "Cloning into fresh copy at $root" # clean repo = no artificats +git clone . $root +git rev-parse HEAD +cd $root +if [ "$RELEASE_BRANCH" != "candidate-$version" ] ; then + git branch -f "$RELEASE_BRANCH" +fi +git checkout "$RELEASE_BRANCH" + +SetVersion() { + ver="$1" + for pkg_dir in $SUBPKGS letsencrypt-compatibility-test + do + sed -i "s/^version.*/version = '$ver'/" $pkg_dir/setup.py + done + sed -i "s/^__version.*/__version__ = '$ver'/" letsencrypt/__init__.py + + # interactive user input + git add -p letsencrypt $SUBPKGS letsencrypt-compatibility-test + +} + +SetVersion "$version" + +echo "Preparing sdists and wheels" +for pkg_dir in . $SUBPKGS +do + cd $pkg_dir + + python setup.py clean + rm -rf build dist + python setup.py sdist + python setup.py bdist_wheel + + echo "Signing ($pkg_dir)" + for x in dist/*.tar.gz dist/*.whl + do + gpg -u "$RELEASE_GPG_KEY" --detach-sign --armor --sign $x + done + + cd - +done + + +mkdir "dist.$version" +mv dist "dist.$version/letsencrypt" +for pkg_dir in $SUBPKGS +do + mv $pkg_dir/dist "dist.$version/$pkg_dir/" +done + +echo "Testing packages" +cd "dist.$version" +# start local PyPI +python -m SimpleHTTPServer $PORT & +# cd .. is NOT done on purpose: we make sure that all subpackages are +# installed from local PyPI rather than current directory (repo root) +virtualenv --no-site-packages ../venv +. ../venv/bin/activate +pip install -U setuptools +pip install -U pip +# Now, use our local PyPI +pip install \ + --extra-index-url http://localhost:$PORT \ + letsencrypt $SUBPKGS +# stop local PyPI +kill $! +cd ~- + +# freeze before installing anything else, so that we know end-user KGS +# make sure "twine upload" doesn't catch "kgs" +if [ -d ../kgs ] ; then + echo Deleting old kgs... + rm -rf ../kgs +fi +mkdir ../kgs +kgs="../kgs/$version" +pip freeze | tee $kgs +pip install nose +for module in letsencrypt $subpkgs_modules ; do + echo testing $module + nosetests $module +done +deactivate + +# ensure we have the latest built version of leauto +letsencrypt-auto-source/build.py + +# and that it's signed correctly +while ! openssl dgst -sha256 -verify $RELEASE_OPENSSL_PUBKEY -signature \ + letsencrypt-auto-source/letsencrypt-auto.sig \ + letsencrypt-auto-source/letsencrypt-auto ; do + read -p "Please correctly sign letsencrypt-auto with offline-signrequest.sh" +done + +git diff --cached +git commit --gpg-sign="$RELEASE_GPG_KEY" -m "Release $version" +git tag --local-user "$RELEASE_GPG_KEY" \ + --sign --message "Release $version" "$tag" + +cd .. +echo Now in $PWD +name=${root_without_le%.*} +ext="${root_without_le##*.}" +rev="$(git rev-parse --short HEAD)" +echo tar cJvf $name.$rev.tar.xz $name.$rev +echo gpg -U $RELEASE_GPG_KEY --detach-sign --armor $name.$rev.tar.xz +cd ~- + +echo "New root: $root" +echo "KGS is at $root/kgs" +echo "Test commands (in the letstest repo):" +echo 'python multitester.py targets.yaml $AWS_KEY $USERNAME scripts/test_leauto_upgrades.sh --alt_pip $YOUR_PIP_REPO --branch public-beta' +echo 'python multitester.py targets.yaml $AWK_KEY $USERNAME scripts/test_letsencrypt_auto_certonly_standalone.sh --branch candidate-0.1.1' +echo 'python multitester.py --saveinstances targets.yaml $AWS_KEY $USERNAME scripts/test_apache2.sh' +echo "In order to upload packages run the following command:" +echo twine upload "$root/dist.$version/*/*" + +if [ "$RELEASE_BRANCH" = candidate-"$version" ] ; then + SetVersion "$nextversion".dev0 + git diff + git commit -m "Bump version to $nextversion" +fi diff --git a/tox.ini b/tox.ini index d1fafe20f..c54c9934c 100644 --- a/tox.ini +++ b/tox.ini @@ -6,18 +6,18 @@ # acme and letsencrypt are not yet on pypi, so when Tox invokes # "install *.zip", it will not find deps skipsdist = true -envlist = py26,py27,py33,py34,py35,cover,lint +envlist = py{26,27,33,34,35},py{26,27}-oldest,cover,lint # nosetest -v => more verbose output, allows to detect busy waiting # loops, especially on Travis [testenv] -# packages installed separately to ensure that dowstream deps problems +# packages installed separately to ensure that downstream deps problems # are detected, c.f. #1002 commands = pip install -e acme[testing] nosetests -v acme - pip install -r py26reqs.txt -e .[testing] + pip install -e .[testing] nosetests -v letsencrypt pip install -e letsencrypt-apache nosetests -v letsencrypt_apache @@ -31,6 +31,13 @@ setenv = PYTHONHASHSEED = 0 # https://testrun.org/tox/latest/example/basic.html#special-handling-of-pythonhas +deps = + py{26,27}-oldest: cryptography==0.8 + py{26,27}-oldest: configargparse==0.10.0 + py{26,27}-oldest: psutil==2.1.0 + py{26,27}-oldest: PyOpenSSL==0.13 + py{26,27}-oldest: python2-pythondialog==3.2.2rc1 + [testenv:py33] commands = pip install -e acme[testing] @@ -62,8 +69,26 @@ commands = pip install -e acme -e .[dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt ./pep8.travis.sh pylint --rcfile=.pylintrc letsencrypt - pylint --rcfile=.pylintrc acme/acme + pylint --rcfile=acme/.pylintrc acme/acme pylint --rcfile=.pylintrc letsencrypt-apache/letsencrypt_apache pylint --rcfile=.pylintrc letsencrypt-nginx/letsencrypt_nginx pylint --rcfile=.pylintrc letsencrypt-compatibility-test/letsencrypt_compatibility_test pylint --rcfile=.pylintrc letshelp-letsencrypt/letshelp_letsencrypt + +[testenv:apacheconftest] +#basepython = python2.7 +setenv = + LETSENCRYPT=/home/travis/build/letsencrypt/letsencrypt/.tox/apacheconftest/bin/letsencrypt +commands = + pip install -e acme -e .[dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt + sudo ./letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/apache-conf-test --debian-modules + +[testenv:le_auto] +# At the moment, this tests under Python 2.7 only, as only that version is +# readily available on the Trusty Docker image. +commands = + docker build -t lea letsencrypt-auto-source + docker run --rm -t -i lea +whitelist_externals = + docker +passenv = DOCKER_* \ No newline at end of file