aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2026-01-20 22:28:36 +0700
committerShulhan <ms@kilabit.info>2026-01-20 22:28:36 +0700
commitd0969869954c299c04ea58ab0fda1eba6a0350da (patch)
tree8d316903a98e384ffb7746819618521eb66683eb
parentbf87c6ad4824c7ed1c990aeff2d2883936c1f20a (diff)
downloadlilin-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--.gitignore2
-rw-r--r--CHANGELOG.adoc19
-rw-r--r--README.md38
-rw-r--r--go.mod1
-rw-r--r--go.sum4
-rw-r--r--lilin.go2
-rw-r--r--notif_config.go56
-rw-r--r--notif_config_test.go51
-rw-r--r--server_config_test.go27
-rw-r--r--testdata/server_config/ok/etc/lilin/lilin.cfg14
-rw-r--r--testdata/worker/pushNotifMattermost/etc/lilin/lilin.cfg2
-rw-r--r--worker.go86
12 files changed, 270 insertions, 32 deletions
diff --git a/.gitignore b/.gitignore
index d1dc80f..fcbd285 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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)
diff --git a/README.md b/README.md
index 083932d..3fbc094 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/go.mod b/go.mod
index 068deae..a19892a 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index c6e6ef6..6f5f55b 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/lilin.go b/lilin.go
index acf8e21..b855692 100644
--- a/lilin.go
+++ b/lilin.go
@@ -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!
diff --git a/worker.go b/worker.go
index bccfe74..b39b047 100644
--- a/worker.go
+++ b/worker.go
@@ -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)
+ }
+}