diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd98b28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/certificate.yaml +/snapshot.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5000de6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +# syntax=docker/dockerfile:1.3-labs +FROM ghcr.io/flant/shell-operator:latest +RUN apk --no-progress update && apk --no-progress add jo zsh step-cli +ADD hooks/ /hooks/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..34b307f --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +`step-renewer` is a Kubernetes operator that renews Kubernetes `Secret` resources that contain Step CA issued certificates using `step ca renew` and therefore x5c certificate renewal. `step-renewer` will renew the certificates and update the Kubernetes `Secret` with the renewed certificate. + +`step-renewer` is a simple and minimal way to renew Step CA certificates in Kubernetes. It does not require providing a JWT provisioner password. It simply follows the same basic renewal instructions that you would use to renew certificates on Linux with `systemd`. It deploys as a single Pod. + +The drawback is that there is no automatic issuance of certificates. You need to build and upload a birth certificate but it will then be maintained in perpetuity. This is exactly what I need for a NATS, PostgreSQL or OpenSearch deployment in Kubernetes. They each have a single TLS certificate, the deployments are long lived, and I'm not deploying NATS nor OpenSearch by the thousands. + +**Help**: If anyone can see any other advantages or drawbacks of this method over the `cert-manager` and `autocert` and other methods, I'd appreciate your feedback, and I've asked for guidance in the Step CA discussions. + +## Install + +You can create your own Kubernetes manifests to deploy. In our examples, as in all the Shell Operator examples from the Shell Operator documentation, we're going to use `kubectl` commands to create the namespace and service account. + +``` +kubectl create namespace step-renewer +kubectl create clusterrole +kubectl create serviceaccount +kubectl create clusterrolebinding +``` + +You can now create a Kubernetes manifest for the `step-renewer` Pod. You will need to set the following environment variables. + +``` +apiVersion: v1 +kind: Namespace +metadata: + name: step-renewer +--- +apiVersion: v1 +kind: Pod +metadata: + namespace: step-renewer + name: step-renewer +spec: + containers: + - name: step-renewer + image: flatheadmill/step-renewer:latest + imagePullPolicy: Always + env: + - name: STEP_RENEWER_STEP_CA_URL + value: https://ca.prettyrobots.net + - name: STEP_RENEWER_STEP_CA_FINGERPRINT + value: 6fedeaa92e08e59967b8cb4ead5427b2c51a6ccb45cfe4f504d5af1a3392c16c + - name: STEP_RENEWER_EXPIRES_IN + value: '80%' + - name: STEP_RENEWER_DEBUG + value: '1' + - name: STEP_RENEWER_HUP + value: | + #!/usr/bin/env zsh + case "$1" in + program/program ) + curl http://my-secure-service.step-renewer/reload-certs + ;; + esac + serviceAccountName: step-renewer +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: step-renewer + namespace: step-renewer +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: step-renewer +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: step-renewer +subjects: +- kind: ServiceAccount + name: step-renewer + namespace: step-renewer +roleRef: + kind: ClusterRole + name: step-renewer + apiGroup: rbac.authorization.k8s.io +``` + +Step Operator documentation usually shows deployment using Pods instead of Deployments. If this causes a problem I'll come back and update this documentation, if it doesn't I'll come back and let you know that it doesn't really cause a problem. + +Now when you create a certificate you need to label it with `step-renewer.prettybobots.com: enabled`. The `step-renewer` will check it according to it's Step Operator `schedule`. + +## Configuration + +The full set of configuration environment variables are as follows. + +| Name | Value | +| ---------------------------------- | ------------------------------------------------------------ | +| `STEP_RENEWER_STEP_CA_URL` | The URL of the Step CA. | +| `STEP_RENEWER_STEP_CA_FINGERPRINT` | The fingerprint of the Step CA root certificate. | +| `STEP_RENEWER_EXPIRES_IN` | The amount of time remaining before certificate expiration, at which point a renewal should be attempted. The certificate renewal will not be performed if the time to expiration is greater than the value. Can be expressed as a percentage of the certificate validity duration. See `step ca renew --help` for more details. | +| `STEP_RENEWER_HUP` | A string containing an interpreted program that will be run after a secret has been updated. The program will be written to file with the executable bit set and executed to reload any services. It should be plain text and start with a shebang line. | +| `STEP_RENEWER_DEBUG` | (Optional) Print `ca certificate inspect` for each certificate on each scheduled invocation. | +| `STEP_RENEWER_UNSAFE_LOGGING` | (Optional) **Do not** set this environment variable for production. If set it will print the `_BINDING_CONTEXT` to standard output for use in debugging the application locally. Only use on development clusters with temporary, placeholder certificates. | + +To configure you build a container that overwrites the default configuration can mount a different configuration to `/hooks/config.yaml`. The default configuration is to run once every 15 minutes. + +## Hacking + +The crux of the operator is implemented in `hooks/step-renewer.zsh`. The entry point is `hooks/hook`. You can debug the application locally with a test cluster using the programs in `debug/`. + +The program `debug/renew /` tests renewal against a specific secret in your cluster. Invoke it with the same environment variables used to deploy a pod, plus a `namespace/secret` argument indicating the certificate you want to renew. + +``` +STEP_RENEWER_EXPIRES_IN='0%' \ + STEP_RENEWER_STEP_CA_URL=https://ca.prettyrobots.com \ + STEP_RENEWER_FINGERPRINT=6fedeaa92e08e59967b8cb4ead5427b2c51a6ccb45cfe4f504d5af1a3392c16c \ + debug/rewnew step-renewer/example +``` + +The program `debug/binding_context ` will run through an example of a binding context. You can capture an example of a binding context by running the `step-renewer` pod with the environment variable `STEP_RENEWER_UNSAFE_LOGGING=1`. **Do not** set this environment variable in a production cluster. Once running, each invocation of the `step-renewer` will print the `BINDING_CONTEXT` to standard output. + +Because Shell Operator logs standard output wrapped in JSON, you will need to extract the lines from the JSON. You can get plain standard output with the following command. + +``` +kubectl -n step-renewer logs step-renewer | jq -r 'select(.output == "stdout") | .msg' +``` + +The above command simply extracts the standard output from the hook from the JSON formatted logging messages. You will have to copy and paste an example of the binding context into a JSON file. In the example of invocation below we've copied and pasted a binding context example into `binding_context.json`. + +``` +STEP_RENEWER_EXPIRES_IN='0%' \ + STEP_RENEWER_STEP_CA_URL=https://ca.prettyrobots.com \ + STEP_RENEWER_FINGERPRINT=6fedeaa92e08e59967b8cb4ead5427b2c51a6ccb45cfe4f504d5af1a3392c16c \ + debug/binding_context ./binding_context.json +``` diff --git a/debug/binding_context b/debug/binding_context new file mode 100644 index 0000000..3dd95e8 --- /dev/null +++ b/debug/binding_context @@ -0,0 +1,12 @@ +#!/usr/bin/env zsh + +source ${0:a:h}/../hooks/step-renewer.zsh + +function { + typeset snapshot=${1:-} tmp=$(mktemp -d) + { + STEPPATH=$tmp/step process_binding_context $snapshot + } always { + rm -rf "$tmp" + } +} "$@" diff --git a/debug/config b/debug/config new file mode 100755 index 0000000..792f80f --- /dev/null +++ b/debug/config @@ -0,0 +1,5 @@ +#!/usr/bin/env zsh + +source ${0:a:h}/../hooks/config.zsh + +config diff --git a/debug/renew b/debug/renew new file mode 100755 index 0000000..e8c4f00 --- /dev/null +++ b/debug/renew @@ -0,0 +1,8 @@ +#!/usr/bin/env zsh + +source ${0:a:h}/../hooks/step-renewer.zsh + +function { + typeset namespace=${1:-} + renew_certificates <(kubectl -n "$namespace" get secrets -o json | jq '.items') +} "$@" diff --git a/hooks/config.zsh b/hooks/config.zsh new file mode 100644 index 0000000..52ae00e --- /dev/null +++ b/hooks/config.zsh @@ -0,0 +1,20 @@ +function config { + jo -p \ + configVersion=v1 \ + schedule="$(jo -a "$( + jo crontab=${STEP_RENEWER_CRONTAB:-'*/20 * * * *'} \ + group=certificates + )" )" \ + kubernetes="$(jo -a "$( + jo \ + apiVersion=v1 \ + kind=Secret \ + labelSelector="$( + jo matchLabels="$( + jo -- -s 'flatheadmill.github.io/step-renewer=' + )" + )" \ + executeHookOnEvent='[]' \ + group=certificates + )" )" +} diff --git a/hooks/hook b/hooks/hook new file mode 100755 index 0000000..d7e1764 --- /dev/null +++ b/hooks/hook @@ -0,0 +1,17 @@ +#!/usr/bin/env zsh + +source ${ZSH_ARGZERO:a:h}/step-renewer.zsh +source ${ZSH_ARGZERO:a:h}/config.zsh + +function { + typeset config_map_name + if [[ ${1:-} = '--config' ]]; then + config + else + if [[ $STEP_RENEWER_UNSAFE_LOGGING = 1 ]]; then + cat $BINDING_CONTEXT_PATH + print + fi + process_binding_context $BINDING_CONTEXT_PATH + fi +} "$@" diff --git a/hooks/step-renewer.zsh b/hooks/step-renewer.zsh new file mode 100644 index 0000000..55975c1 --- /dev/null +++ b/hooks/step-renewer.zsh @@ -0,0 +1,94 @@ +#!/usr/bin/env zsh + +function abend { + printf -- "$@" 1>&2 + print -u 2 + exit 1 +} + +# Note that the key is never written to this temporary directory, is read with +# process substitution so that the key is never written do disk, at least not +# by the code inside this file. We use a temp directory for the certificate, +# though. The alternative is write process substitution. A temp directory +# makes the code easier to read. + +function maybe_renew_certificate { + typeset tmp=${1:-} name=${2:-} namespace=${3:-} expires + shift 3 + while (( $# )); do + encoding=${1:-} crt_name=${2:-} crt=${3:-} key=${4:-} + shift 4 + base64 -d <<< "$crt" > $tmp/temp.crt + expires=$(step certificate inspect --format json $tmp/temp.crt | jq -r '.validity.end') + if ! expires=$(step certificate inspect --format json $tmp/temp.crt | jq -r '.validity.end'); then + print -- "secret=$namespace/$name certificate=$crt_name encoding=$encoding message=invalid" + continue + else + print -- "secret=$namespace/$name certificate=$crt_name encoding=$encoding expires=$expires message=visiting" + fi + [[ $STEP_RENEWER_DEBUG = 1 ]] && step certificate inspect $tmp/temp.crt + if step certificate needs-renewal --expires-in $STEP_RENEWER_EXPIRES_IN $tmp/temp.crt 2>/dev/null; then + if ! step ca renew --force $tmp/temp.crt <(base64 -d <<< $key); then + printf 'unable to renew `%s/%s`.\n' $namespace $name + continue + fi + expires=$(step certificate inspect --format json $tmp/temp.crt | jq -r '.validity.end') + kubectl -n $namespace patch secret $name --patch-file =(jo data="$(jo $crt_name=%$tmp/temp.crt)") > /dev/null + print -- "secret=$namespace/$name certificate=$crt_name encoding=$encoding expires=$expires message=renewed" + else + print -- "secret=$namespace/$name certificate=$crt_name encoding=$encoding expires=$expires message=okay" + fi + done +} + +function renew_certificates { + [[ -n $STEP_RENEWER_STEP_CA_URL ]] || abend 'STEP_RENEWER_STEP_CA_URL is not set' + [[ -n $STEP_RENEWER_STEP_CA_FINGERPRINT ]] || abend 'STEP_RENEWER_STEP_CA_FINGERPRINT is not set' + typeset input=${1:-} tmp name count certificates=() + set -- "${(QA@)${(z)$(jq -r ' + [ + .[] | + select(.metadata.labels["flatheadmill.github.io/step-renewer"] == "") | + . as $root | + [[ + if (.metadata.annotations | has("flatheadmill.github.io/step-renewer.pairs")) + then .metadata.annotations["flatheadmill.github.io/step-renewer.pairs"] + else "tls.crt/tls.key/pem" end | + split(":")[] | + split("/") | { + crt: (if (. | length) > 1 then .[0] else "" end), + key: (if (. | length) > 2 then .[1] else "" end), + type: (if (. | length) == 3 then .[2] else "pem" end) + } + ][] | ( + .type, + .crt, + (.crt as $crt | if ($root.data | has($crt)) then $root.data[.crt] else "" end), + (.key as $key | if ($root.data | has($key)) then $root.data[.key] else "" end) + )] as $certficates | + (.metadata.name, .metadata.namespace, ($certficates | length), $certficates[]) + ] | @sh + ' < $input)}}" + tmp=$(mktemp -d) || abend 'cannot create temporary directory' + { + STEPPATH=$tmp/step step ca bootstrap --force \ + --ca-url "$STEP_RENEWER_STEP_CA_URL" \ + --fingerprint "$STEP_RENEWER_STEP_CA_FINGERPRINT" > /dev/null 2>&1 || + abend 'unable to bootstrap step' + while (( $# )); do + name=${1:-} namespace=${2:-} count=${3:-} + shift 3 + certificates=( "$@[1,$count]" ) + shift $count + STEPPATH=$tmp/step maybe_renew_certificate $tmp $name $namespace "${(@)certificates}" + done + } always { + [[ -d $tmp ]] && rm -rf $tmp + } +} + +function process_binding_context { + typeset process_binding=${1:-} + shift + renew_certificates <(jq '[ .[0].snapshots.kubernetes[] | .object ]' < $process_binding) +} diff --git a/step-renewer.yaml b/step-renewer.yaml new file mode 100644 index 0000000..9f90f0a --- /dev/null +++ b/step-renewer.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: step-renewer +--- +apiVersion: v1 +kind: Pod +metadata: + namespace: step-renewer + name: step-renewer +spec: + containers: + - name: step-renewer + image: flatheadmill/step-renewer:latest + imagePullPolicy: Always + env: + - name: STEP_RENEWER_STEP_CA_URL + value: https://ca.prettyrobots.net + - name: STEP_RENEWER_STEP_CA_FINGERPRINT + value: 6fedeaa92e08e59967b8cb4ead5427b2c51a6ccb45cfe4f504d5af1a3392c16c + - name: STEP_RENEWER_EXPIRES_IN + value: '80%' + - name: STEP_RENEWER_DEBUG + value: '1' + - name: STEP_RENEWER_HUP + value: | + curl http:://program.program/reload-certs + serviceAccountName: step-renewer +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: step-renewer + namespace: step-renewer +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: step-renewer +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: step-renewer +subjects: +- kind: ServiceAccount + name: step-renewer + namespace: step-renewer +roleRef: + kind: ClusterRole + name: step-renewer + apiGroup: rbac.authorization.k8s.io