Skip to content

Commit

Permalink
webhook: Basic TLS Configuration, Custom Headers
Browse files Browse the repository at this point in the history
This change allows some basic TLS related configuration changes:
- Fixed Certificate Common Name
- Custom CA
- Insecure Mode w/o any verification

The last setting brought me to an unexpected boolean representation in
the JSON configuration, requiring a custom boolean type. It was added to
pkg/plugin and its documentation.

In addition, support for arbitrary HTTP request headers was added. This
allows custom headers, even carrying values based on the
NotificationRequest.

Additionally, an unique User-Agent was assigned.

Closes #256.
  • Loading branch information
oxzi committed Aug 1, 2024
1 parent 114c1be commit 309bcc4
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 9 deletions.
151 changes: 142 additions & 9 deletions cmd/channels/webhook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package main

import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"github.com/icinga/icinga-notifications/internal"
"github.com/icinga/icinga-notifications/pkg/plugin"
"io"
"net/http"
"os"
"slices"
"strconv"
"strings"
Expand All @@ -18,16 +21,30 @@ func main() {
plugin.RunPlugin(&Webhook{})
}

type Webhook struct {
Method string `json:"method"`
URLTemplate string `json:"url_template"`
RequestBodyTemplate string `json:"request_body_template"`
ResponseStatusCodes string `json:"response_status_codes"`
// transport is a http.Transport with a custom User-Agent.
type transport http.Transport

// RoundTrip implements http.RoundTripper with a custom User-Agent header.
func (trans *transport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", "icinga-notifications-webhook/"+internal.Version.Version)
return (*http.Transport)(trans).RoundTrip(req)
}

tmplUrl *template.Template
tmplRequestBody *template.Template
type Webhook struct {
Method string `json:"method"`
URLTemplate string `json:"url_template"`
RequestHeadersTemplate string `json:"request_headers_template"`
RequestBodyTemplate string `json:"request_body_template"`
ResponseStatusCodes string `json:"response_status_codes"`
TlsCommonName string `json:"tls_common_name"`
TlsCaPemFile string `json:"tls_ca_pem_file"`
TlsInsecure plugin.Bool `json:"tls_insecure"`

respStatusCodes []int
tmplUrl *template.Template
tmplRequestHeaders map[string]*template.Template
tmplRequestBody *template.Template
respStatusCodes []int
httpTransport http.RoundTripper
}

func (ch *Webhook) GetInfo() *plugin.Info {
Expand Down Expand Up @@ -59,6 +76,19 @@ func (ch *Webhook) GetInfo() *plugin.Info {
},
Required: true,
},
{
Name: "request_headers_template",
Type: "text",
Label: map[string]string{
"en_US": "Request Header Template",
"de_DE": "Request Header-Template",
},
Help: map[string]string{
"en_US": "Multiple lines of 'HTTP-HEADER=TEMPLATE' with TEMPLATE being a Go template about the current plugin.NotificationRequest.",
"de_DE": "Mehrere Zeilen im Format 'HTTP-HEADER=TEMPLATE', wobei TEMPLATE ein Go-Template über das zu verarbeitende plugin.NotificationRequest ist.",
},
Default: "",
},
{
Name: "request_body_template",
Type: "string",
Expand Down Expand Up @@ -86,6 +116,45 @@ func (ch *Webhook) GetInfo() *plugin.Info {
Default: "200",
Required: true,
},
{
Name: "tls_common_name",
Type: "string",
Label: map[string]string{
"en_US": "TLS Common Name",
"de_DE": "TLS Common Name",
},
Help: map[string]string{
"en_US": "Expect this CN for the server's TLS certificate instead of the URL's hostname.",
"de_DE": "Erwarte diesen CN für das TLS-Zertifikat des Servers anstelle des Hostnames aus der URL.",
},
Default: "",
},
{
Name: "tls_ca_pem_file",
Type: "string",
Label: map[string]string{
"en_US": "CA PEM File",
"de_DE": "CA-PEM-Datei",
},
Help: map[string]string{
"en_US": "Path to a custom CA as a PEM file to be used for TLS certificate verification.",
"de_DE": "Dateipfad zu einer eigenen CA als PEM-Datei zum Verifizieren des TLS-Zertifikats.",
},
Default: "",
},
{
Name: "tls_insecure",
Type: "bool",
Label: map[string]string{
"en_US": "No TLS Verification",
"de_DE": "Keine TLS-Verifizierung",
},
Help: map[string]string{
"en_US": "Skip TLS verification. This might be insecure.",
"de_DE": "Führe keine TLS-Verifizierung durch. Dies vermag unsicher zu sein.",
},
Default: false,
},
}

return &plugin.Info{
Expand Down Expand Up @@ -123,6 +192,28 @@ func (ch *Webhook) SetConfig(jsonStr json.RawMessage) error {
return fmt.Errorf("cannot parse URL template: %w", err)
}

ch.tmplRequestHeaders = make(map[string]*template.Template)
for _, reqHeaderEntry := range strings.Split(ch.RequestHeadersTemplate, "\n") {
key, tmplValue, found := strings.Cut(reqHeaderEntry, "=")
if !found {
return fmt.Errorf("cannot process invalid Request Header pair %q", reqHeaderEntry)
}

key = strings.TrimSpace(key)
tmplValue = strings.TrimSpace(tmplValue)

if key == "" {
return fmt.Errorf("cannot process Request Header pair %q with an empty key", reqHeaderEntry)
}

tmpl, err := template.New("request_header_" + key).Funcs(tmplFuncs).Parse(tmplValue)
if err != nil {
return fmt.Errorf("cannot parse Request Header pair %q as a template: %w", reqHeaderEntry, err)
}

ch.tmplRequestHeaders[key] = tmpl
}

ch.tmplRequestBody, err = template.New("request_body").Funcs(tmplFuncs).Parse(ch.RequestBodyTemplate)
if err != nil {
return fmt.Errorf("cannot parse Request Body template: %w", err)
Expand All @@ -138,6 +229,39 @@ func (ch *Webhook) SetConfig(jsonStr json.RawMessage) error {
ch.respStatusCodes[i] = respStatusCode
}

tlsConf := &tls.Config{
// https://ssl-config.mozilla.org/#server=go&config=intermediate
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
}

if ch.TlsCommonName != "" {
tlsConf.ServerName = ch.TlsCommonName
}
if ch.TlsCaPemFile != "" {
caPem, err := os.ReadFile(ch.TlsCaPemFile)
if err != nil {
return fmt.Errorf("cannot open custom CA PEM file %q: %v", ch.TlsCaPemFile, err)
}

tlsConf.RootCAs = x509.NewCertPool()
if !tlsConf.RootCAs.AppendCertsFromPEM(caPem) {
return fmt.Errorf("cannot parse CA PEM file %q", ch.TlsCaPemFile)
}
}
if ch.TlsInsecure {
tlsConf.InsecureSkipVerify = true
}

ch.httpTransport = &transport{TLSClientConfig: tlsConf}

return nil
}

Expand All @@ -154,7 +278,16 @@ func (ch *Webhook) SendNotification(req *plugin.NotificationRequest) error {
if err != nil {
return err
}
httpResp, err := http.DefaultClient.Do(httpReq)
for key, tmplValue := range ch.tmplRequestHeaders {
var valueBuff bytes.Buffer
if err := tmplValue.Execute(&valueBuff, req); err != nil {
return fmt.Errorf("cannot execute Request Header template for key %q: %w", key, err)
}
httpReq.Header.Set(key, valueBuff.String())
}

httpClient := &http.Client{Transport: ch.httpTransport}
httpResp, err := httpClient.Do(httpReq)
if err != nil {
return err
}
Expand Down
40 changes: 40 additions & 0 deletions pkg/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,42 @@ const (
MethodSendNotification = "SendNotification"
)

// Bool is a very special bool with a custom json.Unmarshaler to handle JSON booleans and Icinga Web boolean.
//
// Icinga Web stores booleans sometimes as a string, being either "y" or "n". This might be useful and expected for
// SQL database usages, but it also happens for channel plugins.
//
// https://github.com/Icinga/icinga-notifications-web/issues/267
type Bool bool

// UnmarshalJSON implements json.Unmarshaler for true/false/"y"/"n" to a boolean.
func (b *Bool) UnmarshalJSON(bytes []byte) error {
var anyOut any
if err := json.Unmarshal(bytes, &anyOut); err != nil {
return err
}

switch anyOut := anyOut.(type) {
case bool:
*b = Bool(anyOut)

case string:
switch anyOut {
case "y":
*b = true
case "n":
*b = false
default:
return fmt.Errorf("cannot use %q as a bool", anyOut)
}

default:
return fmt.Errorf("cannot convert type %T to bool", anyOut)
}

return nil
}

// ConfigOption describes a config element.
type ConfigOption struct {
// Element name
Expand All @@ -30,6 +66,8 @@ type ConfigOption struct {
// Element type:
//
// string = text, number = number, bool = checkbox, text = textarea, option = select, options = select[multiple], secret = password
//
// Note that "bool = checkbox" might result in a string being used for configuration. Use the plugin.Bool type.
Type string `json:"type"`

// Element label map. Locale in the standard format (language_REGION) as key and corresponding label as value.
Expand Down Expand Up @@ -218,6 +256,8 @@ func PopulateDefaults(typePtr Plugin) error {
//
// This function reads requests from stdin, calls the associated RPC method, and writes the responses to stdout. As this
// function blocks, it should be called last in a channel plugin's main function.
//
// Each request will be processed in its own goroutine. Thus, there might be concurrent Plugin.SendNotification calls.
func RunPlugin(plugin Plugin) {
encoder := json.NewEncoder(os.Stdout)
decoder := json.NewDecoder(os.Stdin)
Expand Down
34 changes: 34 additions & 0 deletions pkg/plugin/plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package plugin

import (
"github.com/stretchr/testify/require"
"testing"
)

func TestBool_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
in string
out Bool
wantErr bool
}{
{"bool-true", `true`, true, false},
{"bool-false", `false`, false, false},
{"string-true", `"y"`, true, false},
{"string-false", `"n"`, false, false},
{"string-invalid", `"NEIN"`, false, true},
{"invalid-type", `23`, false, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out Bool
if err := out.UnmarshalJSON([]byte(tt.in)); tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.out, out)
}
})
}
}

0 comments on commit 309bcc4

Please sign in to comment.