diff --git a/docs/assets/notif/elasticsearch.png b/docs/assets/notif/elasticsearch.png new file mode 100644 index 00000000..0c9bb385 Binary files /dev/null and b/docs/assets/notif/elasticsearch.png differ diff --git a/docs/config/index.md b/docs/config/index.md index 47e8cbc3..e59ef187 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -247,6 +247,7 @@ Can be transposed to: * [amqp](../notif/amqp.md) * [apprise](../notif/apprise.md) * [discord](../notif/discord.md) + * [elasticsearch](../notif/elasticsearch.md) * [gotify](../notif/gotify.md) * [mail](../notif/mail.md) * [matrix](../notif/matrix.md) diff --git a/docs/config/notif.md b/docs/config/notif.md index ddfeac5a..b1e4097c 100644 --- a/docs/config/notif.md +++ b/docs/config/notif.md @@ -3,6 +3,7 @@ * [`amqp`](../notif/amqp.md) * [`apprise`](../notif/apprise.md) * [`discord`](../notif/discord.md) +* [`elasticsearch`](../notif/elasticsearch.md) * [`gotify`](../notif/gotify.md) * [`mail`](../notif/mail.md) * [`matrix`](../notif/matrix.md) diff --git a/docs/notif/elasticsearch.md b/docs/notif/elasticsearch.md new file mode 100644 index 00000000..62e8663d --- /dev/null +++ b/docs/notif/elasticsearch.md @@ -0,0 +1,82 @@ +# Elasticsearch notifications + +Send notifications to your Elasticsearch cluster as structured documents. + +## Configuration + +!!! example "File" + ```yaml + notif: + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false + ``` + +| Name | Default | Description | +| -------------------- | -------------------- | ------------------------------------------------------------------- | +| `scheme`[^1] | `http` | Elasticsearch scheme (`http` or `https`) | +| `host`[^1] | `localhost` | Elasticsearch host | +| `port`[^1] | `9200` | Elasticsearch port | +| `username` | | Elasticsearch username for authentication | +| `usernameFile` | | Use content of secret file as username if `username` is not defined | +| `password` | | Elasticsearch password for authentication | +| `passwordFile` | | Use content of secret file as password if `password` is not defined | +| `client`[^1] | `diun` | Client name to identify the source of notifications | +| `index`[^1] | `diun-notifications` | Elasticsearch index name where notifications will be stored | +| `timeout`[^1] | `10s` | Timeout specifies a time limit for the request to be made | +| `insecureSkipVerify` | `false` | Skip TLS certificate verification | + +!!! abstract "Environment variables" + * `DIUN_NOTIF_ELASTICSEARCH_SCHEME` + * `DIUN_NOTIF_ELASTICSEARCH_HOST` + * `DIUN_NOTIF_ELASTICSEARCH_PORT` + * `DIUN_NOTIF_ELASTICSEARCH_USERNAME` + * `DIUN_NOTIF_ELASTICSEARCH_USERNAMEFILE` + * `DIUN_NOTIF_ELASTICSEARCH_PASSWORD` + * `DIUN_NOTIF_ELASTICSEARCH_PASSWORDFILE` + * `DIUN_NOTIF_ELASTICSEARCH_CLIENT` + * `DIUN_NOTIF_ELASTICSEARCH_INDEX` + * `DIUN_NOTIF_ELASTICSEARCH_TIMEOUT` + * `DIUN_NOTIF_ELASTICSEARCH_INSECURESKIPVERIFY` + +## Document Structure + +Each notification is stored as a JSON document with following structure: + +```json +{ + "diun_version": "4.24.0", + "hostname": "myserver", + "status": "new", + "provider": "file", + "image": "docker.io/crazymax/diun:latest", + "hub_link": "https://hub.docker.com/r/crazymax/diun", + "mime_type": "application/vnd.docker.distribution.manifest.list.v2+json", + "digest": "sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01", + "created": "2020-03-26T12:23:56Z", + "platform": "linux/amd64", + "client": "diun", + "metadata": { + "ctn_command": "diun serve", + "ctn_createdat": "2022-12-29 10:22:15 +0100 CET", + "ctn_id": "0dbd10e15b31add2c48856fd34451adabf50d276efa466fe19a8ef5fbd87ad7c", + "ctn_names": "diun", + "ctn_size": "0B", + "ctn_state": "running", + "ctn_status": "Up Less than a second (health: starting)" + } +} +``` + +## Sample + +![](../assets/notif/elasticsearch.png) + +[^1]: Value required diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d3b0a5d4..07ee313e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -94,6 +94,17 @@ func TestLoadFile(t *testing.T) { Timeout: utl.NewDuration(10 * time.Second), TemplateBody: model.NotifDefaultTemplateBody, }, + Elasticsearch: &model.NotifElasticsearch{ + Scheme: "https", + Host: "localhost", + Port: 9200, + Username: "elastic", + Password: "password", + Client: "diun", + Index: "diun-notifications", + Timeout: utl.NewDuration(10 * time.Second), + InsecureSkipVerify: false, + }, Gotify: &model.NotifGotify{ Endpoint: "http://gotify.foo.com", Token: "Token123456", diff --git a/internal/config/fixtures/config.test.yml b/internal/config/fixtures/config.test.yml index cf9d6592..73052b54 100644 --- a/internal/config/fixtures/config.test.yml +++ b/internal/config/fixtures/config.test.yml @@ -39,6 +39,16 @@ notif: - "<@&200>" renderFields: true timeout: 10s + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/config/fixtures/config.validate.yml b/internal/config/fixtures/config.validate.yml index 0c5576dc..d6738dbe 100644 --- a/internal/config/fixtures/config.validate.yml +++ b/internal/config/fixtures/config.validate.yml @@ -28,6 +28,16 @@ notif: - "<@&200>" renderFields: true timeout: 10s + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/model/notif.go b/internal/model/notif.go index 7261a624..9ef9b073 100644 --- a/internal/model/notif.go +++ b/internal/model/notif.go @@ -32,22 +32,23 @@ type NotifEntry struct { // Notif holds data necessary for notification configuration type Notif struct { - Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` - Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"` - Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"` - Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"` - Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` - Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"` - Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` - Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` - Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"` - RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"` - Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"` - SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"` - Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"` - Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"` - Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"` - Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"` + Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` + Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"` + Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"` + Elasticsearch *NotifElasticsearch `yaml:"elasticsearch,omitempty" json:"elasticsearch,omitempty"` + Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"` + Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` + Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"` + Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` + Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` + Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"` + RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"` + Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"` + SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"` + Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"` + Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"` + Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"` + Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"` } // GetDefaults gets the default values diff --git a/internal/model/notif_elasticsearch.go b/internal/model/notif_elasticsearch.go new file mode 100644 index 00000000..8872330f --- /dev/null +++ b/internal/model/notif_elasticsearch.go @@ -0,0 +1,39 @@ +package model + +import ( + "time" + + "github.com/crazy-max/diun/v4/pkg/utl" +) + +type NotifElasticsearch struct { + Scheme string `yaml:"scheme,omitempty" json:"scheme,omitempty" validate:"required,oneof=http https"` + Host string `yaml:"host,omitempty" json:"host,omitempty" validate:"required"` + Port int `yaml:"port,omitempty" json:"port,omitempty" validate:"required,min=1"` + Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"` + UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"` + Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"` + PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"` + Client string `yaml:"client,omitempty" json:"client,omitempty" validate:"required"` + Index string `yaml:"index,omitempty" json:"index,omitempty" validate:"required"` + Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"` + InsecureSkipVerify bool `yaml:"insecureSkipVerify,omitempty" json:"insecureSkipVerify,omitempty" validate:"omitempty"` +} + +// GetDefaults gets the default values +func (s *NotifElasticsearch) GetDefaults() *NotifElasticsearch { + n := &NotifElasticsearch{} + n.SetDefaults() + return n +} + +// SetDefaults sets the default values +func (s *NotifElasticsearch) SetDefaults() { + s.Scheme = "http" + s.Host = "localhost" + s.Port = 9200 + s.Client = "diun" + s.Index = "diun-notifications" + s.Timeout = utl.NewDuration(10 * time.Second) + s.InsecureSkipVerify = false +} diff --git a/internal/notif/client.go b/internal/notif/client.go index fc71ed5b..930be373 100644 --- a/internal/notif/client.go +++ b/internal/notif/client.go @@ -7,6 +7,7 @@ import ( "github.com/crazy-max/diun/v4/internal/notif/amqp" "github.com/crazy-max/diun/v4/internal/notif/apprise" "github.com/crazy-max/diun/v4/internal/notif/discord" + "github.com/crazy-max/diun/v4/internal/notif/elasticsearch" "github.com/crazy-max/diun/v4/internal/notif/gotify" "github.com/crazy-max/diun/v4/internal/notif/mail" "github.com/crazy-max/diun/v4/internal/notif/matrix" @@ -54,6 +55,9 @@ func New(config *model.Notif, meta model.Meta) (*Client, error) { if config.Discord != nil { c.notifiers = append(c.notifiers, discord.New(config.Discord, meta)) } + if config.Elasticsearch != nil { + c.notifiers = append(c.notifiers, elasticsearch.New(config.Elasticsearch, meta)) + } if config.Gotify != nil { c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta)) } diff --git a/internal/notif/elasticsearch/client.go b/internal/notif/elasticsearch/client.go new file mode 100644 index 00000000..929936c5 --- /dev/null +++ b/internal/notif/elasticsearch/client.go @@ -0,0 +1,137 @@ +package elasticsearch + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/crazy-max/diun/v4/internal/model" + "github.com/crazy-max/diun/v4/internal/msg" + "github.com/crazy-max/diun/v4/internal/notif/notifier" + "github.com/crazy-max/diun/v4/pkg/utl" + "github.com/pkg/errors" +) + +// Client represents an active elasticsearch notification object +type Client struct { + *notifier.Notifier + cfg *model.NotifElasticsearch + meta model.Meta +} + +// New creates a new elasticsearch notification instance +func New(config *model.NotifElasticsearch, meta model.Meta) notifier.Notifier { + return notifier.Notifier{ + Handler: &Client{ + cfg: config, + meta: meta, + }, + } +} + +// Name returns notifier's name +func (c *Client) Name() string { + return "elasticsearch" +} + +// Send creates and sends an elasticsearch notification with an entry +func (c *Client) Send(entry model.NotifEntry) error { + username, err := utl.GetSecret(c.cfg.Username, c.cfg.UsernameFile) + if err != nil { + return err + } + + password, err := utl.GetSecret(c.cfg.Password, c.cfg.PasswordFile) + if err != nil { + return err + } + + // Use the same JSON structure as webhook notifier + message, err := msg.New(msg.Options{ + Meta: c.meta, + Entry: entry, + }) + if err != nil { + return err + } + + body, err := message.RenderJSON() + if err != nil { + return err + } + + // Parse the JSON to add the client field + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + return err + } + + // Add the current time + doc["@timestamp"] = time.Now().Format(time.RFC3339Nano) + + // Add the client field from the configuration + doc["client"] = c.cfg.Client + + // Re-marshal the JSON with the client field + body, err = json.Marshal(doc) + if err != nil { + return err + } + + // Build the Elasticsearch indexing URL + // This uses the Index API (POST /{index}/_doc) to create a document with an auto-generated _id: + // https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-create + url := fmt.Sprintf("%s://%s:%d/%s/_doc", c.cfg.Scheme, c.cfg.Host, c.cfg.Port, c.cfg.Index) + + cancelCtx, cancel := context.WithCancelCause(context.Background()) + timeoutCtx, _ := context.WithTimeoutCause(cancelCtx, *c.cfg.Timeout, errors.WithStack(context.DeadlineExceeded)) //nolint:govet // no need to manually cancel this context as we already rely on parent + defer func() { cancel(errors.WithStack(context.Canceled)) }() + + hc := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.InsecureSkipVerify, + }, + }, + } + + req, err := http.NewRequestWithContext(timeoutCtx, "POST", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.meta.UserAgent) + + // Add authentication if provided + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } + + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + var errBody struct { + Status int `json:"status"` + Error struct { + Type string `json:"type"` + Reason string `json:"reason"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { + return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + return errors.Errorf("%d %s: %s", errBody.Status, errBody.Error.Type, errBody.Error.Reason) + } + + return nil +} diff --git a/mkdocs.yml b/mkdocs.yml index 23016ad7..5aa8e040 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Amqp: notif/amqp.md - Apprise: notif/apprise.md - Discord: notif/discord.md + - Elasticsearch: notif/elasticsearch.md - Gotify: notif/gotify.md - Mail: notif/mail.md - Matrix: notif/matrix.md