A production-grade cert-manager ACME DNS-01 webhook solver for ArvanCloud DNS, using the ArvanCloud CDN/DNS API v4 and Machine User API keys.
It implements the official webhook.Solver interface
(github.com/cert-manager/cert-manager/pkg/acme/webhook) and runs as a small
extension-apiserver alongside cert-manager.
ArvanCloud API reference: https://www.arvancloud.ir/docs/api/cdn/4.0 ·
API usage / getting a key ·
DNS records.
The client (internal/client) matches the endpoints and TXT payload shape
used by go-acme/lego's ArvanCloud provider.
- API v4 against
https://napi.arvancloud.ir,Authorization: Apikey <key>. - Idempotent
Present(reuses an existing identical TXT record) andCleanUp(a missing record is success, not an error loop). - Hard 15s timeout and bounded retries on every API call, so a slow ArvanCloud response never stalls a cert-manager queue worker.
- Multi-level subdomain handling (
_acme-challenge.apps.cluster.example.com). - Optional public-resolver propagation check (1.1.1.1 / 8.8.8.8) before
Presentreturns — off by default; cert-manager's own DNS self-check already gates ACME validation. - Pooled HTTP transport shared process-wide (tuned keep-alives / idle conns).
- Prometheus metrics (
/metricson:8081): API latency histograms, error rates, challenge success/failure counters; optionalServiceMonitor. - Credentials read only from a namespaced Kubernetes
Secret; never logged. - Restricted pod security context: non-root
10001, read-only root FS, all capabilities dropped,RuntimeDefaultseccomp. - Multi-arch image (
linux/amd64,linux/arm64), distroless base, cosign keyless signature + SBOM + provenance on every release.
helm repo add opscalehub https://opscalehub.github.io/cert-manager-webhook-arvancloud
helm repo update
helm install arvancloud-webhook opscalehub/arvancloud-webhook \
--namespace cert-manager --version <VERSION>The chart is also published as an OCI artifact:
helm install arvancloud-webhook \
oci://ghcr.io/opscalehub/charts/arvancloud-webhook \
--namespace cert-manager --version <VERSION>Or straight from this repo:
helm install arvancloud-webhook ./charts/arvancloud-webhook -n cert-managerProject landing page: https://opscalehub.github.io/cert-manager-webhook-arvancloud
(served from docs/ via the pages workflow, which also hosts the Helm repo).
Key values (charts/arvancloud-webhook/values.yaml, validated by
values.schema.json):
| Value | Default | Notes |
|---|---|---|
groupName |
acme.arvancloud.ir |
Must match the Issuer's webhook.groupName. |
image.repository |
ghcr.io/opscalehub/cert-manager-webhook-arvancloud |
|
certManager.namespace / certManager.serviceAccountName |
cert-manager |
SA granted permission to call the webhook. |
replicaCount |
1 |
1–10. |
metrics.enabled / metrics.port |
true / 8081 |
Prometheus /metrics + /healthz on a separate port. |
metrics.serviceMonitor.enabled |
false |
Create a Prometheus-Operator ServiceMonitor. |
Create a dedicated ArvanCloud Machine User — never a master-account key — and issue it an API key scoped to DNS management for only the zones this webhook manages. A compromised webhook Pod has exactly the blast radius of this token, so keep it least-privilege and rotate it on a schedule: create the replacement key, update the Secret, restart the Deployment, then revoke the old key in the ArvanCloud console.
kubectl -n cert-manager create secret generic arvancloud-credentials \
--from-literal=api-key=<YOUR_MACHINE_USER_KEY>The key is read from this namespaced Secret at request time only — it is never written to logs, ConfigMaps, or Helm values.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-arvancloud
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-arvancloud-account
solvers:
- dns01:
webhook:
groupName: acme.arvancloud.ir
solverName: arvancloud
config:
apiKeySecretRef:
name: arvancloud-credentials
key: api-key
ttl: 120A staging example plus a test Certificate is in
testdata/clusterissuer-staging.yaml.
| Field | Required | Default | Description |
|---|---|---|---|
apiKeySecretRef.name / .key |
yes | – | Secret holding the API key, in the Issuer's namespace (or the cluster resource namespace for a ClusterIssuer). |
apiUrl |
no | https://napi.arvancloud.ir |
Override the API base URL. |
ttl |
no | 120 |
TXT record TTL, seconds. |
propagationCheck |
no | false |
When true, block Present until 1.1.1.1 and 8.8.8.8 both serve the record. Usually unnecessary — cert-manager already self-checks DNS (--dns01-recursive-nameservers) before ACME validation, and a synchronous wait here adds latency to the webhook apiserver call. |
propagationTimeoutSeconds |
no | 45 |
Upper bound on the propagation wait when enabled. Keep below the apiserver --request-timeout (60s). |
The webhook exposes Prometheus metrics and /healthz on :8081 (configurable
via METRICS_BIND_ADDRESS; empty disables). Metrics:
| Metric | Type | Labels |
|---|---|---|
arvancloud_webhook_api_request_duration_seconds |
histogram | method, outcome (2xx/4xx/5xx/error) |
arvancloud_webhook_api_requests_total |
counter | method, code |
arvancloud_webhook_solver_challenges_total |
counter | action (present/cleanup), result |
arvancloud_webhook_solver_propagation_wait_seconds |
histogram | outcome (ok/timeout) — only when propagationCheck is enabled |
Enable scraping with --set metrics.serviceMonitor.enabled=true (requires
Prometheus Operator). Alert on rising ..._api_requests_total{code=~"429|5.."}
and ..._challenges_total{result="error"}.
Slow issuance? If Present calls are slow (visible as long
k8s.io/apiserver "Create" traces in the webhook pod logs), check
..._api_request_duration_seconds (is the ArvanCloud API slow?) and
..._propagation_wait_seconds (is the check waiting on public resolvers?).
Leaving propagationCheck off — the default — removes the propagation wait
from the synchronous webhook call entirely.
go test ./... # unit tests (httptest mock API + fake k8s client)
go vet -tags conformance ./... # keep the conformance suite compiling
go build ./...
helm lint charts/arvancloud-webhook
docker build -t webhook:dev .Conformance suite — cert-manager's official external-webhook tests
(test/acme) driven against the live ArvanCloud API: spins up a local envtest
control plane and calls Present/CleanUp with a synthetic challenge on a
real zone (no ACME order, no Let's Encrypt rate limits). Excluded from
go test ./... by the conformance build tag. Confirmed passing live against
opscale.ir on 2026-09-06.
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
ASSETS="$(setup-envtest use 1.31.0 -p path)"
export TEST_ASSET_ETCD="$ASSETS/etcd" \
TEST_ASSET_KUBE_APISERVER="$ASSETS/kube-apiserver" \
TEST_ASSET_KUBECTL="$ASSETS/kubectl"
ARVANCLOUD_API_KEY=... TEST_ZONE_NAME=opscale.ir. \
go test -tags conformance -run TestConformance ./internal/provider/ -v -timeout 20mGotcha: an ArvanCloud Machine User's DNS-records permission is not granted by default. A key that is otherwise valid but lacks that scope returns
HTTP 403: Your access to this section is restricted.from every DNS call — not a clear permission-denied message. Grant the DNS-records policy to the Machine User for the target zone in the ArvanCloud console.
The compat workflow builds + unit-tests against a matrix of cert-manager
minor versions weekly to catch upstream API-schema breaks early, and runs the
live conformance suite on demand.
Repository layout:
internal/client/ ArvanCloud REST client (arvan.go, types.go) + tests
internal/obs/ Prometheus metrics + diagnostic HTTP server
internal/provider/ webhook.Solver impl + propagation check + tests + conformance
main.go apiserver entrypoint + metrics server
charts/ Helm chart with values.schema.json + ServiceMonitor
deploy/README.md GitOps notes (rendered bundle.yaml ships per release)
docs/index.html GitHub Pages landing page (dark-mode, zero-JS-framework)
.github/workflows/ ci.yml, release.yml (release-please + signed image/chart),
pages.yml, compat.yml (cert-manager matrix + conformance)
Releases are driven by release-please from Conventional Commit messages — no manual tagging.
- Merge PRs to
mainwithfeat:/fix:/feat!:commit subjects. release.ymlkeeps a release PR open ("chore: release X.Y.Z") that updatesCHANGELOG.mdand bumps the version inChart.yamlanddocs/index.html.- Merge the release PR → the git tag
vX.Y.Zand the GitHub Release are created, then the same workflow:- builds & pushes the multi-arch image (
docker buildx,linux/amd64+linux/arm64) taggedX.Y.Z,X.Y,X,latest; - packages & pushes the chart to
oci://ghcr.io/opscalehub/charts, and attaches the.tgz+ a renderedbundle.yamlto the release; - cosign keyless-signs the image and the chart, with an SBOM + build-provenance attestation.
- builds & pushes the multi-arch image (
pages.ymlrepublishes the Helm repo index sohelm repo addsees the new version.
The release PR is authored by github-actions[bot], so its checks start as
"Action required" — approve them (Actions tab, or the PR's checks panel)
before merging. To skip this each time, give release-please a PAT via a
token: input, or relax Settings → Actions → General → Approval for workflow
runs from….
To re-publish artifacts for an existing tag (e.g. after a transient failure),
run the release workflow manually with the tag input set to vX.Y.Z.
The image and chart packages are public and auto-link to this repo via
org.opencontainers.image.source.