Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a2d3f1e
Minimal Shell Operator example.
flatheadmill Aug 12, 2023
bf6f331
Create a `Dockerfile`.
flatheadmill Aug 12, 2023
8ccd7f5
Quote `jq` path argument.
flatheadmill Aug 13, 2023
2b0ce9c
Working Step Operator.
flatheadmill Aug 13, 2023
841f84b
Attempt to match by label.
flatheadmill Aug 13, 2023
5332aee
Let's see what happens every minute.
flatheadmill Aug 13, 2023
73e4785
Add our label.
flatheadmill Aug 13, 2023
3085fbd
Let's switch to secrets.
flatheadmill Aug 13, 2023
95a2b98
Rename our dummy certificate.
flatheadmill Aug 13, 2023
17e91ce
Use secrets in our hook.
flatheadmill Aug 13, 2023
c980d02
Fix secret definition.
flatheadmill Aug 13, 2023
047ecc0
Remove `certificate.yaml`.
flatheadmill Aug 13, 2023
dd4089b
Working renewal.
flatheadmill Aug 13, 2023
09d5d97
Reorganize for debugging.
flatheadmill Sep 20, 2023
6782683
Add builder version.
flatheadmill Sep 21, 2023
8e9a325
Remove `lib` directory.
flatheadmill Sep 21, 2023
d7de4e8
Guess we're not getting snapshots, per se.
flatheadmill Sep 21, 2023
169c554
Install `step-cli`.
flatheadmill Sep 21, 2023
28078b8
Fix label matching.
flatheadmill Sep 21, 2023
abaff1e
Include secret snapshots.
flatheadmill Sep 21, 2023
418b000
Might have used a group last time.
flatheadmill Sep 21, 2023
46f8fe4
Restore snapshot `jq`.
flatheadmill Sep 21, 2023
270b504
Change to `ClusterRoleBinding`.
flatheadmill Sep 21, 2023
557f80f
Setting a bad example with unsafe logging.
flatheadmill Sep 21, 2023
77b68ed
Rename group to `certificates`.
flatheadmill Sep 21, 2023
4dcb965
Emit configuration using `jo`.
flatheadmill Sep 21, 2023
b7aa1b2
Remove test to see if `jo` is installed.
flatheadmill Sep 21, 2023
b83dc4d
Sketch of how `HUP` might work.
flatheadmill Jan 12, 2024
d3a71b1
Fix base64 encoding, bad parameter substitution.
acreops Jan 21, 2024
26ce93f
Sketch of non `tls` type secrets with multiple certificates.
flatheadmill Apr 24, 2024
ddd6841
Working non-TLS type secrets.
flatheadmill Apr 25, 2024
d9d1730
Fix feed into `renew_certificates` function.
flatheadmill Apr 26, 2024
14efeb3
Remove debug printing.
flatheadmill Apr 26, 2024
f36c040
Fix annotation.
flatheadmill Apr 26, 2024
a75118e
Adjust annotation again.
flatheadmill Apr 26, 2024
c5cb421
Adjust label.
flatheadmill Apr 26, 2024
f4a2fa5
Fix step renewer configuration error.
flatheadmill May 13, 2024
9926289
Label presence only, tidy Zsh.
flatheadmill Mar 6, 2025
9d8c4a4
Fix label selector.
flatheadmill Mar 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/certificate.yaml
/snapshot.json
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
133 changes: 133 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <namespace>/<secret>` 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 <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
```
12 changes: 12 additions & 0 deletions debug/binding_context
Original file line number Diff line number Diff line change
@@ -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"
}
} "$@"
5 changes: 5 additions & 0 deletions debug/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env zsh

source ${0:a:h}/../hooks/config.zsh

config
8 changes: 8 additions & 0 deletions debug/renew
Original file line number Diff line number Diff line change
@@ -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')
} "$@"
20 changes: 20 additions & 0 deletions hooks/config.zsh
Original file line number Diff line number Diff line change
@@ -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
)" )"
}
17 changes: 17 additions & 0 deletions hooks/hook
Original file line number Diff line number Diff line change
@@ -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
} "$@"
94 changes: 94 additions & 0 deletions hooks/step-renewer.zsh
Original file line number Diff line number Diff line change
@@ -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)
}
56 changes: 56 additions & 0 deletions step-renewer.yaml
Original file line number Diff line number Diff line change
@@ -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