|
|
@@ -0,0 +1,452 @@
|
|
|
+/*
|
|
|
+Copyright © The ESO Authors
|
|
|
+
|
|
|
+Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
+you may not use this file except in compliance with the License.
|
|
|
+You may obtain a copy of the License at
|
|
|
+
|
|
|
+ https://www.apache.org/licenses/LICENSE-2.0
|
|
|
+
|
|
|
+Unless required by applicable law or agreed to in writing, software
|
|
|
+distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
+See the License for the specific language governing permissions and
|
|
|
+limitations under the License.
|
|
|
+*/
|
|
|
+
|
|
|
+package webhook
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "context"
|
|
|
+ "crypto/tls"
|
|
|
+ "crypto/x509"
|
|
|
+ "encoding/json"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
+ "net/url"
|
|
|
+ tpl "text/template"
|
|
|
+
|
|
|
+ "github.com/Azure/go-ntlmssp"
|
|
|
+ "github.com/PaesslerAG/jsonpath"
|
|
|
+ corev1 "k8s.io/api/core/v1"
|
|
|
+ "sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
+
|
|
|
+ esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
|
|
|
+ esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
|
|
|
+ "github.com/external-secrets/external-secrets/runtime/constants"
|
|
|
+ "github.com/external-secrets/external-secrets/runtime/esutils"
|
|
|
+ "github.com/external-secrets/external-secrets/runtime/metrics"
|
|
|
+ "github.com/external-secrets/external-secrets/runtime/template/v2"
|
|
|
+)
|
|
|
+
|
|
|
+// Webhook implements functionality to interact with webhook endpoints
|
|
|
+// to retrieve and push secrets.
|
|
|
+type Webhook struct {
|
|
|
+ Kube client.Client
|
|
|
+ Namespace string
|
|
|
+ StoreKind string
|
|
|
+ HTTP *http.Client
|
|
|
+ EnforceLabels bool
|
|
|
+ ClusterScoped bool
|
|
|
+}
|
|
|
+
|
|
|
+func (w *Webhook) getStoreSecret(ctx context.Context, ref esmeta.SecretKeySelector) (*corev1.Secret, error) {
|
|
|
+ ke := client.ObjectKey{
|
|
|
+ Name: ref.Name,
|
|
|
+ Namespace: w.Namespace,
|
|
|
+ }
|
|
|
+ if w.ClusterScoped {
|
|
|
+ if ref.Namespace == nil {
|
|
|
+ return nil, fmt.Errorf("no namespace on ClusterScoped webhook secret %s", ref.Name)
|
|
|
+ }
|
|
|
+ ke.Namespace = *ref.Namespace
|
|
|
+ }
|
|
|
+ secret := &corev1.Secret{}
|
|
|
+ if err := w.Kube.Get(ctx, ke, secret); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to get clustersecretstore webhook secret %s: %w", ref.Name, err)
|
|
|
+ }
|
|
|
+ if w.EnforceLabels {
|
|
|
+ expected, ok := secret.Labels["external-secrets.io/type"]
|
|
|
+ if !ok {
|
|
|
+ return nil, errors.New("secret does not contain needed label 'external-secrets.io/type: webhook'. Update secret label to use it with webhook")
|
|
|
+ }
|
|
|
+ if expected != "webhook" {
|
|
|
+ return nil, errors.New("secret type is not 'webhook'")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return secret, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetSecretMap retrieves a secret from a webhook endpoint and processes
|
|
|
+// the response as a map of key-value pairs.
|
|
|
+func (w *Webhook) GetSecretMap(ctx context.Context, provider *Spec, ref *esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
|
|
|
+ result, err := w.GetWebhookData(ctx, provider, ref)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ // We always want json here, so just parse it out
|
|
|
+ jsondata := any(nil)
|
|
|
+ if err := json.Unmarshal(result, &jsondata); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to parse response json: %w", err)
|
|
|
+ }
|
|
|
+ // Get subdata via jsonpath, if given
|
|
|
+ if provider.Result.JSONPath != "" {
|
|
|
+ jsondata, err = jsonpath.Get(provider.Result.JSONPath, jsondata)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to get response path %s: %w", provider.Result.JSONPath, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // If the value is a string, try to parse it as json
|
|
|
+ jsonstring, ok := jsondata.(string)
|
|
|
+ if ok {
|
|
|
+ // This could also happen if the response was a single json-encoded string
|
|
|
+ // but that is an extremely unlikely scenario
|
|
|
+ if err := json.Unmarshal([]byte(jsonstring), &jsondata); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to parse response json from jsonpath: %w", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Use the data as a key-value map
|
|
|
+ jsonvalue, ok := jsondata.(map[string]any)
|
|
|
+ if !ok {
|
|
|
+ return nil, fmt.Errorf("failed to get response (wrong type: %T)", jsondata)
|
|
|
+ }
|
|
|
+ // Change the map of generic objects to a map of byte arrays
|
|
|
+ values := make(map[string][]byte)
|
|
|
+ for rKey := range jsonvalue {
|
|
|
+ values[rKey], err = esutils.GetByteValueFromMap(jsonvalue, rKey)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to get response for key '%s': %w", rKey, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return values, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetTemplateData prepares the template data for webhook requests based on the given remote reference.
|
|
|
+func (w *Webhook) GetTemplateData(ctx context.Context, ref *esv1.ExternalSecretDataRemoteRef, secrets []Secret, urlEncode bool) (map[string]map[string]string, error) {
|
|
|
+ data := map[string]map[string]string{}
|
|
|
+ if ref != nil {
|
|
|
+ if urlEncode {
|
|
|
+ data["remoteRef"] = map[string]string{
|
|
|
+ "key": url.QueryEscape(ref.Key),
|
|
|
+ "version": url.QueryEscape(ref.Version),
|
|
|
+ "property": url.QueryEscape(ref.Property),
|
|
|
+ "namespace": w.Namespace,
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ data["remoteRef"] = map[string]string{
|
|
|
+ "key": ref.Key,
|
|
|
+ "version": ref.Version,
|
|
|
+ "property": ref.Property,
|
|
|
+ "namespace": w.Namespace,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := w.getTemplatedSecrets(ctx, secrets, data); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return data, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetTemplatePushData prepares the template data for webhook push requests.
|
|
|
+func (w *Webhook) GetTemplatePushData(ctx context.Context, ref esv1.PushSecretData, secrets []Secret, urlEncode bool) (map[string]map[string]string, error) {
|
|
|
+ data := map[string]map[string]string{}
|
|
|
+ if ref != nil {
|
|
|
+ if urlEncode {
|
|
|
+ data["remoteRef"] = map[string]string{
|
|
|
+ "remoteKey": url.QueryEscape(ref.GetRemoteKey()),
|
|
|
+ }
|
|
|
+ if v := ref.GetSecretKey(); v != "" {
|
|
|
+ data["remoteRef"]["secretKey"] = url.QueryEscape(v)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ data["remoteRef"] = map[string]string{
|
|
|
+ "remoteKey": ref.GetRemoteKey(),
|
|
|
+ }
|
|
|
+ if v := ref.GetSecretKey(); v != "" {
|
|
|
+ data["remoteRef"]["secretKey"] = v
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := w.getTemplatedSecrets(ctx, secrets, data); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ return data, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (w *Webhook) getTemplatedSecrets(ctx context.Context, secrets []Secret, data map[string]map[string]string) error {
|
|
|
+ for _, secref := range secrets {
|
|
|
+ if _, ok := data[secref.Name]; !ok {
|
|
|
+ data[secref.Name] = make(map[string]string)
|
|
|
+ }
|
|
|
+ secret, err := w.getStoreSecret(ctx, secref.SecretRef)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for sKey, sVal := range secret.Data {
|
|
|
+ data[secref.Name][sKey] = string(sVal)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetWebhookData makes a request to the webhook endpoint and returns the raw response data.
|
|
|
+func (w *Webhook) GetWebhookData(ctx context.Context, provider *Spec, ref *esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
|
|
|
+ if w.HTTP == nil {
|
|
|
+ return nil, errors.New("http client not initialized")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse store secrets
|
|
|
+ escapedData, err := w.GetTemplateData(ctx, ref, provider.Secrets, true)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ rawData, err := w.GetTemplateData(ctx, ref, provider.Secrets, false)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ // set method
|
|
|
+ method := provider.Method
|
|
|
+ if method == "" {
|
|
|
+ method = http.MethodGet
|
|
|
+ }
|
|
|
+
|
|
|
+ // set url
|
|
|
+ url, err := ExecuteTemplateString(provider.URL, escapedData)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to parse url: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // set body
|
|
|
+ body, err := ExecuteTemplate(provider.Body, rawData)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to parse body: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return w.executeRequest(ctx, provider, body.Bytes(), url, method, rawData)
|
|
|
+}
|
|
|
+
|
|
|
+// PushWebhookData pushes data to a webhook endpoint.
|
|
|
+func (w *Webhook) PushWebhookData(ctx context.Context, provider *Spec, data []byte, remoteKey esv1.PushSecretData) error {
|
|
|
+ if w.HTTP == nil {
|
|
|
+ return errors.New("http client not initialized")
|
|
|
+ }
|
|
|
+
|
|
|
+ method := provider.Method
|
|
|
+ if method == "" {
|
|
|
+ method = http.MethodPost
|
|
|
+ }
|
|
|
+
|
|
|
+ escapedData, err := w.GetTemplatePushData(ctx, remoteKey, provider.Secrets, true)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ escapedData["remoteRef"][remoteKey.GetRemoteKey()] = url.QueryEscape(string(data))
|
|
|
+
|
|
|
+ rawData, err := w.GetTemplatePushData(ctx, remoteKey, provider.Secrets, false)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ rawData["remoteRef"][remoteKey.GetRemoteKey()] = string(data)
|
|
|
+
|
|
|
+ url, err := ExecuteTemplateString(provider.URL, escapedData)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to parse url: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ bodyt := provider.Body
|
|
|
+ if bodyt == "" {
|
|
|
+ bodyt = fmt.Sprintf("{{ .remoteRef.%s }}", remoteKey.GetRemoteKey())
|
|
|
+ }
|
|
|
+ body, err := ExecuteTemplate(bodyt, rawData)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to parse body: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if _, err := w.executeRequest(ctx, provider, body.Bytes(), url, method, rawData); err != nil {
|
|
|
+ return fmt.Errorf("failed to push webhook data: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (w *Webhook) executeRequest(ctx context.Context, provider *Spec, data []byte, url, method string, rawData map[string]map[string]string) ([]byte, error) {
|
|
|
+ req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data))
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if provider.Headers != nil {
|
|
|
+ req, err = w.ReqAddHeaders(req, provider, rawData)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if provider.Auth != nil {
|
|
|
+ req, err = w.ReqAddAuth(ctx, req, provider)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ resp, err := w.HTTP.Do(req)
|
|
|
+ metrics.ObserveAPICall(constants.ProviderWebhook, constants.CallWebhookHTTPReq, err)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to call endpoint: %w", err)
|
|
|
+ }
|
|
|
+ defer func() {
|
|
|
+ _ = resp.Body.Close()
|
|
|
+ }()
|
|
|
+ if resp.StatusCode == 404 {
|
|
|
+ return nil, esv1.NoSecretError{}
|
|
|
+ }
|
|
|
+
|
|
|
+ if resp.StatusCode == http.StatusNotModified {
|
|
|
+ return nil, esv1.NotModifiedError{}
|
|
|
+ }
|
|
|
+
|
|
|
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
|
+ return nil, fmt.Errorf("endpoint gave error %s", resp.Status)
|
|
|
+ }
|
|
|
+
|
|
|
+ // return response body
|
|
|
+ return io.ReadAll(resp.Body)
|
|
|
+}
|
|
|
+
|
|
|
+// ReqAddHeaders adds headers to an HTTP request based on provider configuration.
|
|
|
+func (w *Webhook) ReqAddHeaders(r *http.Request, provider *Spec, rawData map[string]map[string]string) (*http.Request, error) {
|
|
|
+ reqWithHeaders := r
|
|
|
+
|
|
|
+ for hKey, hValueTpl := range provider.Headers {
|
|
|
+ hValue, err := ExecuteTemplateString(hValueTpl, rawData)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to parse header %s: %w", hKey, err)
|
|
|
+ }
|
|
|
+ reqWithHeaders.Header.Add(hKey, hValue)
|
|
|
+ }
|
|
|
+
|
|
|
+ return reqWithHeaders, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ReqAddAuth adds authentication to an HTTP request based on provider configuration.
|
|
|
+func (w *Webhook) ReqAddAuth(ctx context.Context, r *http.Request, provider *Spec) (*http.Request, error) {
|
|
|
+ reqWithAuth := r
|
|
|
+
|
|
|
+ //nolint:gocritic // singleCaseSwitch: we prefer to keep it as a switch for clarity
|
|
|
+ switch {
|
|
|
+ case provider.Auth.NTLM != nil:
|
|
|
+ userSecretRef := provider.Auth.NTLM.UserName
|
|
|
+ userSecret, err := w.getStoreSecret(ctx, userSecretRef)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ username := string(userSecret.Data[userSecretRef.Key])
|
|
|
+
|
|
|
+ PasswordSecretRef := provider.Auth.NTLM.Password
|
|
|
+ PasswordSecret, err := w.getStoreSecret(ctx, PasswordSecretRef)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ password := string(PasswordSecret.Data[PasswordSecretRef.Key])
|
|
|
+
|
|
|
+ // This overwrites auth headers set by providers.headers
|
|
|
+ reqWithAuth.SetBasicAuth(username, password)
|
|
|
+ }
|
|
|
+ return reqWithAuth, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetHTTPClient returns an HTTP client configured according to the provider specification.
|
|
|
+func (w *Webhook) GetHTTPClient(ctx context.Context, provider *Spec) (*http.Client, error) {
|
|
|
+ c := &http.Client{}
|
|
|
+
|
|
|
+ // add timeout to client if it is there
|
|
|
+ if provider.Timeout != nil {
|
|
|
+ c.Timeout = provider.Timeout.Duration
|
|
|
+ }
|
|
|
+
|
|
|
+ // add CA to client if it is there
|
|
|
+ if len(provider.CABundle) > 0 || provider.CAProvider != nil {
|
|
|
+ caCertPool, err := w.GetCACertPool(ctx, provider)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ tlsConf := &tls.Config{
|
|
|
+ RootCAs: caCertPool,
|
|
|
+ MinVersion: tls.VersionTLS12,
|
|
|
+ Renegotiation: tls.RenegotiateOnceAsClient,
|
|
|
+ }
|
|
|
+
|
|
|
+ c.Transport = &http.Transport{TLSClientConfig: tlsConf}
|
|
|
+ }
|
|
|
+ // add authentication method if it s there
|
|
|
+ if provider.Auth != nil {
|
|
|
+ if provider.Auth.NTLM != nil {
|
|
|
+ c.Transport =
|
|
|
+ &ntlmssp.Negotiator{
|
|
|
+ RoundTripper: &http.Transport{
|
|
|
+ TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, // Needed to disable HTTP/2
|
|
|
+
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // add additional auth methods here
|
|
|
+ }
|
|
|
+
|
|
|
+ // return client with all add-ons
|
|
|
+ return c, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetCACertPool returns a certificate pool for TLS connections based on provider configuration.
|
|
|
+func (w *Webhook) GetCACertPool(ctx context.Context, provider *Spec) (*x509.CertPool, error) {
|
|
|
+ caCertPool := x509.NewCertPool()
|
|
|
+ ca, err := esutils.FetchCACertFromSource(ctx, esutils.CreateCertOpts{
|
|
|
+ CABundle: provider.CABundle,
|
|
|
+ CAProvider: provider.CAProvider,
|
|
|
+ StoreKind: w.StoreKind,
|
|
|
+ Namespace: w.Namespace,
|
|
|
+ Client: w.Kube,
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ ok := caCertPool.AppendCertsFromPEM(ca)
|
|
|
+ if !ok {
|
|
|
+ return nil, errors.New("failed to append cabundle")
|
|
|
+ }
|
|
|
+
|
|
|
+ return caCertPool, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ExecuteTemplateString executes a template and returns the result as a string.
|
|
|
+func ExecuteTemplateString(tmpl string, data map[string]map[string]string) (string, error) {
|
|
|
+ result, err := ExecuteTemplate(tmpl, data)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ return result.String(), nil
|
|
|
+}
|
|
|
+
|
|
|
+// ExecuteTemplate executes a template and returns the result as a bytes.Buffer.
|
|
|
+func ExecuteTemplate(tmpl string, data map[string]map[string]string) (bytes.Buffer, error) {
|
|
|
+ var result bytes.Buffer
|
|
|
+ if tmpl == "" {
|
|
|
+ return result, nil
|
|
|
+ }
|
|
|
+ urlt, err := tpl.New("webhooktemplate").Funcs(template.FuncMap()).Parse(tmpl)
|
|
|
+ if err != nil {
|
|
|
+ return result, err
|
|
|
+ }
|
|
|
+ if err := urlt.Execute(&result, data); err != nil {
|
|
|
+ return result, err
|
|
|
+ }
|
|
|
+ return result, nil
|
|
|
+}
|