diff options
| author | Mitchell Riedstra <mitch@riedstra.dev> | 2023-01-09 23:01:36 -0500 |
|---|---|---|
| committer | Mitchell Riedstra <mitch@riedstra.dev> | 2023-01-09 23:01:36 -0500 |
| commit | 7e8d29755135a4384d8c2aa8cfd24c5ddfeb7c97 (patch) | |
| tree | 951be2c46639267f229c3fd4496c0049e0ca7127 | |
| download | acme-warehouse-master.tar.gz acme-warehouse-master.tar.xz | |
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Dockerfile | 16 | ||||
| -rwxr-xr-x | entrypoint.sh | 125 | ||||
| -rw-r--r-- | fetch.sh | 161 | ||||
| -rw-r--r-- | readme.md | 100 | ||||
| -rwxr-xr-x | renewal.sh | 13 | ||||
| -rwxr-xr-x | setup.sh | 88 |
7 files changed, 506 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e32a521 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +env* +.env +*home* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c5833a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM docker.io/alpine:3.17 + +RUN echo "http://dl-cdn.alpinelinux.org/alpine/v3.17/main" > /etc/apk/repositories +RUN echo "http://dl-cdn.alpinelinux.org/alpine/v3.17/community" >> /etc/apk/repositories + +RUN apk update +RUN apk add nginx +RUN apk add age signify acme.sh + +RUN mkdir -p /var/acme /var/www/acme + +COPY setup.sh /setup.sh +COPY entrypoint.sh / +COPY renewal.sh / + +ENTRYPOINT /entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..8acd56a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,125 @@ +#!/bin/sh +_username="acme" +printf "\033[1;31m" +set -e +set -x + +SERVICES="nginx renewal" +NGINX_LISTEN="${NGINX_LISTEN:-8080}" +FULL_NAME="${FULL_NAME:-Acme User}" + +ACME_USER_SHELL="${ACME_USER_SHELL:-/bin/ash}" +ACME_USER_UID="${ACME_USER_UID:-3500}" +ACME_USER_GID="${ACME_USER_GID:-3500}" + +NGINX_WORKER_PROCESSES="${NGINX_WORKER_PROCESSES:-1}" +NGINX_WORKER_CONNECTIONS="${NGINX_WORKER_CONNECTIONS:-1024}" +NGINX_AUTOINDEX="${NGINX_AUTOINDEX:-on}" + +AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-2}" + +set +x + +echo "Checking required variables..." + +err=0 +for var in ACME_DELEGATION_DOMAIN ACME_EMAIL DOMAINS ACMESH_FLAGS; do + eval val="\$$var" + #shellcheck disable=SC2154 + echo "$var=$val" + if [ -z "$val" ] && ! [ "$var" = "ACMESH_FLAGS" ] ; then + err=1 + fi +done + +if [ $err -ne 0 ] ; then + echo "Please set environment variables" + printf '\033[0m' + exit 3; +fi + +printf '\033[1;32m' + +echo "all good" + +printf "\033[0m" + +# This is only run once in the container's lifetime unless /setup is removed +setup() { +if [ -e /setup ] ; then return ; fi + +addgroup -g "${ACME_USER_GID}" "$_username" +adduser -h /var/acme --gecos "$FULL_NAME" -D -s "${ACME_USER_SHELL}" \ + -u "${ACME_USER_UID}" -G "$_username" "$_username" +# passwd -u "$_username" + + +touch /setup +} + +run_nginx() { +autoindex=on +if [ "$NGINX_AUTOINDEX" = "OFF" ] ; then + autoindex="off" +fi + +cat > /etc/nginx/nginx.conf <<NGINX +worker_processes $NGINX_WORKER_PROCESSES; +error_log /dev/fd/2; +events { + worker_connections $NGINX_WORKER_CONNECTIONS; +} +http { + access_log /dev/fd/1; + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + gzip on; + server_tokens off; + server { + listen $NGINX_LISTEN; listen [::]:$NGINX_LISTEN; + root /var/www/acme; + + location / { + autoindex $autoindex; + } + } +} +NGINX + +mkdir -p /run/nginx +nginx -g 'daemon off;' & +} + +run_renewal() { + /renewal.sh & +} + +watchServices() { +interval="$1"; shift +while true ; do + for service in $SERVICES ; do + if ! pgrep "$service" >/dev/null ; then + echo "Service $service has stopped... quitting!" + exit 1 + fi + done + sleep "$interval" +done +} + +set -x +# MAIN / Actual entrypoint start +setup +chown -R acme:acme /var/acme /var/www/acme +for service in $SERVICES ; do + eval "run_$service" +done +su acme /setup.sh + +# Bail out if a service stops, poll it every 30 seconds +set +x +watchServices 30 +# or if you comment out the above, drop into a shell +# exec /bin/ash "$@" diff --git a/fetch.sh b/fetch.sh new file mode 100644 index 0000000..fc15aa1 --- /dev/null +++ b/fetch.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# fetch.sh fetch and verify certificates from an HTTP endpoint +# This is meant to be used with the ACME +set -e + +verbose() { + if [ $_verbose -ne 0 ] ; then + return 0 + fi + return 1 +} + +cleanup() { + verbose && echo "Removing $_tmpdir" + rm -rf "$_tmpdir" || echo + exit 0 +} + +_curl='curl -sS' + +fullchainf= +certfile= +keyfile= +fetchurl= +domain= +encryptionKeyFile= +signatureKeyFile= +_verbose=0 +_tmpdir="$(mktemp -d)" + +help() { +cat <<EOF +$0 -f <fullchain> -c <certfile> -k <keyfile> -u <fetchURL> -d <domain> + -K <encryptionKeyFile> -S <signatureKeyFile> [-v] + +Optionally, AGE_ENCRYPTION_KEY and SIGNIFY_KEY can be set and +the contents of the environment variables will be used as key files +instead. + +-v is verbose. + +EOF +exit 1 +} + +#shellcheck disable=SC2034 +while [ $# -gt 0 ] ; do case $1 in + -f) fullchainf="$2"; shift ; shift ;; + -c) certfile="$2"; shift ; shift ;; + -k) keyfile="$2"; shift ; shift ;; + -u) fetchurl="$2"; shift ; shift ;; + -d) domain="$2"; shift ; shift ;; + -K) encryptionKeyFile="$2"; shift ; shift ;; + -S) signatureKeyFile="$2"; shift ; shift ;; + -v) _verbose=1; shift ;; + *) help ;; +esac ; done + +cd "$_tmpdir" + +if [ -n "$AGE_ENCRYPTION_KEY" ] ; then + encryptionKeyFile="age.key" + chmod 600 "$encryptionKeyFile" + echo "$AGE_ENCRYPTION_KEY" > "$encryptionKeyFile" +fi + +if [ -n "$SIGNIFY_KEY" ] ; then + signatureKeyFile="signify.sig" + chmod 600 "$signatureKeyFile" + echo "$SIGNIFY_KEY" > "$signatureKeyFile" +fi + +cd - + +err=0 +for opt in fullchainf certfile keyfile fetchurl domain \ + encryptionKeyFile signatureKeyFile ; do + eval val="\$$opt" + #shellcheck disable=SC2154 + if [ -z "$val" ] ; then + echo "Missing $opt" + err=1 + fi +done +if [ $err -ne 0 ] ; then + echo "missing required arguments" + exit 1; +fi + +encryptionKeyFile="$(realpath "$encryptionKeyFile")" +signatureKeyFile="$(realpath "$signatureKeyFile")" + +[ -e "$keyfile" ] && keyfile="$(realpath "$keyfile")" +[ -e "$fullchainf" ] && fullchainf="$(realpath "$fullchainf")" +[ -e "$certfile" ] && certfile="$(realpath "$certfile")" + +if ! [ -e "$keyfile" ] ; then + echo "Warning: no key file found" >&2 +fi + +if ! [ -e "$fullchainf" ] ; then + echo "Warning: no fullchain file found" >&2 +fi + +if ! [ -e "$certfile" ] ; then + echo "Warning: no cert file found" >&2 +fi + +cd "$_tmpdir" + +trap cleanup EXIT INT + +verbose && echo Fetching checkums... + +$_curl "${fetchurl}/${domain}.sha256sum" > "${domain}.sha256sum" +$_curl "${fetchurl}/${domain}.sha256sum.sig" > "${domain}.sha256sum.sig" + +signify -V -p "$signatureKeyFile" -m "${domain}.sha256sum" + +verbose && echo "Checksums OK" + +ok=0 +checksum="$(sha256sum "$keyfile" | awk '{print $1}')" +if grep -q "$checksum" "${domain}".sha256sum ; then + verbose && echo "Current key file \"$keyfile\" OK" + ok=$((ok + 1)) +fi + +checksum="$(sha256sum "$fullchainf" | awk '{print $1}')" +if grep -q "$checksum" "${domain}".sha256sum ; then + verbose && echo "Current fullchain file \"$fullchainf\" ok" + ok=$((ok + 1)) +fi + +checksum="$(sha256sum "$certfile" | awk '{print $1}')" +if grep -q "$checksum" "${domain}".sha256sum ; then + verbose && echo "Current cert file \"$certfile\" ok" + ok=$((ok + 1)) +fi + +if [ $ok -eq 3 ] ; then + verbose && echo "All files appear okay, exiting" + exit 0 +fi + +for _f in fullchain cer key.enc ; do + verbose && echo "Fetching ${_f}..." + $_curl "${fetchurl}/${domain}.${_f}" > "${domain}.$_f" +done + +verbose && echo "Decrypting key" +age -i "$encryptionKeyFile" -d "${domain}.key.enc" > "${domain}".key + +verbose && echo "Validating checksum for downloaded files" +sha256sum -c "${domain}.sha256sum" + +verbose && echo "Updating files" +cat < "${domain}".fullchain > "$fullchainf" +cat < "${domain}".cer > "$certfile" +cat < "${domain}.key" > "$keyfile" + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6d9c8a9 --- /dev/null +++ b/readme.md @@ -0,0 +1,100 @@ +# ACME certificate warehouse + +A pretty bare-bones docker container for storing and distributing ACME +certificates to your infrastructure. + +It will fetch certificates, encrypt the key file, generate checksums +then sign the checksums for each domain provided. + +This allows plain HTTP to serve as the transport for what is otherwise +sensitive information ( the key file, mostly ) + +It will generate both an encryption and signing key upon first start. + +To simplify usage there's an example `fetch.sh` script that can be used +to fetch the certificates from this service taking care of checksum validation, +signature checking and decryption for you if given a URL. + +It's currently setup *only* to use acme.sh and do DNS validation to route53. + +That may or may not suit your needs. This is not meant to be a generalized +solution for everyone's certificate needs, but rather a shell you can copy +and expand upon for your needs. + +It's only a few hundred lines of shell, so it's easily modified and hacked to +suit your needs. + +The general flow here is: + +``` +entrypoint.sh -> + Nginx -> serve up the certificates and keys + renewal.sh -> renew certificates if needed, every 24 hours + setup.sh -> runs `acme.sh`, then encrypts, signs and places them in the webroot +``` + +### Why? + +Ephemeral services across large swaths of disparate infrastructure where +tools like Ansible break down and give way too large of an attack surface if +automated. + +Solutions like S3 require esoteric rune-casting, and probably more code than +what you see here just to handle assuming fetching temporary credentials from +an assumed IAM role... Then lock you into a specific setup anyway. + +This also splits the access to DNS validation and certificate access even +further, if a server does managed to get compromised, you don't + +## Building + +``` +$ buildah build -t acme-warehouse . +``` + +## Running + +You may wish to bind mount `/var/acme` or create a volume for persistence. + +``` +$ podman run --env-file acme-env-file acme-warehouse +``` + +Where the `acme-env-file` contains the required environment variables. +They are: + +``` +ACME_DELEGATION_DOMAIN +ACME_EMAIL +DOMAINS +``` + +Additionally you may wish to pass in `ACMESH_FLAGS=--staging` until you've +verified that it pulls down and generates certificates as the DNS validation +limits are fairly low. + +`DOMAINS` are space or newline delimited list of domains to fetch certificates +for. That, and the subdomain wildcard will be fetched. + +You may wish to also supply AWS specific environment variables such as: + +``` +AWS_SECRET_ACCESS_KEY +AWS_ACCESS_KEY_ID +AWS_DEFAULT_REGION +``` + +If you want to encrypt with additional keys simply set: + +``` +AGE_RECIPIENTS +``` + +Generating a new key is easy with [`age-keygen`](https://github.com/FiloSottile/age) + +If you want to retrieve the key the container generated run: + +``` +podman exec <running_container_name> cat /var/acme/age.key +``` + diff --git a/renewal.sh b/renewal.sh new file mode 100755 index 0000000..04b3450 --- /dev/null +++ b/renewal.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -ex +timer=86400 + + +while true ; do + sleep $timer + + su acme /bin/sh \ + -c '/var/acme/acme_home/acme.sh --cron --home /var/acme/acme_home/' + + su acme /setup.sh +done diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..1bb9b03 --- /dev/null +++ b/setup.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# acme user's setup script +set -ex +cd "$HOME" + +if ! [ -e "sign.sec" ] ; then + rm -f sign.pub || echo "" + signify -G -n -p sign.pub -s sign.sec +fi +cat sign.pub + +if ! [ -e "age.key" ] ; then + age-keygen -o age.key +fi + +if [ -n "$AGE_RECIPIENTS" ] ; then + echo "$AGE_RECIPIENTS" > recipients.txt +fi + +awk '/public key/{print $4}' age.key >> recipients.txt + +if [ -z "$ACME_EMAIL" ] ; then + echo "ACME_EMAIL must be set" + exit 1 +fi + +if [ -z "$ACME_DELEGATION_DOMAIN" ] ; then + echo "ACME_DELEGATION_DOMAIN must be set" + exit 1 +fi + +if [ -z "$DOMAINS" ] ; then + echo "DOMAINS must be set" + exit 1 +fi + +cp /usr/bin/acme.sh ./ + +sh ./acme.sh --install \ + --home "$HOME/acme_home" \ + --config-home "$HOME/acme_conf" \ + --cert-home "$HOME/certs" \ + --accountemail "$ACME_EMAIL" \ + --accountkey "$HOME/acme_account.key" \ + --accountconf "$HOME/acme_account.conf" \ + --no-cron + + +#shellcheck disable=SC1091 +. "$HOME/acme_home/acme.sh.env" + +acme.sh --upgrade + +for domain in $DOMAINS ; do + #shellcheck disable=SC2086 + if ! [ -f "certs/$domain/$domain.cer" ] ; then + acme.sh $ACMESH_FLAGS \ + --issue \ + --dns dns_aws \ + --challenge-alias "$ACME_DELEGATION_DOMAIN" \ + -d "$domain" -d "*.${domain}" + fi + + cd "certs/$domain" + sha256sum "${domain}.cer" "${domain}.key" \ + > "/var/www/acme/${domain}.sha256sum" + + age -e -a -R "$HOME"/recipients.txt "${domain}.key" \ + > "/var/www/acme/${domain}.key.enc" + + cp "${domain}.cer" /var/www/acme/ + cp "fullchain.cer" /var/www/acme/"${domain}".fullchain + + cd /var/www/acme + + sha256sum "${domain}.fullchain" >> "${domain}.sha256sum" + + sha256sum "${domain}.key.enc" >> "${domain}.sha256sum" + + rm -f "${domain}.sha256sum.sig" || echo "" + + signify -S -m "${domain}.sha256sum" -s "$HOME/sign.sec" \ + -x "${domain}.sha256sum.sig" + + cd "$HOME" +done + +cp sign.pub /var/www/acme/ |
