├── .dockerignore ├── docker ├── dehydrated │ ├── domains.txt │ ├── config │ └── dehydrated ├── Dockerfile └── docker-entrypoint.sh ├── .gitignore ├── config.default.sh ├── deploy.config.sh ├── LICENSE ├── .semaphore ├── semaphore.yml └── build-deploy.yml ├── CONTRIBUTING.md ├── Makefile ├── CHANGELOG.md ├── README.md └── hook.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .semaphore/ 2 | *.md 3 | LICENSE -------------------------------------------------------------------------------- /docker/dehydrated/domains.txt: -------------------------------------------------------------------------------- 1 | www.example.com -------------------------------------------------------------------------------- /docker/dehydrated/config: -------------------------------------------------------------------------------- 1 | #CA="https://acme-staging-v02.api.letsencrypt.org/directory" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .project 3 | config.sh 4 | deploy.sh 5 | !.deploy/scripts/deploy.sh 6 | docker/app/certs/* 7 | !docker/app/certs/.gitkeep 8 | docker/app/config/* 9 | !docker/app/config/.gitkeep 10 | !docker/app/config/config 11 | docker/app/dehydrated/* 12 | !docker/app/dehydrated/dehydrated 13 | /certs 14 | /config/domains.txt -------------------------------------------------------------------------------- /config.default.sh: -------------------------------------------------------------------------------- 1 | # Instead of api_token, you can also use your global API key. For example: 2 | # global_api_key="YOUR_GLOBAL_KEY" 3 | # zones="YOUR_ZONES" 4 | # email="admin@example.com" 5 | 6 | case ${1} in 7 | "www.example.com") 8 | api_token="YOUR_API_TOKEN" 9 | zones="YOUR_ZONES" 10 | ;; 11 | 12 | "www.example.net") 13 | api_token="ANOTHER_API_TOKEN" 14 | zones="ANOTHER_ZONE" 15 | ;; 16 | esac 17 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.19.0 2 | 3 | RUN apk update && apk upgrade && apk add --no-cache bash curl jq openssl --upgrade grep 4 | 5 | RUN mkdir -p dehydrated /app/dehydrated 6 | 7 | COPY docker/dehydrated/ /dehydrated/ 8 | RUN chmod +x /dehydrated/dehydrated 9 | 10 | COPY ./hook.sh /dehydrated 11 | COPY ./config.default.sh /dehydrated/config.sh 12 | 13 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 14 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 15 | 16 | ENTRYPOINT [ "docker-entrypoint.sh" ] -------------------------------------------------------------------------------- /deploy.config.sh: -------------------------------------------------------------------------------- 1 | 2 | case ${1} in 3 | 4 | "www.example.com") 5 | #move certificate to private 6 | 7 | cp /home/sineverba/dehydrated/certs/domain/cert.pem /var/www/domain/private/cert.pem 8 | cp /home/sineverba/dehydrated/certs/domain/privkey.pem /var/www/domain/private/privkey.pem 9 | cp /home/sineverba/dehydrated/certs/domain/fullchain.pem /var/www/domain/private/fullchain.pem 10 | 11 | #restart nginx 12 | service nginx restart 13 | 14 | #exchange certificate for domoticz 15 | rm /home/sineverba/domoticz/server_cert.pem 16 | cat /var/www/domain/private/privkey.pem >> /home/sineverba/domoticz/server_cert.pem 17 | cat /var/www/domain/private/fullchain.pem >> /home/sineverba/domoticz/server_cert.pem 18 | cp /home/sineverba/domoticz/server_cert.pem /home/sineverba/domoticz/letsencrypt_server_cert.pem 19 | 20 | #restart domoticz 21 | /etc/init.d/domoticz.sh restart 22 | 23 | #restart mysensors 24 | service mysgw restart 25 | 26 | ;; 27 | 28 | esac 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 - 2024 sineverba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | ACCOUNTS_DIR=/app/dehydrated/accounts 5 | DATA_DIR=/app/dehydrated 6 | 7 | if [ ! -f "$DATA_DIR/config" ]; then 8 | echo "Creating configuration file..." 9 | cp /dehydrated/config "$DATA_DIR/config" 10 | fi 11 | 12 | if [ ! -f "$DATA_DIR/dehydrated" ]; then 13 | echo "Creating dehydrated file..." 14 | cp /dehydrated/dehydrated "$DATA_DIR/dehydrated" 15 | fi 16 | 17 | if [ ! -f "$DATA_DIR/domains.txt" ]; then 18 | echo "Creating domains.txt file..." 19 | cp /dehydrated/domains.txt "$DATA_DIR/domains.txt" 20 | fi 21 | 22 | if [ ! -f "$DATA_DIR/config.sh" ]; then 23 | echo "Creating config.sh file..." 24 | cp /dehydrated/config.sh "$DATA_DIR/config.sh" 25 | fi 26 | 27 | if [ ! -f "$DATA_DIR/hook.sh" ]; then 28 | echo "Creating hook.sh file..." 29 | cp /dehydrated/hook.sh "$DATA_DIR/hook.sh" 30 | fi 31 | 32 | # Check if account is registered 33 | if [ ! -d "$ACCOUNTS_DIR" ]; then 34 | echo "Registering account" 35 | ./app/dehydrated/dehydrated --register --accept-terms 36 | exit 37 | fi 38 | 39 | RENEW_OPT= 40 | if [ "$FORCE_RENEW" == TRUE ]; then 41 | RENEW_OPT=-x 42 | fi 43 | ./app/dehydrated/dehydrated -c -t dns-01 -k '/app/dehydrated/hook.sh' $RENEW_OPT 44 | 45 | exec "$@" -------------------------------------------------------------------------------- /.semaphore/semaphore.yml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | 3 | name: Build and test 4 | agent: 5 | machine: 6 | type: e1-standard-2 7 | os_image: ubuntu2004 8 | 9 | global_job_config: 10 | 11 | env_vars: 12 | - name: DOCKER_USERNAME 13 | value: sineverba 14 | - name: DOCKER_IMAGE 15 | value: cfhookbash 16 | 17 | blocks: 18 | - name: 'Build and test' 19 | skip: 20 | when: "tag =~ '.*'" 21 | task: 22 | jobs: 23 | - name: 'Build and test' 24 | commands: 25 | - checkout 26 | - docker build --tag $DOCKER_USERNAME/$DOCKER_IMAGE --file ./docker/Dockerfile . 27 | - docker run -it --rm --entrypoint cat --name cfhookbash $DOCKER_USERNAME/$DOCKER_IMAGE /etc/os-release | grep "Alpine Linux v3.19" 28 | - docker run -it --rm --entrypoint cat --name cfhookbash $DOCKER_USERNAME/$DOCKER_IMAGE /etc/os-release | grep "3.19.0" 29 | - docker run -it --rm --name cfhookbash $DOCKER_USERNAME/$DOCKER_IMAGE | grep "Using main config file /app/dehydrated/config" 30 | - docker run -it --rm --name cfhookbash $DOCKER_USERNAME/$DOCKER_IMAGE | grep "Registering account" 31 | 32 | promotions: 33 | - name: Deploy 34 | pipeline_file: build-deploy.yml 35 | auto_promote: 36 | when: "result = 'passed' and tag =~ '.*'" -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. This page details how to 4 | contribute and the expected code quality for all contributions. 5 | 6 | ## ACME stage url 7 | 8 | Create a `config` file in same folder of `./dehydrated` with following content, to no hit Let's Encrypt limits. 9 | 10 | Warning! Use this ONLY during development, not in production! 11 | 12 | ``` shell 13 | CA="https://acme-staging-v02.api.letsencrypt.org/directory" 14 | ``` 15 | 16 | 17 | ## Pull Requests 18 | 19 | We accept contributions via Pull Requests. 20 | 21 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 22 | 23 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 24 | 25 | - **Create feature branches** - Don't ask us to pull from your master branch. 26 | 27 | - Create a branch `feature-myawesomefeature` or `hotfix-myhotfix` from `develop` 28 | - Push your branch against `develop` branch. 29 | - Update `CHANGELOG.md` under a `#Next version` section 30 | 31 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 32 | 33 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 34 | 35 | **Happy coding**! 36 | -------------------------------------------------------------------------------- /.semaphore/build-deploy.yml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | 3 | name: Build and deploy 4 | agent: 5 | machine: 6 | type: e1-standard-2 7 | os_image: ubuntu2004 8 | execution_time_limit: 9 | hours: 3 10 | 11 | global_job_config: 12 | 13 | secrets: 14 | - name: ACCESS_TOKENS 15 | 16 | prologue: 17 | commands: 18 | - echo $DOCKER_TOKEN | docker login --username "$DOCKER_USERNAME" --password-stdin 19 | 20 | env_vars: 21 | - name: DOCKER_USERNAME 22 | value: sineverba 23 | - name: DOCKER_IMAGE 24 | value: cfhookbash 25 | - name: BUILDX_VERSION 26 | value: 0.12.1 27 | - name: BINFMT_VERSION 28 | value: qemu-v7.0.0-28 29 | 30 | blocks: 31 | - name: 'Build and deploy' 32 | task: 33 | jobs: 34 | - name: 'Build and deploy' 35 | commands: 36 | - checkout 37 | - mkdir -vp ~/.docker/cli-plugins/ 38 | - >- 39 | curl 40 | --silent 41 | -L "https://github.com/docker/buildx/releases/download/v$BUILDX_VERSION/buildx-v$BUILDX_VERSION.linux-amd64" 42 | > ~/.docker/cli-plugins/docker-buildx 43 | - chmod a+x ~/.docker/cli-plugins/docker-buildx 44 | - docker buildx version 45 | - docker run --rm --privileged tonistiigi/binfmt:$BINFMT_VERSION --install all 46 | - docker buildx ls 47 | - docker buildx create --name multiarch --driver docker-container --use 48 | - docker buildx inspect --bootstrap --builder multiarch 49 | - >- 50 | docker buildx build 51 | --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7 52 | --tag $DOCKER_USERNAME/$DOCKER_IMAGE:$SEMAPHORE_GIT_TAG_NAME 53 | --tag $DOCKER_USERNAME/$DOCKER_IMAGE:latest 54 | --push 55 | --file ./docker/Dockerfile "." -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE_NAME=sineverba/cfhookbash 2 | CONTAINER_NAME=cfhookbash 3 | APP_VERSION=4.9.0-dev 4 | BUILDX_VERSION=0.12.1 5 | BINFMT_VERSION=qemu-v7.0.0-28 6 | 7 | 8 | build: 9 | docker build --tag $(IMAGE_NAME):$(APP_VERSION) --file ./docker/Dockerfile . 10 | 11 | preparemulti: 12 | mkdir -vp ~/.docker/cli-plugins 13 | curl -L "https://github.com/docker/buildx/releases/download/v$(BUILDX_VERSION)/buildx-v$(BUILDX_VERSION).linux-amd64" > ~/.docker/cli-plugins/docker-buildx 14 | chmod a+x ~/.docker/cli-plugins/docker-buildx 15 | docker buildx version 16 | docker run --rm --privileged tonistiigi/binfmt:$(BINFMT_VERSION) --install all 17 | docker buildx ls 18 | docker buildx rm multiarch 19 | docker buildx create --name multiarch --driver docker-container --use 20 | 21 | multi: 22 | docker buildx inspect --bootstrap --builder multiarch 23 | docker buildx build --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7 --tag $(IMAGE_NAME):$(APP_VERSION) --tag $(IMAGE_NAME):latest --file ./docker/Dockerfile . 24 | 25 | test: 26 | docker run --rm -it --entrypoint cat --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) /etc/os-release | grep "Alpine Linux v3.19" 27 | docker run --rm -it --entrypoint cat --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) /etc/os-release | grep "3.19.0" 28 | docker run -it --rm --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) | grep "INFO: Using main config file /app/dehydrated/config" 29 | docker run -it --rm --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) | grep "Registering account" 30 | docker run -it --rm --entrypoint dehydrated/dehydrated --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) --version | grep "Dehydrated version: 0.7.2" 31 | 32 | spin: 33 | docker run -it --rm --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) 34 | 35 | inspect: 36 | docker run -it --entrypoint "/bin/bash" --rm --name $(CONTAINER_NAME) $(IMAGE_NAME):$(APP_VERSION) 37 | 38 | destroy: 39 | docker image rm alpine:3.19.0 40 | docker image rm $(IMAGE_NAME):$(APP_VERSION) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 4.9.0 2 | + Upgrade dependencies 3 | + Upgrade dependencies 4 | 5 | ## 4.8.0 6 | + Upgrade dependencies 7 | 8 | ## 4.7.1 9 | + Fix deploy 10 | 11 | ## 4.7.0 12 | + Improve automations 13 | + Upgrade dependencies 14 | 15 | ## 4.6.0 16 | + Fix Cron output 17 | + Remove Travis and add Circle CI 18 | + Enable CircleCI 19 | + Fix documentation 20 | + Fix CircleCI badge 21 | + Update license year (yes, this project is alive!) 22 | + Upgrade dependencies / images 23 | 24 | ## 4.5.0 25 | + Rearrange instructions (for Docker) 26 | + Add `linux/arm64` architecture 27 | + Add date to log 28 | + Fix Alpine network issue (@see https://github.com/loomchild/volume-backup/issues/34 and https://github.com/alpinelinux/docker-alpine/issues/135) 29 | 30 | ## 4.4.0 31 | + Remove force renew from Docker 32 | + Set force renew as environment variable 33 | 34 | ## 4.3.0 35 | + Remove Docker implementation (not completed) 36 | + Remove config folder (for old Docker) 37 | + Add Dockerfile 38 | + Add instructions for Docker 39 | + Add semaphore 40 | + Fix semaphore 41 | 42 | ## 4.2.0 43 | + Add hook-chaining support 44 | + Improve logging/error messages 45 | 46 | ## 4.1.2 47 | + Fix #72 48 | 49 | ## 4.1.1 50 | + Fix instructions for crontab 51 | 52 | ## 4.1.0 53 | + Update Docker instructions 54 | + Update error codes 55 | + Add support for using API tokens instead of email+global API key 56 | 57 | ## 4.0.0 58 | 59 | ### Add 60 | + Add dependency for `jq` 61 | 62 | ### Fix 63 | + Fix "method_not_allowed" 64 | 65 | ## 3.1.1 66 | 67 | ### Fix 68 | + Fix missin DOCKER_API_TOKEN 69 | 70 | ## 3.1.0 71 | 72 | ### Add 73 | + Add `Docker` 74 | + Add `Travis` 75 | 76 | ## 3.0.0 77 | 78 | ### Refactor 79 | + Renamed branch from `development` to `develop` 80 | + Improved `hook.sh` looking for config.sh files 81 | + Changed instructions (no more need to put cfhookbash as subfolder of dehydrated) 82 | 83 | ## 2.4.3 84 | + Missing remove file. 85 | 86 | ## 2.4.2 87 | + Fix #36 error 88 | ```bash 89 | { 90 | "code": 1001, 91 | "error": "method_not_allowed" 92 | }} 93 | ``` 94 | 95 | ## 2.4.1 96 | + Close #16 97 | + Add Common error messages on README.md 98 | 99 | ## 2.4.0 100 | + Fix #28 101 | + Update README.md (Disable ACME v1 registrations) 102 | + Update year license 103 | 104 | ##2.3.1 105 | + Fix #27 106 | 107 | ##2.2.1 108 | + Fix #15 109 | 110 | ##2.2.0 111 | + Fix #5 112 | + Fix #9 113 | + Fix typo in README.md 114 | + Clean branch 115 | + Add contributors 116 | + Add CONTRIBUTING.md 117 | + Add CHANGELOG.md 118 | + Refactor .gitignore 119 | 120 | ##2.1.0 121 | Stable branch 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cloudflare dns-01 challenge hook bash for dehydrated 2 | ==================================================== 3 | 4 | | CD / CI | | 5 | | --------- | --------- | 6 | | Semaphore CI | [![Build Status](https://sineverba.semaphoreci.com/badges/cfhookbash/branches/master.svg)](https://sineverba.semaphoreci.com/projects/cfhookbash) | 7 | 8 | **If you like this project, or use it, please, star it!** 9 | 10 | Cloudflare Bash hook for [dehydrated](https://github.com/dehydrated-io/dehydrated). 11 | 12 | ## Docker version 13 | 14 | For [Docker](https://hub.docker.com/r/sineverba/cfhookbash) version usage, see [wiki](https://github.com/sineverba/cfhookbash/wiki/Docker-usage) 15 | 16 | 17 | ## Why Cloudflare? What is this script? 18 | 19 | If you cannot solve the `HTTP-01` challenge, you need to solve the DNS-01 challenge. [Details here](https://letsencrypt.org/docs/challenge-types/). 20 | 21 | With use of Cloudflare API (valid also on free plan!), this script will verify your domain putting a new record with a special token inside DNS zone. 22 | At the end of Let's Encrypt validation, that record will be deleted. 23 | 24 | Depends on `jq`: `sudo apt install -y jq` 25 | 26 | You only need: 27 | 28 | 1. Register on Cloudflare (it works also on free plan) 29 | 2. Change your domain DNS to manage them in Cloudflare (follow their guide). 30 | 3. Run `dehydrated` with this hook (or run Docker image, see below) 31 | 32 | You will find the certificates in the folder of `dehydrated`. 33 | 34 | 35 | 36 | ### Classic mode: Prerequisites 37 | 38 | `cfhookbash` has some prerequisites: 39 | 40 | + cURL 41 | + jq 42 | + Active account on Cloudflare (tested with free account) 43 | + Dehydrated ([follow the instructions on Github](https://github.com/dehydrated-io/dehydrated)) 44 | 45 | ### Classic mode: Setup 46 | 47 | ``` shell 48 | cd ~ 49 | git clone https://github.com/sineverba/cfhookbash.git 50 | ``` 51 | 52 | 53 | ### Classic mode: Configuration 54 | 55 | 1. Create a file `domains.txt` **in the folder of `dehydrated`** 56 | 2. Put inside a list of domains that need certificates. Multiple (sub)domains on a single line will end up on a single certificate. 57 | 58 | ``` shell 59 | example.com www.example.com 60 | home.example.net *.home.example.net 61 | [...] 62 | ``` 63 | 3. Move to the folder of `cfhookbash` 64 | 3. Copy `config.default.sh` to `config.sh` 65 | 4. Edit `config.sh`. To get values: 66 | 67 | | Value | Where to find | Deprecated? | 68 | | -------------- | ------------- | ----------- | 69 | | Zone ID | Main page domain > Right Column > API section | N | 70 | | API Token | Account > My Profile > API Tokens > Create Token > API token templates > "Edit zone DNS" | N | 71 | | Global API Key | Account > My Profile > API Tokens > Api Keys > Global API Key | Y, from 4.1.0 | 72 | 73 | You can choose between using an **API token** and using your **global API key**. It is preferred to create a token, since tokens can be restricted to just the permission to edit DNS records in chosen zones (the `DNS:Edit` permission). 74 | 75 | If you choose to use an API token, it must be filled into `api_token`. If you want to use your global API key, instead use `global_api_key` and `email`. 76 | 77 | `Global API key` is deprecated and will be removed in future version. 78 | 79 | ### Classic mode: Usage 80 | 81 | Make a first run with `CA="https://acme-staging-v02.api.letsencrypt.org/directory"` placed in a `config` file in root directory of `dehydrated`. 82 | 83 | ``` shell 84 | ./dehydrated -c -t dns-01 -k '${PATH_WHERE_YOU_CLONED_CFHOOKBASH}/cfhookbash/hook.sh' 85 | ``` 86 | 87 | You will find the certificates inside `~/dehydrated/certs/[your.domain.name]`. 88 | If you are using dehydrated with a config file and, you can speed up the requests for certificates with multiple (sub)domains by using `HOOK_CHAIN="yes"`. 89 | 90 | 91 | ### Classic mode: Post deploy 92 | You can find in `hook.sh` a recall to another file (`deploy.sh`). 93 | Here you can write different operation to execute **AFTER** every successfull challenge. 94 | 95 | There is a stub file `deploy.config.sh`. 96 | 97 | Usage: 98 | 99 | ``` shell 100 | cp deploy.config.sh deploy.sh && rm deploy.config.sh && nano deploy.sh 101 | ``` 102 | 103 | ### Classic mode: Cronjob 104 | 105 | Remember that some action require sudo privilege (start and stop webserver, e.g.). 106 | 107 | Best is run as root and running in cronjob specify full paths. 108 | 109 | Following script will run every monday at 4AM and will create a log in home folder. 110 | 111 | `$ sudo crontab -e` 112 | 113 | ``` shell 114 | 0 4 * * 1 cd /home//dehydrated && /home//dehydrated/dehydrated -c -t dns-01 -k '/home//cfhookbash/hook.sh' >> /home//cfhookbash-`date +\%Y-\%m-\%d-\%H-\%M-\%S`.log 2>&1 115 | ``` 116 | 117 | #### Update / upgrade 118 | + Move to folder where you downloaded it 119 | + Type `git checkout master && git pull` 120 | 121 | #### Commons error messages 122 | 123 | | Error | Solution | 124 | | ----- | -------- | 125 | | Could not route to /zones/dns_records, perhaps your object identifier is invalid? No route for that URI | Check your `Zone ID` value. There probably is something wrong. | 126 | | /home/YOUR_USER/cfhookbash/hook.sh: line XX: jq: command not found | Install `jq` (`sudo apt install jq`) and try again | 127 | | {"code": 1001, "error": "method_not_allowed"} | Update this script by running `git pull` | 128 | 129 | ### Contributing 130 | Everyone is welcome to contribute! See `CONTRIBUTING.md` 131 | 132 | ### Contributors, credits and bug discovery :) 133 | 134 | + YasharF 135 | + Ramblurr 136 | + Dav999-v 137 | + fallingcats 138 | + simondeziel 139 | 140 | Inspired by 141 | + [https://www.splitbrain.org/blog/2017-08/10-homeassistant_duckdns_letsencrypt](https://www.splitbrain.org/blog/2017-08/10-homeassistant_duckdns_letsencrypt) 142 | + [https://github.com/kappataumu/letsencrypt-Cloudflare-hook](https://github.com/kappataumu/letsencrypt-Cloudflare-hook) 143 | -------------------------------------------------------------------------------- /hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit 3 | set -o pipefail 4 | set -o nounset 5 | shopt -s lastpipe 6 | 7 | prefix="_acme-challenge." 8 | 9 | #if [[ ! -f "${PWD}/hooks/cfhookbash/config.sh" ]]; then 10 | # if [[ -f "${PWD}/config.sh" ]]; then 11 | # configFile="${PWD}/config.sh"; 12 | # fi 13 | #else 14 | # configFile="${PWD}/hooks/cfhookbash/config.sh"; 15 | #fi 16 | 17 | # see https://stackoverflow.com/questions/59895/how-to-get-the-source-directory-of-a-bash-script-from-within-the-script-itself 18 | hookDirectory=$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd ) 19 | 20 | load_config() { 21 | configFile="${hookDirectory}/config.sh" 22 | 23 | . "${configFile}" 24 | #if [[ -z "${ROOT_DIR}" ]];then 25 | # hookDirectory="${PWD}/hooks/cfhookbash"; 26 | #else 27 | # hookDirectory="${ROOT_DIR}"; 28 | #fi 29 | 30 | api="https://api.cloudflare.com/client/v4/zones/${zones}/dns_records" 31 | 32 | if [ -z $api_token ]; then 33 | # New-style API token not found, fall back to global API key 34 | curlParams=("-s" "-H" "X-Auth-Email: ${email}" "-H" "X-Auth-Key: ${global_api_key}" "-H" "Content-Type: application/json" "$api") 35 | else 36 | curlParams=("-s" "-H" "Authorization: Bearer ${api_token}" "-H" "Content-Type: application/json" "$api") 37 | fi 38 | } 39 | 40 | 41 | check_status() { 42 | readarray response 43 | status=$(echo "${response[@]}"| jq -r '.success') 44 | 45 | if [[ "$status" != 'true' ]] 46 | then 47 | if [[ "$status" == 'false' ]] 48 | then 49 | echo "${response[@]}" | jq -r '.errors | .[] | .message' | while read line 50 | do 51 | echo '!!! ERROR: '"$line" 52 | done 53 | else 54 | echo "${response[@]}" 55 | fi 56 | fi 57 | } 58 | 59 | 60 | deploy_challenge() { 61 | if (( $# % 3 != 0 )) 62 | then 63 | echo "!!! Invalid number of arguments" 64 | exit 1 65 | fi 66 | 67 | while(( $# > 0)) 68 | do 69 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" curlParams 70 | load_config "$DOMAIN" 71 | 72 | echo " - Setting up token for ${DOMAIN}" 73 | 74 | local DATA='{"type":"TXT","name":"'${prefix}${DOMAIN}'","content":"'${TOKEN_VALUE}'","ttl":120,"priority":10,"proxied":false}' 75 | curl "${curlParams[@]}" --data "${DATA}" | check_status 76 | shift 3 77 | done 78 | 79 | # Add delay to get the new DNS record 80 | local DELAY=10; 81 | echo "+++ Wait for ${DELAY} seconds. +++"; 82 | sleep "${DELAY}" 83 | 84 | # This hook is called once for every domain that needs to be 85 | # validated, including any alternative names you may have listed. 86 | # 87 | # Parameters: 88 | # - DOMAIN 89 | # The domain name (CN or subject alternative name) being 90 | # validated. 91 | # - TOKEN_FILENAME 92 | # The name of the file containing the token to be served for HTTP 93 | # validation. Should be served by your web server as 94 | # /.well-known/acme-challenge/${TOKEN_FILENAME}. 95 | # - TOKEN_VALUE 96 | # The token value that needs to be served for validation. For DNS 97 | # validation, this is what you want to put in the _acme-challenge 98 | # TXT record. For HTTP validation it is the value that is expected 99 | # be found in the $TOKEN_FILENAME file. 100 | 101 | # Simple example: Use nsupdate with local named 102 | # 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 103 | } 104 | 105 | clean_challenge() { 106 | if (( $# % 3 != 0 )) 107 | then 108 | echo "!!! Invalid number of arguments" 109 | exit 1 110 | fi 111 | 112 | while(( $# > 0)) 113 | do 114 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" curlParams response 115 | load_config "$DOMAIN" curlParams 116 | 117 | curl "${curlParams[@]}" -G -d 'match=all' -d 'per_page=100' -d 'type=TXT' -d "name=${prefix}${DOMAIN}" | readarray response 118 | echo "${response[@]}" | check_status 119 | 120 | record_ids=$( echo "${response[@]}" | jq -r '.result | .[] | .id' ) 121 | for id in ${record_ids}; 122 | do 123 | echo " - Removing token for ${DOMAIN}" 124 | curl -X DELETE "${curlParams[@]}/${id}" | check_status 125 | done 126 | 127 | shift 3 128 | done 129 | 130 | 131 | # This hook is called after attempting to validate each domain, 132 | # whether or not validation was successful. Here you can delete 133 | # files or DNS records that are no longer needed. 134 | # 135 | # The parameters are the same as for deploy_challenge. 136 | 137 | # Simple example: Use nsupdate with local named 138 | # 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 139 | } 140 | 141 | deploy_cert() { 142 | local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" 143 | 144 | FILE="${hookDirectory}/deploy.sh" 145 | if test -f "$FILE"; then 146 | 147 | . "$FILE" 148 | 149 | fi 150 | 151 | # This hook is called once for each certificate that has been 152 | # produced. Here you might, for instance, copy your new certificates 153 | # to service-specific locations and reload the service. 154 | # 155 | # Parameters: 156 | # - DOMAIN 157 | # The primary domain name, i.e. the certificate common 158 | # name (CN). 159 | # - KEYFILE 160 | # The path of the file containing the private key. 161 | # - CERTFILE 162 | # The path of the file containing the signed certificate. 163 | # - FULLCHAINFILE 164 | # The path of the file containing the full certificate chain. 165 | # - CHAINFILE 166 | # The path of the file containing the intermediate certificate(s). 167 | # - TIMESTAMP 168 | # Timestamp when the specified certificate was created. 169 | 170 | # Simple example: Copy file to nginx config 171 | # cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl 172 | # systemctl reload nginx 173 | } 174 | 175 | deploy_ocsp() { 176 | local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}" 177 | 178 | # This hook is called once for each updated ocsp stapling file that has 179 | # been produced. Here you might, for instance, copy your new ocsp stapling 180 | # files to service-specific locations and reload the service. 181 | # 182 | # Parameters: 183 | # - DOMAIN 184 | # The primary domain name, i.e. the certificate common 185 | # name (CN). 186 | # - OCSPFILE 187 | # The path of the ocsp stapling file 188 | # - TIMESTAMP 189 | # Timestamp when the specified ocsp stapling file was created. 190 | 191 | # Simple example: Copy file to nginx config 192 | # cp "${OCSPFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl 193 | # systemctl reload nginx 194 | } 195 | 196 | 197 | unchanged_cert() { 198 | local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" 199 | 200 | # This hook is called once for each certificate that is still 201 | # valid and therefore wasn't reissued. 202 | # 203 | # Parameters: 204 | # - DOMAIN 205 | # The primary domain name, i.e. the certificate common 206 | # name (CN). 207 | # - KEYFILE 208 | # The path of the file containing the private key. 209 | # - CERTFILE 210 | # The path of the file containing the signed certificate. 211 | # - FULLCHAINFILE 212 | # The path of the file containing the full certificate chain. 213 | # - CHAINFILE 214 | # The path of the file containing the intermediate certificate(s). 215 | } 216 | 217 | invalid_challenge() { 218 | local DOMAIN="${1}" RESPONSE="${2}" 219 | 220 | # This hook is called if the challenge response has failed, so domain 221 | # owners can be aware and act accordingly.ls 222 | # 223 | # Parameters: 224 | # - DOMAIN 225 | # The primary domain name, i.e. the certificate common 226 | # name (CN). 227 | # - RESPONSE 228 | # The response that the verification server returned 229 | 230 | # Simple example: Send mail to root 231 | # printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root 232 | } 233 | 234 | request_failure() { 235 | local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" 236 | 237 | # This hook is called when an HTTP request fails (e.g., when the ACME 238 | # server is busy, returns an error, etc). It will be called upon any 239 | # response code that does not start with '2'. Useful to alert admins 240 | # about problems with requests. 241 | # 242 | # Parameters: 243 | # - STATUSCODE 244 | # The HTML status code that originated the error. 245 | # - REASON 246 | # The specified reason for the error. 247 | # - REQTYPE 248 | # The kind of request that was made (GET, POST...) 249 | # - HEADERS 250 | # HTTP headers returned by the CA 251 | 252 | # Simple example: Send mail to root 253 | # printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root 254 | } 255 | 256 | generate_csr() { 257 | local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" 258 | 259 | # This hook is called before any certificate signing operation takes place. 260 | # It can be used to generate or fetch a certificate signing request with external 261 | # tools. 262 | # The output should be just the cerificate signing request formatted as PEM. 263 | # 264 | # Parameters: 265 | # - DOMAIN 266 | # The primary domain as specified in domains.txt. This does not need to 267 | # match with the domains in the CSR, it's basically just the directory name. 268 | # - CERTDIR 269 | # Certificate output directory for this particular certificate. Can be used 270 | # for storing additional files. 271 | # - ALTNAMES 272 | # All domain names for the current certificate as specified in domains.txt. 273 | # Again, this doesn't need to match with the CSR, it's just there for convenience. 274 | 275 | # Simple example: Look for pre-generated CSRs 276 | # if [ -e "${CERTDIR}/pre-generated.csr" ]; then 277 | # cat "${CERTDIR}/pre-generated.csr" 278 | # fi 279 | } 280 | 281 | startup_hook() { 282 | # This hook is called before the cron command to do some initial tasks 283 | # (e.g. starting a webserver). 284 | 285 | : 286 | } 287 | 288 | exit_hook() { 289 | # This hook is called at the end of the cron command and can be used to 290 | # do some final (cleanup or other) tasks. 291 | 292 | : 293 | } 294 | 295 | HANDLER="$1"; shift 296 | if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then 297 | "$HANDLER" "$@" 298 | fi 299 | -------------------------------------------------------------------------------- /docker/dehydrated/dehydrated: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # dehydrated by lukas2511 4 | # Source: https://dehydrated.io 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 && set -o NULL_GLOB && set -o noglob 12 | [[ -z "${ZSH_VERSION:-}" ]] && shopt -s nullglob && set -f 13 | 14 | umask 077 # paranoid umask, we're creating private keys 15 | 16 | # Close weird external file descriptors 17 | exec 3>&- 18 | exec 4>&- 19 | 20 | VERSION="0.7.2" 21 | 22 | # Find directory in which this script is stored by traversing all symbolic links 23 | SOURCE="${0}" 24 | while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink 25 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 26 | SOURCE="$(readlink "$SOURCE")" 27 | [[ $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 28 | done 29 | SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 30 | 31 | BASEDIR="${SCRIPTDIR}" 32 | ORIGARGS=("${@}") 33 | 34 | noglob_set() { 35 | if [[ -n "${ZSH_VERSION:-}" ]]; then 36 | set +o noglob 37 | else 38 | set +f 39 | fi 40 | } 41 | 42 | noglob_clear() { 43 | if [[ -n "${ZSH_VERSION:-}" ]]; then 44 | set -o noglob 45 | else 46 | set -f 47 | fi 48 | } 49 | 50 | # Generate json.sh path matching string 51 | json_path() { 52 | if [ ! "${1}" = "-p" ]; then 53 | printf '"%s"' "${1}" 54 | else 55 | printf '%s' "${2}" 56 | fi 57 | } 58 | 59 | # Get string value from json dictionary 60 | get_json_string_value() { 61 | local filter 62 | filter="$(printf 's/.*\[%s\][[:space:]]*"\([^"]*\)"/\\1/p' "$(json_path "${1:-}" "${2:-}")")" 63 | sed -n "${filter}" 64 | } 65 | 66 | # Get array values from json dictionary 67 | get_json_array_values() { 68 | grep -E '^\['"$(json_path "${1:-}" "${2:-}")"',[0-9]*\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' 69 | } 70 | 71 | # Get sub-dictionary from json 72 | get_json_dict_value() { 73 | local filter 74 | filter="$(printf 's/.*\[%s\][[:space:]]*\(.*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" 75 | sed -n "${filter}" | jsonsh 76 | } 77 | 78 | # Get integer value from json 79 | get_json_int_value() { 80 | local filter 81 | filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" 82 | sed -n "${filter}" 83 | } 84 | 85 | # Get boolean value from json 86 | get_json_bool_value() { 87 | local filter 88 | filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" 89 | sed -n "${filter}" 90 | } 91 | 92 | # JSON.sh JSON-parser 93 | # Modified from https://github.com/dominictarr/JSON.sh 94 | # Original Copyright (c) 2011 Dominic Tarr 95 | # Licensed under The MIT License 96 | jsonsh() { 97 | 98 | throw() { 99 | echo "$*" >&2 100 | exit 1 101 | } 102 | 103 | awk_egrep () { 104 | local pattern_string=$1 105 | 106 | awk '{ 107 | while ($0) { 108 | start=match($0, pattern); 109 | token=substr($0, start, RLENGTH); 110 | print token; 111 | $0=substr($0, start+RLENGTH); 112 | } 113 | }' pattern="$pattern_string" 114 | } 115 | 116 | tokenize () { 117 | local GREP 118 | local ESCAPE 119 | local CHAR 120 | 121 | if echo "test string" | grep -Eao --color=never "test" >/dev/null 2>&1 122 | then 123 | GREP='grep -Eao --color=never' 124 | else 125 | GREP='grep -Eao' 126 | fi 127 | 128 | # shellcheck disable=SC2196 129 | if echo "test string" | grep -Eao "test" >/dev/null 2>&1 130 | then 131 | ESCAPE='(\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' 132 | CHAR='[^[:cntrl:]"\\]' 133 | else 134 | GREP=awk_egrep 135 | ESCAPE='(\\\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' 136 | CHAR='[^[:cntrl:]"\\\\]' 137 | fi 138 | 139 | local STRING="\"$CHAR*($ESCAPE$CHAR*)*\"" 140 | local NUMBER='-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?' 141 | local KEYWORD='null|false|true' 142 | local SPACE='[[:space:]]+' 143 | 144 | # Force zsh to expand $A into multiple words 145 | local is_wordsplit_disabled 146 | is_wordsplit_disabled="$(unsetopt 2>/dev/null | grep -c '^shwordsplit$' || true)" 147 | if [ "${is_wordsplit_disabled}" != "0" ]; then setopt shwordsplit; fi 148 | $GREP "$STRING|$NUMBER|$KEYWORD|$SPACE|." | grep -Ev "^$SPACE$" 149 | if [ "${is_wordsplit_disabled}" != "0" ]; then unsetopt shwordsplit; fi 150 | } 151 | 152 | parse_array () { 153 | local index=0 154 | local ary='' 155 | read -r token 156 | case "$token" in 157 | ']') ;; 158 | *) 159 | while : 160 | do 161 | parse_value "$1" "$index" 162 | index=$((index+1)) 163 | ary="$ary""$value" 164 | read -r token 165 | case "$token" in 166 | ']') break ;; 167 | ',') ary="$ary," ;; 168 | *) throw "EXPECTED , or ] GOT ${token:-EOF}" ;; 169 | esac 170 | read -r token 171 | done 172 | ;; 173 | esac 174 | value=$(printf '[%s]' "$ary") || value= 175 | : 176 | } 177 | 178 | parse_object () { 179 | local key 180 | local obj='' 181 | read -r token 182 | case "$token" in 183 | '}') ;; 184 | *) 185 | while : 186 | do 187 | case "$token" in 188 | '"'*'"') key=$token ;; 189 | *) throw "EXPECTED string GOT ${token:-EOF}" ;; 190 | esac 191 | read -r token 192 | case "$token" in 193 | ':') ;; 194 | *) throw "EXPECTED : GOT ${token:-EOF}" ;; 195 | esac 196 | read -r token 197 | parse_value "$1" "$key" 198 | obj="$obj$key:$value" 199 | read -r token 200 | case "$token" in 201 | '}') break ;; 202 | ',') obj="$obj," ;; 203 | *) throw "EXPECTED , or } GOT ${token:-EOF}" ;; 204 | esac 205 | read -r token 206 | done 207 | ;; 208 | esac 209 | value=$(printf '{%s}' "$obj") || value= 210 | : 211 | } 212 | 213 | parse_value () { 214 | local jpath="${1:+$1,}${2:-}" 215 | case "$token" in 216 | '{') parse_object "$jpath" ;; 217 | '[') parse_array "$jpath" ;; 218 | # At this point, the only valid single-character tokens are digits. 219 | ''|[!0-9]) throw "EXPECTED value GOT ${token:-EOF}" ;; 220 | *) value="${token//\\\///}" 221 | # replace solidus ("\/") in json strings with normalized value: "/" 222 | ;; 223 | esac 224 | [ "$value" = '' ] && return 225 | [ -z "$jpath" ] && return # do not print head 226 | 227 | printf "[%s]\t%s\n" "$jpath" "$value" 228 | : 229 | } 230 | 231 | parse () { 232 | read -r token 233 | parse_value 234 | read -r token || true 235 | case "$token" in 236 | '') ;; 237 | *) throw "EXPECTED EOF GOT $token" ;; 238 | esac 239 | } 240 | 241 | tokenize | parse 242 | } 243 | 244 | # Convert IP addresses to their reverse dns variants. 245 | # Used for ALPN certs as validation for IPs uses this in SNI since IPs aren't allowed there. 246 | ip_to_ptr() { 247 | ip="$(cat)" 248 | if [[ "${ip}" =~ : ]]; then 249 | 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")" 250 | else 251 | printf "%s.in-addr.arpa" "$(printf "%s" "${ip}" | awk -F. '{print $4"."$3"." $2"."$1}')" 252 | fi 253 | } 254 | 255 | # Create (identifiable) temporary files 256 | _mktemp() { 257 | mktemp "${TMPDIR:-/tmp}/dehydrated-XXXXXX" 258 | } 259 | 260 | # Check for script dependencies 261 | check_dependencies() { 262 | # look for required binaries 263 | for binary in grep mktemp diff sed awk curl cut head tail hexdump; do 264 | bin_path="$(command -v "${binary}" 2>/dev/null)" || _exiterr "This script requires ${binary}." 265 | [[ -x "${bin_path}" ]] || _exiterr "${binary} found in PATH but it's not executable" 266 | done 267 | 268 | # just execute some dummy and/or version commands to see if required tools are actually usable 269 | "${OPENSSL}" version > /dev/null 2>&1 || _exiterr "This script requires an openssl binary." 270 | _sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requires sed with support for extended (modern) regular expressions." 271 | 272 | # curl returns with an error code in some ancient versions so we have to catch that 273 | set +e 274 | CURL_VERSION="$(curl -V 2>&1 | head -n1 | awk '{print $2}')" 275 | set -e 276 | } 277 | 278 | store_configvars() { 279 | __KEY_ALGO="${KEY_ALGO}" 280 | __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" 281 | __OCSP_FETCH="${OCSP_FETCH}" 282 | __OCSP_DAYS="${OCSP_DAYS}" 283 | __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}" 284 | __PRIVATE_KEY_ROLLOVER="${PRIVATE_KEY_ROLLOVER}" 285 | __KEYSIZE="${KEYSIZE}" 286 | __CHALLENGETYPE="${CHALLENGETYPE}" 287 | __HOOK="${HOOK}" 288 | __PREFERRED_CHAIN="${PREFERRED_CHAIN}" 289 | __WELLKNOWN="${WELLKNOWN}" 290 | __HOOK_CHAIN="${HOOK_CHAIN}" 291 | __OPENSSL_CNF="${OPENSSL_CNF}" 292 | __RENEW_DAYS="${RENEW_DAYS}" 293 | __IP_VERSION="${IP_VERSION}" 294 | } 295 | 296 | reset_configvars() { 297 | KEY_ALGO="${__KEY_ALGO}" 298 | OCSP_MUST_STAPLE="${__OCSP_MUST_STAPLE}" 299 | OCSP_FETCH="${__OCSP_FETCH}" 300 | OCSP_DAYS="${__OCSP_DAYS}" 301 | PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}" 302 | PRIVATE_KEY_ROLLOVER="${__PRIVATE_KEY_ROLLOVER}" 303 | KEYSIZE="${__KEYSIZE}" 304 | CHALLENGETYPE="${__CHALLENGETYPE}" 305 | HOOK="${__HOOK}" 306 | PREFERRED_CHAIN="${__PREFERRED_CHAIN}" 307 | WELLKNOWN="${__WELLKNOWN}" 308 | HOOK_CHAIN="${__HOOK_CHAIN}" 309 | OPENSSL_CNF="${__OPENSSL_CNF}" 310 | RENEW_DAYS="${__RENEW_DAYS}" 311 | IP_VERSION="${__IP_VERSION}" 312 | } 313 | 314 | hookscript_bricker_hook() { 315 | # Hook scripts should ignore any hooks they don't know. 316 | # Calling a random hook to make this clear to the hook script authors... 317 | if [[ -n "${HOOK}" ]]; then 318 | "${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." 319 | fi 320 | } 321 | 322 | # verify configuration values 323 | verify_config() { 324 | [[ "${CHALLENGETYPE}" == "http-01" || "${CHALLENGETYPE}" == "dns-01" || "${CHALLENGETYPE}" == "tls-alpn-01" ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... cannot continue." 325 | if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then 326 | _exiterr "Challenge type dns-01 needs a hook script for deployment... cannot continue." 327 | fi 328 | if [[ "${CHALLENGETYPE}" = "http-01" && ! -d "${WELLKNOWN}" && ! "${COMMAND:-}" = "register" ]]; then 329 | _exiterr "WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions." 330 | fi 331 | [[ "${KEY_ALGO}" == "rsa" || "${KEY_ALGO}" == "prime256v1" || "${KEY_ALGO}" == "secp384r1" || "${KEY_ALGO}" == "secp521r1" ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... cannot continue." 332 | if [[ -n "${IP_VERSION}" ]]; then 333 | [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... cannot continue." 334 | fi 335 | [[ "${API}" == "auto" || "${API}" == "1" || "${API}" == "2" ]] || _exiterr "Unsupported API version defined in config: ${API}" 336 | [[ "${OCSP_DAYS}" =~ ^[0-9]+$ ]] || _exiterr "OCSP_DAYS must be a number" 337 | } 338 | 339 | # Setup default config values, search for and load configuration files 340 | load_config() { 341 | # Check for config in various locations 342 | if [[ -z "${CONFIG:-}" ]]; then 343 | for check_config in "/etc/dehydrated" "/usr/local/etc/dehydrated" "${PWD}" "${SCRIPTDIR}"; do 344 | if [[ -f "${check_config}/config" ]]; then 345 | BASEDIR="${check_config}" 346 | CONFIG="${check_config}/config" 347 | break 348 | fi 349 | done 350 | fi 351 | 352 | # Preset 353 | CA_ZEROSSL="https://acme.zerossl.com/v2/DV90" 354 | CA_LETSENCRYPT="https://acme-v02.api.letsencrypt.org/directory" 355 | CA_LETSENCRYPT_TEST="https://acme-staging-v02.api.letsencrypt.org/directory" 356 | CA_BUYPASS="https://api.buypass.com/acme/directory" 357 | CA_BUYPASS_TEST="https://api.test4.buypass.no/acme/directory" 358 | 359 | # Default values 360 | CA="letsencrypt" 361 | OLDCA= 362 | CERTDIR= 363 | ALPNCERTDIR= 364 | ACCOUNTDIR= 365 | ACCOUNT_KEYSIZE="4096" 366 | ACCOUNT_KEY_ALGO=rsa 367 | CHALLENGETYPE="http-01" 368 | CONFIG_D= 369 | CURL_OPTS= 370 | DOMAINS_D= 371 | DOMAINS_TXT= 372 | HOOK= 373 | PREFERRED_CHAIN= 374 | HOOK_CHAIN="no" 375 | RENEW_DAYS="30" 376 | KEYSIZE="4096" 377 | WELLKNOWN= 378 | PRIVATE_KEY_RENEW="yes" 379 | PRIVATE_KEY_ROLLOVER="no" 380 | KEY_ALGO=secp384r1 381 | OPENSSL=openssl 382 | OPENSSL_CNF= 383 | CONTACT_EMAIL= 384 | LOCKFILE= 385 | OCSP_MUST_STAPLE="no" 386 | OCSP_FETCH="no" 387 | OCSP_DAYS=5 388 | IP_VERSION= 389 | CHAINCACHE= 390 | AUTO_CLEANUP="no" 391 | DEHYDRATED_USER= 392 | DEHYDRATED_GROUP= 393 | API="auto" 394 | 395 | if [[ -z "${CONFIG:-}" ]]; then 396 | echo "#" >&2 397 | echo "# !! WARNING !! No main config file found, using default config!" >&2 398 | echo "#" >&2 399 | elif [[ -f "${CONFIG}" ]]; then 400 | echo "# INFO: Using main config file ${CONFIG}" 401 | BASEDIR="$(dirname "${CONFIG}")" 402 | # shellcheck disable=SC1090 403 | . "${CONFIG}" 404 | else 405 | _exiterr "Specified config file doesn't exist." 406 | fi 407 | 408 | if [[ -n "${CONFIG_D}" ]]; then 409 | if [[ ! -d "${CONFIG_D}" ]]; then 410 | _exiterr "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory." 411 | fi 412 | 413 | # Allow globbing 414 | noglob_set 415 | 416 | for check_config_d in "${CONFIG_D}"/*.sh; do 417 | if [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then 418 | echo "# INFO: Using additional config file ${check_config_d}" 419 | # shellcheck disable=SC1090 420 | . "${check_config_d}" 421 | else 422 | _exiterr "Specified additional config ${check_config_d} is not readable or not a file at all." 423 | fi 424 | done 425 | 426 | # Disable globbing 427 | noglob_clear 428 | fi 429 | 430 | # Check for missing dependencies 431 | check_dependencies 432 | 433 | has_sudo() { 434 | command -v sudo > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but sudo not available. Please install sudo." 435 | } 436 | 437 | # Check if we are running & are allowed to run as root 438 | if [[ -n "$DEHYDRATED_USER" ]]; then 439 | command -v getent > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but getent not available. Please install getent." 440 | 441 | TARGET_UID="$(getent passwd "${DEHYDRATED_USER}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_USER ${DEHYDRATED_USER} is invalid" 442 | if [[ -z "${DEHYDRATED_GROUP}" ]]; then 443 | if [[ "${EUID}" != "${TARGET_UID}" ]]; then 444 | echo "# INFO: Running $0 as ${DEHYDRATED_USER}" 445 | has_sudo && exec sudo -u "${DEHYDRATED_USER}" "${0}" "${ORIGARGS[@]}" 446 | fi 447 | else 448 | TARGET_GID="$(getent group "${DEHYDRATED_GROUP}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_GROUP ${DEHYDRATED_GROUP} is invalid" 449 | if [[ -z "${EGID:-}" ]]; then 450 | command -v id > /dev/null 2>&1 || _exiterr "DEHYDRATED_GROUP set, don't know current gid and 'id' not available... Please provide 'id' binary." 451 | EGID="$(id -g)" 452 | fi 453 | if [[ "${EUID}" != "${TARGET_UID}" ]] || [[ "${EGID}" != "${TARGET_GID}" ]]; then 454 | echo "# INFO: Running $0 as ${DEHYDRATED_USER}/${DEHYDRATED_GROUP}" 455 | has_sudo && exec sudo -u "${DEHYDRATED_USER}" -g "${DEHYDRATED_GROUP}" "${0}" "${ORIGARGS[@]}" 456 | fi 457 | fi 458 | elif [[ -n "${DEHYDRATED_GROUP}" ]]; then 459 | _exiterr "DEHYDRATED_GROUP can only be used in combination with DEHYDRATED_USER." 460 | fi 461 | 462 | # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. 463 | [[ "$BASEDIR" != "/" ]] && BASEDIR="${BASEDIR%%/}" 464 | 465 | # Check BASEDIR and set default variables 466 | [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}" 467 | 468 | # Check for ca cli parameter 469 | if [ -n "${PARAM_CA:-}" ]; then 470 | CA="${PARAM_CA}" 471 | fi 472 | 473 | # Preset CAs 474 | if [ "${CA}" = "letsencrypt" ]; then 475 | CA="${CA_LETSENCRYPT}" 476 | elif [ "${CA}" = "letsencrypt-test" ]; then 477 | CA="${CA_LETSENCRYPT_TEST}" 478 | elif [ "${CA}" = "zerossl" ]; then 479 | CA="${CA_ZEROSSL}" 480 | elif [ "${CA}" = "buypass" ]; then 481 | CA="${CA_BUYPASS}" 482 | elif [ "${CA}" = "buypass-test" ]; then 483 | CA="${CA_BUYPASS_TEST}" 484 | fi 485 | 486 | if [[ -z "${OLDCA}" ]] && [[ "${CA}" = "https://acme-v02.api.letsencrypt.org/directory" ]]; then 487 | OLDCA="https://acme-v01.api.letsencrypt.org/directory" 488 | fi 489 | 490 | # Create new account directory or symlink to account directory from old CA 491 | # dev note: keep in mind that because of the use of 'echo' instead of 'printf' or 492 | # similar there is a newline encoded in the directory name. not going to fix this 493 | # since it's a non-issue and trying to fix existing installations would be too much 494 | # trouble 495 | CAHASH="$(echo "${CA}" | urlbase64)" 496 | [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts" 497 | if [[ ! -e "${ACCOUNTDIR}/${CAHASH}" ]]; then 498 | OLDCAHASH="$(echo "${OLDCA}" | urlbase64)" 499 | mkdir -p "${ACCOUNTDIR}" 500 | if [[ -n "${OLDCA}" ]] && [[ -e "${ACCOUNTDIR}/${OLDCAHASH}" ]]; then 501 | echo "! Reusing account from ${OLDCA}" 502 | ln -s "${OLDCAHASH}" "${ACCOUNTDIR}/${CAHASH}" 503 | else 504 | mkdir "${ACCOUNTDIR}/${CAHASH}" 505 | fi 506 | fi 507 | 508 | # shellcheck disable=SC1090 509 | [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config" 510 | ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem" 511 | ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json" 512 | ACCOUNT_ID_JSON="${ACCOUNTDIR}/${CAHASH}/account_id.json" 513 | ACCOUNT_DEACTIVATED="${ACCOUNTDIR}/${CAHASH}/deactivated" 514 | 515 | if [[ -f "${ACCOUNT_DEACTIVATED}" ]]; then 516 | _exiterr "Account has been deactivated. Remove account and create a new one using --register." 517 | fi 518 | 519 | if [[ -f "${BASEDIR}/private_key.pem" ]] && [[ ! -f "${ACCOUNT_KEY}" ]]; then 520 | echo "! Moving private_key.pem to ${ACCOUNT_KEY}" 521 | mv "${BASEDIR}/private_key.pem" "${ACCOUNT_KEY}" 522 | fi 523 | if [[ -f "${BASEDIR}/private_key.json" ]] && [[ ! -f "${ACCOUNT_KEY_JSON}" ]]; then 524 | echo "! Moving private_key.json to ${ACCOUNT_KEY_JSON}" 525 | mv "${BASEDIR}/private_key.json" "${ACCOUNT_KEY_JSON}" 526 | fi 527 | 528 | [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs" 529 | [[ -z "${ALPNCERTDIR}" ]] && ALPNCERTDIR="${BASEDIR}/alpn-certs" 530 | [[ -z "${CHAINCACHE}" ]] && CHAINCACHE="${BASEDIR}/chains" 531 | [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" 532 | [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated" 533 | [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock" 534 | [[ -z "${OPENSSL_CNF}" ]] && OPENSSL_CNF="$("${OPENSSL}" version -d | cut -d\" -f2)/openssl.cnf" 535 | [[ -n "${PARAM_LOCKFILE_SUFFIX:-}" ]] && LOCKFILE="${LOCKFILE}-${PARAM_LOCKFILE_SUFFIX}" 536 | [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE="" 537 | 538 | [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" 539 | [[ -n "${PARAM_DOMAINS_TXT:-}" ]] && DOMAINS_TXT="${PARAM_DOMAINS_TXT}" 540 | [[ -n "${PARAM_PREFERRED_CHAIN:-}" ]] && PREFERRED_CHAIN="${PARAM_PREFERRED_CHAIN}" 541 | [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" 542 | [[ -n "${PARAM_ALPNCERTDIR:-}" ]] && ALPNCERTDIR="${PARAM_ALPNCERTDIR}" 543 | [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}" 544 | [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}" 545 | [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}" 546 | [[ -n "${PARAM_IP_VERSION:-}" ]] && IP_VERSION="${PARAM_IP_VERSION}" 547 | 548 | if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ] && [ "${PARAM_FORCE:-no}" = "no" ]; then 549 | _exiterr "Argument --force-validation can only be used in combination with --force (-x)" 550 | fi 551 | 552 | if [ ! "${1:-}" = "noverify" ]; then 553 | verify_config 554 | fi 555 | store_configvars 556 | } 557 | 558 | # Initialize system 559 | init_system() { 560 | load_config 561 | 562 | # Lockfile handling (prevents concurrent access) 563 | if [[ -n "${LOCKFILE}" ]]; then 564 | LOCKDIR="$(dirname "${LOCKFILE}")" 565 | [[ -w "${LOCKDIR}" ]] || _exiterr "Directory ${LOCKDIR} for LOCKFILE ${LOCKFILE} is not writable, aborting." 566 | ( set -C; date > "${LOCKFILE}" ) 2>/dev/null || _exiterr "Lock file '${LOCKFILE}' present, aborting." 567 | remove_lock() { rm -f "${LOCKFILE}"; } 568 | trap 'remove_lock' EXIT 569 | fi 570 | 571 | # Get CA URLs 572 | CA_DIRECTORY="$(http_request get "${CA}" | jsonsh)" 573 | 574 | # Automatic discovery of API version 575 | if [[ "${API}" = "auto" ]]; then 576 | grep -q newOrder <<< "${CA_DIRECTORY}" && API=2 || API=1 577 | fi 578 | 579 | # shellcheck disable=SC2015 580 | if [[ "${API}" = "1" ]]; then 581 | CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" && 582 | CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" && 583 | CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" && 584 | CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value terms-of-service)" && 585 | CA_REQUIRES_EAB="false" && 586 | CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" || 587 | _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." 588 | # Since reg URI is missing from directory we will assume it is the same as CA_NEW_REG without the new part 589 | CA_REG=${CA_NEW_REG/new-reg/reg} 590 | else 591 | CA_NEW_ORDER="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newOrder)" && 592 | CA_NEW_NONCE="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newNonce)" && 593 | CA_NEW_ACCOUNT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newAccount)" && 594 | CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value -p '"meta","termsOfService"')" && 595 | CA_REQUIRES_EAB="$(printf "%s" "${CA_DIRECTORY}" | get_json_bool_value -p '"meta","externalAccountRequired"' || echo false)" && 596 | CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revokeCert)" || 597 | _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." 598 | fi 599 | 600 | # Export some environment variables to be used in hook script 601 | export WELLKNOWN BASEDIR CERTDIR ALPNCERTDIR CONFIG COMMAND 602 | 603 | # Checking for private key ... 604 | register_new_key="no" 605 | generated="false" 606 | if [[ -n "${PARAM_ACCOUNT_KEY:-}" ]]; then 607 | # a private key was specified from the command line so use it for this run 608 | echo "Using private key ${PARAM_ACCOUNT_KEY} instead of account key" 609 | ACCOUNT_KEY="${PARAM_ACCOUNT_KEY}" 610 | ACCOUNT_KEY_JSON="${PARAM_ACCOUNT_KEY}.json" 611 | ACCOUNT_ID_JSON="${PARAM_ACCOUNT_KEY}_id.json" 612 | [ "${COMMAND:-}" = "register" ] && register_new_key="yes" 613 | else 614 | # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) 615 | if [[ ! -e "${ACCOUNT_KEY}" ]]; then 616 | if [[ ! "${PARAM_ACCEPT_TERMS:-}" = "yes" ]]; then 617 | printf '\n' >&2 618 | 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 619 | printf 'To accept these terms of service run "%s --register --accept-terms".\n' "${0}" >&2 620 | exit 1 621 | fi 622 | 623 | echo "+ Generating account key..." 624 | generated="true" 625 | local tmp_account_key 626 | tmp_account_key="$(_mktemp)" 627 | if [[ ${API} -eq 1 && ! "${ACCOUNT_KEY_ALGO}" = "rsa" ]]; then 628 | _exiterr "ACME API version 1 does not support EC account keys" 629 | fi 630 | case "${ACCOUNT_KEY_ALGO}" in 631 | rsa) _openssl genrsa -out "${tmp_account_key}" "${ACCOUNT_KEYSIZE}";; 632 | prime256v1|secp384r1|secp521r1) _openssl ecparam -genkey -name "${ACCOUNT_KEY_ALGO}" -out "${tmp_account_key}" -noout;; 633 | esac 634 | cat "${tmp_account_key}" > "${ACCOUNT_KEY}" 635 | rm "${tmp_account_key}" 636 | register_new_key="yes" 637 | fi 638 | fi 639 | 640 | if ("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then 641 | # Get public components from private key and calculate thumbprint 642 | pubExponent64="$(printf '%x' "$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -text | awk '/publicExponent/ {print $2}')" | hex2bin | urlbase64)" 643 | pubMod64="$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)" 644 | 645 | account_key_info="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}")" 646 | account_key_sigalgo=RS256 647 | elif ("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then 648 | curve="$("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -noout -text 2>/dev/null | grep 'NIST CURVE' | cut -d':' -f2 | tr -d ' ')" 649 | 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 ':')" 650 | 651 | if [ "${curve}" = "P-256" ]; then 652 | account_key_sigalgo="ES256" 653 | elif [ "${curve}" = "P-384" ]; then 654 | account_key_sigalgo="ES384" 655 | elif [ "${curve}" = "P-521" ]; then 656 | account_key_sigalgo="ES512" 657 | else 658 | _exiterr "Unknown account key curve: ${curve}" 659 | fi 660 | 661 | ec_x_offset=2 662 | ec_x_len=$((${#pubkey}/2 - 1)) 663 | ec_x="${pubkey:$ec_x_offset:$ec_x_len}" 664 | ec_x64="$(printf "%s" "${ec_x}" | hex2bin | urlbase64)" 665 | 666 | ec_y_offset=$((ec_x_offset+ec_x_len)) 667 | ec_y_len=$((${#pubkey}-ec_y_offset)) 668 | ec_y="${pubkey:$ec_y_offset:$ec_y_len}" 669 | ec_y64="$(printf "%s" "${ec_y}" | hex2bin | urlbase64)" 670 | 671 | account_key_info="$(printf '{"crv":"%s","kty":"EC","x":"%s","y":"%s"}' "${curve}" "${ec_x64}" "${ec_y64}")" 672 | else 673 | _exiterr "Account key is not valid, cannot continue." 674 | fi 675 | thumbprint="$(printf '%s' "${account_key_info}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" 676 | 677 | # If we generated a new private key in the step above we have to register it with the acme-server 678 | if [[ "${register_new_key}" = "yes" ]]; then 679 | echo "+ Registering account key with ACME server..." 680 | FAILED=false 681 | 682 | if [[ ${API} -eq 1 && -z "${CA_NEW_REG}" ]] || [[ ${API} -eq 2 && -z "${CA_NEW_ACCOUNT}" ]]; then 683 | echo "Certificate authority doesn't allow registrations." 684 | FAILED=true 685 | fi 686 | 687 | # ZeroSSL special sauce 688 | if [[ "${CA}" = "${CA_ZEROSSL}" ]]; then 689 | if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then 690 | if [[ -z "${CONTACT_EMAIL}" ]]; then 691 | echo "ZeroSSL requires contact email to be set or EAB_KID/EAB_HMAC_KEY to be manually configured" 692 | FAILED=true 693 | else 694 | zeroapi="$(curl -s "https://api.zerossl.com/acme/eab-credentials-email" -d "email=${CONTACT_EMAIL}" | jsonsh)" 695 | EAB_KID="$(printf "%s" "${zeroapi}" | get_json_string_value eab_kid)" 696 | EAB_HMAC_KEY="$(printf "%s" "${zeroapi}" | get_json_string_value eab_hmac_key)" 697 | if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then 698 | echo "Unknown error retrieving ZeroSSL API credentials" 699 | echo "${zeroapi}" 700 | FAILED=true 701 | fi 702 | fi 703 | fi 704 | fi 705 | 706 | # Check if external account is required 707 | if [[ "${FAILED}" = "false" ]]; then 708 | if [[ "${CA_REQUIRES_EAB}" = "true" ]]; then 709 | if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then 710 | FAILED=true 711 | echo "This CA requires an external account but no EAB_KID/EAB_HMAC_KEY has been configured" 712 | fi 713 | fi 714 | fi 715 | 716 | # If an email for the contact has been provided then adding it to the registration request 717 | if [[ "${FAILED}" = "false" ]]; then 718 | if [[ ${API} -eq 1 ]]; then 719 | if [[ -n "${CONTACT_EMAIL}" ]]; then 720 | (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true 721 | else 722 | (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true 723 | fi 724 | else 725 | if [[ -n "${EAB_KID:-}" ]] && [[ -n "${EAB_HMAC_KEY:-}" ]]; then 726 | eab_url="${CA_NEW_ACCOUNT}" 727 | eab_protected64="$(printf '{"alg":"HS256","kid":"%s","url":"%s"}' "${EAB_KID}" "${eab_url}" | urlbase64)" 728 | eab_payload64="$(printf "%s" "${account_key_info}" | urlbase64)" 729 | eab_key="$(printf "%s" "${EAB_HMAC_KEY}" | deurlbase64 | bin2hex)" 730 | eab_signed64="$(printf '%s' "${eab_protected64}.${eab_payload64}" | "${OPENSSL}" dgst -binary -sha256 -mac HMAC -macopt "hexkey:${eab_key}" | urlbase64)" 731 | 732 | if [[ -n "${CONTACT_EMAIL}" ]]; then 733 | regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' 734 | else 735 | regjson='{"termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' 736 | fi 737 | else 738 | if [[ -n "${CONTACT_EMAIL}" ]]; then 739 | regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true}' 740 | else 741 | regjson='{"termsOfServiceAgreed": true}' 742 | fi 743 | fi 744 | (signed_request "${CA_NEW_ACCOUNT}" "${regjson}" > "${ACCOUNT_KEY_JSON}") || FAILED=true 745 | fi 746 | fi 747 | 748 | if [[ "${FAILED}" = "true" ]]; then 749 | echo >&2 750 | echo >&2 751 | echo "Error registering account key. See message above for more information." >&2 752 | if [[ "${generated}" = "true" ]]; then 753 | rm "${ACCOUNT_KEY}" 754 | fi 755 | rm -f "${ACCOUNT_KEY_JSON}" 756 | exit 1 757 | fi 758 | elif [[ "${COMMAND:-}" = "register" ]]; then 759 | echo "+ Account already registered!" 760 | exit 0 761 | fi 762 | 763 | # Read account information or request from CA if missing 764 | if [[ -e "${ACCOUNT_KEY_JSON}" ]]; then 765 | if [[ ${API} -eq 1 ]]; then 766 | ACCOUNT_ID="$(jsonsh < "${ACCOUNT_KEY_JSON}" | get_json_int_value id)" 767 | ACCOUNT_URL="${CA_REG}/${ACCOUNT_ID}" 768 | else 769 | if [[ -e "${ACCOUNT_ID_JSON}" ]]; then 770 | ACCOUNT_URL="$(jsonsh < "${ACCOUNT_ID_JSON}" | get_json_string_value url)" 771 | fi 772 | # if account URL is not storred, fetch it from the CA 773 | if [[ -z "${ACCOUNT_URL:-}" ]]; then 774 | echo "+ Fetching account URL..." 775 | ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" 776 | if [[ -z "${ACCOUNT_URL}" ]]; then 777 | _exiterr "Unknown error on fetching account information" 778 | fi 779 | echo '{"url":"'"${ACCOUNT_URL}"'"}' > "${ACCOUNT_ID_JSON}" # store the URL for next time 780 | fi 781 | fi 782 | else 783 | echo "Fetching missing account information from CA..." 784 | if [[ ${API} -eq 1 ]]; then 785 | _exiterr "This is not implemented for ACMEv1! Consider switching to ACMEv2 :)" 786 | else 787 | ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" 788 | ACCOUNT_INFO="$(signed_request "${ACCOUNT_URL}" '{}')" 789 | fi 790 | echo "${ACCOUNT_INFO}" > "${ACCOUNT_KEY_JSON}" 791 | fi 792 | } 793 | 794 | # Different sed version for different os types... 795 | _sed() { 796 | if [[ "${OSTYPE}" = "Linux" || "${OSTYPE:0:5}" = "MINGW" ]]; then 797 | sed -r "${@}" 798 | else 799 | sed -E "${@}" 800 | fi 801 | } 802 | 803 | # Print error message and exit with error 804 | _exiterr() { 805 | if [ -n "${1:-}" ]; then 806 | echo "ERROR: ${1}" >&2 807 | fi 808 | [[ "${skip_exit_hook:-no}" = "no" ]] && [[ -n "${HOOK:-}" ]] && ("${HOOK}" "exit_hook" "${1:-}" || echo 'exit_hook returned with non-zero exit code!' >&2) 809 | exit 1 810 | } 811 | 812 | # Remove newlines and whitespace from json 813 | clean_json() { 814 | tr -d '\r\n' | _sed -e 's/ +/ /g' -e 's/\{ /{/g' -e 's/ \}/}/g' -e 's/\[ /[/g' -e 's/ \]/]/g' 815 | } 816 | 817 | # Encode data as url-safe formatted base64 818 | urlbase64() { 819 | # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' 820 | "${OPENSSL}" base64 -e | tr -d '\n\r' | _sed -e 's:=*$::g' -e 'y:+/:-_:' 821 | } 822 | 823 | # Decode data from url-safe formatted base64 824 | deurlbase64() { 825 | data="$(cat | tr -d ' \n\r')" 826 | modlen=$((${#data} % 4)) 827 | padding="" 828 | if [[ "${modlen}" = "2" ]]; then padding="=="; 829 | elif [[ "${modlen}" = "3" ]]; then padding="="; fi 830 | printf "%s%s" "${data}" "${padding}" | tr -d '\n\r' | _sed -e 'y:-_:+/:' | "${OPENSSL}" base64 -d -A 831 | } 832 | 833 | # Convert hex string to binary data 834 | hex2bin() { 835 | # Remove spaces, add leading zero, escape as hex string and parse with printf 836 | # shellcheck disable=SC2059 837 | printf "%b" "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" 838 | } 839 | 840 | # Convert binary data to hex string 841 | bin2hex() { 842 | hexdump -v -e '/1 "%02x"' 843 | } 844 | 845 | # OpenSSL writes to stderr/stdout even when there are no errors. So just 846 | # display the output if the exit code was != 0 to simplify debugging. 847 | _openssl() { 848 | set +e 849 | out="$("${OPENSSL}" "${@}" 2>&1)" 850 | res=$? 851 | set -e 852 | if [[ ${res} -ne 0 ]]; then 853 | echo " + ERROR: failed to run $* (Exitcode: ${res})" >&2 854 | echo >&2 855 | echo "Details:" >&2 856 | echo "${out}" >&2 857 | echo >&2 858 | exit "${res}" 859 | fi 860 | } 861 | 862 | # Send http(s) request with specified method 863 | http_request() { 864 | tempcont="$(_mktemp)" 865 | tempheaders="$(_mktemp)" 866 | 867 | if [[ -n "${IP_VERSION:-}" ]]; then 868 | ip_version="-${IP_VERSION}" 869 | fi 870 | 871 | set +e 872 | # shellcheck disable=SC2086 873 | if [[ "${1}" = "head" ]]; then 874 | statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" 875 | curlret="${?}" 876 | touch "${tempheaders}" 877 | elif [[ "${1}" = "get" ]]; then 878 | statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -L -s -w "%{http_code}" -o "${tempcont}" -D "${tempheaders}" "${2}")" 879 | curlret="${?}" 880 | elif [[ "${1}" = "post" ]]; then 881 | 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}")" 882 | curlret="${?}" 883 | else 884 | set -e 885 | _exiterr "Unknown request method: ${1}" 886 | fi 887 | set -e 888 | 889 | if [[ ! "${curlret}" = "0" ]]; then 890 | _exiterr "Problem connecting to server (${1} for ${2}; curl returned with ${curlret})" 891 | fi 892 | 893 | if [[ ! "${statuscode:0:1}" = "2" ]]; then 894 | # check for existing registration warning 895 | if [[ "${API}" = "1" ]] && [[ -n "${CA_NEW_REG:-}" ]] && [[ "${2}" = "${CA_NEW_REG:-}" ]] && [[ "${statuscode}" = "409" ]] && grep -q "Registration key is already in use" "${tempcont}"; then 896 | # do nothing 897 | : 898 | # check for already-revoked warning 899 | elif [[ -n "${CA_REVOKE_CERT:-}" ]] && [[ "${2}" = "${CA_REVOKE_CERT:-}" ]] && [[ "${statuscode}" = "409" ]]; then 900 | grep -q "Certificate already revoked" "${tempcont}" && return 901 | else 902 | if grep -q "urn:ietf:params:acme:error:badNonce" "${tempcont}"; then 903 | printf "badnonce %s" "$(grep -Eoi "^replay-nonce:.*$" "${tempheaders}" | sed 's/ //' | cut -d: -f2)" 904 | return 0 905 | fi 906 | echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 907 | echo >&2 908 | echo "Details:" >&2 909 | cat "${tempheaders}" >&2 910 | cat "${tempcont}" >&2 911 | echo >&2 912 | echo >&2 913 | 914 | # An exclusive hook for the {1}-request error might be useful (e.g., for sending an e-mail to admins) 915 | if [[ -n "${HOOK}" ]]; then 916 | errtxt="$(cat "${tempcont}")" 917 | errheaders="$(cat "${tempheaders}")" 918 | "${HOOK}" "request_failure" "${statuscode}" "${errtxt}" "${1}" "${errheaders}" || _exiterr 'request_failure hook returned with non-zero exit code' 919 | fi 920 | 921 | rm -f "${tempcont}" 922 | rm -f "${tempheaders}" 923 | 924 | # remove temporary domains.txt file if used 925 | [[ "${COMMAND:-}" = "sign_domains" && -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}" 926 | _exiterr 927 | fi 928 | fi 929 | 930 | if { true >&4; } 2>/dev/null; then 931 | cat "${tempheaders}" >&4 932 | fi 933 | cat "${tempcont}" 934 | rm -f "${tempcont}" 935 | rm -f "${tempheaders}" 936 | } 937 | 938 | # Send signed request 939 | signed_request() { 940 | # Encode payload as urlbase64 941 | payload64="$(printf '%s' "${2}" | urlbase64)" 942 | 943 | if [ -n "${3:-}" ]; then 944 | nonce="$(printf "%s" "${3}" | tr -d ' \t\n\r')" 945 | else 946 | # Retrieve nonce from acme-server 947 | if [[ ${API} -eq 1 ]]; then 948 | nonce="$(http_request head "${CA}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" 949 | else 950 | nonce="$(http_request head "${CA_NEW_NONCE}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" 951 | fi 952 | fi 953 | 954 | if [[ ${API} -eq 1 ]]; then 955 | # Build another header which also contains the previously received nonce and encode it as urlbase64 956 | protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' 957 | protected64="$(printf '%s' "${protected}" | urlbase64)" 958 | else 959 | # Build another header which also contains the previously received nonce and url and encode it as urlbase64 960 | if [[ -n "${ACCOUNT_URL:-}" ]]; then 961 | protected='{"alg": "'"${account_key_sigalgo}"'", "kid": "'"${ACCOUNT_URL}"'", "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' 962 | else 963 | protected='{"alg": "'"${account_key_sigalgo}"'", "jwk": '"${account_key_info}"', "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' 964 | fi 965 | protected64="$(printf '%s' "${protected}" | urlbase64)" 966 | fi 967 | 968 | # Sign header with nonce and our payload with our private key and encode signature as urlbase64 969 | if [[ "${account_key_sigalgo}" = "RS256" ]]; then 970 | signed64="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)" 971 | else 972 | dgstparams="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha${account_key_sigalgo:2} -sign "${ACCOUNT_KEY}" | "${OPENSSL}" asn1parse -inform DER)" 973 | dgst_parm_1="$(echo "$dgstparams" | head -n 2 | tail -n 1 | cut -d':' -f4)" 974 | dgst_parm_2="$(echo "$dgstparams" | head -n 3 | tail -n 1 | cut -d':' -f4)" 975 | 976 | # zero-padding (doesn't seem to be necessary, but other clients are doing this as well... 977 | case "${account_key_sigalgo}" in 978 | "ES256") siglen=64;; 979 | "ES384") siglen=96;; 980 | "ES512") siglen=132;; 981 | esac 982 | while [[ ${#dgst_parm_1} -lt $siglen ]]; do dgst_parm_1="0${dgst_parm_1}"; done 983 | while [[ ${#dgst_parm_2} -lt $siglen ]]; do dgst_parm_2="0${dgst_parm_2}"; done 984 | 985 | signed64="$(printf "%s%s" "${dgst_parm_1}" "${dgst_parm_2}" | hex2bin | urlbase64)" 986 | fi 987 | 988 | if [[ ${API} -eq 1 ]]; then 989 | # Build header with just our public key and algorithm information 990 | header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' 991 | 992 | # Send header + extended header + payload + signature to the acme-server 993 | data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' 994 | else 995 | # Send extended header + payload + signature to the acme-server 996 | data='{"protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' 997 | fi 998 | 999 | output="$(http_request post "${1}" "${data}")" 1000 | 1001 | if grep -qE "^badnonce " <<< "${output}"; then 1002 | echo " ! Request failed (badNonce), retrying request..." >&2 1003 | signed_request "${1:-}" "${2:-}" "$(printf "%s" "${output}" | cut -d' ' -f2)" 1004 | else 1005 | printf "%s" "${output}" 1006 | fi 1007 | } 1008 | 1009 | # Extracts all subject names from a CSR 1010 | # Outputs either the CN, or the SANs, one per line 1011 | extract_altnames() { 1012 | csr="${1}" # the CSR itself (not a file) 1013 | 1014 | if ! <<<"${csr}" "${OPENSSL}" req -verify -noout >/dev/null 2>&1; then 1015 | _exiterr "Certificate signing request isn't valid" 1016 | fi 1017 | 1018 | reqtext="$( <<<"${csr}" "${OPENSSL}" req -noout -text )" 1019 | if <<<"${reqtext}" grep -q '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$'; then 1020 | # SANs used, extract these 1021 | altnames="$( <<<"${reqtext}" awk '/X509v3 Subject Alternative Name:/{print;getline;print;}' | tail -n1 )" 1022 | # split to one per line: 1023 | # shellcheck disable=SC1003 1024 | altnames="$( <<<"${altnames}" _sed -e 's/^[[:space:]]*//; s/, /\'$'\n''/g' )" 1025 | # we can only get DNS/IP: ones signed 1026 | if grep -qEv '^(DNS|IP( Address)*|othername):' <<<"${altnames}"; then 1027 | _exiterr "Certificate signing request contains non-DNS/IP Subject Alternative Names" 1028 | fi 1029 | # strip away the DNS/IP: prefix 1030 | altnames="$( <<<"${altnames}" _sed -e 's/^(DNS:|IP( Address)*:|othername:)//' )" 1031 | printf "%s" "${altnames}" | tr '\n' ' ' 1032 | else 1033 | # No SANs, extract CN 1034 | altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.*[ /]CN ?= ?([^ /,]*).*/\1/' )" 1035 | printf "%s" "${altnames}" 1036 | fi 1037 | } 1038 | 1039 | # Get last issuer CN in certificate chain 1040 | get_last_cn() { 1041 | <<<"${1}" _sed 'H;/-----BEGIN CERTIFICATE-----/h;$!d;x' | "${OPENSSL}" x509 -noout -issuer | head -n1 | _sed -e 's/.*[ /]CN ?= ?([^/,]*).*/\1/' 1042 | } 1043 | 1044 | # Create certificate for domain(s) and outputs it FD 3 1045 | sign_csr() { 1046 | csr="${1}" # the CSR itself (not a file) 1047 | 1048 | if { true >&3; } 2>/dev/null; then 1049 | : # fd 3 looks OK 1050 | else 1051 | _exiterr "sign_csr: FD 3 not open" 1052 | fi 1053 | 1054 | shift 1 || true 1055 | export altnames="${*}" 1056 | 1057 | if [[ ${API} -eq 1 ]]; then 1058 | if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then 1059 | _exiterr "Certificate authority doesn't allow certificate signing" 1060 | fi 1061 | elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then 1062 | _exiterr "Certificate authority doesn't allow certificate signing" 1063 | fi 1064 | 1065 | if [[ -n "${ZSH_VERSION:-}" ]]; then 1066 | local -A challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args 1067 | else 1068 | local -a challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args 1069 | fi 1070 | 1071 | # Initial step: Find which authorizations we're dealing with 1072 | if [[ ${API} -eq 2 ]]; then 1073 | # Request new order and store authorization URIs 1074 | local challenge_identifiers="" 1075 | for altname in ${altnames}; do 1076 | if [[ "${altname}" =~ ^ip: ]]; then 1077 | challenge_identifiers+="$(printf '{"type": "ip", "value": "%s"}, ' "${altname:3}")" 1078 | else 1079 | challenge_identifiers+="$(printf '{"type": "dns", "value": "%s"}, ' "${altname}")" 1080 | fi 1081 | done 1082 | challenge_identifiers="[${challenge_identifiers%, }]" 1083 | 1084 | echo " + Requesting new certificate order from CA..." 1085 | order_location="$(signed_request "${CA_NEW_ORDER}" '{"identifiers": '"${challenge_identifiers}"'}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" 1086 | result="$(signed_request "${order_location}" "" | jsonsh)" 1087 | 1088 | order_authorizations="$(echo "${result}" | get_json_array_values authorizations)" 1089 | finalize="$(echo "${result}" | get_json_string_value finalize)" 1090 | 1091 | local idx=0 1092 | for uri in ${order_authorizations}; do 1093 | authorizations[${idx}]="${uri}" 1094 | idx=$((idx+1)) 1095 | done 1096 | echo " + Received ${idx} authorizations URLs from the CA" 1097 | else 1098 | # Copy $altnames to $authorizations (just doing this to reduce duplicate code later on) 1099 | local idx=0 1100 | for altname in ${altnames}; do 1101 | authorizations[${idx}]="${altname}" 1102 | idx=$((idx+1)) 1103 | done 1104 | fi 1105 | 1106 | # Check if authorizations are valid and gather challenge information for pending authorizations 1107 | local idx=0 1108 | for authorization in ${authorizations[*]}; do 1109 | if [[ "${API}" -eq 2 ]]; then 1110 | # Receive authorization ($authorization is authz uri) 1111 | response="$(signed_request "$(echo "${authorization}" | _sed -e 's/\"(.*)".*/\1/')" "" | jsonsh)" 1112 | identifier="$(echo "${response}" | get_json_string_value -p '"identifier","value"')" 1113 | identifier_type="$(echo "${response}" | get_json_string_value -p '"identifier","type"')" 1114 | echo " + Handling authorization for ${identifier}" 1115 | else 1116 | # Request new authorization ($authorization is altname) 1117 | identifier="${authorization}" 1118 | echo " + Requesting authorization for ${identifier}..." 1119 | response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${identifier}"'"}}' | jsonsh)" 1120 | fi 1121 | 1122 | # Check if authorization has already been validated 1123 | if [ "$(echo "${response}" | get_json_string_value status)" = "valid" ]; then 1124 | if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ]; then 1125 | echo " + A valid authorization has been found but will be ignored" 1126 | else 1127 | echo " + Found valid authorization for ${identifier}" 1128 | continue 1129 | fi 1130 | fi 1131 | 1132 | # Find challenge in authorization 1133 | challengeindex="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\][[:space:]]+"'"${CHALLENGETYPE}"'"' | cut -d',' -f2 || true)" 1134 | 1135 | if [ -z "${challengeindex}" ]; then 1136 | allowed_validations="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' | tr '\n' ' ')" 1137 | _exiterr "Validating this certificate is not possible using ${CHALLENGETYPE}. Possible validation methods are: ${allowed_validations}" 1138 | fi 1139 | challenge="$(echo "${response}" | get_json_dict_value -p '"challenges",'"${challengeindex}")" 1140 | 1141 | # Gather challenge information 1142 | if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ] ; then 1143 | challenge_names[${idx}]="$(echo "${identifier}" | ip_to_ptr)" 1144 | else 1145 | challenge_names[${idx}]="${identifier}" 1146 | fi 1147 | challenge_tokens[${idx}]="$(echo "${challenge}" | get_json_string_value token)" 1148 | 1149 | if [[ ${API} -eq 2 ]]; then 1150 | challenge_uris[${idx}]="$(echo "${challenge}" | get_json_string_value url)" 1151 | else 1152 | if [[ "$(echo "${challenge}" | get_json_string_value type)" = "urn:acme:error:unauthorized" ]]; then 1153 | _exiterr "Challenge unauthorized: $(echo "${challenge}" | get_json_string_value detail)" 1154 | fi 1155 | challenge_uris[${idx}]="$(echo "${challenge}" | get_json_dict_value validationRecord | get_json_string_value uri)" 1156 | fi 1157 | 1158 | # Prepare challenge tokens and deployment parameters 1159 | keyauth="${challenge_tokens[${idx}]}.${thumbprint}" 1160 | 1161 | case "${CHALLENGETYPE}" in 1162 | "http-01") 1163 | # Store challenge response in well-known location and make world-readable (so that a webserver can access it) 1164 | printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_tokens[${idx}]}" 1165 | chmod a+r "${WELLKNOWN}/${challenge_tokens[${idx}]}" 1166 | keyauth_hook="${keyauth}" 1167 | ;; 1168 | "dns-01") 1169 | # Generate DNS entry content for dns-01 validation 1170 | keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" 1171 | ;; 1172 | "tls-alpn-01") 1173 | keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -c -hex | awk '{print $NF}')" 1174 | generate_alpn_certificate "${identifier}" "${identifier_type}" "${keyauth_hook}" 1175 | ;; 1176 | esac 1177 | 1178 | keyauths[${idx}]="${keyauth}" 1179 | if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ]; then 1180 | deploy_args[${idx}]="$(echo "${identifier}" | ip_to_ptr) ${challenge_tokens[${idx}]} ${keyauth_hook}" 1181 | else 1182 | deploy_args[${idx}]="${identifier} ${challenge_tokens[${idx}]} ${keyauth_hook}" 1183 | fi 1184 | 1185 | idx=$((idx+1)) 1186 | done 1187 | local num_pending_challenges=${idx} 1188 | echo " + ${num_pending_challenges} pending challenge(s)" 1189 | 1190 | # Deploy challenge tokens 1191 | if [[ ${num_pending_challenges} -ne 0 ]]; then 1192 | echo " + Deploying challenge tokens..." 1193 | if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]]; then 1194 | # shellcheck disable=SC2068 1195 | "${HOOK}" "deploy_challenge" ${deploy_args[@]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' 1196 | elif [[ -n "${HOOK}" ]]; then 1197 | # Run hook script to deploy the challenge token 1198 | local idx=0 1199 | while [ ${idx} -lt ${num_pending_challenges} ]; do 1200 | # shellcheck disable=SC2086 1201 | "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' 1202 | idx=$((idx+1)) 1203 | done 1204 | fi 1205 | fi 1206 | 1207 | # Validate pending challenges 1208 | local idx=0 1209 | while [ ${idx} -lt ${num_pending_challenges} ]; do 1210 | echo " + Responding to challenge for ${challenge_names[${idx}]} authorization..." 1211 | 1212 | # Ask the acme-server to verify our challenge and wait until it is no longer pending 1213 | if [[ ${API} -eq 1 ]]; then 1214 | result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauths[${idx}]}"'"}' | jsonsh)" 1215 | else 1216 | result="$(signed_request "${challenge_uris[${idx}]}" '{}' | jsonsh)" 1217 | fi 1218 | 1219 | reqstatus="$(echo "${result}" | get_json_string_value status)" 1220 | 1221 | while [[ "${reqstatus}" = "pending" ]] || [[ "${reqstatus}" = "processing" ]]; do 1222 | sleep 1 1223 | if [[ "${API}" -eq 2 ]]; then 1224 | result="$(signed_request "${challenge_uris[${idx}]}" "" | jsonsh)" 1225 | else 1226 | result="$(http_request get "${challenge_uris[${idx}]}" | jsonsh)" 1227 | fi 1228 | reqstatus="$(echo "${result}" | get_json_string_value status)" 1229 | done 1230 | 1231 | [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" 1232 | [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" 1233 | 1234 | if [[ "${reqstatus}" = "valid" ]]; then 1235 | echo " + Challenge is valid!" 1236 | else 1237 | [[ -n "${HOOK}" ]] && ("${HOOK}" "invalid_challenge" "${altname}" "${result}" || _exiterr 'invalid_challenge hook returned with non-zero exit code') 1238 | break 1239 | fi 1240 | idx=$((idx+1)) 1241 | done 1242 | 1243 | if [[ ${num_pending_challenges} -ne 0 ]]; then 1244 | echo " + Cleaning challenge tokens..." 1245 | 1246 | # Clean challenge tokens using chained hook 1247 | # shellcheck disable=SC2068 1248 | [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[@]} || _exiterr 'clean_challenge hook returned with non-zero exit code') 1249 | 1250 | # Clean remaining challenge tokens if validation has failed 1251 | local idx=0 1252 | while [ ${idx} -lt ${num_pending_challenges} ]; do 1253 | # Delete challenge file 1254 | [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" 1255 | # Delete alpn verification certificates 1256 | [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" 1257 | # Clean challenge token using non-chained hook 1258 | # shellcheck disable=SC2086 1259 | [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[${idx}]} || _exiterr 'clean_challenge hook returned with non-zero exit code') 1260 | idx=$((idx+1)) 1261 | done 1262 | 1263 | if [[ "${reqstatus}" != "valid" ]]; then 1264 | echo " + Challenge validation has failed :(" 1265 | _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})" 1266 | fi 1267 | fi 1268 | 1269 | # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem 1270 | echo " + Requesting certificate..." 1271 | csr64="$( <<<"${csr}" "${OPENSSL}" req -config "${OPENSSL_CNF}" -outform DER | urlbase64)" 1272 | if [[ ${API} -eq 1 ]]; then 1273 | crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | "${OPENSSL}" base64 -e)" 1274 | crt="$( printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" )" 1275 | else 1276 | result="$(signed_request "${finalize}" '{"csr": "'"${csr64}"'"}' | jsonsh)" 1277 | while :; do 1278 | orderstatus="$(echo "${result}" | get_json_string_value status)" 1279 | case "${orderstatus}" 1280 | in 1281 | "processing" | "pending") 1282 | echo " + Order is ${orderstatus}..." 1283 | sleep 2; 1284 | ;; 1285 | "valid") 1286 | break; 1287 | ;; 1288 | *) 1289 | _exiterr "Order in status ${orderstatus}" 1290 | ;; 1291 | esac 1292 | result="$(signed_request "${order_location}" "" | jsonsh)" 1293 | done 1294 | 1295 | resheaders="$(_mktemp)" 1296 | certificate="$(echo "${result}" | get_json_string_value certificate)" 1297 | crt="$(signed_request "${certificate}" "" 4>"${resheaders}")" 1298 | 1299 | if [ -n "${PREFERRED_CHAIN:-}" ]; then 1300 | foundaltchain=0 1301 | altcn="$(get_last_cn "${crt}")" 1302 | altoptions="${altcn}" 1303 | if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then 1304 | foundaltchain=1 1305 | fi 1306 | if [ "${foundaltchain}" = "0" ] && (grep -Ei '^link:' "${resheaders}" | grep -q -Ei 'rel="alternate"'); then 1307 | while read -r altcrturl; do 1308 | if [ "${foundaltchain}" = "0" ]; then 1309 | altcrt="$(signed_request "${altcrturl}" "")" 1310 | altcn="$(get_last_cn "${altcrt}")" 1311 | altoptions="${altoptions}, ${altcn}" 1312 | if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then 1313 | foundaltchain=1 1314 | crt="${altcrt}" 1315 | fi 1316 | fi 1317 | done <<< "$(grep -Ei '^link:' "${resheaders}" | grep -Ei 'rel="alternate"' | cut -d'<' -f2 | cut -d'>' -f1)" 1318 | fi 1319 | if [ "${foundaltchain}" = "0" ]; then 1320 | _exiterr "Alternative chain with CN = ${PREFERRED_CHAIN} not found, available options: ${altoptions}" 1321 | fi 1322 | echo " + Using preferred chain with CN = ${altcn}" 1323 | fi 1324 | rm -f "${resheaders}" 1325 | fi 1326 | 1327 | # Try to load the certificate to detect corruption 1328 | echo " + Checking certificate..." 1329 | _openssl x509 -text <<<"${crt}" 1330 | 1331 | echo "${crt}" >&3 1332 | 1333 | unset challenge_token 1334 | echo " + Done!" 1335 | } 1336 | 1337 | # grep issuer cert uri from certificate 1338 | get_issuer_cert_uri() { 1339 | certificate="${1}" 1340 | "${OPENSSL}" x509 -in "${certificate}" -noout -text | (grep 'CA Issuers - URI:' | cut -d':' -f2-) || true 1341 | } 1342 | 1343 | get_issuer_hash() { 1344 | certificate="${1}" 1345 | "${OPENSSL}" x509 -in "${certificate}" -noout -issuer_hash 1346 | } 1347 | 1348 | get_ocsp_url() { 1349 | certificate="${1}" 1350 | "${OPENSSL}" x509 -in "${certificate}" -noout -ocsp_uri 1351 | } 1352 | 1353 | # walk certificate chain, retrieving all intermediate certificates 1354 | walk_chain() { 1355 | local certificate 1356 | certificate="${1}" 1357 | 1358 | local issuer_cert_uri 1359 | issuer_cert_uri="${2:-}" 1360 | if [[ -z "${issuer_cert_uri}" ]]; then issuer_cert_uri="$(get_issuer_cert_uri "${certificate}")"; fi 1361 | if [[ -n "${issuer_cert_uri}" ]]; then 1362 | # create temporary files 1363 | local tmpcert 1364 | local tmpcert_raw 1365 | tmpcert_raw="$(_mktemp)" 1366 | tmpcert="$(_mktemp)" 1367 | 1368 | # download certificate 1369 | http_request get "${issuer_cert_uri}" > "${tmpcert_raw}" 1370 | 1371 | # PEM 1372 | if grep -q "BEGIN CERTIFICATE" "${tmpcert_raw}"; then mv "${tmpcert_raw}" "${tmpcert}" 1373 | # DER 1374 | elif "${OPENSSL}" x509 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM 2> /dev/null > /dev/null; then : 1375 | # PKCS7 1376 | elif "${OPENSSL}" pkcs7 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM -print_certs 2> /dev/null > /dev/null; then : 1377 | # Unknown certificate type 1378 | else _exiterr "Unknown certificate type in chain" 1379 | fi 1380 | 1381 | local next_issuer_cert_uri 1382 | next_issuer_cert_uri="$(get_issuer_cert_uri "${tmpcert}")" 1383 | if [[ -n "${next_issuer_cert_uri}" ]]; then 1384 | printf "\n%s\n" "${issuer_cert_uri}" 1385 | cat "${tmpcert}" 1386 | walk_chain "${tmpcert}" "${next_issuer_cert_uri}" 1387 | fi 1388 | rm -f "${tmpcert}" "${tmpcert_raw}" 1389 | fi 1390 | } 1391 | 1392 | # Generate ALPN verification certificate 1393 | generate_alpn_certificate() { 1394 | local altname="${1}" 1395 | local identifier_type="${2}" 1396 | local acmevalidation="${3}" 1397 | 1398 | local alpncertdir="${ALPNCERTDIR}" 1399 | if [[ ! -e "${alpncertdir}" ]]; then 1400 | echo " + Creating new directory ${alpncertdir} ..." 1401 | mkdir -p "${alpncertdir}" || _exiterr "Unable to create directory ${alpncertdir}" 1402 | fi 1403 | 1404 | echo " + Generating ALPN certificate and key for ${1}..." 1405 | tmp_openssl_cnf="$(_mktemp)" 1406 | cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" 1407 | if [[ "${identifier_type}" = "ip" ]]; then 1408 | printf "\n[SAN]\nsubjectAltName=IP:%s\n" "${altname}" >> "${tmp_openssl_cnf}" 1409 | else 1410 | printf "\n[SAN]\nsubjectAltName=DNS:%s\n" "${altname}" >> "${tmp_openssl_cnf}" 1411 | fi 1412 | printf "1.3.6.1.5.5.7.1.31=critical,DER:04:20:%s\n" "${acmevalidation}" >> "${tmp_openssl_cnf}" 1413 | SUBJ="/CN=${altname}/" 1414 | [[ "${OSTYPE:0:5}" = "MINGW" ]] && SUBJ="/${SUBJ}" 1415 | if [[ "${identifier_type}" = "ip" ]]; then 1416 | altname="$(echo "${altname}" | ip_to_ptr)" 1417 | fi 1418 | _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}" 1419 | chmod g+r "${alpncertdir}/${altname}.key.pem" "${alpncertdir}/${altname}.crt.pem" 1420 | rm -f "${tmp_openssl_cnf}" 1421 | } 1422 | 1423 | # Create certificate for domain(s) 1424 | sign_domain() { 1425 | local certdir="${1}" 1426 | shift 1427 | timestamp="${1}" 1428 | shift 1429 | domain="${1}" 1430 | altnames="${*}" 1431 | 1432 | export altnames 1433 | 1434 | echo " + Signing domains..." 1435 | if [[ ${API} -eq 1 ]]; then 1436 | if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then 1437 | _exiterr "Certificate authority doesn't allow certificate signing" 1438 | fi 1439 | elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then 1440 | _exiterr "Certificate authority doesn't allow certificate signing" 1441 | fi 1442 | 1443 | local privkey="privkey.pem" 1444 | if [[ ! -e "${certdir}/cert-${timestamp}.csr" ]]; then 1445 | # generate a new private key if we need or want one 1446 | if [[ ! -r "${certdir}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then 1447 | echo " + Generating private key..." 1448 | privkey="privkey-${timestamp}.pem" 1449 | local tmp_privkey 1450 | tmp_privkey="$(_mktemp)" 1451 | case "${KEY_ALGO}" in 1452 | rsa) _openssl genrsa -out "${tmp_privkey}" "${KEYSIZE}";; 1453 | prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${tmp_privkey}" -noout;; 1454 | esac 1455 | cat "${tmp_privkey}" > "${certdir}/privkey-${timestamp}.pem" 1456 | rm "${tmp_privkey}" 1457 | fi 1458 | # move rolloverkey into position (if any) 1459 | if [[ -r "${certdir}/privkey.pem" && -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_RENEW}" = "yes" && "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then 1460 | echo " + Moving Rolloverkey into position.... " 1461 | mv "${certdir}/privkey.roll.pem" "${certdir}/privkey-tmp.pem" 1462 | mv "${certdir}/privkey-${timestamp}.pem" "${certdir}/privkey.roll.pem" 1463 | mv "${certdir}/privkey-tmp.pem" "${certdir}/privkey-${timestamp}.pem" 1464 | fi 1465 | # generate a new private rollover key if we need or want one 1466 | if [[ ! -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_ROLLOVER}" = "yes" && "${PRIVATE_KEY_RENEW}" = "yes" ]]; then 1467 | echo " + Generating private rollover key..." 1468 | case "${KEY_ALGO}" in 1469 | rsa) _openssl genrsa -out "${certdir}/privkey.roll.pem" "${KEYSIZE}";; 1470 | prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${certdir}/privkey.roll.pem" -noout;; 1471 | esac 1472 | fi 1473 | # delete rolloverkeys if disabled 1474 | if [[ -r "${certdir}/privkey.roll.pem" && ! "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then 1475 | echo " + Removing Rolloverkey (feature disabled)..." 1476 | rm -f "${certdir}/privkey.roll.pem" 1477 | fi 1478 | 1479 | # Generate signing request config and the actual signing request 1480 | echo " + Generating signing request..." 1481 | SAN="" 1482 | for altname in ${altnames}; do 1483 | if [[ "${altname}" =~ ^ip: ]]; then 1484 | SAN="${SAN}IP:${altname:3}, " 1485 | else 1486 | SAN="${SAN}DNS:${altname}, " 1487 | fi 1488 | done 1489 | if [[ "${domain}" =~ ^ip: ]]; then 1490 | SUBJ="/CN=${domain:3}/" 1491 | else 1492 | SUBJ="/CN=${domain}/" 1493 | fi 1494 | SAN="${SAN%%, }" 1495 | local tmp_openssl_cnf 1496 | tmp_openssl_cnf="$(_mktemp)" 1497 | cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" 1498 | printf "\n[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}" 1499 | if [ "${OCSP_MUST_STAPLE}" = "yes" ]; then 1500 | printf "\n1.3.6.1.5.5.7.1.24=DER:30:03:02:01:05" >> "${tmp_openssl_cnf}" 1501 | fi 1502 | if [[ "${OSTYPE:0:5}" = "MINGW" ]]; then 1503 | # The subject starts with a /, so MSYS will assume it's a path and convert 1504 | # it unless we escape it with another one: 1505 | SUBJ="/${SUBJ}" 1506 | fi 1507 | "${OPENSSL}" req -new -sha256 -key "${certdir}/${privkey}" -out "${certdir}/cert-${timestamp}.csr" -subj "${SUBJ}" -reqexts SAN -config "${tmp_openssl_cnf}" 1508 | rm -f "${tmp_openssl_cnf}" 1509 | fi 1510 | 1511 | crt_path="${certdir}/cert-${timestamp}.pem" 1512 | # shellcheck disable=SC2086 1513 | sign_csr "$(< "${certdir}/cert-${timestamp}.csr")" ${altnames} 3>"${crt_path}" 1514 | 1515 | # Create fullchain.pem 1516 | echo " + Creating fullchain.pem..." 1517 | if [[ ${API} -eq 1 ]]; then 1518 | cat "${crt_path}" > "${certdir}/fullchain-${timestamp}.pem" 1519 | local issuer_hash 1520 | issuer_hash="$(get_issuer_hash "${crt_path}")" 1521 | if [ -e "${CHAINCACHE}/${issuer_hash}.chain" ]; then 1522 | echo " + Using cached chain!" 1523 | cat "${CHAINCACHE}/${issuer_hash}.chain" > "${certdir}/chain-${timestamp}.pem" 1524 | else 1525 | echo " + Walking chain..." 1526 | local issuer_cert_uri 1527 | issuer_cert_uri="$(get_issuer_cert_uri "${crt_path}" || echo "unknown")" 1528 | (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})" 1529 | cat "${certdir}/chain-${timestamp}.pem" > "${CHAINCACHE}/${issuer_hash}.chain" 1530 | fi 1531 | cat "${certdir}/chain-${timestamp}.pem" >> "${certdir}/fullchain-${timestamp}.pem" 1532 | else 1533 | tmpcert="$(_mktemp)" 1534 | tmpchain="$(_mktemp)" 1535 | awk '{print >out}; /----END CERTIFICATE-----/{out=tmpchain}' out="${tmpcert}" tmpchain="${tmpchain}" "${certdir}/cert-${timestamp}.pem" 1536 | mv "${certdir}/cert-${timestamp}.pem" "${certdir}/fullchain-${timestamp}.pem" 1537 | cat "${tmpcert}" > "${certdir}/cert-${timestamp}.pem" 1538 | cat "${tmpchain}" > "${certdir}/chain-${timestamp}.pem" 1539 | rm "${tmpcert}" "${tmpchain}" 1540 | fi 1541 | 1542 | # Wait for hook script to sync the files before creating the symlinks 1543 | [[ -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') 1544 | 1545 | # Update symlinks 1546 | [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${certdir}/privkey.pem" 1547 | 1548 | ln -sf "chain-${timestamp}.pem" "${certdir}/chain.pem" 1549 | ln -sf "fullchain-${timestamp}.pem" "${certdir}/fullchain.pem" 1550 | ln -sf "cert-${timestamp}.csr" "${certdir}/cert.csr" 1551 | ln -sf "cert-${timestamp}.pem" "${certdir}/cert.pem" 1552 | 1553 | # Wait for hook script to clean the challenge and to deploy cert if used 1554 | [[ -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') 1555 | 1556 | unset challenge_token 1557 | echo " + Done!" 1558 | } 1559 | 1560 | # Usage: --version (-v) 1561 | # Description: Print version information 1562 | command_version() { 1563 | load_config noverify 1564 | 1565 | echo "Dehydrated by Lukas Schauer" 1566 | echo "https://dehydrated.io" 1567 | echo "" 1568 | echo "Dehydrated version: ${VERSION}" 1569 | revision="$(cd "${SCRIPTDIR}"; git rev-parse HEAD 2>/dev/null || echo "unknown")" 1570 | echo "GIT-Revision: ${revision}" 1571 | echo "" 1572 | # shellcheck disable=SC1091 1573 | if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then 1574 | echo "OS: $(uname -sr)" 1575 | elif [[ -e /etc/os-release ]]; then 1576 | ( . /etc/os-release && echo "OS: $PRETTY_NAME" ) 1577 | elif [[ -e /usr/lib/os-release ]]; then 1578 | ( . /usr/lib/os-release && echo "OS: $PRETTY_NAME" ) 1579 | else 1580 | echo "OS: $(grep -v '^$' /etc/issue | head -n1 | _sed 's/\\(r|n|l) .*//g')" 1581 | fi 1582 | echo "Used software:" 1583 | [[ -n "${BASH_VERSION:-}" ]] && echo " bash: ${BASH_VERSION}" 1584 | [[ -n "${ZSH_VERSION:-}" ]] && echo " zsh: ${ZSH_VERSION}" 1585 | echo " curl: ${CURL_VERSION}" 1586 | if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then 1587 | echo " awk, sed, mktemp, grep, diff: BSD base system versions" 1588 | else 1589 | echo " awk: $(awk -W version 2>&1 | head -n1)" 1590 | echo " sed: $(sed --version 2>&1 | head -n1)" 1591 | echo " mktemp: $(mktemp --version 2>&1 | head -n1)" 1592 | echo " grep: $(grep --version 2>&1 | head -n1)" 1593 | echo " diff: $(diff --version 2>&1 | head -n1)" 1594 | fi 1595 | echo " openssl: $("${OPENSSL}" version 2>&1)" 1596 | 1597 | exit 0 1598 | } 1599 | 1600 | # Usage: --display-terms 1601 | # Description: Display current terms of service 1602 | command_terms() { 1603 | init_system 1604 | echo "The current terms of service: $CA_TERMS" 1605 | echo "+ Done!" 1606 | exit 0 1607 | } 1608 | 1609 | # Usage: --register 1610 | # Description: Register account key 1611 | command_register() { 1612 | init_system 1613 | echo "+ Done!" 1614 | exit 0 1615 | } 1616 | 1617 | # Usage: --account 1618 | # Description: Update account contact information 1619 | command_account() { 1620 | init_system 1621 | FAILED=false 1622 | 1623 | NEW_ACCOUNT_KEY_JSON="$(_mktemp)" 1624 | 1625 | # Check if we have the registration url 1626 | if [[ -z "${ACCOUNT_URL}" ]]; then 1627 | _exiterr "Error retrieving registration url." 1628 | fi 1629 | 1630 | echo "+ Updating registration url: ${ACCOUNT_URL} contact information..." 1631 | if [[ ${API} -eq 1 ]]; then 1632 | # If an email for the contact has been provided then adding it to the registered account 1633 | if [[ -n "${CONTACT_EMAIL}" ]]; then 1634 | (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true 1635 | else 1636 | (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true 1637 | fi 1638 | else 1639 | # If an email for the contact has been provided then adding it to the registered account 1640 | if [[ -n "${CONTACT_EMAIL}" ]]; then 1641 | (signed_request "${ACCOUNT_URL}" '{"contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true 1642 | else 1643 | (signed_request "${ACCOUNT_URL}" '{"contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true 1644 | fi 1645 | fi 1646 | 1647 | if [[ "${FAILED}" = "true" ]]; then 1648 | rm "${NEW_ACCOUNT_KEY_JSON}" 1649 | _exiterr "Error updating account information. See message above for more information." 1650 | fi 1651 | if diff -q "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" > /dev/null; then 1652 | echo "+ Account information was the same after the update" 1653 | rm "${NEW_ACCOUNT_KEY_JSON}" 1654 | else 1655 | ACCOUNT_KEY_JSON_BACKUP="${ACCOUNT_KEY_JSON%.*}-$(date +%s).json" 1656 | echo "+ Backup ${ACCOUNT_KEY_JSON} as ${ACCOUNT_KEY_JSON_BACKUP}" 1657 | cp -p "${ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON_BACKUP}" 1658 | echo "+ Populate ${ACCOUNT_KEY_JSON}" 1659 | mv "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" 1660 | fi 1661 | echo "+ Done!" 1662 | exit 0 1663 | } 1664 | 1665 | # Parse contents of domains.txt and domains.txt.d 1666 | parse_domains_txt() { 1667 | # Allow globbing temporarily 1668 | noglob_set 1669 | local inputs=("${DOMAINS_TXT}" "${DOMAINS_TXT}.d"/*.txt) 1670 | noglob_clear 1671 | 1672 | cat "${inputs[@]}" | 1673 | tr -d '\r' | 1674 | awk '{print tolower($0)}' | 1675 | _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' -e 's/([^ ])>/\1 >/g' -e 's/> />/g' | 1676 | (grep -vE '^(#|$)' || true) 1677 | } 1678 | 1679 | # Usage: --cron (-c) 1680 | # Description: Sign/renew non-existent/changed/expiring certificates. 1681 | command_sign_domains() { 1682 | init_system 1683 | hookscript_bricker_hook 1684 | 1685 | # Call startup hook 1686 | [[ -n "${HOOK}" ]] && ("${HOOK}" "startup_hook" || _exiterr 'startup_hook hook returned with non-zero exit code') 1687 | 1688 | if [ ! -d "${CHAINCACHE}" ]; then 1689 | echo " + Creating chain cache directory ${CHAINCACHE}" 1690 | mkdir "${CHAINCACHE}" 1691 | fi 1692 | 1693 | if [[ -n "${PARAM_DOMAIN:-}" ]]; then 1694 | DOMAINS_TXT="$(_mktemp)" 1695 | if [[ -n "${PARAM_ALIAS:-}" ]]; then 1696 | printf "%s > %s" "${PARAM_DOMAIN}" "${PARAM_ALIAS}" > "${DOMAINS_TXT}" 1697 | else 1698 | printf "%s" "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" 1699 | fi 1700 | elif [[ -e "${DOMAINS_TXT}" ]]; then 1701 | if [[ ! -r "${DOMAINS_TXT}" ]]; then 1702 | _exiterr "domains.txt found but not readable" 1703 | fi 1704 | else 1705 | _exiterr "domains.txt not found and --domain not given" 1706 | fi 1707 | 1708 | # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire 1709 | ORIGIFS="${IFS}" 1710 | IFS=$'\n' 1711 | for line in $(parse_domains_txt); do 1712 | reset_configvars 1713 | IFS="${ORIGIFS}" 1714 | alias="$(grep -Eo '>[^ ]+' <<< "${line}" || true)" 1715 | line="$(_sed -e 's/>[^ ]+[ ]*//g' <<< "${line}")" 1716 | aliascount="$(grep -Eo '>' <<< "${alias}" | awk 'END {print NR}' || true )" 1717 | [ "${aliascount}" -gt 1 ] && _exiterr "Only one alias per line is allowed in domains.txt!" 1718 | 1719 | domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" 1720 | morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" 1721 | [ "${aliascount}" -lt 1 ] && alias="${domain}" || alias="${alias#>}" 1722 | export alias 1723 | 1724 | if [[ -z "${morenames}" ]];then 1725 | echo "Processing ${domain}" 1726 | else 1727 | echo "Processing ${domain} with alternative names: ${morenames}" 1728 | fi 1729 | 1730 | if [ "${alias:0:2}" = "*." ]; then 1731 | _exiterr "Please define a valid alias for your ${domain} wildcard-certificate. See domains.txt-documentation for more details." 1732 | fi 1733 | 1734 | local certdir="${CERTDIR}/${alias}" 1735 | cert="${certdir}/cert.pem" 1736 | chain="${certdir}/chain.pem" 1737 | 1738 | force_renew="${PARAM_FORCE:-no}" 1739 | 1740 | timestamp="$(date +%s)" 1741 | 1742 | # If there is no existing certificate directory => make it 1743 | if [[ ! -e "${certdir}" ]]; then 1744 | echo " + Creating new directory ${certdir} ..." 1745 | mkdir -p "${certdir}" || _exiterr "Unable to create directory ${certdir}" 1746 | fi 1747 | 1748 | # read cert config 1749 | # for now this loads the certificate specific config in a subshell and parses a diff of set variables. 1750 | # we could just source the config file but i decided to go this way to protect people from accidentally overriding 1751 | # variables used internally by this script itself. 1752 | if [[ -n "${DOMAINS_D}" ]]; then 1753 | certconfig="${DOMAINS_D}/${alias}" 1754 | else 1755 | certconfig="${certdir}/config" 1756 | fi 1757 | 1758 | if [ -f "${certconfig}" ]; then 1759 | echo " + Using certificate specific config file!" 1760 | ORIGIFS="${IFS}" 1761 | IFS=$'\n' 1762 | for cfgline in $( 1763 | beforevars="$(_mktemp)" 1764 | aftervars="$(_mktemp)" 1765 | set > "${beforevars}" 1766 | # shellcheck disable=SC1090 1767 | . "${certconfig}" 1768 | set > "${aftervars}" 1769 | diff -u "${beforevars}" "${aftervars}" | grep -E '^\+[^+]' 1770 | rm "${beforevars}" 1771 | rm "${aftervars}" 1772 | ); do 1773 | config_var="$(echo "${cfgline:1}" | cut -d'=' -f1)" 1774 | config_value="$(echo "${cfgline:1}" | cut -d'=' -f2- | tr -d "'")" 1775 | # All settings that are allowed here should also be stored and 1776 | # restored in store_configvars() and reset_configvars() 1777 | case "${config_var}" in 1778 | 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) 1779 | echo " + ${config_var} = ${config_value}" 1780 | declare -- "${config_var}=${config_value}" 1781 | ;; 1782 | _) ;; 1783 | *) echo " ! Setting ${config_var} on a per-certificate base is not (yet) supported" >&2 1784 | esac 1785 | done 1786 | IFS="${ORIGIFS}" 1787 | fi 1788 | verify_config 1789 | hookscript_bricker_hook 1790 | export WELLKNOWN CHALLENGETYPE KEY_ALGO PRIVATE_KEY_ROLLOVER 1791 | 1792 | skip="no" 1793 | 1794 | # Allow for external CSR generation 1795 | local csr="" 1796 | if [[ -n "${HOOK}" ]]; then 1797 | csr="$("${HOOK}" "generate_csr" "${domain}" "${certdir}" "${domain} ${morenames}")" || _exiterr 'generate_csr hook returned with non-zero exit code' 1798 | if grep -qE "\-----BEGIN (NEW )?CERTIFICATE REQUEST-----" <<< "${csr}"; then 1799 | altnames="$(extract_altnames "${csr}")" 1800 | domain="$(cut -d' ' -f1 <<< "${altnames}")" 1801 | morenames="$(cut -s -d' ' -f2- <<< "${altnames}")" 1802 | echo " + Using CSR from hook script (real names: ${altnames})" 1803 | else 1804 | csr="" 1805 | fi 1806 | fi 1807 | 1808 | # Check domain names of existing certificate 1809 | if [[ -e "${cert}" && "${force_renew}" = "no" ]]; then 1810 | printf " + Checking domain name(s) of existing cert..." 1811 | 1812 | 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/ $//')" 1813 | givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _sed 's/ip://g' | _sed 's/ $//' | _sed 's/^ //')" 1814 | 1815 | if [[ "${certnames}" = "${givennames}" ]]; then 1816 | echo " unchanged." 1817 | else 1818 | echo " changed!" 1819 | echo " + Domain name(s) are not matching!" 1820 | echo " + Names in old certificate: ${certnames}" 1821 | echo " + Configured names: ${givennames}" 1822 | echo " + Forcing renew." 1823 | force_renew="yes" 1824 | fi 1825 | fi 1826 | 1827 | # Check expire date of existing certificate 1828 | if [[ -e "${cert}" ]]; then 1829 | echo " + Checking expire date of existing cert..." 1830 | valid="$("${OPENSSL}" x509 -enddate -noout -in "${cert}" | cut -d= -f2- )" 1831 | 1832 | printf " + Valid till %s " "${valid}" 1833 | if ("${OPENSSL}" x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}" > /dev/null 2>&1); then 1834 | printf "(Longer than %d days). " "${RENEW_DAYS}" 1835 | if [[ "${force_renew}" = "yes" ]]; then 1836 | echo "Ignoring because renew was forced!" 1837 | else 1838 | # Certificate-Names unchanged and cert is still valid 1839 | echo "Skipping renew!" 1840 | [[ -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') 1841 | skip="yes" 1842 | fi 1843 | else 1844 | echo "(Less than ${RENEW_DAYS} days). Renewing!" 1845 | fi 1846 | fi 1847 | 1848 | local update_ocsp 1849 | update_ocsp="no" 1850 | 1851 | # Sign certificate for this domain 1852 | if [[ ! "${skip}" = "yes" ]]; then 1853 | update_ocsp="yes" 1854 | [[ -z "${csr}" ]] || printf "%s" "${csr}" > "${certdir}/cert-${timestamp}.csr" 1855 | # shellcheck disable=SC2086 1856 | if [[ "${PARAM_KEEP_GOING:-}" = "yes" ]]; then 1857 | skip_exit_hook=yes 1858 | sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} & 1859 | wait $! || exit_with_errorcode=1 1860 | skip_exit_hook=no 1861 | else 1862 | sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} 1863 | fi 1864 | fi 1865 | 1866 | if [[ "${OCSP_FETCH}" = "yes" ]]; then 1867 | local ocsp_url 1868 | ocsp_url="$(get_ocsp_url "${cert}")" 1869 | 1870 | if [[ ! -e "${certdir}/ocsp.der" ]]; then 1871 | update_ocsp="yes" 1872 | 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 1873 | update_ocsp="yes" 1874 | fi 1875 | 1876 | if [[ "${update_ocsp}" = "yes" ]]; then 1877 | echo " + Updating OCSP stapling file" 1878 | ocsp_timestamp="$(date +%s)" 1879 | if grep -qE "^(openssl (0|(1\.0))\.)|(libressl (1|2|3)\.)" <<< "$(${OPENSSL} version | awk '{print tolower($0)}')"; then 1880 | 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}" 1881 | else 1882 | 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}" 1883 | fi 1884 | ln -sf "ocsp-${ocsp_timestamp}.der" "${certdir}/ocsp.der" 1885 | [[ -n "${HOOK}" ]] && (altnames="${domain} ${morenames}" "${HOOK}" "deploy_ocsp" "${domain}" "${certdir}/ocsp.der" "${ocsp_timestamp}" || _exiterr 'deploy_ocsp hook returned with non-zero exit code') 1886 | else 1887 | echo " + OCSP stapling file is still valid (skipping update)" 1888 | fi 1889 | fi 1890 | done 1891 | reset_configvars 1892 | 1893 | # remove temporary domains.txt file if used 1894 | [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}" 1895 | 1896 | [[ -n "${HOOK}" ]] && ("${HOOK}" "exit_hook" || echo 'exit_hook returned with non-zero exit code!' >&2) 1897 | if [[ "${AUTO_CLEANUP}" == "yes" ]]; then 1898 | echo "+ Running automatic cleanup" 1899 | command_cleanup noinit 1900 | fi 1901 | 1902 | exit "${exit_with_errorcode}" 1903 | } 1904 | 1905 | # Usage: --signcsr (-s) path/to/csr.pem 1906 | # Description: Sign a given CSR, output CRT on stdout (advanced usage) 1907 | command_sign_csr() { 1908 | init_system 1909 | 1910 | # redirect stdout to stderr 1911 | # leave stdout over at fd 3 to output the cert 1912 | exec 3>&1 1>&2 1913 | 1914 | # load csr 1915 | csrfile="${1}" 1916 | if [ ! -r "${csrfile}" ]; then 1917 | _exiterr "Could not read certificate signing request ${csrfile}" 1918 | fi 1919 | csr="$(cat "${csrfile}")" 1920 | 1921 | # extract names 1922 | altnames="$(extract_altnames "${csr}")" 1923 | 1924 | # gen cert 1925 | certfile="$(_mktemp)" 1926 | # shellcheck disable=SC2086 1927 | sign_csr "${csr}" ${altnames} 3> "${certfile}" 1928 | 1929 | # print cert 1930 | echo "# CERT #" >&3 1931 | cat "${certfile}" >&3 1932 | echo >&3 1933 | 1934 | # print chain 1935 | if [ -n "${PARAM_FULL_CHAIN:-}" ]; then 1936 | # get and convert ca cert 1937 | chainfile="$(_mktemp)" 1938 | tmpchain="$(_mktemp)" 1939 | http_request get "$("${OPENSSL}" x509 -in "${certfile}" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${tmpchain}" 1940 | if grep -q "BEGIN CERTIFICATE" "${tmpchain}"; then 1941 | mv "${tmpchain}" "${chainfile}" 1942 | else 1943 | "${OPENSSL}" x509 -in "${tmpchain}" -inform DER -out "${chainfile}" -outform PEM 1944 | rm "${tmpchain}" 1945 | fi 1946 | 1947 | echo "# CHAIN #" >&3 1948 | cat "${chainfile}" >&3 1949 | 1950 | rm "${chainfile}" 1951 | fi 1952 | 1953 | # cleanup 1954 | rm "${certfile}" 1955 | 1956 | exit 0 1957 | } 1958 | 1959 | # Usage: --revoke (-r) path/to/cert.pem 1960 | # Description: Revoke specified certificate 1961 | command_revoke() { 1962 | init_system 1963 | 1964 | [[ -n "${CA_REVOKE_CERT}" ]] || _exiterr "Certificate authority doesn't allow certificate revocation." 1965 | 1966 | cert="${1}" 1967 | if [[ -L "${cert}" ]]; then 1968 | # follow symlink and use real certificate name (so we move the real file and not the symlink at the end) 1969 | local link_target 1970 | link_target="$(readlink -n "${cert}")" 1971 | if [[ "${link_target}" =~ ^/ ]]; then 1972 | cert="${link_target}" 1973 | else 1974 | cert="$(dirname "${cert}")/${link_target}" 1975 | fi 1976 | fi 1977 | [[ -f "${cert}" ]] || _exiterr "Could not find certificate ${cert}" 1978 | 1979 | echo "Revoking ${cert}" 1980 | 1981 | cert64="$("${OPENSSL}" x509 -in "${cert}" -inform PEM -outform DER | urlbase64)" 1982 | if [[ ${API} -eq 1 ]]; then 1983 | response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)" 1984 | else 1985 | response="$(signed_request "${CA_REVOKE_CERT}" '{"certificate": "'"${cert64}"'"}' | clean_json)" 1986 | fi 1987 | # if there is a problem with our revoke request _request (via signed_request) will report this and "exit 1" out 1988 | # so if we are here, it is safe to assume the request was successful 1989 | echo " + Done." 1990 | echo " + Renaming certificate to ${cert}-revoked" 1991 | mv -f "${cert}" "${cert}-revoked" 1992 | } 1993 | 1994 | # Usage: --deactivate 1995 | # Description: Deactivate account 1996 | command_deactivate() { 1997 | init_system 1998 | 1999 | echo "Deactivating account ${ACCOUNT_URL}" 2000 | 2001 | if [[ ${API} -eq 1 ]]; then 2002 | echo "Deactivation for ACMEv1 is not implemented" 2003 | else 2004 | response="$(signed_request "${ACCOUNT_URL}" '{"status": "deactivated"}' | clean_json)" 2005 | deactstatus=$(echo "$response" | jsonsh | get_json_string_value "status") 2006 | if [[ "${deactstatus}" = "deactivated" ]]; then 2007 | touch "${ACCOUNT_DEACTIVATED}" 2008 | else 2009 | _exiterr "Account deactivation failed!" 2010 | fi 2011 | fi 2012 | 2013 | echo " + Done." 2014 | } 2015 | 2016 | # Usage: --cleanup (-gc) 2017 | # Description: Move unused certificate files to archive directory 2018 | command_cleanup() { 2019 | if [ ! "${1:-}" = "noinit" ]; then 2020 | load_config 2021 | fi 2022 | 2023 | if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then 2024 | # Create global archive directory if not existent 2025 | if [[ ! -e "${BASEDIR}/archive" ]]; then 2026 | mkdir "${BASEDIR}/archive" 2027 | fi 2028 | fi 2029 | 2030 | # Allow globbing 2031 | noglob_set 2032 | 2033 | # Loop over all certificate directories 2034 | for certdir in "${CERTDIR}/"*; do 2035 | # Skip if entry is not a folder 2036 | [[ -d "${certdir}" ]] || continue 2037 | 2038 | # Get certificate name 2039 | certname="$(basename "${certdir}")" 2040 | 2041 | # Create certificates archive directory if not existent 2042 | if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then 2043 | archivedir="${BASEDIR}/archive/${certname}" 2044 | if [[ ! -e "${archivedir}" ]]; then 2045 | mkdir "${archivedir}" 2046 | fi 2047 | fi 2048 | 2049 | # Loop over file-types (certificates, keys, signing-requests, ...) 2050 | for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem ocsp.der; do 2051 | # Delete all if symlink is broken 2052 | if [[ -r "${certdir}/${filetype}" ]]; then 2053 | # Look up current file in use 2054 | current="$(basename "$(readlink "${certdir}/${filetype}")")" 2055 | else 2056 | if [[ -h "${certdir}/${filetype}" ]]; then 2057 | echo "Removing broken symlink: ${certdir}/${filetype}" 2058 | rm -f "${certdir}/${filetype}" 2059 | fi 2060 | current="" 2061 | fi 2062 | 2063 | # Split filetype into name and extension 2064 | filebase="$(echo "${filetype}" | cut -d. -f1)" 2065 | fileext="$(echo "${filetype}" | cut -d. -f2)" 2066 | 2067 | # Loop over all files of this type 2068 | for file in "${certdir}/${filebase}-"*".${fileext}" "${certdir}/${filebase}-"*".${fileext}-revoked"; do 2069 | # Check if current file is in use, if unused move to archive directory 2070 | filename="$(basename "${file}")" 2071 | if [[ ! "${filename}" = "${current}" ]] && [[ -f "${certdir}/${filename}" ]]; then 2072 | if [[ "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then 2073 | echo "Deleting unused file: ${certname}/${filename}" 2074 | rm "${certdir}/${filename}" 2075 | else 2076 | echo "Moving unused file to archive directory: ${certname}/${filename}" 2077 | mv "${certdir}/${filename}" "${archivedir}/${filename}" 2078 | fi 2079 | fi 2080 | done 2081 | done 2082 | done 2083 | 2084 | exit "${exit_with_errorcode}" 2085 | } 2086 | 2087 | # Usage: --cleanup-delete (-gcd) 2088 | # Description: Deletes (!) unused certificate files 2089 | command_cleanupdelete() { 2090 | command_cleanup 2091 | } 2092 | 2093 | 2094 | # Usage: --help (-h) 2095 | # Description: Show help text 2096 | command_help() { 2097 | printf "Usage: %s [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ...\n\n" "${0}" 2098 | printf "Default command: help\n\n" 2099 | echo "Commands:" 2100 | grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do 2101 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then 2102 | _exiterr "Error generating help text." 2103 | fi 2104 | printf " %-32s %s\n" "${usage##"# Usage: "}" "${description##"# Description: "}" 2105 | done 2106 | printf -- "\nParameters:\n" 2107 | grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do 2108 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then 2109 | _exiterr "Error generating help text." 2110 | fi 2111 | printf " %-32s %s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}" 2112 | done 2113 | } 2114 | 2115 | # Usage: --env (-e) 2116 | # Description: Output configuration variables for use in other scripts 2117 | command_env() { 2118 | echo "# dehydrated configuration" 2119 | load_config 2120 | 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 2121 | } 2122 | 2123 | # Main method (parses script arguments and calls command_* methods) 2124 | main() { 2125 | exit_with_errorcode=0 2126 | skip_exit_hook=no 2127 | COMMAND="" 2128 | set_command() { 2129 | [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information." 2130 | COMMAND="${1}" 2131 | } 2132 | 2133 | check_parameters() { 2134 | if [[ -z "${1:-}" ]]; then 2135 | echo "The specified command requires additional parameters. See help:" >&2 2136 | echo >&2 2137 | command_help >&2 2138 | exit 1 2139 | elif [[ "${1:0:1}" = "-" ]]; then 2140 | _exiterr "Invalid argument: ${1}" 2141 | fi 2142 | } 2143 | 2144 | [[ -z "${*}" ]] && eval set -- "--help" 2145 | 2146 | while (( ${#} )); do 2147 | case "${1}" in 2148 | --help|-h) 2149 | command_help 2150 | exit 0 2151 | ;; 2152 | 2153 | --env|-e) 2154 | set_command env 2155 | ;; 2156 | 2157 | --cron|-c) 2158 | set_command sign_domains 2159 | ;; 2160 | 2161 | --register) 2162 | set_command register 2163 | ;; 2164 | 2165 | --account) 2166 | set_command account 2167 | ;; 2168 | 2169 | # PARAM_Usage: --accept-terms 2170 | # PARAM_Description: Accept CAs terms of service 2171 | --accept-terms) 2172 | PARAM_ACCEPT_TERMS="yes" 2173 | ;; 2174 | 2175 | --display-terms) 2176 | set_command terms 2177 | ;; 2178 | 2179 | --signcsr|-s) 2180 | shift 1 2181 | set_command sign_csr 2182 | check_parameters "${1:-}" 2183 | PARAM_CSR="${1}" 2184 | ;; 2185 | 2186 | --revoke|-r) 2187 | shift 1 2188 | set_command revoke 2189 | check_parameters "${1:-}" 2190 | PARAM_REVOKECERT="${1}" 2191 | ;; 2192 | 2193 | --deactivate) 2194 | set_command deactivate 2195 | ;; 2196 | 2197 | --version|-v) 2198 | set_command version 2199 | ;; 2200 | 2201 | --cleanup|-gc) 2202 | set_command cleanup 2203 | ;; 2204 | 2205 | --cleanup-delete|-gcd) 2206 | set_command cleanupdelete 2207 | PARAM_CLEANUPDELETE="yes" 2208 | ;; 2209 | 2210 | # PARAM_Usage: --full-chain (-fc) 2211 | # PARAM_Description: Print full chain when using --signcsr 2212 | --full-chain|-fc) 2213 | PARAM_FULL_CHAIN="1" 2214 | ;; 2215 | 2216 | # PARAM_Usage: --ipv4 (-4) 2217 | # PARAM_Description: Resolve names to IPv4 addresses only 2218 | --ipv4|-4) 2219 | PARAM_IP_VERSION="4" 2220 | ;; 2221 | 2222 | # PARAM_Usage: --ipv6 (-6) 2223 | # PARAM_Description: Resolve names to IPv6 addresses only 2224 | --ipv6|-6) 2225 | PARAM_IP_VERSION="6" 2226 | ;; 2227 | 2228 | # PARAM_Usage: --domain (-d) domain.tld 2229 | # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!) 2230 | --domain|-d) 2231 | shift 1 2232 | check_parameters "${1:-}" 2233 | if [[ -z "${PARAM_DOMAIN:-}" ]]; then 2234 | PARAM_DOMAIN="${1}" 2235 | else 2236 | PARAM_DOMAIN="${PARAM_DOMAIN} ${1}" 2237 | fi 2238 | ;; 2239 | 2240 | # PARAM_Usage: --ca url/preset 2241 | # PARAM_Description: Use specified CA URL or preset 2242 | --ca) 2243 | shift 1 2244 | check_parameters "${1:-}" 2245 | [[ -n "${PARAM_CA:-}" ]] && _exiterr "CA can only be specified once!" 2246 | PARAM_CA="${1}" 2247 | ;; 2248 | 2249 | # PARAM_Usage: --alias certalias 2250 | # PARAM_Description: Use specified name for certificate directory (and per-certificate config) instead of the primary domain (only used if --domain is specified) 2251 | --alias) 2252 | shift 1 2253 | check_parameters "${1:-}" 2254 | [[ -n "${PARAM_ALIAS:-}" ]] && _exiterr "Alias can only be specified once!" 2255 | PARAM_ALIAS="${1}" 2256 | ;; 2257 | 2258 | # PARAM_Usage: --keep-going (-g) 2259 | # PARAM_Description: Keep going after encountering an error while creating/renewing multiple certificates in cron mode 2260 | --keep-going|-g) 2261 | PARAM_KEEP_GOING="yes" 2262 | ;; 2263 | 2264 | # PARAM_Usage: --force (-x) 2265 | # PARAM_Description: Force certificate renewal even if it is not due to expire within RENEW_DAYS 2266 | --force|-x) 2267 | PARAM_FORCE="yes" 2268 | ;; 2269 | 2270 | # PARAM_Usage: --force-validation 2271 | # PARAM_Description: Force revalidation of domain names (used in combination with --force) 2272 | --force-validation) 2273 | PARAM_FORCE_VALIDATION="yes" 2274 | ;; 2275 | 2276 | # PARAM_Usage: --no-lock (-n) 2277 | # PARAM_Description: Don't use lockfile (potentially dangerous!) 2278 | --no-lock|-n) 2279 | PARAM_NO_LOCK="yes" 2280 | ;; 2281 | 2282 | # PARAM_Usage: --lock-suffix example.com 2283 | # PARAM_Description: Suffix lockfile name with a string (useful for with -d) 2284 | --lock-suffix) 2285 | shift 1 2286 | check_parameters "${1:-}" 2287 | PARAM_LOCKFILE_SUFFIX="${1}" 2288 | ;; 2289 | 2290 | # PARAM_Usage: --ocsp 2291 | # PARAM_Description: Sets option in CSR indicating OCSP stapling to be mandatory 2292 | --ocsp) 2293 | PARAM_OCSP_MUST_STAPLE="yes" 2294 | ;; 2295 | 2296 | # PARAM_Usage: --privkey (-p) path/to/key.pem 2297 | # PARAM_Description: Use specified private key instead of account key (useful for revocation) 2298 | --privkey|-p) 2299 | shift 1 2300 | check_parameters "${1:-}" 2301 | PARAM_ACCOUNT_KEY="${1}" 2302 | ;; 2303 | 2304 | # PARAM_Usage: --domains-txt path/to/domains.txt 2305 | # PARAM_Description: Use specified domains.txt instead of default/configured one 2306 | --domains-txt) 2307 | shift 1 2308 | check_parameters "${1:-}" 2309 | PARAM_DOMAINS_TXT="${1}" 2310 | ;; 2311 | 2312 | # PARAM_Usage: --config (-f) path/to/config 2313 | # PARAM_Description: Use specified config file 2314 | --config|-f) 2315 | shift 1 2316 | check_parameters "${1:-}" 2317 | CONFIG="${1}" 2318 | ;; 2319 | 2320 | # PARAM_Usage: --hook (-k) path/to/hook.sh 2321 | # PARAM_Description: Use specified script for hooks 2322 | --hook|-k) 2323 | shift 1 2324 | check_parameters "${1:-}" 2325 | PARAM_HOOK="${1}" 2326 | ;; 2327 | 2328 | # PARAM_Usage: --preferred-chain issuer-cn 2329 | # PARAM_Description: Use alternative certificate chain identified by issuer CN 2330 | --preferred-chain) 2331 | shift 1 2332 | check_parameters "${1:-}" 2333 | PARAM_PREFERRED_CHAIN="${1}" 2334 | ;; 2335 | 2336 | # PARAM_Usage: --out (-o) certs/directory 2337 | # PARAM_Description: Output certificates into the specified directory 2338 | --out|-o) 2339 | shift 1 2340 | check_parameters "${1:-}" 2341 | PARAM_CERTDIR="${1}" 2342 | ;; 2343 | 2344 | # PARAM_Usage: --alpn alpn-certs/directory 2345 | # PARAM_Description: Output alpn verification certificates into the specified directory 2346 | --alpn) 2347 | shift 1 2348 | check_parameters "${1:-}" 2349 | PARAM_ALPNCERTDIR="${1}" 2350 | ;; 2351 | 2352 | # PARAM_Usage: --challenge (-t) http-01|dns-01|tls-alpn-01 2353 | # PARAM_Description: Which challenge should be used? Currently http-01, dns-01, and tls-alpn-01 are supported 2354 | --challenge|-t) 2355 | shift 1 2356 | check_parameters "${1:-}" 2357 | PARAM_CHALLENGETYPE="${1}" 2358 | ;; 2359 | 2360 | # PARAM_Usage: --algo (-a) rsa|prime256v1|secp384r1 2361 | # PARAM_Description: Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 2362 | --algo|-a) 2363 | shift 1 2364 | check_parameters "${1:-}" 2365 | PARAM_KEY_ALGO="${1}" 2366 | ;; 2367 | *) 2368 | echo "Unknown parameter detected: ${1}" >&2 2369 | echo >&2 2370 | command_help >&2 2371 | exit 1 2372 | ;; 2373 | esac 2374 | 2375 | shift 1 2376 | done 2377 | 2378 | case "${COMMAND}" in 2379 | env) command_env;; 2380 | sign_domains) command_sign_domains;; 2381 | register) command_register;; 2382 | account) command_account;; 2383 | sign_csr) command_sign_csr "${PARAM_CSR}";; 2384 | revoke) command_revoke "${PARAM_REVOKECERT}";; 2385 | deactivate) command_deactivate;; 2386 | cleanup) command_cleanup;; 2387 | terms) command_terms;; 2388 | cleanupdelete) command_cleanupdelete;; 2389 | version) command_version;; 2390 | *) command_help; exit 1;; 2391 | esac 2392 | 2393 | exit "${exit_with_errorcode}" 2394 | } 2395 | 2396 | # Determine OS type 2397 | OSTYPE="$(uname)" 2398 | 2399 | if [[ ! "${DEHYDRATED_NOOP:-}" = "NOOP" ]]; then 2400 | # Run script 2401 | main "${@:-}" 2402 | fi 2403 | 2404 | # vi: expandtab sw=2 ts=2 --------------------------------------------------------------------------------