diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9bdd3e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.21-alpine3.18 AS build_deps + +RUN apk add --no-cache git + +WORKDIR /workspace + +COPY go.mod . +COPY go.sum . + +RUN go mod download + +FROM build_deps AS build + +COPY . . + +RUN CGO_ENABLED=0 go build -o webhook -ldflags '-w -extldflags "-static"' . + +FROM alpine:3.18 + +RUN apk add --no-cache ca-certificates + +COPY --from=build /workspace/webhook /usr/local/bin/webhook + +ENTRYPOINT ["webhook"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5dc18eb --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +GO ?= $(shell which go) +OS ?= $(shell $(GO) env GOOS) +ARCH ?= $(shell $(GO) env GOARCH) + +IMAGE_NAME := "webhook" +IMAGE_TAG := "latest" + +OUT := $(shell pwd)/_out + +KUBEBUILDER_VERSION=1.28.0 + +HELM_FILES := $(shell find deploy/sthome-webhook) + +test: _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/etcd _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kube-apiserver _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kubectl + TEST_ASSET_ETCD=_test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/etcd \ + TEST_ASSET_KUBE_APISERVER=_test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kube-apiserver \ + TEST_ASSET_KUBECTL=_test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kubectl \ + $(GO) test -v . + +_test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH).tar.gz: | _test + curl -fsSL https://go.kubebuilder.io/test-tools/$(KUBEBUILDER_VERSION)/$(OS)/$(ARCH) -o $@ + +_test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/etcd _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kube-apiserver _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH)/kubectl: _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH).tar.gz | _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH) + tar xfO $< kubebuilder/bin/$(notdir $@) > $@ && chmod +x $@ + +.PHONY: clean +clean: + rm -r _test $(OUT) + +.PHONY: build +build: + docker build -t "$(IMAGE_NAME):$(IMAGE_TAG)" . + +.PHONY: rendered-manifest.yaml +rendered-manifest.yaml: $(OUT)/rendered-manifest.yaml + +$(OUT)/rendered-manifest.yaml: $(HELM_FILES) | $(OUT) + helm template \ + --name sthome-webhook \ + --set image.repository=$(IMAGE_NAME) \ + --set image.tag=$(IMAGE_TAG) \ + deploy/sthome-webhook > $@ + +_test $(OUT) _test/kubebuilder-$(KUBEBUILDER_VERSION)-$(OS)-$(ARCH): + mkdir -p $@ diff --git a/deploy/example-webhook/.helmignore b/deploy/example-webhook/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/deploy/example-webhook/.helmignore @@ -0,0 +1,21 @@ +# 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 +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/deploy/example-webhook/Chart.yaml b/deploy/example-webhook/Chart.yaml new file mode 100644 index 0000000..77c6ead --- /dev/null +++ b/deploy/example-webhook/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: example-webhook +version: 0.1.0 diff --git a/deploy/example-webhook/templates/NOTES.txt b/deploy/example-webhook/templates/NOTES.txt new file mode 100644 index 0000000..e69de29 diff --git a/deploy/example-webhook/templates/_helpers.tpl b/deploy/example-webhook/templates/_helpers.tpl new file mode 100644 index 0000000..d3c474b --- /dev/null +++ b/deploy/example-webhook/templates/_helpers.tpl @@ -0,0 +1,48 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "example-webhook.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 "example-webhook.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 "example-webhook.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "example-webhook.selfSignedIssuer" -}} +{{ printf "%s-selfsign" (include "example-webhook.fullname" .) }} +{{- end -}} + +{{- define "example-webhook.rootCAIssuer" -}} +{{ printf "%s-ca" (include "example-webhook.fullname" .) }} +{{- end -}} + +{{- define "example-webhook.rootCACertificate" -}} +{{ printf "%s-ca" (include "example-webhook.fullname" .) }} +{{- end -}} + +{{- define "example-webhook.servingCertificate" -}} +{{ printf "%s-webhook-tls" (include "example-webhook.fullname" .) }} +{{- end -}} diff --git a/deploy/example-webhook/templates/apiservice.yaml b/deploy/example-webhook/templates/apiservice.yaml new file mode 100644 index 0000000..4f6d5ce --- /dev/null +++ b/deploy/example-webhook/templates/apiservice.yaml @@ -0,0 +1,19 @@ +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.{{ .Values.groupName }} + labels: + app: {{ include "example-webhook.name" . }} + chart: {{ include "example-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "example-webhook.servingCertificate" . }}" +spec: + group: {{ .Values.groupName }} + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: {{ include "example-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + version: v1alpha1 diff --git a/deploy/example-webhook/templates/deployment.yaml b/deploy/example-webhook/templates/deployment.yaml new file mode 100644 index 0000000..057cc42 --- /dev/null +++ b/deploy/example-webhook/templates/deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "example-webhook.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "example-webhook.name" . }} + chart: {{ include "example-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ include "example-webhook.name" . }} + release: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ include "example-webhook.name" . }} + release: {{ .Release.Name }} + spec: + serviceAccountName: {{ include "example-webhook.fullname" . }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --tls-cert-file=/tls/tls.crt + - --tls-private-key-file=/tls/tls.key + 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 "example-webhook.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/deploy/example-webhook/templates/pki.yaml b/deploy/example-webhook/templates/pki.yaml new file mode 100644 index 0000000..b4b4c23 --- /dev/null +++ b/deploy/example-webhook/templates/pki.yaml @@ -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 "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 diff --git a/deploy/example-webhook/templates/rbac.yaml b/deploy/example-webhook/templates/rbac.yaml new file mode 100644 index 0000000..605fcf5 --- /dev/null +++ b/deploy/example-webhook/templates/rbac.yaml @@ -0,0 +1,91 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "example-webhook.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + 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 }} diff --git a/deploy/example-webhook/templates/service.yaml b/deploy/example-webhook/templates/service.yaml new file mode 100644 index 0000000..a76ddc7 --- /dev/null +++ b/deploy/example-webhook/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "example-webhook.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "example-webhook.name" . }} + chart: {{ include "example-webhook.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 "example-webhook.name" . }} + release: {{ .Release.Name }} diff --git a/deploy/example-webhook/values.yaml b/deploy/example-webhook/values.yaml new file mode 100644 index 0000000..31eb151 --- /dev/null +++ b/deploy/example-webhook/values.yaml @@ -0,0 +1,43 @@ +# The GroupName here is used to identify your company or business unit that +# created this webhook. +# For example, this may be "acme.mycompany.com". +# This name will need to be referenced in each Issuer's `webhook` stanza to +# inform cert-manager of where to send ChallengePayload resources in order to +# solve the DNS01 challenge. +# This group name should be **unique**, hence using your own company's domain +# here is recommended. +groupName: acme.mycompany.com + +certManager: + namespace: cert-manager + serviceAccountName: cert-manager + +image: + repository: mycompany/webhook-image + tag: latest + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 443 + +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 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c62945a --- /dev/null +++ b/go.mod @@ -0,0 +1,113 @@ +module github.com/cert-manager/webhook-example + +go 1.20 + +require ( + github.com/cert-manager/cert-manager v1.12.6 + github.com/miekg/dns v1.1.50 + github.com/stretchr/testify v1.8.4 + k8s.io/apiextensions-apiserver v0.27.2 + k8s.io/client-go v0.27.2 +) + +require ( + github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect + github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.9.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.2.4 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.1 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/cel-go v0.12.6 // indirect + github.com/google/gnostic v0.6.9 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.15.1 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + go.etcd.io/etcd/api/v3 v3.5.7 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect + go.etcd.io/etcd/client/v3 v3.5.7 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/sdk v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/net v0.18.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.9.1 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.27.2 // indirect + k8s.io/apimachinery v0.27.2 // indirect + k8s.io/apiserver v0.27.2 // indirect + k8s.io/component-base v0.27.2 // indirect + k8s.io/klog/v2 v2.100.1 // indirect + k8s.io/kms v0.27.2 // indirect + k8s.io/kube-aggregator v0.27.2 // indirect + k8s.io/kube-openapi v0.0.0-20230515203736-54b630e78af5 // indirect + k8s.io/utils v0.0.0-20230505201702-9f6742963106 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2 // indirect + sigs.k8s.io/controller-runtime v0.15.0 // indirect + sigs.k8s.io/gateway-api v0.7.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..a001e36 --- /dev/null +++ b/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + + "github.com/jetstack/cert-manager/pkg/acme/webhook/cmd" + + "github.com/stuurmcp/cert-manager-webhook-sthome/pkg/dns" +) + +// GroupName is the name under which the webhook will be available +var GroupName = os.Getenv("GROUP_NAME") + +func main() { + if GroupName == "" { + panic("GROUP_NAME must be specified") + } + + cmd.RunWebhookServer(GroupName, + &dns.ProviderSolver{}, + ) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..5e3a9f9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "testing" + + acmetest "github.com/cert-manager/cert-manager/test/acme" + + "github.com/cert-manager/webhook-sthome/sthome" +) + +var ( + zone = os.Getenv("TEST_ZONE_NAME") +) + +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 := acmetest.NewFixture(&customDNSProviderSolver{}, + // acmetest.SetResolvedZone(zone), + // acmetest.SetAllowAmbientCredentials(false), + // acmetest.SetManifestPath("testdata/my-custom-solver"), + // acmetest.SetBinariesPath("_test/kubebuilder/bin"), + //) + solver := sthome.New("59351") + fixture := acmetest.NewFixture(solver, + acmetest.SetResolvedZone("sthome.net."), + acmetest.SetManifestPath("testdata/my-custom-solver"), + acmetest.SetDNSServer("127.0.0.1:59351"), + acmetest.SetUseAuthoritative(false), + ) + //need to uncomment and RunConformance delete runBasic and runExtended once https://github.com/cert-manager/cert-manager/pull/4835 is merged + //fixture.RunConformance(t) + fixture.RunBasic(t) + fixture.RunExtended(t) + +} diff --git a/pkg/dns/dns.go b/pkg/dns/dns.go new file mode 100644 index 0000000..e2400a8 --- /dev/null +++ b/pkg/dns/dns.go @@ -0,0 +1,69 @@ +package dns + +import ( + "fmt" + + "github.com/miekg/dns" +) + +func (e *sthomeSolver) 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 *sthomeSolver) 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) + } +} diff --git a/pkg/dns/solver.go b/pkg/dns/solver.go new file mode 100644 index 0000000..ef9b798 --- /dev/null +++ b/pkg/dns/solver.go @@ -0,0 +1,130 @@ +package dns + +import ( + "fmt" + "strconv" + "strings" + + v1alpha1 "github.com/cert-manager/cert-manager/tree/master/pkg/acme/webhook/apis/acme/v1alpha1" //"github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + providerName = "sthome" +) + +// ProviderSolver is the struct implementing the webhook.Solver interface +// for sthome DNS +type ProviderSolver struct { + client kubernetes.Interface +} + +// Name is used as the name for this DNS solver when referencing it on the ACME +// Issuer resource +func (p *ProviderSolver) Name() string { + return providerName +} + +// 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 (p *ProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { + domainAPI, err := p.getDomainAPI(ch) + if err != nil { + return err + } + + request := &domain.UpdateDNSZoneRecordsRequest{ + DNSZone: strings.TrimSuffix(ch.ResolvedZone, "."), + Changes: []*domain.RecordChange{ + { + Set: &domain.RecordChangeSet{ + IDFields: &domain.RecordIdentifier{ + Name: strings.TrimSuffix(strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone), "."), + Type: domain.RecordTypeTXT, + Data: StringPtr(strconv.Quote(ch.Key)), + }, + Records: []*domain.Record{ + { + Name: strings.TrimSuffix(strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone), "."), + Data: strconv.Quote(ch.Key), + Type: domain.RecordTypeTXT, + TTL: 60, + }, + }, + }, + }, + }, + } + + _, err = domainAPI.UpdateDNSZoneRecords(request) + if err != nil { + return fmt.Errorf("failed to update DNS zone records: %w", err) + } + + 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 (p *ProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { + domainAPI, err := p.getDomainAPI(ch) + if err != nil { + return err + } + + request := &domain.UpdateDNSZoneRecordsRequest{ + DNSZone: strings.TrimSuffix(ch.ResolvedZone, "."), + Changes: []*domain.RecordChange{ + { + Delete: &domain.RecordChangeDelete{ + IDFields: &domain.RecordIdentifier{ + Name: strings.TrimSuffix(strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone), "."), + Data: StringPtr(strconv.Quote(ch.Key)), + Type: domain.RecordTypeTXT, + }, + }, + }, + }, + } + + _, err = domainAPI.UpdateDNSZoneRecords(request) + if err != nil { + return fmt.Errorf("failed to update DNS zone records: %w", err) + } + + return nil +} + +// Initialize will be called when the webhook first starts. +// This method can be used to instantiate the webhook, i.e. initialising +// connections or warming up caches. +// Typically, the kubeClientConfig parameter is used to build a Kubernetes +// client that can be used to fetch resources from the Kubernetes API, e.g. +// Secret resources containing credentials used to authenticate with DNS +// 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 (p *ProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { + + cl, err := kubernetes.NewForConfig(kubeClientConfig) + if err != nil { + return fmt.Errorf("failed to get kubernetes client: %w", err) + } + + p.client = cl + + return nil +} + +// StringPtr returns a pointer to the string value provided +func StringPtr(v string) *string { + return &v +} diff --git a/pkg/dns/sthome.go b/pkg/dns/sthome.go new file mode 100644 index 0000000..7baef5a --- /dev/null +++ b/pkg/dns/sthome.go @@ -0,0 +1,68 @@ +// package example contains a self-contained example of a webhook that passes the cert-manager +// DNS conformance tests +package dns + +import ( + "fmt" + "os" + "sync" + + "github.com/cert-manager/cert-manager/pkg/acme/webhook" + acme "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" + "github.com/miekg/dns" + "k8s.io/client-go/rest" +) + +type sthomeSolver struct { + name string + server *dns.Server + txtRecords map[string]string + sync.RWMutex +} + +func (e *sthomeSolver) Name() string { + return e.name +} + +func (e *sthomeSolver) Present(ch *acme.ChallengeRequest) error { + e.Lock() + e.txtRecords[ch.ResolvedFQDN] = ch.Key + e.Unlock() + return nil +} + +func (e *sthomeSolver) CleanUp(ch *acme.ChallengeRequest) error { + e.Lock() + delete(e.txtRecords, ch.ResolvedFQDN) + e.Unlock() + return nil +} + +func (e *sthomeSolver) 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 := &sthomeSolver{ + name: "example", + txtRecords: make(map[string]string), + } + e.server = &dns.Server{ + Addr: ":" + port, + Net: "udp", + Handler: dns.HandlerFunc(e.handleDNSRequest), + } + return e +} diff --git a/pkg/dns/sthome_test.go b/pkg/dns/sthome_test.go new file mode 100644 index 0000000..00e3fce --- /dev/null +++ b/pkg/dns/sthome_test.go @@ -0,0 +1,96 @@ +package dns + +import ( + "crypto/rand" + "math/big" + "testing" + + acme "github.com/cert-manager/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) +} diff --git a/pkg/util/version.go b/pkg/util/version.go new file mode 100644 index 0000000..613d967 --- /dev/null +++ b/pkg/util/version.go @@ -0,0 +1,35 @@ +package util + +import ( + "fmt" + "runtime" +) + +// These are set during build time via -ldflags +var ( + version = "0.0.1+dev" + gitCommit string + buildDate string +) + +// VersionInfo represents the current running version +type VersionInfo struct { + Version string `json:"version"` + GitCommit string `json:"gitCommit"` + BuildDate string `json:"buildDate"` + GoVersion string `json:"goVersion"` + Compiler string `json:"compiler"` + Platform string `json:"platform"` +} + +// GetVersion returns the current running version +func GetVersion() VersionInfo { + return VersionInfo{ + Version: version, + GitCommit: gitCommit, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} diff --git a/testdata/my-custom-solver/README.md b/testdata/my-custom-solver/README.md new file mode 100644 index 0000000..feb4cbd --- /dev/null +++ b/testdata/my-custom-solver/README.md @@ -0,0 +1,3 @@ +# Solver testdata directory + +TODO diff --git a/testdata/my-custom-solver/config.json b/testdata/my-custom-solver/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/testdata/my-custom-solver/config.json @@ -0,0 +1 @@ +{}