diff --git a/doc/10-Channels.md b/doc/10-Channels.md index 62a74d87f..abe06cbb0 100644 --- a/doc/10-Channels.md +++ b/doc/10-Channels.md @@ -1,88 +1,358 @@ # Channels -After Icinga Notifications decides to send a notification of any kind, it will be passed to a channel plugin. -Such a channel plugin submits the notification event to a channel, e.g., email or a chat client. +After Icinga Notifications decides to send a notification of any kind, it is passed to a channel plugin. +Such a plugin submits the notification event to a domain-specific channel, such as email or a chat client. -Icinga Notifications comes packed with channel plugins, but also enables you to develop your own plugins. +Icinga Notifications comes packed with channels, but also enables you to develop your own channels. -To make those plugins available to Icinga Notifications, they must be placed in the +To make these channels available to Icinga Notifications, they must be placed in the [channels directory](03-Configuration.md#channels-directory), -which is being done automatically for package installations. -Afterwards they can be configured through Icinga Notifications Web. +which is done automatically during package installations. +At startup, Icinga Notifications scans this directory, starts each channel once to query its configuration options +and stores those options in the database. +Using this information, Icinga Notifications Web allows channels to be configured, +which are then started, configured, and finally used to send notification events from Icinga Notifications. ## Technical Channel Description -Channel plugins are processes that run continuously and independently of each other. They receive many requests over -their lifetime. They receive JSON-formatted requests on stdin and reply with JSON-formatted responses on stdout. The -request and response structure is inspired by JSON-RPC. +!!! warning -### Request + As this is still an early preview version, things might change. + There may still be severe bugs and incompatible changes may happen without any notice. -The request must be in JSON format and should contain following keys: +Channel plugins are independent processes that run continuously, started and supervised by Icinga Notifications. +They receive JSON-formatted requests on `stdin` and reply with JSON-formatted responses on `stdout`. +For logging or debugging purposes, channels can write to `stderr`, +which is being forwarded to the Icinga Notifications log. -- `method`: The request method to call. -- `params`: The params for request method. -- `id`: Unsigned int value. Required to assign the response to its request as responses can be sent out of order. +### RPC Architecture + +The request and response structure is inspired by JSON-RPC. +Both the general anatomy of requests and responses as well as the specific methods are described below. +Note that fields marked as optional must be omitted from the JSON object if they do not have a value. + +This documentation uses beautified JSON for ease of reading. + +#### Request + +A channel receives a request as a JSON object with the following fields: + +| Field | Type | Description | +|----------|------------------|----------------------------------------------| +| `method` | String | **Required.** Request method to call. | +| `params` | JSON object | **Optional.** Params for the request method. | +| `id` | Unsigned integer | **Required.** Unique identifier. | + +The `params` field is optional because some methods do not require parameters. +If they are required for a particular method, +they are specified along with the expected value type in the method description below. + +Each request contains a unique `id` that must be echoed back in the channel's response. +This allows Icinga Notifications to associate a response with its request. Examples: +- Simple request without any `params`: + ```json + { + "method": "Simple", + "id": 1000 + } + ``` +- Request with `params` of different types: + ```json + { + "method": "WithParams", + "params": { + "foo": 23, + "bar": "hello" + }, + "id": 1000 + } + ``` + +#### Response + +Each request must be answered by the channel with a JSON object of the following fields: + +| Field | Type | Description | +|----------|------------------|---------------------------------------------------| +| `result` | JSON value (any) | **Optional.** Output of a successful method call. | +| `error` | String | **Optional.** Error message. | +| `id` | Unsigned integer | **Required.** Request `id`. | + +The `result` and the `error` fields are mutually exclusive. +A `result` can be omitted when the method does not return a value, i.e., for setter calls. +However, in case of a present `error` value, the `result` field must be omitted. +Thus, a successful response without a `result` contains only an `id` field. + +Examples: + +- Successful response without a `result` message: + ```json + { + "id": 1000 + } + ``` +- Successful response with a `result`: + ```json + { + "result": "hello world", + "id": 1000 + } + ``` +- Response with an error: + ```json + { + "error": "unknown method: 'Foo'", + "id": 1000 + } + ``` + +### RPC Methods + +The following methods must be implemented by a channel. + +#### GetInfo + +The parameterless `GetInfo` method returns information about the channel. + +Its `result` is expected to be a JSON object with the `json` fields defined in the +[`Info` type](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#Info). +The `config_attrs` field must be an array of JSON objects according to the +[`ConfigOption` type](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#ConfigOption). +These attributes define configuration options for the channel to be set by the `SetConfig` method. +They are also used for channel configuration in Icinga Notifications Web. + +##### Example GetInfo Request + ```json { - "method": "Add", - "params": { - "num1": 5, - "num2": 3 + "method": "GetInfo", + "id": 1 +} +``` + +##### Example GetInfo Response + +```json +{ + "result": { + "name": "Minified Webhook", + "version": "0.0.0-gf369a11-dirty", + "author": "Icinga GmbH", + "config_attrs": [ + { + "name": "url_template", + "type": "string", + "label": { + "de_DE": "URL-Template", + "en_US": "URL Template" + }, + "help": { + "de_DE": "URL, optional als Go-Template über das zu verarbeitende plugin.NotificationRequest.", + "en_US": "URL, optionally as a Go template over the current plugin.NotificationRequest." + }, + "required": true, + "min": null, + "max": null + }, + { + "name": "response_status_codes", + "type": "string", + "label": { + "de_DE": "Antwort-Status-Codes", + "en_US": "Response Status Codes" + }, + "help": { + "de_DE": "Kommaseparierte Liste erwarteter Status-Code der HTTP-Antwort, z.B.: 200,201,202,208,418", + "en_US": "Comma separated list of expected HTTP response status code, e.g., 200,201,202,208,418" + }, + "default": "200", + "min": null, + "max": null + } + ] }, - "id": 2020 + "id": 1 +} +``` + +#### SetConfig + +The `SetConfig` method configures the channel. + +The Icinga Notifications daemon will call this method at least once on each channel +before sending the first notifications to initialize the channel plugin. + +The passed JSON object in the request's `param` field reflects the objects from `GetInfo`'s `config_attrs`. +Each object in the `config_attrs` array must be configurable, +using its `name` attribute as a key together along with the desired configuration value, +which must be of the type specified in the `type` field. + +To illustrate, the URL template from the above output is configurable with the following JSON object passed in `params`: + +```json +{ + "url_template": "http://localhost:8000/update/{{.Incident.Id}}" } ``` +If the channel plugin has successfully configured itself, a response without a `result` must be returned. +Otherwise, if the channel decides that the provided configuration is incorrect, an `error` must be returned. +This may happen if, for example, an invalid configuration value was given. + +##### Example SetConfig Request + ```json { - "method": "Foo", + "method": "SetConfig", "params": { - "a": "value1", - "b": "value2" + "url_template": "http://localhost:8000/update/{{.Incident.Id}}", + "response_status_codes": "200" }, - "id": 3030 + "id": 2 } ``` -### Response +##### Example SetConfig Response -The response is in JSON format and contains following keys: +```json +{ + "id": 2 +} +``` -- `result`: The result as JSON format. Omitted when the method does not return a value (e.g. setter calls) or an error - has occurred. -- `error`: The error message. Omitted when no error has occurred. -- `id`: The request id. When result value is empty and no error is occurred, the response will only contain the request - id. +#### SendNotification -Examples: +The `SendNotification` method requests that the channel dispatches notifications. + +Within the request's `params`, a JSON object representing a +[`NotificationRequest`](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#NotificationRequest) +is passed. + +If the channel is unable to send a notification, an `error` must be returned. +This may be due to channel-specific reasons, such as an email channel where the SMTP server is unavailable, +or if the channel is missing required configuration values. + +##### Example SendNotification Request ```json { - "result": 8, - "id": 2020 + "method": "SendNotification", + "params": { + "contact": { + "full_name": "icingaadmin", + "addresses": [ + { + "type": "email", + "address": "icingaaadmin@example.com" + } + ] + }, + "object": { + "name": "dummy-816!random fortune", + "url": "http://localhost/icingaweb2/icingadb/service?name=random%20fortune&host.name=dummy-816", + "tags": { + "host": "dummy-816", + "service": "random fortune" + }, + "extra_tags": { + "hostgroup/app-mobile": "", + "hostgroup/department-dev": "", + "hostgroup/env-prod": "", + "hostgroup/location-rome": "", + "servicegroup/app-storage": "", + "servicegroup/department-ps": "", + "servicegroup/env-prod": "", + "servicegroup/location-rome": "" + } + }, + "incident": { + "id": 1437, + "url": "http://localhost/icingaweb2/notifications/incident?id=1437", + "severity": "crit" + }, + "event": { + "time": "2024-07-12T10:47:30.445439055Z", + "type": "state", + "username": "", + "message": "Q:\tWhat looks like a cat, flies like a bat, brays like a donkey, and\n\tplays like a monkey?\nA:\tNothing." + } + }, + "id": 3 } ``` +##### Example SendNotification Response + ```json { - "error": "unknown method: 'Foo'", - "id": 3030 + "id": 3 } ``` -### Methods +### Channel Configuration + +A channel offers its configuration options through its response to the [`GetInfo` method call](#getinfo). +Each configuration option is described by a +[`ConfigOption` entry](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#ConfigOption) +in the `config_attrs` array. +Those information will be queried once by Icinga Notifications upon startup and stored in the database. + +Depending on the `ConfigOption`'s type, Icinga Notifications Web will render different form element. +For example, a `text` will result in an input box, while `options` will result in a multi-select list. + +When a user configures a Channel in Icinga Notifications Web, the configuration will be stored in the database as JSON. +More specifically, a JSON object mapping `config_attrs.name` to the configured value is stored, +as expected as the `params` for the [`SetConfig` method](#setconfig). + +Since `ConfigOption`s may have defaults defined, +Icinga Notifications Web will not add unchanged defaults to the configuration JSON object. +Therefore, the channel plugin is expected to use their offered default value if the key-value pair is absent. + +A `SetConfig` implementation may follow this logic by first setting its defaults and +then overwriting its state based on the configuration received via the `params`. + +Finally, Icinga Notifications will start a new process for this channel and +pass the stored JSON object to the channel by calling the `SetConfig` method. +The process is kept alive and receives occasional [`SendNotification` method calls](#sendnotification). + +## Writing Channel Plugins + +!!! tip + + Icinga Notifications comes with a Webhook channel plugin. + Consider using this channel if your transport uses HTTP instead of writing a custom channel. + +!!! tip + + When developing custom channels, consider naming them with a unique prefix, + as additional channels will be added to Icinga Notifications in the future. + For example, name your channel `x_irc` or `my_irc` instead of `irc`. + +The channels that ship with Icinga Notifications can only cover some use cases. +Therefore, we encourage you to develop your own channels that cover your specific needs. + +### Writing Channel Plugins in Go + +!!! warning + + As this is still an early preview version, things might change. + There may still be severe bugs and incompatible changes may happen without any notice. + +Since Icinga Notifications and all of its channels are written in the Go programming language, +libraries already used internally can be reused. +In particular, the [`Plugin`](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#Plugin) +interface must be implemented, requesting methods for all the RPC methods described above. + +To respect the [channel configuration logic](#channel-configuration) described above, +an implementation of `SetConfig` should start by calling +[`PopulateDefaults`](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#PopulateDefaults). -Currently, the channel plugin include following three methods: +The channel plugin's `main` function should call +the [`RunPlugin`](https://pkg.go.dev/github.com/icinga/icinga-notifications/pkg/plugin#RunPlugin) function, +taking care about calling the RPC method implementations. -- `SetConfig`: Initialize the channel plugin with specified config as `params` key. The config is plugin specific - therefore each plugin defines what is expected as config. - [(example)](../internal/channel/examples/set-config.json) -- `GetInfo`: Get the information about the channel e.g. Name. The `params` key has no effect and can be omitted. - [(example)](../internal/channel/examples/get-info.json) -- `SendNotification`: Send the notifications. The `params` key should contain the information about the contact to be - notified, corresponding object, the incident and the triggered event. - [(example)](../internal/channel/examples/send-notification.json) +For concrete examples, there are the implemented channels in the Icinga Notifications repository at +[`./cmd/channels`](https://github.com/Icinga/icinga-notifications/tree/main/cmd/channels). \ No newline at end of file diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index f2b122638..d2bfa3a01 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -35,7 +35,7 @@ curl -v -u 'source-2:insecureinsecure' -d '@-' 'http://localhost:5680/process-ev "type": "state", "severity": "crit", "username": "", - "message": "Something went somehwere very wrong." + "message": "Something went somewhere very wrong." } EOF ``` diff --git a/internal/channel/examples/get-info.json b/internal/channel/examples/get-info.json deleted file mode 100644 index cecd4202e..000000000 --- a/internal/channel/examples/get-info.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "method": "GetInfo", - "id" : 2020 -} diff --git a/internal/channel/examples/send-notification.json b/internal/channel/examples/send-notification.json deleted file mode 100644 index 75d754f1a..000000000 --- a/internal/channel/examples/send-notification.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "method" : "SendNotification", - "params": { - "contact": { - "full_name": "John Doe", - "addresses": [ - { - "type": "email", - "address": "johnd@example.com" - },{ - "type": "rocketchat", - "address": "@johndoe" - } - ] - }, - "object": { - "name": "httpd", - "url": "https://example.com/icingaweb2/icingadb/service?name=httpd&host.name=www1", - "tags": { - "host": "www1", - "service": "httpd" - }, - "extra_tags": { - "hostgroup/server": null - } - }, - "incident": { - "id": 22, - "url": "https://example.com/icingaweb2/notifications/incident?id=22" - }, - "event": { - "type": "state", - "severity": "crit", - "message": "Service is down" - } - }, - "id": 2020 -} diff --git a/internal/channel/examples/set-config.json b/internal/channel/examples/set-config.json deleted file mode 100644 index 182905145..000000000 --- a/internal/channel/examples/set-config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "method": "SetConfig", - "params": { - "host": "example.com", - "port": "25", - "from": "notifications-daemon@example.com" - }, - "id" : 2020 -} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 6d85a508b..9e28237d7 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -22,7 +22,7 @@ const ( MethodSendNotification = "SendNotification" ) -// ConfigOption describes a config element of the channel form +// ConfigOption describes a config element. type ConfigOption struct { // Element name Name string `json:"name"` @@ -80,12 +80,23 @@ func (c ConfigOptions) Value() (driver.Value, error) { return json.Marshal(c) } -// Info contains plugin information. +// Info contains channel plugin information. type Info struct { - Type string `db:"type" json:"-"` - Name string `db:"name" json:"name"` - Version string `db:"version" json:"version"` - Author string `db:"author" json:"author"` + // 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"` } @@ -94,51 +105,93 @@ func (i *Info) TableName() string { return "available_channel_type" } +// Contact to receive notifications for the NotificationRequest. type Contact struct { - FullName string `json:"full_name"` + // 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 string `json:"type"` + // 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 string `json:"name"` - Url string `json:"url"` - Tags map[string]string `json:"tags"` + // 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 int64 `json:"id"` - Url string `json:"url"` + // 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 string `json:"severity"` } +// Event indicating this NotificationRequest. type Event struct { - Time time.Time `json:"time"` - Type string `json:"type"` - Username string `json:"username"` - Message string `json:"message"` + // 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 string `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 *Contact `json:"contact"` - Object *Object `json:"object"` + // 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 *Event `json:"event"` + + // 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 returns the corresponding plugin *Info. GetInfo() *Info - // SetConfig sets the plugin config, returns an error on failure + // 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 sends the notification, returns an error on failure. SendNotification(req *NotificationRequest) error } @@ -161,7 +214,10 @@ func PopulateDefaults(typePtr Plugin) error { return json.Unmarshal(defaultConf, typePtr) } -// RunPlugin reads the incoming stdin requests, processes and writes the responses to stdout +// 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) @@ -223,7 +279,9 @@ func RunPlugin(plugin Plugin) { wg.Wait() } -// FormatMessage formats a notification message and adds to the given io.Writer +// 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"