├── .github ├── ISSUE_TEMPLATE │ ├── autocert_bug.md │ ├── autocert_enhancement.md │ └── documentation-request.md ├── PULL_REQUEST_TEMPLATE ├── dependabot.yml └── workflows │ ├── actionlint.yml │ ├── ci.yml │ ├── code-scan-cron.yml │ ├── dependabot-auto-merge.yml │ ├── release.yml │ └── triage.yml ├── .gitignore ├── .goreleaser.yml ├── .version.sh ├── INSTALL.md ├── LICENSE ├── Makefile ├── README.md ├── RUNBOOK.md ├── SECURITY.md ├── autocert-arch.png ├── autocert-bootstrap.png ├── autocert-logo.png ├── bootstrapper ├── Dockerfile └── bootstrapper.sh ├── connect-with-mtls.png ├── controller ├── Dockerfile ├── client.go ├── main.go └── main_test.go ├── demo.gif ├── examples └── hello-mtls │ ├── README.md │ ├── curl │ ├── Dockerfile.client │ ├── client.sh │ └── hello-mtls.client.yaml │ ├── envoy │ ├── Dockerfile.server │ ├── certwatch.sh │ ├── entrypoint.sh │ ├── hello-mtls.server.yaml │ ├── hot-restarter.py │ ├── requirements.txt │ ├── server.py │ ├── server.yaml │ └── start-envoy.sh │ ├── go-grpc │ ├── client │ │ ├── Dockerfile.client │ │ ├── client.go │ │ └── hello-mtls.client.yaml │ ├── hello │ │ ├── hello.pb.go │ │ └── hello.proto │ └── server │ │ ├── Dockerfile.server │ │ ├── hello-mtls.server.yaml │ │ └── server.go │ ├── go │ ├── client │ │ ├── Dockerfile.client │ │ ├── client.go │ │ └── hello-mtls.client.yaml │ └── server │ │ ├── Dockerfile.server │ │ ├── hello-mtls.server.yaml │ │ └── server.go │ ├── nginx │ ├── Dockerfile.server │ ├── certwatch.sh │ ├── entrypoint.sh │ ├── hello-mtls.server.yaml │ └── site.conf │ ├── node │ ├── Dockerfile.client │ ├── Dockerfile.server │ ├── client.js │ ├── hello-mtls.client.yaml │ ├── hello-mtls.server.yaml │ └── server.js │ └── py-gunicorn │ ├── Dockerfile.client │ ├── Dockerfile.server │ ├── client.py │ ├── client.requirements.txt │ ├── gunicorn.conf │ ├── hello-mtls.client.yaml │ ├── hello-mtls.server.yaml │ ├── requirements.txt │ └── server.py ├── go.mod ├── go.sum ├── icon.png ├── icon.svg ├── init ├── Dockerfile └── autocert.sh ├── install ├── 01-step-ca.yaml ├── 02-autocert.yaml └── 03-rbac.yaml ├── mtls-handshake.png └── renewer └── Dockerfile /.github/ISSUE_TEMPLATE/autocert_bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Autocert Bug 3 | about: Report a bug you found in autocert 4 | labels: bug, needs triage 5 | --- 6 | 7 | ### Subject of the issue 8 | Describe your issue here 9 | 10 | ### Environment 11 | * Kubernetes version: 12 | * Cloud provider or hardware configuration: 13 | * OS (e.g., from /etc/os-release): 14 | * Kernel (e.g., `uname -a`): 15 | * Install tools: 16 | * Other: 17 | 18 | ### Steps to reproduce 19 | Tell us how to reproduce this issue 20 | 21 | ### Expected behaviour 22 | Tell us what should happen 23 | 24 | ### Actual behaviour 25 | Tell us what happens instead 26 | 27 | ### Additional context 28 | Add any other context about the problem here 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/autocert_enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Autocert Enhancement 3 | about: Suggest an enhancement to autocert 4 | labels: enhancement, needs triage 5 | --- 6 | 7 | ### What would you like to be added 8 | 9 | 10 | ### Why this is needed 11 | 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Request 3 | about: Request documentation for a feature 4 | title: '' 5 | labels: documentation, needs triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE: -------------------------------------------------------------------------------- 1 | 6 | #### Name of feature: 7 | 8 | #### Pain or issue this feature alleviates: 9 | 10 | #### Why is this important to the project (if not answered above): 11 | 12 | #### Is there documentation on how to use this feature? If so, where? 13 | 14 | #### In what environments or workflows is this feature supported? 15 | 16 | #### In what environments or workflows is this feature explicitly NOT supported (if any)? 17 | 18 | #### Supporting links/other PRs/issues: 19 | 20 | 💔Thank you! 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "gomod" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions workflows 2 | on: 3 | push: 4 | workflow_call: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 8 | cancel-in-progress: true 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | actionlint: 16 | uses: smallstep/workflows/.github/workflows/actionlint.yml@main 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - 'v*' 7 | branches: 8 | - "master" 9 | pull_request: 10 | workflow_call: 11 | secrets: 12 | CODECOV_TOKEN: 13 | required: true 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | ci: 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | uses: smallstep/workflows/.github/workflows/goCI.yml@main 26 | with: 27 | only-latest-golang: true 28 | run-codeql: true 29 | secrets: inherit 30 | -------------------------------------------------------------------------------- /.github/workflows/code-scan-cron.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: '0 0 * * SUN' 4 | 5 | jobs: 6 | code-scan: 7 | uses: smallstep/workflows/.github/workflows/code-scan.yml@main 8 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot-auto-merge: 10 | uses: smallstep/workflows/.github/workflows/dependabot-auto-merge.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release & Upload Assets 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 8 | 9 | jobs: 10 | ci: 11 | uses: smallstep/autocert/.github/workflows/ci.yml@master 12 | secrets: inherit 13 | 14 | create_release: 15 | name: Create Release 16 | needs: ci 17 | runs-on: ubuntu-latest 18 | env: 19 | INIT_DOCKER_IMAGE: smallstep/autocert-init 20 | BOOTSTRAPPER_DOCKER_IMAGE: smallstep/autocert-bootstrapper 21 | RENEWER_DOCKER_IMAGE: smallstep/autocert-renewer 22 | CONTROLLER_DOCKER_IMAGE: smallstep/autocert-controller 23 | outputs: 24 | version: ${{ steps.extract-tag.outputs.VERSION }} 25 | vversion: ${{ steps.extract-tag.outputs.VVERSION }} 26 | is_prerelease: ${{ steps.is_prerelease.outputs.IS_PRERELEASE }} 27 | init_docker_tags: ${{ env.INIT_DOCKER_TAGS }} 28 | bootstrapper_docker_tags: ${{ env.BOOTSTRAPPER_DOCKER_TAGS }} 29 | renewer_docker_tags: ${{ env.RENEWER_DOCKER_TAGS }} 30 | controller_docker_tags: ${{ env.CONTROLLER_DOCKER_TAGS }} 31 | steps: 32 | - name: Is Pre-release 33 | id: is_prerelease 34 | run: | 35 | set +e 36 | echo ${{ github.ref }} | grep "\-rc.*" 37 | OUT=$? 38 | if [ $OUT -eq 0 ]; then IS_PRERELEASE=true; else IS_PRERELEASE=false; fi 39 | echo "IS_PRERELEASE=${IS_PRERELEASE}" >> "${GITHUB_OUTPUT}" 40 | - name: Extract Tag Names 41 | id: extract-tag 42 | run: | 43 | VVERSION="${GITHUB_REF#refs/tags/}" 44 | VERSION="${GITHUB_REF#refs/tags/v}" 45 | # shellcheck disable=SC2129 46 | echo "VVERSION=${VVERSION}" >> "${GITHUB_OUTPUT}" 47 | echo "VERSION=${VERSION}" >> "${GITHUB_OUTPUT}" 48 | # shellcheck disable=SC2129 49 | echo "INIT_DOCKER_TAGS=${{ env.INIT_DOCKER_IMAGE }}:${VERSION}" >> "${GITHUB_ENV}" 50 | echo "BOOTSTRAPPER_DOCKER_TAGS=${{ env.BOOTSTRAPPER_DOCKER_IMAGE }}:${VERSION}" >> "${GITHUB_ENV}" 51 | echo "RENEWER_DOCKER_TAGS=${{ env.RENEWER_DOCKER_IMAGE }}:${VERSION}" >> "${GITHUB_ENV}" 52 | echo "CONTROLLER_DOCKER_TAGS=${{ env.CONTROLLER_DOCKER_IMAGE }}:${VERSION}" >> "${GITHUB_ENV}" 53 | - name: Add Latest Tag 54 | if: steps.is_prerelease.outputs.IS_PRERELEASE == 'false' 55 | run: | 56 | # shellcheck disable=SC2129 57 | echo "INIT_DOCKER_TAGS=${{ env.INIT_DOCKER_TAGS }},${{ env.INIT_DOCKER_IMAGE }}:latest" >> "${GITHUB_ENV}" 58 | echo "BOOTSTRAPPER_DOCKER_TAGS=${{ env.BOOTSTRAPPER_DOCKER_TAGS }},${{ env.BOOTSTRAPPER_DOCKER_IMAGE }}:latest" >> "${GITHUB_ENV}" 59 | echo "RENEWER_DOCKER_TAGS=${{ env.RENEWER_DOCKER_TAGS }},${{ env.RENEWER_DOCKER_IMAGE }}:latest" >> "${GITHUB_ENV}" 60 | echo "CONTROLLER_DOCKER_TAGS=${{ env.CONTROLLER_DOCKER_TAGS }},${{ env.CONTROLLER_DOCKER_IMAGE }}:latest" >> "${GITHUB_ENV}" 61 | - name: Create Release 62 | id: create_release 63 | uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | tag_name: ${{ github.ref_name }} 68 | name: Release ${{ github.ref_name }} 69 | draft: false 70 | prerelease: ${{ steps.is_prerelease.outputs.IS_PRERELEASE }} 71 | 72 | goreleaser: 73 | needs: create_release 74 | permissions: 75 | id-token: write 76 | contents: write 77 | packages: write 78 | uses: smallstep/workflows/.github/workflows/goreleaser.yml@main 79 | secrets: inherit 80 | 81 | build_upload_docker_autocert_init: 82 | name: Build & Upload Autocert Init Docker Images 83 | needs: create_release 84 | permissions: 85 | id-token: write 86 | contents: write 87 | uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main 88 | with: 89 | platforms: linux/amd64,linux/arm64 90 | tags: ${{ needs.create_release.outputs.init_docker_tags }} 91 | docker_image: smallstep/autocert-init 92 | docker_file: init/Dockerfile 93 | secrets: inherit 94 | 95 | build_upload_docker_autocert_renewer: 96 | name: Build & Upload Autocert Renewer Images 97 | needs: create_release 98 | permissions: 99 | id-token: write 100 | contents: write 101 | uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main 102 | with: 103 | platforms: linux/amd64,linux/arm64 104 | tags: ${{ needs.create_release.outputs.renewer_docker_tags }} 105 | docker_image: smallstep/autocert-renewer 106 | docker_file: renewer/Dockerfile 107 | secrets: inherit 108 | 109 | build_upload_docker_autocert_bootstrapper: 110 | name: Build & Upload Autocert Bootstrapper Images 111 | needs: create_release 112 | permissions: 113 | id-token: write 114 | contents: write 115 | uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main 116 | with: 117 | platforms: linux/amd64,linux/arm64 118 | tags: ${{ needs.create_release.outputs.bootstrapper_docker_tags }} 119 | docker_image: smallstep/autocert-bootstrapper 120 | docker_file: bootstrapper/Dockerfile 121 | secrets: inherit 122 | 123 | build_upload_docker_autocert_controller: 124 | name: Build & Upload Autocert Bootstrapper Images 125 | needs: create_release 126 | permissions: 127 | id-token: write 128 | contents: write 129 | uses: smallstep/workflows/.github/workflows/docker-buildx-push.yml@main 130 | with: 131 | platforms: linux/amd64,linux/arm64 132 | tags: ${{ needs.create_release.outputs.controller_docker_tags }} 133 | docker_image: smallstep/autocert-controller 134 | docker_file: controller/Dockerfile 135 | secrets: inherit 136 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: Add Issues and PRs to Triage 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | pull_request_target: 9 | types: 10 | - opened 11 | - reopened 12 | 13 | jobs: 14 | triage: 15 | uses: smallstep/workflows/.github/workflows/triage.yml@main 16 | secrets: inherit 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | /bin/ 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Others 16 | /vendor 17 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | project_name: autocert 4 | 5 | before: 6 | hooks: 7 | # You may remove this if you don't use go modules. 8 | - go mod download 9 | # - go generate ./... 10 | 11 | builds: 12 | - 13 | id: default 14 | env: 15 | - CGO_ENABLED=0 16 | targets: 17 | - linux_amd64 18 | flags: 19 | - -trimpath 20 | main: ./controller 21 | binary: autocert 22 | ldflags: 23 | - -w -X main.Version={{.Version}} -X main.BuildTime={{.Date}} 24 | 25 | archives: 26 | - 27 | # Can be used to change the archive formats for specific GOOSs. 28 | # Most common use case is to archive as zip on Windows. 29 | # Default is empty. 30 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" 31 | format_overrides: 32 | - goos: windows 33 | format: zip 34 | builds: 35 | - default 36 | wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}" 37 | files: 38 | - README.md 39 | - LICENSE 40 | 41 | source: 42 | enabled: true 43 | name_template: '{{ .ProjectName }}_{{ .Version }}' 44 | 45 | checksum: 46 | name_template: 'checksums.txt' 47 | extra_files: 48 | - glob: ./.releases/* 49 | 50 | signs: 51 | - cmd: cosign 52 | signature: "${artifact}.sig" 53 | certificate: "${artifact}.pem" 54 | args: ["sign-blob", "--oidc-issuer=https://token.actions.githubusercontent.com", "--output-certificate=${certificate}", "--output-signature=${signature}", "${artifact}", "--yes"] 55 | artifacts: all 56 | 57 | snapshot: 58 | name_template: "{{ .Tag }}-next" 59 | 60 | release: 61 | # Repo in which the release will be created. 62 | # Default is extracted from the origin remote URL or empty if its private hosted. 63 | # Note: it can only be one: either github, gitlab or gitea 64 | github: 65 | owner: smallstep 66 | name: autocert 67 | 68 | # IDs of the archives to use. 69 | # Defaults to all. 70 | #ids: 71 | # - default 72 | # - bar 73 | 74 | # If set to true, will not auto-publish the release. 75 | # Default is false. 76 | draft: false 77 | 78 | # If set to auto, will mark the release as not ready for production 79 | # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 80 | # If set to true, will mark the release as not ready for production. 81 | # Default is false. 82 | prerelease: auto 83 | 84 | # You can change the name of the release. 85 | # Default is `{{.Tag}}` 86 | name_template: "Autocert {{ .Tag }} ({{ .Env.RELEASE_DATE }})" 87 | 88 | # Header template for the release body. 89 | # Defaults to empty. 90 | header: | 91 | ## Signatures and Checksums 92 | 93 | `autocert` uses [sigstore/cosign](https://github.com/sigstore/cosign) for signing and verifying release artifacts. 94 | 95 | Below is an example using `cosign` to verify a release artifact: 96 | 97 | ``` 98 | COSIGN_EXPERIMENTAL=1 cosign verify-blob \ 99 | --certificate ~/Downloads/autocert_linux_{{ .Version }}_amd64.tar.gz.pem \ 100 | --signature ~/Downloads/autocert_linux{{ .Version }}_amd64.tar.gz.sig \ 101 | ~/Downloads/autocert_linux{{ .Version }}_amd64.tar.gz 102 | ``` 103 | 104 | The `checksums.txt` file (in the 'Assets' section below) contains a checksum for every artifact in the release. 105 | 106 | # Footer template for the release body. 107 | # Defaults to empty. 108 | footer: | 109 | ## Thanks! 110 | 111 | Those were the changes on {{ .Tag }}! 112 | 113 | Come join us on [Discord](https://discord.gg/X2RKGwEbV9) to ask questions, chat about PKI, or get a sneak peak at the freshest PKI memes. 114 | 115 | # You can disable this pipe in order to not upload any artifacts. 116 | # Defaults to false. 117 | #disable: true 118 | 119 | # You can add extra pre-existing files to the release. 120 | # The filename on the release will be the last part of the path (base). If 121 | # another file with the same name exists, the latest one found will be used. 122 | # Defaults to empty. 123 | extra_files: 124 | - glob: ./.releases/* 125 | # - glob: ./glob/**/to/**/file/**/* 126 | # - glob: ./glob/foo/to/bar/file/foobar/override_from_previous 127 | -------------------------------------------------------------------------------- /.version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | read -r firstline < .VERSION 3 | last_half="${firstline##*tag: }" 4 | if [[ ${last_half::1} == "v" ]]; then 5 | version_string="${last_half%%[,)]*}" 6 | fi 7 | echo "${version_string:-v0.0.0}" 8 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installing `autocert` 2 | 3 | ### Prerequisites 4 | 5 | To get started you'll need [`kubectl`](https://kubernetes.io/docs/tasks/tools/install-kubectl/#install-kubectl) and a cluster running kubernetes `1.9` or later with [admission webhooks](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#admission-webhooks) enabled: 6 | 7 | ```bash 8 | $ kubectl version --short 9 | Client Version: v1.13.1 10 | Server Version: v1.10.11 11 | $ kubectl api-versions | grep "admissionregistration.k8s.io/v1beta1" 12 | admissionregistration.k8s.io/v1beta1 13 | ``` 14 | 15 | ### Install 16 | 17 | The easiest way to install `autocert` is to run: 18 | 19 | ```bash 20 | kubectl run autocert-init -it --rm --image smallstep/autocert-init --restart Never 21 | ``` 22 | 23 | 💥 installation complete. 24 | 25 | > You might want to [check out what this command does](init/autocert.sh) before running it. 26 | 27 | ## Manual install 28 | 29 | To install manually you'll need to [install step](https://github.com/smallstep/cli#installing) version `0.18.2` or later. 30 | 31 | ``` 32 | $ step version 33 | Smallstep CLI/0.18.2 (darwin/amd64) 34 | Release Date: 2022-03-30 06:08 UTC 35 | ``` 36 | 37 | ### Create a CA 38 | 39 | Set your `STEPPATH` to a working directory where we can stage our CA artifacts before we push them to kubernetes. You can delete this directory once installation is complete. 40 | 41 | ``` 42 | $ export STEPPATH=$(mktemp -d /tmp/step.XXX) 43 | $ step path 44 | /tmp/step.0kE 45 | ``` 46 | 47 | Run `step ca init` to generate a root certificate and CA configuration for your cluster. You'll be prompted for a password that will be used to encrypt key material. 48 | 49 | ``` 50 | $ step ca init \ 51 | --name Autocert \ 52 | --dns "ca.step.svc.cluster.local,127.0.0.1" \ 53 | --address ":4443" \ 54 | --provisioner admin \ 55 | --with-ca-url "ca.step.svc.cluster.local" 56 | ``` 57 | 58 | For older versions of `step` run this command without the flags. 59 | 60 | Add provisioning credentials for use by `autocert`. You'll be prompted for a password for `autocert`. 61 | 62 | ``` 63 | $ step ca provisioner add autocert --create 64 | ``` 65 | 66 | For older versions of [`step`](https://github.com/smallstep/cli/releases): 67 | 68 | * Run `step ca init` and follow prompts 69 | * Edit `$(step path)/config/ca.json` and change base paths to `/home/step` 70 | * Edit `$(step path)/config/defaults.json` to change base paths to `/home/step` and remove port from CA URL 71 | 72 | ``` 73 | $ sed -i "" "s|ca.step.svc.cluster.local:4443|ca.step.svc.cluster.local|" $(step path)/config/defaults.json 74 | ``` 75 | 76 | ### Install the CA in Kubernetes 77 | 78 | We'll be creating a new kubernetes namespace and setting up some RBAC rules during installation. You'll need appropriate permissions in your cluster (e.g., you may need to be cluster-admin). GKE, in particular, does not give the cluster owner these rights by default. You can give yourself cluster-admin rights on GKE by running: 79 | 80 | ```bash 81 | kubectl create clusterrolebinding cluster-admin-binding \ 82 | --clusterrole cluster-admin \ 83 | --user $(gcloud config get-value account) 84 | ``` 85 | 86 | We'll install our CA and the `autocert` controller in the `step` namespace. 87 | 88 | ``` 89 | $ kubectl create namespace step 90 | ``` 91 | 92 | To install the CA we need to configmap the CA certificates, signing keys, and configuration artifacts. Note that key material is encrypted so we don't need to use secrets. 93 | 94 | ``` 95 | $ kubectl -n step create configmap config --from-file $(step path)/config 96 | $ kubectl -n step create configmap certs --from-file $(step path)/certs 97 | $ kubectl -n step create configmap secrets --from-file $(step path)/secrets 98 | ``` 99 | 100 | But we will need to create secrets for the CA and autocert to decrypt their keys: 101 | 102 | ``` 103 | $ kubectl -n step create secret generic ca-password --from-literal password= 104 | $ kubectl -n step create secret generic autocert-password --from-literal password= 105 | ``` 106 | 107 | Where `` is the password you entered during `step ca init` and `` is the password you entered during `step ca provisioner add`. 108 | 109 | Next, we'll install the CA. 110 | 111 | ``` 112 | $ kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/01-step-ca.yaml 113 | ``` 114 | 115 | Once you've done this you can delete the temporary `$STEPPATH` directory and `unset STEPPATH` (though you may want to retain it as a backup). 116 | 117 | ### Install `autocert` in Kubernetes 118 | 119 | Install the `autocert` controller. 120 | 121 | ``` 122 | $ kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/02-autocert.yaml 123 | ``` 124 | 125 | Autocert creates secrets containing single-use bootstrap tokens for pods to authenticate with the CA and obtain a certificate. The tokens are automatically cleaned up after they expire. To do this, `autocert` needs permission to create and delete secrets in your cluster. 126 | 127 | If you have RBAC enabled in your cluster, apply `rbac.yaml` to give `autocert` these permissions. 128 | 129 | ``` 130 | $ kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/03-rbac.yaml 131 | ``` 132 | 133 | Finally, register the `autocert` mutation webhook with kubernetes. 134 | 135 | ``` 136 | $ cat < By the way, we also have a [cert-manager](https://cert-manager.io/) Certificate Issuer called [step-issuer](https://github.com/smallstep/step-issuer) that works directly with either your [step-ca](https://github.com/smallstep/certificates/) server or [our cloud CA product](https://smallstep.com/certificate-manager/). While Autocert volume mounts certificates and keys directly into Pods, step-issuer makes them available via Secrets. 18 | 19 | We ❤️ feedback, [bugs](https://github.com/smallstep/autocert/issues/new?template=autocert_bug.md), and [enhancement suggestions](https://github.com/smallstep/autocert/issues/new?template=autocert_enhancement.md). We also have an #autocert channel [on our Discord](https://bit.ly/step-discord). 20 | 21 | ![Autocert demo gif](https://raw.githubusercontent.com/smallstep/autocert/master/demo.gif) 22 | 23 | ## Motivation 24 | 25 | `Autocert` exists to **make it easy to use mTLS** ([mutual TLS](examples/hello-mtls/README.md#mutual-tls)) to **improve security** within a cluster and to **secure communication into, out of, and between kubernetes clusters**. 26 | 27 | TLS (and HTTPS, which is HTTP over TLS) provides _authenticated encryption_: an _identity dialtone_ and _end-to-end encryption_ for your workloads. It **makes workloads identity-aware**, improving observability and enabling granular access control. Perhaps most compelling, mTLS lets you securely communicate with workloads running anywhere, not just inside kubernetes. 28 | 29 | ![Connect with mTLS diagram](https://raw.githubusercontent.com/smallstep/autocert/master/connect-with-mtls.png) 30 | 31 | Unlike VPNs & SDNs, deploying and scaling mTLS is pretty easy. You're (hopefully) already using TLS, and your existing tools and standard libraries will provide most of what you need. 32 | 33 | There's just one problem: **you need certificates issued by your own certificate authority (CA)**. Building and operating a CA, issuing certificates, and making sure they're renewed before they expire is tricky. `Autocert` does all of this for you. 34 | 35 | ## Features 36 | 37 | First and foremost, `autocert` is easy. You can **get started in minutes**. 38 | 39 | `Autocert` runs [`step-ca`](https://github.com/smallstep/certificates) to internally generate keys and issue certificates. This process is secure and automatic, all you have to do is [install autocert](#install) and [annotate your pods](#enable-autocert-per-namespace). 40 | 41 | Features include: 42 | 43 | * A fully featured private CA for workloads running on kubernetes and elsewhere 44 | * [RFC5280](https://tools.ietf.org/html/rfc5280) and [CA/Browser Forum](https://cabforum.org/baseline-requirements-documents/) compliant certificates that work for TLS 45 | * Namespaced installation into the `step` namespace so it's easy to lock down your CA 46 | * Short-lived certificates with fully automated enrollment and renewal 47 | * Private keys are never transmitted across the network and aren't stored in `etcd` 48 | 49 | Because `autocert` is built on [`step-ca`](https://github.com/smallstep/certificates) you can easily [extend access](#connecting-from-outside-the-cluster) to developers, endpoints, and workloads running outside your cluster, too. 50 | 51 | ## Tutorial & Demo 52 | 53 | smallstep-cm-autocert-demo-keyframe 54 | 55 | In [this tutorial video](https://www.youtube.com/watch?v=NhHkfvSuKiM), Smallstep Software Engineer Andrew Reed shows how to use autocert alongside Smallstep [Certificate Manager](https://smallstep.com/certificate-manager/) hosted CA. 56 | 57 | ## Installation 58 | 59 | ### Prerequisites 60 | 61 | All you need to get started is [`kubectl`](https://kubernetes.io/docs/tasks/tools/install-kubectl/#install-kubectl) and a cluster running kubernetes with [admission webhooks](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#admission-webhooks) enabled: 62 | 63 | ```bash 64 | $ kubectl version 65 | Client Version: v1.26.1 66 | Kustomize Version: v4.5.7 67 | Server Version: v1.25.3 68 | $ kubectl api-versions | grep "admissionregistration.k8s.io/v1" 69 | admissionregistration.k8s.io/v1 70 | ``` 71 | 72 | ### Install via `kubectl` 73 | 74 | To install `autocert` run: 75 | 76 | ```bash 77 | kubectl run autocert-init -it --rm --image cr.smallstep.com/smallstep/autocert-init --restart Never 78 | ``` 79 | 80 | 💥 installation complete. 81 | 82 | > You might want to [check out what this command does](init/autocert.sh) before running it. You can also [install `autocert` manually](INSTALL.md#manual-install) if that's your style. 83 | 84 | #### Install via Helm 85 | 86 | Autocert can also be installed using the [Helm](https://helm.sh) package 87 | manager, to install the repository and `autocert` run: 88 | 89 | ```bash 90 | helm repo add smallstep https://smallstep.github.io/helm-charts/ 91 | helm repo update 92 | helm install smallstep/autocert 93 | ``` 94 | 95 | You can see all the configuration options at https://hub.helm.sh/charts/smallstep/autocert. 96 | 97 | ## Usage 98 | 99 | Using `autocert` is also easy: 100 | 101 | * Enable `autocert` for a namespace by labelling it with `autocert.step.sm=enabled`, then 102 | * Inject certificates into containers by annotating pods with `autocert.step.sm/name: ` 103 | 104 | ### Enable autocert (per namespace) 105 | 106 | To enable `autocert` for a namespace it must be labelled `autocert.step.sm=enabled`. 107 | 108 | To label the `default` namespace run: 109 | 110 | ```bash 111 | kubectl label namespace default autocert.step.sm=enabled 112 | ``` 113 | 114 | To check which namespaces have `autocert` enabled run: 115 | 116 | ```bash 117 | $ kubectl get namespace -L autocert.step.sm 118 | NAME STATUS AGE AUTOCERT.STEP.SM 119 | default Active 59m enabled 120 | ... 121 | ``` 122 | 123 | ### Annotate pods to get certificates 124 | 125 | To get a certificate you need to tell `autocert` your workload's name using the 126 | `autocert.step.sm/name` annotation (this name will appear as the X.509 common 127 | name and SAN). 128 | 129 | It's also possible to define the duration of the certificate using the 130 | annotation `autocert.step.sm/duration`, a duration is a sequence of decimal 131 | numbers, each with optional fraction and a unit suffix, such as "300ms", "1.5h" 132 | or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". Take 133 | into account that the container will crash if the duration is not between the 134 | limits defined by the used provisioner, the defaults are 5m and 24h. 135 | 136 | By default the certificate, key and root will be owned by root and world-readable (0644). 137 | Use the `autocert.step.sm/owner` and `autocert.step.sm/mode` annotations to set the owner and permissions of the files. 138 | The owner annotation requires user and group IDs rather than names because the images used by the containers that create and renew the certificates do not have the same user list as the main application containers. 139 | 140 | 141 | Let's deploy a [simple mTLS server](examples/hello-mtls/go/server/server.go) 142 | named `hello-mtls.default.svc.cluster.local`: 143 | 144 | ```yaml 145 | cat < Note that **the authority portion of the URL** (the `HELLO_MTLS_URL` env var) **matches the name of the server we're connecting to** (both are `hello-mtls.default.svc.cluster.local`). That's required for standard HTTPS and can sometimes require some DNS trickery. 266 | 267 | Once deployed we should start seeing the client log responses from the server [saying hello](examples/hello-mtls/go/server/server.go#L71-L72): 268 | 269 | ``` 270 | $ export HELLO_MTLS_CLIENT=$(kubectl get pods -l app=hello-mtls-client -o jsonpath='{$.items[0].metadata.name}') 271 | $ kubectl logs $HELLO_MTLS_CLIENT -c hello-mtls-client 272 | Thu Feb 7 23:35:23 UTC 2019: Hello, hello-mtls-client.default.pod.cluster.local! 273 | Thu Feb 7 23:35:28 UTC 2019: Hello, hello-mtls-client.default.pod.cluster.local! 274 | ``` 275 | 276 | For kicks, let's `exec` into this pod and try `curl`ing ourselves: 277 | 278 | ``` 279 | $ kubectl exec $HELLO_MTLS_CLIENT -c hello-mtls-client -- curl -sS \ 280 | --cacert /var/run/autocert.step.sm/root.crt \ 281 | --cert /var/run/autocert.step.sm/site.crt \ 282 | --key /var/run/autocert.step.sm/site.key \ 283 | https://hello-mtls.default.svc.cluster.local 284 | Hello, hello-mtls-client.default.pod.cluster.local! 285 | ``` 286 | 287 | ✅ mTLS inside cluster. 288 | 289 | ### Connecting from outside the cluster 290 | 291 | Connecting from outside the cluster is a bit more complicated. We need to handle DNS and obtain a certificate ourselves. These tasks were handled automatically inside the cluster by kubernetes and `autocert`, respectively. 292 | 293 | That said, because our server uses mTLS **only clients that have a certificate issued by our certificate authority will be allowed to connect**. That means it can be safely and easily exposed directly to the public internet using a [LoadBalancer service type](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer): 294 | 295 | ``` 296 | kubectl expose deployment hello-mtls --name=hello-mtls-lb --port=443 --type=LoadBalancer 297 | ``` 298 | 299 | To connect we need a certificate. There are a [couple](RUNBOOK.md#federation) [different](RUNBOOK.md#multiple-intermediates) [ways](RUNBOOK.md#exposing-the-ca) to get one, but for simplicity we'll just forward a port. 300 | 301 | ``` 302 | kubectl -n step port-forward $(kubectl -n step get pods -l app=ca -o jsonpath={$.items[0].metadata.name}) 4443:4443 303 | ``` 304 | 305 | In another window we'll use `step` to grab the root certificate, generate a key pair, and get a certificate. 306 | 307 | > To follow along you'll need to [`install step`](https://github.com/smallstep/cli#installing) if you haven't already. You'll also need your admin password and CA fingerprint, which were output during installation (see [here](RUNBOOK.md#recover-admin-and-ca-password) and [here](RUNBOOK.md#recompute-root-certificate-fingerprint) if you already lost them :). 308 | 309 | ```bash 310 | $ export CA_POD=$(kubectl -n step get pods -l app=ca -o jsonpath='{$.items[0].metadata.name}') 311 | $ step ca root root.crt --ca-url https://127.0.0.1:4443 --fingerprint 312 | $ step ca certificate mike mike.crt mike.key --ca-url https://127.0.0.1:4443 --root root.crt 313 | ✔ Key ID: H4vH5VfvaMro0yrk-UIkkeCoPFqEfjF6vg0GHFdhVyM (admin) 314 | ✔ Please enter the password to decrypt the provisioner key: 0QOC9xcq56R1aEyLHPzBqN18Z3WfGZ01 315 | ✔ CA: https://127.0.0.1:4443/1.0/sign 316 | ✔ Certificate: mike.crt 317 | ✔ Private Key: mike.key 318 | ``` 319 | 320 | Now we can simply `curl` the service: 321 | 322 | > If you're using minikube or docker for mac the load balancer's "IP" might be `localhost`, which won't work. In that case, simply `export HELLO_MTLS_IP=127.0.0.1` and try again. 323 | 324 | ``` 325 | $ export HELLO_MTLS_IP=$(kubectl get svc hello-mtls-lb -ojsonpath={$.status.loadBalancer.ingress[0].ip}) 326 | $ curl --resolve hello-mtls.default.svc.cluster.local:443:$HELLO_MTLS_IP \ 327 | --cacert root.crt \ 328 | --cert mike.crt \ 329 | --key mike.key \ 330 | https://hello-mtls.default.svc.cluster.local 331 | Hello, mike! 332 | ``` 333 | 334 | > Note that we're using `--resolve` to tell `curl` to override DNS and resolve the name in our workload's certificate to its public IP address. In a real production infrastructure you could configure DNS manually, or you could propagate DNS to workloads outside kubernetes using something like [ExternalDNS](https://github.com/kubernetes-incubator/external-dns). 335 | 336 | ✅ mTLS outside cluster. 337 | 338 | ### Cleanup & uninstall 339 | 340 | To clean up after running through the tutorial remove the `hello-mtls` and `hello-mtls-client` deployments and services: 341 | 342 | ``` 343 | kubectl delete deployment hello-mtls 344 | kubectl delete deployment hello-mtls-client 345 | kubectl delete service hello-mtls 346 | kubectl delete service hello-mtls-lb 347 | ``` 348 | 349 | See the runbook for instructions on [uninstalling `autocert`](RUNBOOK.md#uninstalling). 350 | 351 | ## How it works 352 | 353 | ### Architecture 354 | 355 | `Autocert` is an [admission webhook](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#admission-webhooks) that intercepts and patches pod creation requests with [some YAML](install/02-autocert.yaml#L26-L44) to inject an [init container](bootstrapper/) and [sidecar](renewer/) that handle obtaining and renewing certificates, respectively. 356 | 357 | ![Autocert architecture diagram](https://raw.githubusercontent.com/smallstep/autocert/master/autocert-arch.png) 358 | 359 | ### Enrollment & renewal 360 | 361 | It integrates with [`step certificates`](https://github.com/smallstep/certificates) and uses the [one-time token bootstrap protocol](https://smallstep.com/blog/step-certificates.html#automated-certificate-management) from that project to mutually authenticate a new pod with your certificate authority, and obtain a certificate. 362 | 363 | ![Autocert bootstrap protocol diagram](https://raw.githubusercontent.com/smallstep/autocert/master/autocert-bootstrap.png) 364 | 365 | Tokens are [generated by the admission webhook](controller/provisioner.go#L46-L72) and [transmitted to the injected init container via a kubernetes secret](controller/main.go#L91-L125). The init container [uses the one-time token](bootstrapper/bootstrapper.sh) to obtain a certificate. A sidecar is also installed to [renew certificates](renewer/Dockerfile#L8) before they expire. Renewal simply uses mTLS with the CA. 366 | 367 | ## FAQs 368 | 369 | ### Wait, so any pod can get a certificate with any identity? How is that secure? 370 | 371 | 1. Don't give people `kubectl` access to your production clusters 372 | 2. Use a deploy pipeline based on `git` artifacts 373 | 3. Enforce code review on those `git` artifacts 374 | 375 | If that doesn't work for you, or if you have a better idea, we'd love to hear! Please [open an issue](https://github.com/smallstep/autocert/issues/new?template=autocert_enhancement.md)! 376 | 377 | ### Why do I have to tell you the name to put in a certificate? Why can't you automatically bind service names? 378 | 379 | Mostly because monitoring the API server to figure out which services are associated with which workloads is complicated and somewhat magical. And it might not be what you want. 380 | 381 | That said, we're not totally opposed to this idea. If anyone has strong feels and a good design please [open an issue](https://github.com/smallstep/autocert/issues/new?template=autocert_enhancement.md). 382 | 383 | ### Doesn't Kubernetes already ship with a CA? 384 | 385 | Kubernetes needs [several certificates](https://jvns.ca/blog/2017/08/05/how-kubernetes-certificates-work/) for different sorts of control plane communication. 386 | It ships with a very limited CA 387 | and integration points that allow you to use an alternative CA. 388 | 389 | The built-in Kuberenetes CA is limited to signing certificates for kubeconfigs and kubelets. 390 | Specifically, the [controller-manager will sign CSRs in some cases](https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/#signer-control-plane). 391 | 392 | See our blog [Automating TLS in Kubernetes The Hard Way](https://smallstep.com/blog/kubernetes-the-secure-way/) to learn a lot more. 393 | 394 | While you _could_ use the Kubernetes CA for service-to-service data plane and ingress certificates, 395 | we don't recommend it. 396 | Having two CAs will give you a crisp cryptographic boundary. 397 | 398 | ### What permissions does `autocert` require in my cluster and why? 399 | 400 | `Autocert` needs permission to create and delete secrets cluster-wide. You can [check out our RBAC config here](install/03-rbac.yaml). These permissions are needed in order to transmit one-time tokens to workloads using secrets, and to clean up afterwards. We'd love to scope these permissions down further. If anyone has any ideas please [open an issue](https://github.com/smallstep/autocert/issues/new?template=autocert_enhancement.md). 401 | 402 | #### Why does `autocert` create secrets? 403 | 404 | The `autocert` admission webhook needs to securely transmit one-time bootstrap tokens to containers. This could be accomplished without using secrets. The webhook returns a [JSONPatch](https://tools.ietf.org/html/rfc6902) response that's applied to the pod spec. This response could patch the literal token value into our init container's environment. 405 | 406 | Unfortunately, the kubernetes API server does not authenticate itself to admission webhooks by default, and configuring it to do so [requires passing a custom config file](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#authenticate-apiservers) at apiserver startup. This isn't an option for everyone (e.g., on GKE) so we opted not to rely on it. 407 | 408 | Since our webhook can't authenticate callers, including bootstrap tokens in patch responses would be dangerous. By using secrets an attacker can still trick `autocert` into generating superflous bootstrap tokens, but they'd also need read access to cluster secrets to do anything with them. 409 | 410 | Hopefully this story will improve with time. 411 | 412 | ### Why not use kubernetes service accounts instead of bootstrap tokens? 413 | 414 | Great idea! This should be pretty easy to add using the [TokenRequest API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-request-v1/). 415 | 416 | ### Can I lengthen the duration of the bootstrap tokens? 417 | 418 | If you're facing deployment times longer than five minutes, use the annotation `autocert.step.sm/init-first: "true"`, which will force the bootstrapper to run before any other initContainer. As long as the CA is available, you will get a certificate valid for 24h that should be enough for initializing the rest of the deployment. After the bootstrapper, it will run the rest of the initContainers that can wait for the dependencies to be ready. See [smallstep/autocert#108](https://github.com/smallstep/autocert/issues/108) for more details. 419 | 420 | ### Too. many. containers. Why do you need to install an init container _and_ a sidecar? 421 | 422 | We don't. It's just easier for you. Your containers can generate key pairs, exchange them for certificates, and manage renewals themselves. This is pretty easy if you [install `step`](https://github.com/smallstep/cli#installing) in your containers, or integrate with our [golang SDK](https://godoc.org/github.com/smallstep/certificates/ca). To support this we'd need to add the option to inject a bootstrap token without injecting these containers. 423 | 424 | That said, the init container and sidecar are both super lightweight. 425 | 426 | ### Why are keys and certificates managed via volume mounts? Why not use a Secret or some custom resource? 427 | 428 | Because, by default, kubernetes Secrets are stored in plaintext in `etcd` and might even be transmitted unencrypted across the network. Even if Secrets were properly encrypted, transmitting a private key across the network violates PKI best practices. Key pairs should always be generated where they're used, and private keys should never be known by anyone but their owners. 429 | 430 | That said, there are use cases where a certificate mounted in a Secret resource is desirable (e.g., for use with a kubernetes `Ingress`). For that, we recommend [`step-issuer`](https://github.com/smallstep/step-issuer). 431 | 432 | (Add a 👍 to smallstep/autocert#48 I'd like `autocert` to expose Secrets in the future.) 433 | 434 | ### How is this different than [`cert-manager`](https://github.com/jetstack/cert-manager) 435 | 436 | `Cert-manager` is a great project, but it's design is focused on managing Web PKI certificates issued by [Let's Encrypt's](https://letsencrypt.org/) public certificate authority. These certificates are useful for TLS ingress from web browsers. `Autocert` is purpose-built to manage certificates issued by your own private CA to support the use of mTLS for service-to-service communication. 437 | 438 | ### What sorts of keys are issued and how often are certificates rotated? 439 | 440 | `Autocert` builds on `step certificates` which issues ECDSA certificates using the P256 curve with ECDSA-SHA256 signatures by default. If this is all Greek to you, rest assured these are safe, sane, and modern defaults that are suitable for the vast majority of environments. 441 | 442 | ### What crypto library is under the hood? 443 | 444 | https://golang.org/pkg/crypto/ 445 | 446 | ## Building 447 | 448 | This project is based on four container images: 449 | - `autocert-controller` (the admission webhook) 450 | - `autocert-bootstrapper` (the init container that generates a key pair and exchanges a bootstrap token for a certificate) 451 | - `autocert-renewer` (the sidecar that renews certificates) 452 | - `autocert-init` (the install script) 453 | 454 | They use [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) so all you need in order to build them is `docker`. 455 | 456 | To build all of the images, run: 457 | 458 | ``` 459 | docker build -t smallstep/autocert-controller:latest -f controller/Dockerfile . 460 | docker build -t smallstep/autocert-bootstrapper:latest -f bootstrapper/Dockerfile . 461 | docker build -t smallstep/autocert-renewer:latest -f renewer/Dockerfile . 462 | docker build -t smallstep/autocert-init:latest -f init/Dockerfile . 463 | ``` 464 | 465 | If you build your own containers you'll probably need to [install manually](INSTALL.md). You'll also need to adjust which images are deployed in the [deployment yaml](install/02-autocert.yaml). 466 | 467 | ## Contributing 468 | 469 | If you have improvements to `autocert`, send us your pull requests! For those just getting started, GitHub has a [howto](https://help.github.com/articles/about-pull-requests/). A team member will review your pull requests, provide feedback, and merge your changes. In order to accept contributions we do need you to [sign our contributor license agreement](https://cla-assistant.io/smallstep/autocert). 470 | 471 | If you want to contribute but you're not sure where to start, take a look at the [issues with the "good first issue" label](https://github.com/smallstep/autocert/issues?q=is%3Aopen+label%3A%22good+first+issue%22+label%3Aarea%2Fautocert). These are issues that we believe are particularly well suited for outside contributions, often because we probably won't get to them right now. If you decide to start on an issue, leave a comment so that other people know that you're working on it. If you want to help out, but not alone, use the issue comment thread to coordinate. 472 | 473 | If you've identified a bug or have ideas for improving `autocert` that you don't have time to implement, we'd love to hear about them. Please open an issue to [report a bug](https://github.com/smallstep/autocert/issues/new?template=autocert_bug.md) or [suggest an enhancement](https://github.com/smallstep/autocert/issues/new?template=autocert_enhancement.md)! 474 | 475 | ## Further Reading 476 | 477 | * We tweet [@smallsteplabs](https://twitter.com/smallsteplabs) 478 | * Read [our blog](https://smallstep.com/blog) 479 | * Check out the [runbook](RUNBOOK.md) 480 | * Check out [`step` CLI](https://github.com/smallstep/cli) 481 | 482 | ## License 483 | 484 | Copyright 2023 Smallstep Labs 485 | 486 | Licensed under [the Apache License, Version 2.0](https://github.com/smallstep/autocert/blob/master/LICENSE) 487 | -------------------------------------------------------------------------------- /RUNBOOK.md: -------------------------------------------------------------------------------- 1 | # Runbook 2 | 3 | ## Common admin tasks 4 | 5 | #### Recover `admin` and CA password 6 | 7 | ``` 8 | kubectl -n step get secret ca-password -o jsonpath='{$.data.password}' | base64 -D 9 | ``` 10 | 11 | #### Recover `autocert` password 12 | 13 | ``` 14 | kubectl -n step get secret autocert-password -o jsonpath='{$.data.password}' | base64 -D 15 | ``` 16 | 17 | #### Recompute root certificate fingerprint 18 | 19 | ``` 20 | export CA_POD=$(kubectl -n step get pods -l app=ca -o jsonpath={$.items[0].metadata.name}) 21 | kubectl -n step exec -it $CA_POD step certificate fingerprint /home/step/certs/root_ca.crt 22 | ``` 23 | 24 | > Tip: Some slight fanciness is necessary to trim this string if you want to put it into an environment variable: 25 | > 26 | > ``` 27 | > export FINGERPRINT="$(kubectl -n step exec -it $CA_POD step certificate fingerprint /home/step/certs/root_ca.crt | tr -d '[:space:]')" 28 | > ``` 29 | 30 | #### Inspect a certificate 31 | 32 | ``` 33 | kubectl exec -it -c autocert-renewer -- step certificate inspect /var/run/autocert.step.sm/site.crt 34 | ``` 35 | 36 | #### Labelling a namespace (enabling `autocert` for a namespace) 37 | 38 | To enable `autocert` for a namespace it must be labelled. To label an existing namespace run: 39 | 40 | ``` 41 | kubectl label namespace autocert.step.sm=enabled 42 | ``` 43 | 44 | #### Checking which namespaces are labelled 45 | 46 | ``` 47 | kubectl get namespace -L autocert.step.sm 48 | ``` 49 | 50 | #### Removing a label from a namespace (disabling `autocert` for a namespace) 51 | 52 | ``` 53 | kubectl label namespace autocert.step.sm- 54 | ``` 55 | 56 | #### Naming considerations 57 | 58 | Use hostnames. Must be global. Everyone who connects to the service using mTLS must use the same hostname. For internal communication it's easy enough to use the FQDN of a service. For stuff you expose publicly you'll need to manage DNS yourself... 59 | 60 | In any case, the critical invariant is: ... 61 | 62 | Diagram here? 63 | 64 | #### Cleaning up one-time token secrets 65 | 66 | ``` 67 | for ns in $(kubectl get namespace --selector autocert.step.sm=enabled -o jsonpath='{$.items[*].metadata.name}'); do 68 | kubectl -n "$ns" delete secrets --selector="autocert.step.sm/token=true" 69 | done 70 | ``` 71 | 72 | ### TODO: 73 | * Change admin password 74 | * Change autocert password 75 | * Federating with another CA 76 | * DNS tips and tricks 77 | * Multiple SANs 78 | * Getting rid of the sidecar 79 | * Getting logs from the CA (certificates weren't issued) 80 | * Getting logs from the init container / renewer (didn't start properly) 81 | * Adjusting certificate expiration (default 24h) 82 | * Remove label 83 | * Clean up secrets 84 | * Naming considerations (maybe this should be in hello-mtls) 85 | 86 | ## Federation 87 | 88 | TODO: Example of federating a CA running in kubernetes with another CA. 89 | 90 | For now, see https://smallstep.com/blog/step-v0.8.3-federation-root-rotation.html 91 | 92 | ## Multiple intermediates 93 | 94 | TODO: Example of creating an additional intermediate signing certificate off of our kubernetes root CA. 95 | 96 | For now, see https://smallstep.com/docs/cli/ca/init/ (specifically, the `--root` flag) 97 | 98 | ## Exposing the CA 99 | 100 | Beware that the CA exposes an unauthenticated endpoint that lists your configured provisioners and their encrypted private keys. For this reason, you may not want to expose it directly to the public internet. 101 | 102 | ## Uninstalling 103 | 104 | To uninstall `autocert` completely simply delete the mutating webhook configuration, the `step` namespace and the `autocert` RBAC artifacts: 105 | 106 | ``` 107 | kubectl delete mutatingwebhookconfiguration autocert-webhook-config 108 | kubectl delete namespace step 109 | kubectl delete clusterrolebinding autocert-controller 110 | kubectl delete clusterrole autocert-controller 111 | ``` 112 | 113 | Remove any namespace labels and clean up any stray secrets that `autocert` hasn't cleaned up yet: 114 | 115 | ``` 116 | for ns in $(kubectl get namespace --selector autocert.step.sm=enabled -o jsonpath='{$.items[*].metadata.name}'); do 117 | kubectl label namespace "$ns" autocert.step.sm- 118 | kubectl -n "$ns" delete secrets --selector="autocert.step.sm/token=true" 119 | done 120 | ``` 121 | 122 | Any remaining sidecar containers will go away once you remove annotations and re-deploy your workloads. 123 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | We appreciate any effort to discover and disclose security vulnerabilities responsibly. 2 | 3 | If you would like to report a vulnerability in one of our projects, or have security concerns regarding Smallstep software, please email security@smallstep.com. 4 | 5 | In order for us to best respond to your report, please include any of the following: 6 | * Steps to reproduce or proof-of-concept 7 | * Any relevant tools, including versions used 8 | * Tool output 9 | -------------------------------------------------------------------------------- /autocert-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/autocert-arch.png -------------------------------------------------------------------------------- /autocert-bootstrap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/autocert-bootstrap.png -------------------------------------------------------------------------------- /autocert-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/autocert-logo.png -------------------------------------------------------------------------------- /bootstrapper/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM smallstep/step-cli:0.26.0 2 | 3 | USER root 4 | ENV CRT="/var/run/autocert.step.sm/site.crt" 5 | ENV KEY="/var/run/autocert.step.sm/site.key" 6 | ENV STEP_ROOT="/var/run/autocert.step.sm/root.crt" 7 | 8 | COPY bootstrapper/bootstrapper.sh /home/step/ 9 | RUN chmod +x /home/step/bootstrapper.sh 10 | CMD ["/home/step/bootstrapper.sh"] 11 | -------------------------------------------------------------------------------- /bootstrapper/bootstrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | if [ -f "$STEP_ROOT" ] && [ -f "$CRT" ] && [ -f "$KEY" ]; 5 | then 6 | echo "Found existing $STEP_ROOT, $CRT, and $KEY, skipping bootstrap" 7 | exit 0 8 | fi 9 | 10 | # Download the root certificate and set permissions 11 | if [ "$DURATION" == "" ]; 12 | then 13 | step ca certificate $COMMON_NAME $CRT $KEY 14 | else 15 | step ca certificate --not-after $DURATION $COMMON_NAME $CRT $KEY 16 | fi 17 | 18 | step ca root $STEP_ROOT 19 | 20 | if [ -n "$OWNER" ] 21 | then 22 | chown "$OWNER" $CRT $KEY $STEP_ROOT 23 | fi 24 | 25 | if [ -n "$MODE" ] 26 | then 27 | chmod "$MODE" $CRT $KEY $STEP_ROOT 28 | else 29 | chmod 644 $CRT $KEY $STEP_ROOT 30 | fi 31 | 32 | -------------------------------------------------------------------------------- /connect-with-mtls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/connect-with-mtls.png -------------------------------------------------------------------------------- /controller/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build-env 3 | RUN apk update && apk upgrade && \ 4 | apk add --no-cache git 5 | 6 | WORKDIR $GOPATH/src/github.com/autocert/controller 7 | COPY go.mod go.sum ./ 8 | COPY controller/client.go controller/main.go ./ 9 | RUN go build -o /server . 10 | 11 | # final stage 12 | FROM smallstep/step-cli:0.26.0 13 | ENV STEPPATH="/home/step" 14 | ENV PWDPATH="/home/step/password/password" 15 | ENV CONFIGPATH="/home/step/autocert/config.yaml" 16 | COPY --from=build-env /server . 17 | ENTRYPOINT ./server $CONFIGPATH 18 | -------------------------------------------------------------------------------- /controller/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | //nolint:gosec // path to file 16 | serviceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token" 17 | serviceAccountCACert = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 18 | ) 19 | 20 | // Client is minimal kubernetes client interface 21 | type Client interface { 22 | Do(req *http.Request) (*http.Response, error) 23 | GetRequest(url string) (*http.Request, error) 24 | PostRequest(url, body, contentType string) (*http.Request, error) 25 | DeleteRequest(url string) (*http.Request, error) 26 | Host() string 27 | } 28 | 29 | type k8sClient struct { 30 | host string 31 | token string 32 | httpClient *http.Client 33 | } 34 | 35 | func (kc *k8sClient) GetRequest(url string) (*http.Request, error) { 36 | if !strings.HasPrefix(url, kc.host) { 37 | url = fmt.Sprintf("%s/%s", kc.host, url) 38 | } 39 | req, err := http.NewRequest("GET", url, http.NoBody) 40 | if err != nil { 41 | return nil, err 42 | } 43 | if kc.token != "" { 44 | req.Header.Set("Authorization", "Bearer "+kc.token) 45 | } 46 | return req, nil 47 | } 48 | 49 | func (kc *k8sClient) PostRequest(url, body, contentType string) (*http.Request, error) { 50 | if !strings.HasPrefix(url, kc.host) { 51 | url = fmt.Sprintf("%s/%s", kc.host, url) 52 | } 53 | req, err := http.NewRequest("POST", url, strings.NewReader(body)) 54 | if err != nil { 55 | return nil, err 56 | } 57 | if kc.token != "" { 58 | req.Header.Set("Authorization", "Bearer "+kc.token) 59 | } 60 | if contentType != "" { 61 | req.Header.Set("Content-Type", contentType) 62 | } 63 | return req, nil 64 | } 65 | 66 | func (kc *k8sClient) DeleteRequest(url string) (*http.Request, error) { 67 | if !strings.HasPrefix(url, kc.host) { 68 | url = fmt.Sprintf("%s/%s", kc.host, url) 69 | } 70 | req, err := http.NewRequest("DELETE", url, http.NoBody) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if kc.token != "" { 75 | req.Header.Set("Authorization", "Bearer "+kc.token) 76 | } 77 | return req, nil 78 | } 79 | 80 | func (kc *k8sClient) Do(req *http.Request) (*http.Response, error) { 81 | return kc.httpClient.Do(req) 82 | } 83 | 84 | func (kc *k8sClient) Host() string { 85 | return kc.host 86 | } 87 | 88 | // NewInClusterK8sClient creates K8sClient if it is inside Kubernetes 89 | func NewInClusterK8sClient() (Client, error) { 90 | host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") 91 | if host == "" || port == "" { 92 | return nil, fmt.Errorf("unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be defined") 93 | } 94 | token, err := os.ReadFile(serviceAccountToken) 95 | if err != nil { 96 | return nil, err 97 | } 98 | ca, err := os.ReadFile(serviceAccountCACert) 99 | if err != nil { 100 | return nil, err 101 | } 102 | certPool := x509.NewCertPool() 103 | certPool.AppendCertsFromPEM(ca) 104 | transport := &http.Transport{TLSClientConfig: &tls.Config{ 105 | MinVersion: tls.VersionTLS12, 106 | RootCAs: certPool, 107 | }} 108 | httpClient := &http.Client{Transport: transport, Timeout: time.Nanosecond * 0} 109 | 110 | return &k8sClient{ 111 | host: "https://" + net.JoinHostPort(host, port), 112 | token: string(token), 113 | httpClient: httpClient, 114 | }, nil 115 | } 116 | -------------------------------------------------------------------------------- /controller/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "os" 13 | "strings" 14 | "time" 15 | "unicode" 16 | 17 | "github.com/pkg/errors" 18 | log "github.com/sirupsen/logrus" 19 | "github.com/smallstep/certificates/ca" 20 | "github.com/smallstep/certificates/pki" 21 | "github.com/smallstep/cli-utils/errs" 22 | "github.com/smallstep/cli-utils/step" 23 | "go.step.sm/crypto/pemutil" 24 | "k8s.io/api/admission/v1beta1" 25 | corev1 "k8s.io/api/core/v1" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | "k8s.io/apimachinery/pkg/runtime/serializer" 29 | "k8s.io/utils/ptr" 30 | "sigs.k8s.io/yaml" 31 | ) 32 | 33 | var ( 34 | runtimeScheme = runtime.NewScheme() 35 | codecs = serializer.NewCodecFactory(runtimeScheme) 36 | deserializer = codecs.UniversalDeserializer() 37 | ) 38 | 39 | const ( 40 | admissionWebhookAnnotationKey = "autocert.step.sm/name" 41 | admissionWebhookStatusKey = "autocert.step.sm/status" 42 | durationWebhookStatusKey = "autocert.step.sm/duration" 43 | firstAnnotationKey = "autocert.step.sm/init-first" 44 | bootstrapperOnlyAnnotationKey = "autocert.step.sm/bootstrapper-only" 45 | sansAnnotationKey = "autocert.step.sm/sans" 46 | ownerAnnotationKey = "autocert.step.sm/owner" 47 | modeAnnotationKey = "autocert.step.sm/mode" 48 | volumeMountPath = "/var/run/autocert.step.sm" 49 | tokenSecretKey = "token" 50 | //nolint:gosec // not a secret 51 | tokenSecretLabel = "autocert.step.sm/token" 52 | tokenLifetime = 5 * time.Minute 53 | ) 54 | 55 | // Config options for the autocert admission controller. 56 | type Config struct { 57 | Address string `yaml:"address"` 58 | Service string `yaml:"service"` 59 | LogFormat string `yaml:"logFormat"` 60 | CaURL string `yaml:"caUrl"` 61 | CertLifetime string `yaml:"certLifetime"` 62 | Bootstrapper corev1.Container `yaml:"bootstrapper"` 63 | Renewer corev1.Container `yaml:"renewer"` 64 | CertsVolume corev1.Volume `yaml:"certsVolume"` 65 | RestrictCertificatesToNamespace bool `yaml:"restrictCertificatesToNamespace"` 66 | ClusterDomain string `yaml:"clusterDomain"` 67 | RootCAPath string `yaml:"rootCAPath"` 68 | ProvisionerPasswordPath string `yaml:"provisionerPasswordPath"` 69 | } 70 | 71 | // GetAddress returns the address set in the configuration, defaults to ":4443" 72 | // if it's not specified. 73 | func (c Config) GetAddress() string { 74 | if c.Address != "" { 75 | return c.Address 76 | } 77 | 78 | return ":4443" 79 | } 80 | 81 | // GetServiceName returns the service name set in the configuration, defaults to 82 | // "autocert" if it's not specified. 83 | func (c Config) GetServiceName() string { 84 | if c.Service != "" { 85 | return c.Service 86 | } 87 | 88 | return "autocert" 89 | } 90 | 91 | // GetClusterDomain returns the Kubernetes cluster domain, defaults to 92 | // "cluster.local" if not specified in the configuration. 93 | func (c Config) GetClusterDomain() string { 94 | if c.ClusterDomain != "" { 95 | return c.ClusterDomain 96 | } 97 | 98 | return "cluster.local" 99 | } 100 | 101 | // GetRootCAPath returns the root CA path in the configuration, defaults to 102 | // "STEPPATH/certs/root_ca.crt" if it's not specified. 103 | func (c Config) GetRootCAPath() string { 104 | if c.RootCAPath != "" { 105 | return c.RootCAPath 106 | } 107 | 108 | return pki.GetRootCAPath() 109 | } 110 | 111 | // GetProvisionerPasswordPath returns the path to the provisioner password, 112 | // defaults to "/home/step/password/password" if not specified in the 113 | // configuration. 114 | func (c Config) GetProvisionerPasswordPath() string { 115 | if c.ProvisionerPasswordPath != "" { 116 | return c.ProvisionerPasswordPath 117 | } 118 | 119 | return "/home/step/password/password" 120 | } 121 | 122 | // PatchOperation represents a RFC6902 JSONPatch Operation 123 | type PatchOperation struct { 124 | Op string `json:"op"` 125 | Path string `json:"path"` 126 | Value interface{} `json:"value,omitempty"` 127 | } 128 | 129 | // RFC6901 JSONPath Escaping -- https://tools.ietf.org/html/rfc6901 130 | func escapeJSONPath(path string) string { 131 | // Replace`~` with `~0` then `/` with `~1`. Note that the order 132 | // matters otherwise we'll turn a `/` into a `~/`. 133 | path = strings.ReplaceAll(path, "~", "~0") 134 | path = strings.ReplaceAll(path, "/", "~1") 135 | return path 136 | } 137 | 138 | func loadConfig(file string) (*Config, error) { 139 | data, err := os.ReadFile(file) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | var cfg Config 145 | if err := yaml.Unmarshal(data, &cfg); err != nil { 146 | return nil, err 147 | } 148 | 149 | return &cfg, nil 150 | } 151 | 152 | // createTokenSecret generates a kubernetes Secret object containing a bootstrap token 153 | // in the specified namespace. The secret name is randomly generated with a given prefix. 154 | // A goroutine is scheduled to cleanup the secret after the token expires. The secret 155 | // is also labeled for easy identification and manual cleanup. 156 | func createTokenSecret(prefix, namespace, token string) (string, error) { 157 | secret := corev1.Secret{ 158 | TypeMeta: metav1.TypeMeta{ 159 | Kind: "Secret", 160 | APIVersion: "v1", 161 | }, 162 | ObjectMeta: metav1.ObjectMeta{ 163 | GenerateName: prefix, 164 | Namespace: namespace, 165 | Labels: map[string]string{ 166 | tokenSecretLabel: "true", 167 | }, 168 | }, 169 | StringData: map[string]string{ 170 | tokenSecretKey: token, 171 | }, 172 | Type: corev1.SecretTypeOpaque, 173 | } 174 | 175 | client, err := NewInClusterK8sClient() 176 | if err != nil { 177 | return "", err 178 | } 179 | 180 | body, err := json.Marshal(secret) 181 | if err != nil { 182 | return "", err 183 | } 184 | log.WithField("secret", string(body)).Debug("Creating secret") 185 | 186 | req, err := client.PostRequest(fmt.Sprintf("api/v1/namespaces/%s/secrets", namespace), string(body), "application/json") 187 | if err != nil { 188 | return "", err 189 | } 190 | 191 | resp, err := client.Do(req) 192 | if err != nil { 193 | log.Errorf("Secret creation error. Response: %v", resp) 194 | return "", errors.Wrap(err, "secret creation") 195 | } 196 | defer resp.Body.Close() 197 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 198 | log.Errorf("Secret creation error (!2XX). Response: %v", resp) 199 | var rbody []byte 200 | if resp.Body != nil { 201 | if data, err := io.ReadAll(resp.Body); err == nil { 202 | rbody = data 203 | } 204 | } 205 | log.Error("Error body: ", string(rbody)) 206 | return "", errors.New("Not 200") 207 | } 208 | 209 | var rbody []byte 210 | if resp.Body != nil { 211 | if data, err := io.ReadAll(resp.Body); err == nil { 212 | rbody = data 213 | } 214 | } 215 | if len(rbody) == 0 { 216 | return "", errors.New("Empty response body") 217 | } 218 | 219 | var created *corev1.Secret 220 | if err := json.Unmarshal(rbody, &created); err != nil { 221 | return "", errors.Wrap(err, "Error unmarshalling secret response") 222 | } 223 | 224 | // Clean up after ourselves by deleting the Secret after the bootstrap 225 | // token expires. This is best effort -- obviously we'll miss some stuff 226 | // if this process goes away -- but the secrets are also labeled so 227 | // it's also easy to clean them up in bulk using kubectl if we miss any. 228 | go func() { 229 | time.Sleep(tokenLifetime) 230 | req, err := client.DeleteRequest(fmt.Sprintf("api/v1/namespaces/%s/secrets/%s", namespace, created.Name)) 231 | ctxLog := log.WithFields(log.Fields{ 232 | "name": created.Name, 233 | "namespace": namespace, 234 | }) 235 | if err != nil { 236 | ctxLog.WithField("error", err).Error("Error deleting expired bootstrap token secret") 237 | return 238 | } 239 | resp, err := client.Do(req) 240 | if err != nil { 241 | ctxLog.WithField("error", err).Error("Error deleting expired bootstrap token secret") 242 | return 243 | } 244 | defer resp.Body.Close() 245 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 246 | ctxLog.WithFields(log.Fields{ 247 | "status": resp.Status, 248 | "statusCode": resp.StatusCode, 249 | }).Error("Error deleting expired bootstrap token secret") 250 | return 251 | } 252 | ctxLog.Info("Deleted expired bootstrap token secret") 253 | }() 254 | 255 | return created.Name, err 256 | } 257 | 258 | // mkBootstrapper generates a bootstrap container based on the template defined in Config. It 259 | // generates a new bootstrap token and mounts it, along with other required configuration, as 260 | // environment variables in the returned bootstrap container. 261 | func mkBootstrapper(config *Config, podName, commonName, duration, owner, mode, namespace string, sans []string, provisioner *ca.Provisioner) (corev1.Container, error) { 262 | b := config.Bootstrapper 263 | 264 | token, err := provisioner.Token(commonName, sans...) 265 | if err != nil { 266 | return b, errors.Wrap(err, "token generation") 267 | } 268 | 269 | // Generate CA fingerprint 270 | crt, err := pemutil.ReadCertificate(config.GetRootCAPath()) 271 | if err != nil { 272 | return b, errors.Wrap(err, "CA fingerprint") 273 | } 274 | sum := sha256.Sum256(crt.Raw) 275 | fingerprint := strings.ToLower(hex.EncodeToString(sum[:])) 276 | 277 | secretName, err := createTokenSecret(commonName+"-", namespace, token) 278 | if err != nil { 279 | return b, errors.Wrap(err, "create token secret") 280 | } 281 | log.Infof("Secret name is: %s", secretName) 282 | 283 | b.Env = append(b.Env, 284 | corev1.EnvVar{ 285 | Name: "COMMON_NAME", 286 | Value: commonName, 287 | }, 288 | corev1.EnvVar{ 289 | Name: "DURATION", 290 | Value: duration, 291 | }, 292 | corev1.EnvVar{ 293 | Name: "OWNER", 294 | Value: owner, 295 | }, 296 | corev1.EnvVar{ 297 | Name: "MODE", 298 | Value: mode, 299 | }, 300 | corev1.EnvVar{ 301 | Name: "STEP_TOKEN", 302 | ValueFrom: &corev1.EnvVarSource{ 303 | SecretKeyRef: &corev1.SecretKeySelector{ 304 | LocalObjectReference: corev1.LocalObjectReference{ 305 | Name: secretName, 306 | }, 307 | Key: tokenSecretKey, 308 | Optional: ptr.To[bool](true), 309 | }, 310 | }, 311 | }, 312 | corev1.EnvVar{ 313 | Name: "STEP_CA_URL", 314 | Value: config.CaURL, 315 | }, 316 | corev1.EnvVar{ 317 | Name: "STEP_FINGERPRINT", 318 | Value: fingerprint, 319 | }, 320 | corev1.EnvVar{ 321 | Name: "STEP_NOT_AFTER", 322 | Value: config.CertLifetime, 323 | }, 324 | corev1.EnvVar{ 325 | Name: "POD_NAME", 326 | Value: podName, 327 | }, 328 | corev1.EnvVar{ 329 | Name: "NAMESPACE", 330 | Value: namespace, 331 | }, 332 | corev1.EnvVar{ 333 | Name: "CLUSTER_DOMAIN", 334 | Value: config.ClusterDomain, 335 | }) 336 | 337 | return b, nil 338 | } 339 | 340 | // mkRenewer generates a new renewer based on the template provided in Config. 341 | func mkRenewer(config *Config, podName, commonName, namespace string) corev1.Container { 342 | r := config.Renewer 343 | r.Env = append(r.Env, 344 | corev1.EnvVar{ 345 | Name: "STEP_CA_URL", 346 | Value: config.CaURL, 347 | }, 348 | corev1.EnvVar{ 349 | Name: "COMMON_NAME", 350 | Value: commonName, 351 | }, 352 | corev1.EnvVar{ 353 | Name: "POD_NAME", 354 | Value: podName, 355 | }, 356 | corev1.EnvVar{ 357 | Name: "NAMESPACE", 358 | Value: namespace, 359 | }, 360 | corev1.EnvVar{ 361 | Name: "CLUSTER_DOMAIN", 362 | Value: config.ClusterDomain, 363 | }) 364 | return r 365 | } 366 | 367 | func removeInitContainers() (ops PatchOperation) { 368 | return PatchOperation{ 369 | Op: "remove", 370 | Path: "/spec/initContainers", 371 | } 372 | } 373 | 374 | func addContainers(existing, nu []corev1.Container, path string) (ops []PatchOperation) { 375 | if len(existing) == 0 { 376 | return []PatchOperation{ 377 | { 378 | Op: "add", 379 | Path: path, 380 | Value: nu, 381 | }, 382 | } 383 | } 384 | 385 | for _, add := range nu { 386 | ops = append(ops, PatchOperation{ 387 | Op: "add", 388 | Path: path + "/-", 389 | Value: add, 390 | }) 391 | } 392 | 393 | return ops 394 | } 395 | 396 | func addVolumes(existing, nu []corev1.Volume, path string) (ops []PatchOperation) { 397 | if len(existing) == 0 { 398 | return []PatchOperation{ 399 | { 400 | Op: "add", 401 | Path: path, 402 | Value: nu, 403 | }, 404 | } 405 | } 406 | 407 | for _, add := range nu { 408 | ops = append(ops, PatchOperation{ 409 | Op: "add", 410 | Path: path + "/-", 411 | Value: add, 412 | }) 413 | } 414 | return ops 415 | } 416 | 417 | func addCertsVolumeMount(volumeName string, containers []corev1.Container, containerType string, first bool) (ops []PatchOperation) { 418 | volumeMount := corev1.VolumeMount{ 419 | Name: volumeName, 420 | MountPath: volumeMountPath, 421 | ReadOnly: true, 422 | } 423 | 424 | add := 0 425 | if first { 426 | add = 1 427 | } 428 | 429 | for i, container := range containers { 430 | if len(container.VolumeMounts) == 0 { 431 | ops = append(ops, PatchOperation{ 432 | Op: "add", 433 | Path: fmt.Sprintf("/spec/%s/%v/volumeMounts", containerType, i+add), 434 | Value: []corev1.VolumeMount{volumeMount}, 435 | }) 436 | } else { 437 | ops = append(ops, PatchOperation{ 438 | Op: "add", 439 | Path: fmt.Sprintf("/spec/%s/%v/volumeMounts/-", containerType, i+add), 440 | Value: volumeMount, 441 | }) 442 | } 443 | } 444 | return ops 445 | } 446 | 447 | func addAnnotations(existing, nu map[string]string) (ops []PatchOperation) { 448 | if len(existing) == 0 { 449 | return []PatchOperation{ 450 | { 451 | Op: "add", 452 | Path: "/metadata/annotations", 453 | Value: nu, 454 | }, 455 | } 456 | } 457 | for k, v := range nu { 458 | if existing[k] == "" { 459 | ops = append(ops, PatchOperation{ 460 | Op: "add", 461 | Path: "/metadata/annotations/" + escapeJSONPath(k), 462 | Value: v, 463 | }) 464 | } else { 465 | ops = append(ops, PatchOperation{ 466 | Op: "replace", 467 | Path: "/metadata/annotations/" + escapeJSONPath(k), 468 | Value: v, 469 | }) 470 | } 471 | } 472 | return ops 473 | } 474 | 475 | // patch produces a list of patches to apply to a pod to inject a certificate. In particular, 476 | // we patch the pod in order to: 477 | // - Mount the `certs` volume in existing containers and initContainers defined in the pod 478 | // - Add the autocert-renewer as a container (a sidecar) 479 | // - Add the autocert-bootstrapper as an initContainer 480 | // - Add the `certs` volume definition 481 | // - Annotate the pod to indicate that it's been processed by this controller 482 | // The result is a list of serialized JSONPatch objects (or an error). 483 | func patch(pod *corev1.Pod, namespace string, config *Config, provisioner *ca.Provisioner) ([]byte, error) { 484 | var ops []PatchOperation 485 | 486 | name := pod.ObjectMeta.GetName() 487 | if name == "" { 488 | name = pod.ObjectMeta.GetGenerateName() 489 | } 490 | 491 | annotations := pod.ObjectMeta.GetAnnotations() 492 | commonName := annotations[admissionWebhookAnnotationKey] 493 | first := strings.EqualFold(annotations[firstAnnotationKey], "true") 494 | sans := strings.Split(annotations[sansAnnotationKey], ",") 495 | if annotations[sansAnnotationKey] == "" { 496 | sans = []string{commonName} 497 | } 498 | bootstrapperOnly := strings.EqualFold(annotations[bootstrapperOnlyAnnotationKey], "true") 499 | duration := annotations[durationWebhookStatusKey] 500 | owner := annotations[ownerAnnotationKey] 501 | mode := annotations[modeAnnotationKey] 502 | renewer := mkRenewer(config, name, commonName, namespace) 503 | bootstrapper, err := mkBootstrapper(config, name, commonName, duration, owner, mode, namespace, sans, provisioner) 504 | if err != nil { 505 | return nil, err 506 | } 507 | 508 | if first { 509 | if len(pod.Spec.InitContainers) > 0 { 510 | ops = append(ops, removeInitContainers()) 511 | } 512 | 513 | initContainers := append([]corev1.Container{bootstrapper}, pod.Spec.InitContainers...) 514 | ops = append(ops, addContainers([]corev1.Container{}, initContainers, "/spec/initContainers")...) 515 | } else { 516 | ops = append(ops, addContainers(pod.Spec.InitContainers, []corev1.Container{bootstrapper}, "/spec/initContainers")...) 517 | } 518 | 519 | ops = append(ops, addCertsVolumeMount(config.CertsVolume.Name, pod.Spec.Containers, "containers", false)...) 520 | ops = append(ops, addCertsVolumeMount(config.CertsVolume.Name, pod.Spec.InitContainers, "initContainers", first)...) 521 | if !bootstrapperOnly { 522 | ops = append(ops, addContainers(pod.Spec.Containers, []corev1.Container{renewer}, "/spec/containers")...) 523 | } 524 | ops = append(ops, addVolumes(pod.Spec.Volumes, []corev1.Volume{config.CertsVolume}, "/spec/volumes")...) 525 | ops = append(ops, addAnnotations(pod.Annotations, map[string]string{admissionWebhookStatusKey: "injected"})...) 526 | 527 | return json.Marshal(ops) 528 | } 529 | 530 | // shouldMutate checks whether a pod is subject to mutation by this admission controller. A pod 531 | // is subject to mutation if it's annotated with the `admissionWebhookAnnotationKey` and if it 532 | // has not already been processed (indicated by `admissionWebhookStatusKey` set to `injected`). 533 | // If the pod requests a certificate with a subject matching a namespace other than its own 534 | // and restrictToNamespace is true, then shouldMutate will return a validation error 535 | // that should be returned to the client. 536 | func shouldMutate(metadata *metav1.ObjectMeta, namespace, clusterDomain string, restrictToNamespace bool) (bool, error) { 537 | annotations := metadata.GetAnnotations() 538 | if annotations == nil { 539 | annotations = map[string]string{} 540 | } 541 | 542 | // Only mutate if the object is annotated appropriately (annotation key set) and we haven't 543 | // mutated already (status key isn't set). 544 | if annotations[admissionWebhookAnnotationKey] == "" || annotations[admissionWebhookStatusKey] == "injected" { 545 | return false, nil 546 | } 547 | 548 | if !restrictToNamespace { 549 | return true, nil 550 | } 551 | 552 | subject := strings.Trim(annotations[admissionWebhookAnnotationKey], ".") 553 | 554 | err := fmt.Errorf("subject \"%s\" matches a namespace other than \"%s\" and is not permitted. This check can be disabled by setting restrictCertificatesToNamespace to false in the autocert-config ConfigMap", subject, namespace) 555 | 556 | if strings.HasSuffix(subject, ".svc") && !strings.HasSuffix(subject, fmt.Sprintf(".%s.svc", namespace)) { 557 | return false, err 558 | } 559 | 560 | if strings.HasSuffix(subject, fmt.Sprintf(".svc.%s", clusterDomain)) && !strings.HasSuffix(subject, fmt.Sprintf(".%s.svc.%s", namespace, clusterDomain)) { 561 | return false, err 562 | } 563 | 564 | return true, nil 565 | } 566 | 567 | // mutate takes an `AdmissionReview`, determines whether it is subject to mutation, and returns 568 | // an appropriate `AdmissionResponse` including patches or any errors that occurred. 569 | func mutate(review *v1beta1.AdmissionReview, config *Config, provisioner *ca.Provisioner) *v1beta1.AdmissionResponse { 570 | ctxLog := log.WithField("uid", review.Request.UID) 571 | 572 | request := review.Request 573 | var pod corev1.Pod 574 | if err := json.Unmarshal(request.Object.Raw, &pod); err != nil { 575 | ctxLog.WithField("error", err).Error("Error unmarshaling pod") 576 | return &v1beta1.AdmissionResponse{ 577 | Allowed: false, 578 | UID: request.UID, 579 | Result: &metav1.Status{ 580 | Message: err.Error(), 581 | }, 582 | } 583 | } 584 | 585 | ctxLog = ctxLog.WithFields(log.Fields{ 586 | "kind": request.Kind, 587 | "operation": request.Operation, 588 | "name": pod.Name, 589 | "generateName": pod.GenerateName, 590 | "namespace": request.Namespace, 591 | "user": request.UserInfo, 592 | }) 593 | 594 | mutationAllowed, validationErr := shouldMutate(&pod.ObjectMeta, request.Namespace, config.GetClusterDomain(), config.RestrictCertificatesToNamespace) 595 | 596 | if validationErr != nil { 597 | ctxLog.WithField("error", validationErr).Info("Validation error") 598 | return &v1beta1.AdmissionResponse{ 599 | Allowed: false, 600 | UID: request.UID, 601 | Result: &metav1.Status{ 602 | Message: validationErr.Error(), 603 | }, 604 | } 605 | } 606 | 607 | if !mutationAllowed { 608 | ctxLog.WithField("annotations", pod.Annotations).Info("Skipping mutation") 609 | return &v1beta1.AdmissionResponse{ 610 | Allowed: true, 611 | UID: request.UID, 612 | } 613 | } 614 | 615 | patchBytes, err := patch(&pod, request.Namespace, config, provisioner) 616 | if err != nil { 617 | ctxLog.WithField("error", err).Error("Error generating patch") 618 | return &v1beta1.AdmissionResponse{ 619 | Allowed: false, 620 | UID: request.UID, 621 | Result: &metav1.Status{ 622 | Message: err.Error(), 623 | }, 624 | } 625 | } 626 | 627 | ctxLog.WithField("patch", string(patchBytes)).Info("Generated patch") 628 | return &v1beta1.AdmissionResponse{ 629 | Allowed: true, 630 | Patch: patchBytes, 631 | UID: request.UID, 632 | PatchType: func() *v1beta1.PatchType { 633 | pt := v1beta1.PatchTypeJSONPatch 634 | return &pt 635 | }(), 636 | } 637 | } 638 | 639 | func main() { 640 | if len(os.Args) != 2 { 641 | log.Errorf("Usage: %s \n", os.Args[0]) 642 | os.Exit(1) 643 | } 644 | 645 | config, err := loadConfig(os.Args[1]) 646 | if err != nil { 647 | panic(err) 648 | } 649 | 650 | // Initialize step environment 651 | if err := step.Init(); err != nil { 652 | panic(err) 653 | } 654 | 655 | log.SetOutput(os.Stdout) 656 | if config.LogFormat == "json" { 657 | log.SetFormatter(&log.JSONFormatter{}) 658 | } 659 | if config.LogFormat == "text" { 660 | log.SetFormatter(&log.TextFormatter{}) 661 | } 662 | 663 | log.WithFields(log.Fields{ 664 | "config": config, 665 | }).Info("Loaded config") 666 | 667 | provisionerName := os.Getenv("PROVISIONER_NAME") 668 | provisionerKid := os.Getenv("PROVISIONER_KID") 669 | log.WithFields(log.Fields{ 670 | "provisionerName": provisionerName, 671 | "provisionerKid": provisionerKid, 672 | }).Info("Loaded provisioner configuration") 673 | 674 | password, err := readPasswordFromFile(config.GetProvisionerPasswordPath()) 675 | if err != nil { 676 | panic(err) 677 | } 678 | 679 | provisioner, err := ca.NewProvisioner( 680 | provisionerName, provisionerKid, config.CaURL, password, 681 | ca.WithRootFile(config.GetRootCAPath())) 682 | if err != nil { 683 | log.Errorf("Error loading provisioner: %v", err) 684 | os.Exit(1) 685 | } 686 | log.WithFields(log.Fields{ 687 | "name": provisioner.Name(), 688 | "kid": provisioner.Kid(), 689 | }).Info("Loaded provisioner") 690 | 691 | namespace := os.Getenv("NAMESPACE") 692 | if namespace == "" { 693 | log.Errorf("$NAMESPACE not set") 694 | os.Exit(1) 695 | } 696 | 697 | name := fmt.Sprintf("%s.%s.svc", config.GetServiceName(), namespace) 698 | token, err := provisioner.Token(name) 699 | if err != nil { 700 | log.WithField("error", err).Errorf("Error generating bootstrap token during controller startup") 701 | os.Exit(1) 702 | } 703 | log.WithField("name", name).Infof("Generated bootstrap token for controller") 704 | 705 | // make sure to cancel the renew goroutine 706 | ctx, cancel := context.WithCancel(context.Background()) 707 | defer cancel() 708 | 709 | srv, err := ca.BootstrapServer(ctx, token, &http.Server{ 710 | Addr: config.GetAddress(), 711 | ReadHeaderTimeout: 15 * time.Second, 712 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 713 | if r.URL.Path == "/healthz" { 714 | log.Info("/healthz") 715 | w.WriteHeader(http.StatusOK) 716 | fmt.Fprintln(w, "ok") 717 | return 718 | } 719 | 720 | if r.URL.Path != "/mutate" { 721 | log.WithField("path", r.URL.Path).Error("Bad Request: 404 Not Found") 722 | http.NotFound(w, r) 723 | return 724 | } 725 | 726 | var body []byte 727 | if r.Body != nil { 728 | if data, err := io.ReadAll(r.Body); err == nil { 729 | body = data 730 | } 731 | } 732 | if len(body) == 0 { 733 | log.Error("Bad Request: 400 (Empty Body)") 734 | http.Error(w, "Bad Request (Empty Body)", http.StatusBadRequest) 735 | return 736 | } 737 | 738 | contentType := r.Header.Get("Content-Type") 739 | if contentType != "application/json" { 740 | log.WithField("Content-Type", contentType).Error("Bad Request: 415 (Unsupported Media Type)") 741 | http.Error(w, fmt.Sprintf("Bad Request: 415 Unsupported Media Type (Expected Content-Type 'application/json' but got '%s')", contentType), http.StatusUnsupportedMediaType) 742 | return 743 | } 744 | 745 | var response *v1beta1.AdmissionResponse 746 | review := v1beta1.AdmissionReview{} 747 | if _, _, err := deserializer.Decode(body, nil, &review); err != nil { 748 | log.WithFields(log.Fields{ 749 | "body": body, 750 | "error": err, 751 | }).Error("Can't decode body") 752 | response = &v1beta1.AdmissionResponse{ 753 | Allowed: false, 754 | Result: &metav1.Status{ 755 | Message: err.Error(), 756 | }, 757 | } 758 | } else { 759 | response = mutate(&review, config, provisioner) 760 | } 761 | 762 | resp, err := json.Marshal(v1beta1.AdmissionReview{ 763 | Response: response, 764 | }) 765 | if err != nil { 766 | log.WithFields(log.Fields{ 767 | "uid": review.Request.UID, 768 | "error": err, 769 | }).Info("Marshal error") 770 | http.Error(w, fmt.Sprintf("Marshal Error: %v", err), http.StatusInternalServerError) 771 | } else { 772 | log.WithFields(log.Fields{ 773 | "uid": review.Request.UID, 774 | "response": string(resp), 775 | }).Info("Returning review") 776 | if _, err := w.Write(resp); err != nil { 777 | log.WithFields(log.Fields{ 778 | "uid": review.Request.UID, 779 | "error": err, 780 | }).Info("Write error") 781 | } 782 | } 783 | }), 784 | }, ca.VerifyClientCertIfGiven()) 785 | if err != nil { 786 | panic(err) 787 | } 788 | 789 | log.Info("Listening on", config.GetAddress(), "...") 790 | if err := srv.ListenAndServeTLS("", ""); err != nil { 791 | panic(err) 792 | } 793 | } 794 | 795 | // readPasswordFromFile reads and returns the password from the given filename. 796 | // The contents of the file will be trimmed at the right. 797 | func readPasswordFromFile(filename string) ([]byte, error) { 798 | password, err := os.ReadFile(filename) 799 | if err != nil { 800 | return nil, errs.FileError(err, filename) 801 | } 802 | password = bytes.TrimRightFunc(password, unicode.IsSpace) 803 | return password, nil 804 | } 805 | -------------------------------------------------------------------------------- /controller/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | corev1 "k8s.io/api/core/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | func TestGetClusterDomain(t *testing.T) { 12 | c := Config{} 13 | if c.GetClusterDomain() != "cluster.local" { 14 | t.Errorf("cluster domain should default to cluster.local, not: %s", c.GetClusterDomain()) 15 | } 16 | 17 | c.ClusterDomain = "mydomain.com" 18 | if c.GetClusterDomain() != "mydomain.com" { 19 | t.Errorf("cluster domain should default to cluster.local, not: %s", c.GetClusterDomain()) 20 | } 21 | } 22 | 23 | func TestShouldMutate(t *testing.T) { 24 | testCases := []struct { 25 | description string 26 | subject string 27 | namespace string 28 | expected bool 29 | }{ 30 | {"full cluster domain", "test.default.svc.cluster.local", "default", true}, 31 | {"full cluster domain wrong ns", "test.default.svc.cluster.local", "kube-system", false}, 32 | {"left dots get stripped", ".test.default.svc.cluster.local", "default", true}, 33 | {"left dots get stripped wrong ns", ".test.default.svc.cluster.local", "kube-system", false}, 34 | {"right dots get stripped", "test.default.svc.cluster.local.", "default", true}, 35 | {"right dots get stripped wrong ns", "test.default.svc.cluster.local.", "kube-system", false}, 36 | {"dots get stripped", ".test.default.svc.cluster.local.", "default", true}, 37 | {"dots get stripped wrong ns", ".test.default.svc.cluster.local.", "kube-system", false}, 38 | {"partial cluster domain", "test.default.svc.cluster", "default", true}, 39 | {"partial cluster domain wrong ns is still allowed because not valid hostname", "test.default.svc.cluster", "kube-system", true}, 40 | {"service domain", "test.default.svc", "default", true}, 41 | {"service domain wrong ns", "test.default.svc", "kube-system", false}, 42 | {"two part domain", "test.default", "default", true}, 43 | {"two part domain different ns", "test.default", "kube-system", true}, 44 | {"one hostname", "test", "default", true}, 45 | {"no subject specified", "", "default", false}, 46 | {"three part not cluster", "test.default.com", "kube-system", true}, 47 | {"four part not cluster", "test.default.svc.com", "kube-system", true}, 48 | {"five part not cluster", "test.default.svc.cluster.com", "kube-system", true}, 49 | {"six part not cluster", "test.default.svc.cluster.local.com", "kube-system", true}, 50 | } 51 | 52 | for _, testCase := range testCases { 53 | t.Run(testCase.description, func(t *testing.T) { 54 | mutationAllowed, validationErr := shouldMutate(&metav1.ObjectMeta{ 55 | Annotations: map[string]string{ 56 | admissionWebhookAnnotationKey: testCase.subject, 57 | }, 58 | }, testCase.namespace, "cluster.local", true) 59 | if mutationAllowed != testCase.expected { 60 | t.Errorf("shouldMutate did not return %t for %s", testCase.expected, testCase.description) 61 | } 62 | if testCase.subject != "" && mutationAllowed == false && validationErr == nil { 63 | t.Errorf("shouldMutate should return validation error for invalid hostname") 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestShouldMutateNotRestrictToNamespace(t *testing.T) { 70 | mutationAllowed, _ := shouldMutate(&metav1.ObjectMeta{ 71 | Annotations: map[string]string{ 72 | admissionWebhookAnnotationKey: "test.default.svc.cluster.local", 73 | }, 74 | }, "kube-system", "cluster.local", false) 75 | if mutationAllowed == false { 76 | t.Errorf("shouldMutate should return true even with a wrong namespace if restrictToNamespace is false.") 77 | } 78 | } 79 | 80 | func Test_mkRenewer(t *testing.T) { 81 | type args struct { 82 | config *Config 83 | podName string 84 | commonName string 85 | namespace string 86 | } 87 | tests := []struct { 88 | name string 89 | args args 90 | want corev1.Container 91 | }{ 92 | {"ok", args{&Config{CaURL: "caURL", ClusterDomain: "clusterDomain"}, "podName", "commonName", "namespace"}, corev1.Container{ 93 | Env: []corev1.EnvVar{ 94 | {Name: "STEP_CA_URL", Value: "caURL"}, 95 | {Name: "COMMON_NAME", Value: "commonName"}, 96 | {Name: "POD_NAME", Value: "podName"}, 97 | {Name: "NAMESPACE", Value: "namespace"}, 98 | {Name: "CLUSTER_DOMAIN", Value: "clusterDomain"}, 99 | }, 100 | }}, 101 | } 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | if got := mkRenewer(tt.args.config, tt.args.podName, tt.args.commonName, tt.args.namespace); !reflect.DeepEqual(got, tt.want) { 105 | t.Errorf("mkRenewer() = %v, want %v", got, tt.want) 106 | } 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/demo.gif -------------------------------------------------------------------------------- /examples/hello-mtls/README.md: -------------------------------------------------------------------------------- 1 | # hello-mtls 2 | 3 | This repository contains examples of dockerized [m]TLS clients and servers in 4 | various languages. There's a lot of confusion and misinformation regarding how 5 | to do mTLS properly with an internal public key infrastructure. The goal of 6 | this repository is to demonstrate best practices like: 7 | 8 | * Properly configuring TLS to use your internal CA's root certificate 9 | * mTLS (client certificates / client authentication) 10 | * Short-lived certificate support (clients and servers automatically load 11 | renewed certificates) 12 | 13 | Examples use multi-stage docker builds and can be built via without any 14 | required local dependencies (except `docker`): 15 | 16 | ``` 17 | docker build -f Dockerfile.server -t hello-mtls-server- . 18 | docker build -f Dockerfile.client -t hello-mtls-client- . 19 | ``` 20 | 21 | Once built, you should be able to deploy via: 22 | 23 | ``` 24 | kubectl apply -f hello-mtls.server.yaml 25 | kubectl apply -f hello-mtls.client.yaml 26 | ``` 27 | 28 | ## Mutual TLS 29 | 30 | Unlike the _server auth TLS_ that's typical with web browsers, where the browser authenticates the server but not vice versa, _mutual TLS_ (mTLS) connections have both remote peers (client and server) authenticate to one another by presenting certificates. mTLS is not a different protocol. It's just a variant of TLS that's not usually turned on by default. This repository demonstrates **how to turn on mTLS** with different tools and languages. It also demonstrates other **TLS best practices** like certificate rotation. 31 | 32 | mTLS provides _authenticated encryption_: an _identity dialtone_ and _end-to-end encryption_ for your workloads. It's like a secure line with caller ID. This has [all sorts of benefits](https://smallstep.com/blog/use-tls.html): better security, compliance, and easier auditability for starters. It **makes workloads identity-aware**, improving observability and enabling granular access control. Perhaps most compelling, mTLS lets you securely communicate with workloads running anywhere. Code, containers, devices, people, and anything else can connect securely using mTLS as long as they know one anothers' names and can resolve those names to routable IP addresses. 33 | 34 | With properly configured mTLS, services can be safely exposed directly to the public internet: **only clients that have a certificate issued by the internal certificate authority will be allowed to connect**. 35 | 36 | Here's a rough approximation of how an mTLS handshake works: 37 | 38 | ![mTLS handshake diagram](https://raw.githubusercontent.com/smallstep/autocert/master/mtls-handshake.png) 39 | 40 | A few things to note: 41 | 42 | * It's the signing of random numbers that proves we're talking to the right remote. It's the digital equivalent of asking someone to send you a photo of them with today's newspaper. 43 | * The client and server need to have prior knowledge of the root certificate(s) used for signing other certificates. 44 | * The client and server need to be configured to use the correct certificate and private key (the certificate must have been issued by a CA with a trusted root certificate) 45 | * Private keys are never shared. This is the magic of public key cryptography: unlike passwords or access tokens, certificates let you prove who you are without giving anyone the ability to impersonate you. 46 | 47 | ## Feature matrix 48 | 49 | This matrix shows the set of features we'd like to demonstrate in each language 50 | and where each language is. Bug fixes, improvements, and examples in new 51 | languages are appreciated! 52 | 53 | [curl/](curl/) 54 | - [X] Client 55 | - [X] mTLS (send client certificate if server asks for it) 56 | - [X] Automatic certificate rotation 57 | - [ ] Restrict to safe ciphersuites and TLS versions 58 | - [ ] TLS stack configuration loaded from `step-ca` 59 | - [ ] Root certificate rotation 60 | 61 | [nginx/](nginx/) 62 | - [X] Server 63 | - [X] mTLS (client authentication using internal root certificate) 64 | - [X] Automatic certificate renewal 65 | - [X] Restrict to safe ciphersuites and TLS versions 66 | - [ ] TLS stack configuration loaded from `step-ca` 67 | - [ ] Root certificate rotation 68 | 69 | [envoy/](envoy/) 70 | - [X] Server 71 | - [X] mTLS (client authentication using internal root certificate) 72 | - [X] Automatic certificate renewal 73 | - [X] Restrict to safe ciphersuites and TLS versions 74 | - [ ] TLS stack configuration loaded from `step-ca` 75 | - [ ] Root certificate rotation 76 | 77 | [go/](go/) 78 | - [X] Server using autocert certificate & key 79 | - [X] mTLS (client authentication using internal root certificate) 80 | - [X] Automatic certificate renewal 81 | - [X] Restrict to safe ciphersuites and TLS versions 82 | - [ ] TLS stack configuration loaded from `step-ca` 83 | - [ ] Root certificate rotation 84 | - [X] Client using autocert root certificate 85 | - [X] mTLS (send client certificate if server asks for it) 86 | - [X] Automatic certificate rotation 87 | - [X] Restrict to safe ciphersuites and TLS versions 88 | - [ ] TLS stack configuration loaded from `step-ca` 89 | - [ ] Root certificate rotation 90 | 91 | [go-grpc/](go-grpc/) 92 | - [X] Server using autocert certificate & key 93 | - [X] mTLS (client authentication using internal root certificate) 94 | - [X] Automatic certificate renewal 95 | - [X] Restrict to safe ciphersuites and TLS versions 96 | - [ ] TLS stack configuration loaded from `step-ca` 97 | - [ ] Root certificate rotation 98 | - [X] Client using autocert root certificate 99 | - [X] mTLS (send client certificate if server asks for it) 100 | - [X] Automatic certificate rotation 101 | - [X] Restrict to safe ciphersuites and TLS versions 102 | - [ ] TLS stack configuration loaded from `step-ca` 103 | - [ ] Root certificate rotation 104 | 105 | [node/](node/) 106 | - [X] Server 107 | - [X] mTLS (client authentication using internal root certificate) 108 | - [X] Automatic certificate renewal 109 | - [X] Restrict to safe ciphersuites and TLS versions 110 | - [ ] TLS stack configuration loaded from `step-ca` 111 | - [ ] Root certificate rotation 112 | - [X] Client using autocert root certificate 113 | - [X] mTLS (send client certificate if server asks for it) 114 | - [X] Automatic certificate rotation 115 | - [X] Restrict to safe ciphersuites and TLS versions 116 | - [ ] TLS stack configuration loaded from `step-ca` 117 | - [ ] Root certificate rotation 118 | 119 | [py-gunicorn/](py-gunicorn/) 120 | - [X] Server (gunicorn + Flask) 121 | - [X] mTLS (client authentication using internal root certificate) 122 | - [X] Automatic certificate renewal 123 | - [X] Restrict to safe ciphersuites and TLS versions 124 | - [ ] TLS stack configuration loaded from `step-ca` 125 | - [ ] Root certificate rotation 126 | - [X] Client using autocert root certificate (python) 127 | - [X] mTLS (send client certificate if server asks for it) 128 | - [X] Automatic certificate rotation 129 | - [X] Restrict to safe ciphersuites and TLS versions 130 | - [ ] TLS stack configuration loaded from `step-ca` 131 | - [ ] Root certificate rotation 132 | -------------------------------------------------------------------------------- /examples/hello-mtls/curl/Dockerfile.client: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | RUN apk add --no-cache bash curl 3 | COPY client.sh . 4 | RUN chmod +x client.sh 5 | ENTRYPOINT ./client.sh 6 | -------------------------------------------------------------------------------- /examples/hello-mtls/curl/client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | while : 3 | do 4 | response=$(curl -sS \ 5 | --cacert /var/run/autocert.step.sm/root.crt \ 6 | --cert /var/run/autocert.step.sm/site.crt \ 7 | --key /var/run/autocert.step.sm/site.key \ 8 | ${HELLO_MTLS_URL}) 9 | echo "$(date): ${response}" 10 | sleep 5 11 | done -------------------------------------------------------------------------------- /examples/hello-mtls/curl/hello-mtls.client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-mtls-client 5 | labels: {app: hello-mtls-client} 6 | spec: 7 | replicas: 1 8 | selector: {matchLabels: {app: hello-mtls-client}} 9 | template: 10 | metadata: 11 | annotations: 12 | autocert.step.sm/name: hello-mtls-client.default.pod.cluster.local 13 | labels: {app: hello-mtls-client} 14 | spec: 15 | containers: 16 | - name: hello-mtls-client 17 | image: hello-mtls-client-curl:latest 18 | imagePullPolicy: Never 19 | resources: {requests: {cpu: 10m, memory: 20Mi}} 20 | env: 21 | - name: HELLO_MTLS_URL 22 | value: https://hello-mtls.default.svc.cluster.local 23 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM envoyproxy/envoy-alpine 2 | 3 | RUN apk update 4 | RUN apk add python3 5 | RUN apk add inotify-tools 6 | RUN mkdir /src 7 | 8 | ADD entrypoint.sh /src 9 | ADD certwatch.sh /src 10 | ADD hot-restarter.py /src 11 | ADD start-envoy.sh /src 12 | ADD server.yaml /src 13 | 14 | # Flask app 15 | ADD server.py /src 16 | ADD requirements.txt /src 17 | RUN pip3 install -r /src/requirements.txt 18 | 19 | # app, certificate watcher and envoy 20 | ENTRYPOINT ["/src/entrypoint.sh"] 21 | CMD ["python3", "/src/hot-restarter.py", "/src/start-envoy.sh"] 22 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/certwatch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while true; do 4 | inotifywait -e modify /var/run/autocert.step.sm/site.crt 5 | kill -HUP 1 6 | done 7 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # start hello world app 4 | python3 /src/server.py & 5 | 6 | # watch for the update of the cert and reload nginx 7 | /src/certwatch.sh & 8 | 9 | # Run docker CMD 10 | exec "$@" -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-envoy:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/hot-restarter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import os 5 | import signal 6 | import sys 7 | import time 8 | 9 | # The number of seconds to wait for children to gracefully exit after 10 | # propagating SIGTERM before force killing children. 11 | # NOTE: If using a shutdown mechanism such as runit's `force-stop` which sends 12 | # a KILL after a specified timeout period, it's important to ensure that this 13 | # constant is smaller than the KILL timeout 14 | TERM_WAIT_SECONDS = 30 15 | 16 | restart_epoch = 0 17 | pid_list = [] 18 | 19 | 20 | def term_all_children(): 21 | """ Iterate through all known child processes, send a TERM signal to each of 22 | them, and then wait up to TERM_WAIT_SECONDS for them to exit gracefully, 23 | exiting early if all children go away. If one or more children have not 24 | exited after TERM_WAIT_SECONDS, they will be forcibly killed """ 25 | 26 | # First uninstall the SIGCHLD handler so that we don't get called again. 27 | signal.signal(signal.SIGCHLD, signal.SIG_DFL) 28 | 29 | global pid_list 30 | for pid in pid_list: 31 | print("sending TERM to PID={}".format(pid)) 32 | try: 33 | os.kill(pid, signal.SIGTERM) 34 | except OSError: 35 | print("error sending TERM to PID={} continuing".format(pid)) 36 | 37 | all_exited = False 38 | 39 | # wait for TERM_WAIT_SECONDS seconds for children to exit cleanly 40 | retries = 0 41 | while not all_exited and retries < TERM_WAIT_SECONDS: 42 | for pid in list(pid_list): 43 | ret_pid, exit_status = os.waitpid(pid, os.WNOHANG) 44 | if ret_pid == 0 and exit_status == 0: 45 | # the child is still running 46 | continue 47 | 48 | pid_list.remove(pid) 49 | 50 | if len(pid_list) == 0: 51 | all_exited = True 52 | else: 53 | retries += 1 54 | time.sleep(1) 55 | 56 | if all_exited: 57 | print("all children exited cleanly") 58 | else: 59 | for pid in pid_list: 60 | print("child PID={} did not exit cleanly, killing".format(pid)) 61 | force_kill_all_children() 62 | sys.exit(1) # error status because a child did not exit cleanly 63 | 64 | 65 | def force_kill_all_children(): 66 | """ Iterate through all known child processes and force kill them. Typically 67 | term_all_children() should be attempted first to give child processes an 68 | opportunity to clean up state before exiting """ 69 | 70 | global pid_list 71 | for pid in pid_list: 72 | print("force killing PID={}".format(pid)) 73 | try: 74 | os.kill(pid, signal.SIGKILL) 75 | except OSError: 76 | print("error force killing PID={} continuing".format(pid)) 77 | 78 | pid_list = [] 79 | 80 | 81 | def shutdown(): 82 | """ Attempt to gracefully shutdown all child Envoy processes and then exit. 83 | See term_all_children() for further discussion. """ 84 | term_all_children() 85 | sys.exit(0) 86 | 87 | 88 | def sigterm_handler(signum, frame): 89 | """ Handler for SIGTERM. """ 90 | print("got SIGTERM") 91 | shutdown() 92 | 93 | 94 | def sigint_handler(signum, frame): 95 | """ Handler for SIGINT (ctrl-c). The same as the SIGTERM handler. """ 96 | print("got SIGINT") 97 | shutdown() 98 | 99 | 100 | def sighup_handler(signum, frame): 101 | """ Handler for SIGUP. This signal is used to cause the restarter to fork and exec a new 102 | child. """ 103 | 104 | print("got SIGHUP") 105 | fork_and_exec() 106 | 107 | 108 | def sigusr1_handler(signum, frame): 109 | """ Handler for SIGUSR1. Propagate SIGUSR1 to all of the child processes """ 110 | 111 | global pid_list 112 | for pid in pid_list: 113 | print("sending SIGUSR1 to PID={}".format(pid)) 114 | try: 115 | os.kill(pid, signal.SIGUSR1) 116 | except OSError: 117 | print("error in SIGUSR1 to PID={} continuing".format(pid)) 118 | 119 | 120 | def sigchld_handler(signum, frame): 121 | """ Handler for SIGCHLD. Iterates through all of our known child processes and figures out whether 122 | the signal/exit was expected or not. Python doesn't have any of the native signal handlers 123 | ability to get the child process info directly from the signal handler so we need to iterate 124 | through all child processes and see what happened.""" 125 | 126 | print("got SIGCHLD") 127 | 128 | kill_all_and_exit = False 129 | global pid_list 130 | pid_list_copy = list(pid_list) 131 | for pid in pid_list_copy: 132 | ret_pid, exit_status = os.waitpid(pid, os.WNOHANG) 133 | if ret_pid == 0 and exit_status == 0: 134 | # This child is still running. 135 | continue 136 | 137 | pid_list.remove(pid) 138 | 139 | # Now we see how the child exited. 140 | if os.WIFEXITED(exit_status): 141 | exit_code = os.WEXITSTATUS(exit_status) 142 | print("PID={} exited with code={}".format(ret_pid, exit_code)) 143 | if exit_code == 0: 144 | # Normal exit. We assume this was on purpose. 145 | pass 146 | else: 147 | # Something bad happened. We need to tear everything down so that whoever started the 148 | # restarter can know about this situation and restart the whole thing. 149 | kill_all_and_exit = True 150 | elif os.WIFSIGNALED(exit_status): 151 | print("PID={} was killed with signal={}".format(ret_pid, os.WTERMSIG(exit_status))) 152 | kill_all_and_exit = True 153 | else: 154 | kill_all_and_exit = True 155 | 156 | if kill_all_and_exit: 157 | print("Due to abnormal exit, force killing all child processes and exiting") 158 | 159 | # First uninstall the SIGCHLD handler so that we don't get called again. 160 | signal.signal(signal.SIGCHLD, signal.SIG_DFL) 161 | 162 | force_kill_all_children() 163 | 164 | # Our last child died, so we have no purpose. Exit. 165 | if not pid_list: 166 | print("exiting due to lack of child processes") 167 | sys.exit(1 if kill_all_and_exit else 0) 168 | 169 | 170 | def fork_and_exec(): 171 | """ This routine forks and execs a new child process and keeps track of its PID. Before we fork, 172 | set the current restart epoch in an env variable that processes can read if they care. """ 173 | 174 | global restart_epoch 175 | os.environ['RESTART_EPOCH'] = str(restart_epoch) 176 | print("forking and execing new child process at epoch {}".format(restart_epoch)) 177 | restart_epoch += 1 178 | 179 | child_pid = os.fork() 180 | if child_pid == 0: 181 | # Child process 182 | os.execl(sys.argv[1], sys.argv[1]) 183 | else: 184 | # Parent process 185 | print("forked new child process with PID={}".format(child_pid)) 186 | pid_list.append(child_pid) 187 | 188 | 189 | def main(): 190 | """ Script main. This script is designed so that a process watcher like runit or monit can watch 191 | this process and take corrective action if it ever goes away. """ 192 | 193 | print("starting hot-restarter with target: {}".format(sys.argv[1])) 194 | 195 | signal.signal(signal.SIGTERM, sigterm_handler) 196 | signal.signal(signal.SIGINT, sigint_handler) 197 | signal.signal(signal.SIGHUP, sighup_handler) 198 | signal.signal(signal.SIGCHLD, sigchld_handler) 199 | signal.signal(signal.SIGUSR1, sigusr1_handler) 200 | 201 | # Start the first child process and then go into an endless loop since everything else happens via 202 | # signals. 203 | fork_and_exec() 204 | while True: 205 | time.sleep(60) 206 | 207 | 208 | if __name__ == '__main__': 209 | main() 210 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | app = Flask(__name__) 3 | 4 | @app.route("/") 5 | def hello(): 6 | return "Hello World!\n" 7 | 8 | if __name__ == "__main__": 9 | app.run(host='127.0.0.1', port=8080, debug=False) 10 | -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/server.yaml: -------------------------------------------------------------------------------- 1 | static_resources: 2 | listeners: 3 | - address: 4 | socket_address: 5 | address: 0.0.0.0 6 | port_value: 443 7 | filter_chains: 8 | - filters: 9 | - name: envoy.http_connection_manager 10 | config: 11 | codec_type: auto 12 | stat_prefix: ingress_http 13 | route_config: 14 | name: hello 15 | virtual_hosts: 16 | - name: hello 17 | domains: 18 | - "hello-mtls.default.svc.cluster.local" 19 | routes: 20 | - match: 21 | prefix: "/" 22 | route: 23 | cluster: hello-mTLS 24 | http_filters: 25 | - name: envoy.router 26 | config: {} 27 | tls_context: 28 | common_tls_context: 29 | tls_params: 30 | tls_minimum_protocol_version: TLSv1_2 31 | tls_maximum_protocol_version: TLSv1_3 32 | cipher_suites: "[ECDHE-ECDSA-AES128-GCM-SHA256|ECDHE-ECDSA-CHACHA20-POLY1305]" 33 | tls_certificates: 34 | - certificate_chain: 35 | filename: "/var/run/autocert.step.sm/site.crt" 36 | private_key: 37 | filename: "/var/run/autocert.step.sm/site.key" 38 | validation_context: 39 | trusted_ca: 40 | filename: "/var/run/autocert.step.sm/root.crt" 41 | require_client_certificate: true 42 | clusters: 43 | - name: hello-mTLS 44 | connect_timeout: 0.25s 45 | type: strict_dns 46 | lb_policy: round_robin 47 | hosts: 48 | - socket_address: 49 | address: 127.0.0.1 50 | port_value: 8080 -------------------------------------------------------------------------------- /examples/hello-mtls/envoy/start-envoy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ulimit -n 65536 4 | /usr/local/bin/envoy -c /src/server.yaml --service-cluster hello-mTLS --restart-epoch $RESTART_EPOCH 5 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/client/Dockerfile.client: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build-env 3 | RUN apk update 4 | RUN apk add git 5 | RUN mkdir /src 6 | 7 | WORKDIR /go/src/github.com/smallstep/autocert/examples/hello-mtls/go-grpc 8 | ADD client/client.go . 9 | COPY hello hello 10 | RUN go get -d -v ./... 11 | RUN go build -o client 12 | 13 | # final stage 14 | FROM alpine 15 | COPY --from=build-env /go/src/github.com/smallstep/autocert/examples/hello-mtls/go-grpc/client . 16 | CMD ["./client"] 17 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/client/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "os" 11 | "sync" 12 | "time" 13 | 14 | "google.golang.org/grpc" 15 | "google.golang.org/grpc/credentials" 16 | 17 | "github.com/smallstep/autocert/examples/hello-mtls/go-grpc/hello" 18 | ) 19 | 20 | const ( 21 | autocertFile = "/var/run/autocert.step.sm/site.crt" 22 | autocertKey = "/var/run/autocert.step.sm/site.key" 23 | autocertRoot = "/var/run/autocert.step.sm/root.crt" 24 | requestFrequency = 5 * time.Second 25 | tickFrequency = 15 * time.Second 26 | ) 27 | 28 | // Uses techniques from https://diogomonica.com/2017/01/11/hitless-tls-certificate-rotation-in-go/ 29 | // to automatically rotate certificates when they're renewed. 30 | 31 | type rotator struct { 32 | sync.RWMutex 33 | certificate *tls.Certificate 34 | } 35 | 36 | func (r *rotator) getClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) { 37 | r.RLock() 38 | defer r.RUnlock() 39 | return r.certificate, nil 40 | } 41 | 42 | func (r *rotator) loadCertificate(certFile, keyFile string) error { 43 | r.Lock() 44 | defer r.Unlock() 45 | 46 | c, err := tls.LoadX509KeyPair(certFile, keyFile) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | r.certificate = &c 52 | 53 | return nil 54 | } 55 | 56 | func loadRootCertPool() (*x509.CertPool, error) { 57 | root, err := os.ReadFile(autocertRoot) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | pool := x509.NewCertPool() 63 | if ok := pool.AppendCertsFromPEM(root); !ok { 64 | return nil, errors.New("missing or invalid root certificate") 65 | } 66 | 67 | return pool, nil 68 | } 69 | 70 | func sayHello(c hello.GreeterClient) error { 71 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 72 | defer cancel() 73 | 74 | r, err := c.SayHello(ctx, &hello.HelloRequest{Name: "world"}) 75 | if err != nil { 76 | return err 77 | } 78 | log.Printf("Greeting: %s", r.Message) 79 | return nil 80 | } 81 | 82 | func sayHelloAgain(c hello.GreeterClient) error { 83 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 84 | defer cancel() 85 | 86 | r, err := c.SayHelloAgain(ctx, &hello.HelloRequest{Name: "world"}) 87 | if err != nil { 88 | return err 89 | } 90 | log.Printf("Greeting: %s", r.Message) 91 | return nil 92 | } 93 | 94 | func main() { 95 | if err := run(); err != nil { 96 | log.Println(err) 97 | os.Exit(1) 98 | } 99 | } 100 | 101 | func run() error { 102 | // Read the root certificate for our CA from disk 103 | roots, err := loadRootCertPool() 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // Load certificate 109 | r := &rotator{} 110 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 111 | return fmt.Errorf("error loading certificate and key: %w", err) 112 | } 113 | 114 | tlsConfig := &tls.Config{ 115 | RootCAs: roots, 116 | MinVersion: tls.VersionTLS12, 117 | CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, 118 | CipherSuites: []uint16{ 119 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 120 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 121 | }, 122 | // GetClientCertificate is called when a server requests a 123 | // certificate from a client. 124 | // 125 | // In this example keep alives will cause the certificate to 126 | // only be called once, but if we disable them, 127 | // GetClientCertificate will be called on every request. 128 | GetClientCertificate: r.getClientCertificate, 129 | } 130 | 131 | // Schedule periodic re-load of certificate 132 | // A real implementation can use something like 133 | // https://github.com/fsnotify/fsnotify 134 | done := make(chan struct{}) 135 | go func() { 136 | ticker := time.NewTicker(tickFrequency) 137 | defer ticker.Stop() 138 | for { 139 | select { 140 | case <-ticker.C: 141 | fmt.Println("Checking for new certificate...") 142 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 143 | log.Println("Error loading certificate and key", err) 144 | } 145 | case <-done: 146 | return 147 | } 148 | } 149 | }() 150 | defer close(done) 151 | 152 | // Set up a connection to the server. 153 | address := os.Getenv("HELLO_MTLS_URL") 154 | conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) 155 | if err != nil { 156 | return fmt.Errorf("did not connect: %w", err) 157 | } 158 | defer conn.Close() 159 | client := hello.NewGreeterClient(conn) 160 | 161 | for { 162 | if err := sayHello(client); err != nil { 163 | return fmt.Errorf("could not greet: %w", err) 164 | } 165 | if err := sayHelloAgain(client); err != nil { 166 | return fmt.Errorf("could not greet: %w", err) 167 | } 168 | time.Sleep(requestFrequency) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/client/hello-mtls.client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-mtls-client 5 | labels: {app: hello-mtls-client} 6 | spec: 7 | replicas: 1 8 | selector: {matchLabels: {app: hello-mtls-client}} 9 | template: 10 | metadata: 11 | annotations: 12 | autocert.step.sm/name: hello-mtls-client.default.pod.cluster.local 13 | labels: {app: hello-mtls-client} 14 | spec: 15 | containers: 16 | - name: hello-mtls-client 17 | image: hello-mtls-client-go-grpc:latest 18 | imagePullPolicy: Never 19 | resources: {requests: {cpu: 10m, memory: 20Mi}} 20 | env: 21 | - name: HELLO_MTLS_URL 22 | value: hello-mtls.default.svc.cluster.local:443 23 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/hello/hello.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: hello.proto 3 | 4 | package hello 5 | 6 | import ( 7 | fmt "fmt" 8 | 9 | proto "github.com/golang/protobuf/proto" 10 | 11 | math "math" 12 | 13 | context "golang.org/x/net/context" 14 | 15 | grpc "google.golang.org/grpc" 16 | ) 17 | 18 | // Reference imports to suppress errors if they are not otherwise used. 19 | var _ = proto.Marshal 20 | var _ = fmt.Errorf 21 | var _ = math.Inf 22 | 23 | // This is a compile-time assertion to ensure that this generated file 24 | // is compatible with the proto package it is being compiled against. 25 | // A compilation error at this line likely means your copy of the 26 | // proto package needs to be updated. 27 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 28 | 29 | // The request message containing the user's name. 30 | type HelloRequest struct { 31 | Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"` 32 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 33 | XXX_unrecognized []byte `json:"-"` 34 | XXX_sizecache int32 `json:"-"` 35 | } 36 | 37 | func (m *HelloRequest) Reset() { *m = HelloRequest{} } 38 | func (m *HelloRequest) String() string { return proto.CompactTextString(m) } 39 | func (*HelloRequest) ProtoMessage() {} 40 | func (*HelloRequest) Descriptor() ([]byte, []int) { 41 | return fileDescriptor_hello_4c93420831fe68fb, []int{0} 42 | } 43 | func (m *HelloRequest) XXX_Unmarshal(b []byte) error { 44 | return xxx_messageInfo_HelloRequest.Unmarshal(m, b) 45 | } 46 | func (m *HelloRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 47 | return xxx_messageInfo_HelloRequest.Marshal(b, m, deterministic) 48 | } 49 | func (dst *HelloRequest) XXX_Merge(src proto.Message) { 50 | xxx_messageInfo_HelloRequest.Merge(dst, src) 51 | } 52 | func (m *HelloRequest) XXX_Size() int { 53 | return xxx_messageInfo_HelloRequest.Size(m) 54 | } 55 | func (m *HelloRequest) XXX_DiscardUnknown() { 56 | xxx_messageInfo_HelloRequest.DiscardUnknown(m) 57 | } 58 | 59 | var xxx_messageInfo_HelloRequest proto.InternalMessageInfo 60 | 61 | func (m *HelloRequest) GetName() string { 62 | if m != nil { 63 | return m.Name 64 | } 65 | return "" 66 | } 67 | 68 | // The response message containing the greetings 69 | type HelloReply struct { 70 | Message string `protobuf:"bytes,1,opt,name=message" json:"message,omitempty"` 71 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 72 | XXX_unrecognized []byte `json:"-"` 73 | XXX_sizecache int32 `json:"-"` 74 | } 75 | 76 | func (m *HelloReply) Reset() { *m = HelloReply{} } 77 | func (m *HelloReply) String() string { return proto.CompactTextString(m) } 78 | func (*HelloReply) ProtoMessage() {} 79 | func (*HelloReply) Descriptor() ([]byte, []int) { 80 | return fileDescriptor_hello_4c93420831fe68fb, []int{1} 81 | } 82 | func (m *HelloReply) XXX_Unmarshal(b []byte) error { 83 | return xxx_messageInfo_HelloReply.Unmarshal(m, b) 84 | } 85 | func (m *HelloReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 86 | return xxx_messageInfo_HelloReply.Marshal(b, m, deterministic) 87 | } 88 | func (dst *HelloReply) XXX_Merge(src proto.Message) { 89 | xxx_messageInfo_HelloReply.Merge(dst, src) 90 | } 91 | func (m *HelloReply) XXX_Size() int { 92 | return xxx_messageInfo_HelloReply.Size(m) 93 | } 94 | func (m *HelloReply) XXX_DiscardUnknown() { 95 | xxx_messageInfo_HelloReply.DiscardUnknown(m) 96 | } 97 | 98 | var xxx_messageInfo_HelloReply proto.InternalMessageInfo 99 | 100 | func (m *HelloReply) GetMessage() string { 101 | if m != nil { 102 | return m.Message 103 | } 104 | return "" 105 | } 106 | 107 | func init() { 108 | proto.RegisterType((*HelloRequest)(nil), "HelloRequest") 109 | proto.RegisterType((*HelloReply)(nil), "HelloReply") 110 | } 111 | 112 | // Reference imports to suppress errors if they are not otherwise used. 113 | var _ context.Context 114 | var _ grpc.ClientConn 115 | 116 | // This is a compile-time assertion to ensure that this generated file 117 | // is compatible with the grpc package it is being compiled against. 118 | const _ = grpc.SupportPackageIsVersion4 119 | 120 | // Client API for Greeter service 121 | 122 | type GreeterClient interface { 123 | // Sends a greeting 124 | SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) 125 | // Sends another greeting 126 | SayHelloAgain(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) 127 | } 128 | 129 | type greeterClient struct { 130 | cc *grpc.ClientConn 131 | } 132 | 133 | func NewGreeterClient(cc *grpc.ClientConn) GreeterClient { 134 | return &greeterClient{cc} 135 | } 136 | 137 | func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { 138 | out := new(HelloReply) 139 | err := grpc.Invoke(ctx, "/Greeter/SayHello", in, out, c.cc, opts...) 140 | if err != nil { 141 | return nil, err 142 | } 143 | return out, nil 144 | } 145 | 146 | func (c *greeterClient) SayHelloAgain(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) { 147 | out := new(HelloReply) 148 | err := grpc.Invoke(ctx, "/Greeter/SayHelloAgain", in, out, c.cc, opts...) 149 | if err != nil { 150 | return nil, err 151 | } 152 | return out, nil 153 | } 154 | 155 | // Server API for Greeter service 156 | 157 | type GreeterServer interface { 158 | // Sends a greeting 159 | SayHello(context.Context, *HelloRequest) (*HelloReply, error) 160 | // Sends another greeting 161 | SayHelloAgain(context.Context, *HelloRequest) (*HelloReply, error) 162 | } 163 | 164 | func RegisterGreeterServer(s *grpc.Server, srv GreeterServer) { 165 | s.RegisterService(&_Greeter_serviceDesc, srv) 166 | } 167 | 168 | func _Greeter_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 169 | in := new(HelloRequest) 170 | if err := dec(in); err != nil { 171 | return nil, err 172 | } 173 | if interceptor == nil { 174 | return srv.(GreeterServer).SayHello(ctx, in) 175 | } 176 | info := &grpc.UnaryServerInfo{ 177 | Server: srv, 178 | FullMethod: "/Greeter/SayHello", 179 | } 180 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 181 | return srv.(GreeterServer).SayHello(ctx, req.(*HelloRequest)) 182 | } 183 | return interceptor(ctx, in, info, handler) 184 | } 185 | 186 | func _Greeter_SayHelloAgain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 187 | in := new(HelloRequest) 188 | if err := dec(in); err != nil { 189 | return nil, err 190 | } 191 | if interceptor == nil { 192 | return srv.(GreeterServer).SayHelloAgain(ctx, in) 193 | } 194 | info := &grpc.UnaryServerInfo{ 195 | Server: srv, 196 | FullMethod: "/Greeter/SayHelloAgain", 197 | } 198 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 199 | return srv.(GreeterServer).SayHelloAgain(ctx, req.(*HelloRequest)) 200 | } 201 | return interceptor(ctx, in, info, handler) 202 | } 203 | 204 | var _Greeter_serviceDesc = grpc.ServiceDesc{ 205 | ServiceName: "Greeter", 206 | HandlerType: (*GreeterServer)(nil), 207 | Methods: []grpc.MethodDesc{ 208 | { 209 | MethodName: "SayHello", 210 | Handler: _Greeter_SayHello_Handler, 211 | }, 212 | { 213 | MethodName: "SayHelloAgain", 214 | Handler: _Greeter_SayHelloAgain_Handler, 215 | }, 216 | }, 217 | Streams: []grpc.StreamDesc{}, 218 | Metadata: "hello.proto", 219 | } 220 | 221 | func init() { proto.RegisterFile("hello.proto", fileDescriptor_hello_4c93420831fe68fb) } 222 | 223 | var fileDescriptor_hello_4c93420831fe68fb = []byte{ 224 | // 141 bytes of a gzipped FileDescriptorProto 225 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0xce, 0x48, 0xcd, 0xc9, 226 | 0xc9, 0xd7, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x57, 0x52, 0xe2, 0xe2, 0xf1, 0x00, 0x71, 0x83, 0x52, 227 | 0x0b, 0x4b, 0x53, 0x8b, 0x4b, 0x84, 0x84, 0xb8, 0x58, 0xf2, 0x12, 0x73, 0x53, 0x25, 0x18, 0x15, 228 | 0x18, 0x35, 0x38, 0x83, 0xc0, 0x6c, 0x25, 0x35, 0x2e, 0x2e, 0xa8, 0x9a, 0x82, 0x9c, 0x4a, 0x21, 229 | 0x09, 0x2e, 0xf6, 0xdc, 0xd4, 0xe2, 0xe2, 0xc4, 0x74, 0x98, 0x22, 0x18, 0xd7, 0x28, 0x89, 0x8b, 230 | 0xdd, 0xbd, 0x28, 0x35, 0xb5, 0x24, 0xb5, 0x48, 0x48, 0x83, 0x8b, 0x23, 0x38, 0xb1, 0x12, 0xac, 231 | 0x4b, 0x88, 0x57, 0x0f, 0xd9, 0x06, 0x29, 0x6e, 0x3d, 0x84, 0x61, 0x4a, 0x0c, 0x42, 0xba, 0x5c, 232 | 0xbc, 0x30, 0x95, 0x8e, 0xe9, 0x89, 0x99, 0x79, 0xf8, 0x95, 0x27, 0xb1, 0x81, 0x9d, 0x6d, 0x0c, 233 | 0x08, 0x00, 0x00, 0xff, 0xff, 0xa6, 0x84, 0x2d, 0xb6, 0xc5, 0x00, 0x00, 0x00, 234 | } 235 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/hello/hello.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | // The greeting service definition. 4 | service Greeter { 5 | // Sends a greeting 6 | rpc SayHello (HelloRequest) returns (HelloReply) {} 7 | // Sends another greeting 8 | rpc SayHelloAgain (HelloRequest) returns (HelloReply) {} 9 | } 10 | 11 | // The request message containing the user's name. 12 | message HelloRequest { 13 | string name = 1; 14 | } 15 | 16 | // The response message containing the greetings 17 | message HelloReply { 18 | string message = 1; 19 | } 20 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/server/Dockerfile.server: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build-env 3 | RUN apk update 4 | RUN apk add git 5 | 6 | WORKDIR /go/src/github.com/smallstep/autocert/examples/hello-mtls/go-grpc 7 | ADD server/server.go . 8 | COPY hello hello 9 | RUN go get -d -v ./... 10 | RUN go build -o server 11 | 12 | # final stage 13 | FROM alpine 14 | COPY --from=build-env /go/src/github.com/smallstep/autocert/examples/hello-mtls/go-grpc/server . 15 | CMD ["./server"] 16 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/server/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-go-grpc:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/go-grpc/server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "net" 11 | "os" 12 | "sync" 13 | "time" 14 | 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/credentials" 17 | "google.golang.org/grpc/peer" 18 | 19 | "github.com/smallstep/autocert/examples/hello-mtls/go-grpc/hello" 20 | ) 21 | 22 | const ( 23 | autocertFile = "/var/run/autocert.step.sm/site.crt" 24 | autocertKey = "/var/run/autocert.step.sm/site.key" 25 | autocertRoot = "/var/run/autocert.step.sm/root.crt" 26 | tickFrequency = 15 * time.Second 27 | ) 28 | 29 | // Uses techniques from https://diogomonica.com/2017/01/11/hitless-tls-certificate-rotation-in-go/ 30 | // to automatically rotate certificates when they're renewed. 31 | 32 | type rotator struct { 33 | sync.RWMutex 34 | certificate *tls.Certificate 35 | } 36 | 37 | func (r *rotator) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { 38 | r.RLock() 39 | defer r.RUnlock() 40 | return r.certificate, nil 41 | } 42 | 43 | func (r *rotator) loadCertificate(certFile, keyFile string) error { 44 | r.Lock() 45 | defer r.Unlock() 46 | 47 | c, err := tls.LoadX509KeyPair(certFile, keyFile) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | r.certificate = &c 53 | 54 | return nil 55 | } 56 | 57 | func loadRootCertPool() (*x509.CertPool, error) { 58 | root, err := os.ReadFile(autocertRoot) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | pool := x509.NewCertPool() 64 | if ok := pool.AppendCertsFromPEM(root); !ok { 65 | return nil, errors.New("missing or invalid root certificate") 66 | } 67 | 68 | return pool, nil 69 | } 70 | 71 | // Greeter is a service that sends greetings. 72 | type Greeter struct{} 73 | 74 | // SayHello sends a greeting 75 | func (g *Greeter) SayHello(ctx context.Context, in *hello.HelloRequest) (*hello.HelloReply, error) { 76 | return &hello.HelloReply{Message: "Hello " + in.Name + " (" + getServerName(ctx) + ")"}, nil 77 | } 78 | 79 | // SayHelloAgain sends another greeting 80 | func (g *Greeter) SayHelloAgain(ctx context.Context, in *hello.HelloRequest) (*hello.HelloReply, error) { 81 | return &hello.HelloReply{Message: "Hello again " + in.Name + " (" + getServerName(ctx) + ")"}, nil 82 | } 83 | 84 | func getServerName(ctx context.Context) string { 85 | if p, ok := peer.FromContext(ctx); ok { 86 | if tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo); ok { 87 | return tlsInfo.State.ServerName 88 | } 89 | } 90 | return "unknown" 91 | } 92 | 93 | func main() { 94 | if err := run(); err != nil { 95 | log.Println(err) 96 | os.Exit(1) 97 | } 98 | } 99 | 100 | func run() error { 101 | roots, err := loadRootCertPool() 102 | if err != nil { 103 | return err 104 | } 105 | 106 | // Load certificate 107 | r := &rotator{} 108 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 109 | log.Fatal("error loading certificate and key", err) 110 | } 111 | tlsConfig := &tls.Config{ 112 | ClientAuth: tls.RequireAndVerifyClientCert, 113 | ClientCAs: roots, 114 | MinVersion: tls.VersionTLS12, 115 | CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, 116 | PreferServerCipherSuites: true, 117 | CipherSuites: []uint16{ 118 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 119 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 120 | }, 121 | GetCertificate: r.getCertificate, 122 | } 123 | 124 | // Schedule periodic re-load of certificate 125 | // A real implementation can use something like 126 | // https://github.com/fsnotify/fsnotify 127 | done := make(chan struct{}) 128 | go func() { 129 | ticker := time.NewTicker(tickFrequency) 130 | defer ticker.Stop() 131 | for { 132 | select { 133 | case <-ticker.C: 134 | fmt.Println("Checking for new certificate...") 135 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 136 | log.Println("Error loading certificate and key", err) 137 | } 138 | case <-done: 139 | return 140 | } 141 | } 142 | }() 143 | defer close(done) 144 | 145 | lis, err := net.Listen("tcp", "127.0.0.1:443") 146 | if err != nil { 147 | return fmt.Errorf("failed to listen: %w", err) 148 | } 149 | 150 | srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) 151 | hello.RegisterGreeterServer(srv, &Greeter{}) 152 | 153 | log.Println("Listening on :443") 154 | if err := srv.Serve(lis); err != nil { 155 | return fmt.Errorf("failed to serve: %w", err) 156 | } 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/client/Dockerfile.client: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build-env 3 | RUN mkdir /src 4 | ADD client.go /src 5 | RUN cd /src && go build -o client 6 | 7 | # final stage 8 | FROM alpine 9 | COPY --from=build-env /src/client . 10 | ENTRYPOINT ./client 11 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/client/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | const ( 18 | autocertFile = "/var/run/autocert.step.sm/site.crt" 19 | autocertKey = "/var/run/autocert.step.sm/site.key" 20 | autocertRoot = "/var/run/autocert.step.sm/root.crt" 21 | requestFrequency = 5 * time.Second 22 | tickFrequency = 15 * time.Second 23 | ) 24 | 25 | type rotator struct { 26 | sync.RWMutex 27 | certificate *tls.Certificate 28 | } 29 | 30 | func (r *rotator) getClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) { 31 | r.RLock() 32 | defer r.RUnlock() 33 | return r.certificate, nil 34 | } 35 | 36 | func (r *rotator) loadCertificate(certFile, keyFile string) error { 37 | r.Lock() 38 | defer r.Unlock() 39 | 40 | c, err := tls.LoadX509KeyPair(certFile, keyFile) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | r.certificate = &c 46 | 47 | return nil 48 | } 49 | 50 | func loadRootCertPool() (*x509.CertPool, error) { 51 | root, err := os.ReadFile(autocertRoot) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | pool := x509.NewCertPool() 57 | if ok := pool.AppendCertsFromPEM(root); !ok { 58 | return nil, errors.New("missing or invalid root certificate") 59 | } 60 | 61 | return pool, nil 62 | } 63 | 64 | func main() { 65 | if err := run(); err != nil { 66 | log.Println(err) 67 | os.Exit(1) 68 | } 69 | } 70 | 71 | func run() error { 72 | url := os.Getenv("HELLO_MTLS_URL") 73 | 74 | // Read the root certificate for our CA from disk 75 | roots, err := loadRootCertPool() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // Load certificate 81 | r := &rotator{} 82 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 83 | return fmt.Errorf("error loading certificate and key: %w", err) 84 | } 85 | 86 | // Create an HTTPS client using our cert, key & pool 87 | client := &http.Client{ 88 | Transport: &http.Transport{ 89 | TLSClientConfig: &tls.Config{ 90 | RootCAs: roots, 91 | MinVersion: tls.VersionTLS12, 92 | CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, 93 | CipherSuites: []uint16{ 94 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 95 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 96 | }, 97 | // GetClientCertificate is called when a server requests a 98 | // certificate from a client. 99 | // 100 | // In this example keep alives will cause the certificate to 101 | // only be called once, but if we disable them, 102 | // GetClientCertificate will be called on every request. 103 | GetClientCertificate: r.getClientCertificate, 104 | }, 105 | // Add this line to get the certificate on every request. 106 | // DisableKeepAlives: true, 107 | }, 108 | } 109 | 110 | // Schedule periodic re-load of certificate 111 | // A real implementation can use something like 112 | // https://github.com/fsnotify/fsnotify 113 | done := make(chan struct{}) 114 | go func() { 115 | ticker := time.NewTicker(tickFrequency) 116 | defer ticker.Stop() 117 | for { 118 | select { 119 | case <-ticker.C: 120 | fmt.Println("Checking for new certificate...") 121 | err := r.loadCertificate(autocertFile, autocertKey) 122 | if err != nil { 123 | log.Println("Error loading certificate and key", err) 124 | } 125 | case <-done: 126 | return 127 | } 128 | } 129 | }() 130 | defer close(done) 131 | 132 | for { 133 | // Make request 134 | r, err := client.Get(url) 135 | if err != nil { 136 | return err 137 | } 138 | defer r.Body.Close() //nolint:gocritic // false positive 139 | 140 | body, err := io.ReadAll(r.Body) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | fmt.Printf("%s: %s\n", time.Now().Format(time.RFC3339), strings.Trim(string(body), "\n")) 146 | 147 | time.Sleep(requestFrequency) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/client/hello-mtls.client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-mtls-client 5 | labels: {app: hello-mtls-client} 6 | spec: 7 | replicas: 1 8 | selector: {matchLabels: {app: hello-mtls-client}} 9 | template: 10 | metadata: 11 | annotations: 12 | autocert.step.sm/name: hello-mtls-client.default.pod.cluster.local 13 | labels: {app: hello-mtls-client} 14 | spec: 15 | containers: 16 | - name: hello-mtls-client 17 | image: hello-mtls-client-go:latest 18 | imagePullPolicy: Never 19 | resources: {requests: {cpu: 10m, memory: 20Mi}} 20 | env: 21 | - name: HELLO_MTLS_URL 22 | value: https://hello-mtls.default.svc.cluster.local 23 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/server/Dockerfile.server: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build-env 3 | RUN mkdir /src 4 | ADD server.go /src 5 | RUN cd /src && go build -o server 6 | 7 | # final stage 8 | FROM alpine 9 | COPY --from=build-env /src/server . 10 | ENTRYPOINT ./server 11 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/server/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-go:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/go/server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | const ( 16 | autocertFile = "/var/run/autocert.step.sm/site.crt" 17 | autocertKey = "/var/run/autocert.step.sm/site.key" 18 | autocertRoot = "/var/run/autocert.step.sm/root.crt" 19 | tickFrequency = 15 * time.Second 20 | ) 21 | 22 | // Uses techniques from https://diogomonica.com/2017/01/11/hitless-tls-certificate-rotation-in-go/ 23 | // to automatically rotate certificates when they're renewed. 24 | 25 | type rotator struct { 26 | sync.RWMutex 27 | certificate *tls.Certificate 28 | } 29 | 30 | func (r *rotator) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { 31 | r.RLock() 32 | defer r.RUnlock() 33 | return r.certificate, nil 34 | } 35 | 36 | func (r *rotator) loadCertificate(certFile, keyFile string) error { 37 | r.Lock() 38 | defer r.Unlock() 39 | 40 | c, err := tls.LoadX509KeyPair(certFile, keyFile) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | r.certificate = &c 46 | 47 | return nil 48 | } 49 | 50 | func loadRootCertPool() (*x509.CertPool, error) { 51 | root, err := os.ReadFile(autocertRoot) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | pool := x509.NewCertPool() 57 | if ok := pool.AppendCertsFromPEM(root); !ok { 58 | return nil, errors.New("missing or invalid root certificate") 59 | } 60 | 61 | return pool, nil 62 | } 63 | 64 | func main() { 65 | if err := run(); err != nil { 66 | log.Println(err) 67 | os.Exit(1) 68 | } 69 | } 70 | 71 | func run() error { 72 | mux := http.NewServeMux() 73 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 74 | if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { 75 | fmt.Fprintf(w, "Unauthenticated") 76 | } else { 77 | name := r.TLS.PeerCertificates[0].Subject.CommonName 78 | fmt.Fprintf(w, "Hello, %s!\n", name) 79 | } 80 | }) 81 | mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { 82 | fmt.Fprintf(w, "Ok\n") 83 | }) 84 | 85 | roots, err := loadRootCertPool() 86 | if err != nil { 87 | return err 88 | } 89 | 90 | r := &rotator{} 91 | cfg := &tls.Config{ 92 | ClientAuth: tls.RequireAndVerifyClientCert, 93 | ClientCAs: roots, 94 | MinVersion: tls.VersionTLS12, 95 | CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, 96 | PreferServerCipherSuites: true, 97 | CipherSuites: []uint16{ 98 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 99 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 100 | }, 101 | GetCertificate: r.getCertificate, 102 | } 103 | srv := &http.Server{ 104 | Addr: ":443", 105 | Handler: mux, 106 | TLSConfig: cfg, 107 | ReadHeaderTimeout: 30 * time.Second, 108 | } 109 | 110 | // Load certificate 111 | err = r.loadCertificate(autocertFile, autocertKey) 112 | if err != nil { 113 | return fmt.Errorf("error loading certificate and key: %w", err) 114 | } 115 | 116 | // Schedule periodic re-load of certificate 117 | // A real implementation can use something like 118 | // https://github.com/fsnotify/fsnotify 119 | done := make(chan struct{}) 120 | go func() { 121 | ticker := time.NewTicker(tickFrequency) 122 | defer ticker.Stop() 123 | for { 124 | select { 125 | case <-ticker.C: 126 | fmt.Println("Checking for new certificate...") 127 | if err := r.loadCertificate(autocertFile, autocertKey); err != nil { 128 | log.Println("Error loading certificate and key", err) 129 | } 130 | case <-done: 131 | return 132 | } 133 | } 134 | }() 135 | defer close(done) 136 | 137 | log.Println("Listening no :443") 138 | 139 | // Start serving HTTPS 140 | if err := srv.ListenAndServeTLS("", ""); err != nil { 141 | return fmt.Errorf("ListenAndServerTLS: %w", err) 142 | } 143 | 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /examples/hello-mtls/nginx/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | RUN apk add inotify-tools 4 | RUN mkdir /src 5 | ADD site.conf /etc/nginx/conf.d 6 | ADD certwatch.sh /src 7 | ADD entrypoint.sh /src 8 | 9 | # Certificate watcher and nginx 10 | ENTRYPOINT ["/src/entrypoint.sh"] 11 | CMD ["nginx", "-g", "daemon off;"] 12 | -------------------------------------------------------------------------------- /examples/hello-mtls/nginx/certwatch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while true; do 4 | inotifywait -e modify /var/run/autocert.step.sm/site.crt 5 | nginx -s reload 6 | done 7 | -------------------------------------------------------------------------------- /examples/hello-mtls/nginx/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # watch for the update of the cert and reload nginx 4 | /src/certwatch.sh & 5 | 6 | # Run docker CMD 7 | exec "$@" -------------------------------------------------------------------------------- /examples/hello-mtls/nginx/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-nginx:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/nginx/site.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | server_name localhost; 4 | ssl_protocols TLSv1.2; 5 | ssl_certificate /var/run/autocert.step.sm/site.crt; 6 | ssl_certificate_key /var/run/autocert.step.sm/site.key; 7 | ssl_client_certificate /var/run/autocert.step.sm/root.crt; 8 | ssl_verify_client on; 9 | ssl_prefer_server_ciphers on; 10 | ssl_ciphers ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256; 11 | 12 | location / { 13 | root /usr/share/nginx/html; 14 | index index.html index.htm; 15 | } 16 | } -------------------------------------------------------------------------------- /examples/hello-mtls/node/Dockerfile.client: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | RUN mkdir /src 4 | ADD client.js /src 5 | 6 | CMD ["node", "/src/client.js"] 7 | -------------------------------------------------------------------------------- /examples/hello-mtls/node/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | RUN mkdir /src 4 | ADD server.js /src 5 | 6 | CMD ["node", "/src/server.js"] 7 | -------------------------------------------------------------------------------- /examples/hello-mtls/node/client.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const https = require('https'); 3 | 4 | const config = { 5 | ca: '/var/run/autocert.step.sm/root.crt', 6 | key: '/var/run/autocert.step.sm/site.key', 7 | cert: '/var/run/autocert.step.sm/site.crt', 8 | url: process.env.HELLO_MTLS_URL, 9 | requestFrequency: 5000 10 | }; 11 | 12 | var options = { 13 | ca: fs.readFileSync(config.ca), 14 | key: fs.readFileSync(config.key), 15 | cert: fs.readFileSync(config.cert), 16 | ciphers: 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256', 17 | minVersion: 'TLSv1.2', 18 | maxVersion: 'TLSv1.2', 19 | // Not necessary as it defaults to true 20 | rejectUnauthorized: true 21 | }; 22 | 23 | fs.watch(config.cert, (event, filename) => { 24 | if (event == 'change') { 25 | options.cert = fs.readFileSync(config.cert); 26 | } 27 | }); 28 | 29 | function loop() { 30 | var req = https.request(config.url, options, function(res) { 31 | res.on('data', (data) => { 32 | process.stdout.write(options.cert) 33 | process.stdout.write(data) 34 | setTimeout(loop, config.requestFrequency); 35 | }); 36 | }); 37 | req.on('error', (e) => { 38 | process.stderr.write('error: ' + e.message + '\n'); 39 | setTimeout(loop, config.requestFrequency); 40 | }) 41 | req.end(); 42 | } 43 | 44 | loop(); 45 | -------------------------------------------------------------------------------- /examples/hello-mtls/node/hello-mtls.client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-mtls-client 5 | labels: {app: hello-mtls-client} 6 | spec: 7 | replicas: 1 8 | selector: {matchLabels: {app: hello-mtls-client}} 9 | template: 10 | metadata: 11 | annotations: 12 | autocert.step.sm/name: hello-mtls-client.default.pod.cluster.local 13 | labels: {app: hello-mtls-client} 14 | spec: 15 | containers: 16 | - name: hello-mtls-client 17 | image: hello-mtls-client-node:latest 18 | imagePullPolicy: Never 19 | resources: {requests: {cpu: 10m, memory: 20Mi}} 20 | env: 21 | - name: HELLO_MTLS_URL 22 | value: https://hello-mtls.default.svc.cluster.local 23 | -------------------------------------------------------------------------------- /examples/hello-mtls/node/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-node:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/node/server.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const tls = require('tls'); 3 | const fs = require('fs'); 4 | 5 | var config = { 6 | ca: '/var/run/autocert.step.sm/root.crt', 7 | key: '/var/run/autocert.step.sm/site.key', 8 | cert: '/var/run/autocert.step.sm/site.crt', 9 | ciphers: 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256', 10 | minVersion: 'TLSv1.2', 11 | maxVersion: 'TLSv1.2' 12 | }; 13 | 14 | function createSecureContext() { 15 | return tls.createSecureContext({ 16 | ca: fs.readFileSync(config.ca), 17 | key: fs.readFileSync(config.key), 18 | cert: fs.readFileSync(config.cert), 19 | ciphers: config.ciphers, 20 | }); 21 | } 22 | 23 | var ctx = createSecureContext() 24 | 25 | fs.watch(config.cert, (event, filename) => { 26 | if (event == 'change') { 27 | ctx = createSecureContext(); 28 | } 29 | }); 30 | 31 | https.createServer({ 32 | requestCert: true, 33 | rejectUnauthorized: true, 34 | SNICallback: (servername, cb) => { 35 | cb(null, ctx); 36 | } 37 | }, (req, res) => { 38 | res.writeHead(200); 39 | res.end('hello nodejs\n'); 40 | }).listen(443); 41 | 42 | console.log("Listening on :443 ..."); -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/Dockerfile.client: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | RUN mkdir /src 4 | 5 | ADD client.py /src 6 | ADD client.requirements.txt /src 7 | RUN pip3 install -r /src/client.requirements.txt 8 | 9 | CMD ["python", "/src/client.py"] 10 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | RUN mkdir /src 4 | 5 | # Gunicorn configuration 6 | ADD gunicorn.conf /src 7 | 8 | # Flask app 9 | ADD server.py /src 10 | ADD requirements.txt /src 11 | RUN pip3 install -r /src/requirements.txt 12 | 13 | # app, certificate watcher and envoy 14 | CMD ["gunicorn", "--config", "/src/gunicorn.conf", "--pythonpath", "/src", "server:app"] 15 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import ssl 5 | import signal 6 | import time 7 | import logging 8 | import threading 9 | import http.client 10 | from watchdog.events import FileSystemEventHandler 11 | from watchdog.observers import Observer 12 | from urllib.parse import urlparse 13 | 14 | ca_certs = '/var/run/autocert.step.sm/root.crt' 15 | cert_file = '/var/run/autocert.step.sm/site.crt' 16 | key_file = '/var/run/autocert.step.sm/site.key' 17 | 18 | # RenewHandler is an even file system event handler that reloads the certs in 19 | # the context when a file is modified. 20 | class RenewHandler(FileSystemEventHandler): 21 | def __init__(self, ctx): 22 | self.ctx = ctx 23 | super().__init__() 24 | 25 | def on_modified(self, event): 26 | logging.info("reloading certs ...") 27 | ctx.load_cert_chain(cert_file, key_file) 28 | 29 | # Monitor is a thread that watches for changes in a path and calls to the 30 | # RenewHandler when a file is modified. 31 | class Monitor(threading.Thread): 32 | def __init__(self, handler, path): 33 | super().__init__() 34 | self.handler = handler 35 | self.path = path 36 | 37 | def run(self): 38 | observer = Observer() 39 | observer.schedule(self.handler, self.path) 40 | observer.start() 41 | 42 | # Signal handler 43 | def handler(signum, frame): 44 | print("exiting ...") 45 | sys.exit(0) 46 | 47 | if __name__ == "__main__": 48 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s') 49 | 50 | # Start signal handler to exit 51 | signal.signal(signal.SIGTERM, handler) 52 | 53 | # url from the environment 54 | url = urlparse(os.environ['HELLO_MTLS_URL']) 55 | 56 | # ssl context 57 | ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 58 | ctx.set_ciphers('ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256') 59 | ctx.load_verify_locations(ca_certs) 60 | ctx.load_cert_chain(cert_file, key_file) 61 | 62 | # initialize the renewer with the ssl context 63 | renewer = RenewHandler(ctx) 64 | 65 | # start file monitor 66 | monitor = Monitor(renewer, os.path.dirname(cert_file)) 67 | monitor.start() 68 | 69 | # Do requests 70 | while True: 71 | try: 72 | conn = http.client.HTTPSConnection(url.netloc, context=ctx) 73 | conn.request("GET", url.path) 74 | r = conn.getresponse() 75 | data = r.read() 76 | logging.info("%d - %s - %s", r.status, r.reason, data) 77 | except Exception as err: 78 | print('Something went wrong:', err) 79 | time.sleep(5) 80 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/client.requirements.txt: -------------------------------------------------------------------------------- 1 | watchdog -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/gunicorn.conf: -------------------------------------------------------------------------------- 1 | bind = '0.0.0.0:443' 2 | workers = 2 3 | accesslog = '-' 4 | 5 | # mTLS configuration with TLSv1.2 and requiring and validating client 6 | # certificates 7 | ssl_version = 5 # ssl.PROTOCOL_TLSv1_2 8 | cert_reqs = 2 # ssl.CERT_REQUIRED 9 | ciphers = 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256' 10 | ca_certs = '/var/run/autocert.step.sm/root.crt' 11 | certfile = '/var/run/autocert.step.sm/site.crt' 12 | keyfile = '/var/run/autocert.step.sm/site.key' 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/hello-mtls.client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: hello-mtls-client 5 | labels: {app: hello-mtls-client} 6 | spec: 7 | replicas: 1 8 | selector: {matchLabels: {app: hello-mtls-client}} 9 | template: 10 | metadata: 11 | annotations: 12 | autocert.step.sm/name: hello-mtls-client.default.pod.cluster.local 13 | labels: {app: hello-mtls-client} 14 | spec: 15 | containers: 16 | - name: hello-mtls-client 17 | image: hello-mtls-client-py-gunicorn:latest 18 | imagePullPolicy: Never 19 | resources: {requests: {cpu: 10m, memory: 20Mi}} 20 | env: 21 | - name: HELLO_MTLS_URL 22 | value: https://hello-mtls.default.svc.cluster.local 23 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/hello-mtls.server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: {app: hello-mtls} 5 | name: hello-mtls 6 | spec: 7 | type: ClusterIP 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: {app: hello-mtls} 12 | 13 | --- 14 | 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: hello-mtls 19 | labels: {app: hello-mtls} 20 | spec: 21 | replicas: 1 22 | selector: {matchLabels: {app: hello-mtls}} 23 | template: 24 | metadata: 25 | annotations: 26 | autocert.step.sm/name: hello-mtls.default.svc.cluster.local 27 | labels: {app: hello-mtls} 28 | spec: 29 | containers: 30 | - name: hello-mtls 31 | image: hello-mtls-server-py-gunicorn:latest 32 | imagePullPolicy: Never 33 | resources: {requests: {cpu: 10m, memory: 20Mi}} 34 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | gunicorn 3 | -------------------------------------------------------------------------------- /examples/hello-mtls/py-gunicorn/server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | app = Flask(__name__) 3 | 4 | @app.route("/") 5 | def hello(): 6 | return "Hello World!\n" 7 | 8 | if __name__ == "__main__": 9 | app.run(host='127.0.0.1', port=8080, debug=False) 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/smallstep/autocert 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/golang/protobuf v1.5.4 9 | github.com/pkg/errors v0.9.1 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/smallstep/certificates v0.28.4 12 | github.com/smallstep/cli-utils v0.12.1 13 | go.step.sm/crypto v0.70.0 14 | golang.org/x/net v0.46.0 15 | google.golang.org/grpc v1.76.0 16 | k8s.io/api v0.35.0-alpha.0 17 | k8s.io/apimachinery v0.35.0-alpha.0 18 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 19 | sigs.k8s.io/yaml v1.6.0 20 | ) 21 | 22 | require ( 23 | cloud.google.com/go/auth v0.16.4 // indirect 24 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 25 | cloud.google.com/go/compute/metadata v0.8.0 // indirect 26 | dario.cat/mergo v1.0.1 // indirect 27 | filippo.io/edwards25519 v1.1.0 // indirect 28 | github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect 29 | github.com/Masterminds/goutils v1.1.1 // indirect 30 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 31 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/ccoveille/go-safecast v1.6.1 // indirect 34 | github.com/cespare/xxhash v1.1.0 // indirect 35 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 36 | github.com/chzyer/readline v1.5.1 // indirect 37 | github.com/coreos/go-oidc/v3 v3.14.1 // indirect 38 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 39 | github.com/dgraph-io/badger v1.6.2 // indirect 40 | github.com/dgraph-io/badger/v2 v2.2007.4 // indirect 41 | github.com/dgraph-io/ristretto v0.1.0 // indirect 42 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 43 | github.com/dustin/go-humanize v1.0.1 // indirect 44 | github.com/felixge/httpsnoop v1.0.4 // indirect 45 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 46 | github.com/go-chi/chi/v5 v5.2.2 // indirect 47 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 48 | github.com/go-jose/go-jose/v4 v4.1.2 // indirect 49 | github.com/go-logr/logr v1.4.3 // indirect 50 | github.com/go-logr/stdr v1.2.2 // indirect 51 | github.com/go-sql-driver/mysql v1.8.1 // indirect 52 | github.com/gogo/protobuf v1.3.2 // indirect 53 | github.com/golang/glog v1.2.5 // indirect 54 | github.com/golang/snappy v0.0.4 // indirect 55 | github.com/google/certificate-transparency-go v1.1.7 // indirect 56 | github.com/google/go-tpm v0.9.5 // indirect 57 | github.com/google/go-tspi v0.3.0 // indirect 58 | github.com/google/s2a-go v0.1.9 // indirect 59 | github.com/google/uuid v1.6.0 // indirect 60 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 61 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 62 | github.com/huandu/xstrings v1.5.0 // indirect 63 | github.com/jackc/pgpassfile v1.0.0 // indirect 64 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 65 | github.com/jackc/pgx/v5 v5.6.0 // indirect 66 | github.com/jackc/puddle/v2 v2.2.1 // indirect 67 | github.com/json-iterator/go v1.1.12 // indirect 68 | github.com/klauspost/compress v1.18.0 // indirect 69 | github.com/manifoldco/promptui v0.9.0 // indirect 70 | github.com/mattn/go-colorable v0.1.13 // indirect 71 | github.com/mattn/go-isatty v0.0.20 // indirect 72 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 73 | github.com/mitchellh/copystructure v1.2.0 // indirect 74 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 75 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 76 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 77 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 78 | github.com/newrelic/go-agent/v3 v3.39.0 // indirect 79 | github.com/prometheus/client_golang v1.22.0 // indirect 80 | github.com/prometheus/client_model v0.6.1 // indirect 81 | github.com/prometheus/common v0.62.0 // indirect 82 | github.com/prometheus/procfs v0.15.1 // indirect 83 | github.com/rs/xid v1.6.0 // indirect 84 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 85 | github.com/shopspring/decimal v1.4.0 // indirect 86 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 87 | github.com/slackhq/nebula v1.9.5 // indirect 88 | github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca // indirect 89 | github.com/smallstep/linkedca v0.23.0 // indirect 90 | github.com/smallstep/nosql v0.7.0 // indirect 91 | github.com/smallstep/pkcs7 v0.2.1 // indirect 92 | github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect 93 | github.com/spf13/cast v1.7.0 // indirect 94 | github.com/urfave/cli v1.22.17 // indirect 95 | github.com/x448/float16 v0.8.4 // indirect 96 | go.etcd.io/bbolt v1.3.10 // indirect 97 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 98 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 99 | go.opentelemetry.io/otel v1.37.0 // indirect 100 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 101 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 102 | go.yaml.in/yaml/v2 v2.4.2 // indirect 103 | golang.org/x/crypto v0.43.0 // indirect 104 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 105 | golang.org/x/oauth2 v0.30.0 // indirect 106 | golang.org/x/sync v0.17.0 // indirect 107 | golang.org/x/sys v0.37.0 // indirect 108 | golang.org/x/term v0.36.0 // indirect 109 | golang.org/x/text v0.30.0 // indirect 110 | google.golang.org/api v0.247.0 // indirect 111 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect 112 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect 113 | google.golang.org/protobuf v1.36.7 // indirect 114 | gopkg.in/inf.v0 v0.9.1 // indirect 115 | k8s.io/klog/v2 v2.130.1 // indirect 116 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 117 | sigs.k8s.io/randfill v1.0.0 // indirect 118 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 119 | ) 120 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallstep/autocert/6cc12d0370a297f987bdb4e5638c39f23438180c/icon.png -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.9.4 6 | 2019-05-24 18:12:06 +0000 7 | 8 | 9 | Canvas 3 10 | 11 | Layer 1 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /init/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM smallstep/step-cli:0.26.0 2 | 3 | ENV CA_NAME="Autocert" 4 | ENV CA_DNS="ca.step.svc.cluster.local,127.0.0.1" 5 | ENV CA_ADDRESS=":4443" 6 | ENV CA_DEFAULT_PROVISIONER="admin" 7 | ENV CA_URL="ca.step.svc.cluster.local" 8 | 9 | ENV KUBE_LATEST_VERSION="v1.30.0" 10 | 11 | ENV AUTO_START=false 12 | 13 | USER root 14 | RUN curl -L https://storage.googleapis.com/kubernetes-release/release/${KUBE_LATEST_VERSION}/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \ 15 | && chmod +x /usr/local/bin/kubectl 16 | RUN apk --update add expect 17 | 18 | COPY init/autocert.sh /home/step/ 19 | RUN chmod +x /home/step/autocert.sh 20 | CMD ["/home/step/autocert.sh"] 21 | -------------------------------------------------------------------------------- /init/autocert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #set -x 4 | 5 | echo "Welcome to Autocert configuration. Press return to begin." 6 | 7 | if [ "$AUTO_START" = false ] ; then 8 | read ANYKEY 9 | fi 10 | 11 | STEPPATH=/home/step 12 | 13 | CA_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 ; echo '') 14 | AUTOCERT_PASSWORD=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 ; echo '') 15 | 16 | echo -e "\e[1mChecking cluster permissions...\e[0m" 17 | 18 | function permission_error { 19 | # TODO: Figure out the actual service account instead of assuming default. 20 | echo 21 | echo -e "\033[0;31mPERMISSION ERROR\033[0m" 22 | echo "Set permissions by running the following command, then try again:" 23 | echo -e "\e[1m" 24 | echo " kubectl create clusterrolebinding autocert-init-binding \\" 25 | echo " --clusterrole cluster-admin \\" 26 | echo " --user \"system:serviceaccount:default:default\"" 27 | echo -e "\e[0m" 28 | echo "Once setup is complete you can remove this binding by running:" 29 | echo -e "\e[1m" 30 | echo " kubectl delete clusterrolebinding autocert-init-binding" 31 | echo -e "\e[0m" 32 | 33 | exit 1 34 | } 35 | 36 | echo -n "Checking for permission to create step namespace: " 37 | kubectl auth can-i create namespaces 38 | if [ $? -ne 0 ]; then 39 | permission_error "create step namespace" 40 | fi 41 | 42 | echo -n "Checking for permission to create configmaps in step namespace: " 43 | kubectl auth can-i create configmaps --namespace step 44 | if [ $? -ne 0 ]; then 45 | permission_error "create configmaps" 46 | fi 47 | 48 | echo -n "Checking for permission to create secrets in step namespace: " 49 | kubectl auth can-i create secrets --namespace step 50 | if [ $? -ne 0 ]; then 51 | permission_error "create secrets" 52 | fi 53 | 54 | echo -n "Checking for permission to create deployments in step namespace: " 55 | kubectl auth can-i create deployments --namespace step 56 | if [ $? -ne 0 ]; then 57 | permission_error "create deployments" 58 | fi 59 | 60 | echo -n "Checking for permission to create services in step namespace: " 61 | kubectl auth can-i create services --namespace step 62 | if [ $? -ne 0 ]; then 63 | permission_error "create services" 64 | fi 65 | 66 | echo -n "Checking for permission to create cluster role: " 67 | kubectl auth can-i create clusterrole 68 | if [ $? -ne 0 ]; then 69 | permission_error "create cluster roles" 70 | fi 71 | 72 | echo -n "Checking for permission to create cluster role binding:" 73 | kubectl auth can-i create clusterrolebinding 74 | if [ $? -ne 0 ]; then 75 | permission_error "create cluster role bindings" 76 | exit 1 77 | fi 78 | 79 | # Setting this here on purpose, after the above section which explicitly checks 80 | # for and handles exit errors. 81 | set -e 82 | 83 | step ca init \ 84 | --name "$CA_NAME" \ 85 | --dns "$CA_DNS" \ 86 | --address "$CA_ADDRESS" \ 87 | --provisioner "$CA_DEFAULT_PROVISIONER" \ 88 | --with-ca-url "$CA_URL" \ 89 | --password-file <(echo "$CA_PASSWORD") 90 | 91 | echo 92 | echo -e "\e[1mCreating autocert provisioner...\e[0m" 93 | 94 | step ca provisioner add autocert --create --password-file <(echo "${AUTOCERT_PASSWORD}") 95 | 96 | echo 97 | echo -e "\e[1mCreating step namespace and preparing environment...\e[0m" 98 | 99 | kubectl create namespace step 100 | 101 | kubectl -n step create configmap config --from-file $(step path)/config 102 | kubectl -n step create configmap certs --from-file $(step path)/certs 103 | kubectl -n step create configmap secrets --from-file $(step path)/secrets 104 | 105 | kubectl -n step create secret generic ca-password --from-literal "password=${CA_PASSWORD}" 106 | kubectl -n step create secret generic autocert-password --from-literal "password=${AUTOCERT_PASSWORD}" 107 | 108 | # Deploy CA and wait for rollout to complete 109 | echo 110 | echo -e "\e[1mDeploying certificate authority...\e[0m" 111 | 112 | kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/01-step-ca.yaml 113 | kubectl -n step rollout status deployment/ca 114 | 115 | # Deploy autocert, setup RBAC, and wait for rollout to complete 116 | echo 117 | echo -e "\e[1mDeploying autocert...\e[0m" 118 | 119 | kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/02-autocert.yaml 120 | kubectl apply -f https://raw.githubusercontent.com/smallstep/autocert/master/install/03-rbac.yaml 121 | kubectl -n step rollout status deployment/autocert 122 | 123 | # Some `base64`s wrap lines... no thanks! 124 | CA_BUNDLE=$(cat $(step path)/certs/root_ca.crt | base64 | tr -d '\n') 125 | 126 | cat <