diff options
32 files changed, 825 insertions, 211 deletions
diff --git a/README.rst b/README.rst index d1f5d3428..57908e90f 100644 --- a/README.rst +++ b/README.rst @@ -163,5 +163,5 @@ Current Features * Free and Open Source Software, made with Python. -.. _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/acme/acme/client.py b/acme/acme/client.py index 08d476783..c3e28ef47 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -20,7 +20,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() @@ -338,7 +340,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes `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 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/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index b975da444..411d7bd92 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -2,7 +2,7 @@ # Tested with: # - Fedora 22, 23 (x64) -# - Centos 7 (x64: onD igitalOcean droplet) +# - Centos 7 (x64: on DigitalOcean droplet) if type dnf 2>/dev/null then diff --git a/docs/using.rst b/docs/using.rst index 6e15d2cf2..1423d6eba 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -31,16 +31,21 @@ Firstly, please `install Git`_ and run the following commands: .. _`install Git`: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git +.. 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: .. code-block:: shell ./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 @@ -58,8 +63,8 @@ or for full help, type: ``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 -experimental, Arch linux and FreeBSD now have native packages, so on those +client beta releases on systems that don't have a packaged version. Debian, +Arch linux and FreeBSD 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 @@ -173,10 +178,10 @@ 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`. 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 +`--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 @@ -224,21 +229,23 @@ 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 <https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#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 <https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslcertificatechainfile>`_. ``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 + <https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslcertificatefile>`_, + and what nginx needs for `ssl_certificate <http://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate>`_. @@ -287,7 +294,7 @@ get support on our `forums <https://community.letsencrypt.org>`_. If you find a bug in the software, please do report it in our `issue tracker <https://github.com/letsencrypt/letsencrypt/issues>`_. 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 @@ -350,20 +357,20 @@ Operating System Packages sudo pacman -S letsencrypt letsencrypt-apache -**Debian Experimental** +**Debian** -If you run Debian unstable, you can install experimental letsencrypt packages. -Add the line ``deb http://ftp.us.debian.org/debian/ experimental main`` (or -the equivalent for your country) to ``/etc/apt/sources.list``, then run +If you run Debian Stretch or Debian Sid, you can install letsencrypt packages. .. code-block:: shell sudo apt-get update - sudo apt-get -t experimental install letsencrypt python-letsencrypt-apache + sudo apt-get install letsencrypt python-letsencrypt-apache If you don't want to use the Apache plugin, you can ommit 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 diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug b/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug index 30d8ca501..d665ea7a7 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]|[^\\ '"\t\r\n][^ '"\t\r\n]*[^\\ '"\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 *) @@ -91,7 +108,7 @@ let section (body:lens) = indent . dels "</" in let kword = key word in let dword = del word "a" in - [ indent . dels "<" . square kword inner dword . del ">" ">" . eol ] + [ indent . dels "<" . square kword inner dword . del />[ \t\n\r]*/ ">\n" ] let rec content = section (content|directive) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 98b0b8820..0b40a7e38 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -93,7 +93,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): add("enmod", default=constants.CLI_DEFAULTS["enmod"], help="Path to the Apache 'a2enmod' binary.") add("dismod", default=constants.CLI_DEFAULTS["dismod"], - help="Path to the Apache 'a2enmod' binary.") + help="Path to the Apache 'a2dismod' binary.") add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"], help="SSL vhost configuration extension.") add("server-root", default=constants.CLI_DEFAULTS["server_root"], @@ -120,7 +120,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.version = version self.vhosts = None self._enhance_func = {"redirect": self._enable_redirect, - "ensure-http-header": self._set_http_header} + "ensure-http-header": self._set_http_header} @property def mod_ssl_conf(self): @@ -545,21 +545,43 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Check for Listen <port> # 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"] + 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"] - - 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"]) + # 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)) def make_addrs_sni_ready(self, addrs): """Checks to see if the server is ready for SNI challenges. diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index fcccfaae2..991704144 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -391,6 +391,39 @@ 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_make_vhost_ssl(self): ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) 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 @@ <VirtualHost 1.1.1.1> ServerName invalid.net - -</virtualHost> diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 61cde7a3f..29519d430 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -207,76 +207,131 @@ 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 renewal: - return ident_names_cert if ident_names_cert is not None else subset_names_cert - - return None + 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 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) + +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") + 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"): + 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): @@ -316,10 +371,21 @@ def _suggest_donate(): 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) - - if lineage is not None: + # 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). + + 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 + 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) @@ -333,7 +399,7 @@ def _auth_from_domains(le_client, config, domains): # 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: @@ -343,6 +409,27 @@ def _auth_from_domains(le_client, config, domains): return lineage +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 + + # 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 set_configurator(previously, now): """ @@ -487,7 +574,6 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo def obtain_cert(args, config, plugins): """Authenticate & obtain cert, but do not install it.""" - 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? @@ -697,6 +783,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 @@ -871,27 +966,38 @@ def prepare_and_parse_args(plugins, args): 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__), help="show program's version number and exit") helpful.add( "automation", "--renew-by-default", action="store_true", help="Select renewal by default when domains are a superset of a " - "previously attained cert") + "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_group( "testing", description="The following flags are meant for " @@ -912,7 +1018,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( @@ -1039,6 +1148,10 @@ 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): diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 69778f5f0..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,40 +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") - - # Unicode - try: - for domain in domains: - domain.encode('ascii') - except UnicodeDecodeError: - raise errors.ConfigurationError( - "Internationalized domain names 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}(?<!-)\\.)+[A-Za-z]{2,63}$") - if any(True for d in domains if not fqdn.match(d)): - raise errors.ConfigurationError("Requested domain is not a FQDN") + for domain in config.namespace.domains: + le_util.check_domain_sanity(domain) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 40155abd7..a1dccd1ea 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -30,8 +30,9 @@ CLI_DEFAULTS = dict( auth_chain_path="./chain.pem", strict_permissions=False, ) -"""Defaults for CLI flags and `.IConfig` attributes.""" +STAGING_URI = "https://acme-staging.api.letsencrypt.org/directory" +"""Defaults for CLI flags and `.IConfig` attributes.""" RENEWER_DEFAULTS = dict( renewer_enabled="yes", diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 038ad6fdc..102dbe3a0 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -4,6 +4,7 @@ import os import zope.component +from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util from letsencrypt.display import util as display_util @@ -186,7 +187,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( @@ -207,6 +209,22 @@ def choose_names(installer): else: 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. @@ -245,7 +263,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, diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 7869fc9a5..64295a80f 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -10,6 +10,8 @@ import stat import subprocess import sys +import configargparse + from letsencrypt import errors @@ -278,5 +280,41 @@ def add_deprecated_argument(add_argument, argument_name, nargs): 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}(?<!-)\\.)+[A-Za-z]{2,63}$") + if not fqdn.match(domain): + raise errors.ConfigurationError("Requested domain is not a FQDN") diff --git a/letsencrypt/plugins/standalone.py b/letsencrypt/plugins/standalone.py index 8b8612fd1..4319e51f9 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): @@ -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 0b81d45b5..0679bc349 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -2,7 +2,6 @@ import errno import logging import os -import stat import zope.interface @@ -59,24 +58,38 @@ 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]) - # Set permissions as parent directory (GH #1389) - # We don't use the parameters in makedirs because it - # may not always work + # 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 - stat_path = os.stat(path) - filemode = stat.S_IMODE(stat_path.st_mode) - os.chmod(self.full_roots[name], filemode) - # Set owner and group, too - os.chown(self.full_roots[name], stat_path.st_uid, - stat_path.st_gid) + 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" @@ -87,26 +100,26 @@ to serve all files under specified web root ({0}).""" path = self.full_roots[achall.domain] except IndexError: raise errors.PluginError("Missing --webroot-path for domain: {1}" - .format(achall.domain)) + .format(achall.domain)) if not os.path.exists(path): raise errors.PluginError("Mysteriously missing path {0} for domain: {1}" - .format(path, achall.domain)) + .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()) - - # Set permissions as parent directory (GH #1389) - parent_path = self.full_roots[achall.domain] - stat_parent_path = os.stat(parent_path) - filemode = stat.S_IMODE(stat_parent_path.st_mode) - # Remove execution bit (not needed for this file) - os.chmod(path, filemode & ~stat.S_IEXEC) - os.chown(path, stat_parent_path.st_uid, stat_parent_path.st_gid) + + # 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 diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index e7f96b50d..9f5b6bba8 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -1,9 +1,10 @@ """Tests for letsencrypt.plugins.webroot.""" +import errno import os import shutil +import stat import tempfile import unittest -import stat import mock @@ -35,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) @@ -48,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") @@ -70,17 +70,33 @@ class AuthenticatorTest(unittest.TestCase): 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]) - parent_permissions = (stat.S_IMODE(os.stat(self.path).st_mode) & - ~stat.S_IEXEC) + path_permissions = stat.S_IMODE(os.stat(self.validation_path).st_mode) + self.assertEqual(path_permissions, 0o644) - actual_permissions = stat.S_IMODE(os.stat(self.validation_path).st_mode) + # 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) - self.assertEqual(parent_permissions, actual_permissions) parent_gid = os.stat(self.path).st_gid parent_uid = os.stat(self.path).st_uid @@ -88,6 +104,7 @@ class AuthenticatorTest(unittest.TestCase): self.assertEqual(os.stat(self.validation_path).st_uid, parent_uid) 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/storage.py b/letsencrypt/storage.py index 7e2802b14..3b2b548b0 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -471,7 +471,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 @@ -480,12 +480,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") @@ -529,7 +533,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 @@ -540,12 +544,16 @@ 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 @@ -559,8 +567,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "cert", self.latest_common_version())) now = pytz.UTC.fromutc(datetime.datetime.utcnow()) if expiry < add_time_interval(now, interval): - logger.debug("Should renew, certificate " - "has been expired since %s.", + 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 diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 462d37a87..ccf16f5b5 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 @@ -343,6 +344,19 @@ 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', '-w', '/var/www/example', @@ -389,7 +403,7 @@ 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']) @@ -399,13 +413,13 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods @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, _suggest): - cert_path = '/etc/letsencrypt/live/foo.bar/cert.pem' + 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_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_key, 'csr') @@ -537,6 +551,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/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index b0b905c33..30183b955 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 @@ -385,6 +386,17 @@ 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) + class SuccessInstallationTest(unittest.TestCase): # pylint: disable=too-few-public-methods 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/tests/apache-conf-files/failing/missing-double-quote-1724.conf b/tests/apache-conf-files/failing/missing-double-quote-1724.conf new file mode 100644 index 000000000..7d97b23d0 --- /dev/null +++ b/tests/apache-conf-files/failing/missing-double-quote-1724.conf @@ -0,0 +1,52 @@ +<VirtualHost *:443> + 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 + + <Directory /> + Options FollowSymLinks + AllowOverride All + </Directory> + <Directory /var/www/example.com/www> + 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 + </Directory> + + ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ + <Directory "/usr/lib/cgi-bin"> + AllowOverride None + Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch + Order allow,deny + Allow from all + </Directory> + + 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/" + <Directory "/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 + </Directory> + +</VirtualHost> diff --git a/tests/apache-conf-files/hackish-apache-test b/tests/apache-conf-files/hackish-apache-test new file mode 100755 index 000000000..c6663551e --- /dev/null +++ b/tests/apache-conf-files/hackish-apache-test @@ -0,0 +1,28 @@ +#!/bin/bash + +# A hackish script to see if the client is behaving as expected +# with each of the "passing" conf files. + +# TODO presently this requires interaction and human judgement to +# assess, but it should be automated +export EA=/etc/apache2/ +TESTDIR="`dirname $0`" +LEROOT="`realpath \"$TESTDIR/../../\"`" +cd $TESTDIR/passing + +function CleanupExit() { + echo control c, exiting tests... + if [ "$f" != "" ] ; then + sudo rm /etc/apache2/sites-{enabled,available}/"$f" + fi + exit 1 +} + +trap CleanupExit INT +for f in *.conf ; do + echo testing "$f" + sudo cp "$f" "$EA"/sites-available/ + sudo ln -s "$EA/sites-available/$f" "$EA/sites-enabled/$f" + sudo "$LEROOT"/venv/bin/letsencrypt --apache certonly -t + sudo rm /etc/apache2/sites-{enabled,available}/"$f" +done diff --git a/tests/apache-conf-files/passing/1626-1531.conf b/tests/apache-conf-files/passing/1626-1531.conf new file mode 100644 index 000000000..1622a57df --- /dev/null +++ b/tests/apache-conf-files/passing/1626-1531.conf @@ -0,0 +1,37 @@ +<VirtualHost *:80> + 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 + + <Directory /home/denver/www/c-beta.ossguy.com/static> + Options -Indexes +FollowSymLinks -MultiViews + Require all granted + AllowOverride None + </Directory> + + <Directory /home/denver/www/c-beta.ossguy.com/static/source> + Options +Indexes +FollowSymLinks -MultiViews + Require all granted + AllowOverride None + </Directory> + + # Custom log file locations + LogLevel warn + ErrorLog /tmp/error.log + CustomLog /tmp/access.log combined +</VirtualHost> diff --git a/tests/apache-conf-files/passing/README.modules b/tests/apache-conf-files/passing/README.modules index 9c5853061..7edbd3e84 100644 --- a/tests/apache-conf-files/passing/README.modules +++ b/tests/apache-conf-files/passing/README.modules @@ -3,3 +3,5 @@ Modules required to parse these conf files: ssl rewrite macro +wsgi +deflate diff --git a/tests/apache-conf-files/passing/drupal-errordocument-arg-1724.conf b/tests/apache-conf-files/passing/drupal-errordocument-arg-1724.conf new file mode 100644 index 000000000..4733ffa4a --- /dev/null +++ b/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. +<FilesMatch "\.(engine|inc|info|install|make|module|profile|test|po|sh|.*sql|theme|tpl(\.php)?|xtmpl|svn-base)$|^(code-style\.pl|Entries.*|Repository|Root|Tag|Template|all-wcprops|entries|format)$"> + Order allow,deny +</FilesMatch> + +# 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. +<Files favicon.ico> + # There is no end quote below, for compatibility with Apache 1.3. + ErrorDocument 404 "The requested file favicon.ico was not found. +</Files> + +# 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. +<IfModule mod_php4.c> + 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 +</IfModule> + +# PHP 4, Apache 2. +<IfModule sapi_apache2.c> + 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 +</IfModule> + +# PHP 5, Apache 1 and 2. +<IfModule mod_php5.c> + 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 +</IfModule> + +# Requires mod_expires to be enabled. +<IfModule mod_expires.c> + # Enable expirations. + ExpiresActive On + + # Cache all files for 2 weeks after access (A). + ExpiresDefault A1209600 + + <FilesMatch \.php$> + # 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 + </FilesMatch> +</IfModule> + +# Various rewrite rules. +<IfModule mod_rewrite.c> + 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] +</IfModule> + +# $Id$ diff --git a/tests/apache-conf-files/failing/drupal-htaccess-1531.conf b/tests/apache-conf-files/passing/drupal-htaccess-1531.conf index a1aab7a39..a1aab7a39 100644 --- a/tests/apache-conf-files/failing/drupal-htaccess-1531.conf +++ b/tests/apache-conf-files/passing/drupal-htaccess-1531.conf diff --git a/tests/apache-conf-files/passing/example-1755.conf b/tests/apache-conf-files/passing/example-1755.conf new file mode 100644 index 000000000..260029576 --- /dev/null +++ b/tests/apache-conf-files/passing/example-1755.conf @@ -0,0 +1,36 @@ +<VirtualHost *:80> + # 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 +</VirtualHost> + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/tests/apache-conf-files/passing/missing-quote-1724.conf b/tests/apache-conf-files/passing/missing-quote-1724.conf new file mode 100644 index 000000000..7d97b23d0 --- /dev/null +++ b/tests/apache-conf-files/passing/missing-quote-1724.conf @@ -0,0 +1,52 @@ +<VirtualHost *:443> + 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 + + <Directory /> + Options FollowSymLinks + AllowOverride All + </Directory> + <Directory /var/www/example.com/www> + 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 + </Directory> + + ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ + <Directory "/usr/lib/cgi-bin"> + AllowOverride None + Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch + Order allow,deny + Allow from all + </Directory> + + 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/" + <Directory "/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 + </Directory> + +</VirtualHost> diff --git a/tests/apache-conf-files/passing/sslrequire-wordlist-1827.htaccess b/tests/apache-conf-files/passing/sslrequire-wordlist-1827.htaccess new file mode 100644 index 000000000..1c06d5497 --- /dev/null +++ b/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/tests/apache-conf-files/failing/two-blocks-one-line-1693.conf b/tests/apache-conf-files/passing/two-blocks-one-line-1693.conf index 5d3cef423..5d3cef423 100644 --- a/tests/apache-conf-files/failing/two-blocks-one-line-1693.conf +++ b/tests/apache-conf-files/passing/two-blocks-one-line-1693.conf |