Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webhook: Basic TLS Configuration, Custom Headers #269

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
})
}
}
Loading