├── .gitignore ├── README.md ├── accounts └── README.txt ├── archive ├── README.md ├── config │ ├── config.sh │ ├── creds.json │ └── domains.txt ├── le_hook.py └── letsencrypt.sh ├── certs └── README.txt ├── config ├── cron_wrapper ├── domains.txt ├── hook_script.py ├── img ├── le_cert_details.png └── le_certs_bigip.png ├── rule_le_challenge.iRule ├── virtual_servers.example └── wellknown └── README.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # project stuff 2 | !accounts/README.txt 3 | !certs/README.txt 4 | !wellknown/README.txt 5 | accounts/ 6 | certs/ 7 | wellknown/ 8 | 9 | 10 | # Created by .ignore support plugin (hsz.mobi) 11 | ### Python template 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | env/ 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *,cover 57 | .hypothesis/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # IPython Notebook 81 | .ipynb_checkpoints 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # dotenv 90 | .env 91 | 92 | # virtualenv 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | ### VirtualEnv template 102 | # Virtualenv 103 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 104 | .Python 105 | [Bb]in 106 | [Ii]nclude 107 | [Ll]ib 108 | [Ll]ib64 109 | [Ll]ocal 110 | [Ss]cripts 111 | pyvenv.cfg 112 | .venv 113 | pip-selfcheck.json 114 | ### JetBrains template 115 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 116 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 117 | 118 | # User-specific stuff: 119 | .idea/workspace.xml 120 | .idea/tasks.xml 121 | .idea/dictionaries 122 | .idea/vcs.xml 123 | .idea/jsLibraryMappings.xml 124 | 125 | # Sensitive or high-churn files: 126 | .idea/dataSources.ids 127 | .idea/dataSources.xml 128 | .idea/dataSources.local.xml 129 | .idea/sqlDataSources.xml 130 | .idea/dynamic.xml 131 | .idea/uiDesigner.xml 132 | 133 | # Gradle: 134 | .idea/gradle.xml 135 | .idea/libraries 136 | 137 | # Mongo Explorer plugin: 138 | .idea/mongoSettings.xml 139 | 140 | .idea/ 141 | 142 | ## File-based project format: 143 | *.iws 144 | 145 | ## Plugin-specific files: 146 | 147 | # IntelliJ 148 | /out/ 149 | 150 | # mpeltonen/sbt-idea plugin 151 | .idea_modules/ 152 | 153 | # JIRA plugin 154 | atlassian-ide-plugin.xml 155 | 156 | # Crashlytics plugin (for Android Studio and IntelliJ) 157 | com_crashlytics_export_strings.xml 158 | crashlytics.properties 159 | crashlytics-build.properties 160 | fabric.properties 161 | 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Synopsis 2 | 3 | This project is a rewrite of my original project ([archived](archive)) based on Lukas2511's letsencrypt.sh shell script 4 | as the basis for deploying certificates to an F5 BIG-IP. This update utilizes Lukas2511's 5 | [dehydrated](https://github.com/dehydrated-io/dehydrated) 6 | acme client. 7 | 8 | Secondly, this update uses the HTTP challenge instead of the DNS challenge I used in the original project. 9 | 10 | Finally, it still utilizes F5's iControl REST interface to upload and configure the certificates, but I swap 11 | out the mostly-retired [f5-sdk](https://github.com/f5networks/f5-common-python) library for the 12 | [bigrest](https://github.com/leonardobdes/BIGREST) library. 13 | 14 | **(Overridden by Tim Riker) Removed from this project altogether is the creation of client SSL profiles, as that is a separate function 15 | than certificate management and should have its own workflow. 16 | 17 | ### [Tim Riker](https://rikers.org) added 18 | * run as non-root in a working directory 19 | * include full chain in .crt so no separate chain is needed 20 | * create or update certs/keys 21 | * create client-ssl profiles if missing 22 | * irule uses a datagroup to handle multiple challenges for multiple names in a certificate. 23 | 24 | ## [Scott Campbell](https://github.com/ScottECampbell) September 2023 25 | 26 | * Moved all environmental variables to configuration files so that this script can be used on multiple domains/certificates on the same run of dehydrated. 27 | * Added ".f5creds" JSON file which gives the host, user and password for LB access. The user defined MUST be a LB Administrator otherwise the REST API will not function properly. 28 | * Added "virtual_servers" JSON file which maps the SSL certificate domains to their LB Virtual Server names since they do not always match **HTTP version of VS if available. 29 | * Added logic for SAN certs where both domains need verification and irule will be attached (and attempted deletion) on both domains. 30 | * Added variable to hook_script.py for SSL Client Parent Profile used for new SSL profiles with all desired settings included. 31 | * Fixed logic bug that would break processing if met with VS with no existing irules on LB. 32 | * Created bash script "cron_wrapper" so that process can be run from cron or command line and includes activation of correct python virtual environment (if required). 33 | 34 | ## Getting Started 35 | 36 | Install dehydrated. On Debian based distros this probably works: 37 | 38 | ```bash 39 | $ sudo apt install dehydrated 40 | ``` 41 | 42 | Install bigrest in python 43 | ```bash 44 | $ pip install bigrest 45 | ``` 46 | Set CONTACT_EMAIL in config to your email. 47 | 48 | register with dehydrated 49 | ```bash 50 | $ dehydrated -f config --register --accept-terms 51 | ``` 52 | 53 | Edit virtual_servers file to include domain x Virtual Server mapping 54 | 55 | Add your domains and aliases to domains.txt and try a request 56 | ```bash 57 | $ dehydrated -f config -c --force --force-validation 58 | ``` 59 | 60 | 61 | ## Test Setup 62 | ```bash 63 | config # Dehydrated configuration file (edit CONTACT_EMAIL) 64 | domains.txt # Domains to sign and generate certs for (add names and aliases) 65 | virtual_servers # Domains x Virtual Servers mapping on F5 Loadbalancer 66 | dehydrated # acme client (install) 67 | bigrest # install python library 68 | rule_le_challenge.iRule # iRule configured and deployed to BIG-IP by the hook script 69 | hook_script.py # Python script called by dehydrated for special steps in the cert generation process 70 | 71 | # Environment Variables (credentials moved to .f5creds file, vs_vip listings moved to virtual_servers file - S Campbell) 72 | #export F5_HOST=f.q.d.n 73 | #export F5_USER=admin 74 | #export F5_PASS=admin 75 | #export F5_HTTP=vs_vip-name_HTTP 76 | #export F5_HTTPS=vs_vip-name_HTTPS 77 | ``` 78 | ## Usage 79 | 80 | ### Testing - Stage API 81 | ```bash 82 | $ dehydrated -f config -c --force --force-validation 83 | ``` 84 | 85 | ### Otherwise 86 | ```bash 87 | $ dehydrated -f config -c -g 88 | or 89 | $ cron_wrapper 90 | ``` 91 | 92 | ## Expected Output 93 | 94 | ```bash 95 | $ dehydrated -f config -c --force --force-validation 96 | # INFO: Using main config file config 97 | Processing example.com 98 | + Checking domain name(s) of existing cert... unchanged. 99 | + Checking expire date of existing cert... 100 | + Valid till Dec 7 17:08:55 2022 GMT (Longer than 30 days). Ignoring because renew was forced! 101 | + Signing domains... 102 | + Generating private key... 103 | + Generating signing request... 104 | + Requesting new certificate order from CA... 105 | + Received 1 authorizations URLs from the CA 106 | + Handling authorization for example.com 107 | + A valid authorization has been found but will be ignored 108 | + 1 pending challenge(s) 109 | + Deploying challenge tokens... 110 | + (hook) Deploying Challenge example.com 111 | + (hook) irule rule_le_challenge added. 112 | + (hook) datagroup dg_le_challenge added. 113 | + (hook) Challenge rule added to virtual vs_example.com_HTTP. 114 | + (hook) Challenge added to datagroup dg_le_challenge for example.com. 115 | + Responding to challenge for example.com authorization... 116 | + Challenge is valid! 117 | + Cleaning challenge tokens... 118 | + (hook) Cleaning Challenge example.com 119 | + (hook) Challenge rule rule_le_challenge removed from virtual vs_example.com_HTTP. 120 | + (hook) irule rule_le_challenge removed. 121 | + (hook) datagroup dg_le_challenge removed. 122 | + Requesting certificate... 123 | + Checking certificate... 124 | + Done! 125 | + Creating fullchain.pem... 126 | + (hook) Deploying Certs example.com 127 | + (hook) Cert/Key example.com updated in transaction. 128 | + Done! 129 | ``` 130 | ![Certs on BIG-IP](img/le_certs_bigip.png) 131 | ![Cert Details](img/le_cert_details.png) 132 | 133 | ## Caveats 134 | I tested one use case for a standard domain. Let's Encrypt and dehydrated support far more 135 | than I tested, so you'll likely need to do additional development to support those. 136 | 137 | * S Campbell - added ability for multiple certificates including SAN certificates 138 | * virtual_servers file needs an entry for EACH SAN. Could investigate "HOOK_CHAIN=yes" functionality in dehydrated and then change hook script to deal with all SANs at once. 139 | 140 | ## Contributors 141 | 142 | This update is made possible by: 143 | 144 | * https://github.com/dehydrated-io/dehydrated 145 | -------------------------------------------------------------------------------- /accounts/README.txt: -------------------------------------------------------------------------------- 1 | # dehydrated accounts are stored here 2 | -------------------------------------------------------------------------------- /archive/README.md: -------------------------------------------------------------------------------- 1 | ## Synopsis 2 | 3 | This project uses Lukas2511's letsencrypt.sh shell script as the basis for deploying certificates to an F5 BIG-IP. 4 | 5 | It utilizes the DNS challenge and reaches out to name.com's API (currently beta) for the challenge setup and teardown. Major (below reference) has example for Rackspace DNS that this is based on. 6 | 7 | It utilizes F5's iControl REST interface to upload and configure the certificates into a clientssl profile for SSL offloading capability. 8 | 9 | ## Usage 10 | 11 | ./letsencrypt.sh -c -f /var/tmp/le/config/config.sh 12 | 13 | where the configuration options are defined as appropriate in config.sh 14 | 15 | ## Contributors 16 | 17 | Much of this project is based on the work of these projects: 18 | 19 | * https://devcentral.f5.com/codeshare/lets-encrypt-on-a-big-ip 20 | * https://github.com/lukas2511/letsencrypt.sh 21 | * https://github.com/sporky/letsencrypt-dns 22 | * https://github.com/major/letsencrypt-rackspace-hook 23 | -------------------------------------------------------------------------------- /archive/config/config.sh: -------------------------------------------------------------------------------- 1 | # This is the main config file for letsencrypt.sh # 2 | # # 3 | # This file is looked for in the following locations: # 4 | # $SCRIPTDIR/config.sh (next to this script) # 5 | # /usr/local/etc/letsencrypt.sh/config.sh # 6 | # /etc/letsencrypt.sh/config.sh # 7 | # ${PWD}/config.sh (in current working-directory) # 8 | # # 9 | # Default values of this config are in comments # 10 | ######################################################## 11 | 12 | 13 | # E-mail to use during the registration (default: ) 14 | CONTACT_EMAIL=youremail@yourdomain.tld 15 | 16 | # Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 17 | KEY_ALGO=rsa 18 | 19 | # Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory) 20 | #CA="https://acme-v01.api.letsencrypt.org/directory" 21 | CA="https://acme-staging.api.letsencrypt.org/directory" 22 | 23 | # Path to license agreement (default: https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf) 24 | #LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" 25 | LICENSE="https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf" 26 | 27 | # Which challenge should be used? Currently http-01 and dns-01 are supported 28 | CHALLENGETYPE="dns-01" 29 | 30 | # Path to a directory containing additional config files, allowing to override 31 | # the defaults found in the main configuration file. Additional config files 32 | # in this directory needs to be named with a '.sh' ending. 33 | # default: 34 | CONFIG_D="/var/tmp/le/config" 35 | 36 | # Program or function called in certain situations 37 | # 38 | # After generating the challenge-response, or after failed challenge (in this case altname is empty) 39 | # Given arguments: clean_challenge|deploy_challenge altname token-filename token-content 40 | # 41 | # After successfully signing certificate 42 | # Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem 43 | # 44 | # BASEDIR and WELLKNOWN variables are exported and can be used in an external program 45 | # default: 46 | HOOK=/var/tmp/le/le_hook.py 47 | 48 | 49 | # Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined) 50 | #BASEDIR=$SCRIPTDIR 51 | 52 | # Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: $BASEDIR/.acme-challenges) 53 | #WELLKNOWN="${BASEDIR}/.acme-challenges" 54 | 55 | # Location of private account key (default: $BASEDIR/private_key.pem) 56 | #ACCOUNT_KEY="${BASEDIR}/private_key.pem" 57 | 58 | # Location of private account registration information (default: $BASEDIR/private_key.json) 59 | #ACCOUNT_KEY_JSON="${BASEDIR}/private_key.json" 60 | 61 | # Default keysize for private keys (default: 4096) 62 | #KEYSIZE="4096" 63 | 64 | # Path to openssl config file (default: - tries to figure out system default) 65 | OPENSSL_CNF=/etc/ssl/openssl.cnf 66 | 67 | # Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no) 68 | #HOOK_CHAIN="no" 69 | 70 | # Minimum days before expiration to automatically renew certificate (default: 30) 71 | #RENEW_DAYS="30" 72 | 73 | # Regenerate private keys instead of just signing new certificates on renewal (default: no) 74 | #PRIVATE_KEY_RENEW="no" 75 | 76 | # Lockfile location, to prevent concurrent access (default: $BASEDIR/lock) 77 | #LOCKFILE="${BASEDIR}/lock" -------------------------------------------------------------------------------- /archive/config/creds.json: -------------------------------------------------------------------------------- 1 | {"dnsacct": "dns_username", "dnshost": "api.name.com", "apitoken": "api_key", "f5host": "172.16.44.15", "f5acct": "admin", "f5pw": "admin"} -------------------------------------------------------------------------------- /archive/config/domains.txt: -------------------------------------------------------------------------------- 1 | # example domain 2 | # domain.com www.domain.com -------------------------------------------------------------------------------- /archive/le_hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import requests 4 | import json 5 | import logging 6 | import dns.resolver 7 | from tldextract import extract 8 | from f5.bigip import ManagementRoot 9 | from f5.bigip.contexts import TransactionContextManager 10 | import os 11 | import sys 12 | import time 13 | 14 | requests.packages.urllib3.disable_warnings() 15 | 16 | # slurp credentials 17 | with open('../config/creds.json', 'r') as f: 18 | config = json.load(f) 19 | f.close() 20 | 21 | api_host = config['dnshost'] 22 | api_acct = config['dnsacct'] 23 | api_token = config['apitoken'] 24 | f5_host = config['f5host'] 25 | f5_user = config['f5acct'] 26 | f5_password = config['f5pw'] 27 | 28 | # Logging 29 | logger = logging.getLogger(__name__) 30 | logger.addHandler(logging.StreamHandler()) 31 | logger.setLevel(logging.INFO) 32 | 33 | # Name.com Nameservers 34 | dns_servers = [] 35 | for ns in range(1, 5): 36 | dns_servers.append('ns%d.name.com' % ns) 37 | 38 | # Resolve IPs for nameservers 39 | resolver = dns.resolver.Resolver() 40 | namedotcom_dns_servers = [item.address for server in dns_servers 41 | for item in resolver.query(server)] 42 | 43 | 44 | def _has_dns_propagated(name, token): 45 | successes = 0 46 | for dns_server in namedotcom_dns_servers: 47 | resolver.nameservers = [dns_server] 48 | 49 | try: 50 | dns_response = resolver.query(name, 'txt') 51 | except dns.exception.DNSException as error: 52 | return False 53 | 54 | text_records = [record.strings[0] for record in dns_response] 55 | for text_record in text_records: 56 | if text_record == token: 57 | successes += 1 58 | 59 | if successes == 4: 60 | logger.info(" + (hook) All challenge records found!") 61 | return True 62 | else: 63 | return False 64 | 65 | 66 | def create_txt_record(args): 67 | """ 68 | Create a TXT DNS record via name.com's DNS API 69 | """ 70 | domain_name, token = args[0], args[2] 71 | fqdn_tuple = extract(domain_name) 72 | base_domain_name = ".".join([fqdn_tuple.domain, fqdn_tuple.suffix]) 73 | 74 | if fqdn_tuple.subdomain is '': 75 | txtrecord = u'_acme-challenge' 76 | else: 77 | txtrecord = u'_acme-challenge.{0}'.format(fqdn_tuple.subdomain) 78 | name = "{0}.{1}".format(txtrecord, base_domain_name) 79 | record = { 80 | 'hostname': txtrecord, 81 | 'type': u'TXT', 82 | 'content': token, 83 | 'ttl': u'300', 84 | 'priority': u'10' 85 | } 86 | 87 | b = requests.session() 88 | b.verify = False 89 | b.headers.update({u'Content-Type': u'application/json', 90 | u'Api-Username': api_acct, 91 | u'Api-Token': api_token}) 92 | url = u'https://{0}/api/dns/create/{1}'.format(api_host, base_domain_name) 93 | create_record = b.post(url, json.dumps(record)).json() 94 | logger.info(" + (hook) TXT record created: {0}.{1} => {2}".format( 95 | txtrecord, 96 | base_domain_name, 97 | token)) 98 | logger.info(" + (hook) Result: {0}".format(create_record['result'])) 99 | logger.info(" + (hook) Settling down for 10s...") 100 | time.sleep(10) 101 | 102 | while not _has_dns_propagated(name, token): 103 | logger.info(" + (hook) DNS not propagated, waiting 30s...") 104 | time.sleep(30) 105 | 106 | 107 | def delete_txt_record(args): 108 | """ 109 | Delete the TXT DNS challenge record via name.com's DNS API 110 | """ 111 | domain_name = args[0] 112 | fqdn_tuple = extract(domain_name) 113 | base_domain_name = ".".join([fqdn_tuple.domain, fqdn_tuple.suffix]) 114 | 115 | b = requests.session() 116 | b.verify = False 117 | b.headers.update({u'Content-Type': u'application/json', 118 | u'Api-Username': api_acct, 119 | u'Api-Token': api_token}) 120 | url = u'https://{0}/api/dns/list/{1}'.format(api_host, base_domain_name) 121 | 122 | records = b.get(url).json() 123 | 124 | for record in records['records']: 125 | if record['type'] == 'TXT' and u'_acme-challenge' in record['name']: 126 | record_id = record['record_id'] 127 | 128 | record_payload = {u'record_id': record_id} 129 | url = u'https://{0}/api/dns/delete/{1}'.format(api_host, base_domain_name) 130 | 131 | delete_record = b.post(url, json.dumps(record_payload)).json() 132 | 133 | logger.info(" + (hook) TXT record deleted: {0}".format(record_id)) 134 | logger.info(" + (hook) Result: {0}".format(delete_record['result'])) 135 | 136 | 137 | def deploy_cert(args): 138 | domain = args[0] 139 | key = args[1] 140 | cert = args[2] 141 | chain = args[4] 142 | 143 | mr = ManagementRoot(f5_host, f5_user, f5_password) 144 | 145 | # Upload files 146 | mr.shared.file_transfer.uploads.upload_file(key) 147 | mr.shared.file_transfer.uploads.upload_file(cert) 148 | mr.shared.file_transfer.uploads.upload_file(chain) 149 | 150 | # Check to see if these already exist 151 | key_status = mr.tm.sys.file.ssl_keys.ssl_key.exists( 152 | name='{0}.key'.format(domain)) 153 | cert_status = mr.tm.sys.file.ssl_certs.ssl_cert.exists( 154 | name='{0}.crt'.format(domain)) 155 | chain_status = mr.tm.sys.file.ssl_certs.ssl_cert.exists(name='le-chain.crt') 156 | 157 | if key_status and cert_status and chain_status: 158 | 159 | # Because they exist, we will modify them in a transaction 160 | tx = mr.tm.transactions.transaction 161 | with TransactionContextManager(tx) as api: 162 | 163 | modkey = api.tm.sys.file.ssl_keys.ssl_key.load( 164 | name='{0}.key'.format(domain)) 165 | modkey.sourcePath = 'file:/var/config/rest/downloads/{0}'.format( 166 | os.path.basename(key)) 167 | modkey.update() 168 | 169 | modcert = api.tm.sys.file.ssl_certs.ssl_cert.load( 170 | name='{0}.crt'.format(domain)) 171 | modcert.sourcePath = 'file:/var/config/rest/downloads/{0}'.format( 172 | os.path.basename(cert)) 173 | modcert.update() 174 | 175 | modchain = api.tm.sys.file.ssl_certs.ssl_cert.load( 176 | name='le-chain.crt') 177 | modchain.sourcePath = 'file:/var/config/rest/downloads/{0}'.format( 178 | os.path.basename(chain)) 179 | modchain.update() 180 | 181 | logger.info( 182 | " + (hook) Existing Certificate/Key updated in transaction.") 183 | 184 | else: 185 | newkey = mr.tm.sys.file.ssl_keys.ssl_key.create( 186 | name='{0}.key'.format(domain), 187 | sourcePath='file:/var/config/rest/downloads/{0}'.format( 188 | os.path.basename(key))) 189 | newcert = mr.tm.sys.file.ssl_certs.ssl_cert.create( 190 | name='{0}.crt'.format(domain), 191 | sourcePath='file:/var/config/rest/downloads/{0}'.format( 192 | os.path.basename(cert))) 193 | newchain = mr.tm.sys.file.ssl_certs.ssl_cert.create( 194 | name='le-chain.crt', 195 | sourcePath='file:/var/config/rest/downloads/{0}'.format( 196 | os.path.basename(chain))) 197 | logger.info(" + (hook) New Certificate/Key created.") 198 | 199 | # Create SSL Profile if necessary 200 | if not mr.tm.ltm.profile.client_ssls.client_ssl.exists( 201 | name='cssl.{0}'.format(domain), partition='Common'): 202 | cssl_profile = { 203 | 'name': '/Common/cssl.{0}'.format(domain), 204 | 'cert': '/Common/{0}.crt'.format(domain), 205 | 'key': '/Common/{0}.key'.format(domain), 206 | 'chain': '/Common/le-chain.crt', 207 | 'defaultsFrom': '/Common/clientssl' 208 | } 209 | mr.tm.ltm.profile.client_ssls.client_ssl.create(**cssl_profile) 210 | 211 | 212 | def unchanged_cert(args): 213 | logger.info(" + (hook) No changes necessary. ") 214 | 215 | 216 | def main(argv): 217 | """ 218 | The main logic of the hook. 219 | letsencrypt.sh will pass different arguments for different types of 220 | operations. The hook calls different functions based on the arguments 221 | passed. 222 | """ 223 | ops = { 224 | 'deploy_challenge': create_txt_record, 225 | 'clean_challenge': delete_txt_record, 226 | 'deploy_cert': deploy_cert, 227 | 'unchanged_cert': unchanged_cert, 228 | } 229 | logger.info(" + (hook) executing: {0}".format(argv[0])) 230 | ops[argv[0]](argv[1:]) 231 | 232 | 233 | if __name__ == '__main__': 234 | main(sys.argv[1:]) 235 | -------------------------------------------------------------------------------- /archive/letsencrypt.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # letsencrypt.sh by lukas2511 4 | # Source: https://github.com/lukas2511/letsencrypt.sh 5 | # 6 | # This script is licensed under The MIT License (see LICENSE for more information). 7 | 8 | set -e 9 | set -u 10 | set -o pipefail 11 | [[ -n "${ZSH_VERSION:-}" ]] && set -o SH_WORD_SPLIT && set +o FUNCTION_ARGZERO 12 | umask 077 # paranoid umask, we're creating private keys 13 | 14 | # Find directory in which this script is stored by traversing all symbolic links 15 | SOURCE="${0}" 16 | while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink 17 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 18 | SOURCE="$(readlink "$SOURCE")" 19 | [[ $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 20 | done 21 | SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 22 | 23 | BASEDIR="${SCRIPTDIR}" 24 | 25 | # Create (identifiable) temporary files 26 | _mktemp() { 27 | # shellcheck disable=SC2068 28 | mktemp ${@:-} "${TMPDIR:-/tmp}/letsencrypt.sh-XXXXXX" 29 | } 30 | 31 | # Check for script dependencies 32 | check_dependencies() { 33 | # just execute some dummy and/or version commands to see if required tools exist and are actually usable 34 | openssl version > /dev/null 2>&1 || _exiterr "This script requires an openssl binary." 35 | _sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requires sed with support for extended (modern) regular expressions." 36 | command -v grep > /dev/null 2>&1 || _exiterr "This script requires grep." 37 | _mktemp -u > /dev/null 2>&1 || _exiterr "This script requires mktemp." 38 | diff -u /dev/null /dev/null || _exiterr "This script requires diff." 39 | 40 | # curl returns with an error code in some ancient versions so we have to catch that 41 | set +e 42 | curl -V > /dev/null 2>&1 43 | retcode="$?" 44 | set -e 45 | if [[ ! "${retcode}" = "0" ]] && [[ ! "${retcode}" = "2" ]]; then 46 | _exiterr "This script requires curl." 47 | fi 48 | } 49 | 50 | store_configvars() { 51 | __KEY_ALGO="${KEY_ALGO}" 52 | __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" 53 | __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}" 54 | __KEYSIZE="${KEYSIZE}" 55 | __CHALLENGETYPE="${CHALLENGETYPE}" 56 | __HOOK="${HOOK}" 57 | __WELLKNOWN="${WELLKNOWN}" 58 | __HOOK_CHAIN="${HOOK_CHAIN}" 59 | __OPENSSL_CNF="${OPENSSL_CNF}" 60 | __RENEW_DAYS="${RENEW_DAYS}" 61 | __IP_VERSION="${IP_VERSION}" 62 | } 63 | 64 | reset_configvars() { 65 | KEY_ALGO="${__KEY_ALGO}" 66 | OCSP_MUST_STAPLE="${__OCSP_MUST_STAPLE}" 67 | PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}" 68 | KEYSIZE="${__KEYSIZE}" 69 | CHALLENGETYPE="${__CHALLENGETYPE}" 70 | HOOK="${__HOOK}" 71 | WELLKNOWN="${__WELLKNOWN}" 72 | HOOK_CHAIN="${__HOOK_CHAIN}" 73 | OPENSSL_CNF="${__OPENSSL_CNF}" 74 | RENEW_DAYS="${__RENEW_DAYS}" 75 | IP_VERSION="${__IP_VERSION}" 76 | } 77 | 78 | # verify configuration values 79 | verify_config() { 80 | [[ "${CHALLENGETYPE}" =~ (http-01|dns-01) ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... can not continue." 81 | if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then 82 | _exiterr "Challenge type dns-01 needs a hook script for deployment... can not continue." 83 | fi 84 | if [[ "${CHALLENGETYPE}" = "http-01" && ! -d "${WELLKNOWN}" ]]; then 85 | _exiterr "WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions." 86 | fi 87 | [[ "${KEY_ALGO}" =~ ^(rsa|prime256v1|secp384r1)$ ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... can not continue." 88 | if [[ -n "${IP_VERSION}" ]]; then 89 | [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... can not continue." 90 | fi 91 | } 92 | 93 | # Setup default config values, search for and load configuration files 94 | load_config() { 95 | # Check for config in various locations 96 | if [[ -z "${CONFIG:-}" ]]; then 97 | for check_config in "/etc/letsencrypt.sh" "/usr/local/etc/letsencrypt.sh" "${PWD}" "${SCRIPTDIR}"; do 98 | if [[ -f "${check_config}/config" ]]; then 99 | BASEDIR="${check_config}" 100 | CONFIG="${check_config}/config" 101 | break 102 | fi 103 | done 104 | fi 105 | 106 | # Default values 107 | CA="https://acme-v01.api.letsencrypt.org/directory" 108 | LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" 109 | CERTDIR= 110 | ACCOUNTDIR= 111 | CHALLENGETYPE="http-01" 112 | CONFIG_D= 113 | DOMAINS_D= 114 | DOMAINS_TXT= 115 | HOOK= 116 | HOOK_CHAIN="no" 117 | RENEW_DAYS="30" 118 | KEYSIZE="4096" 119 | WELLKNOWN= 120 | PRIVATE_KEY_RENEW="yes" 121 | KEY_ALGO=rsa 122 | OPENSSL_CNF="$(openssl version -d | cut -d\" -f2)/openssl.cnf" 123 | CONTACT_EMAIL= 124 | LOCKFILE= 125 | OCSP_MUST_STAPLE="no" 126 | IP_VERSION= 127 | 128 | if [[ -z "${CONFIG:-}" ]]; then 129 | echo "#" >&2 130 | echo "# !! WARNING !! No main config file found, using default config!" >&2 131 | echo "#" >&2 132 | elif [[ -f "${CONFIG}" ]]; then 133 | echo "# INFO: Using main config file ${CONFIG}" 134 | BASEDIR="$(dirname "${CONFIG}")" 135 | # shellcheck disable=SC1090 136 | . "${CONFIG}" 137 | else 138 | _exiterr "Specified config file doesn't exist." 139 | fi 140 | 141 | if [[ -n "${CONFIG_D}" ]]; then 142 | if [[ ! -d "${CONFIG_D}" ]]; then 143 | _exiterr "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory." >&2 144 | fi 145 | 146 | for check_config_d in "${CONFIG_D}"/*.sh; do 147 | if [[ ! -e "${check_config_d}" ]]; then 148 | echo "# !! WARNING !! Extra configuration directory ${CONFIG_D} exists, but no configuration found in it." >&2 149 | break 150 | elif [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then 151 | echo "# INFO: Using additional config file ${check_config_d}" 152 | # shellcheck disable=SC1090 153 | . "${check_config_d}" 154 | else 155 | _exiterr "Specified additional config ${check_config_d} is not readable or not a file at all." >&2 156 | fi 157 | done 158 | fi 159 | 160 | # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. 161 | BASEDIR="${BASEDIR%%/}" 162 | 163 | # Check BASEDIR and set default variables 164 | [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}" 165 | 166 | CAHASH="$(echo "${CA}" | urlbase64)" 167 | [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts" 168 | mkdir -p "${ACCOUNTDIR}/${CAHASH}" 169 | [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config" 170 | ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem" 171 | ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json" 172 | 173 | if [[ -f "${BASEDIR}/private_key.pem" ]] && [[ ! -f "${ACCOUNT_KEY}" ]]; then 174 | echo "! Moving private_key.pem to ${ACCOUNT_KEY}" 175 | mv "${BASEDIR}/private_key.pem" "${ACCOUNT_KEY}" 176 | fi 177 | if [[ -f "${BASEDIR}/private_key.json" ]] && [[ ! -f "${ACCOUNT_KEY_JSON}" ]]; then 178 | echo "! Moving private_key.json to ${ACCOUNT_KEY_JSON}" 179 | mv "${BASEDIR}/private_key.json" "${ACCOUNT_KEY_JSON}" 180 | fi 181 | 182 | [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs" 183 | [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" 184 | [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/letsencrypt" 185 | [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock" 186 | [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE="" 187 | 188 | [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" 189 | [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" 190 | [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}" 191 | [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}" 192 | [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}" 193 | [[ -n "${PARAM_IP_VERSION:-}" ]] && IP_VERSION="${PARAM_IP_VERSION}" 194 | 195 | verify_config 196 | store_configvars 197 | } 198 | 199 | # Initialize system 200 | init_system() { 201 | load_config 202 | 203 | # Lockfile handling (prevents concurrent access) 204 | if [[ -n "${LOCKFILE}" ]]; then 205 | LOCKDIR="$(dirname "${LOCKFILE}")" 206 | [[ -w "${LOCKDIR}" ]] || _exiterr "Directory ${LOCKDIR} for LOCKFILE ${LOCKFILE} is not writable, aborting." 207 | ( set -C; date > "${LOCKFILE}" ) 2>/dev/null || _exiterr "Lock file '${LOCKFILE}' present, aborting." 208 | remove_lock() { rm -f "${LOCKFILE}"; } 209 | trap 'remove_lock' EXIT 210 | fi 211 | 212 | # Get CA URLs 213 | CA_DIRECTORY="$(http_request get "${CA}")" 214 | CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" && 215 | CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" && 216 | CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" && 217 | # shellcheck disable=SC2015 218 | CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" || 219 | _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." 220 | 221 | # Export some environment variables to be used in hook script 222 | export WELLKNOWN BASEDIR CERTDIR CONFIG 223 | 224 | # Checking for private key ... 225 | register_new_key="no" 226 | if [[ -n "${PARAM_ACCOUNT_KEY:-}" ]]; then 227 | # a private key was specified from the command line so use it for this run 228 | echo "Using private key ${PARAM_ACCOUNT_KEY} instead of account key" 229 | ACCOUNT_KEY="${PARAM_ACCOUNT_KEY}" 230 | ACCOUNT_KEY_JSON="${PARAM_ACCOUNT_KEY}.json" 231 | else 232 | # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) 233 | if [[ ! -e "${ACCOUNT_KEY}" ]]; then 234 | echo "+ Generating account key..." 235 | _openssl genrsa -out "${ACCOUNT_KEY}" "${KEYSIZE}" 236 | register_new_key="yes" 237 | fi 238 | fi 239 | openssl rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null || _exiterr "Account key is not valid, can not continue." 240 | 241 | # Get public components from private key and calculate thumbprint 242 | pubExponent64="$(printf '%x' "$(openssl rsa -in "${ACCOUNT_KEY}" -noout -text | awk '/publicExponent/ {print $2}')" | hex2bin | urlbase64)" 243 | pubMod64="$(openssl rsa -in "${ACCOUNT_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)" 244 | 245 | thumbprint="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}" | openssl dgst -sha256 -binary | urlbase64)" 246 | 247 | # If we generated a new private key in the step above we have to register it with the acme-server 248 | if [[ "${register_new_key}" = "yes" ]]; then 249 | echo "+ Registering account key with letsencrypt..." 250 | [[ ! -z "${CA_NEW_REG}" ]] || _exiterr "Certificate authority doesn't allow registrations." 251 | # If an email for the contact has been provided then adding it to the registration request 252 | FAILED=false 253 | if [[ -n "${CONTACT_EMAIL}" ]]; then 254 | (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true 255 | else 256 | (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true 257 | fi 258 | if [[ "${FAILED}" = "true" ]]; then 259 | echo 260 | echo 261 | echo "Error registering account key. See message above for more information." 262 | rm "${ACCOUNT_KEY}" "${ACCOUNT_KEY_JSON}" 263 | exit 1 264 | fi 265 | fi 266 | 267 | } 268 | 269 | # Different sed version for different os types... 270 | _sed() { 271 | if [[ "${OSTYPE}" = "Linux" ]]; then 272 | sed -r "${@}" 273 | else 274 | sed -E "${@}" 275 | fi 276 | } 277 | 278 | # Print error message and exit with error 279 | _exiterr() { 280 | echo "ERROR: ${1}" >&2 281 | exit 1 282 | } 283 | 284 | # Remove newlines and whitespace from json 285 | clean_json() { 286 | tr -d '\r\n' | _sed -e 's/ +/ /g' -e 's/\{ /{/g' -e 's/ \}/}/g' -e 's/\[ /[/g' -e 's/ \]/]/g' 287 | } 288 | 289 | # Encode data as url-safe formatted base64 290 | urlbase64() { 291 | # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' 292 | openssl base64 -e | tr -d '\n\r' | _sed -e 's:=*$::g' -e 'y:+/:-_:' 293 | } 294 | 295 | # Convert hex string to binary data 296 | hex2bin() { 297 | # Remove spaces, add leading zero, escape as hex string and parse with printf 298 | printf -- "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" 299 | } 300 | 301 | # Get string value from json dictionary 302 | get_json_string_value() { 303 | local filter 304 | filter=$(printf 's/.*"%s": *"\([^"]*\)".*/\\1/p' "$1") 305 | sed -n "${filter}" 306 | } 307 | 308 | # OpenSSL writes to stderr/stdout even when there are no errors. So just 309 | # display the output if the exit code was != 0 to simplify debugging. 310 | _openssl() { 311 | set +e 312 | out="$(openssl "${@}" 2>&1)" 313 | res=$? 314 | set -e 315 | if [[ ${res} -ne 0 ]]; then 316 | echo " + ERROR: failed to run $* (Exitcode: ${res})" >&2 317 | echo >&2 318 | echo "Details:" >&2 319 | echo "${out}" >&2 320 | echo >&2 321 | exit ${res} 322 | fi 323 | } 324 | 325 | # Send http(s) request with specified method 326 | http_request() { 327 | tempcont="$(_mktemp)" 328 | 329 | if [[ -n "${IP_VERSION:-}" ]]; then 330 | ip_version="-${IP_VERSION}" 331 | fi 332 | 333 | set +e 334 | if [[ "${1}" = "head" ]]; then 335 | statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" 336 | curlret="${?}" 337 | elif [[ "${1}" = "get" ]]; then 338 | statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}")" 339 | curlret="${?}" 340 | elif [[ "${1}" = "post" ]]; then 341 | statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -d "${3}")" 342 | curlret="${?}" 343 | else 344 | set -e 345 | _exiterr "Unknown request method: ${1}" 346 | fi 347 | set -e 348 | 349 | if [[ ! "${curlret}" = "0" ]]; then 350 | _exiterr "Problem connecting to server (${1} for ${2}; curl returned with ${curlret})" 351 | fi 352 | 353 | if [[ ! "${statuscode:0:1}" = "2" ]]; then 354 | echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 355 | echo >&2 356 | echo "Details:" >&2 357 | cat "${tempcont}" >&2 358 | rm -f "${tempcont}" 359 | 360 | # Wait for hook script to clean the challenge if used 361 | if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token:+set}" ]]; then 362 | "${HOOK}" "clean_challenge" '' "${challenge_token}" "${keyauth}" 363 | fi 364 | 365 | # remove temporary domains.txt file if used 366 | [[ -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}" 367 | exit 1 368 | fi 369 | 370 | cat "${tempcont}" 371 | rm -f "${tempcont}" 372 | } 373 | 374 | # Send signed request 375 | signed_request() { 376 | # Encode payload as urlbase64 377 | payload64="$(printf '%s' "${2}" | urlbase64)" 378 | 379 | # Retrieve nonce from acme-server 380 | nonce="$(http_request head "${CA}" | grep Replay-Nonce: | awk -F ': ' '{print $2}' | tr -d '\n\r')" 381 | 382 | # Build header with just our public key and algorithm information 383 | header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' 384 | 385 | # Build another header which also contains the previously received nonce and encode it as urlbase64 386 | protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' 387 | protected64="$(printf '%s' "${protected}" | urlbase64)" 388 | 389 | # Sign header with nonce and our payload with our private key and encode signature as urlbase64 390 | signed64="$(printf '%s' "${protected64}.${payload64}" | openssl dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)" 391 | 392 | # Send header + extended header + payload + signature to the acme-server 393 | data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' 394 | 395 | http_request post "${1}" "${data}" 396 | } 397 | 398 | # Extracts all subject names from a CSR 399 | # Outputs either the CN, or the SANs, one per line 400 | extract_altnames() { 401 | csr="${1}" # the CSR itself (not a file) 402 | 403 | if ! <<<"${csr}" openssl req -verify -noout 2>/dev/null; then 404 | _exiterr "Certificate signing request isn't valid" 405 | fi 406 | 407 | reqtext="$( <<<"${csr}" openssl req -noout -text )" 408 | if <<<"${reqtext}" grep -q '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$'; then 409 | # SANs used, extract these 410 | altnames="$( <<<"${reqtext}" grep -A1 '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$' | tail -n1 )" 411 | # split to one per line: 412 | # shellcheck disable=SC1003 413 | altnames="$( <<<"${altnames}" _sed -e 's/^[[:space:]]*//; s/, /\'$'\n''/g' )" 414 | # we can only get DNS: ones signed 415 | if grep -qv '^DNS:' <<<"${altnames}"; then 416 | _exiterr "Certificate signing request contains non-DNS Subject Alternative Names" 417 | fi 418 | # strip away the DNS: prefix 419 | altnames="$( <<<"${altnames}" _sed -e 's/^DNS://' )" 420 | echo "${altnames}" 421 | 422 | else 423 | # No SANs, extract CN 424 | altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.* CN=([^ /,]*).*/\1/' )" 425 | echo "${altnames}" 426 | fi 427 | } 428 | 429 | # Create certificate for domain(s) and outputs it FD 3 430 | sign_csr() { 431 | csr="${1}" # the CSR itself (not a file) 432 | 433 | if { true >&3; } 2>/dev/null; then 434 | : # fd 3 looks OK 435 | else 436 | _exiterr "sign_csr: FD 3 not open" 437 | fi 438 | 439 | shift 1 || true 440 | altnames="${*:-}" 441 | if [ -z "${altnames}" ]; then 442 | altnames="$( extract_altnames "${csr}" )" 443 | fi 444 | 445 | if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then 446 | _exiterr "Certificate authority doesn't allow certificate signing" 447 | fi 448 | 449 | local idx=0 450 | if [[ -n "${ZSH_VERSION:-}" ]]; then 451 | local -A challenge_uris challenge_tokens keyauths deploy_args 452 | else 453 | local -a challenge_uris challenge_tokens keyauths deploy_args 454 | fi 455 | 456 | # Request challenges 457 | for altname in ${altnames}; do 458 | # Ask the acme-server for new challenge token and extract them from the resulting json block 459 | echo " + Requesting challenge for ${altname}..." 460 | response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${altname}"'"}}' | clean_json)" 461 | 462 | challenges="$(printf '%s\n' "${response}" | sed -n 's/.*\("challenges":[^\[]*\[[^]]*]\).*/\1/p')" 463 | repl=$'\n''{' # fix syntax highlighting in Vim 464 | challenge="$(printf "%s" "${challenges//\{/${repl}}" | grep \""${CHALLENGETYPE}"\")" 465 | challenge_token="$(printf '%s' "${challenge}" | get_json_string_value token | _sed 's/[^A-Za-z0-9_\-]/_/g')" 466 | challenge_uri="$(printf '%s' "${challenge}" | get_json_string_value uri)" 467 | 468 | if [[ -z "${challenge_token}" ]] || [[ -z "${challenge_uri}" ]]; then 469 | _exiterr "Can't retrieve challenges (${response})" 470 | fi 471 | 472 | # Challenge response consists of the challenge token and the thumbprint of our public certificate 473 | keyauth="${challenge_token}.${thumbprint}" 474 | 475 | case "${CHALLENGETYPE}" in 476 | "http-01") 477 | # Store challenge response in well-known location and make world-readable (so that a webserver can access it) 478 | printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_token}" 479 | chmod a+r "${WELLKNOWN}/${challenge_token}" 480 | keyauth_hook="${keyauth}" 481 | ;; 482 | "dns-01") 483 | # Generate DNS entry content for dns-01 validation 484 | keyauth_hook="$(printf '%s' "${keyauth}" | openssl dgst -sha256 -binary | urlbase64)" 485 | ;; 486 | esac 487 | 488 | challenge_uris[${idx}]="${challenge_uri}" 489 | keyauths[${idx}]="${keyauth}" 490 | challenge_tokens[${idx}]="${challenge_token}" 491 | # Note: assumes args will never have spaces! 492 | deploy_args[${idx}]="${altname} ${challenge_token} ${keyauth_hook}" 493 | idx=$((idx+1)) 494 | done 495 | 496 | # Wait for hook script to deploy the challenges if used 497 | # shellcheck disable=SC2068 498 | [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[@]} 499 | 500 | # Respond to challenges 501 | idx=0 502 | for altname in ${altnames}; do 503 | challenge_token="${challenge_tokens[${idx}]}" 504 | keyauth="${keyauths[${idx}]}" 505 | 506 | # Wait for hook script to deploy the challenge if used 507 | # shellcheck disable=SC2086 508 | [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]} 509 | 510 | # Ask the acme-server to verify our challenge and wait until it is no longer pending 511 | echo " + Responding to challenge for ${altname}..." 512 | result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauth}"'"}' | clean_json)" 513 | 514 | reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)" 515 | 516 | while [[ "${reqstatus}" = "pending" ]]; do 517 | sleep 1 518 | result="$(http_request get "${challenge_uris[${idx}]}")" 519 | reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)" 520 | done 521 | 522 | [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_token}" 523 | 524 | # Wait for hook script to clean the challenge if used 525 | if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token}" ]]; then 526 | # shellcheck disable=SC2086 527 | "${HOOK}" "clean_challenge" ${deploy_args[${idx}]} 528 | fi 529 | idx=$((idx+1)) 530 | 531 | if [[ "${reqstatus}" = "valid" ]]; then 532 | echo " + Challenge is valid!" 533 | else 534 | break 535 | fi 536 | done 537 | 538 | # Wait for hook script to clean the challenges if used 539 | # shellcheck disable=SC2068 540 | [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "clean_challenge" ${deploy_args[@]} 541 | 542 | if [[ "${reqstatus}" != "valid" ]]; then 543 | # Clean up any remaining challenge_tokens if we stopped early 544 | if [[ "${CHALLENGETYPE}" = "http-01" ]]; then 545 | while [ ${idx} -lt ${#challenge_tokens[@]} ]; do 546 | rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" 547 | idx=$((idx+1)) 548 | done 549 | fi 550 | 551 | _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})" 552 | fi 553 | 554 | # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem 555 | echo " + Requesting certificate..." 556 | csr64="$( <<<"${csr}" openssl req -outform DER | urlbase64)" 557 | crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | openssl base64 -e)" 558 | crt="$( printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" )" 559 | 560 | # Try to load the certificate to detect corruption 561 | echo " + Checking certificate..." 562 | _openssl x509 -text <<<"${crt}" 563 | 564 | echo "${crt}" >&3 565 | 566 | unset challenge_token 567 | echo " + Done!" 568 | } 569 | 570 | # Create certificate for domain(s) 571 | sign_domain() { 572 | domain="${1}" 573 | altnames="${*}" 574 | timestamp="$(date +%s)" 575 | 576 | echo " + Signing domains..." 577 | if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then 578 | _exiterr "Certificate authority doesn't allow certificate signing" 579 | fi 580 | 581 | # If there is no existing certificate directory => make it 582 | if [[ ! -e "${CERTDIR}/${domain}" ]]; then 583 | echo " + Creating new directory ${CERTDIR}/${domain} ..." 584 | mkdir -p "${CERTDIR}/${domain}" || _exiterr "Unable to create directory ${CERTDIR}/${domain}" 585 | fi 586 | 587 | privkey="privkey.pem" 588 | # generate a new private key if we need or want one 589 | if [[ ! -r "${CERTDIR}/${domain}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then 590 | echo " + Generating private key..." 591 | privkey="privkey-${timestamp}.pem" 592 | case "${KEY_ALGO}" in 593 | rsa) _openssl genrsa -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem" "${KEYSIZE}";; 594 | prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem";; 595 | esac 596 | fi 597 | 598 | # Generate signing request config and the actual signing request 599 | echo " + Generating signing request..." 600 | SAN="" 601 | for altname in ${altnames}; do 602 | SAN+="DNS:${altname}, " 603 | done 604 | SAN="${SAN%%, }" 605 | local tmp_openssl_cnf 606 | tmp_openssl_cnf="$(_mktemp)" 607 | cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" 608 | printf "[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}" 609 | if [ "${OCSP_MUST_STAPLE}" = "yes" ]; then 610 | printf "\n1.3.6.1.5.5.7.1.24=DER:30:03:02:01:05" >> "${tmp_openssl_cnf}" 611 | fi 612 | openssl req -new -sha256 -key "${CERTDIR}/${domain}/${privkey}" -out "${CERTDIR}/${domain}/cert-${timestamp}.csr" -subj "/CN=${domain}/" -reqexts SAN -config "${tmp_openssl_cnf}" 613 | rm -f "${tmp_openssl_cnf}" 614 | 615 | crt_path="${CERTDIR}/${domain}/cert-${timestamp}.pem" 616 | # shellcheck disable=SC2086 617 | sign_csr "$(< "${CERTDIR}/${domain}/cert-${timestamp}.csr" )" ${altnames} 3>"${crt_path}" 618 | 619 | # Create fullchain.pem 620 | echo " + Creating fullchain.pem..." 621 | cat "${crt_path}" > "${CERTDIR}/${domain}/fullchain-${timestamp}.pem" 622 | http_request get "$(openssl x509 -in "${CERTDIR}/${domain}/cert-${timestamp}.pem" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${CERTDIR}/${domain}/chain-${timestamp}.pem" 623 | if ! grep -q "BEGIN CERTIFICATE" "${CERTDIR}/${domain}/chain-${timestamp}.pem"; then 624 | openssl x509 -in "${CERTDIR}/${domain}/chain-${timestamp}.pem" -inform DER -out "${CERTDIR}/${domain}/chain-${timestamp}.pem" -outform PEM 625 | fi 626 | cat "${CERTDIR}/${domain}/chain-${timestamp}.pem" >> "${CERTDIR}/${domain}/fullchain-${timestamp}.pem" 627 | 628 | # Update symlinks 629 | [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${CERTDIR}/${domain}/privkey.pem" 630 | 631 | ln -sf "chain-${timestamp}.pem" "${CERTDIR}/${domain}/chain.pem" 632 | ln -sf "fullchain-${timestamp}.pem" "${CERTDIR}/${domain}/fullchain.pem" 633 | ln -sf "cert-${timestamp}.csr" "${CERTDIR}/${domain}/cert.csr" 634 | ln -sf "cert-${timestamp}.pem" "${CERTDIR}/${domain}/cert.pem" 635 | 636 | # Wait for hook script to clean the challenge and to deploy cert if used 637 | export KEY_ALGO 638 | [[ -n "${HOOK}" ]] && "${HOOK}" "deploy_cert" "${domain}" "${CERTDIR}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem" "${timestamp}" 639 | 640 | unset challenge_token 641 | echo " + Done!" 642 | } 643 | 644 | # Usage: --cron (-c) 645 | # Description: Sign/renew non-existant/changed/expiring certificates. 646 | command_sign_domains() { 647 | init_system 648 | 649 | if [[ -n "${PARAM_DOMAIN:-}" ]]; then 650 | DOMAINS_TXT="$(_mktemp)" 651 | printf -- "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" 652 | elif [[ -e "${DOMAINS_TXT}" ]]; then 653 | if [[ ! -r "${DOMAINS_TXT}" ]]; then 654 | _exiterr "domains.txt found but not readable" 655 | fi 656 | else 657 | _exiterr "domains.txt not found and --domain not given" 658 | fi 659 | 660 | # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire 661 | ORIGIFS="${IFS}" 662 | IFS=$'\n' 663 | for line in $(<"${DOMAINS_TXT}" tr -d '\r' | tr '[:upper:]' '[:lower:]' | _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' | (grep -vE '^(#|$)' || true)); do 664 | reset_configvars 665 | IFS="${ORIGIFS}" 666 | domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" 667 | morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" 668 | cert="${CERTDIR}/${domain}/cert.pem" 669 | 670 | force_renew="${PARAM_FORCE:-no}" 671 | 672 | if [[ -z "${morenames}" ]];then 673 | echo "Processing ${domain}" 674 | else 675 | echo "Processing ${domain} with alternative names: ${morenames}" 676 | fi 677 | 678 | # read cert config 679 | # for now this loads the certificate specific config in a subshell and parses a diff of set variables. 680 | # we could just source the config file but i decided to go this way to protect people from accidentally overriding 681 | # variables used internally by this script itself. 682 | if [[ -n "${DOMAINS_D}" ]]; then 683 | certconfig="${DOMAINS_D}/${domain}" 684 | else 685 | certconfig="${CERTDIR}/${domain}/config" 686 | fi 687 | 688 | if [ -f "${certconfig}" ]; then 689 | echo " + Using certificate specific config file!" 690 | ORIGIFS="${IFS}" 691 | IFS=$'\n' 692 | for cfgline in $( 693 | beforevars="$(_mktemp)" 694 | aftervars="$(_mktemp)" 695 | set > "${beforevars}" 696 | # shellcheck disable=SC1090 697 | . "${certconfig}" 698 | set > "${aftervars}" 699 | diff -u "${beforevars}" "${aftervars}" | grep -E '^\+[^+]' 700 | rm "${beforevars}" 701 | rm "${aftervars}" 702 | ); do 703 | config_var="$(echo "${cfgline:1}" | cut -d'=' -f1)" 704 | config_value="$(echo "${cfgline:1}" | cut -d'=' -f2-)" 705 | case "${config_var}" in 706 | KEY_ALGO|OCSP_MUST_STAPLE|PRIVATE_KEY_RENEW|KEYSIZE|CHALLENGETYPE|HOOK|WELLKNOWN|HOOK_CHAIN|OPENSSL_CNF|RENEW_DAYS) 707 | echo " + ${config_var} = ${config_value}" 708 | declare -- "${config_var}=${config_value}" 709 | ;; 710 | _) ;; 711 | *) echo " ! Setting ${config_var} on a per-certificate base is not (yet) supported" 712 | esac 713 | done 714 | IFS="${ORIGIFS}" 715 | fi 716 | verify_config 717 | 718 | if [[ -e "${cert}" ]]; then 719 | printf " + Checking domain name(s) of existing cert..." 720 | 721 | certnames="$(openssl x509 -in "${cert}" -text -noout | grep DNS: | _sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//')" 722 | givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//' | _sed 's/^ //')" 723 | 724 | if [[ "${certnames}" = "${givennames}" ]]; then 725 | echo " unchanged." 726 | else 727 | echo " changed!" 728 | echo " + Domain name(s) are not matching!" 729 | echo " + Names in old certificate: ${certnames}" 730 | echo " + Configured names: ${givennames}" 731 | echo " + Forcing renew." 732 | force_renew="yes" 733 | fi 734 | fi 735 | 736 | if [[ -e "${cert}" ]]; then 737 | echo " + Checking expire date of existing cert..." 738 | valid="$(openssl x509 -enddate -noout -in "${cert}" | cut -d= -f2- )" 739 | 740 | printf " + Valid till %s " "${valid}" 741 | if openssl x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}"; then 742 | printf "(Longer than %d days). " "${RENEW_DAYS}" 743 | if [[ "${force_renew}" = "yes" ]]; then 744 | echo "Ignoring because renew was forced!" 745 | else 746 | # Certificate-Names unchanged and cert is still valid 747 | echo "Skipping renew!" 748 | [[ -n "${HOOK}" ]] && "${HOOK}" "unchanged_cert" "${domain}" "${CERTDIR}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem" 749 | continue 750 | fi 751 | else 752 | echo "(Less than ${RENEW_DAYS} days). Renewing!" 753 | fi 754 | fi 755 | 756 | # shellcheck disable=SC2086 757 | sign_domain ${line} 758 | done 759 | 760 | # remove temporary domains.txt file if used 761 | [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}" 762 | 763 | exit 0 764 | } 765 | 766 | # Usage: --signcsr (-s) path/to/csr.pem 767 | # Description: Sign a given CSR, output CRT on stdout (advanced usage) 768 | command_sign_csr() { 769 | # redirect stdout to stderr 770 | # leave stdout over at fd 3 to output the cert 771 | exec 3>&1 1>&2 772 | 773 | init_system 774 | 775 | csrfile="${1}" 776 | if [ ! -r "${csrfile}" ]; then 777 | _exiterr "Could not read certificate signing request ${csrfile}" 778 | fi 779 | 780 | # gen cert 781 | certfile="$(_mktemp)" 782 | sign_csr "$(< "${csrfile}" )" 3> "${certfile}" 783 | 784 | # get and convert ca cert 785 | chainfile="$(_mktemp)" 786 | http_request get "$(openssl x509 -in "${certfile}" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${chainfile}" 787 | 788 | if ! grep -q "BEGIN CERTIFICATE" "${chainfile}"; then 789 | openssl x509 -inform DER -in "${chainfile}" -outform PEM -out "${chainfile}" 790 | fi 791 | 792 | # output full chain 793 | echo "# CERT #" >&3 794 | cat "${certfile}" >&3 795 | echo >&3 796 | echo "# CHAIN #" >&3 797 | cat "${chainfile}" >&3 798 | 799 | # cleanup 800 | rm "${certfile}" 801 | rm "${chainfile}" 802 | 803 | exit 0 804 | } 805 | 806 | # Usage: --revoke (-r) path/to/cert.pem 807 | # Description: Revoke specified certificate 808 | command_revoke() { 809 | init_system 810 | 811 | [[ -n "${CA_REVOKE_CERT}" ]] || _exiterr "Certificate authority doesn't allow certificate revocation." 812 | 813 | cert="${1}" 814 | if [[ -L "${cert}" ]]; then 815 | # follow symlink and use real certificate name (so we move the real file and not the symlink at the end) 816 | local link_target 817 | link_target="$(readlink -n "${cert}")" 818 | if [[ "${link_target}" =~ ^/ ]]; then 819 | cert="${link_target}" 820 | else 821 | cert="$(dirname "${cert}")/${link_target}" 822 | fi 823 | fi 824 | [[ -f "${cert}" ]] || _exiterr "Could not find certificate ${cert}" 825 | 826 | echo "Revoking ${cert}" 827 | 828 | cert64="$(openssl x509 -in "${cert}" -inform PEM -outform DER | urlbase64)" 829 | response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)" 830 | # if there is a problem with our revoke request _request (via signed_request) will report this and "exit 1" out 831 | # so if we are here, it is safe to assume the request was successful 832 | echo " + Done." 833 | echo " + Renaming certificate to ${cert}-revoked" 834 | mv -f "${cert}" "${cert}-revoked" 835 | } 836 | 837 | # Usage: --cleanup (-gc) 838 | # Description: Move unused certificate files to archive directory 839 | command_cleanup() { 840 | load_config 841 | 842 | # Create global archive directory if not existant 843 | if [[ ! -e "${BASEDIR}/archive" ]]; then 844 | mkdir "${BASEDIR}/archive" 845 | fi 846 | 847 | # Loop over all certificate directories 848 | for certdir in "${CERTDIR}/"*; do 849 | # Skip if entry is not a folder 850 | [[ -d "${certdir}" ]] || continue 851 | 852 | # Get certificate name 853 | certname="$(basename "${certdir}")" 854 | 855 | # Create certitifaces archive directory if not existant 856 | archivedir="${BASEDIR}/archive/${certname}" 857 | if [[ ! -e "${archivedir}" ]]; then 858 | mkdir "${archivedir}" 859 | fi 860 | 861 | # Loop over file-types (certificates, keys, signing-requests, ...) 862 | for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem; do 863 | # Skip if symlink is broken 864 | [[ -r "${certdir}/${filetype}" ]] || continue 865 | 866 | # Look up current file in use 867 | current="$(basename "$(readlink "${certdir}/${filetype}")")" 868 | 869 | # Split filetype into name and extension 870 | filebase="$(echo "${filetype}" | cut -d. -f1)" 871 | fileext="$(echo "${filetype}" | cut -d. -f2)" 872 | 873 | # Loop over all files of this type 874 | for file in "${certdir}/${filebase}-"*".${fileext}"; do 875 | # Handle case where no files match the wildcard 876 | [[ -f "${file}" ]] || break 877 | 878 | # Check if current file is in use, if unused move to archive directory 879 | filename="$(basename "${file}")" 880 | if [[ ! "${filename}" = "${current}" ]]; then 881 | echo "Moving unused file to archive directory: ${certname}/${filename}" 882 | mv "${certdir}/${filename}" "${archivedir}/${filename}" 883 | fi 884 | done 885 | done 886 | done 887 | 888 | exit 0 889 | } 890 | 891 | # Usage: --help (-h) 892 | # Description: Show help text 893 | command_help() { 894 | printf "Usage: %s [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ...\n\n" "${0}" 895 | printf "Default command: help\n\n" 896 | echo "Commands:" 897 | grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do 898 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then 899 | _exiterr "Error generating help text." 900 | fi 901 | printf " %-32s %s\n" "${usage##"# Usage: "}" "${description##"# Description: "}" 902 | done 903 | printf -- "\nParameters:\n" 904 | grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do 905 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then 906 | _exiterr "Error generating help text." 907 | fi 908 | printf " %-32s %s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}" 909 | done 910 | } 911 | 912 | # Usage: --env (-e) 913 | # Description: Output configuration variables for use in other scripts 914 | command_env() { 915 | echo "# letsencrypt.sh configuration" 916 | load_config 917 | typeset -p CA LICENSE CERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE 918 | } 919 | 920 | # Main method (parses script arguments and calls command_* methods) 921 | main() { 922 | COMMAND="" 923 | set_command() { 924 | [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information." 925 | COMMAND="${1}" 926 | } 927 | 928 | check_parameters() { 929 | if [[ -z "${1:-}" ]]; then 930 | echo "The specified command requires additional parameters. See help:" >&2 931 | echo >&2 932 | command_help >&2 933 | exit 1 934 | elif [[ "${1:0:1}" = "-" ]]; then 935 | _exiterr "Invalid argument: ${1}" 936 | fi 937 | } 938 | 939 | [[ -z "${@}" ]] && eval set -- "--help" 940 | 941 | while (( ${#} )); do 942 | case "${1}" in 943 | --help|-h) 944 | command_help 945 | exit 0 946 | ;; 947 | 948 | --env|-e) 949 | set_command env 950 | ;; 951 | 952 | --cron|-c) 953 | set_command sign_domains 954 | ;; 955 | 956 | --signcsr|-s) 957 | shift 1 958 | set_command sign_csr 959 | check_parameters "${1:-}" 960 | PARAM_CSR="${1}" 961 | ;; 962 | 963 | --revoke|-r) 964 | shift 1 965 | set_command revoke 966 | check_parameters "${1:-}" 967 | PARAM_REVOKECERT="${1}" 968 | ;; 969 | 970 | --cleanup|-gc) 971 | set_command cleanup 972 | ;; 973 | 974 | # PARAM_Usage: --ipv4 (-4) 975 | # PARAM_Description: Resolve names to IPv4 addresses only 976 | --ipv4|-4) 977 | PARAM_IP_VERSION="4" 978 | ;; 979 | 980 | # PARAM_Usage: --ipv6 (-6) 981 | # PARAM_Description: Resolve names to IPv6 addresses only 982 | --ipv6|-6) 983 | PARAM_IP_VERSION="6" 984 | ;; 985 | 986 | # PARAM_Usage: --domain (-d) domain.tld 987 | # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!) 988 | --domain|-d) 989 | shift 1 990 | check_parameters "${1:-}" 991 | if [[ -z "${PARAM_DOMAIN:-}" ]]; then 992 | PARAM_DOMAIN="${1}" 993 | else 994 | PARAM_DOMAIN="${PARAM_DOMAIN} ${1}" 995 | fi 996 | ;; 997 | 998 | # PARAM_Usage: --force (-x) 999 | # PARAM_Description: Force renew of certificate even if it is longer valid than value in RENEW_DAYS 1000 | --force|-x) 1001 | PARAM_FORCE="yes" 1002 | ;; 1003 | 1004 | # PARAM_Usage: --no-lock (-n) 1005 | # PARAM_Description: Don't use lockfile (potentially dangerous!) 1006 | --no-lock|-n) 1007 | PARAM_NO_LOCK="yes" 1008 | ;; 1009 | 1010 | # PARAM_Usage: --ocsp 1011 | # PARAM_Description: Sets option in CSR indicating OCSP stapling to be mandatory 1012 | --ocsp) 1013 | PARAM_OCSP_MUST_STAPLE="yes" 1014 | ;; 1015 | 1016 | # PARAM_Usage: --privkey (-p) path/to/key.pem 1017 | # PARAM_Description: Use specified private key instead of account key (useful for revocation) 1018 | --privkey|-p) 1019 | shift 1 1020 | check_parameters "${1:-}" 1021 | PARAM_ACCOUNT_KEY="${1}" 1022 | ;; 1023 | 1024 | # PARAM_Usage: --config (-f) path/to/config 1025 | # PARAM_Description: Use specified config file 1026 | --config|-f) 1027 | shift 1 1028 | check_parameters "${1:-}" 1029 | CONFIG="${1}" 1030 | ;; 1031 | 1032 | # PARAM_Usage: --hook (-k) path/to/hook.sh 1033 | # PARAM_Description: Use specified script for hooks 1034 | --hook|-k) 1035 | shift 1 1036 | check_parameters "${1:-}" 1037 | PARAM_HOOK="${1}" 1038 | ;; 1039 | 1040 | # PARAM_Usage: --out (-o) certs/directory 1041 | # PARAM_Description: Output certificates into the specified directory 1042 | --out|-o) 1043 | shift 1 1044 | check_parameters "${1:-}" 1045 | PARAM_CERTDIR="${1}" 1046 | ;; 1047 | 1048 | # PARAM_Usage: --challenge (-t) http-01|dns-01 1049 | # PARAM_Description: Which challenge should be used? Currently http-01 and dns-01 are supported 1050 | --challenge|-t) 1051 | shift 1 1052 | check_parameters "${1:-}" 1053 | PARAM_CHALLENGETYPE="${1}" 1054 | ;; 1055 | 1056 | # PARAM_Usage: --algo (-a) rsa|prime256v1|secp384r1 1057 | # PARAM_Description: Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 1058 | --algo|-a) 1059 | shift 1 1060 | check_parameters "${1:-}" 1061 | PARAM_KEY_ALGO="${1}" 1062 | ;; 1063 | 1064 | *) 1065 | echo "Unknown parameter detected: ${1}" >&2 1066 | echo >&2 1067 | command_help >&2 1068 | exit 1 1069 | ;; 1070 | esac 1071 | 1072 | shift 1 1073 | done 1074 | 1075 | case "${COMMAND}" in 1076 | env) command_env;; 1077 | sign_domains) command_sign_domains;; 1078 | sign_csr) command_sign_csr "${PARAM_CSR}";; 1079 | revoke) command_revoke "${PARAM_REVOKECERT}";; 1080 | cleanup) command_cleanup;; 1081 | *) command_help; exit 1;; 1082 | esac 1083 | } 1084 | 1085 | # Determine OS type 1086 | OSTYPE="$(uname)" 1087 | 1088 | # Check for missing dependencies 1089 | check_dependencies 1090 | 1091 | # Run script 1092 | main "${@:-}" 1093 | -------------------------------------------------------------------------------- /certs/README.txt: -------------------------------------------------------------------------------- 1 | # dehydrated certs are stored here 2 | -------------------------------------------------------------------------------- /config: -------------------------------------------------------------------------------- 1 | ######################################################## 2 | # This is the main config file for dehydrated # 3 | # # 4 | # This file is looked for in the following locations: # 5 | # $SCRIPTDIR/config (next to this script) # 6 | # /usr/local/etc/dehydrated/config # 7 | # /etc/dehydrated/config # 8 | # ${PWD}/config (in current working-directory) # 9 | # # 10 | # Default values of this config are in comments # 11 | ######################################################## 12 | 13 | # Which user should dehydrated run as? This will be implicitly enforced when running as root 14 | #DEHYDRATED_USER= 15 | 16 | # Which group should dehydrated run as? This will be implicitly enforced when running as root 17 | #DEHYDRATED_GROUP= 18 | 19 | # Resolve names to addresses of IP version only. (curl) 20 | # supported values: 4, 6 21 | # default: 22 | #IP_VERSION= 23 | 24 | # URL to certificate authority or internal preset 25 | # Presets: letsencrypt, letsencrypt-test, zerossl, buypass, buypass-test 26 | # default: letsencrypt 27 | #CA="letsencrypt" 28 | CA="letsencrypt-test" 29 | 30 | # Path to old certificate authority 31 | # Set this value to your old CA value when upgrading from ACMEv1 to ACMEv2 under a different endpoint. 32 | # If dehydrated detects an account-key for the old CA it will automatically reuse that key 33 | # instead of registering a new one. 34 | # default: https://acme-v01.api.letsencrypt.org/directory 35 | #OLDCA="https://acme-v01.api.letsencrypt.org/directory" 36 | 37 | # Which challenge should be used? Currently http-01, dns-01 and tls-alpn-01 are supported 38 | #CHALLENGETYPE="http-01" 39 | CHALLENGETYPE="http-01" 40 | 41 | # Path to a directory containing additional config files, allowing to override 42 | # the defaults found in the main configuration file. Additional config files 43 | # in this directory needs to be named with a '.sh' ending. 44 | # default: 45 | #CONFIG_D= 46 | 47 | # Directory for per-domain configuration files. 48 | # If not set, per-domain configurations are sourced from each certificates output directory. 49 | # default: 50 | #DOMAINS_D= 51 | 52 | # Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined) 53 | #BASEDIR=$SCRIPTDIR 54 | 55 | # File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt) 56 | #DOMAINS_TXT="${BASEDIR}/domains.txt" 57 | 58 | # Output directory for generated certificates 59 | #CERTDIR="${BASEDIR}/certs" 60 | 61 | # Output directory for alpn verification certificates 62 | #ALPNCERTDIR="${BASEDIR}/alpn-certs" 63 | 64 | # Directory for account keys and registration information 65 | #ACCOUNTDIR="${BASEDIR}/accounts" 66 | 67 | # Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated) 68 | #WELLKNOWN="/var/www/dehydrated" 69 | WELLKNOWN="wellknown" 70 | 71 | # Default keysize for private keys (default: 4096) 72 | #KEYSIZE="4096" 73 | 74 | # Path to openssl config file (default: - tries to figure out system default) 75 | #OPENSSL_CNF= 76 | 77 | # Path to OpenSSL binary (default: "openssl") 78 | #OPENSSL="openssl" 79 | 80 | # Extra options passed to the curl binary (default: ) 81 | #CURL_OPTS= 82 | CURL_OPTS="--http1.1" 83 | 84 | # Program or function called in certain situations 85 | # 86 | # After generating the challenge-response, or after failed challenge (in this case altname is empty) 87 | # Given arguments: clean_challenge|deploy_challenge altname token-filename token-content 88 | # 89 | # After successfully signing certificate 90 | # Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem 91 | # 92 | # BASEDIR and WELLKNOWN variables are exported and can be used in an external program 93 | # default: 94 | #HOOK= 95 | HOOK=${BASEDIR}/hook_script.py 96 | 97 | # Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no) 98 | #HOOK_CHAIN="no" 99 | 100 | # Minimum days before expiration to automatically renew certificate (default: 30) 101 | #RENEW_DAYS="30" 102 | 103 | # Regenerate private keys instead of just signing new certificates on renewal (default: yes) 104 | #PRIVATE_KEY_RENEW="yes" 105 | 106 | # Create an extra private key for rollover (default: no) 107 | #PRIVATE_KEY_ROLLOVER="no" 108 | 109 | # Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 110 | #KEY_ALGO=secp384r1 111 | 112 | # E-mail to use during the registration (default: ) 113 | #CONTACT_EMAIL= 114 | 115 | # Lockfile location, to prevent concurrent access (default: $BASEDIR/lock) 116 | #LOCKFILE="${BASEDIR}/lock" 117 | 118 | # Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no) 119 | #OCSP_MUST_STAPLE="no" 120 | 121 | # Fetch OCSP responses (default: no) 122 | #OCSP_FETCH="no" 123 | 124 | # OCSP refresh interval (default: 5 days) 125 | #OCSP_DAYS=5 126 | 127 | # Issuer chain cache directory (default: $BASEDIR/chains) 128 | #CHAINCACHE="${BASEDIR}/chains" 129 | 130 | # Automatic cleanup (default: no) 131 | #AUTO_CLEANUP="no" 132 | 133 | # ACME API version (default: auto) 134 | #API=auto 135 | 136 | # Preferred issuer chain (default: -> uses default chain) 137 | #PREFERRED_CHAIN= 138 | -------------------------------------------------------------------------------- /cron_wrapper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # find where the script lives 4 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 5 | 6 | # only required if separate python environment needed - otherwise commands can be directly added to your crontab 7 | cd $SCRIPT_DIR/lets-encrypt-python 8 | source $SCRIPT_DIR/pythonvenv/python37/bin/activate 9 | ./dehydrated -c -g 2>&1 10 | -------------------------------------------------------------------------------- /domains.txt: -------------------------------------------------------------------------------- 1 | # Create certificate for 'example.org' with an alternative name of 2 | # 'www.example.org'. It will be stored in the directory ${CERT_DIR}/example.org 3 | #example.org www.example.org 4 | example.com 5 | 6 | # Create certificate for 'example.com' with alternative names of 7 | # 'www.example.com' & 'wiki.example.com'. It will be stored in the directory 8 | # ${CERT_DIR}/example.com 9 | #example.com www.example.com wiki.example.com 10 | 11 | # Using the alias 'certalias' create certificate for 'example.net' with 12 | # alternate name 'www.example.net' and store it in the directory 13 | # ${CERTDIR}/certalias 14 | #example.net www.example.net > certalias 15 | 16 | # Using the alias 'service_example_com' create a wildcard certificate for 17 | # '*.service.example.com' and store it in the directory 18 | # ${CERTDIR}/service_example_com 19 | # NOTE: It is NOT a certificate for 'service.example.com' 20 | #*.service.example.com > service_example_com 21 | 22 | # Using the alias 'star_service_example_org' create a wildcard certificate for 23 | # '*.service.example.org' with an alternative name of `service.example.org' 24 | # and store it in the directory ${CERTDIR}/star_service_example_org 25 | # NOTE: It is a certificate for 'service.example.org' 26 | #*.service.example.org service.example.org > star_service_example_org 27 | 28 | # Optionally you can also append the certificate algorithm here to create 29 | # multiple certificate types for the same domain. 30 | # 31 | # This allows to set per certificates options. How to do this is 32 | # explained in [domains.txt documentation](domains_txt.md). 33 | # 34 | #*.service.example.org service.example.org > star_service_example_org_rsa 35 | #*.service.example.org service.example.org > star_service_example_org_ecdsa 36 | 37 | # Create a certificate for 'service.example.net' with an alternative name of 38 | # '*.service.example.net' (which is a wildcard domain) and store it in the 39 | # directory ${CERTDIR}/service.example.net 40 | #service.example.net *.service.example.net -------------------------------------------------------------------------------- /hook_script.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from bigrest.bigip import BIGIP 4 | import json 5 | import logging 6 | import os 7 | import requests 8 | import sys 9 | 10 | requests.packages.urllib3.disable_warnings() 11 | 12 | # Set parent profile of all clientssl profiles created through this script 13 | BaseSSLProfile = "/Common/clientssl-secure" 14 | 15 | def get_credentials(): 16 | if 'f5_creds' in globals(): 17 | return {'host': f5_creds['HOST'], 'user': f5_creds['USER'], 'pass': f5_creds['PASS']} 18 | else: 19 | return {'host': os.getenv('F5_HOST'), 'user': os.getenv('F5_USER'), 'pass': os.getenv('F5_PASS')} 20 | 21 | def instantiate_bigip(credentials): 22 | return BIGIP(credentials.get('host'), credentials.get('user'), credentials.get('pass'), session_verify=False) 23 | 24 | def deploy_challenge(args): 25 | br = instantiate_bigip(get_credentials()) 26 | f = open('rule_le_challenge.iRule') 27 | irule = f.read() 28 | f.close() 29 | if not br.exist('/mgmt/tm/ltm/rule/rule_le_challenge'): 30 | rule = {'name': 'rule_le_challenge', 'apiAnonymous': irule} 31 | br.create('/mgmt/tm/ltm/rule', rule) 32 | logger.info(' + (hook) irule rule_le_challenge added.') 33 | if not br.exist('/mgmt/tm/ltm/data-group/internal/dg_le_challenge'): 34 | dg = {'name': 'dg_le_challenge', 'type': 'string', 'records': [{'name':'name','data':'data'}]} 35 | br.create('/mgmt/tm/ltm/data-group/internal', dg) 36 | logger.info(' + (hook) datagroup dg_le_challenge added.') 37 | if br.exist('/mgmt/tm/ltm/rule/rule_le_challenge'): 38 | vip = br.load(f'/mgmt/tm/ltm/virtual/{f5_http}') 39 | if vip.properties.get('rules') is None: 40 | vip.properties['rules'] = ['rule_le_challenge'] 41 | elif '/Common/rule_le_challenge' not in vip.properties['rules']: 42 | if not '/mgmt/tm/ltm/rule/rule_le_challenge' in vip.properties['rules']: 43 | vip.properties['rules'].insert(0,'rule_le_challenge') 44 | br.save(vip) 45 | logger.info(f' + (hook) Challenge rule added to virtual {f5_http}.') 46 | dg = br.load('/mgmt/tm/ltm/data-group/internal/dg_le_challenge') 47 | dg.properties['records'].append({'name':args[1],'data':args[2]}) 48 | br.save(dg) 49 | logger.info(f' + (hook) Challenge added to datagroup dg_le_challenge for {args[0]}.') 50 | 51 | def invalid_challenge(args): 52 | logger.info(f' + (hook) Invalid Challenge Args: {args}') 53 | sys.exit(-1) 54 | 55 | def clean_challenge(args): 56 | br = instantiate_bigip(get_credentials()) 57 | vip = br.load(f'/mgmt/tm/ltm/virtual/{f5_http}') 58 | if vip.properties.get('rules') is None: 59 | logger.info(f' + (hook) irule has already been removed probably due to SAN cert.') 60 | elif '/Common/rule_le_challenge' in vip.properties['rules']: 61 | vip.properties['rules'].remove('/Common/rule_le_challenge') 62 | br.save(vip) 63 | logger.info(f' + (hook) Challenge rule rule_le_challenge removed from virtual {f5_http}.') 64 | if br.exist('/mgmt/tm/ltm/rule/rule_le_challenge'): 65 | try: 66 | br.delete('/mgmt/tm/ltm/rule/rule_le_challenge') 67 | logger.info(f' + (hook) irule rule_le_challenge removed.') 68 | except Exception: 69 | logger.info(f' + (hook) Error: irule rule_le_challenge still attached to a Virtual Server.') 70 | if br.exist('/mgmt/tm/ltm/data-group/internal/dg_le_challenge'): 71 | try: 72 | br.delete('/mgmt/tm/ltm/data-group/internal/dg_le_challenge') 73 | logger.info(' + (hook) datagroup dg_le_challenge removed.') 74 | except Exception: 75 | logger.info(f' + (hook) Error: datagroup dg_le_challenge could not be removed.') 76 | 77 | def deploy_cert(args): 78 | br = instantiate_bigip(get_credentials()) 79 | br.upload('/mgmt/shared/file-transfer/uploads', args[1]) 80 | br.upload('/mgmt/shared/file-transfer/uploads', args[3]) 81 | key_status = br.exist(f'/mgmt/tm/sys/file/ssl-key/auto_le_{args[0]}.key') 82 | cert_status = br.exist(f'/mgmt/tm/sys/file/ssl-cert/auto_le_{args[0]}.crt') 83 | 84 | if key_status and cert_status: 85 | with br as transaction: 86 | modkey = br.load(f'/mgmt/tm/sys/file/ssl-key/auto_le_{args[0]}.key') 87 | modkey.properties['sourcePath'] = f'file:/var/config/rest/downloads/{args[1].split("/")[-1]}' 88 | br.save(modkey) 89 | modcert = br.load(f'/mgmt/tm/sys/file/ssl-cert/auto_le_{args[0]}.crt') 90 | modcert.properties['sourcePath'] = f'file:/var/config/rest/downloads/{args[3].split("/")[-1]}' 91 | br.save(modcert) 92 | logger.info(f' + (hook) Cert/Key {args[0]} updated in transaction.') 93 | else: 94 | keydata = {'name': f'auto_le_{args[0]}.key', 'sourcePath': f'file:/var/config/rest/downloads/{args[1].split("/")[-1]}'} 95 | certdata = {'name': f'auto_le_{args[0]}.crt', 'sourcePath': f'file:/var/config/rest/downloads/{args[3].split("/")[-1]}'} 96 | br.create('/mgmt/tm/sys/file/ssl-key', keydata) 97 | br.create('/mgmt/tm/sys/file/ssl-cert', certdata) 98 | logger.info(f' + (hook) Cert/Key {args[0]} created.') 99 | if not br.exist(f'/mgmt/tm/ltm/profile/client-ssl/auto_le_{args[0]}'): 100 | sslprof = { 101 | 'name' : f'auto_le_{args[0]}', 102 | 'defaultsFrom': (BaseSSLProfile), 103 | 'certKeyChain': [{ 104 | 'name': f'{args[0]}_0', 105 | 'cert': f'/Common/auto_le_{args[0]}.crt', 106 | 'key': f'/Common/auto_le_{args[0]}.key' 107 | }] 108 | } 109 | logger.info(sslprof) 110 | br.create('/mgmt/tm/ltm/profile/client-ssl', sslprof) 111 | logger.info(f' + (hook) client-ssl profile created auto_le_{args[0]}.') 112 | 113 | def unchanged_cert(args): 114 | logger.info(f' + (hook) No changes necessary.') 115 | 116 | if __name__ == '__main__': 117 | # Logging 118 | logger = logging.getLogger(__name__) 119 | logger.addHandler(logging.StreamHandler()) 120 | logger.setLevel(logging.INFO) 121 | 122 | # read domain to LB Virtual Server xref file 123 | f = open("virtual_servers", 'r') 124 | f5_vsxref = json.loads(f.read()) 125 | 126 | if os.access(".f5creds", os.R_OK): 127 | # read f5 credentials file 128 | f = open(".f5creds", 'r') 129 | f5_creds = json.loads(f.read()) 130 | 131 | if len(sys.argv) > 2: 132 | hook = sys.argv[1] 133 | else: 134 | hook = '' 135 | if hook == 'deploy_challenge': 136 | logger.info(f' + (hook) Deploying Challenge {sys.argv[2]}') 137 | f5_http = f5_vsxref[(sys.argv[2])] 138 | deploy_challenge(sys.argv[2:]) 139 | elif hook == 'invalid_challenge': 140 | logger.info(f' + (hook) Invalid Challenge {sys.argv[2]}') 141 | f5_http = f5_vsxref[(sys.argv[2])] 142 | invalid_challenge(sys.argv[2:]) 143 | elif hook == 'clean_challenge': 144 | logger.info(f' + (hook) Cleaning Challenge {sys.argv[2]}') 145 | f5_http = f5_vsxref[(sys.argv[2])] 146 | clean_challenge(sys.argv[2:]) 147 | elif hook == 'deploy_cert': 148 | logger.info(f' + (hook) Deploying Certs {sys.argv[2]}') 149 | f5_http = f5_vsxref[(sys.argv[2])] 150 | deploy_cert(sys.argv[2:]) 151 | elif hook == 'unchanged_cert': 152 | logger.info(f' + (hook) Unchanged Certs {sys.argv[2]}') 153 | f5_http = f5_vsxref[(sys.argv[2])] 154 | unchanged_cert(sys.argv[2:]) 155 | -------------------------------------------------------------------------------- /img/le_cert_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/f5devcentral/lets-encrypt-python/149a0aa88daab46f17e5ed82d52565b36d04145b/img/le_cert_details.png -------------------------------------------------------------------------------- /img/le_certs_bigip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/f5devcentral/lets-encrypt-python/149a0aa88daab46f17e5ed82d52565b36d04145b/img/le_certs_bigip.png -------------------------------------------------------------------------------- /rule_le_challenge.iRule: -------------------------------------------------------------------------------- 1 | # look up Let's Encrypt validation requests in dg_le_challenge and return if found 2 | # pass through if not found in dg 3 | # Tim Riker 4 | 5 | when HTTP_REQUEST { 6 | if { [class exists dg_le_challenge] } { 7 | if {"[HTTP::uri]" starts_with "/.well-known/acme-challenge/"} { 8 | set log(lekey) [getfield "[HTTP::uri]" "/" 4] 9 | set log(levalue) [class match -value -- $log(lekey) equals dg_le_challenge] 10 | if { $log(levalue) ne "" } { 11 | HTTP::respond 200 content "$log(levalue)\n" "Content-Type" "text/plain" "Connection" "close" 12 | event disable 13 | return 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /virtual_servers.example: -------------------------------------------------------------------------------- 1 | { 2 | "domain.name": "VirtualServerNameOnLB_HTTP_if_available", 3 | "example.com": "VS_example.com_http", 4 | "example.net": "VS_example.net_https", 5 | "www.example.com": "VS_example.com_http", 6 | "anothersanname.example.com": "VS_example.com_http", 7 | } 8 | -------------------------------------------------------------------------------- /wellknown/README.txt: -------------------------------------------------------------------------------- 1 | # dehydrated challenges are stored here 2 | --------------------------------------------------------------------------------