Просмотр исходного кода

feat: add runtime webhook provider support

Moritz Johner 2 месяцев назад
Родитель
Сommit
307143ae3c
4 измененных файлов с 566 добавлено и 0 удалено
  1. 3 0
      runtime/go.mod
  2. 7 0
      runtime/go.sum
  3. 104 0
      runtime/webhook/webhook/models.go
  4. 452 0
      runtime/webhook/webhook/webhook.go

+ 3 - 0
runtime/go.mod

@@ -4,8 +4,10 @@ go 1.26.2
 
 require (
 	dario.cat/mergo v1.0.1
+	github.com/Azure/go-ntlmssp v0.1.0
 	github.com/Masterminds/goutils v1.1.1
 	github.com/Masterminds/semver/v3 v3.4.0
+	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/aws/aws-sdk-go-v2 v1.39.3
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/proto v0.0.0
@@ -36,6 +38,7 @@ require (
 )
 
 require (
+	github.com/PaesslerAG/gval v1.2.4 // indirect
 	github.com/aws/smithy-go v1.23.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect

+ 7 - 0
runtime/go.sum

@@ -1,9 +1,15 @@
 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
+github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
 github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
 github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU=
+github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
+github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
+github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
 github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM=
 github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
 github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
@@ -214,6 +220,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=

+ 104 - 0
runtime/webhook/webhook/models.go

@@ -0,0 +1,104 @@
+/*
+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 provides functionality for interacting with external webhook services
+// to fetch and push secret data.
+package webhook
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+// Spec defines the configuration for a webhook provider.
+type Spec struct {
+	// Webhook Method
+	// +optional, default GET
+	Method string `json:"method,omitempty"`
+
+	// Webhook url to call
+	URL string `json:"url"`
+
+	// Headers
+	// +optional
+	Headers map[string]string `json:"headers,omitempty"`
+
+	// Auth specifies a authorization protocol. Only one protocol may be set.
+	// +optional
+	Auth *AuthorizationProtocol `json:"auth,omitempty"`
+
+	// Body
+	// +optional
+	Body string `json:"body,omitempty"`
+
+	// Timeout
+	// +optional
+	Timeout *metav1.Duration `json:"timeout,omitempty"`
+
+	// Result formatting
+	Result Result `json:"result"`
+
+	// Secrets to fill in templates
+	// These secrets will be passed to the templating function as key value pairs under the given name
+	// +optional
+	Secrets []Secret `json:"secrets,omitempty"`
+
+	// PEM encoded CA bundle used to validate webhook server certificate. Only used
+	// if the Server URL is using HTTPS protocol. This parameter is ignored for
+	// plain HTTP protocol connection. If not set the system root certificates
+	// are used to validate the TLS connection.
+	// +optional
+	CABundle []byte `json:"caBundle,omitempty"`
+
+	// The provider for the CA bundle to use to validate webhook server certificate.
+	// +optional
+	CAProvider *esv1.CAProvider `json:"caProvider,omitempty"`
+}
+
+// AuthorizationProtocol contains the protocol-specific configuration
+// +kubebuilder:validation:MinProperties=1
+// +kubebuilder:validation:MaxProperties=1
+type AuthorizationProtocol struct {
+	// NTLMProtocol configures the store to use NTLM for auth
+	// +optional
+	NTLM *NTLMProtocol `json:"ntlm,omitempty"`
+
+	// Define other protocols here
+}
+
+// NTLMProtocol contains the NTLM-specific configuration.
+type NTLMProtocol struct {
+	UserName esmeta.SecretKeySelector `json:"usernameSecret"`
+	Password esmeta.SecretKeySelector `json:"passwordSecret"`
+}
+
+// Result defines how to process and extract data from webhook responses.
+type Result struct {
+	// Json path of return value
+	// +optional
+	JSONPath string `json:"jsonPath,omitempty"`
+}
+
+// Secret defines a secret that can be used in webhook templates.
+type Secret struct {
+	// Name of this secret in templates
+	Name string `json:"name"`
+
+	// Secret ref to fill in credentials
+	SecretRef esmeta.SecretKeySelector `json:"secretRef"`
+}

+ 452 - 0
runtime/webhook/webhook/webhook.go

@@ -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
+}