diff options
| author | Shulhan <ms@kilabit.info> | 2026-01-20 22:28:36 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2026-01-20 22:28:36 +0700 |
| commit | d0969869954c299c04ea58ab0fda1eba6a0350da (patch) | |
| tree | 8d316903a98e384ffb7746819618521eb66683eb | |
| parent | bf87c6ad4824c7ed1c990aeff2d2883936c1f20a (diff) | |
| download | lilin-d0969869954c299c04ea58ab0fda1eba6a0350da.tar.xz | |
all: support sending notification to SMTP server (email)
In the main configuration, lilin.cfg, user now can add "notif" section with
kind "smtp" to send notification using user's email.
This require renaming "webhook_url" to "remote_url" to minimize duplicate
field, so different kind of notif can use the same "remote_url" value.
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | CHANGELOG.adoc | 19 | ||||
| -rw-r--r-- | README.md | 38 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | lilin.go | 2 | ||||
| -rw-r--r-- | notif_config.go | 56 | ||||
| -rw-r--r-- | notif_config_test.go | 51 | ||||
| -rw-r--r-- | server_config_test.go | 27 | ||||
| -rw-r--r-- | testdata/server_config/ok/etc/lilin/lilin.cfg | 14 | ||||
| -rw-r--r-- | testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg | 2 | ||||
| -rw-r--r-- | worker.go | 86 |
12 files changed, 270 insertions, 32 deletions
@@ -1,7 +1,7 @@ ## SPDX-License-Identifier: GPL-3.0-only ## SPDX-FileCopyrightText: 2025 M. Shulhan <ms@kilabit.info> -/_www/doc/index.html +/_www/doc/*.html /cover.html /cover.out /lilin diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c1e45aa..38d3154 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,13 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: 2025 M. Shulhan <ms@kilabit.info> -= lilin releases changelog += lilin release changelog :sectanchors: :toc: -Change log of each release of the lilin software. -The latest release log is put on the top. - Legend, * 🪵: Breaking changes @@ -15,6 +12,20 @@ Legend, * 🌼: Enhancement * 💧: Chores + +[#lilin_v0_2_0] +== lilin v0.2.0 (2026-xx-xx) + +**🪵 all: rename notif field "webhook_url" to "remote_url" + +These changes is to minimize duplicate field on "notif" section, so +different kind of notif can use the same "remote_url" value. + +**🌱 all: support sending notification to SMTP server (email)** + +In the main configuration, lilin.cfg, user now can add "notif" section with +kind "smtp" to send notification using user's email. + [#lilin_v0_1_0] == lilin v0.1.0 (2025-12-27) @@ -112,7 +112,7 @@ timeout = 60s ``` Sample of service configuration that monitor TCP service at -127.0.0.1:5432 every 90 seconds with timeout 30 seconds, +127.0.0.1:5432 every 90 seconds with 30 seconds timeout, ``` [service] @@ -127,18 +127,21 @@ timeout = 30s Lilin support sending notification to, - Mattermost using incoming webhook. +- SMTP server (email). See the next section on how to use the notification. ### Mattermost incoming webhook +<!--{{{--> + In the main configuration, add the section "notif" with the following format, ``` [notif] kind = mattermost -webhook_url = # The incoming webhook URL. +remote_url = # The incoming webhook URL. channel = # The channel where the notification will be placed. down_template = # Message template when service is down. up_template = # Message template when service is up. @@ -170,6 +173,37 @@ will be rendered as 2025-09-26 06:38:11 +0000 UTC: Service http-server is down: 503 Service Unavailable ``` +<!--}}}--> + +### SMTP server + +<!--{{{--> + +``` +[notif] +kind = smtp +remote_url = <scheme "://" (domain | ip_address [":" port])> +user = +password = +recipient = +``` + +The `remote_url` define the SMTP server address. +The scheme in `remote_url` can be set either to, + +- smtps: implicit TLS, connect to port 465 by default +- smtp+starttls: explicit STARTTLS, connect to port 587 by default. + +The `user` field define the sender email address. + +The `password` field define the password for authentication, on behalf of +the `user`. + +The `recipient` is the email address that will receives the notification. +This field can be defined multiple times. + +<!--}}}--> + ## Links [Project page](https://sr.ht/~shulhan/lilin). @@ -8,6 +8,7 @@ go 1.24.0 require git.sr.ht/~shulhan/pakakeh.go v0.60.2 require ( + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect @@ -2,6 +2,8 @@ git.sr.ht/~shulhan/pakakeh.go v0.60.2 h1:ZSRE77lYm+mkhvg9pSrxCIO81ydbqt93qbsWuZJ git.sr.ht/~shulhan/pakakeh.go v0.60.2/go.mod h1:1MkKXbLZRHTcnheeSEbRpGztkym4Yxzh90ep+jCxbDc= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= @@ -12,5 +14,7 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= @@ -9,7 +9,7 @@ import ( ) // Version latest released version. -const Version = `0.1.0` +const Version = `0.2.0` // defTimeout define default timeout for service and client connection. const defTimeout = 5 * time.Second diff --git a/notif_config.go b/notif_config.go index 7fa7d2d..c3ed43c 100644 --- a/notif_config.go +++ b/notif_config.go @@ -11,43 +11,75 @@ import ( const ( notifKindMattermost = `mattermost` + notifKindSMTP = `smtp` ) type NotifConfig struct { - webhookURL *url.URL - downTmpl *template.Template - upTmpl *template.Template + remoteURL *url.URL + + downTmpl *template.Template + upTmpl *template.Template + + Kind string `ini:"notif::kind"` + + // RemoteURL define the remote server to send notification. + // For mattermost this is the webhook URL. + // For smtp this is the domain or IP of SMTP server. + RemoteURL string `ini:"notif::remote_url"` + + // Channel that receive the mattermost message. + Channel string `ini:"notif::channel"` + + // User for authentication with SMTP server. + User string `ini:"notif::user"` + + // Password for authentication with SMTP server. + Password string `ini:"notif::password"` - Kind string `ini:"notif::kind"` - WebhookURL string `ini:"notif::webhook_url"` - Channel string `ini:"notif::channel"` DownTemplate string `ini:"notif::down_template"` UpTemplate string `ini:"notif::up_template"` + + // Recipient of email. + Recipient []string `ini:"notif::recipient"` } func (notifConfig *NotifConfig) init() (err error) { + var logp = `notifConfig.init` + switch notifConfig.Kind { case notifKindMattermost: - notifConfig.webhookURL, err = url.Parse(notifConfig.WebhookURL) + notifConfig.remoteURL, err = url.Parse(notifConfig.RemoteURL) if err != nil { - return fmt.Errorf(`invalid WebhookURL %q: %s`, - notifConfig.WebhookURL, err) + return fmt.Errorf(`invalid RemoteURL %q: %s`, + notifConfig.RemoteURL, err) + } + case notifKindSMTP: + if notifConfig.User == `` { + return fmt.Errorf(`%s: %s: empty user`, logp, notifConfig.Kind) + } + if notifConfig.Password == `` { + return fmt.Errorf(`%s: %s: empty password`, logp, notifConfig.Kind) + } + if len(notifConfig.Recipient) == 0 { + return fmt.Errorf(`%s: %s: empty recipient`, logp, notifConfig.Kind) } default: - return fmt.Errorf(`unknown notif kind: %s`, notifConfig.Kind) + return fmt.Errorf(`%s: unknown notif kind %q`, logp, notifConfig.Kind) } if notifConfig.DownTemplate != "" { notifConfig.downTmpl = template.New(`down`) notifConfig.downTmpl, err = notifConfig.downTmpl.Parse(notifConfig.DownTemplate) if err != nil { - return fmt.Errorf(`failed to parse down_template: %s`, err) + return fmt.Errorf(`%s: %s: failed to parse down_template: %s`, + logp, notifConfig.Kind, err) } } if notifConfig.UpTemplate != "" { notifConfig.upTmpl = template.New(`up`) notifConfig.upTmpl, err = notifConfig.upTmpl.Parse(notifConfig.UpTemplate) if err != nil { - return fmt.Errorf(`failed to parse up_template: %s`, err) + return fmt.Errorf(`%s: %s: failed to parse up_template: %s`, + logp, notifConfig.Kind, err) } } return nil diff --git a/notif_config_test.go b/notif_config_test.go new file mode 100644 index 0000000..b7990b1 --- /dev/null +++ b/notif_config_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2025 M. Shulhan <ms@kilabit.info> + +package lilin + +import ( + "testing" + + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +func TestNotifConfig_init(t *testing.T) { + listCase := []struct { + expError string + config NotifConfig + }{{ + config: NotifConfig{ + Kind: notifKindSMTP, + }, + expError: `notifConfig.init: smtp: empty user`, + }, { + config: NotifConfig{ + Kind: notifKindSMTP, + User: `john.doe@example.local`, + }, + expError: `notifConfig.init: smtp: empty password`, + }, { + config: NotifConfig{ + Kind: notifKindSMTP, + User: `john.doe@example.local`, + Password: `dummy`, + }, + expError: `notifConfig.init: smtp: empty recipient`, + }, { + config: NotifConfig{ + Kind: notifKindSMTP, + User: `john.doe@example.local`, + Password: `dummy`, + Recipient: []string{ + `jane.doe@example.local`, + }, + }, + }} + for _, tc := range listCase { + err := tc.config.init() + if err != nil { + test.Assert(t, ``, tc.expError, err.Error()) + continue + } + } +} diff --git a/server_config_test.go b/server_config_test.go index bb30e5c..bd17b5e 100644 --- a/server_config_test.go +++ b/server_config_test.go @@ -16,15 +16,15 @@ func TestServerConfig_init(t *testing.T) { exp ServerConfig } - var webhookURL12000 *url.URL - var webhookURL12001 *url.URL + var remoteURL12000 *url.URL + var remoteURL12001 *url.URL var err error - webhookURL12000, err = url.Parse(`http://127.0.0.1:12000`) + remoteURL12000, err = url.Parse(`http://127.0.0.1:12000`) if err != nil { t.Fatal(err) } - webhookURL12001, err = url.Parse(`http://127.0.0.1:12001`) + remoteURL12001, err = url.Parse(`http://127.0.0.1:12001`) if err != nil { t.Fatal(err) } @@ -39,18 +39,29 @@ func TestServerConfig_init(t *testing.T) { Address: `127.0.0.1:12121`, Notifs: []*NotifConfig{{ Kind: `mattermost`, - WebhookURL: `http://127.0.0.1:12000`, + RemoteURL: `http://127.0.0.1:12000`, Channel: `chan_1`, UpTemplate: `Service {{.ID}} is up again!`, DownTemplate: `Service {{.ID}} is down: {{.Error}}`, - webhookURL: webhookURL12000, + remoteURL: remoteURL12000, }, { Kind: `mattermost`, - WebhookURL: `http://127.0.0.1:12001`, + RemoteURL: `http://127.0.0.1:12001`, Channel: `chan_2`, UpTemplate: `Service {{.ID}} is alive!`, DownTemplate: `Service {{.ID}} is down: {{.Error}}`, - webhookURL: webhookURL12001, + remoteURL: remoteURL12001, + }, { + Kind: `smtp`, + RemoteURL: `smtps://127.0.0.1:4650`, + User: `john.doe@example.local`, + Password: `dummy`, + Recipient: []string{ + `jane@example.local`, + `kent@example.local`, + }, + UpTemplate: `Service {{.ID}} is alive!`, + DownTemplate: `Service {{.ID}} is down: {{.Error}}`, }}, }, }} diff --git a/testdata/server_config/ok/etc/lilin/lilin.cfg b/testdata/server_config/ok/etc/lilin/lilin.cfg index ef179d4..901d9e6 100644 --- a/testdata/server_config/ok/etc/lilin/lilin.cfg +++ b/testdata/server_config/ok/etc/lilin/lilin.cfg @@ -6,14 +6,24 @@ address = 127.0.0.1:12121 [notif] kind = mattermost -webhook_url = http://127.0.0.1:12000 +remote_url = http://127.0.0.1:12000 channel = chan_1 down_template = Service {{.ID}} is down: {{.Error}} up_template = Service {{.ID}} is up again! [notif] kind = mattermost -webhook_url = http://127.0.0.1:12001 +remote_url = http://127.0.0.1:12001 channel = chan_2 down_template = Service {{.ID}} is down: {{.Error}} up_template = Service {{.ID}} is alive! + +[notif] +kind = smtp +remote_url = smtps://127.0.0.1:4650 +user = john.doe@example.local +password = dummy +recipient = jane@example.local +recipient = kent@example.local +down_template = Service {{.ID}} is down: {{.Error}} +up_template = Service {{.ID}} is alive! diff --git a/testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg b/testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg index 3c479f3..2225609 100644 --- a/testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg +++ b/testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg @@ -6,7 +6,7 @@ address = 127.0.0.1:12121 [notif] kind = mattermost -webhook_url = http://127.0.0.1:6330/mattermost +remote_url = http://127.0.0.1:6330/mattermost channel = test_webhook down_template = Service {{.ID}} is down: {{.Error}} up_template = Service {{.ID}} is up again! @@ -6,6 +6,7 @@ package lilin import ( "bytes" "encoding/json" + "fmt" "io" "log" "net/http" @@ -14,7 +15,9 @@ import ( "strings" "time" + "git.sr.ht/~shulhan/pakakeh.go/lib/email" "git.sr.ht/~shulhan/pakakeh.go/lib/ini" + "git.sr.ht/~shulhan/pakakeh.go/lib/smtp" ) // worker contains the report of all services. @@ -174,6 +177,8 @@ func (wrk *worker) handlePushNotif() { switch notifConfig.Kind { case notifKindMattermost: wrk.pushNotifMattermost(notifConfig, &scanReport) + case notifKindSMTP: + wrk.pushNotifSMTP(notifConfig, &scanReport) } } } @@ -213,7 +218,7 @@ func (wrk *worker) pushNotifMattermost( var req = &http.Request{ Method: http.MethodPost, - URL: notifConfig.webhookURL, + URL: notifConfig.remoteURL, Header: http.Header{ `Content-Type`: []string{ `application/json`, @@ -242,3 +247,82 @@ func (wrk *worker) pushNotifMattermost( log.Printf(`%s: fail with status code %d: %s`, logp, resp.StatusCode, body) } + +// pushNotifSMTP send notification through SMTP. +func (wrk *worker) pushNotifSMTP(notifConfig *NotifConfig, scanReport *ScanReport) { + var logp = `pushNotifSMTP` + var msg = email.Message{} + var err error + + err = msg.SetFrom(notifConfig.User) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + + for _, recipient := range notifConfig.Recipient { + err = msg.AddTo(recipient) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + } + + var subject string + var text bytes.Buffer + if scanReport.Success { + subject = fmt.Sprintf(`[lilin] Service %s is up!`, scanReport.ID) + err = notifConfig.upTmpl.Execute(&text, scanReport) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + } else { + subject = fmt.Sprintf(`[lilin] Service %s is down!`, scanReport.ID) + err = notifConfig.downTmpl.Execute(&text, scanReport) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + } + + msg.SetSubject(subject) + + err = msg.SetBodyText(text.Bytes()) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + + var data []byte + data, err = msg.Pack() + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + + var mailtx = smtp.NewMailTx(notifConfig.User, notifConfig.Recipient, data) + + var clientOpts = smtp.ClientOptions{ + ServerURL: notifConfig.RemoteURL, + AuthUser: notifConfig.User, + AuthPass: notifConfig.Password, + AuthMechanism: smtp.SaslMechanismPlain, + } + var smtpc *smtp.Client + smtpc, err = smtp.NewClient(clientOpts) + if err != nil { + log.Printf(`%s: %s`, logp, err) + return + } + + _, err = smtpc.MailTx(mailtx) + if err != nil { + log.Printf(`%s: %s`, logp, err) + } + + _, err = smtpc.Quit() + if err != nil { + log.Printf(`%s: %s`, logp, err) + } +} |
