diff --git a/Makefile b/Makefile index 6f1b2d7..6b74cca 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # To re-generate a bundle for another specific version without changing the standard setup, you can: # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 0.2.1 +VERSION ?= 0.3.0 # CHANNELS define the bundle channels used in the bundle. # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") @@ -104,6 +104,7 @@ $(HELMIFY): $(LOCALBIN) helm: manifests kustomize helmify ## Generate helm chart using helmify. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default | $(HELMIFY) + ./hack/add-ca-cert-to-helm.sh .PHONY: fmt diff --git a/README.md b/README.md index 17898cc..8a92385 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # dvls-kubernetes-operator :warning: **This operator is a work in progress, expect breaking changes between releases** :warning: -Operator to sync Devolutions Server `Credential Entry - Username / Password` entries as Kubernetes Secrets +Operator to sync Devolutions Server `Credential Entry` entries as Kubernetes Secrets ## Description This operator uses the defined custom resource DvlsSecret which manages its own Kubernetes Secret and will keep itself up to date at a defined interval (every minute by default). @@ -13,6 +13,7 @@ The following Environment Variables can be used to configure the operator : - `DEVO_OPERATOR_DVLS_APPID` (required) - DVLS Application ID - `DEVO_OPERATOR_DVLS_APPSECRET` (required) - DVLS Application Secret - `DEVO_OPERATOR_REQUEUE_DURATION` (optional) - Entry/Secret resync interval (default 60s). Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". +- `SSL_CERT_FILE` (optional) - Path to a custom CA certificate file for DVLS servers with self-signed certificates. This is automatically set by the Helm chart when `instanceSecret.caCert` is provided. A sample of the custom resource can be found [here](https://github.com/Devolutions/dvls-kubernetes-operator/blob/master/config/samples/dvls_v1alpha1_dvlssecret.yaml). The entry ID can be fetched by going in the entry properties, `Advanced -> Session ID`. @@ -20,10 +21,7 @@ The entry ID can be fetched by going in the entry properties, `Advanced -> Sessi ### Devolutions Server configuration We recommend creating an [Application ID](https://helpserver.devolutions.net/webinterface_applications.html?q=application) specifically to be used with the Operator that has [minimal access to a vault](https://helpserver.devolutions.net/vaults_applications.html?q=application) that only contains the secrets to be synchronized. -Only `Credential Entry - Username / Password` entries are supported at the moment. The following entry data is available per secret : -- entry name -- username -- password +Only `Credential Entry` entries are supported at the moment. The available entry data will depend on the `Credential Entry` type. ### Kubernetes configuration Since this operator uses Kubernetes Secrets, it is recommended that you follow [best practices](https://kubernetes.io/docs/concepts/security/secrets-good-practices/) surrounding secrets, especially [encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/). @@ -33,17 +31,73 @@ You’ll need a Kubernetes cluster to run against. You can use [KIND](https://si **Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster `kubectl cluster-info` shows). ### Helm Chart -An Helm Chart is available to simplify installation, just add our helm chart repository, create a `values.yaml` from [the default values](https://github.com/Devolutions/dvls-kubernetes-operator/blob/master/chart/values.yaml) as a baseline and update values as necessary. Run `helm install` and add your `values.yaml`. -The following values should be updated from your `values.yaml` -- controllerManager.manager.env.devoOperatorDvlsBaseuri -- controllerManager.manager.env.devoOperatorDvlsAppid -- instanceSecret.secret +A Helm Chart is available to simplify installation. Add the Devolutions Helm chart repository, create a `values.yaml` from [the default values](https://github.com/Devolutions/dvls-kubernetes-operator/blob/master/chart/values.yaml) as a baseline, and update values as necessary. + +#### Required Configuration +The following values **must** be configured in your `values.yaml`: +- `controllerManager.manager.env.devoOperatorDvlsBaseuri` - Your DVLS server URL (e.g., `https://dvls.example.com`) +- `controllerManager.manager.env.devoOperatorDvlsAppid` - Application ID from your DVLS server +- `instanceSecret.secret` - Application Secret from your DVLS server + +#### Optional Configuration +- `instanceSecret.caCert` - Custom CA certificate for self-signed DVLS servers (see below) +- `controllerManager.manager.env.devoOperatorRequeueDuration` - How often to sync secrets (default: `60s`) + +#### Basic Example Configuration + +Create a `values.yaml` file with your DVLS configuration: + +```yaml +controllerManager: + manager: + env: + devoOperatorDvlsAppid: "00000000-0000-0000-0000-000000000000" + devoOperatorDvlsBaseuri: "https://dvls.example.com" + devoOperatorRequeueDuration: "60s" + +instanceSecret: + secret: "your-app-secret-here" +``` + +#### Installation ```sh helm repo add devolutions-helm-charts https://devolutions.github.io/helm-charts helm repo update helm install dvls-kubernetes-operator devolutions-helm-charts/dvls-kubernetes-operator --values values.yaml ``` +#### Using a Custom CA Certificate + +If your DVLS server uses a self-signed certificate (common in test/development environments), you need to provide the CA certificate so the operator can establish a trusted TLS connection. + +**When to use this:** +- Testing with self-signed certificates +- Internal CA certificates not in the system trust store +- Development/staging environments with custom PKI + +**Configuration:** + +Add the CA certificate content to your `values.yaml`: + +```yaml +controllerManager: + manager: + env: + devoOperatorDvlsAppid: "00000000-0000-0000-0000-000000000000" + devoOperatorDvlsBaseuri: "https://dvls.example.com" + devoOperatorRequeueDuration: "60s" + +instanceSecret: + secret: "your-app-secret" + # Add your CA certificate here (PEM format) + caCert: | + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAKZ... + (your CA certificate content) + ... + -----END CERTIFICATE----- +``` + ### Running on the cluster 1. Install Instances of Custom Resources: diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 2ce4402..dacb2bb 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.2.1 +version: 0.3.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.2.1" +appVersion: "0.3.0" diff --git a/chart/templates/ca-cert-secret.yaml b/chart/templates/ca-cert-secret.yaml new file mode 100644 index 0000000..07aa093 --- /dev/null +++ b/chart/templates/ca-cert-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.instanceSecret.caCert }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "chart.fullname" . }}-ca-cert + labels: + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: dvls-kubernetes-operator + app.kubernetes.io/part-of: dvls-kubernetes-operator + control-plane: controller-manager + {{- include "chart.labels" . | nindent 4 }} +stringData: + ca.crt: {{ .Values.instanceSecret.caCert | quote }} +{{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 14b5f6b..b0e3fa3 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -43,6 +43,10 @@ spec: env: - name: KUBERNETES_CLUSTER_DOMAIN value: {{ quote .Values.kubernetesClusterDomain }} + {{- if .Values.instanceSecret.caCert }} + - name: SSL_CERT_FILE + value: /etc/dvls-ca-cert/ca.crt + {{- end }} image: {{ .Values.controllerManager.kubeRbacProxy.image.repository }}:{{ .Values.controllerManager.kubeRbacProxy.image.tag | default .Chart.AppVersion }} name: kube-rbac-proxy @@ -73,6 +77,10 @@ spec: name: {{ include "chart.fullname" . }}-instance-secret - name: KUBERNETES_CLUSTER_DOMAIN value: {{ quote .Values.kubernetesClusterDomain }} + {{- if .Values.instanceSecret.caCert }} + - name: SSL_CERT_FILE + value: /etc/dvls-ca-cert/ca.crt + {{- end }} image: {{ .Values.controllerManager.manager.image.repository }}:{{ .Values.controllerManager.manager.image.tag | default .Chart.AppVersion }} livenessProbe: @@ -92,7 +100,22 @@ spec: }} securityContext: {{- toYaml .Values.controllerManager.manager.containerSecurityContext | nindent 10 }} + {{- if .Values.instanceSecret.caCert }} + volumeMounts: + - name: dvls-ca-cert + mountPath: /etc/dvls-ca-cert + readOnly: true + {{- end }} securityContext: {{- toYaml .Values.controllerManager.podSecurityContext | nindent 8 }} serviceAccountName: {{ include "chart.fullname" . }}-controller-manager terminationGracePeriodSeconds: 10 + {{- if .Values.instanceSecret.caCert }} + volumes: + - name: dvls-ca-cert + secret: + secretName: {{ include "chart.fullname" . }}-ca-cert + items: + - key: ca.crt + path: ca.crt + {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index b30efa5..a25aa70 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -36,7 +36,7 @@ controllerManager: devoOperatorRequeueDuration: 60s image: repository: devolutions/dvls-kubernetes-operator - tag: 0.2.1 + tag: 0.3.0 resources: limits: memory: 128Mi diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 6805105..27b415d 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -6,4 +6,4 @@ kind: Kustomization images: - name: controller newName: devolutions/dvls-kubernetes-operator - newTag: 0.2.1 + newTag: 0.3.0 diff --git a/controllers/dvlssecret_controller.go b/controllers/dvlssecret_controller.go index 309e899..826d958 100644 --- a/controllers/dvlssecret_controller.go +++ b/controllers/dvlssecret_controller.go @@ -79,7 +79,7 @@ func (r *DvlsSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, fmt.Errorf("failed to get DvlsSecret object, %w", err) } - if dvlsSecret.Status.Conditions == nil || len(dvlsSecret.Status.Conditions) == 0 || dvlsSecret.Status.EntryModifiedDate.IsZero() { + if len(dvlsSecret.Status.Conditions) == 0 || dvlsSecret.Status.EntryModifiedDate.IsZero() { meta.SetStatusCondition(&dvlsSecret.Status.Conditions, v1.Condition{Type: statusAvailableDvlsSecret, Status: v1.ConditionUnknown, Reason: "Reconciling"}) dvlsSecret.Status.EntryModifiedDate = v1.Date(0001, time.January, 1, 1, 1, 1, 1, time.UTC) if err := r.Status().Update(ctx, dvlsSecret); err != nil { @@ -102,15 +102,6 @@ func (r *DvlsSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, nil } - if entry.Type != string(dvls.ServerConnectionCredential) || entry.SubType != string(dvls.ServerConnectionSubTypeDefault) { - log.Error(err, "entry type not supported, only username/password entries are supported", "entryId", dvlsSecret.Spec.EntryID, "entryType", entry.Type, "entrySubType", entry.SubType) - meta.SetStatusCondition(&dvlsSecret.Status.Conditions, v1.Condition{Type: statusDegradedDvlsSecret, Status: v1.ConditionTrue, Reason: "Reconciling", Message: "Entry type not supported, only username/password entries are supported"}) - if err := r.Status().Update(ctx, dvlsSecret); err != nil { - log.Error(err, "Failed to update DvlsSecret status") - } - return ctrl.Result{}, nil - } - kSecret := &corev1.Secret{} err = r.Get(ctx, req.NamespacedName, kSecret) if err != nil && !apierrors.IsNotFound(err) { @@ -130,19 +121,9 @@ func (r *DvlsSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) }, nil } - defaultData, ok := entry.GetCredentialDefaultData() - if !ok { - return ctrl.Result{}, fmt.Errorf( - "failed to extract credential data for entry ID %s: unsupported or unexpected entry type (type: %s, subtype: %s)", - dvlsSecret.Spec.EntryID, entry.Type, entry.SubType) - } - - secretMap := make(map[string]string) - secretMap["entry-id"] = entry.Id - secretMap["entry-name"] = entry.Name - secretMap["username"] = defaultData.Username - if defaultData.Password != "" { - secretMap["password"] = defaultData.Password + secretMap, err := setSecretMap(entry) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to set secret map, %w", err) } if kSecretNotFound { @@ -210,3 +191,87 @@ func (r *DvlsSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.Secret{}). Complete(r) } + +func setSecretMap(entry dvls.Entry) (map[string]string, error) { + secretMap := make(map[string]string) + secretMap["entry-id"] = entry.Id + secretMap["entry-name"] = entry.Name + + switch entry.SubType { + case dvls.EntryCredentialSubTypeDefault: + if data, ok := entry.GetCredentialDefaultData(); ok { + if data.Username != "" { + secretMap["username"] = data.Username + } + if data.Password != "" { + secretMap["password"] = data.Password + } + if data.Domain != "" { + secretMap["domain"] = data.Domain + } + } + + case dvls.EntryCredentialSubTypeAccessCode: + if data, ok := entry.GetCredentialAccessCodeData(); ok { + if data.Password != "" { + secretMap["password"] = data.Password + } + } + + case dvls.EntryCredentialSubTypeApiKey: + if data, ok := entry.GetCredentialApiKeyData(); ok { + if data.ApiId != "" { + secretMap["api-id"] = data.ApiId + } + if data.ApiKey != "" { + secretMap["api-key"] = data.ApiKey + } + if data.TenantId != "" { + secretMap["tenant-id"] = data.TenantId + } + } + + case dvls.EntryCredentialSubTypeAzureServicePrincipal: + if data, ok := entry.GetCredentialAzureServicePrincipalData(); ok { + if data.ClientId != "" { + secretMap["client-id"] = data.ClientId + } + if data.ClientSecret != "" { + secretMap["client-secret"] = data.ClientSecret + } + if data.TenantId != "" { + secretMap["tenant-id"] = data.TenantId + } + } + + case dvls.EntryCredentialSubTypeConnectionString: + if data, ok := entry.GetCredentialConnectionStringData(); ok { + if data.ConnectionString != "" { + secretMap["connection-string"] = data.ConnectionString + } + } + + case dvls.EntryCredentialSubTypePrivateKey: + if data, ok := entry.GetCredentialPrivateKeyData(); ok { + if data.Username != "" { + secretMap["username"] = data.Username + } + if data.Password != "" { + secretMap["password"] = data.Password + } + if data.PrivateKey != "" { + secretMap["private-key"] = data.PrivateKey + } + if data.PublicKey != "" { + secretMap["public-key"] = data.PublicKey + } + if data.Passphrase != "" { + secretMap["passphrase"] = data.Passphrase + } + } + default: + return nil, fmt.Errorf("unsupported credential subtype: %s", entry.SubType) + } + + return secretMap, nil +} diff --git a/hack/add-ca-cert-to-helm.sh b/hack/add-ca-cert-to-helm.sh new file mode 100755 index 0000000..576dd27 --- /dev/null +++ b/hack/add-ca-cert-to-helm.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -euo pipefail + +DEPLOYMENT_FILE="chart/templates/deployment.yaml" + +if [ ! -f "$DEPLOYMENT_FILE" ]; then + echo "Error: $DEPLOYMENT_FILE not found" + exit 1 +fi + +TMP_FILE=$(mktemp) +trap 'rm -f "$TMP_FILE"' EXIT + +awk ' +/^ - name: KUBERNETES_CLUSTER_DOMAIN$/ { + print + getline + print + print " {{- if .Values.instanceSecret.caCert }}" + print " - name: SSL_CERT_FILE" + print " value: /etc/dvls-ca-cert/ca.crt" + print " {{- end }}" + next +} +/^ securityContext: {{- toYaml .Values.controllerManager.manager.containerSecurityContext$/ { + print + getline + print + print " {{- if .Values.instanceSecret.caCert }}" + print " volumeMounts:" + print " - name: dvls-ca-cert" + print " mountPath: /etc/dvls-ca-cert" + print " readOnly: true" + print " {{- end }}" + next +} +/^ terminationGracePeriodSeconds: 10$/ { + print + print " {{- if .Values.instanceSecret.caCert }}" + print " volumes:" + print " - name: dvls-ca-cert" + print " secret:" + print " secretName: {{ include \"chart.fullname\" . }}-ca-cert" + print " items:" + print " - key: ca.crt" + print " path: ca.crt" + print " {{- end }}" + next +} +{ print } +' "$DEPLOYMENT_FILE" > "$TMP_FILE" + +mv "$TMP_FILE" "$DEPLOYMENT_FILE"