Implemented first prototype

This commit is contained in:
Vinzenz Stadtmueller 2021-07-31 20:22:51 +02:00
parent ca744fc511
commit f86c74c385
27 changed files with 474 additions and 511 deletions

View file

@ -21,4 +21,4 @@ RUN apk add --no-cache ca-certificates
COPY --from=build /workspace/webhook /usr/local/bin/webhook
ENTRYPOINT ["webhook"]
ENTRYPOINT ["webhook"]

View file

@ -1,8 +1,8 @@
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
IMAGE_NAME := "webhook"
IMAGE_TAG := "latest"
IMAGE_NAME := vstadtmueller/cert-manager-webhook-powerdns
IMAGE_TAG := latest
OUT := $(shell pwd)/_out
@ -32,7 +32,6 @@ build:
.PHONY: rendered-manifest.yaml
rendered-manifest.yaml:
helm template \
--name example-webhook \
--set image.repository=$(IMAGE_NAME) \
--set image.tag=$(IMAGE_TAG) \
deploy/example-webhook > "$(OUT)/rendered-manifest.yaml"
deploy/cert-manager-webhook-powerndns > "$(OUT)/rendered-manifest.yaml"

132
README.md
View file

@ -1,38 +1,109 @@
# ACME webhook example
# ACME webhook for PowerDNS API
The ACME issuer type supports an optional 'webhook' solver, which can be used
to implement custom DNS01 challenge solving logic.
This solver can be used when you want to use cert-manager with PowerDNS HTTP API. API documentation is [here](https://doc.powerdns.com/authoritative/http-api/#)
This is useful if you need to use cert-manager with a DNS provider that is not
officially supported in cert-manager core.
## Requirements
- [go](https://golang.org/) >= 1.13.0
- [helm](https://helm.sh/) >= v3.0.0
- [kubernetes](https://kubernetes.io/) >= v1.14.0
- [cert-manager](https://cert-manager.io/) >= 0.12.0
## Why not in core?
## Installation
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.
### cert-manager
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.
Follow the [instructions](https://cert-manager.io/docs/installation/) using the cert-manager documentation to install it within your cluster.
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.
### Webhook
## Creating your own webhook
#### Using public helm chart
```bash
helm repo add cert-manager-webhook-powerdns https://lordofsystem.github.io/cert-manager-webhook-powerdns
# Replace the groupName value with your desired domain
helm install --namespace cert-manager cert-manager-webhook-powerdns cert-manager-webhook-powerdns/cert-manager-webhook-powerdns --set groupName=acme.yourdomain.tld
```
Webhook's themselves are deployed as Kubernetes API services, in order to allow
administrators to restrict access to webhooks with Kubernetes RBAC.
#### From local checkout
This is important, as otherwise it'd be possible for anyone with access to your
webhook to complete ACME challenge validations and obtain certificates.
```bash
helm install --namespace cert-manager cert-manager-webhook-powerdns deploy/cert-manager-webhook-powerdns
```
**Note**: The kubernetes resources used to install the Webhook should be deployed within the same namespace as the cert-manager.
To make the set up of these webhook's easier, we provide a template repository
that can be used to get started quickly.
To uninstall the webhook run
```bash
helm uninstall --namespace cert-manager cert-manager-webhook-powerdns
```
### Creating your own repository
## Issuer
Create a `ClusterIssuer` or `Issuer` resource as following:
```yaml
apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
# The ACME server URL
server: https://acme-staging-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: mail@example.com # REPLACE THIS WITH YOUR EMAIL!!!
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- dns01:
webhook:
# This group needs to be configured when installing the helm package, otherwise the webhook won't have permission to create an ACME challenge for this API group.
groupName: acme.yourdomain.tld
solverName: pdns
config:
secretName: powerdns-secret
zoneName: example.com.
apiUrl: https://powerndns.com
```
### Credentials
In order to access the HTTP API, the webhook needs an API token.
If you choose another name for the secret than `powerdns-secret`, ensure you modify the value of `secretName` in the `[Cluster]Issuer`.
The secret for the example above will look like this:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: pdns-secret
type: Opaque
data:
api-key: your-key-base64-encoded
```
### Create a certificate
Finally you can create certificates, for example:
```yaml
apiVersion: cert-manager.io/v1alpha2
kind: Certificate
metadata:
name: example-cert
namespace: cert-manager
spec:
commonName: example.com
dnsNames:
- example.com
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
secretName: example-cert
```
## Development
### Running the test suite
@ -42,13 +113,14 @@ else they will have undetermined behaviour when used with cert-manager.
**It is essential that you configure and run the test suite when creating a
DNS01 webhook.**
An example Go test file has been provided in [main_test.go](https://github.com/jetstack/cert-manager-webhook-example/blob/master/main_test.go).
You need to replace `zoneName` parameter at `testdata/pdns/config.json` file with actual one.
You also must encode your api token into base64 and put the hash into `testdata/pdns/pdns-secret.yml` file.
You can run the test suite with:
You can then run the test suite with:
```bash
$ TEST_ZONE_NAME=example.com. make test
# first install necessary binaries (only required once)
./scripts/fetch-test-binaries.sh
# then run the tests
TEST_ZONE_NAME=example.com. make verify
```
The example file has a number of areas you must fill in and replace with your
own options in order for tests to pass.

View file

@ -1,5 +1,5 @@
apiVersion: v1
appVersion: "1.0"
description: A Helm chart for Kubernetes
name: example-webhook
name: cert-manager-webhook-powerdns
version: 0.1.0

View file

@ -2,7 +2,7 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "example-webhook.name" -}}
{{- define "cert-manager-webhook-powerdns.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
@ -11,7 +11,7 @@ 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 "example-webhook.fullname" -}}
{{- define "cert-manager-webhook-powerdns.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
@ -27,22 +27,22 @@ If release name contains chart name it will be used as a full name.
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "example-webhook.chart" -}}
{{- define "cert-manager-webhook-powerdns.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "example-webhook.selfSignedIssuer" -}}
{{ printf "%s-selfsign" (include "example-webhook.fullname" .) }}
{{- define "cert-manager-webhook-powerdns.selfSignedIssuer" -}}
{{ printf "%s-selfsign" (include "cert-manager-webhook-powerdns.fullname" .) }}
{{- end -}}
{{- define "example-webhook.rootCAIssuer" -}}
{{ printf "%s-ca" (include "example-webhook.fullname" .) }}
{{- define "cert-manager-webhook-powerdns.rootCAIssuer" -}}
{{ printf "%s-ca" (include "cert-manager-webhook-powerdns.fullname" .) }}
{{- end -}}
{{- define "example-webhook.rootCACertificate" -}}
{{ printf "%s-ca" (include "example-webhook.fullname" .) }}
{{- define "cert-manager-webhook-powerdns.rootCACertificate" -}}
{{ printf "%s-ca" (include "cert-manager-webhook-powerdns.fullname" .) }}
{{- end -}}
{{- define "example-webhook.servingCertificate" -}}
{{ printf "%s-webhook-tls" (include "example-webhook.fullname" .) }}
{{- define "cert-manager-webhook-powerdns.servingCertificate" -}}
{{ printf "%s-webhook-tls" (include "cert-manager-webhook-powerdns.fullname" .) }}
{{- end -}}

View file

@ -3,17 +3,17 @@ kind: APIService
metadata:
name: v1alpha1.{{ .Values.groupName }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
annotations:
cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "example-webhook.servingCertificate" . }}"
cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "cert-manager-webhook-powerdns.servingCertificate" . }}"
spec:
group: {{ .Values.groupName }}
groupPriorityMinimum: 1000
versionPriority: 15
service:
name: {{ include "example-webhook.fullname" . }}
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}
namespace: {{ .Release.Namespace }}
version: v1alpha1

View file

@ -1,25 +1,25 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "example-webhook.fullname" . }}
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ include "example-webhook.name" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ include "example-webhook.name" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
release: {{ .Release.Name }}
spec:
serviceAccountName: {{ include "example-webhook.fullname" . }}
serviceAccountName: {{ include "cert-manager-webhook-powerdns.fullname" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
@ -53,7 +53,7 @@ spec:
volumes:
- name: certs
secret:
secretName: {{ include "example-webhook.servingCertificate" . }}
secretName: {{ include "cert-manager-webhook-powerdns.servingCertificate" . }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{ toYaml . | indent 8 }}

View file

@ -0,0 +1,76 @@
---
# 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-powerdns.selfSignedIssuer" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.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-powerdns.rootCACertificate" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
secretName: {{ include "cert-manager-webhook-powerdns.rootCACertificate" . }}
duration: 43800h # 5y
issuerRef:
name: {{ include "cert-manager-webhook-powerdns.selfSignedIssuer" . }}
commonName: "ca.cert-manager-webhook-powerdns.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-powerdns.rootCAIssuer" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
ca:
secretName: {{ include "cert-manager-webhook-powerdns.rootCACertificate" . }}
---
# Finally, generate a serving certificate for the webhook to use
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "cert-manager-webhook-powerdns.servingCertificate" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
secretName: {{ include "cert-manager-webhook-powerdns.servingCertificate" . }}
duration: 8760h # 1y
issuerRef:
name: {{ include "cert-manager-webhook-powerdns.rootCAIssuer" . }}
dnsNames:
- {{ include "cert-manager-webhook-powerdns.fullname" . }}
- {{ include "cert-manager-webhook-powerdns.fullname" . }}.{{ .Release.Namespace }}
- {{ include "cert-manager-webhook-powerdns.fullname" . }}.{{ .Release.Namespace }}.svc

View file

@ -0,0 +1,123 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.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-powerdns.fullname" . }}:webhook-authentication-reader
namespace: kube-system
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.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-powerdns.fullname" . }}
namespace: {{ .Release.Namespace }}
---
# 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-powerdns.fullname" . }}:auth-delegator
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.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-powerdns.fullname" . }}
namespace: {{ .Release.Namespace }}
---
# Grant cert-manager permission to validate using our apiserver
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}:domain-solver
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.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-powerdns.fullname" . }}:domain-solver
labels:
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}:domain-solver
subjects:
- apiGroup: ""
kind: ServiceAccount
name: {{ .Values.certManager.serviceAccountName }}
namespace: {{ .Values.certManager.namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}:secret-reader
namespace: {{ .Release.Namespace }}
rules:
- apiGroups:
- ""
resources:
- "secrets"
{{- with .Values.secretName }}
resourceNames:
{{ toYaml . | indent 4 }}
{{- end }}
verbs:
- "get"
- "watch"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}:secret-reader
namespace: {{ .Release.Namespace }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}:secret-reader
subjects:
- apiGroup: ""
kind: ServiceAccount
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}
namespace: {{ .Release.Namespace }}

View file

@ -1,10 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "example-webhook.fullname" . }}
name: {{ include "cert-manager-webhook-powerdns.fullname" . }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
chart: {{ include "cert-manager-webhook-powerdns.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
@ -15,5 +15,5 @@ spec:
protocol: TCP
name: https
selector:
app: {{ include "example-webhook.name" . }}
app: {{ include "cert-manager-webhook-powerdns.name" . }}
release: {{ .Release.Name }}

View file

@ -6,15 +6,15 @@
# solve the DNS01 challenge.
# This group name should be **unique**, hence using your own company's domain
# here is recommended.
groupName: acme.mycompany.com
groupName: acme.stadtmueller.at
certManager:
namespace: cert-manager
serviceAccountName: cert-manager
image:
repository: mycompany/webhook-image
tag: latest
repository: vstadtmueller/cert-manager-webhook-powerdns
tag: dev
pullPolicy: IfNotPresent
nameOverride: ""
@ -24,6 +24,9 @@ service:
type: ClusterIP
port: 443
secretName:
- pdns-secret
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

View file

@ -1,76 +0,0 @@
---
# 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 "example-webhook.selfSignedIssuer" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.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 "example-webhook.rootCACertificate" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
secretName: {{ include "example-webhook.rootCACertificate" . }}
duration: 43800h # 5y
issuerRef:
name: {{ include "example-webhook.selfSignedIssuer" . }}
commonName: "ca.example-webhook.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 "example-webhook.rootCAIssuer" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
ca:
secretName: {{ include "example-webhook.rootCACertificate" . }}
---
# Finally, generate a serving certificate for the webhook to use
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "example-webhook.servingCertificate" . }}
namespace: {{ .Release.Namespace | quote }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
secretName: {{ include "example-webhook.servingCertificate" . }}
duration: 8760h # 1y
issuerRef:
name: {{ include "example-webhook.rootCAIssuer" . }}
dnsNames:
- {{ include "example-webhook.fullname" . }}
- {{ include "example-webhook.fullname" . }}.{{ .Release.Namespace }}
- {{ include "example-webhook.fullname" . }}.{{ .Release.Namespace }}.svc

View file

@ -1,90 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "example-webhook.fullname" . }}
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.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 "example-webhook.fullname" . }}:webhook-authentication-reader
namespace: kube-system
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.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 "example-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
---
# 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 "example-webhook.fullname" . }}:auth-delegator
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.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 "example-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
---
# Grant cert-manager permission to validate using our apiserver
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "example-webhook.fullname" . }}:domain-solver
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.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 "example-webhook.fullname" . }}:domain-solver
labels:
app: {{ include "example-webhook.name" . }}
chart: {{ include "example-webhook.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "example-webhook.fullname" . }}:domain-solver
subjects:
- apiGroup: ""
kind: ServiceAccount
name: {{ .Values.certManager.serviceAccountName }}
namespace: {{ .Values.certManager.namespace }}

View file

@ -1,69 +0,0 @@
package example
import (
"fmt"
"github.com/miekg/dns"
)
func (e *exampleSolver) handleDNSRequest(w dns.ResponseWriter, req *dns.Msg) {
msg := new(dns.Msg)
msg.SetReply(req)
switch req.Opcode {
case dns.OpcodeQuery:
for _, q := range msg.Question {
if err := e.addDNSAnswer(q, msg, req); err != nil {
msg.SetRcode(req, dns.RcodeServerFailure)
break
}
}
}
w.WriteMsg(msg)
}
func (e *exampleSolver) addDNSAnswer(q dns.Question, msg *dns.Msg, req *dns.Msg) error {
switch q.Qtype {
// Always return loopback for any A query
case dns.TypeA:
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN A 127.0.0.1", q.Name))
if err != nil {
return err
}
msg.Answer = append(msg.Answer, rr)
return nil
// TXT records are the only important record for ACME dns-01 challenges
case dns.TypeTXT:
e.RLock()
record, found := e.txtRecords[q.Name]
e.RUnlock()
if !found {
msg.SetRcode(req, dns.RcodeNameError)
return nil
}
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN TXT %s", q.Name, record))
if err != nil {
return err
}
msg.Answer = append(msg.Answer, rr)
return nil
// NS and SOA are for authoritative lookups, return obviously invalid data
case dns.TypeNS:
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN NS ns.example-acme-webook.invalid.", q.Name))
if err != nil {
return err
}
msg.Answer = append(msg.Answer, rr)
return nil
case dns.TypeSOA:
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN SOA %s 20 5 5 5 5", "ns.example-acme-webook.invalid.", "ns.example-acme-webook.invalid."))
if err != nil {
return err
}
msg.Answer = append(msg.Answer, rr)
return nil
default:
return fmt.Errorf("unimplemented record type %v", q.Qtype)
}
}

View file

@ -1,68 +0,0 @@
// package example contains a self-contained example of a webhook that passes the cert-manager
// DNS conformance tests
package example
import (
"fmt"
"os"
"sync"
"github.com/jetstack/cert-manager/pkg/acme/webhook"
acme "github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
"github.com/miekg/dns"
"k8s.io/client-go/rest"
)
type exampleSolver struct {
name string
server *dns.Server
txtRecords map[string]string
sync.RWMutex
}
func (e *exampleSolver) Name() string {
return e.name
}
func (e *exampleSolver) Present(ch *acme.ChallengeRequest) error {
e.Lock()
e.txtRecords[ch.ResolvedFQDN] = ch.Key
e.Unlock()
return nil
}
func (e *exampleSolver) CleanUp(ch *acme.ChallengeRequest) error {
e.Lock()
delete(e.txtRecords, ch.ResolvedFQDN)
e.Unlock()
return nil
}
func (e *exampleSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error {
go func(done <-chan struct{}) {
<-done
if err := e.server.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
}
}(stopCh)
go func() {
if err := e.server.ListenAndServe(); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
os.Exit(1)
}
}()
return nil
}
func New(port string) webhook.Solver {
e := &exampleSolver{
name: "example",
txtRecords: make(map[string]string),
}
e.server = &dns.Server{
Addr: ":" + port,
Net: "udp",
Handler: dns.HandlerFunc(e.handleDNSRequest),
}
return e
}

View file

@ -1,96 +0,0 @@
package example
import (
"crypto/rand"
"math/big"
"testing"
acme "github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
func TestExampleSolver_Name(t *testing.T) {
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
port = port.Add(port, big.NewInt(15534))
solver := New(port.String())
assert.Equal(t, "example", solver.Name())
}
func TestExampleSolver_Initialize(t *testing.T) {
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
port = port.Add(port, big.NewInt(15534))
solver := New(port.String())
done := make(chan struct{})
err := solver.Initialize(nil, done)
assert.NoError(t, err, "Expected Initialize not to error")
close(done)
}
func TestExampleSolver_Present_Cleanup(t *testing.T) {
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
port = port.Add(port, big.NewInt(15534))
solver := New(port.String())
done := make(chan struct{})
err := solver.Initialize(nil, done)
assert.NoError(t, err, "Expected Initialize not to error")
validTestData := []struct {
hostname string
record string
}{
{"test1.example.com.", "testkey1"},
{"test2.example.com.", "testkey2"},
{"test3.example.com.", "testkey3"},
}
for _, test := range validTestData {
err := solver.Present(&acme.ChallengeRequest{
Action: acme.ChallengeActionPresent,
Type: "dns-01",
ResolvedFQDN: test.hostname,
Key: test.record,
})
assert.NoError(t, err, "Unexpected error while presenting %v", t)
}
// Resolve test data
for _, test := range validTestData {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.RecursionDesired = true
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{dns.Fqdn(test.hostname), dns.TypeTXT, dns.ClassINET}
in, err := dns.Exchange(msg, "127.0.0.1:"+port.String())
assert.NoError(t, err, "Presented record %s not resolvable", test.hostname)
assert.Len(t, in.Answer, 1, "RR response is of incorrect length")
assert.Equal(t, []string{test.record}, in.Answer[0].(*dns.TXT).Txt, "TXT record returned did not match presented record")
}
// Cleanup test data
for _, test := range validTestData {
err := solver.CleanUp(&acme.ChallengeRequest{
Action: acme.ChallengeActionCleanUp,
Type: "dns-01",
ResolvedFQDN: test.hostname,
Key: test.record,
})
assert.NoError(t, err, "Unexpected error while cleaning up %v", t)
}
// Resolve test data
for _, test := range validTestData {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.RecursionDesired = true
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{dns.Fqdn(test.hostname), dns.TypeTXT, dns.ClassINET}
in, err := dns.Exchange(msg, "127.0.0.1:"+port.String())
assert.NoError(t, err, "Presented record %s not resolvable", test.hostname)
assert.Len(t, in.Answer, 0, "RR response is of incorrect length")
assert.Equal(t, dns.RcodeNameError, in.Rcode, "Expexted NXDOMAIN")
}
close(done)
}

5
go.mod
View file

@ -1,11 +1,14 @@
module github.com/cert-manager/webhook-example
module github.com/lordofsystem/cert-manager-webhook-powerdns
go 1.13
require (
github.com/jetstack/cert-manager v1.2.0
github.com/joeig/go-powerdns/v2 v2.4.1
github.com/miekg/dns v1.1.31
github.com/stretchr/testify v1.6.1
k8s.io/apiextensions-apiserver v0.19.0
k8s.io/apimachinery v0.19.0
k8s.io/client-go v0.19.0
k8s.io/klog v1.0.0
)

3
go.sum
View file

@ -348,10 +348,13 @@ github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
github.com/jetstack/cert-manager v1.2.0 h1:xgXGdvHxGwCFjB13rCQ/fwa4A7FMpPRewa3wiW++EP4=
github.com/jetstack/cert-manager v1.2.0/go.mod h1:maDZ7RUO9H6RB+/ks9XBe8jf9zdC8cI0dGY3HBLzTVQ=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joeig/go-powerdns/v2 v2.4.1 h1:bo360+v9N/cDz+fCFqH7axbmkHWd4amjRFWoe0/7ahA=
github.com/joeig/go-powerdns/v2 v2.4.1/go.mod h1:VgLq0WK8knYT+c6RcD5dB/L3LUvUXHNnGZp/nmSwJBk=
github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=

View file

@ -1,5 +1,5 @@
package internal
type Config struct {
ApiKey, ZoneName, ApiUrl string
ApiKey, ZoneName, ServerName, ApiUrl string
}

124
main.go
View file

@ -1,16 +1,24 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
//"k8s.io/client-go/kubernetes"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/klog"
"github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
"github.com/jetstack/cert-manager/pkg/acme/webhook/cmd"
"github.com/lordofsystem/cert-manager-webhook-powerdns/internal"
"github.com/joeig/go-powerdns/v2"
)
var GroupName = os.Getenv("GROUP_NAME")
@ -26,25 +34,26 @@ func main() {
// webhook, where the Name() method will be used to disambiguate between
// the different implementations.
cmd.RunWebhookServer(GroupName,
&customDNSProviderSolver{},
&pdnsDNSProviderSolver{},
)
}
// customDNSProviderSolver implements the provider-specific logic needed to
// pdnsDNSProviderSolver implements the provider-specific logic needed to
// 'present' an ACME challenge TXT record for your own DNS provider.
// To do so, it must implement the `github.com/jetstack/cert-manager/pkg/acme/webhook.Solver`
// interface.
type customDNSProviderSolver struct {
type pdnsDNSProviderSolver 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
// pdnsDNSProviderConfig is a structure that is used to decode into when
// solving a DNS01 challenge.
// This information is provided by cert-manager, and may be a reference to
// additional configuration that's needed to solve the challenge for this
@ -58,7 +67,7 @@ type customDNSProviderSolver struct {
// 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 {
type pdnsDNSProviderConfig 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
@ -66,6 +75,10 @@ type customDNSProviderConfig struct {
//Email string `json:"email"`
//APIKeySecretRef v1alpha1.SecretKeySelector `json:"apiKeySecretRef"`
SecretRef string `json:"secretName"`
ZoneName string `json:"zoneName"`
ServerName string `json:"server"`
ApiUrl string `json:"apiUrl"`
}
// Name is used as the name for this DNS solver when referencing it on the ACME
@ -74,8 +87,8 @@ type customDNSProviderConfig struct {
// solvers configured with the same Name() **so long as they do not co-exist
// 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"
func (c *pdnsDNSProviderSolver) Name() string {
return "pdns"
}
// Present is responsible for actually presenting the DNS record with the
@ -83,16 +96,25 @@ func (c *customDNSProviderSolver) Name() string {
// 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)
func (c *pdnsDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
klog.Infof("call function Present: namespace=%s, zone=%s, fqdn=%s", ch.ResourceNamespace, ch.ResolvedZone, ch.ResolvedFQDN)
config, err := clientConfig(c, ch)
key := fmt.Sprintf("\"%s\"", ch.Key)
if err != nil {
return err
klog.Errorf("unable to get secret `%s`; %v", ch.ResourceNamespace, err)
}
// TODO: do something more useful with the decoded configuration
fmt.Printf("Decoded configuration %v", cfg)
pdns := powerdns.NewClient(config.ApiUrl, config.ServerName, map[string]string{"X-API-Key": config.ApiKey}, nil)
p_err := pdns.Records.Add(config.ZoneName, ch.ResolvedFQDN, powerdns.RRTypeTXT, 120, []string{key})
if p_err != nil {
klog.Errorf("Pdns client error: %v", p_err)
}
klog.Infof("Presented txt record %v", ch.ResolvedFQDN)
// TODO: add code that sets a record in the DNS provider's console
return nil
}
@ -102,8 +124,22 @@ func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
// 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 {
func (c *pdnsDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
// TODO: add code that deletes a record from the DNS provider's console
config, err := clientConfig(c, ch)
if err != nil {
klog.Errorf("unable to get secret `%s`; %v", ch.ResourceNamespace, err)
}
pdns := powerdns.NewClient(config.ApiUrl, config.ServerName, map[string]string{"X-API-Key": config.ApiKey}, nil)
p_err := pdns.Records.Delete(config.ZoneName, ch.ResolvedFQDN, powerdns.RRTypeTXT)
if p_err != nil {
klog.Error(p_err)
}
klog.Infof("Delete TXT record result: %s", ch.ResolvedFQDN)
return nil
}
@ -116,16 +152,17 @@ func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
// provider accounts.
// 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 {
func (c *pdnsDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error {
///// UNCOMMENT THE BELOW CODE TO MAKE A KUBERNETES CLIENTSET AVAILABLE TO
///// YOUR CUSTOM DNS PROVIDER
//cl, err := kubernetes.NewForConfig(kubeClientConfig)
//if err != nil {
// return err
//}
//
//c.client = cl
cl, err := kubernetes.NewForConfig(kubeClientConfig)
klog.V(6).Infof("Input variable stopCh is %d length", len(stopCh))
if err != nil {
return err
}
c.client = cl
///// END OF CODE TO MAKE KUBERNETES CLIENTSET AVAILABLE
return nil
@ -133,15 +170,52 @@ func (c *customDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stop
// loadConfig is a small helper function that decodes JSON configuration into
// the typed config struct.
func loadConfig(cfgJSON *extapi.JSON) (customDNSProviderConfig, error) {
cfg := customDNSProviderConfig{}
func loadConfig(cfgJSON *extapi.JSON) (pdnsDNSProviderConfig, error) {
cfg := pdnsDNSProviderConfig{}
// handle the 'base case' where no configuration has been provided
if cfgJSON == nil {
return cfg, nil
}
if err := json.Unmarshal(cfgJSON.Raw, &cfg); err != nil {
klog.Errorf("error decoding solver config: %v", err)
return cfg, fmt.Errorf("error decoding solver config: %v", err)
}
return cfg, nil
}
func stringFromSecretData(secretData *map[string][]byte, key string) (string, error) {
data, ok := (*secretData)[key]
if !ok {
return "", fmt.Errorf("key %q not found in secret data", key)
}
return string(data), nil
}
func clientConfig(c *pdnsDNSProviderSolver, ch *v1alpha1.ChallengeRequest) (internal.Config, error) {
var config internal.Config
cfg, err := loadConfig(ch.Config)
if err != nil {
return config, err
}
config.ZoneName = cfg.ZoneName
config.ApiUrl = cfg.ApiUrl
config.ServerName = cfg.ServerName
secretName := cfg.SecretRef
sec, err := c.client.CoreV1().Secrets(ch.ResourceNamespace).Get(context.Background(), secretName, metav1.GetOptions{})
if err != nil {
return config, fmt.Errorf("unable to get secret `%s/%s`; %v", secretName, ch.ResourceNamespace, err)
}
apiKey, err := stringFromSecretData(&sec.Data, "api-key")
config.ApiKey = apiKey
if err != nil {
return config, fmt.Errorf("unable to get api-key from secret `%s/%s`; %v", secretName, ch.ResourceNamespace, err)
}
return config, nil
}

View file

@ -5,12 +5,11 @@ import (
"testing"
"github.com/jetstack/cert-manager/test/acme/dns"
"github.com/cert-manager/webhook-example/example"
)
var (
zone = os.Getenv("TEST_ZONE_NAME")
fqdn string
)
func TestRunsSuite(t *testing.T) {
@ -18,23 +17,25 @@ func TestRunsSuite(t *testing.T) {
// snippet of valid configuration that should be included on the
// ChallengeRequest passed as part of the test cases.
//
fqdn = "_acme-challenge.test." + zone
// 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."),
dns.SetManifestPath("testdata/my-custom-solver"),
fixture := dns.NewFixture(&pdnsDNSProviderSolver{},
dns.SetResolvedZone(zone),
dns.SetResolvedFQDN(fqdn),
dns.SetAllowAmbientCredentials(false),
dns.SetManifestPath("testdata/pdns"),
dns.SetBinariesPath("_test/kubebuilder/bin"),
dns.SetDNSServer("127.0.0.1:59351"),
dns.SetUseAuthoritative(false),
)
// solver := example.New("59351")
// fixture := dns.NewFixture(&pdnsDNSProviderSolver{},
// dns.SetResolvedZone("example.com."),
// dns.SetManifestPath("testdata/pdns"),
// dns.SetBinariesPath("_test/kubebuilder/bin"),
// dns.SetDNSServer("127.0.0.1:59351"),
// dns.SetUseAuthoritative(false),
// )
fixture.RunConformance(t)
}
}

View file

@ -1,3 +0,0 @@
# Solver testdata directory
TODO

View file

@ -1 +0,0 @@
{}

6
testdata/pdns/config.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"secretName": "pdns-secret",
"zoneName": "example.com.",
"server": "localhost",
"apiUrl" : "https://powerdns.api"
}

6
testdata/pdns/pdns-secret.yml vendored Normal file
View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: Secret
metadata:
name: pdns-secret
data:
api-key: VEhpc0FzM2NyM1RlQVNUM3JFZ0c=