dehydrated-0.7.1/0000755000175000017500000000000014327753726013516 5ustar lukas2511lukas2511dehydrated-0.7.1/docs/0000755000175000017500000000000014327753726014446 5ustar lukas2511lukas2511dehydrated-0.7.1/docs/acme-v1.md0000644000175000017500000000325514327753726016226 0ustar lukas2511lukas2511## (Future) Removal of API version 1 The ACME API version 1 was never really standardized and was only supported by Let's Encrypt. Even though the protocol specification was public, it wasn't really friendly to be integrated into existing CA systems so initial adoption was basically non-existant. ACME version 2 is being designed to overcome these issues by becoming an official IETF standard and supporting a more traditional approach of account and order management in the backend, making it friendlier to integrate into existing systems centered around those. It has since become a semi-stable IETF standard draft which only ever got two breaking changes, Content-Type enforcement and `POST-as-GET`, the latter being announced in October 2018 to be enforced by November 2019. See https://datatracker.ietf.org/wg/acme/documents/ for a better insight into the draft and its changes. Next to backend changes that many users won't really care about ACME v2 has all of the features ACME v1 had, but also some additional new features like e.g. support for [wildcard certificates](domains_txt.md#wildcards). Since ACME v2 is basically to be considered stable and ACME v1 has no real benefits over v2, there doesn't seem to be much of a reason to keep the old protocol around, but since there actually are a few Certificate Authorities and resellers that implemented the v1 protocol and didn't yet make the change to v2, so dehydrated still supports the old protocol for now. Please keep in mind that support for the old ACME protocol version 1 might get removed at any point of bigger inconvenience, e.g. on code changes that would require a lot of work or ugly workarounds to keep both versions supported. dehydrated-0.7.1/docs/tls-alpn.md0000644000175000017500000001043014327753726016520 0ustar lukas2511lukas2511# TLS-ALPN-01 With `tls-alpn-01`-type verification Let's Encrypt (or the ACME-protocol in general) is checking if you are in control of a domain by accessing your webserver using a custom ALPN and expecting a specially crafted TLS certificate containing a verification token. It will do that for any (sub-)domain you want to sign a certificate for. Dehydrated generates the required verification certificates, but the delivery is out of its scope. ### Example lighttpd config lighttpd can be configured to recognize ALPN `acme-tls/1` and to respond to such requests using the specially crafted TLS certificates generated by dehydrated. Configure lighttpd and dehydrated to use the same path for these certificates. (Be sure to allow read access to the user account under which the lighttpd server is running.) `mkdir -p /etc/dehydrated/alpn-certs` lighttpd.conf: ``` ssl.acme-tls-1 = "/etc/dehydrated/alpn-certs" ``` When renewing certificates, specify `-t tls-alpn-01` and `--alpn /etc/dehydrated/alpn-certs` to dehydrated, e.g. ``` dehydrated -t tls-alpn-01 --alpn /etc/dehydrated/alpn-certs -c --out /etc/lighttpd/certs -d www.example.com # gracefully reload lighttpd to use the new certificates by sending lighttpd pid SIGUSR1 systemctl reload lighttpd ``` ### Example nginx config On an nginx tcp load-balancer you can use the `ssl_preread` module to map a different port for acme-tls requests than for e.g. HTTP/2 or HTTP/1.1 requests. Your config should look something like this: ```nginx stream { map $ssl_preread_alpn_protocols $tls_port { ~\bacme-tls/1\b 10443; default 443; } server { listen 443; listen [::]:443; proxy_pass 10.13.37.42:$tls_port; ssl_preread on; } } ``` That way https requests are forwarded to port 443 on the backend server, and acme-tls/1 requests are forwarded to port 10443. In the future nginx might support internal routing based on custom ALPNs, but for now you'll have to use a custom responder for the alpn verification certificates (see below). ### Example responder I hacked together a simple responder in Python, it might not be the best, but it works for me: ```python #!/usr/bin/env python3 import ssl import socketserver import threading import re import os ALPNDIR="/etc/dehydrated/alpn-certs" PROXY_PROTOCOL=False FALLBACK_CERTIFICATE="/etc/ssl/certs/ssl-cert-snakeoil.pem" FALLBACK_KEY="/etc/ssl/private/ssl-cert-snakeoil.key" class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): pass class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): def create_context(self, certfile, keyfile, first=False): ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.set_ciphers('ECDHE+AESGCM') ssl_context.set_alpn_protocols(["acme-tls/1"]) ssl_context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 if first: ssl_context.set_servername_callback(self.load_certificate) ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) return ssl_context def load_certificate(self, sslsocket, sni_name, sslcontext): print("Got request for %s" % sni_name) if not re.match(r'^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][-_.a-zA-Z0-9]{0,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,13}|[a-zA-Z0-9-]{2,30}.[a-zA-Z]{2,3})$', sni_name): return certfile = os.path.join(ALPNDIR, "%s.crt.pem" % sni_name) keyfile = os.path.join(ALPNDIR, "%s.key.pem" % sni_name) if not os.path.exists(certfile) or not os.path.exists(keyfile): return sslsocket.context = self.create_context(certfile, keyfile) def handle(self): if PROXY_PROTOCOL: buf = b"" while b"\r\n" not in buf: buf += self.request.recv(1) ssl_context = self.create_context(FALLBACK_CERTIFICATE, FALLBACK_KEY, True) newsock = ssl_context.wrap_socket(self.request, server_side=True) if __name__ == "__main__": HOST, PORT = "0.0.0.0", 10443 server = ThreadedTCPServer((HOST, PORT), ThreadedTCPRequestHandler, bind_and_activate=False) server.allow_reuse_address = True try: server.server_bind() server.server_activate() server.serve_forever() except: server.shutdown() ``` dehydrated-0.7.1/docs/dns-verification.md0000644000175000017500000000354714327753726020245 0ustar lukas2511lukas2511### dns-01 challenge This script also supports the new `dns-01`-type verification. This type of verification requires you to be able to create a specific `TXT` DNS record for each hostname included in the certificate. You need a hook script that deploys the challenge to your DNS server. The hook script (indicated in the config file or the `--hook/-k` command line argument) gets four arguments: $1 an operation name (`clean_challenge`, `deploy_challenge`, `deploy_cert`, `invalid_challenge` or `request_failure`) and some operands for that. For `deploy_challenge` $2 is the domain name for which the certificate is required, $3 is a "challenge token" (which is not needed for dns-01), and $4 is a token which needs to be inserted in a TXT record for the domain. Typically, you will need to split the subdomain name in two, the subdomain name and the domain name separately. For example, for "my.example.com", you'll need "my" and "example.com" separately. You then have to prefix "_acme-challenge." before the subdomain name, as in "_acme-challenge.my" and set a TXT record for that on the domain (e.g. "example.com") which has the value supplied in $4 ``` _acme-challenge IN TXT $4 _acme-challenge.my IN TXT $4 ``` That could be done manually (as most providers don't have a DNS API), by having your hook script echo $1, $2 and $4 and then wait (`read -s -r -e < /dev/tty`) - give it a little time to get into their DNS system. Usually providers give you a boxes to put "_acme-challenge.my" and the token value in, and a dropdown to choose the record type, TXT. Or when you do have a DNS API, pass the details accordingly to achieve the same thing. You can delete the TXT record when called with operation `clean_challenge`, when $2 is also the domain name. Here are some examples: [Examples for DNS-01 hooks](https://github.com/dehydrated-io/dehydrated/wiki) dehydrated-0.7.1/docs/domains_txt.md0000644000175000017500000000670514327753726017331 0ustar lukas2511lukas2511## domains.txt dehydrated uses the file `domains.txt` as configuration for which certificates should be requested. The file should have the following format: ```text example.org example.com www.example.com example.net www.example.net wiki.example.net ``` This states that there are the following certificates: * `example.org` without any *alternative names* * `example.com` with an *alternative name* of `www.example.com` * `example.net` with the *alternative names*: `www.example.net` and `wiki.example.net` ### Aliases You can define an *alias* for your certificate which will (instead of the primary domain) be used as the directory name under your `CERTDIR` and for a per-certificate lookup. This is done using the `>` character. This allows multiple certificates with identical sets of domains but different configuration to exist. Here is an example of using an *alias* called `certalias` for creating the certificate for `example.net` with *alternative names* `www.example.net` and `wiki.example.net`. The certificate will be stored in the directory `certalias` under your `CERTDIR`. ```text example.net www.example.net wiki.example.net > certalias ``` This allows to set per certificates options. The options you can change are explained in [Per Certificate Config](per-certificate-config.md). If you want to create different certificate types for the same domain you can use: ```text *.service.example.org service.example.org > star_service_example_org_rsa *.service.example.org service.example.org > star_service_example_org_ecdsa ``` Then add a config file `certs/star_service_example_org_rsa/config` with the value ``` KEY_ALGO="rsa" ``` or respectively ``` KEY_ALGO="ecdsa" ``` ### Wildcards Support for wildcards was added by the ACME v2 protocol. Certificates with a wildcard domain as the first (or only) name require an *alias* to be set. *Aliases* can't start with `*.`. For example to create the wildcard for `*.service.example.com` your `domains.txt` could use the *alias* method like this: ```text *.service.example.com > star_service_example_com ``` This creates a wildcard certificate for only `*.service.example.com` and will store it in the directory `star_service_example_com` under your `CERTDIR`. As a note this certificate will **NOT** be valid for `service.example.com` but only for `*.service.example.com`. So it would, for example, be valid for `foo.service.example.com`. Another way to create it is using *alternative names*. For example your `domains.txt` could do this: ```text service.example.com *.service.example.com eggs.example.com *.ham.example.com ``` This creates two certificates one for `service.example.com` with an *alternative name* of `*.service.example.com` and a second certificate for `eggs.example.com` with an *alternative name* of `*.ham.example.com`. **Note:** The first certificate is valid for both `service.example.com` and for `*.service.example.com` which can be a useful way to create wildcard certificates. ### Drop-in directory If a directory named `domains.txt.d` exists in the same location as `domains.txt`, the contents of `*.txt` files in that directory are appended to the list of domains, in alphabetical order of the filenames. This is useful for automation, as it doesn't require editing an existing file to add new domains. Warning: Behaviour of this might change as the naming between `domains.txt.d` and the `DOMAINS_D` config variable (which is used for per-certificate configuration) is a bit confusing. dehydrated-0.7.1/docs/troubleshooting.md0000644000175000017500000000673514327753726020232 0ustar lukas2511lukas2511# Troubleshooting Generally if the following information doesn't provide a solution to your problem please take a look at existing issues (search for keywords) before creating a new one. ## "No registration exists matching provided key" You probably changed from staging-CA to production-CA (or the other way). Currently dehydrated doesn't detect a missing registration on the selected CA, the current workaround is to move `private_key.pem` (and, if you care, `private_key.json`) out of the way so the scripts generates and registers a new one. This will hopefully be fixed in the future. ## "Error creating new cert :: Too many certificates already issued for: [...]" This is not an issue with dehydrated but an API limit with boulder (the ACME server). At the time of writing this you can only create 5 certificates per domain in a sliding window of 7 days. ## "Certificate request has 123 names, maximum is 100." This also is an API limit from boulder, you are requesting to sign a certificate with way too many domains. ## Invalid challenges There are a few factors that could result in invalid challenges. If you are using HTTP validation make sure that the path you have configured with WELLKNOWN is readable under your domain. To test this create a file (e.g. `test.txt`) in that directory and try opening it with your browser: `http://example.org/.well-known/acme-challenge/test.txt`. Note that if you have an IPv6 address, the challenge connection will be on IPv6. Be sure that you test HTTP connections on both IPv4 and IPv6. Checking the test file in your browser is often not sufficient because the browser just fails over to IPv4. If you get any error you'll have to fix your web server configuration. ## DNS invalid challenge since dehydrated 0.6.0 / Why are DNS challenges deployed first and verified later? Since Let's Encrypt (and in general the ACMEv2 protocol) now supports wildcard domains there is a situation where DNS caching can become a problem. If somebody wants to validate a certificate with `example.org` and `*.example.org` there are two tokens that have to be deployed on `_acme-challenge.example.org`. If dehydrated would deploy and verify each token on its own the CA would cache the first token on `_acme-challenge.example.org` and the next challenge would simply fail. Let's Encrypt uses your DNS TTL with a max limit of 5 minutes, but this doesn't seem to be part of the ACME protocol, just some LE specific configuration, so with other CAs and certain DNS providers who don't allow low TTLs this could potentially take hours. Since dehydrated now deploys all challenges first that no longer is a problem. The CA will query and cache both challenges, and both authorizations can be validated. Some hook-scripts were written in a way that erases the old TXT record rather than adding a new entry, those should be (and many of them already have been) fixed. There are certain DNS providers which really only allow one TXT record on a domain. This is really odd and you should probably contact your DNS provider and ask them to fix this. If for whatever reason you can't switch DNS providers and your DNS provider only supports one TXT record and doesn't want to fix that you could try splitting your certificate into multiple certificates and add a sleep in the `deploy_cert` hook. If you can't do that or really don't want to please leave a comment on https://github.com/lukas2511/dehydrated/issues/554, if many people are having this unfixable problem I might try to implement a workaround. dehydrated-0.7.1/docs/per-certificate-config.md0000644000175000017500000000135014327753726021300 0ustar lukas2511lukas2511# Config on per-certificate base dehydrated allows a few configuration variables to be set on a per-certificate base. To use this feature create a `config` file in the certificates output directory (e.g. `certs/example.org/config`). Currently supported options: - PRIVATE_KEY_RENEW - PRIVATE_KEY_ROLLOVER - KEY_ALGO - KEYSIZE - OCSP_MUST_STAPLE - OCSP_FETCH - OCSP_DAYS - CHALLENGETYPE - HOOK - HOOK_CHAIN - WELLKNOWN - OPENSSL_CNF - RENEW_DAYS - PREFERRED_CHAIN ## DOMAINS_D If `DOMAINS_D` is set, dehydrated will use it for your per-certificate configurations. Instead of `certs/example.org/config` it will look for a configuration under `DOMAINS_D/example.org`. If an alias is set, it will be used instead of the primary domain name. dehydrated-0.7.1/docs/staging.md0000644000175000017500000000104214327753726016421 0ustar lukas2511lukas2511# Staging Let’s Encrypt has stringent rate limits in place. If you start testing using the production endpoint (which is the default), you will quickly hit these limits and find yourself locked out. To avoid this, please set the CA property to the Let’s Encrypt staging server URL in your config file: ```bash CA="https://acme-staging-v02.api.letsencrypt.org/directory" ``` Alternatively you can define the CA using the CLI argument `--ca letsencrypt-test` (`letsencrypt-test` is an integrated preset-CA corresponding to the URL above). dehydrated-0.7.1/docs/man/0000755000175000017500000000000014327753726015221 5ustar lukas2511lukas2511dehydrated-0.7.1/docs/man/dehydrated.10000644000175000017500000001303414327753726017421 0ustar lukas2511lukas2511.TH DEHYDRATED 1 2018-01-13 "Dehydrated ACME Client" .SH NAME dehydrated \- ACME client implemented as a shell-script .SH SYNOPSIS .B dehydrated [\fBcommand\fR [\fBargument\fR]] [\fBargument\fR [\fBargument\fR]] .IR ... .SH DESCRIPTION A client for ACME-based Certificate Authorities, such as LetsEncrypt. It can be used to request and obtain TLS certificates from an ACME-based certificate authority. Before any certificates can be requested, Dehydrated needs to acquire an account with the Certificate Authorities. Optionally, an email address can be provided. It will be used to e.g. notify about expiring certificates. You will usually need to accept the Terms of Service of the CA. Dehydrated will notify if no account is configured. Run with \fB--register --accept-terms\fR to create a new account. Next, all domain names must be provided in domains.txt. The format is line based: If the file contains two lines "example.com" and "example.net", Dehydrated will request two certificate, one for "example.com" and the other for "example.net". A single line while "example.com example.net" will request a single certificate valid for both "example.net" and "example.com" through the \fISubject Alternative Name\fR (SAN) field. For the next step, one way of verifying domain name ownership needs to be configured. Dehydrated implements \fIhttp-01\fR and \fIdns-01\fR verification. The \fIhttp-01\fR verification provides proof of ownership by providing a challenge token. In order to do that, the directory referenced in the \fIWELLKNOWN\fR config variable needs to be exposed at \fIhttp://{domain}/.well-known/acme-challenge/\fR, where {domain} is every domain name specified in \fIdomains.txt\fR. Dehydrated does not provide its own challenge responder, but relies on an existing web server to provide the challenge response. See \fIwellknown.md\fR for configuration examples of popular web servers. The \fIdns-01\fR verification works by providing a challenge token through DNS. This is especially interesting for hosts that cannot be exposed to the public Internet. Because adding records to DNS zones is oftentimes highly specific to the software or the DNS provider at hand, there are many third party hooks available for dehydrated. See \fIdns-verification.md\fR for hooks for popular DNS servers and DNS hosters. Finally, the certificates need to be requested and updated on a regular basis. This can happen through a cron job or a timer. Initially, you may enforce this by invoking \fIdehydrated -c\fR manually. After a successful run, certificates are stored in \fI/etc/dehydrated/certs/{domain}\fR, where {domain} is the domain name in the first column of \fIdomains.txt\fR. .SH OPTIONS .BR Commands .TP .BR \-\-version ", " \-v Print version information .TP .BR \-\-register Register account key .TP .BR \-\-account Update account contact information .TP .BR \-\-cron ", " \-c Sign/renew non\-existent/changed/expiring certificates. .TP .BR \-\-signcsr ", " \-s " " \fIpath/to/csr.pem\fR Sign a given CSR, output CRT on stdout (advanced usage) .TP .BR \-\-revoke ", " \-r " " \fIpath/to/cert.pem\fR Revoke specified certificate .TP .BR \-\-cleanup ", " \-gc Move unused certificate files to archive directory .TP .BR \-\-help ", " \-h Show help text .TP .BR \-\-env ", " \-e Output configuration variables for use in other scripts .PP .BR Parameters .TP .BR \-\-accept\-terms Accept CAs terms of service .TP .BR \-\-full\-chain ", " \-fc Print full chain when using \fB\-\-signcsr\fR .TP .BR \-\-ipv4 ", " \-4 Resolve names to IPv4 addresses only .TP .BR \-\-ipv6 ", " \-6 Resolve names to IPv6 addresses only .TP .BR \-\-domain ", " \-d " " \fIdomain.tld\fR Use specified domain name(s) instead of domains.txt entry (one certificate!) .TP .BR \-\-keep\-going ", " \-g Keep going after encountering an error while creating/renewing multiple certificates in cron mode .TP .BR \-\-force ", " \-x Force renew of certificate even if it is longer valid than value in RENEW_DAYS .TP .BR \-\-no\-lock ", " \-n Don't use lockfile (potentially dangerous!) .TP .BR \-\-lock\-suffix " " \fIexample.com\fR Suffix lockfile name with a string (useful for use with \-d) .TP .BR \-\-ocsp Sets option in CSR indicating OCSP stapling to be mandatory .TP .BR \-\-privkey ", " \-p " " \fIpath/to/key.pem\fR Use specified private key instead of account key (useful for revocation) .TP .BR \-\-config ", " \-f " " \fIpath/to/config\fR Use specified config file .TP .BR \-\-hook ", " \-k " " \fIpath/to/hook.sh\fR Use specified script for hooks .TP .BR \-\-out ", " \-o " " \fIcerts/directory\fR Output certificates into the specified directory .TP .BR \-\-challenge ", " \-t " " \fI[http\-01|dns\-01]\fR Which challenge should be used? Currently http\-01 and dns\-01 are supported .TP .BR \-\-algo ", " \-a " " \fI[rsa|prime256v1|secp384r1]\fR Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 .SH DIAGNOSTICS The program exits 0 if everything was fine, 1 if an error occurred. .SH BUGS Please report any bugs that you may encounter at the project web site .UR https://github.com/lukas2511/dehydrated/issues .UE . .SH AUTHOR Dehydrated was written by Lukas Schauer. This man page was contributed by Daniel Molkentin. .SH COPYRIGHT Copyright 2015-2018 by Lukas Schauer and the respective contributors. Provided under the MIT License. See the LICENSE file that accompanies the distribution for licensing information. .SH SEE ALSO Full documentation along with configuration examples are provided in the \fIdocs\fR directory of the distribution, or at .UR https://github.com/lukas2511/dehydrated/tree/master/docs .UE . dehydrated-0.7.1/docs/ecc.md0000644000175000017500000000025614327753726015525 0ustar lukas2511lukas2511### Elliptic Curve Cryptography (ECC) This script also supports certificates with Elliptic Curve public keys! Simply set the `KEY_ALGO` variable in one of the config files. dehydrated-0.7.1/docs/examples/0000755000175000017500000000000014327753726016264 5ustar lukas2511lukas2511dehydrated-0.7.1/docs/examples/config0000644000175000017500000001124214327753726017454 0ustar lukas2511lukas2511######################################################## # This is the main config file for dehydrated # # # # This file is looked for in the following locations: # # $SCRIPTDIR/config (next to this script) # # /usr/local/etc/dehydrated/config # # /etc/dehydrated/config # # ${PWD}/config (in current working-directory) # # # # Default values of this config are in comments # ######################################################## # Which user should dehydrated run as? This will be implicitly enforced when running as root #DEHYDRATED_USER= # Which group should dehydrated run as? This will be implicitly enforced when running as root #DEHYDRATED_GROUP= # Resolve names to addresses of IP version only. (curl) # supported values: 4, 6 # default: #IP_VERSION= # URL to certificate authority or internal preset # Presets: letsencrypt, letsencrypt-test, zerossl, buypass, buypass-test # default: letsencrypt #CA="letsencrypt" # Path to old certificate authority # Set this value to your old CA value when upgrading from ACMEv1 to ACMEv2 under a different endpoint. # If dehydrated detects an account-key for the old CA it will automatically reuse that key # instead of registering a new one. # default: https://acme-v01.api.letsencrypt.org/directory #OLDCA="https://acme-v01.api.letsencrypt.org/directory" # Which challenge should be used? Currently http-01, dns-01 and tls-alpn-01 are supported #CHALLENGETYPE="http-01" # Path to a directory containing additional config files, allowing to override # the defaults found in the main configuration file. Additional config files # in this directory needs to be named with a '.sh' ending. # default: #CONFIG_D= # Directory for per-domain configuration files. # If not set, per-domain configurations are sourced from each certificates output directory. # default: #DOMAINS_D= # Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined) #BASEDIR=$SCRIPTDIR # File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt) #DOMAINS_TXT="${BASEDIR}/domains.txt" # Output directory for generated certificates #CERTDIR="${BASEDIR}/certs" # Output directory for alpn verification certificates #ALPNCERTDIR="${BASEDIR}/alpn-certs" # Directory for account keys and registration information #ACCOUNTDIR="${BASEDIR}/accounts" # Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated) #WELLKNOWN="/var/www/dehydrated" # Default keysize for private keys (default: 4096) #KEYSIZE="4096" # Path to openssl config file (default: - tries to figure out system default) #OPENSSL_CNF= # Path to OpenSSL binary (default: "openssl") #OPENSSL="openssl" # Extra options passed to the curl binary (default: ) #CURL_OPTS= # Program or function called in certain situations # # After generating the challenge-response, or after failed challenge (in this case altname is empty) # Given arguments: clean_challenge|deploy_challenge altname token-filename token-content # # After successfully signing certificate # Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem # # BASEDIR and WELLKNOWN variables are exported and can be used in an external program # default: #HOOK= # Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no) #HOOK_CHAIN="no" # Minimum days before expiration to automatically renew certificate (default: 30) #RENEW_DAYS="30" # Regenerate private keys instead of just signing new certificates on renewal (default: yes) #PRIVATE_KEY_RENEW="yes" # Create an extra private key for rollover (default: no) #PRIVATE_KEY_ROLLOVER="no" # Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 #KEY_ALGO=secp384r1 # E-mail to use during the registration (default: ) #CONTACT_EMAIL= # Lockfile location, to prevent concurrent access (default: $BASEDIR/lock) #LOCKFILE="${BASEDIR}/lock" # Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no) #OCSP_MUST_STAPLE="no" # Fetch OCSP responses (default: no) #OCSP_FETCH="no" # OCSP refresh interval (default: 5 days) #OCSP_DAYS=5 # Issuer chain cache directory (default: $BASEDIR/chains) #CHAINCACHE="${BASEDIR}/chains" # Automatic cleanup (default: no) #AUTO_CLEANUP="no" # ACME API version (default: auto) #API=auto # Preferred issuer chain (default: -> uses default chain) #PREFERRED_CHAIN= dehydrated-0.7.1/docs/examples/hook.sh0000755000175000017500000001710114327753726017563 0ustar lukas2511lukas2511#!/usr/bin/env bash deploy_challenge() { local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called once for every domain that needs to be # validated, including any alternative names you may have listed. # # Parameters: # - DOMAIN # The domain name (CN or subject alternative name) being # validated. # - TOKEN_FILENAME # The name of the file containing the token to be served for HTTP # validation. Should be served by your web server as # /.well-known/acme-challenge/${TOKEN_FILENAME}. # - TOKEN_VALUE # The token value that needs to be served for validation. For DNS # validation, this is what you want to put in the _acme-challenge # TXT record. For HTTP validation it is the value that is expected # be found in the $TOKEN_FILENAME file. # Simple example: Use nsupdate with local named # printf 'server 127.0.0.1\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key } clean_challenge() { local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called after attempting to validate each domain, # whether or not validation was successful. Here you can delete # files or DNS records that are no longer needed. # # The parameters are the same as for deploy_challenge. # Simple example: Use nsupdate with local named # printf 'server 127.0.0.1\nupdate delete _acme-challenge.%s TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key } sync_cert() { local KEYFILE="${1}" CERTFILE="${2}" FULLCHAINFILE="${3}" CHAINFILE="${4}" REQUESTFILE="${5}" # This hook is called after the certificates have been created but before # they are symlinked. This allows you to sync the files to disk to prevent # creating a symlink to empty files on unexpected system crashes. # # This hook is not intended to be used for further processing of certificate # files, see deploy_cert for that. # # Parameters: # - KEYFILE # The path of the file containing the private key. # - CERTFILE # The path of the file containing the signed certificate. # - FULLCHAINFILE # The path of the file containing the full certificate chain. # - CHAINFILE # The path of the file containing the intermediate certificate(s). # - REQUESTFILE # The path of the file containing the certificate signing request. # Simple example: sync the files before symlinking them # sync "${KEYFILE}" "${CERTFILE}" "${FULLCHAINFILE}" "${CHAINFILE}" "${REQUESTFILE}" } deploy_cert() { local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" # This hook is called once for each certificate that has been # produced. Here you might, for instance, copy your new certificates # to service-specific locations and reload the service. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - KEYFILE # The path of the file containing the private key. # - CERTFILE # The path of the file containing the signed certificate. # - FULLCHAINFILE # The path of the file containing the full certificate chain. # - CHAINFILE # The path of the file containing the intermediate certificate(s). # - TIMESTAMP # Timestamp when the specified certificate was created. # Simple example: Copy file to nginx config # cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl # systemctl reload nginx } deploy_ocsp() { local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}" # This hook is called once for each updated ocsp stapling file that has # been produced. Here you might, for instance, copy your new ocsp stapling # files to service-specific locations and reload the service. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - OCSPFILE # The path of the ocsp stapling file # - TIMESTAMP # Timestamp when the specified ocsp stapling file was created. # Simple example: Copy file to nginx config # cp "${OCSPFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl # systemctl reload nginx } unchanged_cert() { local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" # This hook is called once for each certificate that is still # valid and therefore wasn't reissued. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - KEYFILE # The path of the file containing the private key. # - CERTFILE # The path of the file containing the signed certificate. # - FULLCHAINFILE # The path of the file containing the full certificate chain. # - CHAINFILE # The path of the file containing the intermediate certificate(s). } invalid_challenge() { local DOMAIN="${1}" RESPONSE="${2}" # This hook is called if the challenge response has failed, so domain # owners can be aware and act accordingly. # # Parameters: # - DOMAIN # The primary domain name, i.e. the certificate common # name (CN). # - RESPONSE # The response that the verification server returned # Simple example: Send mail to root # printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root } request_failure() { local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" # This hook is called when an HTTP request fails (e.g., when the ACME # server is busy, returns an error, etc). It will be called upon any # response code that does not start with '2'. Useful to alert admins # about problems with requests. # # Parameters: # - STATUSCODE # The HTML status code that originated the error. # - REASON # The specified reason for the error. # - REQTYPE # The kind of request that was made (GET, POST...) # - HEADERS # HTTP headers returned by the CA # Simple example: Send mail to root # printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root } generate_csr() { local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" # This hook is called before any certificate signing operation takes place. # It can be used to generate or fetch a certificate signing request with external # tools. # The output should be just the certificate signing request formatted as PEM. # # Parameters: # - DOMAIN # The primary domain as specified in domains.txt. This does not need to # match with the domains in the CSR, it's basically just the directory name. # - CERTDIR # Certificate output directory for this particular certificate. Can be used # for storing additional files. # - ALTNAMES # All domain names for the current certificate as specified in domains.txt. # Again, this doesn't need to match with the CSR, it's just there for convenience. # Simple example: Look for pre-generated CSRs # if [ -e "${CERTDIR}/pre-generated.csr" ]; then # cat "${CERTDIR}/pre-generated.csr" # fi } startup_hook() { # This hook is called before the cron command to do some initial tasks # (e.g. starting a webserver). : } exit_hook() { local ERROR="${1:-}" # This hook is called at the end of the cron command and can be used to # do some final (cleanup or other) tasks. # # Parameters: # - ERROR # Contains error message if dehydrated exits with error } HANDLER="$1"; shift if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|sync_cert|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then "$HANDLER" "$@" fi dehydrated-0.7.1/docs/examples/domains.txt0000644000175000017500000000346614327753726020470 0ustar lukas2511lukas2511# Create certificate for 'example.org' with an alternative name of # 'www.example.org'. It will be stored in the directory ${CERT_DIR}/example.org example.org www.example.org # Create certificate for 'example.com' with alternative names of # 'www.example.com' & 'wiki.example.com'. It will be stored in the directory # ${CERT_DIR}/example.com example.com www.example.com wiki.example.com # Using the alias 'certalias' create certificate for 'example.net' with # alternate name 'www.example.net' and store it in the directory # ${CERTDIR}/certalias example.net www.example.net > certalias # Using the alias 'service_example_com' create a wildcard certificate for # '*.service.example.com' and store it in the directory # ${CERTDIR}/service_example_com # NOTE: It is NOT a certificate for 'service.example.com' *.service.example.com > service_example_com # Using the alias 'star_service_example_org' create a wildcard certificate for # '*.service.example.org' with an alternative name of `service.example.org' # and store it in the directory ${CERTDIR}/star_service_example_org # NOTE: It is a certificate for 'service.example.org' *.service.example.org service.example.org > star_service_example_org # Optionally you can also append the certificate algorithm here to create # multiple certificate types for the same domain. # # This allows to set per certificates options. How to do this is # explained in [domains.txt documentation](domains_txt.md). # *.service.example.org service.example.org > star_service_example_org_rsa *.service.example.org service.example.org > star_service_example_org_ecdsa # Create a certificate for 'service.example.net' with an alternative name of # '*.service.example.net' (which is a wildcard domain) and store it in the # directory ${CERTDIR}/service.example.net service.example.net *.service.example.net dehydrated-0.7.1/docs/hook_chain.md0000644000175000017500000000551414327753726017077 0ustar lukas2511lukas2511# HOOK_CHAIN If you want to deploy (and clean) all challenges for a single certificate in one hook call you can use `HOOK_CHAIN=yes` in your config. Calls to your hook script change in a way that instead of having only X parameters on deploy_challenge and clean_challenge it will have Y*X parameters, where Y is the number of domains in your cert, and you'll have to iterate over a set of X parameters at a time in your hook script. See below for an example on how the calls change: ### HOOK_CHAIN="no" (default behaviour) ``` # INFO: Using main config file /etc/dehydrated/config Processing lukas.im with alternative names: www.lukas.im + Checking domain name(s) of existing cert... unchanged. + Checking expire date of existing cert... + Valid till Jul 7 20:54:00 2016 GMT (Longer than 30 days). Ignoring because renew was forced! + Signing domains... + Generating private key... + Generating signing request... + Requesting challenge for lukas.im... + Requesting challenge for www.lukas.im... HOOK: deploy_challenge lukas.im blablabla blablabla.supersecure + Responding to challenge for lukas.im... HOOK: clean_challenge lukas.im blablabla blablabla.supersecure + Challenge is valid! HOOK: deploy_challenge www.lukas.im blublublu blublublu.supersecure + Responding to challenge for www.lukas.im... HOOK: clean_challenge www.lukas.im blublublu blublublu.supersecure + Challenge is valid! + Requesting certificate... + Checking certificate... + Done! + Creating fullchain.pem... HOOK: deploy_cert lukas.im /etc/dehydrated/certs/lukas.im/privkey.pem /etc/dehydrated/certs/lukas.im/cert.pem /etc/dehydrated/certs/lukas.im/fullchain.pem /etc/dehydrated/certs/lukas.im/chain.pem 1460152442 + Done! ``` ### HOOK_CHAIN="yes" ``` # INFO: Using main config file /etc/dehydrated/config Processing lukas.im with alternative names: www.lukas.im + Checking domain name(s) of existing cert... unchanged. + Checking expire date of existing cert... + Valid till Jul 7 20:52:00 2016 GMT (Longer than 30 days). Ignoring because renew was forced! + Signing domains... + Generating private key... + Generating signing request... + Requesting challenge for lukas.im... + Requesting challenge for www.lukas.im... HOOK: deploy_challenge lukas.im blablabla blablabla.supersecure www.lukas.im blublublu blublublu.supersecure + Responding to challenge for lukas.im... + Challenge is valid! + Responding to challenge for www.lukas.im... + Challenge is valid! HOOK: clean_challenge lukas.im blablabla blablabla.supersecure www.lukas.im blublublu blublublu.supersecure + Requesting certificate... + Checking certificate... + Done! + Creating fullchain.pem... HOOK: deploy_cert lukas.im /etc/dehydrated/certs/lukas.im/privkey.pem /etc/dehydrated/certs/lukas.im/cert.pem /etc/dehydrated/certs/lukas.im/fullchain.pem /etc/dehydrated/certs/lukas.im/chain.pem 1460152408 + Done! ``` dehydrated-0.7.1/docs/logo.png0000644000175000017500000022530214327753726016120 0ustar lukas2511lukas2511PNG  IHDR\rf}iCCPicc(}=HPOSE+vq:Yq*BZu0y4iHR\ׂ?Ug]\AIEJ/)}w^f5hmq1]"*3˘$:=RxV?OY ij0m MOfEY%>'3ď\W<~\pYa3'6VژMx8j: U[r5_+\5 "TQB6bXHyKRU#* ~?=[+?9%@|D.Ш94N3p:0IzEm⺥){0dȦJ~ZB>7e[g͛[@fewxw}n4'ru. cHRMz&u0`:pQ<bKGD pHYsooީtIME  &NzTXtRaw profile type icc8S[ {g8N fJ )Zӯj`UI0MH^ƆlrHO8HҬddY@+Ct9FbQhv6!yc`h ] R3lgvccD{lxz ߂D'R134j YV nDOidUKDhoXi']SpM=h bͰ"w P0 =aE(0;q!O1 ovO)!3TҮ>P濕y~۪DN*>)уGȥ~Ӹi叄Xl|44mm=yTɋ̍6Sn/M'Q8 Ƶ΅sU.;a;;xAj; &2B5zTXtRaw profile type xmpXYے8 |W'ؒmo[- $̰UB.-%3I_xlŚK-%VZ뤣4_.Yy3f,9@ćŰՈI b+-aIY_;L9!*#*Y*1#qX h&w3ep$B8:F|uW'l+/J\QB}ϬzMeXNJ!F09 !VeA>LTV9{*" DD-q->(=UUK@=GWJd)6%W XnRN9=V  9׫z]'=)A:;aMnk-HtVΠ`3'ĽVbfE}O_+@?uD@ ƙb}SfVy[EŕEwVe*oٿ5nHM 7b9) 99j*w9Vڅ9Hă̯Jk|Jf!{T=\M{+{WC-rD =+džk}PhmSvc9O#:`W,о2'H-8 Czϲ ;-s~' ?3g >3 :`]a>.gӖ NɯGN$Opi SvorNTϢwIDATxwdU}n\*r@"M:66ll $!! yr9unx{'̬v|>]us~w*W7!{$G<2xm7D#-yd^;"jbB[n#y$- kCD˿ѿ 2z)Gr<\n0+WgB]|&xd#d7v|xT#CHA}o@i*$ l? vRJEhLB05G0{GH2xdL7}6˟EB̷ϼ{ٿTMqйcSbq"jx~U{\!x$w, 'vBޛ }OȮ~c=t0=KM{r!n/*5ְ`:%dIS+3)0zMX֏mXt}{1P$!CϏ P[,ص[W˦vnSoJ#Q8Cf6o5~x~R>@ְ`Gu' IKH5߆?z;TZmOm7-蛷TY"Wʹ?CpV$d̍4bj¯~:Ym/*J965iߵYؑ]1WX3P(Za`#ou K@ ~2{,?lYV[E)dut=1qVmϯȔ +DoydMK9un`[#mQpu Xq,XִsRz705?01f1pܛL' ~}1'ߵ_hY5DamzߊkjuJ .iZx)LjB߻\l?8|l*x )(0ue D@ebǨyC ŝ' BݭR#pwS@?Ih?:]| ?t?kMCq԰^e.>F% tD~0&;&g2;3nEw.O|W1 Q -#q5;7+6Wu˱KAgO.(l7A T,!R'B,O z!BC`~Ln~]z t#mxώM~{-a\(ޖ!}#[ekbRIkN1j]-*}+ԝ+=(ĚŜgX[R^~KtIxmbC1lNk{?RZʆ[c %DO)A@|JXwKsvߌkAp8ZVCИnd2uoQp6N]+㶵rc{f wo[ߖٹYV-E]V<7cNt#ꙃ ':fqvOgݳǤ6c@F81afت)ׄM"@?Oկ]}]!9̅+ǕWHܬ3%+RLcŖlznYۑ}b_cBgȢsFdvᓥg.8;z<9{n}3)̈́ș1&>==ҭVoZӾsؼͲPJJ;9 R68L0A2ʤT69<|+//9Xw{GޤkZn˘x.S>pK'{??J@ ׾tӧ{/ٞJ]1лr`ʞ'؉-kVK9JTԆF\"ְ)K`wdL39v9u+h=kNyNx#Cbпqw;mZy>s&SY]O&4WA<}/|u`a ] ǓzV_۱sܲkAo$z8k f*AbA?vgz(5t kӏR%ҵe\4|/ݦwPg;h;n<2w.S[艘@ +FP(1ɞ'~^ ìL~Hes-z,[{:vno^4J/]8x"F"Nݱ@IKAKƛALJi^sBm;t|F.WNB0wP%45& FeAn[j.4[w}^n4 0W~㏃|r+J] ʄ^mҴO5;.\|~M,@OBŻ_Jc9*׆psyt&Mf2BkgwMxlrr٥˶9my乱K\>6V8}xpsR&yd] ,!4a1BӨą_].UZ]2؀:: cѾvŋwndvlY=@)9؉^.O q4$Ej"D !D~n'y bW[̡=]/\m]V~3Cg!@\YhM&L6ŒF#⍀FINS=khi],i˶g/['j5ӑ݁}['* |A#Mczdfu~릔/W+ :CL%,URK/]Mm>tr{3ܸx,_<}=Եs{p1 dMI_>+FyO?z 8ȴA|{2Xvʁkwm7LFI+!B %ׇ  1ɦr'qZTYuj#TXm+cg cJ_Ut|ۻ}N۵cNtCAp +LFu#ܝZnP?i_o:˞ H3xk_EVt=I= s~oX i CnXThpOT\cty؝mhyʂcZ0@Oh}bvmyc]ϞVzB0 &<#p҈?K A/RRCFE(|[rɗ`2O2׬Z:пkk:vѓ0Z}Aݞ%j VA+<9qsEDX_7N]VZPz6Mb:U9 .XԂ7=kȩ˗]81^8?, 0dPLi*?P ud4ʫxoN H}"[ovy߼-DɹM浺I4ɥ7t^.G h2w^t+ A҅87cݫO޹=q#~XbMFԗ#pjA0V1RzF ߒ_>r+>nE·=iey kt*ZkCй Ғo^6P ׇF]o/ 1.2v>}O~ +˹:}s'Tv} 2kclQµ֨_J\}n<~wbh<7y=7^VCUk^!D_Ld7=p<@xνGK=7/ @T3n|ef@'|tj!#ԆF^įְ2)bglAaxQ{xU?q}{Agֶ,wVlZٹEKnY=`3_To0 #po"<b~/0z:9c_:g ZF[VNHz+@tץ>2N ^J߇` BW)8s{ԩW/_Gk5-\iӷkk4MXUy$mLZuWˇ9+/? 78IM*}۱]V:((p:^;rx=&vz~p#'~n:AyeF")vܰߵMKm[) {~/c*܎+ _Yېܶ=jiĞAlwu/≳}~:d=| BGFzX=|QT!s0{_/O_/e,Пzj9V-FhqMS*pGq/2?xd h '_My%Ҹ aS8$ZZ!E㋘ɼ=SHgHZՖ~HV/FANڷ vBB (: ^y9y5W McAWmcɭZ&&uJzSY`rO#(0wֵ#fyh0zC]>ٖB.Ӷt!Iߺh>3i)) 4AENoB6̪ٯգAI?٤sk4T]o z!e*sȽQVWFsRZ&c'h.@OejX>.eoxp';ԡS'o)ـmms{:vt([CU1"A {mϰ=o88+ԋE4$M҅$;j S+,FgMI} CA֚ހ ?/djC\yUu uixTRמ' "vl^=fr(@VspntF26$o&qqT]i][򾁎woXܿ؎7)ncJO/ *x^3PLOlg{F-@߲;|i۲.!`@v@t&SzQ:_bf$~Ȕ]aa? gSn lZܾQ[sh߹gbL%p(Bei̔şmF[+]u+MupǷK؂uzۗNR*VJbTJ =j"g/ď~/Ɏ ?sx{E! @g7W1bNNÕ)7xݶRnc+wDrCU]OubbZ[ZM7sj9'4GT¶V$yq9xM=F{-122ަP֨(K}VvgάZqmY4QMH$䎝_|0_ Gh_85>26fDA}kٮˡۑAZtAzeQO0{_9_J&bͪbюMZ[]ޞpމi%WD|zwk|n3Sg71+ Ņٖ|lMGjGg*>18 zVuy]x'"RD*3"t8W_O=o}` jS-Uc6Kp_xb ozX#pl;=\x逺x"rUbK۠uFbʰoe<كpF9&E)={Զ|11vC_-\3е#NXGe}3^Ltx"R q]k%>iLRJ|/ ]k:G/WʥTDupdVGX jH}\y~/珟Uye7gwi=Om'e f_w8>L*SQGuh dɔ`IV.ny~# T&&nKmHۦJr+^ʗ\ʕFX.L(sM-e81xL6E %| DZdShFn :f:gx6i #\ >Jw.zn f{f7xM^¯԰Ҥ/jˢlW~*Wp=\8|R k]ݵUݵԶuX'E*ץs3$yXv^Lyߧ&tt|hU_tuԭU=lSW6HlVs1vgT #LũAiO8VO XisEM" B{Rʰab6gx7'>a+W8ހJX00Rx[7f*IAa=@ACu7(zp1588;xDSIop'"Bq4l-CNޮ7U7^ncڢwp¾dl*\RɶglSB cL!۔K,{Op.e8Ki$(KO\r4 ^RbCO",,f_wSYJrfp̦MCCh<7(=srqQ_T lg˛On#Fn Z!8B1?4B t+7ԑѭu=}ck҉'u[T+U:|MˠThBE(K$ (UJ{Llڎa'5RL<ÉGK _=>ݬ< =Nwsc\PLͶ]'5C!,X_7&P>xpC\|ޞaMb`V;6,]G-f\B }'5a紼Vw9kCڻlҴ+ujYg +ה2Mۼɥ/1 C{˶*Rx*v !ȶ)F1;Lt*i^@&'UZ톦k6j 2]pbat,oo{>@bf}YQI$b!~.t&_"[hMT|$#p\9Z%IbZB[pBq)JQ6P>t2LߪS]|{_R⺈IV!@fߓ]Vcvkf ePr\\TqhZRJe:2 ӬWw[ h A&"?^@)E< wy"&1GT53mIe1J[GfU+5j:&'eB FJUΎ_}۟bGޏv9z؆|Jepͦo7ԃo Tat^+g.RTI&,Yy;7VbeSgRCԮ ,=NEPV"]$![#ej-64z)V6f<ݔکs=/s ebq &:vm#f9z{;:õA0ILSzko*}&zӓ}ηgS21KՍ|IujfT*릁iZұ,]w 'fS)WVjNj 1g N\ b6c8j:h:PgwK~@2vI!j*se@5>^2Y6ЉzɳZ鿣cVuޮ[& (EԎ<]+z(jg/{q?WcɍmEl~FFb*ζ)ׁO^BHTL9nE޷+M!b̊T[:O\>y4 "I6:)0 d ѢO&"+"pЄF"g|$OR'6/Ra;6NBCRx*4D XAu* J 4hkT+UȐ4ଅBbDwoǬzͥT,3#òyBP}} yV,}1Jj8-Uw ##>ЋZ[l/\!?ˇO1:2ic~j;M1z:**A(#7ӅN Zc֖F\ ZU΄34D U륫JeCwvu-S=cBjtj,RJJ: /4ȶ)+ i81h'am?4X™d,(?-4~o& aeUttPqX %j:iuBD*ĶIEP.xkqj0 ~Dh:wCk񦿛1;"fZsϽE<ѳkk1zk@Ep2\( CV -]?a5};2D,m(URH2$'<M®TDh~T5/W*˄%@ VmN{;P@ֆv[ ٓ}E}'R.D74MRD8r7h*id%<'ph1S)Uds!V}J*R;ē1J \T&\6B@=xT90X7[BD'c;Zb62~ɓd7ozoۀI ?viۼ3x7Qڽ{3xκgd2caT^ᓌ|Uy~ϡ⡋WN}sO1nq0PskM& г9۰:֙pچ}#tޤ(0Mv5bs#2 !LУL~ZNTs=̨!v.Atc3:ytT@XxiPFrA:c!v{tY b̞we޷mQ߳Lr)oxlCF QoTko;7F\2ѣN:e}? ?V D|ABhnxZXKZRTr wJgN=+V rc<40LB8ߡVuI$Sq)jX͘Sʼn9/w_2~4ǃ/R=ur}\;ubL<gފ LǓ[#ڬ@玖uK^:{BN@uZ1eY%{O: U]vS 0F0״uxmm T!LĴLY4LT&@<'lDZZTzvoܓX"?xeW1ͻ4tOq Jeg.2>+R9}KyGЙJz܎hBԨ_^ٽG8zJ΍nI< oh P| L~*@7;|-Y8֢ex/IK.sXO2 y h±\_Eí1u,J:7옍s4.!L$Q֑$ӖvW(lۢV*l *5:2ʦGPXiv5^]CTRb&z37 QnAJ[BmR\B/>|Wqs*WwSIֿ-tFYCz HY]NC\۽*'<2҄S1/&)T)*Y)Tt!@km#4^iMt5ysiGGjm_GzI*nL]PP<)?-3c8eF iaD+km<'?^ ۑ!sB2K"7VwS&0RLT "l{MXI!W$?^$N4 R***51{U"'TdӔ#9hwBbX{>^@ls**eX E@Rcq:ǓqO\;Wڱ Wk/]#A'u-;#7@gZP_Nqan<3CjqK< oPDjtJL;߽m5kLsM-6 ҕLuhκhCLsb5:<fmst&IZ鱅@uv]rB"ǰwXirRA<#?^ IaZ75)\)t x!plFt4J5 A@7}pMeST5@G4`R;YtC'3:4|J2xX qW(ZټEA0>T$)KHȴm̍$d1_²ͨq_-\Ջu%uhW~w!ch#`Rv¾#xng0}l}[yj;0ߠZmҁc =3S73,k*fibATRdqJ*ENɂ sL ;th*񎝖q|wuOfۧk`)xuM}l[:YA!W^s''@:y4M GwMA'8q'l\Z-D5jJj]iLL.[kihZ/GF"^mO/iZ3ΟK!X4d RR)WUēg^OR|ҙd3и/JAaxhecW-17 zJUT:GX#irE2fsfOROɍlsg R)Uc r#&3|G7tl"JLl<Hwd82V>e`e[6j`:cWnp #'ѿk+ :J q _=˗ i M:]~5 X-xj5)W|KGv?o8:O=b֑u p?l1Н  =z ]x<UTJof[B9)D]:ĉ۠lJQ#w388RĜf9Sj:ZYhY{ *_I$HK3i'J*] !YXёe {r+m;7c/[x,R"\^./q^V })0ϡOP~>zVL_*CoK][e륯|kHM4HJ~I*@ (hI 21L#ߌJ'4-bE;`RDfZ&RqryRD3%zBsi$(ZQ.V% crjD,HEzns~o؎|@FmLӨ570"Gu~~^`i2lZX,zb OnY=zpy^+2wcK9ukEch(}c]*UgYSeVD z9y?Q6Yʞ`/Lʡ]߲(f S.L5qnJW(Kēݸj!_ NôV0@k{z#}3W-K8ض5V,m 'BxQ"iMF *b֙ŲLT e ER$(l jO`ZִaO<I͛J~BzheF´V&BP-W1K`6-6-](L8kMiє>D >N9ǕJe( jQf U+j*(#L@V*s>默^uat g;S)UUQ#%B x#Vc&lrB~u؅X{ѓ!vȲc~"&w-taD*e[Tz^s<&lǢq,+t-[HÖ]@M`z͘p"lC~@"I06scElr0V0x[!4VLs兽>}+.2L6: 0#oF7tuMh1Z30L')f!HQoEnL'Ղ Ojv b807rl{E]~a]^uCI-Qds=J B9̑LIA[{<2d C^E!YNV/V:㣇W |nueolRIRGoCcDZb 6[-1qa9 R>Ԅt|5hVLq3R 2Y>(1*/3P9s|Np{0n\c<35MO Q@ݐ`fѵ&i 4jae9:-q_.*:Y+xpKNYzog!D7MѾ`{zK/0uaOƛՀjhk Da"Me@uY~t]4R IDvc7X",+N)ehdRmk5bsȦhH)C-<X (IRKJ:O!;7vWavu &ߪCTdx\{s׮0װ AaZOJ+d$6t(\xapszg,twiJJa2`qSev` E0Y?A֭X7~wniV74ܺu='JB"oun>N,ۊB&1F,AGWd $|1Mw7'fG]rk.V:. -)4l:NrZ̾.i Ot^.zsW0U0 AnBK]*3$^"CQ`1bI+ S9#YP*1tm9,PwhŊO<p A6WҶbgMC14<0 JɰHך6 @30a(hN}<s}ERDM}^ <˶BO{cكkaD& K}Nfj432:h5NeoDӏܾs^m[nFsTft^.zs.11 Ann{gAu &g~%ͰWc˙_+ш|-x^j5PmJq Q0p[92R +cwbǜi f؎EZ3 l%P81RL,:R/ZpR6k%XZGKݝ EN;- oJJV,機Ag=z-I>ge ɕ*<ԛtXSv,ܺKn@&jOƖ|DW*XJ2Wv2d~Ξ;Z+ Vh9NPܴB_*Ho6lӾŠ 9u{=*Ul]* P/I:#"F;X.| s!n!YѱxO3t7p:~fSz,_s9&[;ɰ~ h;faYVH[ KrDl!a Ϗ'8\!t_uBD*6Y4$#&I#VR/bt,^nşiws}={<~=^;OhKw̓ 6Zt,pnU1veK~KpFa1)*JI]H](W m4U ׯj7鮂q~YϧOEbN{{ Üߘ9tv^wUkQkB3Qf)PQ/QpN[ԚkvxTdT:oq]H>6i:e\)?4\+5EiqK{bz_(ܤ(![ּ+_W]8s(3'eQ MH/[6ިBE&jnȃOEQZhzRk}]~i˿|yya ,!/Φ&q3\pM]HgaǟS.V""#j s 6ruҠQ˶IN\F B\| }Zԏr]ӰYҷyc 4f$`9y+ GëKTNPBr1scTfuh廔Zy?q^b,tbZ5FM^ Sp C#äS#ZrE|? "h00v! KMy]<]ۉmZoxiˤ1X9+Rv2v'Nq%?FM h3tV6RǾM7*ύ)Ԁ AwMrD[HvK%4$ Hk֨f^^ξ+"VRPmwpY (#˥;hȭp,R6fɪt&g8A3ټڏ^H.jmu/$tpD ɽRze C=CRTDաT%^Xӻk=}Om'}h@E̦x].^!?sNri|5t&]2vkBqoZaC,3ǥ~8 h,fDaFiEjN "]5k!pR\wP J˓y,xM=* vVnZC^bO?#'$LR/Nip_°uiL%&\;h@Z؇mKҽk[Ȍd̸5Sit#*玜0e<2^ Gjӵ"ikS\x ujGA\ABDtSP4TtKه}?ă:"Ú H\/JQ|D,I)Eh{b#Ol%zV[H$S[ep<ʅA N2I*~c&ڤS)٦a:K{S9PuLM*zpsFvMz}T7<Μ<AzuEUn_<y_s@x_s?ZarQ ^4]?q.z _{"z"vS"#S*Cz)`4])U9r~?ߵذ "LI2'S>p{90o0.ka{i~6DA%-16hX u*_IiMmtg͡]"+Y ,y!oHۤ{M|TkGjs)/  yPPq$v0maW$J=q< `|?3GN8w=ia!ñX=܁c\8y(XXW|DKր6z/ЕSʡC' o~5VD٭9\)_3tjJ}b JgBvm#s % 0d~R{(Íڃiך>d'}?:nS1_38Mӽf=Ȭa/-?tS0>qW .i8S~'\TRIj)m&;[\?Q($wwu/@!x|k/Mw?͏w}=㱇 l(eXeǸG_OJe㻟;]Iq\Mjz4#Q{Kt&kqAP\P:T33( >U:򆜙[Os%3T (,v4*GLt,4>[dub P EQB5hbms\2 [o \|Cؿ(?S%~Hиxhϊ7"YFjmS;~q}pkO]+M(/i[l`$4r'=*_wEi0 ?mLF(T3@.3.@zXf(`mߝtqKuwh O^񵁀 0W`2[,?3-Ͽ{{ʀ`δN;B̓!70wHjcM) ڗ\F(($kmkp+PDpF-+QA-EagH. 9 ,Cx^.]45n'Yr1[O&=͏/v>AGG DfS2jq?_˙heK MN8RuK"C(nV6[_ :d{L= X%@6#Ҩ\_^nc ʙuuYF8Q6TqiX~m6\ +UFh1/ZJΜ>˥Rߐt~ * I{m팑l7ݰܗ{s ƟvY:Mg)z6T3[hyQ5[-^EfFc}|_̣ VȞgE 9+?+тZypei.f0f9п&9c/gn JG$!dq(:c)}4RP8Qk͵*\+*zjXӵբ{M'h]9|ѽ.C>Rx9n*Tep{8YWڵo/ٶm=h=._ů1~) =@I2#7hז_A*- t=7c!#z {RX4:gzQ&?#]\l[o\ B%nQKiw&Bj.>5ꂤHYjѿ;]=$dbʯ{pi~?Om(MEkZ"M=K(rkr F) 6Sgc+6No}Uz.@?ʠPTJ 3(T zuɽhhUqzFX Cб?T&"0L0WlשZOpG\*H0! `o"9ETig[~ͦ7bZ~m{:Ws}9rsc#Z:^?3I5tX 8%pN(7V+6^k-FϺjt:t ]s(VX\r3UW4Rz C5X.Gi ۖa IwW?ω?. J\c)-e 2u/B X&~6ƢbGa/&NLc8~_8e$AQaFM:\b:1eGF4{e5>$i`rrToh鬟WjRtr%~?8Əu_CY0o }|Cg=w73&Nyv8~7He&3.΋ ,4!8"/ya?fEĠ~Uq2VbgXjqq^u"̨.~$jaHٿŢsn.PV]BRt%_0;)1F5(MWP'ԧ/L%1 SR7> YLgVX̢EW^lR?S>|_?Ȏ[Q:B)`CN*N={qU*),֩o5P*pDNxBRg$hPڶ,ҹƚ!G|=n3|Iꈈ3g=EYK&1Oо7* J&k)Ax@a'kdas>{)i!ɧgա =@{IG"wFLзa$&gg焚 㣈30lAӜoѶ /K\̰I=A}i4f* P %!f+='KA},N{74$vW;%1r0:/üu1'.!5x{&u3 n<_ëIF_rт:e@@V85#KBV> V܅H[4!nDJԮm,]?gT@8?%??o߅M]^pɜ+im8\|ΟZnn+o]ݔ}!tvU;m=JYcs3%˄JnyZD׺z}=r:Vi>TpX0q-l郎&'\ p x/HFay⦙w+R).;l2Q%ī+׹gUPJRٟpGr2`cLB 'ކ7 uoeZ~~3g.6{Y~'=o?tuݛ7~E w"JkMHy_ǥqltcpzm,jO{5%HTu*?b׏8K%S_ 1E1EW]A ;M{2>9cEFH_aʨVkj[c["ϟv1s|ys!> w#,$klLCRA-F\p\_"Q,&ஔ& t*L c2LJi|oge7=b?ࡰh떵:.Ϋ#qj ^:r.4u&ݴHz0GErHg%}+FR_.\_go/1.1YqZr豐p/ʉ#8 Y}E2k0Cq..UwWSTwE}/+`h﷨nR 6nybXC~CmPB*qs(.'w~aR*)@\P%W-˿`|׾ \'c|ǷX̹RH'nsSgڝL\~>_(S$Mn@oOR:`JA=.tgHܫ%~[ &Jl[,vV#ްT35w]{$ImtyK.v΅+wN}/$ԗ*~|pOiCc#Q?}7?K/hA5?OXhB%%1@9ha\'+5篐nN;Ņ(e-Yq5L>U)YB@qSUڗX,8ϗIGaRAVKߨfY#k~ABA@GV7$\e=ɱZgX 506'$I; ꖤ ,N|ֈ -<J.?_&>1pw "htLF y/eB1]Ona{o Or9Lǟg9z4l*z3$}t-2ӝFx(rkϽʩ#'8;6BIxYKc&*hϥxBfPdm@ iEW'ߖ~))db+.?a.y~%'+B M}Qp@]P̫XAXۦ&̈́ʩTV r`1θ i Uz`F[[ހ :cO,U+uJ{pW9}(gbʹ?C/RO [_A~NlfmXJPϥU>`ܓC``khg'[L:fCypIJ:g>W,O@p#I9J$#5QhK-,&v,#>G~D" 97%ӗ5 珔p*T73yeԓy9C4!~7)q]򁷲~ ~<ˁ֭[1+됒1t - @m8~U;u-}6@%Px0@l}F076ES=( tצ4/׉y3 1ji+q5ᴷkDOܵQUX,Hm6Yɦ}I,|/Մdc\8V&^ }{P/5Ar?<^ܫ!*oȯby$-G-n\BArm6c<򟬇][FX5 ̖P`S P{gONI6jJ7H+tZҥ}#8.C`׳yb^+t&H+3uyK%2t^ y8"P,7R4CS0yw2MQQ+`Ŭ\=]WE1 ]1 C*ÐxDVKbe3=bŝs( cyQ7զkEMGqc{]#1loٸ6FU௫u%Zh_"FӫhPҷ&%ser,/j7A-h]sS58^%y!+а砇 9%dG =. \ V?Lt TU8e13zq)PM9&I<\uI)q]IܝA,LZ͝CR$}uD3uNQ cLwZcEݠV|>3jEI_i!zp Wܖ ƏzOIҝ>w©Q0(\*ۻAM_14OnҴ9 q=t %A,lD(]adoڑFؼ2uh1*WSx*bӵmi dݟ[YfRN͚Sz RY682wqyzCv |VO j2`ckNe]7.7BP~cUmp ʶ<M,Ds-up& U4=di J )b;?DRb 0mp3h׎8KXW9Lڶ[h%:Xk`Sl=:aq|A 6 ,!+KFT !. !~ߴB0Q(2r0ӭ.(bqFiB̀Ay,/~B>,j`;aYOh,zO1F}M Fm ~+/v_\\zԛXob;[V$IWܔH jDžj"` ~wǙ!;`;l"TeSnFޕr4XPJpa?ϻ`7z JOV`(sF$2F.?9MhpZVK0Wh7ڴ-3q:^MrcOg]cSvVpOviVq|a6;8r9`&'tn7-<!~Qr?ѯYĉ!ԝ.1ovBGaL{j5&$4ک}I*ŀM_ z'|٫U @0jkt˜;զs;dtMlNdx\&RM("UoюmXy-MB5HGuD+n#i)|S}곏yVaB ,_=EiXгse՛RQKqmhWW\9\CM^Fϒ8WD_tHqA~@, UhKy],q-V`*[i~A Uzk'>UD}MO ĵ[P eeAٜ8ҴJZ5+*( ubē2im$p,ԹV"6Mfٰ Kpot-h_lr Ŗ)4jһɦsE<xZ'?%&\;N[L\e좆3#n;$,о$ nMq˒xN"ps<8v"tRc5nMIi;`T $)^FbΰR&iw,"$5O ʥ7T{*8? ~:AڷR c]sP9M&}z0w@J_B.މ۾6rm 5D>WhUߒ"꒑KGeߛd`ü'c=hlP1Ti7tB[82rȥtGs c`ög9,Pdlb)?_+X4%c d㘎`qfw(kAWgW]{.,oy<֍]ЈuK<–8-"|})쵦i <ԈB:l+hE(3ѳD&X.1=` IfܱvU|:wL LxQ%ҿ`6pV٢ `fg>[=O.iO ˴@"9}݋mV~ 3yK,I ]Y\!8V ڡE;\;V?+clkjt%ϕ2^>iΑR]&ݧ@57_5j53g/rqN(j !D,,Z8E&JkZX|Г Ypo4Tݢ,z b %6aƉ:WB͊ fq+xuoSg|Bn<_DE%I9CxG%WX$o:~GAl*rdaYJe:Hįsȕ$3%ƅeP5!]TKpU$BLFgcP\251-ʓͻ6V.:oߋmP Y,S-@S0 T_9_=ϺI[[Eؼi Oƍچ 1{Zÿś Yf е;DV4oKjx_hjkMcڬS99Qa5!-DU oO ' 5>PT c#`sތ;tP`C*\yʒq:\Qh *-V/ɾCPps ԮKh( `ےhҏ !C>, ܡ4}JZD`;cS"NUgjthH;<ŕ5ye5-aHK-*}W=:$VڮLlD"?DJ?`Tf,WȻ45M3 /=<gQIDATǩ8ukз<˳o{f`aT0@j`DX.ԭ.e r3TQ-4q$}(MjDߥـx5\$ յTQA FyS:åPMuXq+ߒg'k G #p7 PE&D/\Ry /|u5V*XzX6ɭW,FK%kU5 犌Jyn #ŹJiWRCoR򖬽rݻ Xv9{+so!j4jVlpteI0ւg:I-}=lm ![Ur)73]CkI`ϕ#.B0a+q 57 b"yj$~70 bOr(\ha.s2 P(ٳ=8xW^֭{8r MK1Mh:pkYG.ͼ0%Di"IosQ>s!A\pDv:LȫP/(mpq)yhAF5&HEnY|2Cٳ/}M]Ώ~;6?>0|Wx||?>K]]չi ͰO?ImjɌJE ޑF E12Bqbוi.%rIz h!=z}ͩ#09͆btTC}mb>jy(K'yu֔CrIzTr :jpbsbҩhQL2ݙͩgQt٢kS!0|A$/ARTUe NPWW>?oi;u&׷S/0kV p1!j(0%!Ql T-wR\}Y] 5^2Aݻɯ ҴDI8ݩi;Z$b0F,U[`'/pYUML\gYA#8X ؿaŤ[贚QzuC:ӘYSìKĴUKpV%$xoB>o)NMf%Jx"p:say{na:ٲe{]6>x9 Bˀ*RlW;ɯsoCJI('|$%: Ns`G2IIWh1y/pQ0t|s{E F:8adywiU>3VqWɟ #߆iS4lPQ"E&bTbbjG(]|&/W/%%+sOZ߀3&U$/7E(d5 $ OG{c'9W_W#"==ur%!9T 38 zuN{[nW)D|Vd` rf:4|И{seRLɩ=7MHҖI/Ѻ%}B]0 B$dP^ag<ơ IbpJF9k:(/.c҅i s^-jnݛ'q7za#KSIi\ 949u̔' ä0'p8ۇGc*>wO`2jAA(=#(| 1:CRvstˤm.moq}}F9Sx6Ƥ)Β̺M_g )ZijpӢs > jrhkQ i$ SfًMIBJB=}Hԋ_ LgVVs9xp xH$v:Dƀidf_~6,]ZϪ'I! ok/Z8%-)lTqC/ lJ FqL.iI-([BK"M%U'8j9.2jltTajb|:YvE=.G0{NF ``~$2Ƣ7b7(a]0's^8>e΀$~[:pi^pL0ܰ DJv;W-<=H[[S> 4M3?Lkk;{̝[CFz@;Nވ*nsQD/l%DlksXËztLֺУGøã! MJlt@\$J?ki֨NפY` G 1pbEMH ~ r3rX\7klβyqv_Ѩg9xOr:V.pQ q!P4c4l"f̘FK);yr1MdƌR>{Gk=/~~V5f& A1ɹɴE.2 m8 & Di6;^ZL%P8I 2 %A+KE9c3|ɞp6) Ž;CEpbG֟pw T)H!BI8eFh fSR9t1vPU4Z;v-}{#Di|M׭s3f  X :f0MIii]7Oi(vQƄxMW<Xs\u*23"D`j7i*!vG6p.SY,rjxR"8CRrt_{tZY\綎j xQ&oوCֲ4aZ9.im/>0@D6W7HuQ5-dsjа\{ihΓOēOĎ:ufEcϳUcwz u. OZL BQ~۵'%km謁j&W:$]c {Q[lǙbQkfƟOP9(D5 EV.kAKVSattt{A^xq۶dk{bBTU%(キx-&pt pz͑>l0p`2AM$ .$//q)NhN峟&?r")vŤ̙3 c]]8xRFyO(9c2̄SυGb"8R">en9vk"N$`""Ia[C$Y56 MnMJ,}wgJUpXpРxQvFQESX1vC RzK(.B(Jl`Q~c#It0|+V,DA(Aj(Bvv 1 UU,]Zf;q-MBHt 4 h8&{axⳟV"X+|Kkb`S㚙 ?4#\w1N(6`J IO[mEݦ*:p}Ғ0npe(xtD̋QW*t!A+E.<5ɴ\6AzJ8`r9LOSs]LdM1Nqڌ!73uzy^|IvEog{;s+/pʊX-cX)R8q_|!u@woi@JӢ ?MSr p~O}c{̴) niB13qR=cҴjlI׻9Hɘg'? *alZȀANQ*0{׃&*^hazE0Z$kІsCoi30IOͤv>5+R"Vs.zneރ<¶bUUe]oK<ߩnn 6^ ((Mj_uMEJɮ]Ml~5~eqEYYɸ3-QR``T EQx7k:}o]ͼ)B=}D"n(yr@0U \P 6QZ)c4-2#"H*GDסS:E&ABPrc UkR9{8Hx_ؼ˔MbGc7e^J˖Pr (ilg}yanZN"T'IKOfW Ğ3/| HVz0ab&~tvgyEy|㖛723`mqLh0Ǟ (f{nN?ĉODӁ_e9D"5$k$'%?Up*;y3KRU# $u@AF@KE :%% <&cql`vre*6mlܙ2rmr~?)]ɻ*Y]Lb[7:eHUSjP}c !G^InQVVe ~夺S[ˉniV /&YJEɡIQVVڵ 8y3B`  Du.(&$JcGJl6wu|rx[uUQμEtv=|8B2%$VZ:!CzF 2p(T_Jْ:1_% !A^#zn JPXcWIBiq Bq1mˢ haǾg6C:ҩ8>m.lu@ axI}zh3[ڒt:? ׯ0͆הɮM6^yuy7&-M0J3(//atw}G# icwZ:>|ĉ@c'q5k)^׿S}5Zz' kNHMMnu;4Z_ A5^U稄 F 5"ҙ0Wqtdr^t11( WOn[@;ӳ+B\s IzyD9chb-Z d-Ö {nˣ=OSQ>ƂR&te%Z]㟟 %ŪUK(--Fu 0̄ޙ';v짵mXTI EE_fJ| !ؼU{5n~qhG&0e+m&5A~lb9hJ#(1p(A(A< .C۫đS$vCm֑$Ekx%@wP9XH թma3Q5 (3ę̐0=NΙ$+/^;FysZ%8J ~qv4j4u3AK}=XB2 !$=-/Ǩ|!'mbI8LL$kرk)).ʸs'%),Ŧx}^:QM?W\6φF u0Le'acwqjM+<h$X=}7~Jssˈ̝3ssis{lTH `iwhLOf ϟKRۘt:}F*5E/oH.'3u^e-LB;w6׹UX9zN&޻}G#w&TU!==WdA `Ӟa \NXr1[μZrr2+.ˁfÔt}Ml{}/p;u޸fg4 wF-cӁ%o^7ݸukQZZ4t_"@^ɓSSТր}ͼ}_"89Y,_`kt' *zbt\]BA*ѷ_Cu@<]&ydna/9l7`dfP3U Vٮ좗%ع1Mɦ.O\;3GQQ>RBMv > X`a>ػW?1LTUd.l?P(kV/rXa uY 9.^ۺ3Jb 6]w9gUUe|k?FQT#ذ2fG-8.n&c>䚡6UZ;8^ze*+S1VB>o(Z:beg*3pŊL[ui</ ׯ#>|OmMӦ"5j۶fg9|%%I2vv{ҳiX`z޸MӇ*Jp2Ӹٳ ͮU _2BLJ4IV--㟞`z6]w9k,eڴ|Ù\FC|CMg4N &3$\<!e<:~"pʠ#U0lgvw\u)(沆L_@Z]5jvE-S{SUxm?M/uF׍_QQJVVBg8p(zW ٵ-K=X;HT j:ѨEMq0 3Zcw%Cە$a&6pEyy y9I9`x{SsI +so_|ڵ̙SC;eW0YH)N~~ȂY*W>E ]$PħHB;V35*h~$ .AK 0P& /۵\d3,#c,lyCD2/RΒ{D&^vj~ rTt:Bw!v`l6]w9<]D0NRj\N6ېBEp`S]3ر/GS7@, vq],k7k!0H)M>AfϪg^豓ILLdH4,ǦMvRQqN^4MsQ:BPTƫVsoffń!G]{iӒ6RLbHkry)uht=ƭ8/@9heC#jv<*,#{Ͷ |Wē[Xx_'BDiB9͜93VGtg12::hn>N{G'U2vRJqrjDc!il{}/Ͽ-[sq|)(B`qFXutpr`RHL+{>gLy{oWvȣF<o</l~̙\u*z u'J xnb:^t:((eVmΡ 6|C"p FB'smdpqyr$mYy4̝Ce6YZ4&`08ǏF4⢿ߛD #:uEQ8EOOTU~v>@ydd3,^~e']]8p3)afO! iDc~ޚXtN_HOOs9zd?8ȸ?<~۵lr'^ͯpT Ass -ۇYl>7\V.0 =5굉RIs%(Uk4[2YCNH'EҘ+E͈ҧS]]ǬK)^wu9"Śmg%Ȭ #hNJ+Q)tRZZ@_~9z$^e Qm{im &H9i>r/W={0NW?cB0H!J Yd.W_Aytu.op}&٨6[44M9f>4)--wȍ7l`_c3?g{'ۓ.8xbe~zVXHA~NazSma/۵(ݺᦪ˗/tRfUdf)/ ]^'x]D i&m6=}!kłu+oqejk*q|A^߾{8z58r'Y`64S\cʰ$7]w9+W.fŲL^~L3)v! `w_w90$f'")l1˗-_ƣ=8z{=<E ɀbp*gJ;%\[X ζS? &-x򩗸kpe#(R´iBV'Nf z<6|454*ؽ=@̣^²eU6ѨƎ7*DʠlDnɅg[:^745ČyomZZ*.cŜlm^'^d$ ǟx9u3\yJfVp8ްWp{&y|CvuPT_ƛA>Τ#E{Ggbggg2wL5';;})=D(Wvr#~CJkeFF:@3g9y4]L^̴iE8]N22zu>fOcZI!H[ d:>w`z p:QXǩSgس}>1sfyRy<@_g`JH@0QP)8p0;hb{B*˨*_ǎy;:>j{={_UpY0K_\oTQ -4ڌ( 3rX<5(h"쬺6=g1k^Cvvf P,kGZզR[[{9|Gh5)%YesL48B8LKM7??^/H$JEE)tvg52w\oM0Mi2KuuY%X-7_rjOK8S 4^݉"dee&%ŅnCQVc 6##+7dͯē[8px;g&^+2v佂x[mjF$ c.aM(V^yu7/G$^ߺu7_Ӥf247sIMuc&f)8t(DQ&K hNDQ7 Nzz*ӊhnnI $%Ł8u ٙ,X`% &{BP[[J~1'yz=30%,@@JĔ#?ddE~~EEySTOaa.yddƝnCU\qTSTjk*;gFy9^~e=IGع?vmoZϒs^&ARN贚QztTg*32Kx( {9{!b/mfꥼMHȆXIRNqXc̹agzi1'[EQfϮFaIp(M:Y*ѨyeQu#%K7" ݃:= `:,7^7F5z{=z8qt8vnidCaa@Q\O^^Yv[fSs+9z$O>O,kux ƾҒ׾]6eլ]5 Ude\ x/"hnn&Juu9J h:p'O'toJ{BGF !;>{E(JmPX)׃,ʐDU*+'ޟJUUmt:Xr22啼iaJB],*OH$J$ߛ( e RHKsI^n6S\OQaes]o[f^߾471rNdFqP^3\(8]#;^Ӌm 2mZR"Q> N4˴B^/|C̛7"ᩁu=}) zS֍5jr8̜YNӁ#I+nKRUXgM4q nrJVVPTW{A=1[3'8)G+۝5 ƴB6,Ged/t7B~e~}C?|]ߙ#^r:zFVX[%tI=?"kQEoh"IIq<Ďni哑;~-YY(G>|VA Y>q&`JiJ+ ʦ?KbMUrJV,_HOo?NL]]x<^ptҮhxׇ_SFḈB_ 8lw(mFXfj\j,'oi=Kx#  ˯ॗwoJlnOѨF(ަ PDhG)%ydeg& @]]5v=b2}z1?ѿ3$WaB y|^kp$<!@A~ٿc|QM.˧ zx|C{{졫~^@M&e-6kB\.b r%ތh5x,Λ(XWEEkFg9rdoMMG8YUIPa1BX~%tc `(^Pؠ9̌t9y4i̝SzJK澉y !0|<)c29(5WaVm%y/Of׮&>qk())$ yN;:졻>>oP8p-ǃLn3 #J_ ݕYKpϜH87V*y~rrF].^]}ܹIB5>h赴*6-o%YTcǎF^Z-1dŊVr44a,{y}{#wR:pKfddEC䟬3DO^@ttn~=^H$0 'ds:F˞BMy WXBꥤ3&#mmgغm<*Y| ]7ؾ0)/FOO@_o.I*~TeXinLS&͖0B6-mv g)§>ބ}5 ?s~֬^:tPs99vN99YTTLOpLӊ`KOo?gvEOƓRywpaٱs?|;`gaтyps8ݔ:$d I-JP(e9Ԯ]F~|Ӌ9x>EQ)gv'8UU9U+n2.v=gN]553ga>˯McWQuhBOn5tG`N|&++}),7) $?`F4&o꺵 ̜9<!{~+͇[?]Kj3b԰)1Uƣe|BYFe ~o}*3]rQVVnޟlmO4KUoE'_bw4eǎFέ!++yZj;OaA.0HQA~E<_ē[8u EE^$ĥۊYy"q&V,_{k !(~Y 0:עZ,6cچaRR\Q, =I{{'_c~>Q3grM~]W7 8S9Rz)*Sjݾc?lݺ>7*>iZj [ox͛_J2c}Tٳ}oH\Kp)))`tttq#oXTOW͆*x<>{~k.aeӰt[e+2c#x z9SaR>Ϩ[SRR.i&v7]I]]5_^@t48g>>zs/bt=!I)J-:n} JvsϨ4_&OGjj+櫸[<wddQXOnar营Gx]\~B}|y~#̪5w8|lz UUq\ KvB`x}{=Ed @LL ^ܲ_!jj*x*vsq\CTo}%3g9|O|^vG>|QMS4v8G3nވsQ8Z=nwJGlf Ͼƭ7_M"tHL1I}3)%vOеKxL܏^>T*ޯ|3g$rrk[7!Zp))UI_nƒ)Gp?w|#>Go'67+M$55{vٽY@ ~G࿰퉋0IF⹨%3)K9 HG}}?'zp8Bj;o|IMEF\bBm!,:HD"Ql6豓>rEQ9M?77v ?WXz0M-$#oFpɨRʀ=f}yyzeduxj*+' u=yG_gՃ ر~{ル)r%4pLc\xP1b΁ =|KߥwuS͛-";g$V+7LJ{BÖPͦ2p,]2~~ߟlݺ&ade3wn -cfVN8-$>Ec2@ursß$;w6IYn7p!##=Q7 l>xh^عs?=̟? " զmzIGGgvS__HZҋ{8h4#ϩyb*IKK_QKȘR 'Et^2+7ÆU+_My|3=\n*ɈO `WokppД`R݀npع햫z9ȣ>sodߎN~#2cpi+TIo !{A͛EjjJ4MIaa?ke֢(39 0bcouu93gۯc붽<ȳvx+~xV,`e;︞5|;WF5ٿ|6^]=1qH,fCL44 TUa_A>s Kc ha࡯\//l,`~wO_Lr;O a|hPi9n \h6:B1IUUfΜqî0i/Y`-c* )M c.x7;;M]_y'>v75j/~;){c…uZ@:?U}x@Ucc4DD.[8CoDXx:?1ݻ GhX:E iDtܽ @z6a-M0Lݐ v fY}ͼmvl%W DeP|.B ɮ-^?RJ6n\MnN'[϶!M58TUyYY 6̨/gW݀0䠌 N:ï~4,M7n`Fb|Hp:̟{ナlᇟca$xKۈDGhll_ K H@l(k&@<0FQ5 #OIq!lRcnws@qq>Xdwy,1:ܨ)‚uÎ-Bd8s`r4L)}=-[SSS׬kR[[1HDpZwy7ݰ{otY&#cNc]p>9ōB$&C:`bi^~ 6y<ɶ&Ҋ >6f>7<A0.5cenq K%f' ݬ]rcLæ##+c!Ⰳr~]$Rzi) *HuѨ.BXC6=>;53g w#;fIkbnqf@`qz=~9xx@Rx23ttt%˟I{, ׯg˘>UU& ̜9q&n_~W_EolDu" p& `()n;1\UDnoGNPQQIvlCP]ɼڄ7h~BTVNg9()V8}) CZnk ?N>OcȡCǒyBanöm{?qU~̝[;%erIÜLߴ+7c< Vx[=sSt1.yf<(C7qvrk_1lgO?2OEUU(*KRJfϮ1y0z+wQ!U#B%z3"}wʔVZ"=zw atK `)娚nV=J^~e'}x3[MK)i9qᏏsيtV\X@czIIEu,X0^|u~9;igL,@QIk JcYPU0h:pGw^_7zkPB(H]G't>aL/Fk5sԔ[RF.PL4 97UIΈes546Gg_=sg_[ƍ&$2\i wE,(4ML]ZA_'~>l*^h4"EEaϪ||^}mwwwwix1ތF{463}'{V97n aKCF?FAPaǁ) @BDC% ȒT҇b;`,]Z=ᅪϾ#>Occ3!?Mٷ}˹zKH$==-!$2Iô47kbTΦt14/Ꮟpawfv;۶%ìpk3ǎCz֮]FuU.7Gŷa0 4QssKiMчlTVq+g&GXj 7x%˗'''sFQ04'uCEpUoHqfu9_Տ\&@ [n9w>;ƞCz>p;rli`D_q^l9bDQ8Q672u?0R`8.(h00 <_`jh\.'O`gxm9ӝ~fxr ͲJWdF4TU4`(oa,]RW 'X3n *]* ]cxŻHO|2Q0wUTLM#z{78H7rY(vі6l3"#)#;}pOx0 j8TE!;+KlBkɧ^_PsK#(w4}G#\2ߴfYLq.40̘"P[ou׬,?U-z)%ƀБxŻӊH_҆y(ŖƌD >K 9,-"s0d[ޔ*.Hϓ6>yDz`х@/9P`axsp=:EUPT0Pٳ=;y=/lq ןvşӟ䚫_xuaSLgEC#McG8}YVL֕XROJ4T #&vަ#`FKɾ|9V b a4Mt%Սվ 4PϯqlR&HthHX4 s̡Zx9EQ,M ?nkY˾fy96?mmIlH(!;{{%ato͇!^ x8ޭ{;⬜NΦd, 8aZIJt]+ 0%hHW/RkfYfXok\yٖ0E#n u%$<L0MSדc#b彔'XDy|ē/<?p{^6skȻ 9ߴ\{& Y 6K05JNZ %S6`aHMGFx0Pdp:(B5cM-@FCkƿ [<֊pI#"mn Hg}/nÿў>9Yͭ 8 r68c?a8F1tGA ZS=7 8U֟u#Y`HUM|0u5=m ]hoHJ@FQYϕVҖb6Kr,D$X01-"C|GD;:})Dy~=Ğ4L"g}e|MGp6pe#TuȢpP{'G6`#JZwU9AWS{wVϒRZ/T3G9pG/,faGq$ wigǁ)S4ػ}YlٞTI߽m65V<1fPwpai??a:y%M:nو{VLn.ax8K ZwmE/$f0D_Sħ̘NդTNǖvz,8r߾CZvvMNYj 4qxA׃Pb)) uF/@d" h,ɝ8yTKs `I躞Pٍ2}baYsP0SvSgQ3H_4;6ᮩĖn:aC0\ӋY w lV5 ÄNEஞAMWR6 5-epЪ'pf"]VʕY(D|`x}(nwԔDa* 5WO8rH[<»o!fjFf(Ld;=Om!pkWwZܕe 65iV @q9IM8K_|C$ŷ0>lk)jC24I?!D"ޏ(*q l*4oEAQcdWgH~#:ǝx3JBB Ɓ-^TL<)Mps  L{Byv ᖶD[m{a (Y62Tw Op dNj nGцdc~$'@V_kfGO`j:)eȻ23gXIm8 ڍ >éȟ1in/HH)1AH}wYI,$k2>3o&)<Hxl*y+u(qJ;Fx.s|D1U,z'|}hǴB2W/!coE^ Ϣ&$?@i T(Gz*+2#n+o:7#/bݲgA.nflEU1<5]i:"'jP-md`[C͘4 mP70B8-DQT3RӬT @WOnu.PL( 5HDȈȈF$R4H3xsoHP8,e;6( }~B-x_ۅo!*;sw\O84 wċ`o"+)c#'ӝ)ή"{miE()ڴ2| ^q%' Y$^x+v-;w9~(0ƅ*Ź2)u7Rb8r E,/h}LACd0;\j"aJM4D$D@`yP!](NX+·=U]N;o m,yHM'|·!pf(+iEO5LϝI5kq[-Bx+I(Ŗ'Iylnyb^'E?(MZ츤;PƓQ|&1R ng *4q t_O?< Mag)eB%F!IDaHHQPv;) 8LS[u(-&kH[=?ӷui:R fZlbz3u?t\Z1{n658s- qUc{ RQ3h>~ylnyr>"EV(JS2IBukHJBpgT)ML xz @`(.(;(1uޛɲ\w:{~+pE)l(#vnk9Pd,'Vsqw݌jZdK;[z* D˅8&1Og0=[FA㴭 Ү`!B>]LUH 1D`Ku[pD:23bKRጵ xdZGXJ@2E/? V 7eXIfII* 77(1 2i77GIMP 2.10.282021:11:01 19:10:32 BHQ6JFIFC    $.' ",#(7),01444'9=82<.342C  2!!22222222222222222222222222222222222222222222222222" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?((((()HƅݎTd@%}sr221^j#Nh>"K!͚Fc!u6qZI|Ez'k~/sfs1(猥X i)lDQEDQ@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@'T%,vѿ [~-_VfC"0Ȅy?GK|+M׹7aEVD\^(g۟_VmG?U)|Lަ~AER ((R /dԘmT$?S4 nnȇW<9iijW/'ξdo8 tro|GmyD5ï h NIGn5dFܭoox^a*bZH6/k4,vˮ@zl3UE G@8RX/4I2W߯<-20YJ 0*~MBL?.&P4'Uwa 1W%MKUMrMgԃӀHH$=M I%o'Κ~s6юNg+\Ŏ dYwgUd{zAzJTs}]\iY#[Ym&52?:V]E Eu%wx\6\85J (EPEPEpfme 5=2iɥN"$ghDF;haM#'mSX`Fp}k_3-DRG|+b+!Ky9YzƸQ9]>::Ȟ+py#8?PO5z>w).;[4_*VoRQTs ?m+q7?_m6`([汁mVTgYؒ +ٽHڧiV+?FHRn(A'R*(UPvH3?קR`1E8Oi7LuBVTRZΙ!3!נVXOft@` @"v1&rs4K(^GTQԱaxHaIlM>GHRQEsuog Ms R3jw\8Gݯ9ŗ`*Fddcߟw rOCM˕`ԊH*si\.7+}*8O@iY0O$q8+3<; VQ f٫V  \$V}[D!23 >VુOZgx𵺏];ʈp[ʼ!|kL48(FG^DDDE =R9q*W$3|Shٽ IiVRMǝ?O&L3Rv5UF=fTRTu$ 5>,4tOmϏd_i#ܰ[57yq.?ZW4I|rnY1G }qD%AcI|[vV0m-1c@<#RVjw|+j xm IW-*(dNU"2C8}ɦZzwy?&ږWhFEÛkNV!_,W|B~-1cB'ytPi1TeAs55엿XuR?k,N5 cMWNM>u %xXzΔnS0e8\jqr~W@!]f%󒰠P=rO׊j-Nyn2]h,OzojvMG?]f?VCZYƎ7ZUiXb7~5Kϕ9#MkiZtb;;XQQN4JQE25PƽZG \~CD,K6(1 &֕ ]V-,"2] rx+]ܶ+hrw77ng<UhdGc׵O=I/$a3\N^rȖQ k neAI!2Mo[3S){k=(L1k&0cӆ_k=_Is)Y&y6ZmΓzJT* JK9yS?Ž~O?Vdu ZkëSGMg}g6|QkpEun!5%akwS*:JV9gaQw0I9Piwڅwvs=rIeX}jzē>K1A)t}OGh;B2ѷ={xnh$ 5.(짍_>Qކc&yF־&]"r~!s{Wi/`h&^JI?C+6=UQ{DH8Mʅfa~{NofΝi4ݷ;?<~_ .^qG(Iδ)Lv8>ojwp+aqa5zgm+ of(|0=;sǯ1~]Gn]Η#HQK(N֢$:7aES1 *9#,qVv)Hvv_&ҝu0ZddwlWj5{1Y{W{2v?l˥CǩC%=gu_: %JOHc=*itvnK7EY6GK*P\no.eҹl}=)s GE^4]oȽehq.gk TyK$rU$:W:QSWY\kvz~yZKa6:Pg'M$~GbĘ-'>I[T*7$x#V}s7sI=ԒI#;<]d_M>L?fyсYVu6!Kps-pj $RS)=K4Xj29d}?su B=GUw=Q ?/l<\i$q8IWqU3k*jj-NJHlT]m~ =RfCl|q}C:SOur_YVX9;Gk|'m#"{ [áWA*JAbc ഽQp]\3SaxѝQN2R<;m5Ů3^G@mN'n$B98*ǃzG.FR40>o #o)iN3~ͭiN3~ͭT{_LGVԠVb|;C<^6#⧷om<uRZ֥9)Y;T%7>~Th&m}EuxGFJƪWg<6zxeDzO!7PY&M$0I'hnKxzJ1Ih3}nQk,|+[-r95_\-=Sp.1:] D0%ѵ`=FOL2v5q->O7Uh2լ$,U12Cz#,::2㍵DՄUCYxA,$YCU}6ҬcN|k 8V]Dog!O0?Z]E^N*yNGkz۵ By-TrBɥYjѳ:=A8EvN`_wt;NУ߶.4-rBћ qPNRmJXK{#>&Mn+{[Ҵ֮-K eה&JJ}WMu,q_M5Ke>{{MNjLXEvUٿ4co'𜎰j6Hz|ΟZI77IQԯ_mdE8_ƦsEQV5C'5jd}p< ky'Jybݓ_ӥRb|:ח14hvg.:Ƥye18ޖϏVΏzvZտ)+~ xjNuZ$fϰPj<7xl$m'S 2#R׃? d+"9p꿇K7Ҽe`&d?{nCEKAs-{Szy_>쵔wo[v! *}s[ԦԮakqʨ} a]EG-ƿw(#N*2Qyt_r^"[//k@N#.z M [NӵiIq{in*8ZT:>\ney`ս^GSieG\q[Mmo](^-ֶ5K[_VdU7 OϨ_)o,Hry|{jZ I=ć'$u8Yʒgu8ZJmroy[ ,fFgg\YɱR]YI9[4nBlBC5QT׻R5gO@Oj{Iw4nT0Vhxݎq)0׼ʒNKw,NKw,36S{=xmald+^=VV22Fx֬ Ǯ`? ZGS*3?MҍYϟqwWYQQGj126!KN|=̧YRJ+Qu$Rt~0䨸8ZZ7-Y_I,z|s)X^k#=k]*U s\R$:Kӝ7iϾ#{Mѡ_>gNHݍJ~*G &vm Ҩk:뗶򥱎.c$\ ٲϋ'i[O+I;]kR9FT4dxP8m[ub(Ȏ7AH9訤 /^nd~*vz~iQze,?$T{SiR{w xm*pG\'ѿݛe߂mmBSx'"3)kO}VMU.CүO-K,N[7^/2gYgZErO|5EyNr撹N:IYI$kSJO;u8ipmn[i nbU}6 pvԚpvSX" /:dր% gdJ~iZgaʯϭQv v}1\qֿ;lsS ~vؿ5nd"K)Cԩ뚫e`+$t}y!Ӯ˛HdT-:&$L9iKh$QD)/?25xƓiL~曙?p cFt{i/DjO0?ϭ MMT[Oo&F-ͷROH#:$[|>+.8h, vVL QR̂=tڦ|Aķk9 ~f5g';_p')TekvGk]pq@z#U\G[D8Wt v}XkxOD֖yZ7z~r4b3 "g=exvK9)Oq?'-+< k>IfTR@P2I]q}lQZ/yo!6Zu;5He|Y/70է';򪢽1(2ξxF-TQըuBO}eׅlm+(.hvXU͗e;|%Kmo|UϨ+-mb8"`+uVi;^xcTA\~k5;Xgoke)o!Ȯÿ/cvx.C@z5Ft->IY A}(Vz}'BO_FU*.%I+G}KҽEc@T .ZO5K6z9Jk<ފ(SX5 ?QEsU~ хj^JS?"w".QV*J'7ow$\'dbIjͮX`:Vw 'I\wj""Ɯ$QAPYiڔ;+Ic$~uI履CG<+jAiě;rX+jĚg[{vߥt_VkgWm|;?+8H]yus%8/ m~ϻ+Ҿ˵Kԁ{ߙUDH"*Q)QG[8=!ҼihlIGoַBaX%c wwoM#ɎPǏGϥɧ]q%HbQvΤ'vufKkhr,#SW/Y|-E+"CD(<2 sfK9oZO>Ƒ6|~nEC+$ &%[}#/]n#"_'R<7D2 {pWRX~]b]\~HآM[fhH-Mq4r p0pG\1?/t(mLD.?^ghڇ>,Y3gnxWOo^gQG9 ~cWϊoYK[D{{6dU=}FTVc=.K}Jey]̙ OZoSv$=#EΕcen|3db޹lZXxFf/W W?xT6H|5Pf4{>{D6=_>&v>wGLW:Fzn-V$XV_!Ykw~G[[Oy7kؔ:W]|4ﶽߗe_D6vA豠QVkEIuX`|Aj+{eU/dѽN~RDFcV0aRd9]/eAye^lc 3»KgK5̑I4 #se*j*[Q@Š( پ_2G菏^_m-8š޺({$QElft7<=ivmbFH qa_8׼O*:_"]ZO`0 ^Ʊ_e<]ĺIlW[~`}kJ]W˽z|K*hQĵ~<.O'Gxg[/6aϩk+V ͳn'b-HC^oq =,N2 @𥴐Ytf='I;H{\geq?nX#y_Ͱ\r@,bk!2g6(mk%ul0A~`g h5њFX,n& sxH>gp>.g4ΓF kd4YlnEqZ֮tgk9+@FX8P^I/q~Oj6^̱D© 9.2O)h>p[] z5 ~fnCa"H4L4S^\;R+a]|0'!h#IW$Mb7𝏎<[j5kjRUQH2H+)B@*Nm$tEՑQ-S/Vx @Ém7\yNtPQHaEPEPEPEP~w@h֙RI?xzoKqՀIʺ/t*oo +C0PHZz1n?+ү~ۏ%5ߪOe x4Xka yƹoPsc}JO-mA`8ӱCy𮘾#V0m2Yct* ~m*yhd^y೿*9zw}gZEpg20p{WS|8m:ݴk"Zn^H9oM~ѢҬ7&v@$nbUCK1Zx'S<jxCIQs⻯E~nҞГ^Ԥ+F+cs%E@=r>3棦G/+ѕT|=+C)VGɲ5Ev38-W̾*4o˦~.X1Nu,9ǧY(y,ǹ5EM#8WlQ/\*)QHaEPEPEVQm΢C11(Ij˗Th1ۈP$p/ST;.dJ~5+fE?I*rͽm$_ןҠ{w*$dLA|g@-3e?΂b%KKD x[)O\VMͽ?XGָP)쿮EL79 (?Ҹ Iݎ]HXmvAZ(U>6QEQ_Kx?/|#Nm!7 t#JdsN-r*sVQrnON!s&I?ڸlUK[MЭ H_ ~ <[e+{RF<ۆ$?^r6=Tʌ~Pkْ4=Zu/T] h1֡(jo_,VQF`;W$O_ DO*H/cU.ׅX$ݔH9,Xw5_xc)\0 %}H/9XOyȭ@:бHmRjr.͟nPpؠqi\ػri([EPvޝr%tEXtdate:create2021-11-01T18:11:47+00:00,%tEXtdate:modify2021-11-01T18:11:47+00:00ީ&tEXtexif:BitsPerSample8, 8, 8>'tEXtexif:ColorSpace1I!tEXtexif:DateTime2021:11:01 19:10:32=I>VtEXtexif:ExifOffset190LtEXtexif:ImageLength1079QtEXtexif:ImageWidth1079!itEXtexif:SoftwareGIMP 2.10.28$tEXtexif:thumbnail:BitsPerSample8, 8, 8 StEXtexif:thumbnail:Compression6epWtEXtexif:thumbnail:ImageLength256Pp0tEXtexif:thumbnail:ImageWidth256(tEXtexif:thumbnail:JPEGInterchangeFormat3280tEXtexif:thumbnail:JPEGInterchangeFormatLength13905@F*tEXtexif:thumbnail:PhotometricInterpretation6 tEXtexif:thumbnail:SamplesPerPixel3ZtEXticc:copyrightPublic Domain1["tEXticc:descriptionGIMP built-in sRGBLgAtEXticc:manufacturerGIMPLtEXticc:modelsRGB[`IC tEXtunknown1!U|IENDB`dehydrated-0.7.1/docs/wellknown.md0000644000175000017500000000516514327753726017017 0ustar lukas2511lukas2511# WELLKNOWN With `http-01`-type verification (default in this script, there is also support for [dns based verification](dns-verification.md)) Let's Encrypt (or the ACME-protocol in general) is checking if you are in control of a domain by accessing a verification file on an URL similar to `http://example.org/.well-known/acme-challenge/m4g1C-t0k3n`. It will do that for any (sub-)domain you want to sign a certificate for. At the moment you'll need to have that location available over normal HTTP on port 80 (redirect to HTTPS will work, but starting point is always HTTP!). dehydrated has a config variable called `WELLKNOWN`, which corresponds to the directory which should be served under `/.well-known/acme-challenge` on your domain. So in the above example the token would have been saved as `$WELLKNOWN/m4g1C-t0k3n`. If you only have one docroot on your server you could easily do something like `WELLKNOWN=/var/www/.well-known/acme-challenge`, for anything else look at the example below. ## Example Usage If you have more than one docroot (or you are using your server as a reverse proxy / load balancer) the simple configuration mentioned above wouldn't work, but with just a few lines of webserver configuration this can be solved. An example would be to create a directory `/var/www/dehydrated` and set `WELLKNOWN=/var/www/dehydrated` in the scripts config. You'll need to configure aliases on your Webserver: ### Nginx example config With Nginx you'll need to add this to any of your `server`/VHost config blocks: ```nginx server { [...] location ^~ /.well-known/acme-challenge { alias /var/www/dehydrated; } [...] } ``` ### Apache example config With Apache just add this to your config and it should work in any VHost: ```apache Alias /.well-known/acme-challenge /var/www/dehydrated Options None AllowOverride None # Apache 2.x Order allow,deny Allow from all # Apache 2.4 Require all granted ``` ### Lighttpd example config With Lighttpd just add this to your config and it should work in any VHost: ```lighttpd server.modules += ("alias") alias.url += ( "/.well-known/acme-challenge/" => "/var/www/dehydrated/", ) ``` ### Hiawatha example config With Hiawatha just add an alias to your config file for each VirtualHost and it should work: ```hiawatha VirtualHost { Hostname = example.tld subdomain.mywebsite.tld Alias = /.well-known/acme-challenge:/var/www/dehydrated } ``` dehydrated-0.7.1/CHANGELOG0000644000175000017500000001474114327753726014737 0ustar lukas2511lukas2511# Change Log This file contains a log of major changes in dehydrated ## [0.7.1] - 2022-10-31 ## Changed - `--force` no longer forces domain name revalidation by default, a new argument `--force-validation` has been added for that - Added support for EC secp521r1 algorithm (works with e.g. zerossl) - `EC PARAMETERS` are no longer written to privkey.pem (didn't seem necessary and was causing issues with various software) ## Fixed - Requests resulting in `badNonce` errors are now automatically retried (fixes operation with LE staging servers) - Deprecated `egrep` usage has been removed ## Added - Implemented EC for account keys - Domain list now also read from domains.txt.d subdirectory (behaviour might change, see docs) - Implemented RFC 8738 (validating/signing certificates for IP addresses instead of domain names) support (this will not work with most public CAs, if any!) ## [0.7.0] - 2020-12-10 ## Added - Support for external account bindings - Special support for ZeroSSL - Support presets for some CAs instead of requiring URLs - Allow requesting preferred chain (`--preferred-chain`) - Added method to show CAs current terms of service (`--display-terms`) - Allow setting path to domains.txt using cli arguments (`--domains-txt`) - Added new cli command `--cleanupdelete` which deletes old files instead of archiving them ## Fixed - No more silent failures on broken hook-scripts - Better error-handling with KEEP_GOING enabled - Check actual order status instead of assuming it's valid - Don't include keyAuthorization in challenge validation (RFC compliance) ## Changed - Using EC secp384r1 as default certificate type - Use JSON.sh to parse JSON - Use account URL instead of account ID (RFC compliance) - Dehydrated now has a new home: https://github.com/dehydrated-io/dehydrated - Added `OCSP_FETCH` and `OCSP_DAYS` to per-certificate configurable options - Cleanup now also removes dangling symlinks ## [0.6.5] - 2019-06-26 ## Fixed - Fixed broken APIv1 compatibility from last update ## [0.6.4] - 2019-06-25 ## Changed - Fetch account ID from Location header instead of account json ## [0.6.3] - 2019-06-25 ## Changed - OCSP refresh interval is now configurable - Implemented POST-as-GET - Call exit_hook on errors (with error-message as first parameter) ## Added - Initial support for tls-alpn-01 validation - New hook: sync_cert (for syncing certificate files to disk, see example hook description) ## Fixes - Fetch account information after registration to avoid missing account id ## [0.6.2] - 2018-04-25 ## Added - New deploy_ocsp hook - Allow account registration with custom key ## Changed - Don't walk certificate chain for ACMEv2 (certificate contains chain by default) - Improved documentation on wildcards ## Fixes - Added workaround for compatibility with filesystem ACLs - Close unwanted external file-descriptors - Fixed JSON parsing on force-renewal - Fixed cleanup of challenge files/dns-entries on validation errors - A few more minor fixes ## [0.6.1] - 2018-03-13 ## Changed - Use new ACME v2 endpoint by default ## [0.6.0] - 2018-03-11 ## Changed - Challenge validation loop has been modified to loop over authorization identifiers instead of altnames (ACMEv2 + wildcard support) - Removed LICENSE parameter from config (terms of service is now acquired directly from the CA directory) ## Added - Support for ACME v02 (including wildcard certificates!) - New hook: generate_csr (see example hook script for more information) - Calling random hook on startup to make it clear to hook script authors that unknown hooks should just be ignored... ## [0.5.0] - 2018-01-13 ## Changed - Certificate chain is now cached (CHAINCACHE) - OpenSSL binary path is now configurable (OPENSSL) - Cleanup now also moves revoked certificates ## Added - New feature for updating contact information (--account) - Allow automatic cleanup on exit (AUTO_CLEANUP) - Initial support for fetching OCSP status to be used for OCSP stapling (OCSP_FETCH) - Certificates can now have aliases to create multiple certificates with identical set of domains (see --alias and domains.txt documentation) - Allow dehydrated to run as specified user (/group) ## [0.4.0] - 2017-02-05 ## Changed - dehydrated now asks you to read and accept the CAs terms of service before creating an account - Skip challenges for already validated domains - Removed need for some special commands (BusyBox compatibility) - Exported a few more variables for use in hook-scripts - fullchain.pem now actually contains the full chain instead of just the certificate with an intermediate cert ## Added - Added private-key rollover functionality - Added `--lock-suffix` option for allowing parallel execution - Added `invalid_challenge` hook - Added `request_failure` hook - Added `exit_hook` hook - Added standalone `register` command ## [0.3.1] - 2016-09-13 ## Changed - Renamed project to `dehydrated`. - Default WELLKNOWN location is now `/var/www/dehydrated` - Config location is renamed to `dehydrated` (e.g. `/etc/dehydrated`) ## [0.3.0] - 2016-09-07 ## Changed - Config is now named `config` instead of `config.sh`! - Location of domains.txt is now configurable via DOMAINS_TXT config variable - Location of certs directory is now configurable via CERTDIR config variable - signcsr command now also outputs chain certificate if --full-chain/-fc is set - Location of account-key(s) changed - Default WELLKNOWN location is now `/var/www/letsencrypt` - New version of Let's Encrypt Subscriber Agreement ## Added - Added option to add CSR-flag indicating OCSP stapling to be mandatory - Initial support for configuration on per-certificate base - Support for per-CA account keys and custom config for output cert directory, license, etc. - Added option to select IP version of name to address resolution - Added option to run letsencrypt.sh without locks ## Fixed - letsencrypt.sh no longer stores account keys from invalid registrations ## [0.2.0] - 2016-05-22 ### Changed - PRIVATE_KEY config parameter has been renamed to ACCOUNT_KEY to avoid confusion with certificate keys - deploy_cert hook now also has the certificates timestamp as standalone parameter - Temporary files are now identifiable (template: letsencrypt.sh-XXXXXX) - Private keys are now regenerated by default ### Added - Added documentation to repository ### Fixed - Fixed bug with uppercase names in domains.txt (script now converts everything to lowercase) - mktemp no longer uses the deprecated `-t` parameter. - Compatibility with "pretty" json ## [0.1.0] - 2016-03-25 ### Changed - This is the first numbered version of letsencrypt.sh dehydrated-0.7.1/README.md0000644000175000017500000001254114327753726015000 0ustar lukas2511lukas2511# dehydrated [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=23P9DSJBTY7C8) ![](docs/logo.png) Dehydrated is a client for signing certificates with an ACME-server (e.g. Let's Encrypt) implemented as a relatively simple (zsh-compatible) bash-script. This client supports both ACME v1 and the new ACME v2 including support for wildcard certificates! It uses the `openssl` utility for everything related to actually handling keys and certificates, so you need to have that installed. Other dependencies are: cURL, sed, grep, awk, mktemp (all found pre-installed on almost any system, cURL being the only exception). Current features: - Signing of a list of domains (including wildcard domains!) - Signing of a custom CSR (either standalone or completely automated using hooks!) - Renewal if a certificate is about to expire or defined set of domains changed - Certificate revocation - and lots more.. Please keep in mind that this software, the ACME-protocol and all supported CA servers out there are relatively young and there might be a few issues. Feel free to report any issues you find with this script or contribute by submitting a pull request, but please check for duplicates first (feel free to comment on those to get things rolling). ## Getting started For getting started I recommend taking a look at [docs/domains_txt.md](docs/domains_txt.md), [docs/wellknown.md](docs/wellknown.md) and the [Usage](#usage) section on this page (you'll probably only need the `-c` option). Generally you want to set up your WELLKNOWN path first, and then fill in domains.txt. **Please note that you should use the staging URL when experimenting with this script to not hit Let's Encrypt's rate limits.** See [docs/staging.md](docs/staging.md). If you have any problems take a look at our [Troubleshooting](docs/troubleshooting.md) guide. ## Config dehydrated is looking for a config file in a few different places, it will use the first one it can find in this order: - `/etc/dehydrated/config` - `/usr/local/etc/dehydrated/config` - The current working directory of your shell - The directory from which dehydrated was run Have a look at [docs/examples/config](docs/examples/config) to get started, copy it to e.g. `/etc/dehydrated/config` and edit it to fit your needs. ## Usage: ```text Usage: ./dehydrated [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ... Default command: help Commands: --version (-v) Print version information --display-terms Display current terms of service --register Register account key --account Update account contact information --cron (-c) Sign/renew non-existent/changed/expiring certificates. --signcsr (-s) path/to/csr.pem Sign a given CSR, output CRT on stdout (advanced usage) --revoke (-r) path/to/cert.pem Revoke specified certificate --deactivate Deactivate account --cleanup (-gc) Move unused certificate files to archive directory --cleanup-delete (-gcd) Deletes (!) unused certificate files --help (-h) Show help text --env (-e) Output configuration variables for use in other scripts Parameters: --accept-terms Accept CAs terms of service --full-chain (-fc) Print full chain when using --signcsr --ipv4 (-4) Resolve names to IPv4 addresses only --ipv6 (-6) Resolve names to IPv6 addresses only --domain (-d) domain.tld Use specified domain name(s) instead of domains.txt entry (one certificate!) --ca url/preset Use specified CA URL or preset --alias certalias Use specified name for certificate directory (and per-certificate config) instead of the primary domain (only used if --domain is specified) --keep-going (-g) Keep going after encountering an error while creating/renewing multiple certificates in cron mode --force (-x) Force renew of certificate even if it is longer valid than value in RENEW_DAYS --force-validation Force revalidation of domain names (used in combination with --force) --no-lock (-n) Don't use lockfile (potentially dangerous!) --lock-suffix example.com Suffix lockfile name with a string (useful for with -d) --ocsp Sets option in CSR indicating OCSP stapling to be mandatory --privkey (-p) path/to/key.pem Use specified private key instead of account key (useful for revocation) --domains-txt path/to/domains.txt Use specified domains.txt instead of default/configured one --config (-f) path/to/config Use specified config file --hook (-k) path/to/hook.sh Use specified script for hooks --preferred-chain issuer-cn Use alternative certificate chain identified by issuer CN --out (-o) certs/directory Output certificates into the specified directory --alpn alpn-certs/directory Output alpn verification certificates into the specified directory --challenge (-t) http-01|dns-01|tls-alpn-01 Which challenge should be used? Currently http-01, dns-01, and tls-alpn-01 are supported --algo (-a) rsa|prime256v1|secp384r1 Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 ``` dehydrated-0.7.1/.gitignore0000644000175000017500000000014214327753726015503 0ustar lukas2511lukas2511private_key.pem private_key.json domains.txt config hook.sh certs/* archive/* accounts/* chains/* dehydrated-0.7.1/.github/0000755000175000017500000000000014327753726015056 5ustar lukas2511lukas2511dehydrated-0.7.1/.github/FUNDING.yml0000644000175000017500000000016214327753726016672 0ustar lukas2511lukas2511github: lukas2511 custom: ["https://paypal.me/lukas2511", "http://www.amazon.de/registry/wishlist/1TUCFJK35IO4Q"] dehydrated-0.7.1/LICENSE0000644000175000017500000000207514327753726014527 0ustar lukas2511lukas2511The MIT License (MIT) Copyright (c) 2015-2021 Lukas Schauer 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. dehydrated-0.7.1/dehydrated0000755000175000017500000025560714327753726015600 0ustar lukas2511lukas2511#!/usr/bin/env bash # dehydrated by lukas2511 # Source: https://dehydrated.io # # This script is licensed under The MIT License (see LICENSE for more information). set -e set -u set -o pipefail [[ -n "${ZSH_VERSION:-}" ]] && set -o SH_WORD_SPLIT && set +o FUNCTION_ARGZERO && set -o NULL_GLOB && set -o noglob [[ -z "${ZSH_VERSION:-}" ]] && shopt -s nullglob && set -f umask 077 # paranoid umask, we're creating private keys # Close weird external file descriptors exec 3>&- exec 4>&- VERSION="0.7.1" # Find directory in which this script is stored by traversing all symbolic links SOURCE="${0}" while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" SOURCE="$(readlink "$SOURCE")" [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located done SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" BASEDIR="${SCRIPTDIR}" ORIGARGS=("${@}") noglob_set() { if [[ -n "${ZSH_VERSION:-}" ]]; then set +o noglob else set +f fi } noglob_clear() { if [[ -n "${ZSH_VERSION:-}" ]]; then set -o noglob else set -f fi } # Generate json.sh path matching string json_path() { if [ ! "${1}" = "-p" ]; then printf '"%s"' "${1}" else printf '%s' "${2}" fi } # Get string value from json dictionary get_json_string_value() { local filter filter="$(printf 's/.*\[%s\][[:space:]]*"\([^"]*\)"/\\1/p' "$(json_path "${1:-}" "${2:-}")")" sed -n "${filter}" } # Get array values from json dictionary get_json_array_values() { grep -E '^\['"$(json_path "${1:-}" "${2:-}")"',[0-9]*\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' } # Get sub-dictionary from json get_json_dict_value() { local filter filter="$(printf 's/.*\[%s\][[:space:]]*\(.*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" sed -n "${filter}" | jsonsh } # Get integer value from json get_json_int_value() { local filter filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" sed -n "${filter}" } # Get boolean value from json get_json_bool_value() { local filter filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" sed -n "${filter}" } # JSON.sh JSON-parser # Modified from https://github.com/dominictarr/JSON.sh # Original Copyright (c) 2011 Dominic Tarr # Licensed under The MIT License jsonsh() { throw() { echo "$*" >&2 exit 1 } awk_egrep () { local pattern_string=$1 awk '{ while ($0) { start=match($0, pattern); token=substr($0, start, RLENGTH); print token; $0=substr($0, start+RLENGTH); } }' pattern="$pattern_string" } tokenize () { local GREP local ESCAPE local CHAR if echo "test string" | grep -Eao --color=never "test" >/dev/null 2>&1 then GREP='grep -Eao --color=never' else GREP='grep -Eao' fi # shellcheck disable=SC2196 if echo "test string" | grep -Eao "test" >/dev/null 2>&1 then ESCAPE='(\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' CHAR='[^[:cntrl:]"\\]' else GREP=awk_egrep ESCAPE='(\\\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' CHAR='[^[:cntrl:]"\\\\]' fi local STRING="\"$CHAR*($ESCAPE$CHAR*)*\"" local NUMBER='-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?' local KEYWORD='null|false|true' local SPACE='[[:space:]]+' # Force zsh to expand $A into multiple words local is_wordsplit_disabled is_wordsplit_disabled="$(unsetopt 2>/dev/null | grep -c '^shwordsplit$')" if [ "${is_wordsplit_disabled}" != "0" ]; then setopt shwordsplit; fi $GREP "$STRING|$NUMBER|$KEYWORD|$SPACE|." | grep -Ev "^$SPACE$" if [ "${is_wordsplit_disabled}" != "0" ]; then unsetopt shwordsplit; fi } parse_array () { local index=0 local ary='' read -r token case "$token" in ']') ;; *) while : do parse_value "$1" "$index" index=$((index+1)) ary="$ary""$value" read -r token case "$token" in ']') break ;; ',') ary="$ary," ;; *) throw "EXPECTED , or ] GOT ${token:-EOF}" ;; esac read -r token done ;; esac value=$(printf '[%s]' "$ary") || value= : } parse_object () { local key local obj='' read -r token case "$token" in '}') ;; *) while : do case "$token" in '"'*'"') key=$token ;; *) throw "EXPECTED string GOT ${token:-EOF}" ;; esac read -r token case "$token" in ':') ;; *) throw "EXPECTED : GOT ${token:-EOF}" ;; esac read -r token parse_value "$1" "$key" obj="$obj$key:$value" read -r token case "$token" in '}') break ;; ',') obj="$obj," ;; *) throw "EXPECTED , or } GOT ${token:-EOF}" ;; esac read -r token done ;; esac value=$(printf '{%s}' "$obj") || value= : } parse_value () { local jpath="${1:+$1,}${2:-}" case "$token" in '{') parse_object "$jpath" ;; '[') parse_array "$jpath" ;; # At this point, the only valid single-character tokens are digits. ''|[!0-9]) throw "EXPECTED value GOT ${token:-EOF}" ;; *) value="${token/\\\///}" # replace solidus ("\/") in json strings with normalized value: "/" ;; esac [ "$value" = '' ] && return [ -z "$jpath" ] && return # do not print head printf "[%s]\t%s\n" "$jpath" "$value" : } parse () { read -r token parse_value read -r token || true case "$token" in '') ;; *) throw "EXPECTED EOF GOT $token" ;; esac } tokenize | parse } # Convert IP addresses to their reverse dns variants. # Used for ALPN certs as validation for IPs uses this in SNI since IPs aren't allowed there. ip_to_ptr() { ip="$(cat)" if [[ "${ip}" =~ : ]]; then printf "%sip6.arpa" "$(printf "%s" "${ip}" | awk -F: 'BEGIN {OFS=""; }{addCount = 9 - NF; for(i=1; i<=NF;i++){if(length($i) == 0){ for(j=1;j<=addCount;j++){$i = ($i "0000");} } else { $i = substr(("0000" $i), length($i)+5-4);}}; print}' | rev | sed -e "s/./&./g")" else printf "%s.in-addr.arpa" "$(printf "%s" "${ip}" | awk -F. '{print $4"."$3"." $2"."$1}')" fi } # Create (identifiable) temporary files _mktemp() { mktemp "${TMPDIR:-/tmp}/dehydrated-XXXXXX" } # Check for script dependencies check_dependencies() { # look for required binaries for binary in grep mktemp diff sed awk curl cut head tail hexdump; do bin_path="$(command -v "${binary}" 2>/dev/null)" || _exiterr "This script requires ${binary}." [[ -x "${bin_path}" ]] || _exiterr "${binary} found in PATH but it's not executable" done # just execute some dummy and/or version commands to see if required tools are actually usable "${OPENSSL}" version > /dev/null 2>&1 || _exiterr "This script requires an openssl binary." _sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requires sed with support for extended (modern) regular expressions." # curl returns with an error code in some ancient versions so we have to catch that set +e CURL_VERSION="$(curl -V 2>&1 | head -n1 | awk '{print $2}')" set -e } store_configvars() { __KEY_ALGO="${KEY_ALGO}" __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" __OCSP_FETCH="${OCSP_FETCH}" __OCSP_DAYS="${OCSP_DAYS}" __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}" __PRIVATE_KEY_ROLLOVER="${PRIVATE_KEY_ROLLOVER}" __KEYSIZE="${KEYSIZE}" __CHALLENGETYPE="${CHALLENGETYPE}" __HOOK="${HOOK}" __PREFERRED_CHAIN="${PREFERRED_CHAIN}" __WELLKNOWN="${WELLKNOWN}" __HOOK_CHAIN="${HOOK_CHAIN}" __OPENSSL_CNF="${OPENSSL_CNF}" __RENEW_DAYS="${RENEW_DAYS}" __IP_VERSION="${IP_VERSION}" } reset_configvars() { KEY_ALGO="${__KEY_ALGO}" OCSP_MUST_STAPLE="${__OCSP_MUST_STAPLE}" OCSP_FETCH="${__OCSP_FETCH}" OCSP_DAYS="${__OCSP_DAYS}" PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}" PRIVATE_KEY_ROLLOVER="${__PRIVATE_KEY_ROLLOVER}" KEYSIZE="${__KEYSIZE}" CHALLENGETYPE="${__CHALLENGETYPE}" HOOK="${__HOOK}" PREFERRED_CHAIN="${__PREFERRED_CHAIN}" WELLKNOWN="${__WELLKNOWN}" HOOK_CHAIN="${__HOOK_CHAIN}" OPENSSL_CNF="${__OPENSSL_CNF}" RENEW_DAYS="${__RENEW_DAYS}" IP_VERSION="${__IP_VERSION}" } hookscript_bricker_hook() { # Hook scripts should ignore any hooks they don't know. # Calling a random hook to make this clear to the hook script authors... if [[ -n "${HOOK}" ]]; then "${HOOK}" "this_hookscript_is_broken__dehydrated_is_working_fine__please_ignore_unknown_hooks_in_your_script" || _exiterr "Please check your hook script, it should exit cleanly without doing anything on unknown/new hooks." fi } # verify configuration values verify_config() { [[ "${CHALLENGETYPE}" == "http-01" || "${CHALLENGETYPE}" == "dns-01" || "${CHALLENGETYPE}" == "tls-alpn-01" ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... cannot continue." if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then _exiterr "Challenge type dns-01 needs a hook script for deployment... cannot continue." fi if [[ "${CHALLENGETYPE}" = "http-01" && ! -d "${WELLKNOWN}" && ! "${COMMAND:-}" = "register" ]]; then _exiterr "WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions." fi [[ "${KEY_ALGO}" == "rsa" || "${KEY_ALGO}" == "prime256v1" || "${KEY_ALGO}" == "secp384r1" || "${KEY_ALGO}" == "secp521r1" ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... cannot continue." if [[ -n "${IP_VERSION}" ]]; then [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... cannot continue." fi [[ "${API}" == "auto" || "${API}" == "1" || "${API}" == "2" ]] || _exiterr "Unsupported API version defined in config: ${API}" [[ "${OCSP_DAYS}" =~ ^[0-9]+$ ]] || _exiterr "OCSP_DAYS must be a number" } # Setup default config values, search for and load configuration files load_config() { # Check for config in various locations if [[ -z "${CONFIG:-}" ]]; then for check_config in "/etc/dehydrated" "/usr/local/etc/dehydrated" "${PWD}" "${SCRIPTDIR}"; do if [[ -f "${check_config}/config" ]]; then BASEDIR="${check_config}" CONFIG="${check_config}/config" break fi done fi # Preset CA_ZEROSSL="https://acme.zerossl.com/v2/DV90" CA_LETSENCRYPT="https://acme-v02.api.letsencrypt.org/directory" CA_LETSENCRYPT_TEST="https://acme-staging-v02.api.letsencrypt.org/directory" CA_BUYPASS="https://api.buypass.com/acme/directory" CA_BUYPASS_TEST="https://api.test4.buypass.no/acme/directory" # Default values CA="letsencrypt" OLDCA= CERTDIR= ALPNCERTDIR= ACCOUNTDIR= ACCOUNT_KEYSIZE="4096" ACCOUNT_KEY_ALGO=rsa CHALLENGETYPE="http-01" CONFIG_D= CURL_OPTS= DOMAINS_D= DOMAINS_TXT= HOOK= PREFERRED_CHAIN= HOOK_CHAIN="no" RENEW_DAYS="30" KEYSIZE="4096" WELLKNOWN= PRIVATE_KEY_RENEW="yes" PRIVATE_KEY_ROLLOVER="no" KEY_ALGO=secp384r1 OPENSSL=openssl OPENSSL_CNF= CONTACT_EMAIL= LOCKFILE= OCSP_MUST_STAPLE="no" OCSP_FETCH="no" OCSP_DAYS=5 IP_VERSION= CHAINCACHE= AUTO_CLEANUP="no" DEHYDRATED_USER= DEHYDRATED_GROUP= API="auto" if [[ -z "${CONFIG:-}" ]]; then echo "#" >&2 echo "# !! WARNING !! No main config file found, using default config!" >&2 echo "#" >&2 elif [[ -f "${CONFIG}" ]]; then echo "# INFO: Using main config file ${CONFIG}" BASEDIR="$(dirname "${CONFIG}")" # shellcheck disable=SC1090 . "${CONFIG}" else _exiterr "Specified config file doesn't exist." fi if [[ -n "${CONFIG_D}" ]]; then if [[ ! -d "${CONFIG_D}" ]]; then _exiterr "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory." fi # Allow globbing noglob_set for check_config_d in "${CONFIG_D}"/*.sh; do if [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then echo "# INFO: Using additional config file ${check_config_d}" # shellcheck disable=SC1090 . "${check_config_d}" else _exiterr "Specified additional config ${check_config_d} is not readable or not a file at all." fi done # Disable globbing noglob_clear fi # Check for missing dependencies check_dependencies has_sudo() { command -v sudo > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but sudo not available. Please install sudo." } # Check if we are running & are allowed to run as root if [[ -n "$DEHYDRATED_USER" ]]; then command -v getent > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but getent not available. Please install getent." TARGET_UID="$(getent passwd "${DEHYDRATED_USER}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_USER ${DEHYDRATED_USER} is invalid" if [[ -z "${DEHYDRATED_GROUP}" ]]; then if [[ "${EUID}" != "${TARGET_UID}" ]]; then echo "# INFO: Running $0 as ${DEHYDRATED_USER}" has_sudo && exec sudo -u "${DEHYDRATED_USER}" "${0}" "${ORIGARGS[@]}" fi else TARGET_GID="$(getent group "${DEHYDRATED_GROUP}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_GROUP ${DEHYDRATED_GROUP} is invalid" if [[ -z "${EGID:-}" ]]; then command -v id > /dev/null 2>&1 || _exiterr "DEHYDRATED_GROUP set, don't know current gid and 'id' not available... Please provide 'id' binary." EGID="$(id -g)" fi if [[ "${EUID}" != "${TARGET_UID}" ]] || [[ "${EGID}" != "${TARGET_GID}" ]]; then echo "# INFO: Running $0 as ${DEHYDRATED_USER}/${DEHYDRATED_GROUP}" has_sudo && exec sudo -u "${DEHYDRATED_USER}" -g "${DEHYDRATED_GROUP}" "${0}" "${ORIGARGS[@]}" fi fi elif [[ -n "${DEHYDRATED_GROUP}" ]]; then _exiterr "DEHYDRATED_GROUP can only be used in combination with DEHYDRATED_USER." fi # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. [[ "$BASEDIR" != "/" ]] && BASEDIR="${BASEDIR%%/}" # Check BASEDIR and set default variables [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}" # Check for ca cli parameter if [ -n "${PARAM_CA:-}" ]; then CA="${PARAM_CA}" fi # Preset CAs if [ "${CA}" = "letsencrypt" ]; then CA="${CA_LETSENCRYPT}" elif [ "${CA}" = "letsencrypt-test" ]; then CA="${CA_LETSENCRYPT_TEST}" elif [ "${CA}" = "zerossl" ]; then CA="${CA_ZEROSSL}" elif [ "${CA}" = "buypass" ]; then CA="${CA_BUYPASS}" elif [ "${CA}" = "buypass-test" ]; then CA="${CA_BUYPASS_TEST}" fi if [[ -z "${OLDCA}" ]] && [[ "${CA}" = "https://acme-v02.api.letsencrypt.org/directory" ]]; then OLDCA="https://acme-v01.api.letsencrypt.org/directory" fi # Create new account directory or symlink to account directory from old CA # dev note: keep in mind that because of the use of 'echo' instead of 'printf' or # similar there is a newline encoded in the directory name. not going to fix this # since it's a non-issue and trying to fix existing installations would be too much # trouble CAHASH="$(echo "${CA}" | urlbase64)" [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts" if [[ ! -e "${ACCOUNTDIR}/${CAHASH}" ]]; then OLDCAHASH="$(echo "${OLDCA}" | urlbase64)" mkdir -p "${ACCOUNTDIR}" if [[ -n "${OLDCA}" ]] && [[ -e "${ACCOUNTDIR}/${OLDCAHASH}" ]]; then echo "! Reusing account from ${OLDCA}" ln -s "${OLDCAHASH}" "${ACCOUNTDIR}/${CAHASH}" else mkdir "${ACCOUNTDIR}/${CAHASH}" fi fi # shellcheck disable=SC1090 [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config" ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem" ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json" ACCOUNT_ID_JSON="${ACCOUNTDIR}/${CAHASH}/account_id.json" ACCOUNT_DEACTIVATED="${ACCOUNTDIR}/${CAHASH}/deactivated" if [[ -f "${ACCOUNT_DEACTIVATED}" ]]; then _exiterr "Account has been deactivated. Remove account and create a new one using --register." fi if [[ -f "${BASEDIR}/private_key.pem" ]] && [[ ! -f "${ACCOUNT_KEY}" ]]; then echo "! Moving private_key.pem to ${ACCOUNT_KEY}" mv "${BASEDIR}/private_key.pem" "${ACCOUNT_KEY}" fi if [[ -f "${BASEDIR}/private_key.json" ]] && [[ ! -f "${ACCOUNT_KEY_JSON}" ]]; then echo "! Moving private_key.json to ${ACCOUNT_KEY_JSON}" mv "${BASEDIR}/private_key.json" "${ACCOUNT_KEY_JSON}" fi [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs" [[ -z "${ALPNCERTDIR}" ]] && ALPNCERTDIR="${BASEDIR}/alpn-certs" [[ -z "${CHAINCACHE}" ]] && CHAINCACHE="${BASEDIR}/chains" [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated" [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock" [[ -z "${OPENSSL_CNF}" ]] && OPENSSL_CNF="$("${OPENSSL}" version -d | cut -d\" -f2)/openssl.cnf" [[ -n "${PARAM_LOCKFILE_SUFFIX:-}" ]] && LOCKFILE="${LOCKFILE}-${PARAM_LOCKFILE_SUFFIX}" [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE="" [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" [[ -n "${PARAM_DOMAINS_TXT:-}" ]] && DOMAINS_TXT="${PARAM_DOMAINS_TXT}" [[ -n "${PARAM_PREFERRED_CHAIN:-}" ]] && PREFERRED_CHAIN="${PARAM_PREFERRED_CHAIN}" [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" [[ -n "${PARAM_ALPNCERTDIR:-}" ]] && ALPNCERTDIR="${PARAM_ALPNCERTDIR}" [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}" [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}" [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}" [[ -n "${PARAM_IP_VERSION:-}" ]] && IP_VERSION="${PARAM_IP_VERSION}" if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ] && [ "${PARAM_FORCE:-no}" = "no" ]; then _exiterr "Argument --force-validation can only be used in combination with --force (-x)" fi if [ ! "${1:-}" = "noverify" ]; then verify_config fi store_configvars } # Initialize system init_system() { load_config # Lockfile handling (prevents concurrent access) if [[ -n "${LOCKFILE}" ]]; then LOCKDIR="$(dirname "${LOCKFILE}")" [[ -w "${LOCKDIR}" ]] || _exiterr "Directory ${LOCKDIR} for LOCKFILE ${LOCKFILE} is not writable, aborting." ( set -C; date > "${LOCKFILE}" ) 2>/dev/null || _exiterr "Lock file '${LOCKFILE}' present, aborting." remove_lock() { rm -f "${LOCKFILE}"; } trap 'remove_lock' EXIT fi # Get CA URLs CA_DIRECTORY="$(http_request get "${CA}" | jsonsh)" # Automatic discovery of API version if [[ "${API}" = "auto" ]]; then grep -q newOrder <<< "${CA_DIRECTORY}" && API=2 || API=1 fi # shellcheck disable=SC2015 if [[ "${API}" = "1" ]]; then CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" && CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" && CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" && CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value terms-of-service)" && CA_REQUIRES_EAB="false" && CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" || _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." # Since reg URI is missing from directory we will assume it is the same as CA_NEW_REG without the new part CA_REG=${CA_NEW_REG/new-reg/reg} else CA_NEW_ORDER="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newOrder)" && CA_NEW_NONCE="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newNonce)" && CA_NEW_ACCOUNT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newAccount)" && CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value -p '"meta","termsOfService"')" && CA_REQUIRES_EAB="$(printf "%s" "${CA_DIRECTORY}" | get_json_bool_value -p '"meta","externalAccountRequired"' || echo false)" && CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revokeCert)" || _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." fi # Export some environment variables to be used in hook script export WELLKNOWN BASEDIR CERTDIR ALPNCERTDIR CONFIG COMMAND # Checking for private key ... register_new_key="no" generated="false" if [[ -n "${PARAM_ACCOUNT_KEY:-}" ]]; then # a private key was specified from the command line so use it for this run echo "Using private key ${PARAM_ACCOUNT_KEY} instead of account key" ACCOUNT_KEY="${PARAM_ACCOUNT_KEY}" ACCOUNT_KEY_JSON="${PARAM_ACCOUNT_KEY}.json" ACCOUNT_ID_JSON="${PARAM_ACCOUNT_KEY}_id.json" [ "${COMMAND:-}" = "register" ] && register_new_key="yes" else # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) if [[ ! -e "${ACCOUNT_KEY}" ]]; then if [[ ! "${PARAM_ACCEPT_TERMS:-}" = "yes" ]]; then printf '\n' >&2 printf 'To use dehydrated with this certificate authority you have to agree to their terms of service which you can find here: %s\n\n' "${CA_TERMS}" >&2 printf 'To accept these terms of service run "%s --register --accept-terms".\n' "${0}" >&2 exit 1 fi echo "+ Generating account key..." generated="true" local tmp_account_key tmp_account_key="$(_mktemp)" if [[ ${API} -eq 1 && ! "${ACCOUNT_KEY_ALGO}" = "rsa" ]]; then _exiterr "ACME API version 1 does not support EC account keys" fi case "${ACCOUNT_KEY_ALGO}" in rsa) _openssl genrsa -out "${tmp_account_key}" "${ACCOUNT_KEYSIZE}";; prime256v1|secp384r1|secp521r1) _openssl ecparam -genkey -name "${ACCOUNT_KEY_ALGO}" -out "${tmp_account_key}" -noout;; esac cat "${tmp_account_key}" > "${ACCOUNT_KEY}" rm "${tmp_account_key}" register_new_key="yes" fi fi if ("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then # Get public components from private key and calculate thumbprint pubExponent64="$(printf '%x' "$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -text | awk '/publicExponent/ {print $2}')" | hex2bin | urlbase64)" pubMod64="$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)" account_key_info="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}")" account_key_sigalgo=RS256 elif ("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then curve="$("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -noout -text 2>/dev/null | grep 'NIST CURVE' | cut -d':' -f2 | tr -d ' ')" pubkey="$("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -noout -text 2>/dev/null | tr -d '\n ' | grep -Eo 'pub:.*ASN1' | _sed -e 's/^pub://' -e 's/ASN1$//' | tr -d ':')" if [ "${curve}" = "P-256" ]; then account_key_sigalgo="ES256" elif [ "${curve}" = "P-384" ]; then account_key_sigalgo="ES384" elif [ "${curve}" = "P-521" ]; then account_key_sigalgo="ES512" else _exiterr "Unknown account key curve: ${curve}" fi ec_x_offset=2 ec_x_len=$((${#pubkey}/2 - 1)) ec_x="${pubkey:$ec_x_offset:$ec_x_len}" ec_x64="$(printf "%s" "${ec_x}" | hex2bin | urlbase64)" ec_y_offset=$((ec_x_offset+ec_x_len)) ec_y_len=$((${#pubkey}-ec_y_offset)) ec_y="${pubkey:$ec_y_offset:$ec_y_len}" ec_y64="$(printf "%s" "${ec_y}" | hex2bin | urlbase64)" account_key_info="$(printf '{"crv":"%s","kty":"EC","x":"%s","y":"%s"}' "${curve}" "${ec_x64}" "${ec_y64}")" else _exiterr "Account key is not valid, cannot continue." fi thumbprint="$(printf '%s' "${account_key_info}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" # If we generated a new private key in the step above we have to register it with the acme-server if [[ "${register_new_key}" = "yes" ]]; then echo "+ Registering account key with ACME server..." FAILED=false if [[ ${API} -eq 1 && -z "${CA_NEW_REG}" ]] || [[ ${API} -eq 2 && -z "${CA_NEW_ACCOUNT}" ]]; then echo "Certificate authority doesn't allow registrations." FAILED=true fi # ZeroSSL special sauce if [[ "${CA}" = "${CA_ZEROSSL}" ]]; then if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then if [[ -z "${CONTACT_EMAIL}" ]]; then echo "ZeroSSL requires contact email to be set or EAB_KID/EAB_HMAC_KEY to be manually configured" FAILED=true else zeroapi="$(curl -s "https://api.zerossl.com/acme/eab-credentials-email" -d "email=${CONTACT_EMAIL}" | jsonsh)" EAB_KID="$(printf "%s" "${zeroapi}" | get_json_string_value eab_kid)" EAB_HMAC_KEY="$(printf "%s" "${zeroapi}" | get_json_string_value eab_hmac_key)" if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then echo "Unknown error retrieving ZeroSSL API credentials" echo "${zeroapi}" FAILED=true fi fi fi fi # Check if external account is required if [[ "${FAILED}" = "false" ]]; then if [[ "${CA_REQUIRES_EAB}" = "true" ]]; then if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then FAILED=true echo "This CA requires an external account but no EAB_KID/EAB_HMAC_KEY has been configured" fi fi fi # If an email for the contact has been provided then adding it to the registration request if [[ "${FAILED}" = "false" ]]; then if [[ ${API} -eq 1 ]]; then if [[ -n "${CONTACT_EMAIL}" ]]; then (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true else (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true fi else if [[ -n "${EAB_KID:-}" ]] && [[ -n "${EAB_HMAC_KEY:-}" ]]; then eab_url="${CA_NEW_ACCOUNT}" eab_protected64="$(printf '{"alg":"HS256","kid":"%s","url":"%s"}' "${EAB_KID}" "${eab_url}" | urlbase64)" eab_payload64="$(printf "%s" "${account_key_info}" | urlbase64)" eab_key="$(printf "%s" "${EAB_HMAC_KEY}" | deurlbase64 | bin2hex)" eab_signed64="$(printf '%s' "${eab_protected64}.${eab_payload64}" | "${OPENSSL}" dgst -binary -sha256 -mac HMAC -macopt "hexkey:${eab_key}" | urlbase64)" if [[ -n "${CONTACT_EMAIL}" ]]; then regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' else regjson='{"termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' fi else if [[ -n "${CONTACT_EMAIL}" ]]; then regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true}' else regjson='{"termsOfServiceAgreed": true}' fi fi (signed_request "${CA_NEW_ACCOUNT}" "${regjson}" > "${ACCOUNT_KEY_JSON}") || FAILED=true fi fi if [[ "${FAILED}" = "true" ]]; then echo >&2 echo >&2 echo "Error registering account key. See message above for more information." >&2 if [[ "${generated}" = "true" ]]; then rm "${ACCOUNT_KEY}" fi rm -f "${ACCOUNT_KEY_JSON}" exit 1 fi elif [[ "${COMMAND:-}" = "register" ]]; then echo "+ Account already registered!" exit 0 fi # Read account information or request from CA if missing if [[ -e "${ACCOUNT_KEY_JSON}" ]]; then if [[ ${API} -eq 1 ]]; then ACCOUNT_ID="$(jsonsh < "${ACCOUNT_KEY_JSON}" | get_json_int_value id)" ACCOUNT_URL="${CA_REG}/${ACCOUNT_ID}" else if [[ -e "${ACCOUNT_ID_JSON}" ]]; then ACCOUNT_URL="$(jsonsh < "${ACCOUNT_ID_JSON}" | get_json_string_value url)" fi # if account URL is not storred, fetch it from the CA if [[ -z "${ACCOUNT_URL:-}" ]]; then echo "+ Fetching account URL..." ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" if [[ -z "${ACCOUNT_URL}" ]]; then _exiterr "Unknown error on fetching account information" fi echo '{"url":"'"${ACCOUNT_URL}"'"}' > "${ACCOUNT_ID_JSON}" # store the URL for next time fi fi else echo "Fetching missing account information from CA..." if [[ ${API} -eq 1 ]]; then _exiterr "This is not implemented for ACMEv1! Consider switching to ACMEv2 :)" else ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" ACCOUNT_INFO="$(signed_request "${ACCOUNT_URL}" '{}')" fi echo "${ACCOUNT_INFO}" > "${ACCOUNT_KEY_JSON}" fi } # Different sed version for different os types... _sed() { if [[ "${OSTYPE}" = "Linux" || "${OSTYPE:0:5}" = "MINGW" ]]; then sed -r "${@}" else sed -E "${@}" fi } # Print error message and exit with error _exiterr() { if [ -n "${1:-}" ]; then echo "ERROR: ${1}" >&2 fi [[ "${skip_exit_hook:-no}" = "no" ]] && [[ -n "${HOOK:-}" ]] && ("${HOOK}" "exit_hook" "${1:-}" || echo 'exit_hook returned with non-zero exit code!' >&2) exit 1 } # Remove newlines and whitespace from json clean_json() { tr -d '\r\n' | _sed -e 's/ +/ /g' -e 's/\{ /{/g' -e 's/ \}/}/g' -e 's/\[ /[/g' -e 's/ \]/]/g' } # Encode data as url-safe formatted base64 urlbase64() { # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' "${OPENSSL}" base64 -e | tr -d '\n\r' | _sed -e 's:=*$::g' -e 'y:+/:-_:' } # Decode data from url-safe formatted base64 deurlbase64() { data="$(cat | tr -d ' \n\r')" modlen=$((${#data} % 4)) padding="" if [[ "${modlen}" = "2" ]]; then padding="=="; elif [[ "${modlen}" = "3" ]]; then padding="="; fi printf "%s%s" "${data}" "${padding}" | tr -d '\n\r' | _sed -e 'y:-_:+/:' | "${OPENSSL}" base64 -d -A } # Convert hex string to binary data hex2bin() { # Remove spaces, add leading zero, escape as hex string and parse with printf # shellcheck disable=SC2059 printf "%b" "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" } # Convert binary data to hex string bin2hex() { hexdump -v -e '/1 "%02x"' } # OpenSSL writes to stderr/stdout even when there are no errors. So just # display the output if the exit code was != 0 to simplify debugging. _openssl() { set +e out="$("${OPENSSL}" "${@}" 2>&1)" res=$? set -e if [[ ${res} -ne 0 ]]; then echo " + ERROR: failed to run $* (Exitcode: ${res})" >&2 echo >&2 echo "Details:" >&2 echo "${out}" >&2 echo >&2 exit "${res}" fi } # Send http(s) request with specified method http_request() { tempcont="$(_mktemp)" tempheaders="$(_mktemp)" if [[ -n "${IP_VERSION:-}" ]]; then ip_version="-${IP_VERSION}" fi set +e # shellcheck disable=SC2086 if [[ "${1}" = "head" ]]; then statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" curlret="${?}" touch "${tempheaders}" elif [[ "${1}" = "get" ]]; then statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -L -s -w "%{http_code}" -o "${tempcont}" -D "${tempheaders}" "${2}")" curlret="${?}" elif [[ "${1}" = "post" ]]; then statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -s -w "%{http_code}" -o "${tempcont}" "${2}" -D "${tempheaders}" -H 'Content-Type: application/jose+json' -d "${3}")" curlret="${?}" else set -e _exiterr "Unknown request method: ${1}" fi set -e if [[ ! "${curlret}" = "0" ]]; then _exiterr "Problem connecting to server (${1} for ${2}; curl returned with ${curlret})" fi if [[ ! "${statuscode:0:1}" = "2" ]]; then # check for existing registration warning if [[ "${API}" = "1" ]] && [[ -n "${CA_NEW_REG:-}" ]] && [[ "${2}" = "${CA_NEW_REG:-}" ]] && [[ "${statuscode}" = "409" ]] && grep -q "Registration key is already in use" "${tempcont}"; then # do nothing : # check for already-revoked warning elif [[ -n "${CA_REVOKE_CERT:-}" ]] && [[ "${2}" = "${CA_REVOKE_CERT:-}" ]] && [[ "${statuscode}" = "409" ]]; then grep -q "Certificate already revoked" "${tempcont}" && return else if grep -q "urn:ietf:params:acme:error:badNonce" "${tempcont}"; then printf "badnonce %s" "$(grep -Eoi "^replay-nonce:.*$" "${tempheaders}" | sed 's/ //' | cut -d: -f2)" return 0 fi echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 echo >&2 echo "Details:" >&2 cat "${tempheaders}" >&2 cat "${tempcont}" >&2 echo >&2 echo >&2 # An exclusive hook for the {1}-request error might be useful (e.g., for sending an e-mail to admins) if [[ -n "${HOOK}" ]]; then errtxt="$(cat "${tempcont}")" errheaders="$(cat "${tempheaders}")" "${HOOK}" "request_failure" "${statuscode}" "${errtxt}" "${1}" "${errheaders}" || _exiterr 'request_failure hook returned with non-zero exit code' fi rm -f "${tempcont}" rm -f "${tempheaders}" # remove temporary domains.txt file if used [[ "${COMMAND:-}" = "sign_domains" && -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}" _exiterr fi fi if { true >&4; } 2>/dev/null; then cat "${tempheaders}" >&4 fi cat "${tempcont}" rm -f "${tempcont}" rm -f "${tempheaders}" } # Send signed request signed_request() { # Encode payload as urlbase64 payload64="$(printf '%s' "${2}" | urlbase64)" if [ -n "${3:-}" ]; then nonce="$(printf "%s" "${3}" | tr -d ' \t\n\r')" else # Retrieve nonce from acme-server if [[ ${API} -eq 1 ]]; then nonce="$(http_request head "${CA}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" else nonce="$(http_request head "${CA_NEW_NONCE}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" fi fi if [[ ${API} -eq 1 ]]; then # Build another header which also contains the previously received nonce and encode it as urlbase64 protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' protected64="$(printf '%s' "${protected}" | urlbase64)" else # Build another header which also contains the previously received nonce and url and encode it as urlbase64 if [[ -n "${ACCOUNT_URL:-}" ]]; then protected='{"alg": "'"${account_key_sigalgo}"'", "kid": "'"${ACCOUNT_URL}"'", "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' else protected='{"alg": "'"${account_key_sigalgo}"'", "jwk": '"${account_key_info}"', "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' fi protected64="$(printf '%s' "${protected}" | urlbase64)" fi # Sign header with nonce and our payload with our private key and encode signature as urlbase64 if [[ "${account_key_sigalgo}" = "RS256" ]]; then signed64="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)" else dgstparams="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha${account_key_sigalgo:2} -sign "${ACCOUNT_KEY}" | "${OPENSSL}" asn1parse -inform DER)" dgst_parm_1="$(echo "$dgstparams" | head -n 2 | tail -n 1 | cut -d':' -f4)" dgst_parm_2="$(echo "$dgstparams" | head -n 3 | tail -n 1 | cut -d':' -f4)" # zero-padding (doesn't seem to be necessary, but other clients are doing this as well... case "${account_key_sigalgo}" in "ES256") siglen=64;; "ES384") siglen=96;; "ES512") siglen=132;; esac while [[ ${#dgst_parm_1} -lt $siglen ]]; do dgst_parm_1="0${dgst_parm_1}"; done while [[ ${#dgst_parm_2} -lt $siglen ]]; do dgst_parm_2="0${dgst_parm_2}"; done signed64="$(printf "%s%s" "${dgst_parm_1}" "${dgst_parm_2}" | hex2bin | urlbase64)" fi if [[ ${API} -eq 1 ]]; then # Build header with just our public key and algorithm information header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' # Send header + extended header + payload + signature to the acme-server data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' else # Send extended header + payload + signature to the acme-server data='{"protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' fi output="$(http_request post "${1}" "${data}")" if grep -qE "^badnonce " <<< "${output}"; then echo " ! Request failed (badNonce), retrying request..." >&2 signed_request "${1:-}" "${2:-}" "$(printf "%s" "${output}" | cut -d' ' -f2)" else printf "%s" "${output}" fi } # Extracts all subject names from a CSR # Outputs either the CN, or the SANs, one per line extract_altnames() { csr="${1}" # the CSR itself (not a file) if ! <<<"${csr}" "${OPENSSL}" req -verify -noout 2>/dev/null; then _exiterr "Certificate signing request isn't valid" fi reqtext="$( <<<"${csr}" "${OPENSSL}" req -noout -text )" if <<<"${reqtext}" grep -q '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$'; then # SANs used, extract these altnames="$( <<<"${reqtext}" awk '/X509v3 Subject Alternative Name:/{print;getline;print;}' | tail -n1 )" # split to one per line: # shellcheck disable=SC1003 altnames="$( <<<"${altnames}" _sed -e 's/^[[:space:]]*//; s/, /\'$'\n''/g' )" # we can only get DNS/IP: ones signed if grep -qEv '^(DNS|IP( Address)*|othername):' <<<"${altnames}"; then _exiterr "Certificate signing request contains non-DNS/IP Subject Alternative Names" fi # strip away the DNS/IP: prefix altnames="$( <<<"${altnames}" _sed -e 's/^(DNS:|IP( Address)*:|othername:)//' )" printf "%s" "${altnames}" | tr '\n' ' ' else # No SANs, extract CN altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.*[ /]CN ?= ?([^ /,]*).*/\1/' )" printf "%s" "${altnames}" fi } # Get last issuer CN in certificate chain get_last_cn() { <<<"${1}" _sed 'H;/-----BEGIN CERTIFICATE-----/h;$!d;x' | "${OPENSSL}" x509 -noout -issuer | head -n1 | _sed -e 's/.*[ /]CN ?= ?([^/,]*).*/\1/' } # Create certificate for domain(s) and outputs it FD 3 sign_csr() { csr="${1}" # the CSR itself (not a file) if { true >&3; } 2>/dev/null; then : # fd 3 looks OK else _exiterr "sign_csr: FD 3 not open" fi shift 1 || true export altnames="${*}" if [[ ${API} -eq 1 ]]; then if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then _exiterr "Certificate authority doesn't allow certificate signing" fi elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then _exiterr "Certificate authority doesn't allow certificate signing" fi if [[ -n "${ZSH_VERSION:-}" ]]; then local -A challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args else local -a challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args fi # Initial step: Find which authorizations we're dealing with if [[ ${API} -eq 2 ]]; then # Request new order and store authorization URIs local challenge_identifiers="" for altname in ${altnames}; do if [[ "${altname}" =~ ^ip: ]]; then challenge_identifiers+="$(printf '{"type": "ip", "value": "%s"}, ' "${altname:3}")" else challenge_identifiers+="$(printf '{"type": "dns", "value": "%s"}, ' "${altname}")" fi done challenge_identifiers="[${challenge_identifiers%, }]" echo " + Requesting new certificate order from CA..." order_location="$(signed_request "${CA_NEW_ORDER}" '{"identifiers": '"${challenge_identifiers}"'}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" result="$(signed_request "${order_location}" "" | jsonsh)" order_authorizations="$(echo "${result}" | get_json_array_values authorizations)" finalize="$(echo "${result}" | get_json_string_value finalize)" local idx=0 for uri in ${order_authorizations}; do authorizations[${idx}]="${uri}" idx=$((idx+1)) done echo " + Received ${idx} authorizations URLs from the CA" else # Copy $altnames to $authorizations (just doing this to reduce duplicate code later on) local idx=0 for altname in ${altnames}; do authorizations[${idx}]="${altname}" idx=$((idx+1)) done fi # Check if authorizations are valid and gather challenge information for pending authorizations local idx=0 for authorization in ${authorizations[*]}; do if [[ "${API}" -eq 2 ]]; then # Receive authorization ($authorization is authz uri) response="$(signed_request "$(echo "${authorization}" | _sed -e 's/\"(.*)".*/\1/')" "" | jsonsh)" identifier="$(echo "${response}" | get_json_string_value -p '"identifier","value"')" identifier_type="$(echo "${response}" | get_json_string_value -p '"identifier","type"')" echo " + Handling authorization for ${identifier}" else # Request new authorization ($authorization is altname) identifier="${authorization}" echo " + Requesting authorization for ${identifier}..." response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${identifier}"'"}}' | jsonsh)" fi # Check if authorization has already been validated if [ "$(echo "${response}" | get_json_string_value status)" = "valid" ]; then if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ]; then echo " + A valid authorization has been found but will be ignored" else echo " + Found valid authorization for ${identifier}" continue fi fi # Find challenge in authorization challengeindex="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\][[:space:]]+"'"${CHALLENGETYPE}"'"' | cut -d',' -f2 || true)" if [ -z "${challengeindex}" ]; then allowed_validations="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' | tr '\n' ' ')" _exiterr "Validating this certificate is not possible using ${CHALLENGETYPE}. Possible validation methods are: ${allowed_validations}" fi challenge="$(echo "${response}" | get_json_dict_value -p '"challenges",'"${challengeindex}")" # Gather challenge information if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ] ; then challenge_names[${idx}]="$(echo "${identifier}" | ip_to_ptr)" else challenge_names[${idx}]="${identifier}" fi challenge_tokens[${idx}]="$(echo "${challenge}" | get_json_string_value token)" if [[ ${API} -eq 2 ]]; then challenge_uris[${idx}]="$(echo "${challenge}" | get_json_string_value url)" else if [[ "$(echo "${challenge}" | get_json_string_value type)" = "urn:acme:error:unauthorized" ]]; then _exiterr "Challenge unauthorized: $(echo "${challenge}" | get_json_string_value detail)" fi challenge_uris[${idx}]="$(echo "${challenge}" | get_json_dict_value validationRecord | get_json_string_value uri)" fi # Prepare challenge tokens and deployment parameters keyauth="${challenge_tokens[${idx}]}.${thumbprint}" case "${CHALLENGETYPE}" in "http-01") # Store challenge response in well-known location and make world-readable (so that a webserver can access it) printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_tokens[${idx}]}" chmod a+r "${WELLKNOWN}/${challenge_tokens[${idx}]}" keyauth_hook="${keyauth}" ;; "dns-01") # Generate DNS entry content for dns-01 validation keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" ;; "tls-alpn-01") keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -c -hex | awk '{print $NF}')" generate_alpn_certificate "${identifier}" "${identifier_type}" "${keyauth_hook}" ;; esac keyauths[${idx}]="${keyauth}" if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ]; then deploy_args[${idx}]="$(echo "${identifier}" | ip_to_ptr) ${challenge_tokens[${idx}]} ${keyauth_hook}" else deploy_args[${idx}]="${identifier} ${challenge_tokens[${idx}]} ${keyauth_hook}" fi idx=$((idx+1)) done local num_pending_challenges=${idx} echo " + ${num_pending_challenges} pending challenge(s)" # Deploy challenge tokens if [[ ${num_pending_challenges} -ne 0 ]]; then echo " + Deploying challenge tokens..." if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]]; then # shellcheck disable=SC2068 "${HOOK}" "deploy_challenge" ${deploy_args[@]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' elif [[ -n "${HOOK}" ]]; then # Run hook script to deploy the challenge token local idx=0 while [ ${idx} -lt ${num_pending_challenges} ]; do # shellcheck disable=SC2086 "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' idx=$((idx+1)) done fi fi # Validate pending challenges local idx=0 while [ ${idx} -lt ${num_pending_challenges} ]; do echo " + Responding to challenge for ${challenge_names[${idx}]} authorization..." # Ask the acme-server to verify our challenge and wait until it is no longer pending if [[ ${API} -eq 1 ]]; then result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauths[${idx}]}"'"}' | jsonsh)" else result="$(signed_request "${challenge_uris[${idx}]}" '{}' | jsonsh)" fi reqstatus="$(echo "${result}" | get_json_string_value status)" while [[ "${reqstatus}" = "pending" ]] || [[ "${reqstatus}" = "processing" ]]; do sleep 1 if [[ "${API}" -eq 2 ]]; then result="$(signed_request "${challenge_uris[${idx}]}" "" | jsonsh)" else result="$(http_request get "${challenge_uris[${idx}]}" | jsonsh)" fi reqstatus="$(echo "${result}" | get_json_string_value status)" done [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" if [[ "${reqstatus}" = "valid" ]]; then echo " + Challenge is valid!" else [[ -n "${HOOK}" ]] && ("${HOOK}" "invalid_challenge" "${altname}" "${result}" || _exiterr 'invalid_challenge hook returned with non-zero exit code') break fi idx=$((idx+1)) done if [[ ${num_pending_challenges} -ne 0 ]]; then echo " + Cleaning challenge tokens..." # Clean challenge tokens using chained hook # shellcheck disable=SC2068 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[@]} || _exiterr 'clean_challenge hook returned with non-zero exit code') # Clean remaining challenge tokens if validation has failed local idx=0 while [ ${idx} -lt ${num_pending_challenges} ]; do # Delete challenge file [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" # Delete alpn verification certificates [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" # Clean challenge token using non-chained hook # shellcheck disable=SC2086 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[${idx}]} || _exiterr 'clean_challenge hook returned with non-zero exit code') idx=$((idx+1)) done if [[ "${reqstatus}" != "valid" ]]; then echo " + Challenge validation has failed :(" _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})" fi fi # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem echo " + Requesting certificate..." csr64="$( <<<"${csr}" "${OPENSSL}" req -config "${OPENSSL_CNF}" -outform DER | urlbase64)" if [[ ${API} -eq 1 ]]; then crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | "${OPENSSL}" base64 -e)" crt="$( printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" )" else result="$(signed_request "${finalize}" '{"csr": "'"${csr64}"'"}' | jsonsh)" while :; do orderstatus="$(echo "${result}" | get_json_string_value status)" case "${orderstatus}" in "processing" | "pending") echo " + Order is ${orderstatus}..." sleep 2; ;; "valid") break; ;; *) _exiterr "Order in status ${orderstatus}" ;; esac result="$(signed_request "${order_location}" "" | jsonsh)" done resheaders="$(_mktemp)" certificate="$(echo "${result}" | get_json_string_value certificate)" crt="$(signed_request "${certificate}" "" 4>"${resheaders}")" if [ -n "${PREFERRED_CHAIN:-}" ]; then foundaltchain=0 altcn="$(get_last_cn "${crt}")" altoptions="${altcn}" if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then foundaltchain=1 fi if [ "${foundaltchain}" = "0" ] && (grep -Ei '^link:' "${resheaders}" | grep -q -Ei 'rel="alternate"'); then while read -r altcrturl; do if [ "${foundaltchain}" = "0" ]; then altcrt="$(signed_request "${altcrturl}" "")" altcn="$(get_last_cn "${altcrt}")" altoptions="${altoptions}, ${altcn}" if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then foundaltchain=1 crt="${altcrt}" fi fi done <<< "$(grep -Ei '^link:' "${resheaders}" | grep -Ei 'rel="alternate"' | cut -d'<' -f2 | cut -d'>' -f1)" fi if [ "${foundaltchain}" = "0" ]; then _exiterr "Alternative chain with CN = ${PREFERRED_CHAIN} not found, available options: ${altoptions}" fi echo " + Using preferred chain with CN = ${altcn}" fi rm -f "${resheaders}" fi # Try to load the certificate to detect corruption echo " + Checking certificate..." _openssl x509 -text <<<"${crt}" echo "${crt}" >&3 unset challenge_token echo " + Done!" } # grep issuer cert uri from certificate get_issuer_cert_uri() { certificate="${1}" "${OPENSSL}" x509 -in "${certificate}" -noout -text | (grep 'CA Issuers - URI:' | cut -d':' -f2-) || true } get_issuer_hash() { certificate="${1}" "${OPENSSL}" x509 -in "${certificate}" -noout -issuer_hash } get_ocsp_url() { certificate="${1}" "${OPENSSL}" x509 -in "${certificate}" -noout -ocsp_uri } # walk certificate chain, retrieving all intermediate certificates walk_chain() { local certificate certificate="${1}" local issuer_cert_uri issuer_cert_uri="${2:-}" if [[ -z "${issuer_cert_uri}" ]]; then issuer_cert_uri="$(get_issuer_cert_uri "${certificate}")"; fi if [[ -n "${issuer_cert_uri}" ]]; then # create temporary files local tmpcert local tmpcert_raw tmpcert_raw="$(_mktemp)" tmpcert="$(_mktemp)" # download certificate http_request get "${issuer_cert_uri}" > "${tmpcert_raw}" # PEM if grep -q "BEGIN CERTIFICATE" "${tmpcert_raw}"; then mv "${tmpcert_raw}" "${tmpcert}" # DER elif "${OPENSSL}" x509 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM 2> /dev/null > /dev/null; then : # PKCS7 elif "${OPENSSL}" pkcs7 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM -print_certs 2> /dev/null > /dev/null; then : # Unknown certificate type else _exiterr "Unknown certificate type in chain" fi local next_issuer_cert_uri next_issuer_cert_uri="$(get_issuer_cert_uri "${tmpcert}")" if [[ -n "${next_issuer_cert_uri}" ]]; then printf "\n%s\n" "${issuer_cert_uri}" cat "${tmpcert}" walk_chain "${tmpcert}" "${next_issuer_cert_uri}" fi rm -f "${tmpcert}" "${tmpcert_raw}" fi } # Generate ALPN verification certificate generate_alpn_certificate() { local altname="${1}" local identifier_type="${2}" local acmevalidation="${3}" local alpncertdir="${ALPNCERTDIR}" if [[ ! -e "${alpncertdir}" ]]; then echo " + Creating new directory ${alpncertdir} ..." mkdir -p "${alpncertdir}" || _exiterr "Unable to create directory ${alpncertdir}" fi echo " + Generating ALPN certificate and key for ${1}..." tmp_openssl_cnf="$(_mktemp)" cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" if [[ "${identifier_type}" = "ip" ]]; then printf "\n[SAN]\nsubjectAltName=IP:%s\n" "${altname}" >> "${tmp_openssl_cnf}" else printf "\n[SAN]\nsubjectAltName=DNS:%s\n" "${altname}" >> "${tmp_openssl_cnf}" fi printf "1.3.6.1.5.5.7.1.31=critical,DER:04:20:%s\n" "${acmevalidation}" >> "${tmp_openssl_cnf}" SUBJ="/CN=${altname}/" [[ "${OSTYPE:0:5}" = "MINGW" ]] && SUBJ="/${SUBJ}" if [[ "${identifier_type}" = "ip" ]]; then altname="$(echo "${altname}" | ip_to_ptr)" fi _openssl req -x509 -new -sha256 -nodes -newkey rsa:2048 -keyout "${alpncertdir}/${altname}.key.pem" -out "${alpncertdir}/${altname}.crt.pem" -subj "${SUBJ}" -extensions SAN -config "${tmp_openssl_cnf}" chmod g+r "${alpncertdir}/${altname}.key.pem" "${alpncertdir}/${altname}.crt.pem" rm -f "${tmp_openssl_cnf}" } # Create certificate for domain(s) sign_domain() { local certdir="${1}" shift timestamp="${1}" shift domain="${1}" altnames="${*}" export altnames echo " + Signing domains..." if [[ ${API} -eq 1 ]]; then if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then _exiterr "Certificate authority doesn't allow certificate signing" fi elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then _exiterr "Certificate authority doesn't allow certificate signing" fi local privkey="privkey.pem" if [[ ! -e "${certdir}/cert-${timestamp}.csr" ]]; then # generate a new private key if we need or want one if [[ ! -r "${certdir}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then echo " + Generating private key..." privkey="privkey-${timestamp}.pem" local tmp_privkey tmp_privkey="$(_mktemp)" case "${KEY_ALGO}" in rsa) _openssl genrsa -out "${tmp_privkey}" "${KEYSIZE}";; prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${tmp_privkey}" -noout;; esac cat "${tmp_privkey}" > "${certdir}/privkey-${timestamp}.pem" rm "${tmp_privkey}" fi # move rolloverkey into position (if any) if [[ -r "${certdir}/privkey.pem" && -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_RENEW}" = "yes" && "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then echo " + Moving Rolloverkey into position.... " mv "${certdir}/privkey.roll.pem" "${certdir}/privkey-tmp.pem" mv "${certdir}/privkey-${timestamp}.pem" "${certdir}/privkey.roll.pem" mv "${certdir}/privkey-tmp.pem" "${certdir}/privkey-${timestamp}.pem" fi # generate a new private rollover key if we need or want one if [[ ! -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_ROLLOVER}" = "yes" && "${PRIVATE_KEY_RENEW}" = "yes" ]]; then echo " + Generating private rollover key..." case "${KEY_ALGO}" in rsa) _openssl genrsa -out "${certdir}/privkey.roll.pem" "${KEYSIZE}";; prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${certdir}/privkey.roll.pem" -noout;; esac fi # delete rolloverkeys if disabled if [[ -r "${certdir}/privkey.roll.pem" && ! "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then echo " + Removing Rolloverkey (feature disabled)..." rm -f "${certdir}/privkey.roll.pem" fi # Generate signing request config and the actual signing request echo " + Generating signing request..." SAN="" for altname in ${altnames}; do if [[ "${altname}" =~ ^ip: ]]; then SAN="${SAN}IP:${altname:3}, " else SAN="${SAN}DNS:${altname}, " fi done if [[ "${domain}" =~ ^ip: ]]; then SUBJ="/CN=${domain:3}/" else SUBJ="/CN=${domain}/" fi SAN="${SAN%%, }" local tmp_openssl_cnf tmp_openssl_cnf="$(_mktemp)" cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" printf "\n[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}" if [ "${OCSP_MUST_STAPLE}" = "yes" ]; then printf "\n1.3.6.1.5.5.7.1.24=DER:30:03:02:01:05" >> "${tmp_openssl_cnf}" fi if [[ "${OSTYPE:0:5}" = "MINGW" ]]; then # The subject starts with a /, so MSYS will assume it's a path and convert # it unless we escape it with another one: SUBJ="/${SUBJ}" fi "${OPENSSL}" req -new -sha256 -key "${certdir}/${privkey}" -out "${certdir}/cert-${timestamp}.csr" -subj "${SUBJ}" -reqexts SAN -config "${tmp_openssl_cnf}" rm -f "${tmp_openssl_cnf}" fi crt_path="${certdir}/cert-${timestamp}.pem" # shellcheck disable=SC2086 sign_csr "$(< "${certdir}/cert-${timestamp}.csr")" ${altnames} 3>"${crt_path}" # Create fullchain.pem echo " + Creating fullchain.pem..." if [[ ${API} -eq 1 ]]; then cat "${crt_path}" > "${certdir}/fullchain-${timestamp}.pem" local issuer_hash issuer_hash="$(get_issuer_hash "${crt_path}")" if [ -e "${CHAINCACHE}/${issuer_hash}.chain" ]; then echo " + Using cached chain!" cat "${CHAINCACHE}/${issuer_hash}.chain" > "${certdir}/chain-${timestamp}.pem" else echo " + Walking chain..." local issuer_cert_uri issuer_cert_uri="$(get_issuer_cert_uri "${crt_path}" || echo "unknown")" (walk_chain "${crt_path}" > "${certdir}/chain-${timestamp}.pem") || _exiterr "Walking chain has failed, your certificate has been created and can be found at ${crt_path}, the corresponding private key at ${privkey}. If you want you can manually continue on creating and linking all necessary files. If this error occurs again you should manually generate the certificate chain and place it under ${CHAINCACHE}/${issuer_hash}.chain (see ${issuer_cert_uri})" cat "${certdir}/chain-${timestamp}.pem" > "${CHAINCACHE}/${issuer_hash}.chain" fi cat "${certdir}/chain-${timestamp}.pem" >> "${certdir}/fullchain-${timestamp}.pem" else tmpcert="$(_mktemp)" tmpchain="$(_mktemp)" awk '{print >out}; /----END CERTIFICATE-----/{out=tmpchain}' out="${tmpcert}" tmpchain="${tmpchain}" "${certdir}/cert-${timestamp}.pem" mv "${certdir}/cert-${timestamp}.pem" "${certdir}/fullchain-${timestamp}.pem" cat "${tmpcert}" > "${certdir}/cert-${timestamp}.pem" cat "${tmpchain}" > "${certdir}/chain-${timestamp}.pem" rm "${tmpcert}" "${tmpchain}" fi # Wait for hook script to sync the files before creating the symlinks [[ -n "${HOOK}" ]] && ("${HOOK}" "sync_cert" "${certdir}/privkey-${timestamp}.pem" "${certdir}/cert-${timestamp}.pem" "${certdir}/fullchain-${timestamp}.pem" "${certdir}/chain-${timestamp}.pem" "${certdir}/cert-${timestamp}.csr" || _exiterr 'sync_cert hook returned with non-zero exit code') # Update symlinks [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${certdir}/privkey.pem" ln -sf "chain-${timestamp}.pem" "${certdir}/chain.pem" ln -sf "fullchain-${timestamp}.pem" "${certdir}/fullchain.pem" ln -sf "cert-${timestamp}.csr" "${certdir}/cert.csr" ln -sf "cert-${timestamp}.pem" "${certdir}/cert.pem" # Wait for hook script to clean the challenge and to deploy cert if used [[ -n "${HOOK}" ]] && ("${HOOK}" "deploy_cert" "${domain}" "${certdir}/privkey.pem" "${certdir}/cert.pem" "${certdir}/fullchain.pem" "${certdir}/chain.pem" "${timestamp}" || _exiterr 'deploy_cert hook returned with non-zero exit code') unset challenge_token echo " + Done!" } # Usage: --version (-v) # Description: Print version information command_version() { load_config noverify echo "Dehydrated by Lukas Schauer" echo "https://dehydrated.io" echo "" echo "Dehydrated version: ${VERSION}" revision="$(cd "${SCRIPTDIR}"; git rev-parse HEAD 2>/dev/null || echo "unknown")" echo "GIT-Revision: ${revision}" echo "" # shellcheck disable=SC1091 if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then echo "OS: $(uname -sr)" elif [[ -e /etc/os-release ]]; then ( . /etc/os-release && echo "OS: $PRETTY_NAME" ) elif [[ -e /usr/lib/os-release ]]; then ( . /usr/lib/os-release && echo "OS: $PRETTY_NAME" ) else echo "OS: $(grep -v '^$' /etc/issue | head -n1 | _sed 's/\\(r|n|l) .*//g')" fi echo "Used software:" [[ -n "${BASH_VERSION:-}" ]] && echo " bash: ${BASH_VERSION}" [[ -n "${ZSH_VERSION:-}" ]] && echo " zsh: ${ZSH_VERSION}" echo " curl: ${CURL_VERSION}" if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then echo " awk, sed, mktemp, grep, diff: BSD base system versions" else echo " awk: $(awk -W version 2>&1 | head -n1)" echo " sed: $(sed --version 2>&1 | head -n1)" echo " mktemp: $(mktemp --version 2>&1 | head -n1)" echo " grep: $(grep --version 2>&1 | head -n1)" echo " diff: $(diff --version 2>&1 | head -n1)" fi echo " openssl: $("${OPENSSL}" version 2>&1)" exit 0 } # Usage: --display-terms # Description: Display current terms of service command_terms() { init_system echo "The current terms of service: $CA_TERMS" echo "+ Done!" exit 0 } # Usage: --register # Description: Register account key command_register() { init_system echo "+ Done!" exit 0 } # Usage: --account # Description: Update account contact information command_account() { init_system FAILED=false NEW_ACCOUNT_KEY_JSON="$(_mktemp)" # Check if we have the registration url if [[ -z "${ACCOUNT_URL}" ]]; then _exiterr "Error retrieving registration url." fi echo "+ Updating registration url: ${ACCOUNT_URL} contact information..." if [[ ${API} -eq 1 ]]; then # If an email for the contact has been provided then adding it to the registered account if [[ -n "${CONTACT_EMAIL}" ]]; then (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true else (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true fi else # If an email for the contact has been provided then adding it to the registered account if [[ -n "${CONTACT_EMAIL}" ]]; then (signed_request "${ACCOUNT_URL}" '{"contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true else (signed_request "${ACCOUNT_URL}" '{"contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true fi fi if [[ "${FAILED}" = "true" ]]; then rm "${NEW_ACCOUNT_KEY_JSON}" _exiterr "Error updating account information. See message above for more information." fi if diff -q "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" > /dev/null; then echo "+ Account information was the same after the update" rm "${NEW_ACCOUNT_KEY_JSON}" else ACCOUNT_KEY_JSON_BACKUP="${ACCOUNT_KEY_JSON%.*}-$(date +%s).json" echo "+ Backup ${ACCOUNT_KEY_JSON} as ${ACCOUNT_KEY_JSON_BACKUP}" cp -p "${ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON_BACKUP}" echo "+ Populate ${ACCOUNT_KEY_JSON}" mv "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" fi echo "+ Done!" exit 0 } # Parse contents of domains.txt and domains.txt.d parse_domains_txt() { # Allow globbing temporarily noglob_set local inputs=("${DOMAINS_TXT}" "${DOMAINS_TXT}.d"/*.txt) noglob_clear cat "${inputs[@]}" | tr -d '\r' | awk '{print tolower($0)}' | _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' -e 's/([^ ])>/\1 >/g' -e 's/> />/g' | (grep -vE '^(#|$)' || true) } # Usage: --cron (-c) # Description: Sign/renew non-existent/changed/expiring certificates. command_sign_domains() { init_system hookscript_bricker_hook # Call startup hook [[ -n "${HOOK}" ]] && ("${HOOK}" "startup_hook" || _exiterr 'startup_hook hook returned with non-zero exit code') if [ ! -d "${CHAINCACHE}" ]; then echo " + Creating chain cache directory ${CHAINCACHE}" mkdir "${CHAINCACHE}" fi if [[ -n "${PARAM_DOMAIN:-}" ]]; then DOMAINS_TXT="$(_mktemp)" if [[ -n "${PARAM_ALIAS:-}" ]]; then printf "%s > %s" "${PARAM_DOMAIN}" "${PARAM_ALIAS}" > "${DOMAINS_TXT}" else printf "%s" "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" fi elif [[ -e "${DOMAINS_TXT}" ]]; then if [[ ! -r "${DOMAINS_TXT}" ]]; then _exiterr "domains.txt found but not readable" fi else _exiterr "domains.txt not found and --domain not given" fi # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire ORIGIFS="${IFS}" IFS=$'\n' for line in $(parse_domains_txt); do reset_configvars IFS="${ORIGIFS}" alias="$(grep -Eo '>[^ ]+' <<< "${line}" || true)" line="$(_sed -e 's/>[^ ]+[ ]*//g' <<< "${line}")" aliascount="$(grep -Eo '>' <<< "${alias}" | awk 'END {print NR}' || true )" [ "${aliascount}" -gt 1 ] && _exiterr "Only one alias per line is allowed in domains.txt!" domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" [ "${aliascount}" -lt 1 ] && alias="${domain}" || alias="${alias#>}" export alias if [[ -z "${morenames}" ]];then echo "Processing ${domain}" else echo "Processing ${domain} with alternative names: ${morenames}" fi if [ "${alias:0:2}" = "*." ]; then _exiterr "Please define a valid alias for your ${domain} wildcard-certificate. See domains.txt-documentation for more details." fi local certdir="${CERTDIR}/${alias}" cert="${certdir}/cert.pem" chain="${certdir}/chain.pem" force_renew="${PARAM_FORCE:-no}" timestamp="$(date +%s)" # If there is no existing certificate directory => make it if [[ ! -e "${certdir}" ]]; then echo " + Creating new directory ${certdir} ..." mkdir -p "${certdir}" || _exiterr "Unable to create directory ${certdir}" fi # read cert config # for now this loads the certificate specific config in a subshell and parses a diff of set variables. # we could just source the config file but i decided to go this way to protect people from accidentally overriding # variables used internally by this script itself. if [[ -n "${DOMAINS_D}" ]]; then certconfig="${DOMAINS_D}/${alias}" else certconfig="${certdir}/config" fi if [ -f "${certconfig}" ]; then echo " + Using certificate specific config file!" ORIGIFS="${IFS}" IFS=$'\n' for cfgline in $( beforevars="$(_mktemp)" aftervars="$(_mktemp)" set > "${beforevars}" # shellcheck disable=SC1090 . "${certconfig}" set > "${aftervars}" diff -u "${beforevars}" "${aftervars}" | grep -E '^\+[^+]' rm "${beforevars}" rm "${aftervars}" ); do config_var="$(echo "${cfgline:1}" | cut -d'=' -f1)" config_value="$(echo "${cfgline:1}" | cut -d'=' -f2- | tr -d "'")" # All settings that are allowed here should also be stored and # restored in store_configvars() and reset_configvars() case "${config_var}" in KEY_ALGO|OCSP_MUST_STAPLE|OCSP_FETCH|OCSP_DAYS|PRIVATE_KEY_RENEW|PRIVATE_KEY_ROLLOVER|KEYSIZE|CHALLENGETYPE|HOOK|PREFERRED_CHAIN|WELLKNOWN|HOOK_CHAIN|OPENSSL_CNF|RENEW_DAYS) echo " + ${config_var} = ${config_value}" declare -- "${config_var}=${config_value}" ;; _) ;; *) echo " ! Setting ${config_var} on a per-certificate base is not (yet) supported" >&2 esac done IFS="${ORIGIFS}" fi verify_config hookscript_bricker_hook export WELLKNOWN CHALLENGETYPE KEY_ALGO PRIVATE_KEY_ROLLOVER skip="no" # Allow for external CSR generation local csr="" if [[ -n "${HOOK}" ]]; then csr="$("${HOOK}" "generate_csr" "${domain}" "${certdir}" "${domain} ${morenames}")" || _exiterr 'generate_csr hook returned with non-zero exit code' if grep -qE "\-----BEGIN (NEW )?CERTIFICATE REQUEST-----" <<< "${csr}"; then altnames="$(extract_altnames "${csr}")" domain="$(cut -d' ' -f1 <<< "${altnames}")" morenames="$(cut -s -d' ' -f2- <<< "${altnames}")" echo " + Using CSR from hook script (real names: ${altnames})" else csr="" fi fi # Check domain names of existing certificate if [[ -e "${cert}" && "${force_renew}" = "no" ]]; then printf " + Checking domain name(s) of existing cert..." certnames="$("${OPENSSL}" x509 -in "${cert}" -text -noout | grep -E '(DNS|IP( Address*)):' | _sed 's/(DNS|IP( Address)*)://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//')" givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _sed 's/ip://g' | _sed 's/ $//' | _sed 's/^ //')" if [[ "${certnames}" = "${givennames}" ]]; then echo " unchanged." else echo " changed!" echo " + Domain name(s) are not matching!" echo " + Names in old certificate: ${certnames}" echo " + Configured names: ${givennames}" echo " + Forcing renew." force_renew="yes" fi fi # Check expire date of existing certificate if [[ -e "${cert}" ]]; then echo " + Checking expire date of existing cert..." valid="$("${OPENSSL}" x509 -enddate -noout -in "${cert}" | cut -d= -f2- )" printf " + Valid till %s " "${valid}" if ("${OPENSSL}" x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}" > /dev/null 2>&1); then printf "(Longer than %d days). " "${RENEW_DAYS}" if [[ "${force_renew}" = "yes" ]]; then echo "Ignoring because renew was forced!" else # Certificate-Names unchanged and cert is still valid echo "Skipping renew!" [[ -n "${HOOK}" ]] && ("${HOOK}" "unchanged_cert" "${domain}" "${certdir}/privkey.pem" "${certdir}/cert.pem" "${certdir}/fullchain.pem" "${certdir}/chain.pem" || _exiterr 'unchanged_cert hook returned with non-zero exit code') skip="yes" fi else echo "(Less than ${RENEW_DAYS} days). Renewing!" fi fi local update_ocsp update_ocsp="no" # Sign certificate for this domain if [[ ! "${skip}" = "yes" ]]; then update_ocsp="yes" [[ -z "${csr}" ]] || printf "%s" "${csr}" > "${certdir}/cert-${timestamp}.csr" # shellcheck disable=SC2086 if [[ "${PARAM_KEEP_GOING:-}" = "yes" ]]; then skip_exit_hook=yes sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} & wait $! || exit_with_errorcode=1 skip_exit_hook=no else sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} fi fi if [[ "${OCSP_FETCH}" = "yes" ]]; then local ocsp_url ocsp_url="$(get_ocsp_url "${cert}")" if [[ ! -e "${certdir}/ocsp.der" ]]; then update_ocsp="yes" elif ! ("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respin "${certdir}/ocsp.der" -status_age $((OCSP_DAYS*24*3600)) 2>&1 | grep -q "${cert}: good"); then update_ocsp="yes" fi if [[ "${update_ocsp}" = "yes" ]]; then echo " + Updating OCSP stapling file" ocsp_timestamp="$(date +%s)" if grep -qE "^(openssl (0|(1\.0))\.)|(libressl (1|2|3)\.)" <<< "$(${OPENSSL} version | awk '{print tolower($0)}')"; then ocsp_log="$("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respout "${certdir}/ocsp-${ocsp_timestamp}.der" -url "${ocsp_url}" -header "HOST" "$(echo "${ocsp_url}" | _sed -e 's/^http(s?):\/\///' -e 's/\/.*$//g')" 2>&1)" || _exiterr "Error while fetching OCSP information: ${ocsp_log}" else ocsp_log="$("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respout "${certdir}/ocsp-${ocsp_timestamp}.der" -url "${ocsp_url}" 2>&1)" || _exiterr "Error while fetching OCSP information: ${ocsp_log}" fi ln -sf "ocsp-${ocsp_timestamp}.der" "${certdir}/ocsp.der" [[ -n "${HOOK}" ]] && (altnames="${domain} ${morenames}" "${HOOK}" "deploy_ocsp" "${domain}" "${certdir}/ocsp.der" "${ocsp_timestamp}" || _exiterr 'deploy_ocsp hook returned with non-zero exit code') else echo " + OCSP stapling file is still valid (skipping update)" fi fi done reset_configvars # remove temporary domains.txt file if used [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}" [[ -n "${HOOK}" ]] && ("${HOOK}" "exit_hook" || echo 'exit_hook returned with non-zero exit code!' >&2) if [[ "${AUTO_CLEANUP}" == "yes" ]]; then echo "+ Running automatic cleanup" command_cleanup noinit fi exit "${exit_with_errorcode}" } # Usage: --signcsr (-s) path/to/csr.pem # Description: Sign a given CSR, output CRT on stdout (advanced usage) command_sign_csr() { init_system # redirect stdout to stderr # leave stdout over at fd 3 to output the cert exec 3>&1 1>&2 # load csr csrfile="${1}" if [ ! -r "${csrfile}" ]; then _exiterr "Could not read certificate signing request ${csrfile}" fi csr="$(cat "${csrfile}")" # extract names altnames="$(extract_altnames "${csr}")" # gen cert certfile="$(_mktemp)" # shellcheck disable=SC2086 sign_csr "${csr}" ${altnames} 3> "${certfile}" # print cert echo "# CERT #" >&3 cat "${certfile}" >&3 echo >&3 # print chain if [ -n "${PARAM_FULL_CHAIN:-}" ]; then # get and convert ca cert chainfile="$(_mktemp)" tmpchain="$(_mktemp)" http_request get "$("${OPENSSL}" x509 -in "${certfile}" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${tmpchain}" if grep -q "BEGIN CERTIFICATE" "${tmpchain}"; then mv "${tmpchain}" "${chainfile}" else "${OPENSSL}" x509 -in "${tmpchain}" -inform DER -out "${chainfile}" -outform PEM rm "${tmpchain}" fi echo "# CHAIN #" >&3 cat "${chainfile}" >&3 rm "${chainfile}" fi # cleanup rm "${certfile}" exit 0 } # Usage: --revoke (-r) path/to/cert.pem # Description: Revoke specified certificate command_revoke() { init_system [[ -n "${CA_REVOKE_CERT}" ]] || _exiterr "Certificate authority doesn't allow certificate revocation." cert="${1}" if [[ -L "${cert}" ]]; then # follow symlink and use real certificate name (so we move the real file and not the symlink at the end) local link_target link_target="$(readlink -n "${cert}")" if [[ "${link_target}" =~ ^/ ]]; then cert="${link_target}" else cert="$(dirname "${cert}")/${link_target}" fi fi [[ -f "${cert}" ]] || _exiterr "Could not find certificate ${cert}" echo "Revoking ${cert}" cert64="$("${OPENSSL}" x509 -in "${cert}" -inform PEM -outform DER | urlbase64)" if [[ ${API} -eq 1 ]]; then response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)" else response="$(signed_request "${CA_REVOKE_CERT}" '{"certificate": "'"${cert64}"'"}' | clean_json)" fi # if there is a problem with our revoke request _request (via signed_request) will report this and "exit 1" out # so if we are here, it is safe to assume the request was successful echo " + Done." echo " + Renaming certificate to ${cert}-revoked" mv -f "${cert}" "${cert}-revoked" } # Usage: --deactivate # Description: Deactivate account command_deactivate() { init_system echo "Deactivating account ${ACCOUNT_URL}" if [[ ${API} -eq 1 ]]; then echo "Deactivation for ACMEv1 is not implemented" else response="$(signed_request "${ACCOUNT_URL}" '{"status": "deactivated"}' | clean_json)" deactstatus=$(echo "$response" | jsonsh | get_json_string_value "status") if [[ "${deactstatus}" = "deactivated" ]]; then touch "${ACCOUNT_DEACTIVATED}" else _exiterr "Account deactivation failed!" fi fi echo " + Done." } # Usage: --cleanup (-gc) # Description: Move unused certificate files to archive directory command_cleanup() { if [ ! "${1:-}" = "noinit" ]; then load_config fi if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then # Create global archive directory if not existent if [[ ! -e "${BASEDIR}/archive" ]]; then mkdir "${BASEDIR}/archive" fi fi # Allow globbing noglob_set # Loop over all certificate directories for certdir in "${CERTDIR}/"*; do # Skip if entry is not a folder [[ -d "${certdir}" ]] || continue # Get certificate name certname="$(basename "${certdir}")" # Create certificates archive directory if not existent if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then archivedir="${BASEDIR}/archive/${certname}" if [[ ! -e "${archivedir}" ]]; then mkdir "${archivedir}" fi fi # Loop over file-types (certificates, keys, signing-requests, ...) for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem ocsp.der; do # Delete all if symlink is broken if [[ -r "${certdir}/${filetype}" ]]; then # Look up current file in use current="$(basename "$(readlink "${certdir}/${filetype}")")" else if [[ -h "${certdir}/${filetype}" ]]; then echo "Removing broken symlink: ${certdir}/${filetype}" rm -f "${certdir}/${filetype}" fi current="" fi # Split filetype into name and extension filebase="$(echo "${filetype}" | cut -d. -f1)" fileext="$(echo "${filetype}" | cut -d. -f2)" # Loop over all files of this type for file in "${certdir}/${filebase}-"*".${fileext}" "${certdir}/${filebase}-"*".${fileext}-revoked"; do # Check if current file is in use, if unused move to archive directory filename="$(basename "${file}")" if [[ ! "${filename}" = "${current}" ]] && [[ -f "${certdir}/${filename}" ]]; then if [[ "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then echo "Deleting unused file: ${certname}/${filename}" rm "${certdir}/${filename}" else echo "Moving unused file to archive directory: ${certname}/${filename}" mv "${certdir}/${filename}" "${archivedir}/${filename}" fi fi done done done exit "${exit_with_errorcode}" } # Usage: --cleanup-delete (-gcd) # Description: Deletes (!) unused certificate files command_cleanupdelete() { command_cleanup } # Usage: --help (-h) # Description: Show help text command_help() { printf "Usage: %s [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ...\n\n" "${0}" printf "Default command: help\n\n" echo "Commands:" grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then _exiterr "Error generating help text." fi printf " %-32s %s\n" "${usage##"# Usage: "}" "${description##"# Description: "}" done printf -- "\nParameters:\n" grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then _exiterr "Error generating help text." fi printf " %-32s %s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}" done } # Usage: --env (-e) # Description: Output configuration variables for use in other scripts command_env() { echo "# dehydrated configuration" load_config typeset -p CA CERTDIR ALPNCERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON ACCOUNT_ID_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE } # Main method (parses script arguments and calls command_* methods) main() { exit_with_errorcode=0 skip_exit_hook=no COMMAND="" set_command() { [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information." COMMAND="${1}" } check_parameters() { if [[ -z "${1:-}" ]]; then echo "The specified command requires additional parameters. See help:" >&2 echo >&2 command_help >&2 exit 1 elif [[ "${1:0:1}" = "-" ]]; then _exiterr "Invalid argument: ${1}" fi } [[ -z "${*}" ]] && eval set -- "--help" while (( ${#} )); do case "${1}" in --help|-h) command_help exit 0 ;; --env|-e) set_command env ;; --cron|-c) set_command sign_domains ;; --register) set_command register ;; --account) set_command account ;; # PARAM_Usage: --accept-terms # PARAM_Description: Accept CAs terms of service --accept-terms) PARAM_ACCEPT_TERMS="yes" ;; --display-terms) set_command terms ;; --signcsr|-s) shift 1 set_command sign_csr check_parameters "${1:-}" PARAM_CSR="${1}" ;; --revoke|-r) shift 1 set_command revoke check_parameters "${1:-}" PARAM_REVOKECERT="${1}" ;; --deactivate) set_command deactivate ;; --version|-v) set_command version ;; --cleanup|-gc) set_command cleanup ;; --cleanup-delete|-gcd) set_command cleanupdelete PARAM_CLEANUPDELETE="yes" ;; # PARAM_Usage: --full-chain (-fc) # PARAM_Description: Print full chain when using --signcsr --full-chain|-fc) PARAM_FULL_CHAIN="1" ;; # PARAM_Usage: --ipv4 (-4) # PARAM_Description: Resolve names to IPv4 addresses only --ipv4|-4) PARAM_IP_VERSION="4" ;; # PARAM_Usage: --ipv6 (-6) # PARAM_Description: Resolve names to IPv6 addresses only --ipv6|-6) PARAM_IP_VERSION="6" ;; # PARAM_Usage: --domain (-d) domain.tld # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!) --domain|-d) shift 1 check_parameters "${1:-}" if [[ -z "${PARAM_DOMAIN:-}" ]]; then PARAM_DOMAIN="${1}" else PARAM_DOMAIN="${PARAM_DOMAIN} ${1}" fi ;; # PARAM_Usage: --ca url/preset # PARAM_Description: Use specified CA URL or preset --ca) shift 1 check_parameters "${1:-}" [[ -n "${PARAM_CA:-}" ]] && _exiterr "CA can only be specified once!" PARAM_CA="${1}" ;; # PARAM_Usage: --alias certalias # PARAM_Description: Use specified name for certificate directory (and per-certificate config) instead of the primary domain (only used if --domain is specified) --alias) shift 1 check_parameters "${1:-}" [[ -n "${PARAM_ALIAS:-}" ]] && _exiterr "Alias can only be specified once!" PARAM_ALIAS="${1}" ;; # PARAM_Usage: --keep-going (-g) # PARAM_Description: Keep going after encountering an error while creating/renewing multiple certificates in cron mode --keep-going|-g) PARAM_KEEP_GOING="yes" ;; # PARAM_Usage: --force (-x) # PARAM_Description: Force renew of certificate even if it is longer valid than value in RENEW_DAYS --force|-x) PARAM_FORCE="yes" ;; # PARAM_Usage: --force-validation # PARAM_Description: Force revalidation of domain names (used in combination with --force) --force-validation) PARAM_FORCE_VALIDATION="yes" ;; # PARAM_Usage: --no-lock (-n) # PARAM_Description: Don't use lockfile (potentially dangerous!) --no-lock|-n) PARAM_NO_LOCK="yes" ;; # PARAM_Usage: --lock-suffix example.com # PARAM_Description: Suffix lockfile name with a string (useful for with -d) --lock-suffix) shift 1 check_parameters "${1:-}" PARAM_LOCKFILE_SUFFIX="${1}" ;; # PARAM_Usage: --ocsp # PARAM_Description: Sets option in CSR indicating OCSP stapling to be mandatory --ocsp) PARAM_OCSP_MUST_STAPLE="yes" ;; # PARAM_Usage: --privkey (-p) path/to/key.pem # PARAM_Description: Use specified private key instead of account key (useful for revocation) --privkey|-p) shift 1 check_parameters "${1:-}" PARAM_ACCOUNT_KEY="${1}" ;; # PARAM_Usage: --domains-txt path/to/domains.txt # PARAM_Description: Use specified domains.txt instead of default/configured one --domains-txt) shift 1 check_parameters "${1:-}" PARAM_DOMAINS_TXT="${1}" ;; # PARAM_Usage: --config (-f) path/to/config # PARAM_Description: Use specified config file --config|-f) shift 1 check_parameters "${1:-}" CONFIG="${1}" ;; # PARAM_Usage: --hook (-k) path/to/hook.sh # PARAM_Description: Use specified script for hooks --hook|-k) shift 1 check_parameters "${1:-}" PARAM_HOOK="${1}" ;; # PARAM_Usage: --preferred-chain issuer-cn # PARAM_Description: Use alternative certificate chain identified by issuer CN --preferred-chain) shift 1 check_parameters "${1:-}" PARAM_PREFERRED_CHAIN="${1}" ;; # PARAM_Usage: --out (-o) certs/directory # PARAM_Description: Output certificates into the specified directory --out|-o) shift 1 check_parameters "${1:-}" PARAM_CERTDIR="${1}" ;; # PARAM_Usage: --alpn alpn-certs/directory # PARAM_Description: Output alpn verification certificates into the specified directory --alpn) shift 1 check_parameters "${1:-}" PARAM_ALPNCERTDIR="${1}" ;; # PARAM_Usage: --challenge (-t) http-01|dns-01|tls-alpn-01 # PARAM_Description: Which challenge should be used? Currently http-01, dns-01, and tls-alpn-01 are supported --challenge|-t) shift 1 check_parameters "${1:-}" PARAM_CHALLENGETYPE="${1}" ;; # PARAM_Usage: --algo (-a) rsa|prime256v1|secp384r1 # PARAM_Description: Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 --algo|-a) shift 1 check_parameters "${1:-}" PARAM_KEY_ALGO="${1}" ;; *) echo "Unknown parameter detected: ${1}" >&2 echo >&2 command_help >&2 exit 1 ;; esac shift 1 done case "${COMMAND}" in env) command_env;; sign_domains) command_sign_domains;; register) command_register;; account) command_account;; sign_csr) command_sign_csr "${PARAM_CSR}";; revoke) command_revoke "${PARAM_REVOKECERT}";; deactivate) command_deactivate;; cleanup) command_cleanup;; terms) command_terms;; cleanupdelete) command_cleanupdelete;; version) command_version;; *) command_help; exit 1;; esac exit "${exit_with_errorcode}" } # Determine OS type OSTYPE="$(uname)" if [[ ! "${DEHYDRATED_NOOP:-}" = "NOOP" ]]; then # Run script main "${@:-}" fi # vi: expandtab sw=2 ts=2