Skip to content

Commit

Permalink
Add renewal support (major code organization refactoring)
Browse files Browse the repository at this point in the history
  • Loading branch information
brablc committed May 29, 2024
1 parent bc71aa4 commit 0cd12bd
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 110 deletions.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ RUN apk update --no-cache && apk add bash curl jq

WORKDIR /app

COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
COPY *.sh ./
RUN chmod +x *.sh

ENTRYPOINT ["./docker-entrypoint.sh"]
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@

## Functionality

- Dashboard activated with basic authentication.
- Redirect from 80 to 433 with exception of ACME challenge.
- Dynamic loading of generated certificates.
The provided example of `manager-stack.yml` provides following functionality:

- Traefik dashboard protected by basic authentication - replace `REPLACE-ME-USE` with your password generated by `htpasswd -nb`.
- Dashboard itself is covered by created certificate - replace `traefik.example.com` with your dashboard domain.
- Generic redirect from 80 to 433 with exception of ACME challenge.
- Dynamic loading of generated certificates - Treafik actually requires TLS to be in a dynamically loaded file.
- Challenge webroot gets automatically routed by traefik, but only gets opened when needed.
- Automatic discovery of domains, that require cerificate - use `certbot.domain` label - you can separate multiple domains with commas.
- Renewal is performed once in a day, when date change is detected. You can force from outside using `docker exec CERTBOT_ID ./renew.sh`

> [!IMPORTANT]
> - This project does not cover renewal yet, I will add it as soon as I will need it, it should be trivial.
> - When certbot fails to generate certificate it would store log into `/etc/letsencrypt/failed/$DOMAIN` - you have to delete it to get another attempt.
> - The setup expects that multiple copies of traefik run on the same node (manager). The assumption with swarm is, that you only have one entry point, because it is not trivial to have your traffic load balanced to multiple public IPs. The most important part is rolling update of traefik service.
> - The setup expects that multiple copies of traefik run on the same node (manager) to handle rolling update of traefik service.
Example manager-stack.yml with complete configuration - the dashboard itself is covered by created certificate:

```yml
version: '3.8'
Expand All @@ -39,7 +41,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.middlewares.auth.basicauth.users=admin:REPLACE-ME-USE_htpasswd--nb"
- "traefik.http.middlewares.auth.basicauth.users=admin:REPLACE-ME-USE"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.middlewares=auth"
Expand Down
9 changes: 9 additions & 0 deletions config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CERTBOT_EMAIL=${CERTBOT_EMAIL-hostmaster@example.com}

LE_DIR="/etc/letsencrypt"
WEBROOT="/tmp/webroot"
ACME_PATH=".well-known/acme-challenge"
LOCK_FILE="/tmp/certbot.lck"

mkdir -p "$WEBROOT/$ACME_PATH" "$LE_DIR/failed"
echo "ok" > "$WEBROOT/$ACME_PATH/test.txt"
116 changes: 15 additions & 101 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,113 +1,27 @@
#!/usr/bin/env bash

LOOP_SLEEP=${LOOP_SLEEP-60s}
CERTBOT_EMAIL=${CERTBOT_EMAIL-hostmaster@example.com}

LE_DIR=/etc/letsencrypt
TRAEFIK_EXPORT=${LE_DIR}/traefik.yml
WEBROOT=/tmp/webroot
source ./config.sh
source ./logger.sh

mkdir -p $WEBROOT/.well-known/acme-challenge $LE_DIR/failed
log_info "Using email: $CERTBOT_EMAIL"
log_info "Initial list of domains from certbot.domain labels ..."
./domains.sh

function get_domains() {
local sock=/var/run/docker.sock
local url=http://v1.45/services
LAST_DATE=x$(date +"%Y-%m-%d")

curl -s --unix-socket $sock $url \
| jq -r '.[] | select(.Spec.Labels["com.docker.stack.namespace"] != null) | .Spec.Name' \
| xargs -I {} sh -c "curl -s --unix-socket $sock $url/{} | jq -r '.Spec.Labels[\"certbot.domain\"] | select(.)'" \
| grep \. | sed 's/,/\n/g'
}

function export_certificates() {
FILE=$TRAEFIK_EXPORT
(
printf "tls:\n options:\n default:\n minVersion: VersionTLS12\n certificates:\n"
while read DOMAIN; do
printf " # CERT FILE $DOMAIN\n"
printf " - certFile: |-\n"
sed -e 's/^/ /' $DOMAIN/fullchain.pem
printf " keyFile: |-\n"
sed -e 's/^/ /' $DOMAIN/privkey.pem
done < <(find $LE_DIR/live/ -maxdepth 1 -mindepth 1 -type d -print)
) > ${FILE}.new
mv ${FILE}.new $FILE
}

function run_certbot() {
SERVER_PID=""
EXPORT=0
while read DOMAIN; do
if [ -d $LE_DIR/live/$DOMAIN ]; then
continue
fi
if [ -f $LE_DIR/failed/$DOMAIN ]; then
echo "-W|$DOMAIN|Skipping domain marked as failed ..." >&2
continue
fi

if [ -z "$SERVER_PID" ]; then
echo "-I|Starting http server ..." >&2
python -m http.server 80 --directory $WEBROOT &
SERVER_PID=$!
sleep 5
echo "-I|Done." >&2
fi

FILE=.well-known/acme-challenge/check-${DOMAIN}-$(date +%s)
TEST_URL="http://$DOMAIN/$FILE"
echo "-I|$DOMAIN|Test challenge accessibility $TEST_URL ..." >&2
log_info "Entering loop with $LOOP_SLEEP sleep ..."
while true; do
sleep $LOOP_SLEEP

echo "certbot" > $WEBROOT/$FILE
curl --silent -v --max-time 5 $TEST_URL > /tmp/result
ERR=$?
rm -f $WEBROOT/$FILE
if [ $ERR -ne 0 -o "$(cat /tmp/result)" != "certbot" ]; then
echo "-E|$DOMAIN|Domain challenge failed $TEST_URL" >&2
continue
fi
NEW_DATE=$(date +"%Y-%m-%d")

echo "-I|$DOMAIN|Domain challenge ok, run certbot ..."
certbot certonly \
--webroot -w $WEBROOT \
--non-interactive \
--agree-tos \
--no-eff-email \
--keep-until-expiring \
-m $CERTBOT_EMAIL \
--cert-name $DOMAIN \
-d $DOMAIN > $LE_DIR/failed/$DOMAIN
if [ $? -eq 0 ]; then
echo "-I|$DOMAIN|Cerbot ok" >&2
rm -f $LE_DIR/failed/$DOMAIN
EXPORT=1
if [[ $LAST_DATE != $NEW_DATE ]]; then
LAST_DATE=$NEW_DATE
log_info "New date detected renewing ..."
./renew.sh
else
echo "-I|$DOMAIN|Cerbot failed." >&2
./issue.sh
fi

done < <(get_domains)

if [ -n "$SERVER_PID" ]; then
echo "-I|Killing http server ..." >&2
kill $SERVER_PID
sleep 5
echo "-I|Done." >&2
fi

if [ $EXPORT = 1 ]; then
echo "-I|Exporting certificates for traefik ..." >&2
export_certificates
echo "-I|Done." >&2
fi
}


echo "-I|Using email: $CERTBOT_EMAIL"
echo "-I|Initial list of domains from certbot.domain labels ..."
get_domains

echo "-I|Entering loop with $LOOP_SLEEP sleep ..."
while true; do
run_certbot
sleep $LOOP_SLEEP
done
9 changes: 9 additions & 0 deletions domains.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash

SOCK=/var/run/docker.sock
URL=http://v1.45/services

curl -s --unix-socket $SOCK $URL \
| jq -r '.[] | select(.Spec.Labels["com.docker.stack.namespace"] != null) | .Spec.Name' \
| xargs -I {} sh -c "curl -s --unix-socket $SOCK $URL/{} | jq -r '.Spec.Labels[\"certbot.domain\"] | select(.)'" \
| grep \. | sed 's/,/\n/g'
23 changes: 23 additions & 0 deletions export.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash

source ./config.sh
source ./logger.sh

TRAEFIK_EXPORT=${LE_DIR}/traefik.yml

log_info "Exporting certificates for traefik ..."

FILE=$TRAEFIK_EXPORT
(
printf "tls:\n options:\n default:\n minVersion: VersionTLS12\n certificates:\n"
while read DOMAIN; do
printf " # CERT FILE $DOMAIN\n"
printf " - certFile: |-\n"
sed -e 's/^/ /' $DOMAIN/fullchain.pem
printf " keyFile: |-\n"
sed -e 's/^/ /' $DOMAIN/privkey.pem
done < <(find $LE_DIR/live/ -maxdepth 1 -mindepth 1 -type d -print)
) > ${FILE}.new
mv ${FILE}.new $FILE

log_info "Export done."
68 changes: 68 additions & 0 deletions issue.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash

source ./config.sh
source ./logger.sh

SERVER_PID=""
EXPORT=0
while read DOMAIN; do
if [ -d $LE_DIR/live/$DOMAIN ]; then
continue
fi
if [ -f $LE_DIR/failed/$DOMAIN ]; then
log_warn "$DOMAIN|Skipping domain marked as failed ..."
continue
fi

if [ -z "$SERVER_PID" ]; then
log_info "Starting http server ..."
python -m http.server 80 --directory $WEBROOT &
SERVER_PID=$!
trap "kill $SERVER_PID" EXIT
sleep 5
fi

FILE="$ACME_PATH/check-${DOMAIN}-$(date +%s)"
TEST_URL="http://$DOMAIN/$FILE"
log_info "$DOMAIN|Test challenge accessibility $TEST_URL ..."

echo "certbot" > $WEBROOT/$FILE
curl --silent -v --max-time 5 $TEST_URL > /tmp/result
ERR=$?
rm -f $WEBROOT/$FILE
if [ $ERR -ne 0 -o "$(cat /tmp/result)" != "certbot" ]; then
log_error"$DOMAIN|Domain challenge failed $TEST_URL"
continue
fi

log_info "$DOMAIN|Domain challenge ok, run certbot ..."

{
flock 200
certbot certonly \
--webroot -w $WEBROOT \
--non-interactive \
--agree-tos \
--no-eff-email \
--keep-until-expiring \
-m $CERTBOT_EMAIL \
--cert-name $DOMAIN \
-d $DOMAIN > $LE_DIR/failed/$DOMAIN

CERTBOT_RESULT=$?

} 200>$LOCK_FILE

if (( $CERTBOT_RESULT == 0 )); then
log_info "$DOMAIN|Cerbot ok"
rm -f $LE_DIR/failed/$DOMAIN
EXPORT=1
else
log_error"$DOMAIN|Cerbot failed read log $LE_DIR/failed/$DOMAIN ."
fi

done < <(./domains.sh)

if [ $EXPORT = 1 ]; then
./export.sh
fi
43 changes: 43 additions & 0 deletions logger.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
log() {
local level color message
level=$1
message=$2

case "$level" in
INFO)
color=$'\e[36m'
;;
WARNING)
color=$'\e[35m'
;;
ERROR)
color=$'\e[31m'
;;
*)
color=$'\e[39m'
;;
esac

reset=$'\e[39m'
timestamp=$(date +"%Y-%m-%d %H:%M:%S")

if [[ -t 1 || -n $CONTENT_TYPE ]]; then
echo -e "${color}-${level:0:1}|${timestamp}|${message}${reset}"
else
echo "-${level:0:1}|${timestamp}|${message}"
fi

logger -t SCRIPT_NAME -p user.${level,,} "${timestamp} - ${level} - ${message}"
}

log_info() {
log "INFO" "$1"
}

log_warn() {
log "WARNING" "$1"
}

log_error() {
log "ERROR" "$1"
}
46 changes: 46 additions & 0 deletions renew.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash

source ./config.sh
source ./logger.sh

DOMAIN=$(ls /etc/letsencrypt/live/*/cert.pem -1tr | awk -F/ 'NR==1{print $5}')
if [[ -z $DOMAIN ]]; then
log_warn "No live domain found, terminating."
exit
fi
log_info "Will use $DOMAIN to test challenge accessibility."

log_info "Starting http server ..."
python -m http.server 80 --directory $WEBROOT &
trap "kill $!" exit
sleep 5
log_info "Started."

FILE="$ACME_PATH/check-renew-$(date +%s)"
TEST_URL="http://$DOMAIN/$FILE"
log_info "Test challenge accessibility $TEST_URL ..."

echo "certbot" > $WEBROOT/$FILE
curl --silent -v --max-time 5 $TEST_URL > /tmp/result
ERR=$?
rm -f $WEBROOT/$FILE
if [ $ERR -ne 0 -o "$(cat /tmp/result)" != "certbot" ]; then
log_error "Test challenge failed $TEST_URL"
exit 1
fi

log_info "Calling certbot renew ..."
{
flock 200
certbot renew --webroot -w $WEBROOT --non-interactive

CERTBOT_RESULT=$?

} 200>$LOCK_FILE

if (( $CERTBOT_RESULT == 0 )); then
log_info "Cerbot ok"
./export.sh
else
log_error"Cerbot failed."
fi

0 comments on commit 0cd12bd

Please sign in to comment.