diff --git a/go.mod b/go.mod index a7bfe917..ebf57a5c 100644 --- a/go.mod +++ b/go.mod @@ -33,3 +33,7 @@ require ( golang.org/x/sys v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool ( + golang.org/x/tools/cmd/stringer +) diff --git a/notifications/event/event.go b/notifications/event/event.go new file mode 100644 index 00000000..bd3f20e2 --- /dev/null +++ b/notifications/event/event.go @@ -0,0 +1,58 @@ +package event + +import ( + "github.com/icinga/icinga-go-library/types" +) + +// Event represents an Icinga Notifications event that can be sent to the Icinga Notifications API. +// +// It contains all the necessary fields to fully describe an Icinga Notifications event and can be used to +// serialize the event to JSON for transmission over HTTP as well as to deserialize it from JSON requests. +type Event struct { + Name string `json:"name"` // Name is the name of the object this event is all about. + + // URL represents a URL or a relative reference to the object in Icinga Web 2. + // + // If the URL field does not contain a URL, but only a reference relative to an Icinga Web URL, the Icinga + // Notifications daemon will create a URL. This allows a source to set this to something like + // "/icingadb/host?name=example.com" without having to know the Icinga Web 2 root URL by itself. + URL string `json:"url"` + + // Tags contains additional metadata for the event that uniquely identifies the object it's referring to. + // + // It is a map of string keys to string values, allowing for flexible tagging of events if the event + // name alone is not sufficient to identify the object. In the case of using Icinga DB as a source, the + // tags will typically look like this: + // For hosts: {"host": "host_name"} and for services: {"host": "host_name", "service": "service_name"}. + Tags map[string]string `json:"tags"` + + // ExtraTags supplement Tags, for example with host or service groups for an Icinga DB source. + ExtraTags map[string]string `json:"extra_tags"` + + // Type indicates the type of the event. + Type Type `json:"type"` + // Severity of the event. + Severity Severity `json:"severity,omitempty"` + // Username is the name of the user who triggered the event. + Username string `json:"username"` + // Message is a human-readable message describing the event. + Message string `json:"message"` + + // Mute indicates whether the object this event is referring to should be muted or not. + // + // If set to true, the object will be muted in Icinga Web 2, meaning that notifications for this object + // will not be sent out. The MuteReason field can be used to provide a reason for muting the object. + // If you don't set this field to anything, it will be omitted from the generated JSON. + Mute types.Bool `json:"mute,omitzero"` + + // MuteReason provides a reason for muting the object if Mute is set to true. + // + // Setting this field to an empty string while Mute is true will cause the request to fail, + // as Icinga Notifications requires a reason for muting an object. Otherwise, it will be omitted + // from the encoded JSON. + MuteReason string `json:"mute_reason,omitempty"` + + // RulesVersion and RuleIds are the source rules matching for this Event. + RulesVersion string `json:"rules_version"` + RuleIds []string `json:"rule_ids"` +} diff --git a/notifications/event/event_test.go b/notifications/event/event_test.go new file mode 100644 index 00000000..1dcb68a4 --- /dev/null +++ b/notifications/event/event_test.go @@ -0,0 +1,74 @@ +package event + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvent(t *testing.T) { + t.Parallel() + + t.Run("JsonEncode", func(t *testing.T) { + t.Parallel() + + t.Run("Valid Event", func(t *testing.T) { + t.Parallel() + + event := &Event{ + Name: "TestEvent", + URL: "/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host", + Tags: map[string]string{"tag1": "value1"}, + ExtraTags: map[string]string{}, + Type: TypeState, + Severity: SeverityOK, + Username: "testuser", + Message: "Test", + RulesVersion: "0x1", + RuleIds: []string{"1", "2", "3", "6"}, + } + + data, err := json.Marshal(event) + require.NoError(t, err) + + expected := ` + { + "name":"TestEvent", + "url":"/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host", + "tags":{"tag1":"value1"}, + "extra_tags":{}, + "type":"state", + "severity":"ok", + "username":"testuser", + "message":"Test", + "rules_version": "0x1", + "rule_ids": ["1", "2", "3", "6"] + }` + assert.JSONEq(t, expected, string(data), "JSON encoding does not match expected output") + }) + + t.Run("Empty Severity", func(t *testing.T) { + t.Parallel() + + event := &Event{ + Name: "TestEvent", + URL: "https://example.com/icingaweb2/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host", + Tags: map[string]string{"tag1": "value1"}, + Type: TypeMute, + Username: "testuser", + Message: "Test", + } + + data, err := json.Marshal(event) + require.NoError(t, err) + assert.NotContains(t, string(data), "\"severity\":", "severity should be omitted when empty") + + event.Severity = SeverityNone + data, err = json.Marshal(event) + require.NoError(t, err) + assert.NotContains(t, string(data), "\"severity\":", "severity should be omitted when set to none") + }) + }) +} diff --git a/notifications/event/severity.go b/notifications/event/severity.go new file mode 100644 index 00000000..10e486b4 --- /dev/null +++ b/notifications/event/severity.go @@ -0,0 +1,106 @@ +//go:generate go tool stringer -linecomment -type Severity -output severity_string.go + +package event + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// Severity represents the severity level of an event in Icinga notifications. +// It is an integer type with predefined constants for different severity levels. +type Severity uint8 + +const ( + SeverityNone Severity = iota // none + + SeverityOK // ok + SeverityDebug // debug + SeverityInfo // info + SeverityNotice // notice + SeverityWarning // warning + SeverityErr // err + SeverityCrit // crit + SeverityAlert // alert + SeverityEmerg // emerg + + severityMax // internal +) + +// MarshalJSON implements the [json.Marshaler] interface for Severity. +func (s Severity) MarshalJSON() ([]byte, error) { + if s != SeverityNone { + return json.Marshal(s.String()) + } else { + return json.Marshal(nil) + } +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface for Severity. +func (s *Severity) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *s = SeverityNone + return nil + } + + var severityStr string + if err := json.Unmarshal(data, &severityStr); err != nil { + return err + } + + severity, err := ParseSeverity(severityStr) + if err != nil { + return err + } + + *s = severity + return nil +} + +// Scan implements the [sql.Scanner] interface for Severity. +// Supports SQL NULL values. +func (s *Severity) Scan(src any) error { + if src == nil { + *s = SeverityNone + return nil + } + + var severityStr string + switch val := src.(type) { + case string: + severityStr = val + case []byte: + severityStr = string(val) + default: + return fmt.Errorf("cannot scan severity from type %T", src) + } + + severity, err := ParseSeverity(severityStr) + if err != nil { + return err + } + + *s = severity + return nil +} + +// Value implements the [driver.Valuer] interface for Severity. +func (s Severity) Value() (driver.Value, error) { + if s != SeverityNone { + return s.String(), nil + } + return nil, nil // Return nil for SeverityNone or invalid values +} + +// ParseSeverity parses a string representation of a severity level and returns the corresponding Severity value. +// If the string does not match any known severity, it returns an error indicating the unknown severity. +func ParseSeverity(name string) (Severity, error) { + for s := range severityMax { + if s.String() == name { + return s, nil + } + } + + return SeverityNone, fmt.Errorf("unknown severity %q", name) +} diff --git a/notifications/event/severity_string.go b/notifications/event/severity_string.go new file mode 100644 index 00000000..6e0bc95c --- /dev/null +++ b/notifications/event/severity_string.go @@ -0,0 +1,33 @@ +// Code generated by "stringer -linecomment -type Severity -output severity_string.go"; DO NOT EDIT. + +package event + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[SeverityNone-0] + _ = x[SeverityOK-1] + _ = x[SeverityDebug-2] + _ = x[SeverityInfo-3] + _ = x[SeverityNotice-4] + _ = x[SeverityWarning-5] + _ = x[SeverityErr-6] + _ = x[SeverityCrit-7] + _ = x[SeverityAlert-8] + _ = x[SeverityEmerg-9] + _ = x[severityMax-10] +} + +const _Severity_name = "noneokdebuginfonoticewarningerrcritalertemerginternal" + +var _Severity_index = [...]uint8{0, 4, 6, 11, 15, 21, 28, 31, 35, 40, 45, 53} + +func (i Severity) String() string { + if i >= Severity(len(_Severity_index)-1) { + return "Severity(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Severity_name[_Severity_index[i]:_Severity_index[i+1]] +} diff --git a/notifications/event/severity_test.go b/notifications/event/severity_test.go new file mode 100644 index 00000000..d9f59496 --- /dev/null +++ b/notifications/event/severity_test.go @@ -0,0 +1,109 @@ +package event + +import ( + "database/sql/driver" + "testing" + + "github.com/icinga/icinga-go-library/testutils" +) + +func TestSeverity(t *testing.T) { + t.Parallel() + + t.Run("MarshalJson", func(t *testing.T) { + t.Parallel() + + testdata := []testutils.TestCase[string, Severity]{ + {Name: "None", Expected: "null", Data: SeverityNone, Error: nil}, + {Name: "Ok", Expected: `"ok"`, Data: SeverityOK, Error: nil}, + {Name: "Debug", Expected: `"debug"`, Data: SeverityDebug, Error: nil}, + {Name: "Info", Expected: `"info"`, Data: SeverityInfo, Error: nil}, + {Name: "Notice", Expected: `"notice"`, Data: SeverityNotice, Error: nil}, + {Name: "Warning", Expected: `"warning"`, Data: SeverityWarning, Error: nil}, + {Name: "Err", Expected: `"err"`, Data: SeverityErr, Error: nil}, + {Name: "Crit", Expected: `"crit"`, Data: SeverityCrit, Error: nil}, + {Name: "Alert", Expected: `"alert"`, Data: SeverityAlert, Error: nil}, + {Name: "Emerg", Expected: `"emerg"`, Data: SeverityEmerg, Error: nil}, + } + + for _, tt := range testdata { + t.Run(tt.Name, tt.F(func(s Severity) (string, error) { + data, err := s.MarshalJSON() + return string(data), err + })) + } + }) + + t.Run("UnmarshalJson", func(t *testing.T) { + t.Parallel() + + testData := []testutils.TestCase[Severity, string]{ + {Name: "None", Expected: SeverityNone, Data: `null`, Error: nil}, + {Name: "Ok", Expected: SeverityOK, Data: `"ok"`, Error: nil}, + {Name: "Debug", Expected: SeverityDebug, Data: `"debug"`, Error: nil}, + {Name: "Info", Expected: SeverityInfo, Data: `"info"`, Error: nil}, + {Name: "Notice", Expected: SeverityNotice, Data: `"notice"`, Error: nil}, + {Name: "Warning", Expected: SeverityWarning, Data: `"warning"`, Error: nil}, + {Name: "Err", Expected: SeverityErr, Data: `"err"`, Error: nil}, + {Name: "Crit", Expected: SeverityCrit, Data: `"crit"`, Error: nil}, + {Name: "Alert", Expected: SeverityAlert, Data: `"alert"`, Error: nil}, + {Name: "Emerg", Expected: SeverityEmerg, Data: `"emerg"`, Error: nil}, + {Name: "Invalid", Expected: SeverityNone, Data: `"invalid"`, Error: testutils.ErrorContains(`unknown severity "invalid"`)}, + } + + for _, tt := range testData { + t.Run(tt.Name, tt.F(func(input string) (Severity, error) { + var s Severity + return s, s.UnmarshalJSON([]byte(input)) + })) + } + }) + + t.Run("Scan", func(t *testing.T) { + t.Parallel() + + testData := []testutils.TestCase[Severity, any]{ + {Name: "None", Expected: SeverityNone, Data: nil, Error: nil}, + {Name: "Ok", Expected: SeverityOK, Data: `ok`, Error: nil}, + {Name: "Debug", Expected: SeverityDebug, Data: `debug`, Error: nil}, + {Name: "Info", Expected: SeverityInfo, Data: `info`, Error: nil}, + {Name: "Notice", Expected: SeverityNotice, Data: `notice`, Error: nil}, + {Name: "Warning", Expected: SeverityWarning, Data: `warning`, Error: nil}, + {Name: "Err", Expected: SeverityErr, Data: `err`, Error: nil}, + {Name: "Crit", Expected: SeverityCrit, Data: `crit`, Error: nil}, + {Name: "Alert", Expected: SeverityAlert, Data: `alert`, Error: nil}, + {Name: "Alert Bytes", Expected: SeverityAlert, Data: []byte("alert"), Error: nil}, + {Name: "Emerg", Expected: SeverityEmerg, Data: `emerg`, Error: nil}, + {Name: "Invalid Number", Expected: SeverityNone, Data: 150, Error: testutils.ErrorContains(`cannot scan severity from type int`)}, + {Name: "Invalid String", Expected: SeverityNone, Data: `invalid`, Error: testutils.ErrorContains(`unknown severity "invalid"`)}, + } + + for _, tt := range testData { + t.Run(tt.Name, tt.F(func(input any) (Severity, error) { + var s Severity + return s, s.Scan(input) + })) + } + }) + + t.Run("Value", func(t *testing.T) { + t.Parallel() + + testdata := []testutils.TestCase[driver.Value, Severity]{ + {Name: "None", Expected: nil, Data: SeverityNone, Error: nil}, + {Name: "Ok", Expected: `ok`, Data: SeverityOK, Error: nil}, + {Name: "Debug", Expected: `debug`, Data: SeverityDebug, Error: nil}, + {Name: "Info", Expected: `info`, Data: SeverityInfo, Error: nil}, + {Name: "Notice", Expected: `notice`, Data: SeverityNotice, Error: nil}, + {Name: "Warning", Expected: `warning`, Data: SeverityWarning, Error: nil}, + {Name: "Err", Expected: `err`, Data: SeverityErr, Error: nil}, + {Name: "Crit", Expected: `crit`, Data: SeverityCrit, Error: nil}, + {Name: "Alert", Expected: `alert`, Data: SeverityAlert, Error: nil}, + {Name: "Emerg", Expected: `emerg`, Data: SeverityEmerg, Error: nil}, + } + + for _, tt := range testdata { + t.Run(tt.Name, tt.F(func(s Severity) (driver.Value, error) { return s.Value() })) + } + }) +} diff --git a/notifications/event/type.go b/notifications/event/type.go new file mode 100644 index 00000000..9a3c54d1 --- /dev/null +++ b/notifications/event/type.go @@ -0,0 +1,109 @@ +//go:generate go tool stringer -linecomment -type Type -output type_string.go + +package event + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// Type represents the type of event sent to the Icinga Notifications API. +type Type uint8 + +const ( + TypeUnknown Type = iota // unknown + + TypeAcknowledgementCleared // acknowledgement-cleared + TypeAcknowledgementSet // acknowledgement-set + TypeCustom // custom + TypeDowntimeEnd // downtime-end + TypeDowntimeRemoved // downtime-removed + TypeDowntimeStart // downtime-start + TypeFlappingEnd // flapping-end + TypeFlappingStart // flapping-start + TypeIncidentAge // incident-age + TypeMute // mute + TypeState // state + TypeUnmute // unmute + + typeMax // internal +) + +// MarshalJSON implements the [json.Marshaler] interface for Type. +func (t Type) MarshalJSON() ([]byte, error) { + if t != TypeUnknown { + return json.Marshal(t.String()) + } else { + return json.Marshal(nil) + } +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface for Type. +func (t *Type) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *t = TypeUnknown + return nil + } + + var typeString string + if err := json.Unmarshal(data, &typeString); err != nil { + return err + } + + parsedType, err := ParseType(typeString) + if err != nil { + return err + } + + *t = parsedType + return nil +} + +// Scan implements the [sql.Scanner] interface for Severity. +// Supports SQL NULL values. +func (t *Type) Scan(src any) error { + if src == nil { + *t = TypeUnknown + return nil + } + + var typeStr string + switch val := src.(type) { + case string: + typeStr = val + case []byte: + typeStr = string(val) + default: + return fmt.Errorf("cannot scan Type from %T", src) + } + + parsedType, err := ParseType(typeStr) + if err != nil { + return err + } + + *t = parsedType + return nil +} + +// Value implements the [driver.Valuer] interface for Severity. +func (t Type) Value() (driver.Value, error) { + if t != TypeUnknown { + return t.String(), nil + } + return nil, nil // Return nil for unknown type +} + +// ParseType parses a string into a Type. +// +// If the string does not match any known type, it returns an error indicating the unknown type. +func ParseType(s string) (Type, error) { + for t := range typeMax { + if s == t.String() { + return t, nil + } + } + + return TypeUnknown, fmt.Errorf("unknown type %q", s) +} diff --git a/notifications/event/type_string.go b/notifications/event/type_string.go new file mode 100644 index 00000000..b360f2bb --- /dev/null +++ b/notifications/event/type_string.go @@ -0,0 +1,36 @@ +// Code generated by "stringer -linecomment -type Type -output type_string.go"; DO NOT EDIT. + +package event + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TypeUnknown-0] + _ = x[TypeAcknowledgementCleared-1] + _ = x[TypeAcknowledgementSet-2] + _ = x[TypeCustom-3] + _ = x[TypeDowntimeEnd-4] + _ = x[TypeDowntimeRemoved-5] + _ = x[TypeDowntimeStart-6] + _ = x[TypeFlappingEnd-7] + _ = x[TypeFlappingStart-8] + _ = x[TypeIncidentAge-9] + _ = x[TypeMute-10] + _ = x[TypeState-11] + _ = x[TypeUnmute-12] + _ = x[typeMax-13] +} + +const _Type_name = "unknownacknowledgement-clearedacknowledgement-setcustomdowntime-enddowntime-removeddowntime-startflapping-endflapping-startincident-agemutestateunmuteinternal" + +var _Type_index = [...]uint8{0, 7, 30, 49, 55, 67, 83, 97, 109, 123, 135, 139, 144, 150, 158} + +func (i Type) String() string { + if i >= Type(len(_Type_index)-1) { + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Type_name[_Type_index[i]:_Type_index[i+1]] +} diff --git a/notifications/event/type_test.go b/notifications/event/type_test.go new file mode 100644 index 00000000..c7395e73 --- /dev/null +++ b/notifications/event/type_test.go @@ -0,0 +1,87 @@ +package event + +import ( + "testing" + + "database/sql/driver" + "github.com/icinga/icinga-go-library/testutils" +) + +func TestType(t *testing.T) { + t.Parallel() + + t.Run("MarshalJSON", func(t *testing.T) { + t.Parallel() + + testdata := []testutils.TestCase[string, Type]{ + {Name: "Unknown", Expected: "null", Data: TypeUnknown, Error: nil}, + {Name: "State", Expected: `"state"`, Data: TypeState, Error: nil}, + {Name: "Mute", Expected: `"mute"`, Data: TypeMute, Error: nil}, + {Name: "Unmute", Expected: `"unmute"`, Data: TypeUnmute, Error: nil}, + {Name: "DowntimeStart", Expected: `"downtime-start"`, Data: TypeDowntimeStart, Error: nil}, + } + + for _, tt := range testdata { + t.Run(tt.Name, tt.F(func(typ Type) (string, error) { + data, err := typ.MarshalJSON() + return string(data), err + })) + } + }) + + t.Run("UnmarshalJSON", func(t *testing.T) { + t.Parallel() + + testData := []testutils.TestCase[Type, string]{ + {Name: "Unknown", Expected: TypeUnknown, Data: "null", Error: nil}, + {Name: "State", Expected: TypeState, Data: `"state"`, Error: nil}, + {Name: "Mute", Expected: TypeMute, Data: `"mute"`, Error: nil}, + {Name: "Unmute", Expected: TypeUnmute, Data: `"unmute"`, Error: nil}, + {Name: "DowntimeStart", Expected: TypeDowntimeStart, Data: `"downtime-start"`, Error: nil}, + {Name: "Invalid", Expected: TypeUnknown, Data: `"invalid"`, Error: testutils.ErrorContains(`unknown type "invalid"`)}, + } + + for _, tt := range testData { + t.Run(tt.Name, tt.F(func(input string) (Type, error) { + var tType Type + return tType, tType.UnmarshalJSON([]byte(input)) + })) + } + }) + + t.Run("Scan", func(t *testing.T) { + t.Parallel() + + testdata := []testutils.TestCase[Type, any]{ + {Name: "Unknown", Expected: TypeUnknown, Data: nil, Error: nil}, + {Name: "State", Expected: TypeState, Data: `state`, Error: nil}, + {Name: "Mute", Expected: TypeMute, Data: `mute`, Error: nil}, + {Name: "Unmute", Expected: TypeUnmute, Data: `unmute`, Error: nil}, + {Name: "DowntimeStart", Expected: TypeDowntimeStart, Data: `downtime-start`, Error: nil}, + {Name: "Invalid", Expected: TypeUnknown, Data: `invalid`, Error: testutils.ErrorContains(`unknown type "invalid"`)}, + } + + for _, tt := range testdata { + t.Run(tt.Name, tt.F(func(input any) (Type, error) { + var tType Type + return tType, tType.Scan(input) + })) + } + }) + + t.Run("Value", func(t *testing.T) { + t.Parallel() + + testdata := []testutils.TestCase[driver.Value, Type]{ + {Name: "Unknown", Expected: nil, Data: TypeUnknown, Error: nil}, + {Name: "State", Expected: `state`, Data: TypeState, Error: nil}, + {Name: "Mute", Expected: `mute`, Data: TypeMute, Error: nil}, + {Name: "Unmute", Expected: `unmute`, Data: TypeUnmute, Error: nil}, + {Name: "DowntimeStart", Expected: `downtime-start`, Data: TypeDowntimeStart, Error: nil}, + } + + for _, tt := range testdata { + t.Run(tt.Name, tt.F(func(typ Type) (driver.Value, error) { return typ.Value() })) + } + }) +} diff --git a/notifications/plugin/plugin.go b/notifications/plugin/plugin.go new file mode 100644 index 00000000..a5607f3a --- /dev/null +++ b/notifications/plugin/plugin.go @@ -0,0 +1,326 @@ +package plugin + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "github.com/icinga/icinga-go-library/notifications/event" + "github.com/icinga/icinga-go-library/notifications/rpc" + "github.com/icinga/icinga-go-library/types" + "github.com/icinga/icinga-go-library/utils" + "io" + "log" + "os" + "sync" + "time" +) + +const ( + MethodGetInfo = "GetInfo" + MethodSetConfig = "SetConfig" + MethodSendNotification = "SendNotification" +) + +// ConfigOption describes a config element. +type ConfigOption struct { + // Element name + Name string `json:"name"` + + // Element type: + // + // string = text, number = number, bool = checkbox, text = textarea, option = select, options = select[multiple], secret = password + Type string `json:"type"` + + // Element label map. Locale in the standard format (language_REGION) as key and corresponding label as value. + // Locale is assumed to be UTF-8 encoded (Without the suffix in the locale) + // + // e.g. {"en_US": "Save", "de_DE": "Speichern"} + // An "en_US" locale must be given as a fallback + Label map[string]string `json:"label"` + + // Element description map. Locale in the standard format (language_REGION) as key and corresponding label as value. + // Locale is assumed to be UTF-8 encoded (Without the suffix in the locale) + // + // When the user moves the mouse pointer over an element in the web UI, a tooltip is displayed with a given message. + // + // e.g. {"en_US": "HTTP request method for the request.", "de_DE": "HTTP-Methode für die Anfrage."} + // An "en_US" locale must be given as a fallback + Help map[string]string `json:"help,omitempty"` + + // Element default: bool for checkbox default value, string for other elements (used as placeholder) + Default any `json:"default,omitempty"` + + // Set true if this element is required, omit otherwise + Required bool `json:"required,omitempty"` + + // Options of a select element: key => value. + // Only required for the type option or options + // + // e.g., map[string]string{ + // "1": "January", + // "2": "February", + // } + Options map[string]string `json:"options,omitempty"` + + // Element's min option defines the minimum allowed number value. It can only be used for the type number. + Min types.Int `json:"min,omitempty"` + + // Element's max option defines the maximum allowed number value. It can only be used for the type number. + Max types.Int `json:"max,omitempty"` +} + +// ConfigOptions describes all ConfigOption entries. +// +// This type became necessary to implement the database.sql.driver.Valuer to marshal it into JSON. +type ConfigOptions []ConfigOption + +// Value implements database.sql's driver.Valuer to represent all ConfigOptions as a JSON array. +func (c ConfigOptions) Value() (driver.Value, error) { + return json.Marshal(c) +} + +// Info contains channel plugin information. +type Info struct { + // Type of the channel plugin. + // + // Not part of the JSON object. Will be set to the channel plugin file name before database insertion. + Type string `db:"type" json:"-"` + + // Name of this channel plugin in a human-readable value. + Name string `db:"name" json:"name"` + + // Version of this channel plugin. + Version string `db:"version" json:"version"` + + // Author of this channel plugin. + Author string `db:"author" json:"author"` + + // ConfigAttributes contains multiple ConfigOption(s) as JSON-encoded list. + ConfigAttributes ConfigOptions `db:"config_attrs" json:"config_attrs"` +} + +// TableName implements the contracts.TableNamer interface. +func (i *Info) TableName() string { + return "available_channel_type" +} + +// Contact to receive notifications for the NotificationRequest. +type Contact struct { + // FullName of a Contact as defined in Icinga Notifications. + FullName string `json:"full_name"` + + // Addresses of a Contact with a type. + Addresses []*Address `json:"addresses"` +} + +// Address to receive this notification. Each Contact might have multiple addresses. +type Address struct { + // Type field matches the Info.Type, effectively being the channel plugin file name. + Type string `json:"type"` + + // Address is the associated Type-specific address, e.g., an email address for type email. + Address string `json:"address"` +} + +// Object which this NotificationRequest is all about, e.g., an Icinga 2 Host or Service object. +type Object struct { + // Name depending on its source, may be "host!service" when from Icinga 2. + Name string `json:"name"` + + // Url pointing to this Object, may be to Icinga Web. + Url string `json:"url"` + + // Tags defining this Object, may be "host" and "service" when from Icinga 2. + Tags map[string]string `json:"tags"` + + // ExtraTags attached, may be a host or service groups when form Icinga 2. + ExtraTags map[string]string `json:"extra_tags"` +} + +// Incident of this NotificationRequest, grouping Events for this Object. +type Incident struct { + // Id is the unique identifier for this Icinga Notifications Incident, allows linking related events. + Id int64 `json:"id"` + + // Url pointing to the Icinga Notifications Web module's Incident page. + Url string `json:"url"` + + // Severity of this Incident. + Severity event.Severity `json:"severity"` +} + +// Event indicating this NotificationRequest. +type Event struct { + // Time when this event occurred, being encoded according to RFC 3339 when passed as JSON. + Time time.Time `json:"time"` + + // Type of this event, e.g., a "state" change, "mute" or "unmute". See further ./internal/event/event.go + Type event.Type `json:"type"` + + // Username may contain a user triggering this event, depending on the event's source. + Username string `json:"username"` + + // Message of this event, might be a check output when the related Object is an Icinga 2 object. + Message string `json:"message"` +} + +// NotificationRequest is being sent to a channel plugin via Plugin.SendNotification to request notification dispatching. +type NotificationRequest struct { + // Contact to receive this NotificationRequest. + Contact *Contact `json:"contact"` + + // Object associated with this NotificationRequest, e.g., an Icinga 2 Service Object. + Object *Object `json:"object"` + + // Incident associated with this NotificationRequest. + Incident *Incident `json:"incident"` + + // Event being responsible for creating this NotificationRequest, e.g., a firing Icinga 2 Service Check. + Event *Event `json:"event"` +} + +// Plugin defines necessary methods for a channel plugin. +// +// Those methods are being called via the internal JSON-RPC and allow channel interaction. Within the channel's main +// function, the channel should be launched via RunPlugin. +type Plugin interface { + // GetInfo returns the corresponding plugin *Info. + GetInfo() *Info + + // SetConfig sets the plugin config, returns an error on failure. + SetConfig(jsonStr json.RawMessage) error + + // SendNotification sends the notification, returns an error on failure. + SendNotification(req *NotificationRequest) error +} + +// PopulateDefaults sets the struct fields from Info.ConfigAttributes where ConfigOption.Default is set. +// +// It should be called from each channel plugin within its Plugin.SetConfig before doing any further configuration. +func PopulateDefaults(typePtr Plugin) error { + defaults := make(map[string]any) + for _, confAttr := range typePtr.GetInfo().ConfigAttributes { + if confAttr.Default != nil { + defaults[confAttr.Name] = confAttr.Default + } + } + + defaultConf, err := json.Marshal(defaults) + if err != nil { + return err + } + + return json.Unmarshal(defaultConf, typePtr) +} + +// RunPlugin serves the RPC for a Channel Plugin. +// +// 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. +func RunPlugin(plugin Plugin) { + encoder := json.NewEncoder(os.Stdout) + decoder := json.NewDecoder(os.Stdin) + var encoderMu sync.Mutex + + wg := sync.WaitGroup{} + + for { + var req rpc.Request + err := decoder.Decode(&req) + if err != nil { + if errors.Is(err, io.EOF) { + // plugin shutdown requested + break + } + + log.Fatal("failed to read request:", err) + } + + wg.Add(1) + go func(request rpc.Request) { + defer wg.Done() + var response = rpc.Response{Id: request.Id} + switch request.Method { + case MethodGetInfo: + result, err := json.Marshal(plugin.GetInfo()) + if err != nil { + response.Error = fmt.Errorf("failed to collect plugin info: %w", err).Error() + } else { + response.Result = result + } + + case MethodSetConfig: + if err = plugin.SetConfig(request.Params); err != nil { + response.Error = fmt.Errorf("failed to set plugin config: %w", err).Error() + } + + case MethodSendNotification: + var nr NotificationRequest + if err = json.Unmarshal(request.Params, &nr); err != nil { + response.Error = fmt.Errorf("failed to json.Unmarshal request: %w", err).Error() + } else if err = plugin.SendNotification(&nr); err != nil { + response.Error = err.Error() + } + + default: + response.Error = fmt.Sprintf("unknown method: %q", request.Method) + } + + encoderMu.Lock() + err = encoder.Encode(response) + encoderMu.Unlock() + if err != nil { + panic(fmt.Errorf("failed to write response: %w", err)) + } + }(req) + } + + wg.Wait() +} + +// FormatMessage formats a NotificationRequest message and adds to the given io.Writer. +// +// The created message is a multi-line message as one might expect it in an email. +func FormatMessage(writer io.Writer, req *NotificationRequest) { + if req.Event.Message != "" { + msgTitle := "Comment" + if req.Event.Type == event.TypeState { + msgTitle = "Output" + } + + _, _ = fmt.Fprintf(writer, "%s: %s\n\n", msgTitle, req.Event.Message) + } + + _, _ = fmt.Fprintf(writer, "When: %s\n\n", req.Event.Time.Format("2006-01-02 15:04:05 MST")) + + if req.Event.Username != "" { + _, _ = fmt.Fprintf(writer, "Author: %s\n\n", req.Event.Username) + } + _, _ = fmt.Fprintf(writer, "Object: %s\n\n", req.Object.Url) + _, _ = writer.Write([]byte("Tags:\n")) + for k, v := range utils.IterateOrderedMap(req.Object.Tags) { + _, _ = fmt.Fprintf(writer, "%s: %s\n", k, v) + } + + if len(req.Object.ExtraTags) > 0 { + _, _ = writer.Write([]byte("\nExtra Tags:\n")) + for k, v := range utils.IterateOrderedMap(req.Object.ExtraTags) { + _, _ = fmt.Fprintf(writer, "%s: %s\n", k, v) + } + } + + _, _ = fmt.Fprintf(writer, "\nIncident: %s", req.Incident.Url) +} + +// FormatSubject returns the formatted subject string based on the event type. +func FormatSubject(req *NotificationRequest) string { + switch req.Event.Type { + case event.TypeState: + return fmt.Sprintf("[#%d] %s %s is %s", req.Incident.Id, req.Event.Type, req.Object.Name, req.Incident.Severity) + case event.TypeAcknowledgementCleared, event.TypeDowntimeRemoved: + return fmt.Sprintf("[#%d] %s from %s", req.Incident.Id, req.Event.Type, req.Object.Name) + default: + return fmt.Sprintf("[#%d] %s on %s", req.Incident.Id, req.Event.Type, req.Object.Name) + } +} diff --git a/notifications/rpc/rpc.go b/notifications/rpc/rpc.go new file mode 100644 index 00000000..55030634 --- /dev/null +++ b/notifications/rpc/rpc.go @@ -0,0 +1,179 @@ +package rpc + +import ( + "encoding/json" + "errors" + "fmt" + "go.uber.org/zap" + "io" + "sync" +) + +// Request is the internal JSON RPC request representation. +type Request struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Id uint64 `json:"id"` +} + +// Response is the internal JSON RPC response representation. +type Response struct { + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Id uint64 `json:"id"` +} + +// Error is a custom error type, to be returned by RPC.Call. +type Error struct { + cause error +} + +func (err *Error) Error() string { + return fmt.Sprintf("RPC error: %s", err.cause.Error()) +} + +func (err *Error) Unwrap() error { + return err.cause +} + +// RPC is a JSON RPC client. +// +// It is either used internally in Icinga Notifications or within the plugin.RunPlugin method. +type RPC struct { + writer io.Closer // use encoder for writing instead + encoder *json.Encoder + encoderMu sync.Mutex + + decoder *json.Decoder + logger *zap.SugaredLogger + + pendingRequests map[uint64]chan Response + lastRequestId uint64 + requestsMu sync.Mutex + + processResponsesErrCh chan struct{} // never transports a value, only closed through processResponses() to signal an occurred error + processResponsesErr *Error // only initialized via processResponses() when decoder fails (Fatal/non-recoverable) +} + +// NewRPC creates and returns an RPC instance +func NewRPC(writer io.WriteCloser, reader io.Reader, logger *zap.SugaredLogger) *RPC { + rpc := &RPC{ + writer: writer, + encoder: json.NewEncoder(writer), + decoder: json.NewDecoder(reader), + pendingRequests: map[uint64]chan Response{}, + logger: logger, + processResponsesErrCh: make(chan struct{}), + } + + go rpc.processResponses() + + return rpc +} + +// Call sends a request with given parameters. +// Returns the Response.Result or an error. +// +// Two different kinds of error can be returned: +// - rpc.Error: Communication failed and future calls on this instance won't work and a new *RPC has to be created. +// - Response.Error: The response contains an error (that's non-fatal for the RPC object). +func (r *RPC) Call(method string, params json.RawMessage) (json.RawMessage, error) { + if err := r.Err(); err != nil { + return nil, err + } + + promise := make(chan Response, 1) + + r.requestsMu.Lock() + r.lastRequestId++ + newId := r.lastRequestId + r.pendingRequests[newId] = promise + r.requestsMu.Unlock() + + encodeReq := func() error { + r.encoderMu.Lock() + defer r.encoderMu.Unlock() + if r.encoder == nil { + return errors.New("cannot process any further requests, writer already closed") + } + + err := r.encoder.Encode(Request{Method: method, Params: params, Id: newId}) + if err != nil { + r.encoder = nil + _ = r.writer.Close() + return fmt.Errorf("failed to write request: %w", err) + } + + return nil + } + + if err := encodeReq(); err != nil { + return nil, err + } + + select { + case response := <-promise: + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil + + case <-r.Done(): + return nil, r.Err() + } +} + +// Err returns a non-nil error, If Done sends. Otherwise, nil is returned +func (r *RPC) Err() error { + select { + case <-r.Done(): + return r.processResponsesErr + default: + return nil + } +} + +// Done sends when the processResponsesErrCh has been closed. +// processResponsesErrCh is closed when decoder fails to read +func (r *RPC) Done() <-chan struct{} { + return r.processResponsesErrCh +} + +// Close closes the RPC.writer. +// All further calls to Call lead to an error. +// The Process will be terminated as soon as all pending requests have been processed. +func (r *RPC) Close() error { + r.encoderMu.Lock() + defer r.encoderMu.Unlock() + + r.encoder = nil + + return r.writer.Close() +} + +// processResponses sends responses to its channel (identified by response.id) +// In case of any error, all pending requests are dropped +func (r *RPC) processResponses() { + for r.Err() == nil { + var response Response + if err := r.decoder.Decode(&response); err != nil { + r.processResponsesErr = &Error{cause: fmt.Errorf("failed to read json response: %w", err)} + close(r.processResponsesErrCh) + _ = r.Close() + + return + } + + r.requestsMu.Lock() + promise := r.pendingRequests[response.Id] + delete(r.pendingRequests, response.Id) + r.requestsMu.Unlock() + + if promise != nil { + promise <- response + } else { + r.logger.Warn("Ignored response for unknown ID:", response.Id) + } + } +} diff --git a/notifications/rpc/rpc_test.go b/notifications/rpc/rpc_test.go new file mode 100644 index 00000000..9aa813a3 --- /dev/null +++ b/notifications/rpc/rpc_test.go @@ -0,0 +1,66 @@ +package rpc + +import ( + "encoding/json" + "fmt" + "github.com/stretchr/testify/assert" + "go.uber.org/zap/zaptest" + "io" + "sync" + "testing" +) + +func TestRPC(t *testing.T) { + writer, reader := dummyRemote() + rpc := NewRPC(writer, reader, zaptest.NewLogger(t).Sugar()) + + wg := sync.WaitGroup{} + for i := 0; i < 5; i++ { + wg.Add(1) + go func(i int) { + for j := 0; j < 100; j++ { + params := fmt.Sprintf(`{"go":"%d-%d"}`, i, j) + + res, err := rpc.Call("hello", json.RawMessage(params)) + if err != nil { + panic(err) + } + + t.Log(string(res)) + assert.Equal(t, params, string(res)) + } + wg.Done() + }(i) + } + wg.Wait() +} + +func dummyRemote() (io.WriteCloser, io.Reader) { + reqReader, reqWriter := io.Pipe() + resReader, resWriter := io.Pipe() + + go func() { + dec := json.NewDecoder(reqReader) + enc := json.NewEncoder(resWriter) + + for { + var req Request + err := dec.Decode(&req) + if err != nil { + panic(err) + } + + var res Response + + res.Id = req.Id + res.Result = req.Params + + err = enc.Encode(&res) + if err != nil { + panic(err) + } + } + }() + + return reqWriter, resReader +} diff --git a/notifications/source/client.go b/notifications/source/client.go new file mode 100644 index 00000000..45232aa8 --- /dev/null +++ b/notifications/source/client.go @@ -0,0 +1,157 @@ +package source + +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/icinga/icinga-go-library/notifications/event" + "github.com/pkg/errors" +) + +// basicAuthTransport is an http.RoundTripper that adds basic authentication and a User-Agent header to HTTP requests. +type basicAuthTransport struct { + http.RoundTripper // RoundTripper is the underlying HTTP transport to use for making requests. + + // username and password are set as HTTP basic authentication. + username string + password string + // userAgent is used to set the User-Agent header. + userAgent string +} + +// RoundTrip adds basic authentication headers to the request and executes the HTTP request. +func (b *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(b.username, b.password) + // As long as our round tripper is used for the client, the User-Agent header below + // overrides any other value set by the user. + req.Header.Set("User-Agent", b.userAgent) + + return b.RoundTripper.RoundTrip(req) +} + +// Client provides a common interface to interact with the Icinga Notifications API. +// +// It stores the configuration for the API endpoint and holds a reusable HTTP client for requests. To create a Client, +// the NewClient function should be used. +type Client struct { + httpClient http.Client + processEventEndpoint string +} + +// NewClient creates a new Client instance with the provided configuration. +// +// The clientName argument is used as the User-Agent header in HTTP requests sent by this Client and should represent +// the project using this client, e.g., "Icinga DB v1.5.0". +// +// It may return an error if the API base URL cannot be parsed. +func NewClient(cfg Config, clientName string) (*Client, error) { + baseUrl, err := url.Parse(cfg.ApiBaseUrl) + if err != nil { + return nil, errors.Wrap(err, "unable to parse API base URL") + } + + processEventEndpoint := baseUrl.JoinPath("/process-event").String() + + return &Client{ + httpClient: http.Client{ + Transport: &basicAuthTransport{ + RoundTripper: http.DefaultTransport, + username: cfg.Username, + password: cfg.Password, + userAgent: clientName, + }, + }, + processEventEndpoint: processEventEndpoint, + }, nil +} + +// RulesInfo holds information about the event rules for a specific source. +// +// A Client can fetch RulesInfo from the Icinga Notifications API via Client.ProcessEvent. +// +// The Version represents the current rules version for this Client. This value should be stored by a caller and set to +// [event.Event.EventVersion] when being passed to Client.ProcessEvent, as described there. This allows detecting +// outdated source rules. +// +// Rules is a map of rule IDs to object filter expressions. These object filter expressions are source-specific. For +// example, Icinga DB expects an SQL query. +type RulesInfo struct { + // Version of the event rules fetched from the API. + Version string + + // Rules is a map of rule IDs to their corresponding object filter expression. + Rules map[string]string +} + +// ErrRulesOutdated implies that the rules version between the submitted event and Icinga Notifications mismatches. +var ErrRulesOutdated = stderrors.New("rules version is outdated") + +// ProcessEvent submits an event.Event to the Icinga Notifications /process-event API endpoint. +// +// For a successful submission, this method returns (nil, nil). +// +// It may return an ErrRulesOutdated error, implying that the provided ruleVersion does not match the current rules +// version in the Icinga Notifications daemon. Only in this case, it will also return the current rules specific to this +// source and their version as RulesInfo, allowing to retry submitting an event after reevaluating it. +// +// For the Event to be submitted, Event.RulesVersion and Event.RuleIds should be set. If no appropriate values are +// known, they can be left empty. This will return ErrRulesOutdated - unless there are no rules configured that need to +// be evaluated by the source -, which can be handled as described above to retrieve information and resubmit the event. +// +// If the request fails or the response is not as expected, it returns an error. +func (client *Client) ProcessEvent(ctx context.Context, ev *event.Event) (*RulesInfo, error) { + body, err := json.Marshal(ev) + if err != nil { + return nil, errors.Wrap(err, "cannot encode event to JSON") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.processEventEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(err, "cannot create HTTP request") + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + resp, err := client.httpClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "cannot POST HTTP request to process event") + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusPreconditionFailed { + // Indicates that the rules version is outdated and the body should contain the current rules and their version. + // So, we read the body to extract the rules and return an ErrRulesOutdated error, so the caller can retry + // the event submission after it has reevaluated them. + var rulesInfo RulesInfo + if err := json.NewDecoder(resp.Body).Decode(&rulesInfo); err != nil { + return nil, errors.Wrap(err, "cannot decode new rules from process event response") + } + + return &rulesInfo, ErrRulesOutdated + } + + if resp.StatusCode >= http.StatusOK && resp.StatusCode <= 299 { + return nil, nil // Successfully processed the event. + } + + if resp.StatusCode == http.StatusNotAcceptable { + return nil, nil // Superfluous state change event. + } + + var buf bytes.Buffer + _, _ = io.Copy(&buf, &io.LimitedReader{R: resp.Body, N: 1 << 16}) // Limit the error message length to avoid memory exhaustion. + + return nil, errors.Errorf("unexpected response from process event API, status %q (%d): %q", + resp.Status, resp.StatusCode, strings.TrimSpace(buf.String())) +} diff --git a/notifications/source/config.go b/notifications/source/config.go new file mode 100644 index 00000000..d71e7e9b --- /dev/null +++ b/notifications/source/config.go @@ -0,0 +1,13 @@ +package source + +// Config defines all configuration for the Icinga Notifications API Client. +type Config struct { + // ApiBaseUrl points to the Icinga Notifications API, e.g., http://localhost:5680 + ApiBaseUrl string `yaml:"api-base-url" env:"API_BASE_URL"` + + // Username is the API user for the Icinga Notifications API. + Username string `yaml:"username" env:"USERNAME"` + + // Password is the API user's password for the Icinga Notifications API. + Password string `yaml:"password" env:"PASSWORD,unset"` +} diff --git a/types/bool.go b/types/bool.go index 7de3b5cd..636a2616 100644 --- a/types/bool.go +++ b/types/bool.go @@ -22,6 +22,32 @@ type Bool struct { Valid bool // Valid is true if Bool is not NULL } +// TransformZeroBoolToNull is a transformer function that sets the Valid field to false if the Bool is zero. +// This is useful when you want to convert a zero value to a NULL value in a database context. +func TransformZeroBoolToNull(b *Bool) { + if b.Valid && !b.Bool { + b.Valid = false + } +} + +// MakeBool constructs a new Bool. +// +// Multiple transformer functions can be given, each transforming the generated Bool to whatever is needed. +// If no transformers are given, the Bool will be valid and set to the given value. +func MakeBool(bi bool, transformers ...func(*Bool)) Bool { + b := Bool{Bool: bi, Valid: true} + + for _, transformer := range transformers { + transformer(&b) + } + + return b +} + +// IsZero implements the json.isZeroer interface. +// A Bool is considered zero if its Valid field is false regardless of its actual Bool value. +func (b Bool) IsZero() bool { return !b.Valid } + // MarshalJSON implements the json.Marshaler interface. func (b Bool) MarshalJSON() ([]byte, error) { if !b.Valid { diff --git a/types/bool_test.go b/types/bool_test.go index cf65030d..79545f73 100644 --- a/types/bool_test.go +++ b/types/bool_test.go @@ -7,6 +7,46 @@ import ( "unicode/utf8" ) +func TestMakeBool(t *testing.T) { + t.Parallel() + + subtests := []struct { + name string + input bool + transformers []func(*Bool) + output Bool + }{ + { + name: "false", + input: false, + output: Bool{Bool: false, Valid: true}, + }, + { + name: "true", + input: true, + output: Bool{Bool: true, Valid: true}, + }, + { + name: "false-transform-zero-to-null", + input: false, + transformers: []func(*Bool){TransformZeroBoolToNull}, + output: Bool{Valid: false}, + }, + { + name: "true-transform-zero-to-null", + input: true, + transformers: []func(*Bool){TransformZeroBoolToNull}, + output: Bool{Bool: true, Valid: true}, + }, + } + + for _, st := range subtests { + t.Run(st.name, func(t *testing.T) { + require.Equal(t, st.output, MakeBool(st.input, st.transformers...)) + }) + } +} + func TestBool_MarshalJSON(t *testing.T) { subtests := []struct { input Bool diff --git a/utils/utils.go b/utils/utils.go index 5dd0ece6..48e36b16 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -11,6 +11,7 @@ import ( "golang.org/x/exp/utf8string" "iter" "net" + "net/url" "os" "path/filepath" "slices" @@ -186,3 +187,15 @@ func IterateOrderedMap[K cmp.Ordered, V any](m map[K]V) iter.Seq2[K, V] { } } } + +// RawUrlEncode mimics PHP's rawurlencode to be used for parameter encoding. +// +// Icinga Web uses rawurldecode instead of urldecode, which, as its main difference, does not honor the plus char ('+') +// as a valid substitution for space (' '). Unfortunately, Go's url.QueryEscape does this very substitution and +// url.PathEscape does a bit too less and has a misleading name on top. +// +// - https://www.php.net/manual/en/function.rawurlencode.php +// - https://github.com/php/php-src/blob/php-8.2.12/ext/standard/url.c#L538 +func RawUrlEncode(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +}