From acd4bcf7e646cc0f38b00af53d0f6c210e58f888 Mon Sep 17 00:00:00 2001 From: Igor Borodin Date: Thu, 28 Aug 2025 11:33:29 +0200 Subject: [PATCH 1/4] feat: Hetzner Cloud store --- README.md | 52 +++++++ go.mod | 15 +- go.sum | 30 ++-- internal/types/types.go | 2 + pkg/certmanagersync/certmanagersync.go | 3 + stores/hetznercloud/hetznercloud.go | 177 +++++++++++++++++++++++ stores/hetznercloud/hetznercloud_test.go | 133 +++++++++++++++++ 7 files changed, 391 insertions(+), 21 deletions(-) create mode 100644 stores/hetznercloud/hetznercloud.go create mode 100644 stores/hetznercloud/hetznercloud_test.go diff --git a/README.md b/README.md index fde8c47..1248f30 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Enable Kubernetes `cert-manager` to sync TLS certificates to AWS ACM, GCP, Hashi - [Google Cloud](#google-cloud) - [HashiCorp Vault](#hashicorp-vault) - [Heroku](#heroku) + - [Hetzner Cloud](#hetzner-cloud) - [Incapsula](#incapsula) - [ThreatX](#threatx) - [Multiple Sync Destinations](#multiple-sync-destinations) @@ -26,6 +27,11 @@ Enable Kubernetes `cert-manager` to sync TLS certificates to AWS ACM, GCP, Hashi - [Monitoring](#monitoring) - [Prometheus Metrics](#prometheus-metrics) - [Error Logging](#error-logging) + - [PKCS#12 Support for HashiCorp Vault](#pkcs12-support-for-hashicorp-vault) + - [Configuration](#configuration-1) + - [Password Management](#password-management) + - [Storage in Vault](#storage-in-vault) + - [Example](#example) ## Architecture @@ -201,6 +207,48 @@ Annotations: cert-manager-sync.lestak.sh/heroku-cert-name: "" # will be auto-filled by operator for in-place renewals ``` +### Hetzner Cloud + +Create a Hetzner Cloud API Token with `Read & Write` permissions for Certificates. The token can be created in the [Hetzner Cloud Console](https://console.hetzner.cloud) under Security → API Tokens. + +**Project Scope**: Hetzner Cloud organizes resources into projects, and API tokens are scoped to a specific project. When you use cert-manager-sync with a Hetzner Cloud API token: +- Certificates will be uploaded to the **same project** that issued the API token +- You cannot cross project boundaries with a single token +- Ensure you're using an API token from the correct project where you want the certificates to be available for your Load Balancers + +```bash +kubectl -n cert-manager \ + create secret generic example-hetzner-secret \ + --from-literal api_token=XXXXX +``` + +You will then annotate your k8s TLS secret with this secret name to tell the operator to retrieve the Hetzner Cloud API secret from this location. + +Annotations: + +```yaml + cert-manager-sync.lestak.sh/hetznercloud-enabled: "true" # sync certificate to Hetzner Cloud + cert-manager-sync.lestak.sh/hetznercloud-secret-name: "example-hetzner-secret" # secret in same namespace which contains the hetzner cloud api token. If provided in format "namespace/secret-name", will look in that namespace for the secret + cert-manager-sync.lestak.sh/hetznercloud-cert-name: "my-cert" # unique name to give your cert in Hetzner Cloud (optional, defaults to secret name) + cert-manager-sync.lestak.sh/hetznercloud-cert-id: "" # will be auto-filled by operator for in-place renewals + cert-manager-sync.lestak.sh/hetznercloud-label-environment: "production" # (optional) add labels to the certificate in Hetzner Cloud + cert-manager-sync.lestak.sh/hetznercloud-label-team: "devops" # (optional) add more labels as needed +``` + +**Notes:** +- Certificates can be attached to Hetzner Cloud Load Balancers for TLS termination +- The operator will automatically handle certificate renewals by creating new certificates and removing old ones +- If a certificate is in use by a Load Balancer, the operator will create a new certificate with a modified name instead of deleting the in-use certificate + +Hetzner Cloud store supports optional integration testing with a real API. To run: + +```bash +export HETZNER_TEST_TOKEN="your-hetzner-api-token" +go test ./stores/hetznercloud/... -v +``` + +**Note**: Use a test project token to avoid affecting production resources. The test will create and clean up test certificates in the project associated with the API token. + ### Incapsula Create an Incapsula API Key and create a kube secret containing this key. @@ -342,6 +390,10 @@ metadata: cert-manager-sync.lestak.sh/heroku-app: "example-app" # heroku app to attach cert cert-manager-sync.lestak.sh/heroku-secret-name: "example-heroku-secret" # secret in same namespace which contains heroku api key cert-manager-sync.lestak.sh/heroku-cert-name: "" # will be auto-filled by operator for in-place renewals + cert-manager-sync.lestak.sh/hetznercloud-enabled: "true" # sync certificate to Hetzner Cloud + cert-manager-sync.lestak.sh/hetznercloud-secret-name: "example-hetzner-secret" # secret in same namespace which contains hetzner cloud api token + cert-manager-sync.lestak.sh/hetznercloud-cert-name: "my-cert" # unique name to give your cert in Hetzner Cloud + cert-manager-sync.lestak.sh/hetznercloud-cert-id: "" # will be auto-filled by operator for in-place renewals cert-manager-sync.lestak.sh/incapsula-site-id: "12345" # incapsula site to attach cert cert-manager-sync.lestak.sh/incapsula-secret-name: "cert-manager-sync-poc" # secret in same namespace which contains incapsula api key cert-manager-sync.lestak.sh/threatx-hostname: "example.com" # threatx hostname to attach cert diff --git a/go.mod b/go.mod index 7dbfae3..d7bc7ba 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/robertlestak/cert-manager-sync -go 1.24.3 +go 1.24.5 require ( cloud.google.com/go/certificatemanager v1.9.5 @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/vault/api v1.20.0 github.com/heroku/heroku-go/v5 v5.5.0 + github.com/hetznercloud/hcloud-go/v2 v2.22.0 github.com/prometheus/client_golang v1.22.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 @@ -83,12 +84,12 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/term v0.33.0 // indirect + golang.org/x/text v0.27.0 // indirect golang.org/x/time v0.11.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect diff --git a/go.sum b/go.sum index a94daaa..55d3d6b 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3 github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= github.com/heroku/heroku-go/v5 v5.5.0 h1:+pKHpiPskqkkarrPHF7RpeUveXl+mAsKLAEI/ZIY9uA= github.com/heroku/heroku-go/v5 v5.5.0/go.mod h1:Uo3XhGPwaTpniR4X1e50BDjg4SzdFk2Bd2mgYZVkfHo= +github.com/hetznercloud/hcloud-go/v2 v2.22.0 h1:RwcOkgB5y7kvi9Nxt40lHej8HjaS/P+9Yjfs4Glcds0= +github.com/hetznercloud/hcloud-go/v2 v2.22.0/go.mod h1:t14Logj+iLXyS03DGwEyrN+y7/C9243CJt3IArTHbyM= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -220,44 +222,44 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/types/types.go b/internal/types/types.go index bc646ec..a4ced06 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -15,6 +15,7 @@ const ( FilepathStoreType StoreType = "filepath" GCPStoreType StoreType = "gcp" HerokuStoreType StoreType = "heroku" + HetznerCloudStoreType StoreType = "hetznercloud" IncapsulaStoreType StoreType = "incapsula" ThreatxStoreType StoreType = "threatx" VaultStoreType StoreType = "vault" @@ -27,6 +28,7 @@ var EnabledStores = []StoreType{ FilepathStoreType, GCPStoreType, HerokuStoreType, + HetznerCloudStoreType, IncapsulaStoreType, ThreatxStoreType, VaultStoreType, diff --git a/pkg/certmanagersync/certmanagersync.go b/pkg/certmanagersync/certmanagersync.go index 98e03e6..6cb6f25 100644 --- a/pkg/certmanagersync/certmanagersync.go +++ b/pkg/certmanagersync/certmanagersync.go @@ -17,6 +17,7 @@ import ( "github.com/robertlestak/cert-manager-sync/stores/filepath" "github.com/robertlestak/cert-manager-sync/stores/gcpcm" "github.com/robertlestak/cert-manager-sync/stores/heroku" + "github.com/robertlestak/cert-manager-sync/stores/hetznercloud" "github.com/robertlestak/cert-manager-sync/stores/incapsula" "github.com/robertlestak/cert-manager-sync/stores/threatx" "github.com/robertlestak/cert-manager-sync/stores/vault" @@ -50,6 +51,8 @@ func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) { store = &gcpcm.GCPStore{} case cmtypes.HerokuStoreType: store = &heroku.HerokuStore{} + case cmtypes.HetznerCloudStoreType: + store = &hetznercloud.HetznerStore{} case cmtypes.IncapsulaStoreType: store = &incapsula.IncapsulaStore{} case cmtypes.ThreatxStoreType: diff --git a/stores/hetznercloud/hetznercloud.go b/stores/hetznercloud/hetznercloud.go new file mode 100644 index 0000000..6292aa6 --- /dev/null +++ b/stores/hetznercloud/hetznercloud.go @@ -0,0 +1,177 @@ +package hetznercloud + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/robertlestak/cert-manager-sync/pkg/state" + "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type HetznerStore struct { + SecretName string + SecretNamespace string + ApiToken string + CertName string + CertId int64 + Labels map[string]string +} + +func (s *HetznerStore) GetApiToken(ctx context.Context) error { + gopt := metav1.GetOptions{} + sc, err := state.KubeClient.CoreV1().Secrets(s.SecretNamespace).Get(ctx, s.SecretName, gopt) + if err != nil { + return err + } + if sc.Data["api_token"] == nil && sc.Data["token"] == nil { + return fmt.Errorf("api_token or token not found in secret %s/%s", s.SecretNamespace, s.SecretName) + } + // Support both "api_token" and "token" for compatibility + if sc.Data["api_token"] != nil { + s.ApiToken = string(sc.Data["api_token"]) + } else { + s.ApiToken = string(sc.Data["token"]) + } + return nil +} + +func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { + l := log.WithFields(log.Fields{ + "action": "FromConfig", + "store": "hetznercloud", + }) + l.Debugf("FromConfig") + + if c.Config["secret-name"] != "" { + s.SecretName = c.Config["secret-name"] + } + if c.Config["cert-name"] != "" { + s.CertName = c.Config["cert-name"] + } + if c.Config["cert-id"] != "" { + certId, err := strconv.ParseInt(c.Config["cert-id"], 10, 64) + if err != nil { + l.WithError(err).Errorf("failed to parse cert-id") + return err + } + s.CertId = certId + } + + // Parse labels if provided + s.Labels = make(map[string]string) + for k, v := range c.Config { + if strings.HasPrefix(k, "label-") { + labelKey := strings.TrimPrefix(k, "label-") + s.Labels[labelKey] = v + } + } + + // if secret name is in the format of "namespace/secretname" then parse it + if strings.Contains(s.SecretName, "/") { + s.SecretNamespace = strings.Split(s.SecretName, "/")[0] + s.SecretName = strings.Split(s.SecretName, "/")[1] + } + + return nil +} + +func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { + s.SecretNamespace = c.Namespace + l := log.WithFields(log.Fields{ + "action": "Update", + "store": "hetznercloud", + "certName": s.CertName, + "secretName": s.SecretName, + "secretNamespace": s.SecretNamespace, + }) + l.Debugf("Update") + + if s.SecretName == "" && s.ApiToken == "" { + return nil, fmt.Errorf("secret name not found in certificate annotations") + } + + ctx := context.Background() + // Only get API token from K8s secret if not already set (e.g., for testing) + if s.ApiToken == "" { + if err := s.GetApiToken(ctx); err != nil { + l.WithError(err).Errorf("GetApiToken error") + return nil, err + } + } + + // Create Hetzner Cloud client + client := hcloud.NewClient(hcloud.WithToken(s.ApiToken)) + + // Prepare certificate name - use provided name or use secret name + certName := s.CertName + if certName == "" { + certName = c.SecretName + } + + // Check if we need to update an existing certificate + origCertId := s.CertId + + // If we have a cert ID, try to delete the old certificate first + // Hetzner Cloud doesn't support in-place updates, so we need to delete and recreate + if s.CertId != 0 { + l.WithField("id", s.CertId).Debugf("checking existing certificate") + + // Check if certificate exists + existingCert, _, err := client.Certificate.GetByID(ctx, s.CertId) + if err != nil { + l.WithError(err).Warnf("failed to get existing certificate, it may have been deleted") + } else if existingCert != nil { + // Check if certificate is in use + if len(existingCert.UsedBy) > 0 { + l.Warnf("certificate %d is in use by %d resources, skipping deletion", s.CertId, len(existingCert.UsedBy)) + // Generate a new name for the certificate to avoid conflicts + certName = fmt.Sprintf("%s-%d", certName, origCertId) + } else { + // Delete the old certificate + l.WithField("id", s.CertId).Debugf("deleting old certificate") + _, err = client.Certificate.Delete(ctx, existingCert) + if err != nil { + l.WithError(err).Errorf("failed to delete old certificate") + // Continue anyway - we'll try to create a new one + } else { + l.WithField("id", s.CertId).Debugf("old certificate deleted") + } + } + } + } + + // Create the new certificate + createOpts := hcloud.CertificateCreateOpts{ + Name: certName, + Type: hcloud.CertificateTypeUploaded, + Certificate: string(c.Certificate), + PrivateKey: string(c.Key), + Labels: s.Labels, + } + + l.WithField("name", certName).Debugf("creating new certificate") + cert, _, err := client.Certificate.Create(ctx, createOpts) + if err != nil { + l.WithError(err).Errorf("failed to create certificate") + return nil, err + } + + l = l.WithField("id", cert.ID) + s.CertId = cert.ID + + // Prepare updates to annotations if cert ID changed + var newKeys map[string]string + if origCertId != s.CertId { + newKeys = map[string]string{ + "cert-id": strconv.FormatInt(s.CertId, 10), + } + } + + l.Info("certificate synced") + return newKeys, nil +} \ No newline at end of file diff --git a/stores/hetznercloud/hetznercloud_test.go b/stores/hetznercloud/hetznercloud_test.go new file mode 100644 index 0000000..37387c1 --- /dev/null +++ b/stores/hetznercloud/hetznercloud_test.go @@ -0,0 +1,133 @@ +package hetznercloud + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "strconv" + "testing" + "time" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" +) + +// generateTestCertificate generates a valid self-signed certificate for testing +func generateTestCertificate() (cert []byte, key []byte, error error) { + // Generate RSA key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test Org"}, + Country: []string{"US"}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress:[]string{""}, + PostalCode: []string{""}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + // Create certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, err + } + + // PEM encode certificate + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + // PEM encode private key + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return certPEM, keyPEM, nil +} + +// TestIntegrationSync tests the full sync process with a real Hetzner Cloud API +// This test is skipped by default and only runs when HETZNER_TEST_TOKEN is set +func TestIntegrationSync(t *testing.T) { + apiToken := os.Getenv("HETZNER_TEST_TOKEN") + if apiToken == "" { + t.Skip("Skipping integration test: HETZNER_TEST_TOKEN not set") + } + + // Generate valid test certificate + testCert, testKey, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate test certificate: %v", err) + } + + // Create store with API token + s := &HetznerStore{ + ApiToken: apiToken, + CertName: fmt.Sprintf("cert-manager-sync-test-%d", time.Now().Unix()), + SecretName: "test-secret", // Required by Sync method even though we're not using K8s + } + + // Create certificate object + c := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert, + Key: testKey, + } + + // Sync certificate + updates, err := s.Sync(c) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + + // Verify cert-id was returned + if updates["cert-id"] == "" { + t.Error("Expected cert-id in updates") + } + + t.Logf("Successfully synced certificate with ID: %s", updates["cert-id"]) + + // Clean up the test certificate + if updates["cert-id"] != "" { + certId, err := strconv.ParseInt(updates["cert-id"], 10, 64) + if err != nil { + t.Logf("Warning: Failed to parse cert ID for cleanup: %v", err) + } else { + // Create client for cleanup + client := hcloud.NewClient(hcloud.WithToken(apiToken)) + ctx := context.Background() + + cert, _, err := client.Certificate.GetByID(ctx, certId) + if err != nil { + t.Logf("Warning: Failed to get certificate for cleanup: %v", err) + } else if cert != nil { + _, err = client.Certificate.Delete(ctx, cert) + if err != nil { + t.Logf("Warning: Failed to delete test certificate %d: %v", certId, err) + } else { + t.Logf("Cleaned up test certificate with ID: %d", certId) + } + } + } + } +} \ No newline at end of file From 25da4ce77319b5b9cd181f78034bd003160edb89 Mon Sep 17 00:00:00 2001 From: Igor Borodin Date: Thu, 28 Aug 2025 11:38:29 +0200 Subject: [PATCH 2/4] go fmt --- stores/hetznercloud/hetznercloud.go | 34 +++++++------- stores/hetznercloud/hetznercloud_test.go | 18 +++---- stores/vault/vault.go | 46 +++++++++--------- stores/vault/vault_test.go | 60 ++++++++++++------------ 4 files changed, 79 insertions(+), 79 deletions(-) diff --git a/stores/hetznercloud/hetznercloud.go b/stores/hetznercloud/hetznercloud.go index 6292aa6..da6ef65 100644 --- a/stores/hetznercloud/hetznercloud.go +++ b/stores/hetznercloud/hetznercloud.go @@ -46,7 +46,7 @@ func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { "store": "hetznercloud", }) l.Debugf("FromConfig") - + if c.Config["secret-name"] != "" { s.SecretName = c.Config["secret-name"] } @@ -61,7 +61,7 @@ func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { } s.CertId = certId } - + // Parse labels if provided s.Labels = make(map[string]string) for k, v := range c.Config { @@ -70,13 +70,13 @@ func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { s.Labels[labelKey] = v } } - + // if secret name is in the format of "namespace/secretname" then parse it if strings.Contains(s.SecretName, "/") { s.SecretNamespace = strings.Split(s.SecretName, "/")[0] s.SecretName = strings.Split(s.SecretName, "/")[1] } - + return nil } @@ -90,11 +90,11 @@ func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) "secretNamespace": s.SecretNamespace, }) l.Debugf("Update") - + if s.SecretName == "" && s.ApiToken == "" { return nil, fmt.Errorf("secret name not found in certificate annotations") } - + ctx := context.Background() // Only get API token from K8s secret if not already set (e.g., for testing) if s.ApiToken == "" { @@ -103,24 +103,24 @@ func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) return nil, err } } - + // Create Hetzner Cloud client client := hcloud.NewClient(hcloud.WithToken(s.ApiToken)) - + // Prepare certificate name - use provided name or use secret name certName := s.CertName if certName == "" { certName = c.SecretName } - + // Check if we need to update an existing certificate origCertId := s.CertId - + // If we have a cert ID, try to delete the old certificate first // Hetzner Cloud doesn't support in-place updates, so we need to delete and recreate if s.CertId != 0 { l.WithField("id", s.CertId).Debugf("checking existing certificate") - + // Check if certificate exists existingCert, _, err := client.Certificate.GetByID(ctx, s.CertId) if err != nil { @@ -144,7 +144,7 @@ func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) } } } - + // Create the new certificate createOpts := hcloud.CertificateCreateOpts{ Name: certName, @@ -153,17 +153,17 @@ func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) PrivateKey: string(c.Key), Labels: s.Labels, } - + l.WithField("name", certName).Debugf("creating new certificate") cert, _, err := client.Certificate.Create(ctx, createOpts) if err != nil { l.WithError(err).Errorf("failed to create certificate") return nil, err } - + l = l.WithField("id", cert.ID) s.CertId = cert.ID - + // Prepare updates to annotations if cert ID changed var newKeys map[string]string if origCertId != s.CertId { @@ -171,7 +171,7 @@ func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) "cert-id": strconv.FormatInt(s.CertId, 10), } } - + l.Info("certificate synced") return newKeys, nil -} \ No newline at end of file +} diff --git a/stores/hetznercloud/hetznercloud_test.go b/stores/hetznercloud/hetznercloud_test.go index 37387c1..1d1dc0c 100644 --- a/stores/hetznercloud/hetznercloud_test.go +++ b/stores/hetznercloud/hetznercloud_test.go @@ -30,12 +30,12 @@ func generateTestCertificate() (cert []byte, key []byte, error error) { template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ - Organization: []string{"Test Org"}, - Country: []string{"US"}, - Province: []string{""}, - Locality: []string{""}, - StreetAddress:[]string{""}, - PostalCode: []string{""}, + Organization: []string{"Test Org"}, + Country: []string{"US"}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress: []string{""}, + PostalCode: []string{""}, }, NotBefore: time.Now(), NotAfter: time.Now().Add(365 * 24 * time.Hour), @@ -106,7 +106,7 @@ func TestIntegrationSync(t *testing.T) { } t.Logf("Successfully synced certificate with ID: %s", updates["cert-id"]) - + // Clean up the test certificate if updates["cert-id"] != "" { certId, err := strconv.ParseInt(updates["cert-id"], 10, 64) @@ -116,7 +116,7 @@ func TestIntegrationSync(t *testing.T) { // Create client for cleanup client := hcloud.NewClient(hcloud.WithToken(apiToken)) ctx := context.Background() - + cert, _, err := client.Certificate.GetByID(ctx, certId) if err != nil { t.Logf("Warning: Failed to get certificate for cleanup: %v", err) @@ -130,4 +130,4 @@ func TestIntegrationSync(t *testing.T) { } } } -} \ No newline at end of file +} diff --git a/stores/vault/vault.go b/stores/vault/vault.go index d8053ff..8542000 100644 --- a/stores/vault/vault.go +++ b/stores/vault/vault.go @@ -15,8 +15,8 @@ import ( "github.com/robertlestak/cert-manager-sync/pkg/state" "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" log "github.com/sirupsen/logrus" - "software.sslmate.com/src/go-pkcs12" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "software.sslmate.com/src/go-pkcs12" ) type VaultStore struct { @@ -222,37 +222,37 @@ func writeSecretValue(value []byte, asString bool) any { // getPasswordFromSecret retrieves the PKCS#12 password from a Kubernetes secret func (s *VaultStore) getPasswordFromSecret(c *tlssecret.Certificate) (string, error) { l := log.WithFields(log.Fields{ - "action": "getPasswordFromSecret", - "secret": s.PKCS12PassSecret, + "action": "getPasswordFromSecret", + "secret": s.PKCS12PassSecret, "namespace": s.PKCS12PassSecretNamespace, }) l.Debug("Retrieving PKCS#12 password from secret") - + // If no secret is specified, return empty string if s.PKCS12PassSecret == "" { return "", nil } - + // Get the secret from Kubernetes secret, err := state.KubeClient.CoreV1().Secrets(s.PKCS12PassSecretNamespace).Get( - context.Background(), - s.PKCS12PassSecret, + context.Background(), + s.PKCS12PassSecret, metav1.GetOptions{}, ) if err != nil { l.WithError(err).Error("Failed to get secret containing PKCS#12 password") return "", err } - + // Get the password from the secret passwordBytes, ok := secret.Data[s.PKCS12PassSecretKey] if !ok { - err := fmt.Errorf("key %s not found in secret %s/%s", + err := fmt.Errorf("key %s not found in secret %s/%s", s.PKCS12PassSecretKey, s.PKCS12PassSecretNamespace, s.PKCS12PassSecret) l.WithError(err).Error("Failed to get PKCS#12 password from secret") return "", err } - + return string(passwordBytes), nil } @@ -279,10 +279,10 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b if keyBlock == nil { return nil, "", fmt.Errorf("failed to decode key PEM") } - + var privateKey interface{} var parseErr error - + // Try different key formats if keyBlock.Type == "EC PRIVATE KEY" { privateKey, parseErr = x509.ParseECPrivateKey(keyBlock.Bytes) @@ -292,7 +292,7 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b // Try PKCS8 as a fallback privateKey, parseErr = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) } - + if parseErr != nil { return nil, "", fmt.Errorf("failed to parse private key: %v", parseErr) } @@ -315,7 +315,7 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b caCerts = append(caCerts, caCert) } } - + // If no password provided, generate a random one if password == "" { // Generate a random password @@ -348,7 +348,7 @@ func (s *VaultStore) convertToPKCS12(cert []byte, key []byte, ca []byte, c *tlss l.WithError(err).Error("Failed to get password from secret") return nil, "", fmt.Errorf("failed to get password from secret: %v", err) } - + // Convert to PKCS#12 with the password (or generate a random one if empty) return s.convertToPKCS12WithPassword(cert, key, ca, password) } @@ -375,13 +375,13 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { "vaultAuthMethod": s.AuthMethod, "id": vid, }) - + // If PKCS12 is enabled and we need to use the certificate namespace for the password secret if s.PKCS12 && s.PKCS12PassSecret != "" && s.PKCS12PassSecretNamespace == "" { // Set the namespace to the certificate namespace s.PKCS12PassSecretNamespace = c.Namespace } - + _, cerr := s.NewClient() if cerr != nil { l.WithError(cerr).Errorf("vault.NewClient error") @@ -392,16 +392,16 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { l.WithError(err).Errorf("vault.NewToken error") return nil, err } - + cd := map[string]interface{}{} - + // Always store the original PEM files cd["tls.crt"] = writeSecretValue(c.Certificate, s.Base64Decode) cd["tls.key"] = writeSecretValue(c.Key, s.Base64Decode) if len(c.Ca) > 0 { cd["ca.crt"] = writeSecretValue(c.Ca, s.Base64Decode) } - + // If PKCS#12 is enabled, convert and store the certificate in PKCS#12 format if s.PKCS12 { l.Debug("Converting certificate to PKCS#12 format") @@ -410,15 +410,15 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { l.WithError(err).Errorf("PKCS#12 conversion error") return nil, err } - + cd["pkcs12"] = writeSecretValue(pkcs12Data, s.Base64Decode) - + // Store the password if it was generated (not provided in secret) if s.PKCS12PassSecret == "" { cd["pkcs12-password"] = password } } - + _, err = s.WriteSecret(cd) if err != nil { l.WithError(err).Errorf("sync error") diff --git a/stores/vault/vault_test.go b/stores/vault/vault_test.go index 7f01d93..19dfb57 100644 --- a/stores/vault/vault_test.go +++ b/stores/vault/vault_test.go @@ -255,7 +255,7 @@ func TestPKCS12Conversion(t *testing.T) { if len(caCerts) == 0 { t.Error("No CA certificates in PKCS12 data") } - + // Verify the private key type _, ok := privateKey.(*ecdsa.PrivateKey) if !ok { @@ -298,7 +298,7 @@ func TestPKCS12Conversion(t *testing.T) { } }) }) - + // Test with RSA certificate t.Run("RSA Certificate", func(t *testing.T) { // Generate test certificate and key @@ -345,7 +345,7 @@ func TestPKCS12Conversion(t *testing.T) { if len(caCerts) == 0 { t.Error("No CA certificates in PKCS12 data") } - + // Verify the private key type _, ok := privateKey.(*rsa.PrivateKey) if !ok { @@ -353,7 +353,7 @@ func TestPKCS12Conversion(t *testing.T) { } }) }) - + // Test with certificate but no CA t.Run("Certificate without CA", func(t *testing.T) { // Generate test certificate and key @@ -413,29 +413,29 @@ func TestFromConfig(t *testing.T) { "auth-method": "kubernetes", }, want: VaultStore{ - Path: "secret/data/test", - Addr: "https://vault.example.com", - Namespace: "ns1", - Role: "role1", + Path: "secret/data/test", + Addr: "https://vault.example.com", + Namespace: "ns1", + Role: "role1", AuthMethod: "kubernetes", }, }, { name: "With base64 decode", config: map[string]string{ - "path": "secret/data/test", + "path": "secret/data/test", "base64-decode": "true", }, want: VaultStore{ - Path: "secret/data/test", + Path: "secret/data/test", Base64Decode: true, }, }, { name: "With PKCS12 enabled", config: map[string]string{ - "path": "secret/data/test", - "pkcs12": "true", + "path": "secret/data/test", + "pkcs12": "true", }, want: VaultStore{ Path: "secret/data/test", @@ -450,9 +450,9 @@ func TestFromConfig(t *testing.T) { "pkcs12-password-secret": "my-secret", }, want: VaultStore{ - Path: "secret/data/test", - PKCS12: true, - PKCS12PassSecret: "my-secret", + Path: "secret/data/test", + PKCS12: true, + PKCS12PassSecret: "my-secret", PKCS12PassSecretKey: "password", // Default value }, }, @@ -474,9 +474,9 @@ func TestFromConfig(t *testing.T) { { name: "With PKCS12 password secret and namespace", config: map[string]string{ - "path": "secret/data/test", - "pkcs12": "true", - "pkcs12-password-secret": "my-secret", + "path": "secret/data/test", + "pkcs12": "true", + "pkcs12-password-secret": "my-secret", "pkcs12-password-secret-namespace": "my-namespace", }, want: VaultStore{ @@ -499,52 +499,52 @@ func TestFromConfig(t *testing.T) { if err != nil { t.Fatalf("FromConfig() error = %v", err) } - + // Check Path if store.Path != tt.want.Path { t.Errorf("Path = %v, want %v", store.Path, tt.want.Path) } - + // Check Addr if store.Addr != tt.want.Addr { t.Errorf("Addr = %v, want %v", store.Addr, tt.want.Addr) } - + // Check Namespace if store.Namespace != tt.want.Namespace { t.Errorf("Namespace = %v, want %v", store.Namespace, tt.want.Namespace) } - + // Check Role if store.Role != tt.want.Role { t.Errorf("Role = %v, want %v", store.Role, tt.want.Role) } - + // Check AuthMethod if store.AuthMethod != tt.want.AuthMethod { t.Errorf("AuthMethod = %v, want %v", store.AuthMethod, tt.want.AuthMethod) } - + // Check Base64Decode if store.Base64Decode != tt.want.Base64Decode { t.Errorf("Base64Decode = %v, want %v", store.Base64Decode, tt.want.Base64Decode) } - + // Check PKCS12 if store.PKCS12 != tt.want.PKCS12 { t.Errorf("PKCS12 = %v, want %v", store.PKCS12, tt.want.PKCS12) } - + // Check PKCS12PassSecret if store.PKCS12PassSecret != tt.want.PKCS12PassSecret { t.Errorf("PKCS12PassSecret = %v, want %v", store.PKCS12PassSecret, tt.want.PKCS12PassSecret) } - + // Check PKCS12PassSecretKey if store.PKCS12PassSecretKey != tt.want.PKCS12PassSecretKey { t.Errorf("PKCS12PassSecretKey = %v, want %v", store.PKCS12PassSecretKey, tt.want.PKCS12PassSecretKey) } - + // Check PKCS12PassSecretNamespace if store.PKCS12PassSecretNamespace != tt.want.PKCS12PassSecretNamespace { t.Errorf("PKCS12PassSecretNamespace = %v, want %v", store.PKCS12PassSecretNamespace, tt.want.PKCS12PassSecretNamespace) @@ -579,11 +579,11 @@ func TestWriteSecretValue(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := writeSecretValue(tt.value, tt.asString) gotType := reflect.TypeOf(got).String() - + if gotType != tt.wantType { t.Errorf("writeSecretValue() type = %v, want %v", gotType, tt.wantType) } - + // Check value if tt.asString { if got.(string) != string(tt.value) { From dbae49a4f0dccfe9f50c42c983e387ad15ddc793 Mon Sep 17 00:00:00 2001 From: Igor Borodin Date: Thu, 28 Aug 2025 11:52:22 +0200 Subject: [PATCH 3/4] consistent naming --- pkg/certmanagersync/certmanagersync.go | 2 +- stores/hetznercloud/hetznercloud.go | 8 ++++---- stores/hetznercloud/hetznercloud_test.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/certmanagersync/certmanagersync.go b/pkg/certmanagersync/certmanagersync.go index 6cb6f25..a84d6cb 100644 --- a/pkg/certmanagersync/certmanagersync.go +++ b/pkg/certmanagersync/certmanagersync.go @@ -52,7 +52,7 @@ func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) { case cmtypes.HerokuStoreType: store = &heroku.HerokuStore{} case cmtypes.HetznerCloudStoreType: - store = &hetznercloud.HetznerStore{} + store = &hetznercloud.HetznerCloudStore{} case cmtypes.IncapsulaStoreType: store = &incapsula.IncapsulaStore{} case cmtypes.ThreatxStoreType: diff --git a/stores/hetznercloud/hetznercloud.go b/stores/hetznercloud/hetznercloud.go index da6ef65..3badd64 100644 --- a/stores/hetznercloud/hetznercloud.go +++ b/stores/hetznercloud/hetznercloud.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type HetznerStore struct { +type HetznerCloudStore struct { SecretName string SecretNamespace string ApiToken string @@ -22,7 +22,7 @@ type HetznerStore struct { Labels map[string]string } -func (s *HetznerStore) GetApiToken(ctx context.Context) error { +func (s *HetznerCloudStore) GetApiToken(ctx context.Context) error { gopt := metav1.GetOptions{} sc, err := state.KubeClient.CoreV1().Secrets(s.SecretNamespace).Get(ctx, s.SecretName, gopt) if err != nil { @@ -40,7 +40,7 @@ func (s *HetznerStore) GetApiToken(ctx context.Context) error { return nil } -func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { +func (s *HetznerCloudStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { l := log.WithFields(log.Fields{ "action": "FromConfig", "store": "hetznercloud", @@ -80,7 +80,7 @@ func (s *HetznerStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { return nil } -func (s *HetznerStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { +func (s *HetznerCloudStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { s.SecretNamespace = c.Namespace l := log.WithFields(log.Fields{ "action": "Update", diff --git a/stores/hetznercloud/hetznercloud_test.go b/stores/hetznercloud/hetznercloud_test.go index 1d1dc0c..6a38587 100644 --- a/stores/hetznercloud/hetznercloud_test.go +++ b/stores/hetznercloud/hetznercloud_test.go @@ -80,7 +80,7 @@ func TestIntegrationSync(t *testing.T) { } // Create store with API token - s := &HetznerStore{ + s := &HetznerCloudStore{ ApiToken: apiToken, CertName: fmt.Sprintf("cert-manager-sync-test-%d", time.Now().Unix()), SecretName: "test-secret", // Required by Sync method even though we're not using K8s From 4895ac01fccfaad879464abd2b3b6346c74547c4 Mon Sep 17 00:00:00 2001 From: Igor Borodin Date: Thu, 28 Aug 2025 15:00:07 +0200 Subject: [PATCH 4/4] in-place updates handling --- README.md | 68 ++- stores/hetznercloud/hetznercloud_test.go | 553 +++++++++++++++++++++++ 2 files changed, 617 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1248f30..cbb8bb1 100644 --- a/README.md +++ b/README.md @@ -235,10 +235,70 @@ Annotations: cert-manager-sync.lestak.sh/hetznercloud-label-team: "devops" # (optional) add more labels as needed ``` -**Notes:** -- Certificates can be attached to Hetzner Cloud Load Balancers for TLS termination -- The operator will automatically handle certificate renewals by creating new certificates and removing old ones -- If a certificate is in use by a Load Balancer, the operator will create a new certificate with a modified name instead of deleting the in-use certificate +**Certificate Updates and Load Balancer Integration:** + +When cert-manager-sync updates a certificate in Hetzner Cloud: + +1. **Certificate NOT in use**: The old certificate is deleted and a new one is created with the same name. No action required. + +2. **Certificate in use by Load Balancer**: Since Hetzner Cloud prevents deletion of certificates that are in use: + - A new certificate is created with a modified name: `original-name-{old-cert-id}` + - The old certificate remains attached to the Load Balancer + - **Manual action required**: Update your Load Balancer to use the new certificate + +**Important for Hetzner Cloud Controller Manager users:** + +The Hetzner Cloud Controller Manager (for Kubernetes LoadBalancer services) identifies certificates by their exact name or ID specified in the annotation: +```yaml +load-balancer.hetzner.cloud/http-certificates: "my-cert-name" +``` + +When cert-manager-sync creates a new certificate with a modified name (due to the old one being in use), you must: +1. Update the annotation to the new certificate name +2. Apply the service changes to trigger the Load Balancer update + +**Example workflow for certificate renewal when in use:** +1. cert-manager renews the certificate +2. cert-manager-sync attempts to update in Hetzner Cloud +3. If the old certificate is in use, a new one is created as `my-cert-name-12345` +4. Update your service annotation: `load-balancer.hetzner.cloud/http-certificates: "my-cert-name-12345"` +5. Apply the service to update the Load Balancer +6. Once updated, the old certificate can be manually deleted from Hetzner Cloud Console + +**Labels Support:** +Certificates can be labeled for organization and tracking purposes. Use annotations like: +- `cert-manager-sync.lestak.sh/hetznercloud-label-environment: "production"` +- `cert-manager-sync.lestak.sh/hetznercloud-label-managed-by: "cert-manager-sync"` + +**Automated Certificate Rotation Monitoring:** + +For production environments, you can automate the certificate rotation process using monitoring: + +1. **Use Prometheus Blackbox Exporter** to monitor SSL certificate expiry: + - The `probe_ssl_earliest_cert_expiry` metric provides Unix timestamp of certificate expiration + - Calculate days until expiry: `(probe_ssl_earliest_cert_expiry - time()) / 86400` + +2. **Configure cert-manager to renew certificates early** (before your monitoring alerts): + ```yaml + # In your Certificate resource + spec: + renewBefore: 720h # 30 days - renew well before the 7-day alert + ``` + +3. **Set up alerts** for certificates expiring soon (example for 7 days): + ```yaml + - alert: HetznerCertificateExpiringSoon + expr: (probe_ssl_earliest_cert_expiry{job="blackbox"} - time()) < 86400 * 7 + annotations: + summary: "Certificate expiring soon for {{ $labels.instance }}" + description: "Update load-balancer.hetzner.cloud/http-certificates annotation" + ``` + +4. **Automate the update process** (via CI/CD or operators): + - When alert triggers, the new certificate should already exist in Hetzner Cloud (thanks to early renewal) + - Update the Service annotation to the new certificate name: `original-name-{old-cert-id}` + - The Hetzner Cloud Controller Manager will switch to the new certificate on next reconciliation + - Old certificates can be manually deleted after the switch is confirmed Hetzner Cloud store supports optional integration testing with a real API. To run: diff --git a/stores/hetznercloud/hetznercloud_test.go b/stores/hetznercloud/hetznercloud_test.go index 6a38587..2047727 100644 --- a/stores/hetznercloud/hetznercloud_test.go +++ b/stores/hetznercloud/hetznercloud_test.go @@ -9,6 +9,7 @@ import ( "encoding/pem" "fmt" "math/big" + "net" "os" "strconv" "testing" @@ -65,6 +66,132 @@ func generateTestCertificate() (cert []byte, key []byte, error error) { return certPEM, keyPEM, nil } +// TestIntegrationSyncWithLabels tests the full sync process with labels using a real Hetzner Cloud API +// This test is skipped by default and only runs when HETZNER_TEST_TOKEN is set +func TestIntegrationSyncWithLabels(t *testing.T) { + apiToken := os.Getenv("HETZNER_TEST_TOKEN") + if apiToken == "" { + t.Skip("Skipping integration test: HETZNER_TEST_TOKEN not set") + } + + // Generate valid test certificate + testCert, testKey, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate test certificate: %v", err) + } + + // Test various label scenarios + testCases := []struct { + name string + labels map[string]string + valid bool + }{ + { + name: "valid_labels", + labels: map[string]string{ + "environment": "test", + "managed-by": "cert-manager-sync", + "version": "v1.2.3", + }, + valid: true, + }, + { + name: "kubernetes_style_labels", + labels: map[string]string{ + "app.kubernetes.io/name": "cert-sync", + "app.kubernetes.io/instance": "production", + "app.kubernetes.io/component": "certificate", + "cert-manager.io/issuer-name": "letsencrypt", + }, + valid: true, + }, + { + name: "label_with_underscore", + labels: map[string]string{ + "test_label": "value", + }, + valid: true, + }, + { + name: "empty_label_value", + labels: map[string]string{ + "empty": "", + }, + valid: true, + }, + { + name: "long_label_key", + labels: map[string]string{ + "this-is-a-very-long-label-key-that-might-exceed-limits": "value", + }, + valid: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create store with API token and labels + s := &HetznerCloudStore{ + ApiToken: apiToken, + CertName: fmt.Sprintf("cert-sync-test-labels-%d", time.Now().Unix()), + SecretName: "test-secret", + Labels: tc.labels, + } + + // Create certificate object + c := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert, + Key: testKey, + } + + // Sync certificate + updates, err := s.Sync(c) + if tc.valid && err != nil { + t.Fatalf("Expected successful sync with labels %v, but got error: %v", tc.labels, err) + } + if !tc.valid && err == nil { + t.Fatalf("Expected error for invalid labels %v, but sync succeeded", tc.labels) + } + + // Clean up if certificate was created + if updates["cert-id"] != "" { + certId, err := strconv.ParseInt(updates["cert-id"], 10, 64) + if err == nil { + client := hcloud.NewClient(hcloud.WithToken(apiToken)) + ctx := context.Background() + cert, _, err := client.Certificate.GetByID(ctx, certId) + if err == nil && cert != nil { + // Verify labels were applied correctly + if tc.valid { + // Check that we have the expected number of labels + if len(cert.Labels) != len(tc.labels) { + t.Errorf("Label count mismatch: expected %d labels, got %d", len(tc.labels), len(cert.Labels)) + t.Errorf("Expected labels: %v", tc.labels) + t.Errorf("Actual labels: %v", cert.Labels) + } + + // Check each expected label + for k, v := range tc.labels { + if cert.Labels[k] != v { + t.Errorf("Label mismatch for key %s: expected %s, got %s", k, v, cert.Labels[k]) + } + } + + // Log successful label verification + t.Logf("Labels verified successfully on certificate %d: %v", certId, cert.Labels) + } + // Clean up + _, _ = client.Certificate.Delete(ctx, cert) + t.Logf("Cleaned up test certificate with ID: %d", certId) + } + } + } + }) + } +} + // TestIntegrationSync tests the full sync process with a real Hetzner Cloud API // This test is skipped by default and only runs when HETZNER_TEST_TOKEN is set func TestIntegrationSync(t *testing.T) { @@ -131,3 +258,429 @@ func TestIntegrationSync(t *testing.T) { } } } + +// TestDuplicateCertificateNames tests what happens when multiple certificates have the same name +func TestDuplicateCertificateNames(t *testing.T) { + apiToken := os.Getenv("HETZNER_TEST_TOKEN") + if apiToken == "" { + t.Skip("Skipping integration test: HETZNER_TEST_TOKEN not set") + } + + client := hcloud.NewClient(hcloud.WithToken(apiToken)) + ctx := context.Background() + + // Generate two different certificates + testCert1, testKey1, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate first certificate: %v", err) + } + + testCert2, testKey2, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate second certificate: %v", err) + } + + // Create two certificates with the same name + sameName := fmt.Sprintf("duplicate-test-%d", time.Now().Unix()) + + // Create first certificate + cert1Opts := hcloud.CertificateCreateOpts{ + Name: sameName, + Type: hcloud.CertificateTypeUploaded, + Certificate: string(testCert1), + PrivateKey: string(testKey1), + } + + result1, _, err := client.Certificate.Create(ctx, cert1Opts) + if err != nil { + t.Fatalf("Failed to create first certificate: %v", err) + } + cert1 := result1 + t.Logf("Created first certificate: %s (ID: %d)", cert1.Name, cert1.ID) + + // Try to create second certificate with the same name + cert2Opts := hcloud.CertificateCreateOpts{ + Name: sameName, + Type: hcloud.CertificateTypeUploaded, + Certificate: string(testCert2), + PrivateKey: string(testKey2), + } + + result2, _, err := client.Certificate.Create(ctx, cert2Opts) + if err != nil { + // Check if it's a uniqueness error + if hcloud.IsError(err, hcloud.ErrorCodeUniquenessError) { + t.Logf("Cannot create second certificate with same name: uniqueness error") + } else { + t.Logf("Failed to create second certificate with error: %v", err) + } + + // Clean up first certificate + _, _ = client.Certificate.Delete(ctx, cert1) + return + } + + // If we get here, two certificates with same name were created + cert2 := result2 + t.Logf("Created second certificate with same name: %s (ID: %d)", cert2.Name, cert2.ID) + + // Test GetByName behavior + getCert, _, err := client.Certificate.GetByName(ctx, sameName) + if err != nil { + t.Logf("GetByName failed with error: %v", err) + } else if getCert != nil { + t.Logf("GetByName returned certificate ID: %d", getCert.ID) + } + + // Clean up + if cert1 != nil { + _, _ = client.Certificate.Delete(ctx, cert1) + t.Logf("Cleaned up certificate ID: %d", cert1.ID) + } + if cert2 != nil { + _, _ = client.Certificate.Delete(ctx, cert2) + t.Logf("Cleaned up certificate ID: %d", cert2.ID) + } +} + +// TestIntegrationCertificateInUse tests certificate update when the old certificate is in use by a Load Balancer +// It verifies that the store creates a new certificate with a modified name when it can't delete the old one +func TestIntegrationCertificateInUse(t *testing.T) { + apiToken := os.Getenv("HETZNER_TEST_TOKEN") + if apiToken == "" { + t.Skip("Skipping integration test: HETZNER_TEST_TOKEN not set") + } + + // Generate initial certificate + testCert1, testKey1, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate first test certificate: %v", err) + } + + // Generate updated certificate + testCert2, testKey2, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate second test certificate: %v", err) + } + + client := hcloud.NewClient(hcloud.WithToken(apiToken)) + ctx := context.Background() + + // Step 1: Create initial certificate + certName := fmt.Sprintf("cert-sync-lb-test-%d", time.Now().Unix()) + s := &HetznerCloudStore{ + ApiToken: apiToken, + CertName: certName, + SecretName: "test-secret", + } + + c1 := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert1, + Key: testKey1, + } + + // Sync initial certificate + updates1, err := s.Sync(c1) + if err != nil { + t.Fatalf("Failed to sync initial certificate: %v", err) + } + + initialCertId, err := strconv.ParseInt(updates1["cert-id"], 10, 64) + if err != nil { + t.Fatalf("Failed to parse initial cert ID: %v", err) + } + t.Logf("Created initial certificate with ID: %d", initialCertId) + + // Step 2: Create a Load Balancer and attach the certificate + lbName := fmt.Sprintf("lb-test-%d", time.Now().Unix()) + + // Get a network (using the first available network, or create one) + networks, err := client.Network.All(ctx) + if err != nil { + t.Fatalf("Failed to list networks: %v", err) + } + + var network *hcloud.Network + if len(networks) > 0 { + network = networks[0] + t.Logf("Using existing network: %s", network.Name) + } else { + // Create a network for testing + networkOpts := hcloud.NetworkCreateOpts{ + Name: fmt.Sprintf("test-network-%d", time.Now().Unix()), + IPRange: &net.IPNet{ + IP: net.IPv4(10, 0, 0, 0), + Mask: net.IPv4Mask(255, 255, 255, 0), + }, + } + networkResult, _, err := client.Network.Create(ctx, networkOpts) + if err != nil { + t.Fatalf("Failed to create test network: %v", err) + } + network = networkResult + t.Logf("Created test network: %s", network.Name) + defer func() { + _, err := client.Network.Delete(ctx, network) + if err != nil { + t.Logf("Warning: Failed to delete test network: %v", err) + } + }() + } + + // Create subnet for the network if it doesn't have one + if len(network.Subnets) == 0 { + subnetOpts := hcloud.NetworkAddSubnetOpts{ + Subnet: hcloud.NetworkSubnet{ + Type: hcloud.NetworkSubnetTypeCloud, + NetworkZone: hcloud.NetworkZoneEUCentral, + IPRange: &net.IPNet{ + IP: net.IPv4(10, 0, 0, 0), + Mask: net.IPv4Mask(255, 255, 255, 0), + }, + }, + } + _, _, err := client.Network.AddSubnet(ctx, network, subnetOpts) + if err != nil { + t.Logf("Warning: Failed to add subnet to network: %v", err) + } + } + + // Get location for Load Balancer + locations, err := client.Location.All(ctx) + if err != nil { + t.Fatalf("Failed to list locations: %v", err) + } + if len(locations) == 0 { + t.Fatal("No locations available") + } + location := locations[0] + + // Helper function to get pointer to bool + boolPtr := func(b bool) *bool { return &b } + intPtr := func(i int) *int { return &i } + + // Create Load Balancer + lbOpts := hcloud.LoadBalancerCreateOpts{ + Name: lbName, + LoadBalancerType: &hcloud.LoadBalancerType{Name: "lb11"}, + Location: location, + PublicInterface: boolPtr(true), + Labels: map[string]string{ + "test": "cert-manager-sync", + }, + Services: []hcloud.LoadBalancerCreateOptsService{ + { + Protocol: hcloud.LoadBalancerServiceProtocolHTTPS, + ListenPort: intPtr(443), + DestinationPort: intPtr(80), + HTTP: &hcloud.LoadBalancerCreateOptsServiceHTTP{ + Certificates: []*hcloud.Certificate{{ID: initialCertId}}, + }, + }, + }, + Targets: []hcloud.LoadBalancerCreateOptsTarget{ + { + Type: hcloud.LoadBalancerTargetTypeLabelSelector, + LabelSelector: hcloud.LoadBalancerCreateOptsTargetLabelSelector{ + Selector: "test=cert-manager-sync-nonexistent", + }, + }, + }, + } + + lbResult, _, err := client.LoadBalancer.Create(ctx, lbOpts) + if err != nil { + t.Fatalf("Failed to create Load Balancer: %v", err) + } + lb := lbResult.LoadBalancer + t.Logf("Created Load Balancer: %s (ID: %d) with certificate %d", lb.Name, lb.ID, initialCertId) + + // Clean up Load Balancer at the end + defer func() { + t.Log("Cleaning up Load Balancer...") + _, err := client.LoadBalancer.Delete(ctx, lb) + if err != nil { + t.Logf("Warning: Failed to delete test Load Balancer: %v", err) + } else { + t.Logf("Deleted test Load Balancer: %s", lb.Name) + } + }() + + // Step 3: Update the certificate (should handle the in-use certificate gracefully) + s.CertId = initialCertId // Set the existing cert ID to simulate an update + + c2 := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert2, + Key: testKey2, + } + + t.Log("Attempting to update certificate while it's attached to Load Balancer...") + updates2, err := s.Sync(c2) + if err != nil { + t.Fatalf("Failed to sync updated certificate: %v", err) + } + + updatedCertId, err := strconv.ParseInt(updates2["cert-id"], 10, 64) + if err != nil { + t.Fatalf("Failed to parse updated cert ID: %v", err) + } + + // The implementation should have created a new certificate with a modified name + if updatedCertId == initialCertId { + t.Error("Expected a new certificate ID after update, but got the same ID") + } + + t.Logf("Successfully created new certificate with ID: %d (old ID was %d)", updatedCertId, initialCertId) + + // Verify the new certificate exists and has the expected modified name + newCert, _, err := client.Certificate.GetByID(ctx, updatedCertId) + if err != nil || newCert == nil { + t.Errorf("Failed to retrieve new certificate with ID %d: %v", updatedCertId, err) + } else { + expectedName := fmt.Sprintf("%s-%d", certName, initialCertId) + if newCert.Name != expectedName { + t.Errorf("Expected certificate name %s, got %s", expectedName, newCert.Name) + } + t.Logf("Verified new certificate exists with correct name: %s (ID: %d)", newCert.Name, newCert.ID) + } + + // Clean up certificates + defer func() { + // Clean up the new certificate + if updatedCertId != initialCertId { + cert, _, err := client.Certificate.GetByID(ctx, updatedCertId) + if err == nil && cert != nil { + _, err = client.Certificate.Delete(ctx, cert) + if err != nil { + t.Logf("Warning: Failed to delete new certificate %d: %v", updatedCertId, err) + } else { + t.Logf("Cleaned up new certificate with ID: %d", updatedCertId) + } + } + } + + // Try to clean up the old certificate (may fail if still in use) + cert, _, err := client.Certificate.GetByID(ctx, initialCertId) + if err == nil && cert != nil { + _, err = client.Certificate.Delete(ctx, cert) + if err != nil { + t.Logf("Note: Could not delete initial certificate %d (expected if still in use): %v", initialCertId, err) + } else { + t.Logf("Cleaned up initial certificate with ID: %d", initialCertId) + } + } + }() +} + +// TestIntegrationCertificateUpdate tests certificate update when the old certificate is NOT in use +// It verifies that the store successfully deletes the old certificate and creates a new one with the same name +func TestIntegrationCertificateUpdate(t *testing.T) { + apiToken := os.Getenv("HETZNER_TEST_TOKEN") + if apiToken == "" { + t.Skip("Skipping integration test: HETZNER_TEST_TOKEN not set") + } + + // Generate two different certificates + testCert1, testKey1, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate first certificate: %v", err) + } + + testCert2, testKey2, err := generateTestCertificate() + if err != nil { + t.Fatalf("Failed to generate second certificate: %v", err) + } + + client := hcloud.NewClient(hcloud.WithToken(apiToken)) + ctx := context.Background() + + // Create initial certificate + certName := fmt.Sprintf("cert-update-test-%d", time.Now().Unix()) + s := &HetznerCloudStore{ + ApiToken: apiToken, + CertName: certName, + SecretName: "test-secret", + } + + c1 := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert1, + Key: testKey1, + } + + // Sync initial certificate + updates1, err := s.Sync(c1) + if err != nil { + t.Fatalf("Failed to sync initial certificate: %v", err) + } + + initialCertId, err := strconv.ParseInt(updates1["cert-id"], 10, 64) + if err != nil { + t.Fatalf("Failed to parse initial cert ID: %v", err) + } + t.Logf("Created initial certificate with ID: %d", initialCertId) + + // Update the certificate (should delete old and create new with same name) + s.CertId = initialCertId + + c2 := &tlssecret.Certificate{ + SecretName: "test-cert", + Namespace: "test", + Certificate: testCert2, + Key: testKey2, + } + + t.Log("Attempting to update certificate (not in use)...") + updates2, err := s.Sync(c2) + if err != nil { + t.Fatalf("Failed to sync updated certificate: %v", err) + } + + updatedCertId, err := strconv.ParseInt(updates2["cert-id"], 10, 64) + if err != nil { + t.Fatalf("Failed to parse updated cert ID: %v", err) + } + + // The implementation should have created a new certificate with the same name + if updatedCertId == initialCertId { + t.Error("Expected a new certificate ID after update, but got the same ID") + } + + t.Logf("Successfully created new certificate with ID: %d (old ID was %d)", updatedCertId, initialCertId) + + // Verify the new certificate exists with the same name + newCert, _, err := client.Certificate.GetByID(ctx, updatedCertId) + if err != nil || newCert == nil { + t.Errorf("Failed to retrieve new certificate with ID %d: %v", updatedCertId, err) + } else { + if newCert.Name != certName { + t.Errorf("Expected certificate name %s, got %s", certName, newCert.Name) + } + t.Logf("Verified new certificate exists with same name: %s (ID: %d)", newCert.Name, newCert.ID) + } + + // Verify old certificate was deleted + oldCert, _, err := client.Certificate.GetByID(ctx, initialCertId) + if err == nil && oldCert != nil { + t.Errorf("Old certificate %d still exists, expected it to be deleted", initialCertId) + // Clean up if still exists + _, _ = client.Certificate.Delete(ctx, oldCert) + } else { + t.Logf("Confirmed old certificate %d was deleted", initialCertId) + } + + // Clean up new certificate + if newCert != nil { + _, err = client.Certificate.Delete(ctx, newCert) + if err != nil { + t.Logf("Warning: Failed to delete new certificate %d: %v", updatedCertId, err) + } else { + t.Logf("Cleaned up new certificate with ID: %d", updatedCertId) + } + } +}