From ecfaaacf383f50f95b40d893767df6b778edcfe7 Mon Sep 17 00:00:00 2001 From: "Jens-U. Mozdzen" Date: Sun, 3 Jul 2022 20:33:07 +0200 Subject: [PATCH] created webhook for Variomedia AG API (2019+) --- Makefile | 7 +- OWNERS | 16 +- README.md | 121 +++++- helm/.gitignore | 1 + .../.helmignore | 23 ++ .../Chart.yaml | 25 ++ .../templates/_helpers.tpl | 48 +++ .../templates/apiservice.yaml | 19 + .../templates/deployment.yaml | 76 ++++ .../templates/pki.yaml | 70 ++++ .../templates/rbac.yaml | 165 ++++++++ .../templates/service.yaml | 20 + .../values.yaml | 79 ++++ main.go | 262 +++++++++--- main_test.go | 19 +- testdata/my-custom-solver/.gitignore | 2 + testdata/my-custom-solver/README.md | 61 ++- testdata/my-custom-solver/config.json | 1 - testdata/my-custom-solver/config.json.sample | 5 + .../variomedia-credentials-01.yaml.sample | 7 + .../variomedia-credentials-02.yaml.sample | 7 + variomediaclient_api2019.go | 387 ++++++++++++++++++ 22 files changed, 1300 insertions(+), 121 deletions(-) create mode 100644 helm/.gitignore create mode 100644 helm/cert-manager-webhook-variomedia/.helmignore create mode 100644 helm/cert-manager-webhook-variomedia/Chart.yaml create mode 100644 helm/cert-manager-webhook-variomedia/templates/_helpers.tpl create mode 100644 helm/cert-manager-webhook-variomedia/templates/apiservice.yaml create mode 100644 helm/cert-manager-webhook-variomedia/templates/deployment.yaml create mode 100644 helm/cert-manager-webhook-variomedia/templates/pki.yaml create mode 100644 helm/cert-manager-webhook-variomedia/templates/rbac.yaml create mode 100644 helm/cert-manager-webhook-variomedia/templates/service.yaml create mode 100644 helm/cert-manager-webhook-variomedia/values.yaml create mode 100644 testdata/my-custom-solver/.gitignore delete mode 100644 testdata/my-custom-solver/config.json create mode 100644 testdata/my-custom-solver/config.json.sample create mode 100644 testdata/my-custom-solver/variomedia-credentials-01.yaml.sample create mode 100644 testdata/my-custom-solver/variomedia-credentials-02.yaml.sample create mode 100644 variomediaclient_api2019.go diff --git a/Makefile b/Makefile index 9c243ad..246b737 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ OS ?= $(shell go env GOOS) ARCH ?= $(shell go env GOARCH) -IMAGE_NAME := "webhook" +PROVIDER := "variomedia" +IMAGE_NAME := "${REGISTRY}cert-manager-webhook-${PROVIDER}" IMAGE_TAG := "latest" OUT := $(shell pwd)/_out @@ -35,7 +36,7 @@ build: .PHONY: rendered-manifest.yaml rendered-manifest.yaml: helm template \ - --name example-webhook \ + --name cert-manager-webhook-${PROVIDER} \ --set image.repository=$(IMAGE_NAME) \ --set image.tag=$(IMAGE_TAG) \ - deploy/example-webhook > "$(OUT)/rendered-manifest.yaml" + deploy/cert-manager-webhook-${PROVIDER} > "$(OUT)/rendered-manifest.yaml" diff --git a/OWNERS b/OWNERS index e863f5e..edd8ac8 100644 --- a/OWNERS +++ b/OWNERS @@ -1,16 +1,4 @@ approvers: -- munnerz -- joshvanl -- meyskens -- wallrj -- jakexks -- maelvls -- irbekrm +- jmozd reviewers: -- munnerz -- joshvanl -- meyskens -- wallrj -- jakexks -- maelvls -- irbekrm +- jmozd diff --git a/README.md b/README.md index 77ca07b..0754b85 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,43 @@ cert-manager project logo

-# ACME webhook example +# ACME webhook for Variomedia AG (post-2018 API) The ACME issuer type supports an optional 'webhook' solver, which can be used to implement custom DNS01 challenge solving logic. This is useful if you need to use cert-manager with a DNS provider that is not -officially supported in cert-manager core. +officially supported in cert-manager core. This project "cert-manager-webhook-variomedia" +implements a webhook to be used with Variomedia's new (post-2018) API for updating +DNS entries. Support for Variomedia's legacy API can be found in https://github.com/jheyduk/cert-manager-webhook-variomedia -## Why not in core? +You can find Variomedia's documentation on the "new" API in https://api.variomedia.de/docs/ +(German language only) - please don't confuse this with the legacy "Reseller API", see +https://api.variomedia.de/docs/legacy/ . -As the project & adoption has grown, there has been an influx of DNS provider -pull requests to our core codebase. As this number has grown, the test matrix -has become un-maintainable and so, it's not possible for us to certify that -providers work to a sufficient level. +# Security warning -By creating this 'interface' between cert-manager and DNS providers, we allow -users to quickly iterate and test out new integrations, and then packaging -those up themselves as 'extensions' to cert-manager. +The API keys provided by Variomedia are currently *not* restrictable to allow for +DNS updates only - if your key is compromised, *any* entry in your Variomedia customer +profile can be updated by the one having the key. -We can also then provide a standardised 'testing framework', or set of -conformance tests, which allow us to validate the a DNS provider works as -expected. +Also note that you are solely responsible for protecting access to not only the key, but also +to the running webhook: Anyone with access to the webhook will be able to update your DNS entries +at Variomedia, including adding malicious entries, overriding existing entries (even if not DNS-01-related) +and deleting existing entries (even if not DNS-01-related). -## Creating your own webhook +By using this software, you agree to not hold responsible the authors of this software +for *any* damange that may occur to you, directly or indirectly, and accept that the +authors of this software make no guarantees on the suitability of this software for any use. + +In other words: Use this software at your own risk. + +If you find security flaws in the implementation of this software, please report an +according issue at https://github.com/jmozd/cert-manager-webhook-variomedia/issue . + +## Development & building + +### Origins of the Variomedia webhook Webhook's themselves are deployed as Kubernetes API services, in order to allow administrators to restrict access to webhooks with Kubernetes RBAC. @@ -33,21 +46,30 @@ administrators to restrict access to webhooks with Kubernetes RBAC. This is important, as otherwise it'd be possible for anyone with access to your webhook to complete ACME challenge validations and obtain certificates. -To make the set up of these webhook's easier, we provide a template repository -that can be used to get started quickly. +The Variomedia AG webhook implementation is based on the example webhook provided +by the cert-manager project (https://github.com/cert-manager/webhook-example). ### Creating your own repository -### Running the test suite +The GitHub version of the Variomedia webhook implementation is focussed on providing +an implementation in a decentral container registry, i.e. "Harbor". The Docker image +is currently *not* published on docker.io. -All DNS providers **must** run the DNS01 provider conformance testing suite, -else they will have undetermined behaviour when used with cert-manager. +Once you have your registry up & running (which is not part of this README description), +you can build your local copy of the software using the following commands: -**It is essential that you configure and run the test suite when creating a +```bash +# to upload the container image to your registry +export REGISTRY='your.registry.company.com' +docker login $REGISTRY +make build +``` + +#### Running the test suite + +**It is essential that you configure and run the test suite after creating the DNS01 webhook.** -An example Go test file has been provided in [main_test.go](https://github.com/cert-manager/webhook-example/blob/master/main_test.go). - You can run the test suite with: ```bash @@ -56,3 +78,58 @@ $ TEST_ZONE_NAME=example.com. make test The example file has a number of areas you must fill in and replace with your own options in order for tests to pass. + +## Installation via Helm chart + +We have provided a Helm chart to ease the installation of the Variomedia webhook. + +## Configuration + +In addition to installing the webhook, you will also need to configure the according webhook and +cert-manager Issuer. + +Configuration of the webhook consists in providing the according secrets for each DNS domain you +intend to generate certificates for (via cert-manager and i.e. "Let's Encrypt!"). This is done by creating +a Kubernetes "secret" for each API key issued by Variomedia to you and then configuring the cert-manager +to reference each according secret per DNS domain handled by the Issuer: + +```bash +kubectl create secret generic variomedia-credentials-01 --from-literal=api-token='yourApiKeyGoesHere' +kubectl create secret generic variomedia-credentials-02 --from-literal=api-token='someOtherApiKeyGoesHere' +kubectl apply -f - << EOF + apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + metadata: + name: letsencrypt-staging + namespace: cert-manager + spec: + acme: + # The ACME test server URL + server: https://acme-staging-v02.api.letsencrypt.org/directory + # The ACME production server URL + #server: https://acme-v02.api.letsencrypt.org/directory + + # Email address used for ACME registration + email: yourEmailAsKnownToLE@company.com + + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-staging + solvers: + - dns01: + webhook: + groupName: acme.cert-manager-webhook-variomedia.local + solverName: variomedia + config: + example.com: variomedia-credentials-01 + someotherdomain.com: variomedia-credentials-01 + somethirddomain.com: variomedia-credentials-02 +EOF +``` + +Variomedia AG published a page describing how to obtain the according API key (the page is in German +only), basically stating that you can contact their support to have a key issued: +https://www.variomedia.de/faq/Wie-bekomme-ich-einen-API-Token/article/326 + +Please report any problems and errors you experience by using this webhook, via https://github.com/jmozd/cert-manager-webhook-variomedia/issues + diff --git a/helm/.gitignore b/helm/.gitignore new file mode 100644 index 0000000..b7610f9 --- /dev/null +++ b/helm/.gitignore @@ -0,0 +1 @@ +cert-manager-webhook-variomedia*.tgz diff --git a/helm/cert-manager-webhook-variomedia/.helmignore b/helm/cert-manager-webhook-variomedia/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm/cert-manager-webhook-variomedia/Chart.yaml b/helm/cert-manager-webhook-variomedia/Chart.yaml new file mode 100644 index 0000000..06ad8bd --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: cert-manager-webhook-variomedia +description: cert-manager extension for DNS-01 challenges via DNS provider "Variomedia AG" (https://www.variomedia.de). + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.9.1 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.1.0" + diff --git a/helm/cert-manager-webhook-variomedia/templates/_helpers.tpl b/helm/cert-manager-webhook-variomedia/templates/_helpers.tpl new file mode 100644 index 0000000..a0f3ea7 --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/_helpers.tpl @@ -0,0 +1,48 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "cert-manager-webhook-variomedia.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "cert-manager-webhook-variomedia.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "cert-manager-webhook-variomedia.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "cert-manager-webhook-variomedia.selfSignedIssuer" -}} +{{ printf "%s-selfsign" (include "cert-manager-webhook-variomedia.fullname" .) }} +{{- end -}} + +{{- define "cert-manager-webhook-variomedia.rootCAIssuer" -}} +{{ printf "%s-ca" (include "cert-manager-webhook-variomedia.fullname" .) }} +{{- end -}} + +{{- define "cert-manager-webhook-variomedia.rootCACertificate" -}} +{{ printf "%s-ca" (include "cert-manager-webhook-variomedia.fullname" .) }} +{{- end -}} + +{{- define "cert-manager-webhook-variomedia.servingCertificate" -}} +{{ printf "%s-webhook-tls" (include "cert-manager-webhook-variomedia.fullname" .) }} +{{- end -}} diff --git a/helm/cert-manager-webhook-variomedia/templates/apiservice.yaml b/helm/cert-manager-webhook-variomedia/templates/apiservice.yaml new file mode 100644 index 0000000..e7a4808 --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/apiservice.yaml @@ -0,0 +1,19 @@ +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.{{ .Values.groupName }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + cert-manager.io/inject-ca-from: "{{ .Values.certManager.namespace }}/{{ include "cert-manager-webhook-variomedia.servingCertificate" . }}" +spec: + group: {{ .Values.groupName }} + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} + version: v1alpha1 diff --git a/helm/cert-manager-webhook-variomedia/templates/deployment.yaml b/helm/cert-manager-webhook-variomedia/templates/deployment.yaml new file mode 100644 index 0000000..9a9860d --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/deployment.yaml @@ -0,0 +1,76 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + release: {{ .Release.Name }} + spec: + serviceAccountName: {{ include "cert-manager-webhook-variomedia.fullname" . }} + containers: + - name: {{ .Chart.Name }} +{{- if .Values.image.repository }} + image: "{{ .Values.image.repository }}/{{ .Values.image.image }}:{{ .Values.image.tag }}" +{{- else }} + image: "{{ .Values.image.image }}:{{ .Values.image.tag }}" +{{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --tls-cert-file=/tls/tls.crt + - --tls-private-key-file=/tls/tls.key +{{- if .Values.logLevel }} + - --v={{ .Values.logLevel }} +{{- end }} + env: + - name: GROUP_NAME + value: {{ .Values.groupName | quote }} + ports: + - name: https + containerPort: 443 + protocol: TCP + livenessProbe: + httpGet: + scheme: HTTPS + path: /healthz + port: https + readinessProbe: + httpGet: + scheme: HTTPS + path: /healthz + port: https + volumeMounts: + - name: certs + mountPath: /tls + readOnly: true + resources: +{{ toYaml .Values.resources | indent 12 }} + volumes: + - name: certs + secret: + secretName: {{ include "cert-manager-webhook-variomedia.servingCertificate" . }} + {{- with .Values.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} diff --git a/helm/cert-manager-webhook-variomedia/templates/pki.yaml b/helm/cert-manager-webhook-variomedia/templates/pki.yaml new file mode 100644 index 0000000..0423048 --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/pki.yaml @@ -0,0 +1,70 @@ +--- +# Create a selfsigned Issuer, in order to create a root CA certificate for +# signing webhook serving certificates +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "cert-manager-webhook-variomedia.selfSignedIssuer" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + selfSigned: {} +--- +# Generate a CA Certificate used to sign certificates for the webhook +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "cert-manager-webhook-variomedia.rootCACertificate" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + secretName: {{ include "cert-manager-webhook-variomedia.rootCACertificate" . }} + duration: 43800h # 5y + issuerRef: + name: {{ include "cert-manager-webhook-variomedia.selfSignedIssuer" . }} + commonName: "ca.cert-manager-webhook-variomedia.cert-manager" + isCA: true +--- +# Create an Issuer that uses the above generated CA certificate to issue certs +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "cert-manager-webhook-variomedia.rootCAIssuer" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ca: + secretName: {{ include "cert-manager-webhook-variomedia.rootCACertificate" . }} +--- +# Finally, generate a serving certificate for the webhook to use +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "cert-manager-webhook-variomedia.servingCertificate" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + secretName: {{ include "cert-manager-webhook-variomedia.servingCertificate" . }} + duration: 8760h # 1y + issuerRef: + name: {{ include "cert-manager-webhook-variomedia.rootCAIssuer" . }} + dnsNames: + - {{ include "cert-manager-webhook-variomedia.fullname" . }} + - {{ include "cert-manager-webhook-variomedia.fullname" . }}.{{ .Values.certManager.namespace }} + - {{ include "cert-manager-webhook-variomedia.fullname" . }}.{{ .Values.certManager.namespace }}.svc diff --git a/helm/cert-manager-webhook-variomedia/templates/rbac.yaml b/helm/cert-manager-webhook-variomedia/templates/rbac.yaml new file mode 100644 index 0000000..c09f8ac --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/rbac.yaml @@ -0,0 +1,165 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +--- +# Grant the webhook permission to read the ConfigMap containing the Kubernetes +# apiserver's requestheader-ca-certificate +# This ConfigMap is automatically created by the Kubernetes apiserver +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:webhook-authentication-reader + namespace: kube-system + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader +subjects: + - apiGroup: "" + kind: ServiceAccount + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} +--- +# apiserver gets the auth-delegator role to delegate auth decisions to +# the core apiserver +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:auth-delegator + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - apiGroup: "" + kind: ServiceAccount + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote}} +--- +# Grant cert-manager permission to validate using our apiserver +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:domain-solver + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +rules: + - apiGroups: + - {{ .Values.groupName }} + resources: + - "*" + verbs: + - "create" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:domain-solver + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:domain-solver +subjects: + - apiGroup: "" + kind: ServiceAccount + name: {{ .Values.certManager.serviceAccountName }} + namespace: {{ .Values.certManager.namespace | quote }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:secret-reader + namespace: {{ .Values.certManager.namespace | quote }} +rules: + - apiGroups: + - "" + resources: + - "secrets" + resourceNames: + - "variomedia-credentials" + verbs: + - "get" + - "watch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:secret-reader + namespace: {{ .Values.certManager.namespace | quote }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:secret-reader +subjects: + - apiGroup: "" + kind: ServiceAccount + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} +{{- if .Values.features.apiPriorityAndFairness }} +--- +# Grant cert-manager-webhook-variomedia permission to read the flow control mechanism (APF) +# API Priority and Fairness is enabled by default in Kubernetes 1.20 +# https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:flowcontrol-solver + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +rules: + - apiGroups: + - "flowcontrol.apiserver.k8s.io" + resources: + - "prioritylevelconfigurations" + - "flowschemas" + verbs: + - "list" + - "watch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:flowcontrol-solver + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "cert-manager-webhook-variomedia.fullname" . }}:flowcontrol-solver +subjects: + - apiGroup: "" + kind: ServiceAccount + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} +{{- end }} diff --git a/helm/cert-manager-webhook-variomedia/templates/service.yaml b/helm/cert-manager-webhook-variomedia/templates/service.yaml new file mode 100644 index 0000000..6ac3dcf --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cert-manager-webhook-variomedia.fullname" . }} + namespace: {{ .Values.certManager.namespace | quote }} + labels: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + chart: {{ include "cert-manager-webhook-variomedia.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: https + protocol: TCP + name: https + selector: + app: {{ include "cert-manager-webhook-variomedia.name" . }} + release: {{ .Release.Name }} diff --git a/helm/cert-manager-webhook-variomedia/values.yaml b/helm/cert-manager-webhook-variomedia/values.yaml new file mode 100644 index 0000000..c6bc286 --- /dev/null +++ b/helm/cert-manager-webhook-variomedia/values.yaml @@ -0,0 +1,79 @@ +# Default values for cert-manager-webhook-variomedia. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +certManager: + namespace: cert-manager + serviceAccountName: cert-manager + +groupName: acme.cert-manager-webhook-variomedia.local + +image: + repository: '' + image: cert-manager-webhook-variomedia + tag: "v1.1.0" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +logLevel: 6 + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 443 + +features: + apiPriorityAndFairness: false + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} + diff --git a/main.go b/main.go index 33937cb..34dd461 100644 --- a/main.go +++ b/main.go @@ -1,25 +1,54 @@ +// cert-manager webhook supporting Variomedia (https://api.variomedia.de) +// +// (c) 2022 NDE Netzdesign und -entwicklung AG, Hamburg, Germany +// +// Written by Jens-U. Mozdzen +// +// Licensed under Apache License 2.0 (see https://directory.fsf.org/wiki/License:Apache-2.0) +// +// Use at your own risk. As this code interacts with a paid services provider, the auther +// especially makes no claims regarding suitability of this software for your use case, nor +// that it will not cause any damage. Depending on your provider situation, using this software +// may or may not cause the use of billable services and may or may not lead to charges incurred +// to you by your service provider. +// Use at your own risk. + package main import ( "encoding/json" "fmt" "os" + "context" + "strings" extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - //"k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/klog/v2" "github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" "github.com/jetstack/cert-manager/pkg/acme/webhook/cmd" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var GroupName = os.Getenv("GROUP_NAME") +// our DNS entry URL cache: by client domain, by entry name, by key value +var DnsEntryURL map[string]map[string]map[string]string + +const ( + variomediaMinTtl = 300 // variomedia reports an error for values < this value +) func main() { + klog.V(4).Infof( "main() called") + if GroupName == "" { panic("GROUP_NAME must be specified") } + DnsEntryURL = make(map[string]map[string]map[string]string) + // This will register our custom DNS provider with the webhook serving // library, making it available as an API under the provided GroupName. // You can register multiple DNS provider implementations with a single @@ -28,6 +57,7 @@ func main() { cmd.RunWebhookServer(GroupName, &customDNSProviderSolver{}, ) + klog.V(4).Infof( "main() finished") } // customDNSProviderSolver implements the provider-specific logic needed to @@ -35,13 +65,7 @@ func main() { // To do so, it must implement the `github.com/jetstack/cert-manager/pkg/acme/webhook.Solver` // interface. type customDNSProviderSolver struct { - // If a Kubernetes 'clientset' is needed, you must: - // 1. uncomment the additional `client` field in this structure below - // 2. uncomment the "k8s.io/client-go/kubernetes" import at the top of the file - // 3. uncomment the relevant code in the Initialize method below - // 4. ensure your webhook's service account has the required RBAC role - // assigned to it for interacting with the Kubernetes APIs you need. - //client kubernetes.Clientset + client kubernetes.Clientset } // customDNSProviderConfig is a structure that is used to decode into when @@ -49,24 +73,7 @@ type customDNSProviderSolver struct { // This information is provided by cert-manager, and may be a reference to // additional configuration that's needed to solve the challenge for this // particular certificate or issuer. -// This typically includes references to Secret resources containing DNS -// provider credentials, in cases where a 'multi-tenant' DNS solver is being -// created. -// If you do *not* require per-issuer or per-certificate configuration to be -// provided to your webhook, you can skip decoding altogether in favour of -// using CLI flags or similar to provide configuration. -// You should not include sensitive information here. If credentials need to -// be used by your provider here, you should reference a Kubernetes Secret -// resource and fetch these credentials using a Kubernetes clientset. -type customDNSProviderConfig struct { - // Change the two fields below according to the format of the configuration - // to be decoded. - // These fields will be set by users in the - // `issuer.spec.acme.dns01.providers.webhook.config` field. - - //Email string `json:"email"` - //APIKeySecretRef v1alpha1.SecretKeySelector `json:"apiKeySecretRef"` -} +type customDNSProviderConfig map[string]string // Name is used as the name for this DNS solver when referencing it on the ACME // Issuer resource. @@ -75,36 +82,7 @@ type customDNSProviderConfig struct { // within a single webhook deployment**. // For example, `cloudflare` may be used as the name of a solver. func (c *customDNSProviderSolver) Name() string { - return "my-custom-solver" -} - -// Present is responsible for actually presenting the DNS record with the -// DNS provider. -// This method should tolerate being called multiple times with the same value. -// cert-manager itself will later perform a self check to ensure that the -// solver has correctly configured the DNS provider. -func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { - cfg, err := loadConfig(ch.Config) - if err != nil { - return err - } - - // TODO: do something more useful with the decoded configuration - fmt.Printf("Decoded configuration %v", cfg) - - // TODO: add code that sets a record in the DNS provider's console - return nil -} - -// CleanUp should delete the relevant TXT record from the DNS provider console. -// If multiple TXT records exist with the same record name (e.g. -// _acme-challenge.example.com) then **only** the record with the same `key` -// value provided on the ChallengeRequest should be cleaned up. -// This is in order to facilitate multiple DNS validations for the same domain -// concurrently. -func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { - // TODO: add code that deletes a record from the DNS provider's console - return nil + return "variomedia-APIv2019" } // Initialize will be called when the webhook first starts. @@ -117,23 +95,116 @@ func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { // The stopCh can be used to handle early termination of the webhook, in cases // where a SIGTERM or similar signal is sent to the webhook process. func (c *customDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { - ///// UNCOMMENT THE BELOW CODE TO MAKE A KUBERNETES CLIENTSET AVAILABLE TO - ///// YOUR CUSTOM DNS PROVIDER + klog.V(4).Infof( "Initialize() called") + klog.V(5).InfoS("parameters", "config", kubeClientConfig) - //cl, err := kubernetes.NewForConfig(kubeClientConfig) - //if err != nil { - // return err - //} - // - //c.client = cl + cl, err := kubernetes.NewForConfig(kubeClientConfig) + if err != nil { + return err + } - ///// END OF CODE TO MAKE KUBERNETES CLIENTSET AVAILABLE + if DnsEntryURL == nil { + DnsEntryURL = make(map[string]map[string]map[string]string) + } + + c.client = *cl + + klog.V(4).Infof( "Initialize() finished") + return nil +} + +// Present is responsible for actually presenting the DNS record with the +// DNS provider. +// This method should tolerate being called multiple times with the same value. +// cert-manager itself will later perform a self check to ensure that the +// solver has correctly configured the DNS provider. +func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { + klog.V(4).InfoS( "Present() called") + klog.V(5).InfoS("parameters", "challenge", ch) + + cfg, err := c.loadApiKeys(ch.Config, ch.ResourceNamespace) + if err != nil { + klog.V(2).ErrorS( err, "Present() finished with error while loading API keys") + return err + } + klog.V(6).Infof("decoded configuration %v", cfg) + + entry, domain, apiKey, err := c.getDomainAndEntryAndApiKey( ch, &cfg) + if err != nil { + klog.V(2).ErrorS( err, "Present() finished with error while determining domain and entry name") + return fmt.Errorf("unable to get domain key for zone %s: %v", ch.ResolvedZone, err) + } + klog.V(4).InfoS( "present", "entry", entry, "domain", domain, "entry", entry, "API key", apiKey) + + variomediaClient := NewvariomediaClient(apiKey) + + url, err := variomediaClient.UpdateTxtRecord(&domain, &entry, &ch.Key, variomediaMinTtl) + if err != nil { + klog.V(2).ErrorS( err, "Present() finished with error while trying to update the DNS record") + return fmt.Errorf("unable to change TXT record: %v", err) + } + + // update our cache map... making sure each level of map exists + if _, ok := DnsEntryURL[ domain]; !ok { + DnsEntryURL[ domain] = make( map[string]map[string]string) + } + if _, ok := DnsEntryURL[ domain][ entry]; !ok { + DnsEntryURL[ domain][ entry] = make( map[string]string) + } + DnsEntryURL[ domain][ entry][ ch.Key] = url + klog.V(5).InfoS( "updated DNS entry cache", "cache", DnsEntryURL) + + klog.V(4).InfoS( "Present() finished") + return nil +} + +// CleanUp should delete the relevant TXT record from the DNS provider console. +// If multiple TXT records exist with the same record name (e.g. +// _acme-challenge.example.com) then **only** the record with the same `key` +// value provided on the ChallengeRequest should be cleaned up. +// This is in order to facilitate multiple DNS validations for the same domain +// concurrently. +func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { + klog.V(4).InfoS( "CleanUp() called") + klog.V(5).InfoS("parameters", "challenge", ch) + + cfg, err := c.loadApiKeys(ch.Config, ch.ResourceNamespace) + if err != nil { + klog.V(2).ErrorS( err, "CleanUp() finished with error while loading API keys") + return err + } + klog.V(6).Infof("decoded configuration %v", cfg) + + entry, domain, apiKey, err := c.getDomainAndEntryAndApiKey( ch, &cfg) + if err != nil { + klog.V(2).ErrorS( err, "CleanUp() finished with error while determining domain and entry name") + return fmt.Errorf("unable to get domain key for zone %s: %v", ch.ResolvedZone, err) + } + klog.V(4).InfoS( "clean up", "entry", entry, "domain", domain, "entry", entry, "API key", apiKey) + + variomediaClient := NewvariomediaClient(apiKey) + + url := DnsEntryURL[ domain][ entry][ ch.Key] + + err = variomediaClient.DeleteTxtRecord( url, variomediaMinTtl) + if err != nil { + klog.V(2).ErrorS( err, "CleanUp() finished with error while trying to delete the DNS record") + return fmt.Errorf("unable to delete TXT record: %v", err) + } + + // DNS entry deleted - so we delete our cache entry + delete( DnsEntryURL[ domain][ entry], ch.Key) + klog.V(5).InfoS( "updated DNS entry cache", "cache", DnsEntryURL) + + klog.V(4).InfoS( "CleanUp() finished") return nil } // loadConfig is a small helper function that decodes JSON configuration into // the typed config struct. func loadConfig(cfgJSON *extapi.JSON) (customDNSProviderConfig, error) { + klog.V(4).InfoS( "loadConfig() called") + cfg := customDNSProviderConfig{} // handle the 'base case' where no configuration has been provided if cfgJSON == nil { @@ -143,5 +214,66 @@ func loadConfig(cfgJSON *extapi.JSON) (customDNSProviderConfig, error) { return cfg, fmt.Errorf("error decoding solver config: %v", err) } + klog.V(4).InfoS( "LoadConfig() finished") + klog.V(5).InfoS("return values", "configuration", cfg) return cfg, nil } + +// loadApiKeys is a small helper function that takes the decoded JSON configuration +// and extracts the according keys. +// It's called as a wrapper to loadConfig() +func (c *customDNSProviderSolver) loadApiKeys(cfgJSON *extapi.JSON, namespace string) ( customDNSProviderConfig, error) { + klog.V(4).InfoS( "loadApiKeys() called") + klog.V(5).InfoS("parameters", "config", cfgJSON, "namespace", namespace) + + // retrieve configuration block from Kubernetes Issuer config + cfg, err := loadConfig( cfgJSON) + + // no config? Abort. + if err != nil { + return cfg, err + } + + for domain, secretName := range cfg { + klog.V(6).Infof("try to load secret `%s` with key `%s`", secretName, "api-token") + sec, err := c.client.CoreV1().Secrets(namespace).Get(context.Background(), secretName, metav1.GetOptions{}) + if err != nil { + klog.V(2).ErrorS( err, "loadApiKeys() finished with error") + return nil, fmt.Errorf("unable to get secret `%s`; %v", secretName, err) + } + + secBytes, ok := sec.Data["api-token"] + if !ok { + klog.V(2).ErrorS( err, "loadApiKeys() finished with error") + return nil, fmt.Errorf("key %q not found in secret \"%s/%s\"", "api-token", + secretName, namespace) + } + // replace name of secret with value of apiKey - and trim blanks and newlines + cfg[domain] = strings.TrimRight( string(secBytes), "\r\n ") + klog.V(6).InfoS( "stored API key", "domain", domain, "API key", cfg[domain]) + } + + klog.V(4).InfoS( "loadApiKeys() finished") + return cfg, nil +} + +// determine the appropriate domain, according API key and the actual entry to make +func (c *customDNSProviderSolver) getDomainAndEntryAndApiKey(ch *v1alpha1.ChallengeRequest, cfg *customDNSProviderConfig) (string, string, string, error) { + klog.V(4).InfoS( "getDomainAndEntryAndApiKey() called") + klog.V(5).InfoS("parameters", "challenge", ch, "provider config", cfg) + + // Both ch.ResolvedZone and ch.ResolvedFQDN end with a dot: '.' + entry := strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone) + entry = strings.TrimSuffix(entry, ".") + domain := strings.TrimSuffix(ch.ResolvedZone, ".") + apiKey, ok := (*cfg)[domain] + if !ok { + klog.V(2).ErrorS( fmt.Errorf("domain '%s' not found in config.", domain), "getDomainAndEntryAndApiKey() finished with error") + return entry, domain, apiKey, fmt.Errorf("domain '%s' not found in config.", domain) + } + + klog.V(4).InfoS( "getDomainAndEntryAndApiKey() finished") + klog.V(5).InfoS("return values", "entry", entry, "domain", domain, "API key", apiKey) + return entry, domain, apiKey, nil +} + diff --git a/main_test.go b/main_test.go index f81a15e..2e12aba 100644 --- a/main_test.go +++ b/main_test.go @@ -5,8 +5,6 @@ import ( "testing" "github.com/jetstack/cert-manager/test/acme/dns" - - "github.com/cert-manager/webhook-example/example" ) var ( @@ -17,21 +15,12 @@ func TestRunsSuite(t *testing.T) { // The manifest path should contain a file named config.json that is a // snippet of valid configuration that should be included on the // ChallengeRequest passed as part of the test cases. - // - // Uncomment the below fixture when implementing your custom DNS provider - //fixture := dns.NewFixture(&customDNSProviderSolver{}, - // dns.SetResolvedZone(zone), - // dns.SetAllowAmbientCredentials(false), - // dns.SetManifestPath("testdata/my-custom-solver"), - // dns.SetBinariesPath("_test/kubebuilder/bin"), - //) - solver := example.New("59351") - fixture := dns.NewFixture(solver, - dns.SetResolvedZone("example.com."), + fixture := dns.NewFixture(&customDNSProviderSolver{}, + dns.SetResolvedZone(zone), + dns.SetAllowAmbientCredentials(false), dns.SetManifestPath("testdata/my-custom-solver"), - dns.SetDNSServer("127.0.0.1:59351"), - dns.SetUseAuthoritative(false), + // dns.SetBinariesPath("_test/kubebuilder/bin"), ) //need to uncomment and RunConformance delete runBasic and runExtended once https://github.com/cert-manager/cert-manager/pull/4835 is merged //fixture.RunConformance(t) diff --git a/testdata/my-custom-solver/.gitignore b/testdata/my-custom-solver/.gitignore new file mode 100644 index 0000000..289edc2 --- /dev/null +++ b/testdata/my-custom-solver/.gitignore @@ -0,0 +1,2 @@ +config.json +variomedia-credentials-*.yaml diff --git a/testdata/my-custom-solver/README.md b/testdata/my-custom-solver/README.md index feb4cbd..b36e164 100644 --- a/testdata/my-custom-solver/README.md +++ b/testdata/my-custom-solver/README.md @@ -1,3 +1,62 @@ # Solver testdata directory -TODO +The cert-manager team has provided an "integrated" testing mechanism with their +"example" implementation of the web hook. It can be called via the provided +"makefile" and needs configuration data so the connection to Variomedia can be +tested live. The according config data goes into this directory. + +At least two files need to be provided: + +- a config file, defining the web hook config. A sample version of the file + is provided, you can rename "config.json.sample" to "config.json" and adjust + the definition provided, to point to your Variomedia domain(s) and according + secret(s). + +- a secret's manifest prviding the API key under the secret name you configured + in your config.json. Two files "variomedia-credentials-0[12].yaml.sample" are + provided, you can rename these to "*.yaml" to create secrets matching the sample + config in config.json + +The sample file are configured to run three domains using two API keys - of course, +the keys contained in the sample secrets are *not* live Variomedia secrets and +will cause the tests to fail. + +## creating your own config + +The content of "config.json" represents the part of the later "Issuer" configuration +and as the name implies, needs to be in JSON format. + +If you are the owner of a domain "myvariomediadomain.com" and intend to provide the +according API key in a secret called "variomedia-secret", the JSON file needs to +look like +``` +{ + "myvariomediadomain.com": "variomedia-secret" +} +``` + +## creating your own secret + +The secret manifest will be used by the testing code to create the mandatory +secrets containing the encoded Variomedia API keys. While the file names can +be of your choice (keep the extension ".yaml", though), the name of the secret +needs to match the name given in config.json +You can create the according base64-encoded string via + +``` +# echo -n "YourVariomediaAPIKeyGoesHere" | base64 +WW91clZhcmlvbWVkaWFBUElLZXlHb2VzSGVyZQ== +``` + +The implementation of this web hook is removing any trailing blanks, new-lines +and carriage-returns. Therefore, you can also use the following call to create +the base64-encoded string: + +``` +# base64 <<< "YourVariomediaAPIKeyGoesHere" +WW91clZhcmlvbWVkaWFBUElLZXlHb2VzSGVyZQo= +``` + + +config.json config.json.sample README.md variomedia-credentials-01.yaml.sample variomedia-credentials-02.yaml.sample variomedia-credentials-ndeag.yaml + diff --git a/testdata/my-custom-solver/config.json b/testdata/my-custom-solver/config.json deleted file mode 100644 index 0967ef4..0000000 --- a/testdata/my-custom-solver/config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/testdata/my-custom-solver/config.json.sample b/testdata/my-custom-solver/config.json.sample new file mode 100644 index 0000000..6caa8b2 --- /dev/null +++ b/testdata/my-custom-solver/config.json.sample @@ -0,0 +1,5 @@ +{ + "example.com": "variomedia-credentials-01", + "someotherdomain.com": "variomedia-credentials-01", + "somethirddomain.com": "variomedia-credentials-02" +} diff --git a/testdata/my-custom-solver/variomedia-credentials-01.yaml.sample b/testdata/my-custom-solver/variomedia-credentials-01.yaml.sample new file mode 100644 index 0000000..949c6dc --- /dev/null +++ b/testdata/my-custom-solver/variomedia-credentials-01.yaml.sample @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: variomedia-credentials-01 +type: Opaque +data: + api-token: dmFyaW8ta2V5LTEK diff --git a/testdata/my-custom-solver/variomedia-credentials-02.yaml.sample b/testdata/my-custom-solver/variomedia-credentials-02.yaml.sample new file mode 100644 index 0000000..f3ff8be --- /dev/null +++ b/testdata/my-custom-solver/variomedia-credentials-02.yaml.sample @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: variomedia-credentials-02 +type: Opaque +data: + api-token: dmFyaW8ta2V5LTIK diff --git a/variomediaclient_api2019.go b/variomediaclient_api2019.go new file mode 100644 index 0000000..46e86de --- /dev/null +++ b/variomediaclient_api2019.go @@ -0,0 +1,387 @@ +// client implementation for Variomedia API, 2019+ version (https://api.variomedia.de/docs/) +// +// Written by Jens-Uwe Mozdzen +// +// Licensed under LGPL v3 +// +// Entry points: +// client = NewvariomediaClient( apikey) +// - create new instance of API client +// in: +// apikey - customer-specific API key issued by Variomedia +// returns: +// client object +// +// +// client.UpdateTxtRecord(&domain, &entry, Key, ttl) +// - update TXT record +// in: +// domain - DNS domain +// entry - host label +// key - valus of TXT record +// ttl - TTL of record +// returns: +// variomediaDNSEntryURL - the URL of the resulting DNS entry +// +// client.DeleteTxtRecord(&domain, &entry) +// - delete TXT record for entry/domain +// in: +// url - DNS entry's URL +// ttl - TTL of record +// returns: +// - + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "k8s.io/klog/v2" +) + +const ( + variomediaLiveDnsBaseUrl = "https://api.variomedia.de/dns-records" + statusLookupDelay = 2 * time.Second +) + +type variomediaClient struct { + apiKey string +} + +type variomediaDnsAttributes struct { + RecordType string `json:"record_type"` + Name string `json:"name"` + Domain string `json:"domain"` + Data string `json:"data"` + Ttl int `json:"ttl"` +} // variomediaDnsAttributes + +type variomediaData struct { + RequestType string `json:"type"` + RequestAttr variomediaDnsAttributes `json:"attributes"` +} // variomediaData + +type variomediaRequest struct { + variomediaData `json:"data"` +} // variomediaRequest + +type variomediaJobData struct { + Type string `json:"type"` + Id string `json:"id"` + Attributes map[string]string `json:"attributes"` + Links map[string]string `json:"links"` +} // variomediaJobData + +type variomediaResponse struct { + Data variomediaJobData `json:"data"` + Links map[string]string `json:"links"` +} // variomediaRequest + +// NewvariomediaClient() +// create new instance of Variomedia client +func NewvariomediaClient(apiKey string) *variomediaClient { + klog.V(4).InfoS("NewvariomediaClient() called") + klog.V(5).InfoS("parameters", "API key", apiKey) + + klog.V(4).InfoS("NewvariomediaClient() finished") + return &variomediaClient{ + apiKey: apiKey, + } +} + +// client.UpdateTxtRecord(&domain, &entry, Key, ttl) +// - create or update TXT record +// in: +// domain - DNS domain +// entry - host label +// key - valus of TXT record +// ttl - TTL of record +// returns: +// variomediaDNSEntryURL - the URL of the resulting DNS entry +func (c *variomediaClient) UpdateTxtRecord(domain *string, name *string, value *string, ttl int) (string, error) { + klog.V(4).InfoS("UpdateTxtRecord() called") + klog.V(5).InfoS("parameters", "domain", *domain, "name", *name, "value", *value, "TTL", ttl) + + var reqData variomediaRequest + + reqData = variomediaRequest{ variomediaData{ + RequestType: "dns-record", + RequestAttr: variomediaDnsAttributes{ + RecordType: "TXT", + Name: *name, + Domain: *domain, + Data: *value, + Ttl: ttl, + }, + }, + } + + // the actual request is encoded in JSON + body, err := json.Marshal( reqData) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("cannot marshall to json: %v", err) + } + + req, err := http.NewRequest("POST", variomediaLiveDnsBaseUrl, bytes.NewReader(body)) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", err + } + + // contact Variomedia and check the results + status, respData, err := c.doRequest(req, true) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", err + } + + // have we hit the rate limit? + if status == http.StatusTooManyRequests { + klog.V(2).ErrorS( fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests), "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests) + } + + if status != http.StatusCreated && status != http.StatusOK && status != http.StatusAccepted { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("failed creating TXT record: %v", err) + } + + // the request has succeeded - but is the job already finished? + // check the response for an according '' element + var reply variomediaResponse + err = json.Unmarshal( respData, &reply) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("cannot unmarshall response to json: %v", err) + } + klog.V(5).InfoS( "HTTP finished", "JSON reply", reply) + + // we give it 5 iterations to finish + loopcount := 5 + for { + if reply.Data.Attributes[ "status"] == "pending" { + klog.V(2).InfoS( "DNS job still pending") + + // inter-loop delay + time.Sleep( statusLookupDelay) + + // re-fetch the job status + req, err := http.NewRequest("GET", reply.Data.Links[ "queue-job"], nil) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", err + } + + // contact Variomedia and check the results + status, respData, err := c.doRequest(req, true) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", err + } + + // have we hit the rate limit? + if status == http.StatusTooManyRequests { + klog.V(2).ErrorS(fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests), "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests) + } + + if status != http.StatusCreated && status != http.StatusOK && status != http.StatusAccepted { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("failed creating TXT record: %v", err) + } + + // the request has succeeded - but is the job already finished? + // check the response for an according '' element + err = json.Unmarshal( respData, &reply) + if err != nil { + klog.V(2).ErrorS(err, "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("cannot unmarshall response to json: %v", err) + } + klog.V(5).InfoS( "HTTP finished", "JSON reply", reply) + + } + loopcount -= 1 + + if (reply.Data.Attributes[ "status"] == "done") { + klog.V(2).InfoS( "DNS job finished", "retries left", loopcount) + break; + } + if (loopcount == 0) { + klog.V(2).ErrorS(fmt.Errorf("DNS update job timed out with most recent status '%s'", reply.Data.Attributes[ "status"]), "UpdateTxtRecord() finished with error") + return "", fmt.Errorf("DNS update job timed out with most recent status '%s'", reply.Data.Attributes[ "status"]) + } + } // emulated do until + + klog.V(4).InfoS("UpdateTxtRecord() finished") + klog.V(5).InfoS("return values", "url", reply.Data.Links[ "dns-record"]) + return reply.Data.Links[ "dns-record"], nil +} //func UpdateTxtRecord() + +// client.DeleteTxtRecord(&domain, &entry, Key, ttl) +// - create or update TXT record +// in: +// url - DNS entry's URL +// ttl - TTL of record +// returns: +// - +func (c *variomediaClient) DeleteTxtRecord(url string, ttl int) error { + klog.V(4).InfoS("DeleteTxtRecord() called") + klog.V(5).InfoS("parameters", "url", url, "TTL", ttl) + + // deleting a record happens by sending a HTTP "DELETE" request to the DNS entry's URL + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return err + } + + // contact Variomedia and check the results + status, respData, err := c.doRequest(req, true) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return err + } + + // have we hit the rate limit? + if status == http.StatusTooManyRequests { + klog.V(2).ErrorS(fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests), "DeleteTxtRecord() finished with error") + return fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests) + } + + if status != http.StatusCreated && status != http.StatusOK && status != http.StatusAccepted { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return fmt.Errorf("failed deleting TXT record: %v", err) + } + + // the request has succeeded - but is the job already finished? + // check the response for an according '' element + var reply variomediaResponse + err = json.Unmarshal( respData, &reply) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return fmt.Errorf("cannot unmarshall response to json: %v", err) + } + klog.V(5).InfoS( "HTTP finished", "JSON reply", reply) + + // we give it 5 iterations to finish + loopcount := 5 + for { + if reply.Data.Attributes[ "status"] == "pending" && status != http.StatusNotFound { + klog.V(2).InfoS( "DNS job still pending") + + // inter-loop delay: two seconds + time.Sleep( statusLookupDelay) + + // re-fetch the job status + req, err := http.NewRequest("GET", reply.Data.Links[ "queue-job"], nil) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return err + } + + // contact Variomedia and check the results + status, respData, err := c.doRequest(req, true) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return err + } + + // have we hit the rate limit? + if status == http.StatusTooManyRequests { + klog.V(2).ErrorS(fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests), "DeleteTxtRecord() finished with error") + return fmt.Errorf("Variomedia rate limit reached (HTTP code %d)", http.StatusTooManyRequests) + } + + if status != http.StatusCreated && status != http.StatusOK && status != http.StatusAccepted && status != http.StatusNotFound { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return fmt.Errorf("failed creating TXT record: %v", err) + } + + // the request has succeeded - but is the job already finished? + // check the response for an according '' element + err = json.Unmarshal( respData, &reply) + if err != nil { + klog.V(2).ErrorS(err, "DeleteTxtRecord() finished with error") + return fmt.Errorf("cannot unmarshall response to json: %v", err) + } + klog.V(5).InfoS( "HTTP finished", "JSON reply", reply) + + } + + // 404 means "DNS record not found" (anymore) - we're fine with that, the record is gone + if status == http.StatusNotFound { + klog.V(4).InfoS("DeleteTxtRecord() finished because DNS record is gone", "retries left", loopcount) + return nil + } + + loopcount -= 1 + + if (reply.Data.Attributes[ "status"] == "done") { + klog.V(2).InfoS( "DNS job finished", "retries left", loopcount) + break; + } + if (loopcount == 0) { + klog.V(2).ErrorS(fmt.Errorf("DNS update job timed out with most recent status '%s'", reply.Data.Attributes[ "status"]), "DeleteTxtRecord() finished with error") + return fmt.Errorf("DNS update job timed out with most recent status '%s'", reply.Data.Attributes[ "status"]) + } + } // emulated do until + + klog.V(4).InfoS("DeleteTxtRecord() finished") + return nil +} // func DeleteTxtRecord() + + +func (c *variomediaClient) variomediaRecordsUrl(domain string) string { + klog.V(4).InfoS("variomediaRecordsUrl() called") + klog.V(5).InfoS("parameters", "domain", domain) + + urlpart := fmt.Sprintf("%s/dns-records", domain) + + klog.V(4).InfoS("variomediaRecordsUrl() finished") + klog.V(5).InfoS("return values", "url part", urlpart) + return urlpart +} + +func (c *variomediaClient) doRequest(req *http.Request, readResponseBody bool) (int, []byte, error) { + klog.V(4).InfoS("doRequest() called") + klog.V(5).InfoS("parameters", "request", req, "readResponseBody", readResponseBody) + + // Variomedia uses headers for auth, request content type and to signal accepted API versions + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.apiKey)) + req.Header.Set("Content-Type", "application/vnd.api+json") + req.Header.Set("Accept", "application/vnd.variomedia.v1+json") + + client := http.Client{ + Timeout: 30 * time.Second, + } + + res, err := client.Do(req) + if err != nil { + klog.V(2).ErrorS(err, "doRequest() finished with error") + return 0, nil, err + } + + klog.V(5).InfoS( "HTTP request", "response", res) + + // check for proper returns + if (res.StatusCode == http.StatusOK || res.StatusCode == http.StatusAccepted) && readResponseBody { + data, err := ioutil.ReadAll(res.Body) + if err != nil { + klog.V(2).ErrorS(err, "HTTP request finished with error") + return 0, nil, err + } + klog.V(4).InfoS( "HTTP request succeeded", "status code", res.StatusCode) + klog.V(5).InfoS( "HTTP request result", "data", data) + return res.StatusCode, data, nil + } + + klog.V(4).InfoS("doRequest() finished") + klog.V(5).InfoS("return values", "status code", res.StatusCode) + return res.StatusCode, nil, nil +}