diff --git a/cmd/channels/webhook/main.go b/cmd/channels/webhook/main.go index 90a72701..9c4cf91d 100644 --- a/cmd/channels/webhook/main.go +++ b/cmd/channels/webhook/main.go @@ -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" @@ -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 { @@ -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", @@ -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{ @@ -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) @@ -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 } @@ -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 } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 9e28237d..3cf07dbb 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -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 @@ -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. @@ -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) diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 00000000..972b3af9 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -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) + } + }) + } +}