Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
c1b3d106a0 | |||
a4fd43e03d | |||
30bf793598 | |||
9b00959fdb | |||
295ceec3de | |||
ef24503214 | |||
2e1aee0313 | |||
b3290c357d | |||
9f31bf557d | |||
e9f39d25b4 |
16 changed files with 395 additions and 313 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -24,3 +24,6 @@ dist/
|
|||
data
|
||||
config
|
||||
*.bak
|
||||
|
||||
# Config/data files
|
||||
*.json
|
||||
|
|
41
README.md
41
README.md
|
@ -1,4 +1,4 @@
|
|||
# WID Notifier
|
||||
# WidNotifier
|
||||
|
||||
The German [BSI](https://www.bsi.bund.de/) and [LSI Bavaria](https://lsi.bayern.de/) each have a page listing current security notices.
|
||||
This software queries the APIs of these services for new security notices and sends configurable email notifications.
|
||||
|
@ -17,7 +17,7 @@ This Software only supports Linux.
|
|||
# Build
|
||||
|
||||
To cross-compile the software for `i386`, `amd64`, `arm` and `arm64`, run `build.sh`.
|
||||
You need a go version >= 1.21 and git.
|
||||
You need Go 1.22.x and git.
|
||||
|
||||
# Usage
|
||||
|
||||
|
@ -34,27 +34,28 @@ Example:
|
|||
```json
|
||||
{
|
||||
"api_fetch_interval": 600,
|
||||
"datafile": "data",
|
||||
"enabled_api_endpoints": [
|
||||
"bay",
|
||||
"bund"
|
||||
],
|
||||
"datafile": "data.json",
|
||||
"loglevel": 2,
|
||||
"recipients": [
|
||||
"lists": [
|
||||
{
|
||||
"address": "guenther@example.org",
|
||||
"include": [
|
||||
{"classification": "kritisch"},
|
||||
{"title_contains": "jQuery"}
|
||||
"name": "Example List",
|
||||
"recipients": ["someone@example.org"],
|
||||
"filter": [
|
||||
{"classification": "hoch", "title_contains": "Microsoft"},
|
||||
{"classification": "kritisch"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"smtp": {
|
||||
"from": "from@example.org",
|
||||
"host": "example.org",
|
||||
"from": "user@localhost",
|
||||
"host": "127.0.0.1",
|
||||
"port": 587,
|
||||
"user": "from@example.org",
|
||||
"password": "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDĂĽRfEnSiEnIcHt"
|
||||
"user": "user@localhost",
|
||||
"password": "change me :)"
|
||||
},
|
||||
"template": {
|
||||
"subject": "",
|
||||
|
@ -67,9 +68,9 @@ To show debug messages, set the `loglevel` to `3`.
|
|||
|
||||
## Filters
|
||||
|
||||
You define filters for notices to be sent per recipient. Multiple filters can be set per recipient and multiple criteria can be used per filter. The configuration field for those filters is `include`. See [Configuration](#configuration) for an example.
|
||||
You define filters for notices to be sent (per recipient). Multiple filters can be set per recipient and multiple criteria can be used per filter. The configuration field for those filters is `include`. See [Configuration](#configuration) for an example.
|
||||
|
||||
It is determined by the following logic, if a notice is included:
|
||||
If a notice is included is determined by the following logic:
|
||||
|
||||
```
|
||||
{criteria, criteria, ... ALL APPLY}
|
||||
|
@ -77,7 +78,7 @@ OR {criteria, criteria, ... ALL APPLY}
|
|||
OR ...
|
||||
```
|
||||
|
||||
The following criteria are available. Criteria marked with `*` are for optional fields that are not supported by every API endpoint (e.g. https://wid.lsi.bayern.de) - notices from those endpoints will therefore not be included when using those criteria in filters.
|
||||
The following criteria are available. Criteria marked with * are optional fields that are not supported by every API endpoint (e.g. https://wid.lsi.bayern.de) - notices from those endpoints will therefore not be included when using those criteria in filters.
|
||||
|
||||
```json
|
||||
"include": [
|
||||
|
@ -122,7 +123,7 @@ Classification can be `"kritisch"`, `"hoch"`, `"mittel"` or `"niedrig"`.
|
|||
```
|
||||
If set to `""`, this criteria will be ignored.
|
||||
|
||||
### min_basescore `*`
|
||||
### min_basescore *
|
||||
|
||||
Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
|
||||
|
||||
|
@ -131,7 +132,7 @@ Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
|
|||
```
|
||||
This criteria will be ignored if set to `0`.
|
||||
|
||||
### status `*`
|
||||
### status *
|
||||
|
||||
Include notices with this status. This is usually either `NEU` or `UPDATE`.
|
||||
|
||||
|
@ -140,7 +141,7 @@ Include notices with this status. This is usually either `NEU` or `UPDATE`.
|
|||
```
|
||||
If set to `""`, this criteria will be ignored.
|
||||
|
||||
### products_contain `*`
|
||||
### products_contain *
|
||||
|
||||
Include notices whose product list contains this text.
|
||||
|
||||
|
@ -149,7 +150,7 @@ Include notices whose product list contains this text.
|
|||
```
|
||||
If set to `""`, this criteria will be ignored.
|
||||
|
||||
### no_patch `*`
|
||||
### no_patch *
|
||||
|
||||
If set to `"true"`, notices where no patch is available will be included.
|
||||
|
||||
|
@ -202,4 +203,6 @@ type WidNotice struct {
|
|||
}
|
||||
```
|
||||
|
||||
Additionally, the field `WidNotifierVersion` holds the version of the software.
|
||||
|
||||
For an example, take a look at `DEFAULT_SUBJECT_TEMPLATE` and `DEFAULT_BODY_TEMPLATE` in [template.go](./template.go).
|
||||
|
|
19
build.sh
19
build.sh
|
@ -2,14 +2,13 @@
|
|||
|
||||
VERSION=$(git describe --tags)
|
||||
|
||||
# i386
|
||||
GOOS=linux GOARCH=386 go build -o dist/wid-notifier_${VERSION}_linux_i386
|
||||
function build() {
|
||||
printf "Compiling version ${VERSION} for ${1}/${2}\t"
|
||||
GOOS=${1} GOARCH=${2} go build -ldflags "-X 'main.Version=${VERSION}'" -o dist/wid-notifier_${VERSION}_${1}_${3}
|
||||
echo "âś…"
|
||||
}
|
||||
|
||||
# amd64
|
||||
GOOS=linux GOARCH=amd64 go build -o dist/wid-notifier_${VERSION}_linux_amd64
|
||||
|
||||
# arm
|
||||
GOOS=linux GOARCH=arm go build -o dist/wid-notifier_${VERSION}_linux_arm
|
||||
|
||||
# arm64
|
||||
GOOS=linux GOARCH=arm64 go build -o dist/wid-notifier_${VERSION}_linux_arm64
|
||||
build linux "386" i386
|
||||
build linux amd64 amd64
|
||||
build linux arm arm
|
||||
build linux arm64 arm64
|
||||
|
|
66
config.go
Normal file
66
config.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Copyright (c) 2024 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ApiFetchInterval int `json:"api_fetch_interval"` // in seconds
|
||||
EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
|
||||
PersistentDataFilePath string `json:"datafile"`
|
||||
LogLevel int `json:"loglevel"`
|
||||
Lists *[]NotifyList `json:"lists"`
|
||||
SmtpConfiguration SmtpSettings `json:"smtp"`
|
||||
Template MailTemplateConfig `json:"template"`
|
||||
}
|
||||
|
||||
func NewConfig() Config {
|
||||
// Initial config
|
||||
c := Config{
|
||||
ApiFetchInterval: 60 * 10, // every 10 minutes,
|
||||
EnabledApiEndpoints: []string{"bay", "bund"},
|
||||
PersistentDataFilePath: "data.json",
|
||||
LogLevel: 2,
|
||||
Lists: &[]NotifyList{
|
||||
{ Name: "Example List",
|
||||
Recipients: []string{},
|
||||
Filter: []Filter{},},
|
||||
},
|
||||
SmtpConfiguration: SmtpSettings{
|
||||
From: "user@localhost",
|
||||
User: "user@localhost",
|
||||
Password: "change me :)",
|
||||
ServerHost: "127.0.0.1",
|
||||
ServerPort: 587},
|
||||
Template: MailTemplateConfig{
|
||||
SubjectTemplate: "",
|
||||
BodyTemplate: "",
|
||||
},
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func checkConfig(config Config) {
|
||||
if len(*config.Lists) < 1 {
|
||||
logger.error("Configuration is incomplete")
|
||||
panic(errors.New("no lists are configured"))
|
||||
}
|
||||
for _, l := range *config.Lists {
|
||||
if len(l.Filter) < 1 {
|
||||
logger.error("Configuration is incomplete")
|
||||
panic(errors.New("list " + l.Name + " has no filter defined - at least [{'any': true/false}] should be configured"))
|
||||
}
|
||||
for _, r := range l.Recipients {
|
||||
if !mailAddressIsValid(r) {
|
||||
logger.error("Configuration includes invalid data")
|
||||
panic(errors.New("'" + r + "' is not a valid e-mail address"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !mailAddressIsValid(config.SmtpConfiguration.From) {
|
||||
logger.error("Configuration includes invalid data")
|
||||
panic(errors.New("'" + config.SmtpConfiguration.From + "' is not a valid e-mail address"))
|
||||
}
|
||||
}
|
85
datastore.go
85
datastore.go
|
@ -4,79 +4,10 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ApiFetchInterval int `json:"api_fetch_interval"` // in seconds
|
||||
EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
|
||||
PersistentDataFilePath string `json:"datafile"`
|
||||
LogLevel int `json:"loglevel"`
|
||||
Recipients []Recipient `json:"recipients"`
|
||||
SmtpConfiguration SmtpSettings `json:"smtp"`
|
||||
Template MailTemplateConfig `json:"template"`
|
||||
}
|
||||
|
||||
func NewConfig() Config {
|
||||
// Initial config
|
||||
c := Config{
|
||||
ApiFetchInterval: 60 * 10, // every 10 minutes,
|
||||
EnabledApiEndpoints: []string{"bay", "bund"},
|
||||
PersistentDataFilePath: "data",
|
||||
LogLevel: 2,
|
||||
Recipients: []Recipient{},
|
||||
SmtpConfiguration: SmtpSettings{
|
||||
From: "from@example.org",
|
||||
User: "from@example.org",
|
||||
Password: "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDĂĽRfEnSiEnIcHt",
|
||||
ServerHost: "example.org",
|
||||
ServerPort: 587},
|
||||
Template: MailTemplateConfig{
|
||||
SubjectTemplate: "",
|
||||
BodyTemplate: "",
|
||||
},
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func checkConfig(config Config) {
|
||||
if len(config.Recipients) < 1 {
|
||||
logger.error("Configuration is incomplete")
|
||||
panic(errors.New("no recipients are configured"))
|
||||
}
|
||||
for _, r := range config.Recipients {
|
||||
if !mailAddressIsValid(r.Address) {
|
||||
logger.error("Configuration includes invalid data")
|
||||
panic(errors.New("'" + r.Address + "' is not a valid e-mail address"))
|
||||
}
|
||||
if len(r.Filters) < 1 {
|
||||
logger.error("Configuration is incomplete")
|
||||
panic(errors.New("recipient " + r.Address + " has no filter defined - at least {'any': true/false} should be configured"))
|
||||
}
|
||||
}
|
||||
if !mailAddressIsValid(config.SmtpConfiguration.From) {
|
||||
logger.error("Configuration includes invalid data")
|
||||
panic(errors.New("'" + config.SmtpConfiguration.From + "' is not a valid e-mail address"))
|
||||
}
|
||||
}
|
||||
|
||||
type PersistentData struct {
|
||||
// {endpoint id 1: time last published, endpoint id 2: ..., ...}
|
||||
LastPublished map[string]time.Time `json:"last_published"`
|
||||
}
|
||||
|
||||
func NewPersistentData(c Config) PersistentData {
|
||||
// Initial persistent data
|
||||
d := PersistentData{LastPublished: map[string]time.Time{}}
|
||||
for _, e := range apiEndpoints {
|
||||
d.LastPublished[e.Id] = time.Now().Add(-time.Hour * 24) // a day ago
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type DataStore struct {
|
||||
filepath string
|
||||
prettyJSON bool
|
||||
|
@ -92,32 +23,24 @@ func (ds *DataStore) save() error {
|
|||
} else {
|
||||
data, err = json.Marshal(ds.data)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil { return err }
|
||||
err = os.WriteFile(ds.filepath, data, ds.fileMode)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ds *DataStore) load() error {
|
||||
data, err := os.ReadFile(ds.filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil { return err }
|
||||
switch ds.data.(type) {
|
||||
case Config:
|
||||
d, _ := ds.data.(Config);
|
||||
err = json.Unmarshal(data, &d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil { return err }
|
||||
ds.data = d
|
||||
case PersistentData:
|
||||
d, _ := ds.data.(PersistentData);
|
||||
err = json.Unmarshal(data, &d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil { return err }
|
||||
ds.data = d
|
||||
}
|
||||
return err
|
||||
|
|
2
go.mod
2
go.mod
|
@ -1,3 +1,3 @@
|
|||
module github.com/ChaoticByte/wid-notifier
|
||||
|
||||
go 1.22.2
|
||||
go 1.24
|
||||
|
|
162
mail.go
162
mail.go
|
@ -1,162 +0,0 @@
|
|||
// Copyright (c) 2023 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MAIL_LINE_SEP = "\r\n"
|
||||
|
||||
type MailContent struct {
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (c MailContent) serializeValidMail(from string, to string) []byte {
|
||||
// We'll send base64 encoded Subject & Body, because we Dschörmäns have umlauts
|
||||
// and I'm too lazy to encode ä into =E4 and so on
|
||||
subjectEncoded := base64.StdEncoding.EncodeToString([]byte(c.Subject))
|
||||
bodyEncoded := base64.StdEncoding.EncodeToString([]byte(c.Body))
|
||||
data := []byte(fmt.Sprintf(
|
||||
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\nFrom: %v%vTo: %v%vSubject: =?utf-8?b?%v?=%v%v%v",
|
||||
from, MAIL_LINE_SEP,
|
||||
to, MAIL_LINE_SEP,
|
||||
subjectEncoded, MAIL_LINE_SEP,
|
||||
MAIL_LINE_SEP,
|
||||
bodyEncoded))
|
||||
// done, I guess
|
||||
return data
|
||||
}
|
||||
|
||||
type Recipient struct {
|
||||
Address string `json:"address"`
|
||||
// Must be a configured filter id
|
||||
Filters []Filter `json:"include"`
|
||||
}
|
||||
|
||||
type SmtpSettings struct {
|
||||
From string `json:"from"`
|
||||
ServerHost string `json:"host"`
|
||||
ServerPort int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (r Recipient) filterAndSendNotices(notices []WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings, cache *map[string][]byte) error {
|
||||
filteredNotices := []WidNotice{}
|
||||
for _, f := range r.Filters {
|
||||
for _, n := range f.filter(notices) {
|
||||
if !noticeSliceContains(filteredNotices, n) {
|
||||
filteredNotices = append(filteredNotices, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Reverse(filteredNotices)
|
||||
logger.debug(fmt.Sprintf("Including %v of %v notices for recipient %v", len(filteredNotices), len(notices), r.Address))
|
||||
logger.debug("Generating and sending mails to " + r.Address + " ...")
|
||||
cacheHits := 0
|
||||
cacheMisses := 0
|
||||
mails := [][]byte{}
|
||||
for _, n := range filteredNotices {
|
||||
var data []byte
|
||||
cacheResult := (*cache)[n.Uuid]
|
||||
if len(cacheResult) > 0 {
|
||||
cacheHits++
|
||||
data = cacheResult
|
||||
} else {
|
||||
cacheMisses++
|
||||
mailContent, err := template.generate(n)
|
||||
if err != nil {
|
||||
logger.error("Could not create mail from template")
|
||||
logger.error(err)
|
||||
}
|
||||
// serialize & send mail
|
||||
data = mailContent.serializeValidMail(smtpConfig.From, r.Address)
|
||||
// add to cache
|
||||
(*cache)[n.Uuid] = data
|
||||
}
|
||||
mails = append(mails, data)
|
||||
}
|
||||
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
|
||||
err := sendMails(
|
||||
smtpConfig,
|
||||
auth,
|
||||
r.Address,
|
||||
mails,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.debug("Successfully sent all mails to " + r.Address)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, data [][]byte) error {
|
||||
addr := fmt.Sprintf("%v:%v", smtpConf.ServerHost, smtpConf.ServerPort)
|
||||
logger.debug("Connecting to mail server at " + addr + " ...")
|
||||
connection, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
// can leave out connection.Hello
|
||||
hasTlsExt, _ := connection.Extension("starttls")
|
||||
if hasTlsExt {
|
||||
err = connection.StartTLS(&tls.Config{ServerName: smtpConf.ServerHost})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.debug("Mail Server supports TLS")
|
||||
} else {
|
||||
logger.debug("Mail Server doesn't support TLS")
|
||||
}
|
||||
logger.debug("Authenticating to mail server ...")
|
||||
err = connection.Auth(auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if logger.LogLevel >= 3 {
|
||||
fmt.Printf("DEBUG %v Sending mails to server ", time.Now().Format("2006/01/02 15:04:05.000000"))
|
||||
}
|
||||
for _, d := range data {
|
||||
err = connection.Mail(smtpConf.From)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = connection.Rcpt(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := connection.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if logger.LogLevel >= 3 {
|
||||
print(".")
|
||||
}
|
||||
}
|
||||
if logger.LogLevel >= 3 {
|
||||
print("\n")
|
||||
}
|
||||
return connection.Quit()
|
||||
}
|
||||
|
||||
func mailAddressIsValid(address string) bool {
|
||||
_, err := mail.ParseAddress(address);
|
||||
return err == nil
|
||||
}
|
90
mail_common.go
Normal file
90
mail_common.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
// Copyright (c) 2023 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MailContent struct {
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (c MailContent) serializeValidMail(from string, to string) []byte {
|
||||
// format subject using Q Encoding from RFC2047
|
||||
subjectEncoded := mime.QEncoding.Encode("utf-8", c.Subject)
|
||||
// format body using Quoted-Printable Encoding from RFC2045
|
||||
var bodyEncoded strings.Builder
|
||||
bew := quotedprintable.NewWriter(&bodyEncoded)
|
||||
bew.Write([]byte(c.Body))
|
||||
bew.Close()
|
||||
// glue it all together
|
||||
data := fmt.Appendf(nil,
|
||||
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: Quoted-Printable\r\nFrom: %v\r\nTo: %v\r\nSubject: %v\r\n\r\n%v",
|
||||
from, to, subjectEncoded, bodyEncoded.String(),
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
type NotifyList struct {
|
||||
Name string `json:"name"`
|
||||
Recipients []string `json:"recipients"`
|
||||
// Must be a configured filter id
|
||||
Filter []Filter `json:"filter"`
|
||||
}
|
||||
|
||||
type SmtpSettings struct {
|
||||
From string `json:"from"`
|
||||
ServerHost string `json:"host"`
|
||||
ServerPort int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func sendNotices(recipient string, notices []*WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings, mailContentCache *map[string]*MailContent) error {
|
||||
logger.debug("Generating and sending mails for recipient " + recipient + " ...")
|
||||
cacheHits := 0
|
||||
cacheMisses := 0
|
||||
mails := []*MailContent{}
|
||||
for _, n := range notices {
|
||||
var mc *MailContent
|
||||
cacheResult := (*mailContentCache)[n.Uuid]
|
||||
if cacheResult != nil {
|
||||
cacheHits++
|
||||
mc = cacheResult
|
||||
} else {
|
||||
cacheMisses++
|
||||
mc_, err := template.generate(TemplateData{n, Version})
|
||||
if err != nil {
|
||||
logger.error("Could not create mail from template")
|
||||
logger.error(err)
|
||||
} else {
|
||||
mc = &mc_
|
||||
// add to cache
|
||||
(*mailContentCache)[n.Uuid] = mc
|
||||
}
|
||||
}
|
||||
mails = append(mails, mc)
|
||||
}
|
||||
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
|
||||
err := sendMails(
|
||||
smtpConfig,
|
||||
auth,
|
||||
recipient,
|
||||
mails,
|
||||
)
|
||||
if err != nil { return err }
|
||||
logger.debug("Successfully sent all mails to " + recipient)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailAddressIsValid(address string) bool {
|
||||
_, err := mail.ParseAddress(address);
|
||||
return err == nil
|
||||
}
|
57
mail_transfer.go
Normal file
57
mail_transfer.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// +build !debug_mail_transfer
|
||||
// Copyright (c) 2023 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
|
||||
addr := fmt.Sprintf("%v:%v", smtpConf.ServerHost, smtpConf.ServerPort)
|
||||
logger.debug("Connecting to mail server at " + addr + " ...")
|
||||
connection, err := smtp.Dial(addr)
|
||||
if err != nil { return err }
|
||||
defer connection.Close()
|
||||
// can leave out connection.Hello
|
||||
hasTlsExt, _ := connection.Extension("starttls")
|
||||
if hasTlsExt {
|
||||
err = connection.StartTLS(&tls.Config{ServerName: smtpConf.ServerHost})
|
||||
if err != nil { return err }
|
||||
logger.debug("Mail Server supports StartTLS")
|
||||
} else {
|
||||
logger.debug("Mail Server doesn't support StartTLS")
|
||||
}
|
||||
logger.debug("Authenticating to mail server ...")
|
||||
err = connection.Auth(auth)
|
||||
if err != nil { return err }
|
||||
if logger.LogLevel >= 3 {
|
||||
fmt.Printf("DEBUG %v Sending mails to server ", time.Now().Format("2006/01/02 15:04:05.000000"))
|
||||
}
|
||||
for _, mc := range mails {
|
||||
// serialize mail
|
||||
d := mc.serializeValidMail(smtpConf.From, to)
|
||||
// send mail
|
||||
err = connection.Mail(smtpConf.From)
|
||||
if err != nil { return err }
|
||||
err = connection.Rcpt(to)
|
||||
if err != nil { return err }
|
||||
writer, err := connection.Data()
|
||||
if err != nil { return err }
|
||||
_, err = writer.Write(d)
|
||||
if err != nil { return err }
|
||||
err = writer.Close()
|
||||
if err != nil { return err }
|
||||
if logger.LogLevel >= 3 {
|
||||
print(".")
|
||||
}
|
||||
}
|
||||
if logger.LogLevel >= 3 {
|
||||
print("\n")
|
||||
}
|
||||
return connection.Quit()
|
||||
}
|
26
mail_transfer_debug.go
Normal file
26
mail_transfer_debug.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
// +build debug_mail_transfer
|
||||
// Copyright (c) 2023 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
|
||||
logger.warn("Mail Transfer Debugging is active. Not connecting.")
|
||||
logger.info("MAIL TRANSFER: \n\n")
|
||||
for _, mc := range mails {
|
||||
// serialize mail
|
||||
d := mc.serializeValidMail(smtpConf.From, to)
|
||||
// output mail
|
||||
fmt.Println("MAIL FROM:" + smtpConf.From)
|
||||
fmt.Println("RCPT TO:" + to)
|
||||
fmt.Println("DATA")
|
||||
fmt.Println(string(d))
|
||||
fmt.Println(".")
|
||||
}
|
||||
fmt.Print("\n\n")
|
||||
return nil
|
||||
}
|
92
main.go
92
main.go
|
@ -6,35 +6,55 @@ import (
|
|||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
var executableName string
|
||||
var logger Logger
|
||||
|
||||
|
||||
func showVersion() {
|
||||
fmt.Printf("wid-notifier %s\n", Version)
|
||||
}
|
||||
|
||||
func showHelp() {
|
||||
fmt.Printf("Usage: %v <configfile>\n\nIf the config file doesn't exist, an incomplete \n" +
|
||||
"configuration with default values is created.\n\n",
|
||||
executableName)
|
||||
showVersion()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// get cli arguments
|
||||
args := os.Args
|
||||
executableName = args[0]
|
||||
if len(args) < 2 {
|
||||
fmt.Printf("Usage: %v <configfile>\nIf the config file doesn't exist, a incomplete configuration with default values is created.\n", args[0])
|
||||
showHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
configFilePath := os.Args[1]
|
||||
for _, arg := range args {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
showHelp()
|
||||
os.Exit(0)
|
||||
} else if arg == "--version" {
|
||||
showVersion()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
configFilePath := args[1]
|
||||
// create logger
|
||||
logger = NewLogger(2)
|
||||
// init
|
||||
logger.info("Initializing ...")
|
||||
defer logger.info("Exiting ...")
|
||||
// open & check config
|
||||
config := NewDataStore(
|
||||
configFilePath,
|
||||
NewConfig(),
|
||||
true,
|
||||
0600,
|
||||
).data.(Config)
|
||||
persistent := NewDataStore(
|
||||
config.PersistentDataFilePath,
|
||||
NewPersistentData(config),
|
||||
false,
|
||||
0640)
|
||||
logger.LogLevel = config.LogLevel
|
||||
logger.debug("Checking configuration file ...")
|
||||
checkConfig(config)
|
||||
|
@ -66,17 +86,24 @@ func main() {
|
|||
}
|
||||
}
|
||||
}
|
||||
// open data file
|
||||
persistent := NewDataStore(
|
||||
config.PersistentDataFilePath,
|
||||
NewPersistentData(config),
|
||||
false,
|
||||
0640)
|
||||
// main loop
|
||||
logger.debug("Entering main loop ...")
|
||||
for {
|
||||
t1 := time.Now().UnixMilli()
|
||||
newNotices := []WidNotice{}
|
||||
cache := map[string][]byte{}
|
||||
lastPublished := map[string]time.Time{} // endpoint id : last published timestamp
|
||||
cache := map[string]*MailContent{} // cache generated emails for reuse
|
||||
for _, a := range enabledApiEndpoints {
|
||||
logger.info("Querying endpoint '" + a.Id + "' for new notices ...")
|
||||
n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
||||
if err != nil {
|
||||
// retry
|
||||
// retry (once)
|
||||
logger.warn("Couldn't query notices from API endpoint '" + a.Id + "'. Retrying ...")
|
||||
logger.warn(err)
|
||||
n, t, err = a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
||||
|
@ -87,26 +114,59 @@ func main() {
|
|||
logger.error(err)
|
||||
} else if len(n) > 0 {
|
||||
newNotices = append(newNotices, n...)
|
||||
persistent.data.(PersistentData).LastPublished[a.Id] = t
|
||||
persistent.save()
|
||||
lastPublished[a.Id] = t
|
||||
}
|
||||
}
|
||||
logger.debug(fmt.Sprintf("Got %v new notices", len(newNotices)))
|
||||
if len(newNotices) > 0 {
|
||||
logger.info("Sending email notifications ...")
|
||||
// mail recipient : pointer to slice of wid notices to be sent
|
||||
noticesToBeSent := map[string][]*WidNotice{}
|
||||
recipientsNotified := 0
|
||||
for _, r := range config.Recipients {
|
||||
err := r.filterAndSendNotices(newNotices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
|
||||
var err error
|
||||
for _, l := range *config.Lists {
|
||||
// Filter notices for this list
|
||||
for _, f := range l.Filter {
|
||||
for _, n := range f.filter(newNotices) {
|
||||
np := &n
|
||||
for _, r := range l.Recipients {
|
||||
if !noticeSliceContains(noticesToBeSent[r], np) {
|
||||
noticesToBeSent[r] = append(noticesToBeSent[r], np)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r, notices := range noticesToBeSent {
|
||||
// sort by publish date
|
||||
slices.SortFunc(notices, func(a *WidNotice, b *WidNotice) int {
|
||||
if a.Published == b.Published {
|
||||
return 0
|
||||
} else if a.Published.After(b.Published) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
})
|
||||
// send
|
||||
err = sendNotices(r, notices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
|
||||
if err != nil {
|
||||
logger.error(err)
|
||||
} else {
|
||||
recipientsNotified++
|
||||
}
|
||||
}
|
||||
logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(config.Recipients)))
|
||||
if recipientsNotified < 1 && err != nil {
|
||||
logger.error("Couldn't send any mail notification!")
|
||||
} else {
|
||||
for id, t := range lastPublished {
|
||||
persistent.data.(PersistentData).LastPublished[id] = t
|
||||
persistent.save()
|
||||
}
|
||||
logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(noticesToBeSent)))
|
||||
}
|
||||
}
|
||||
t2 := time.Now().UnixMilli()
|
||||
dt := int(t2 - t1)
|
||||
dt := int(time.Now().UnixMilli() - t1)
|
||||
time.Sleep(time.Millisecond * time.Duration((config.ApiFetchInterval * 1000) - dt))
|
||||
}
|
||||
}
|
||||
|
|
12
notice.go
12
notice.go
|
@ -25,17 +25,7 @@ type WidNotice struct {
|
|||
PortalUrl string
|
||||
}
|
||||
|
||||
// func (n WidNotice) serialized() ([]byte, error) {
|
||||
// return json.Marshal(n)
|
||||
// }
|
||||
|
||||
// func NewWidNoticeFromJSON(data []byte) (WidNotice, error) {
|
||||
// n := WidNotice{}
|
||||
// err := json.Unmarshal(data, &n)
|
||||
// return n, err
|
||||
// }
|
||||
|
||||
func noticeSliceContains(notices []WidNotice, notice WidNotice) bool {
|
||||
func noticeSliceContains(notices []*WidNotice, notice *WidNotice) bool {
|
||||
for _, x := range notices {
|
||||
if x.Uuid == notice.Uuid {
|
||||
return true
|
||||
|
|
21
persistent_data.go
Normal file
21
persistent_data.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright (c) 2024 Julian MĂĽller (ChaoticByte)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PersistentData struct {
|
||||
// {endpoint id 1: time last published, endpoint id 2: ..., ...}
|
||||
LastPublished map[string]time.Time `json:"last_published"`
|
||||
}
|
||||
|
||||
func NewPersistentData(c Config) PersistentData {
|
||||
// Initial persistent data
|
||||
d := PersistentData{LastPublished: map[string]time.Time{}}
|
||||
for _, e := range apiEndpoints {
|
||||
d.LastPublished[e.Id] = time.Now().Add(-time.Hour * 24) // a day ago
|
||||
}
|
||||
return d
|
||||
}
|
25
template.go
25
template.go
|
@ -26,7 +26,16 @@ Affected Products:{{ range $product := .ProductNames }}
|
|||
|
||||
Assigned CVEs:{{ range $cve := .Cves }}
|
||||
- {{ $cve }} -> https://www.cve.org/CVERecord?id={{ $cve }}
|
||||
{{- end }}{{ end }}`
|
||||
{{- end }}{{ end }}
|
||||
|
||||
|
||||
Sent by WidNotifier {{ .WidNotifierVersion }}
|
||||
`
|
||||
|
||||
type TemplateData struct {
|
||||
*WidNotice
|
||||
WidNotifierVersion string
|
||||
}
|
||||
|
||||
type MailTemplateConfig struct {
|
||||
SubjectTemplate string `json:"subject"`
|
||||
|
@ -38,19 +47,15 @@ type MailTemplate struct {
|
|||
BodyTemplate template.Template
|
||||
}
|
||||
|
||||
func (t MailTemplate) generate(notice WidNotice) (MailContent, error) {
|
||||
func (t MailTemplate) generate(data TemplateData) (MailContent, error) {
|
||||
c := MailContent{}
|
||||
buffer := &bytes.Buffer{}
|
||||
err := t.SubjectTemplate.Execute(buffer, notice)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
err := t.SubjectTemplate.Execute(buffer, data)
|
||||
if err != nil { return c, err }
|
||||
c.Subject = buffer.String()
|
||||
buffer.Truncate(0) // we can recycle our buffer
|
||||
err = t.BodyTemplate.Execute(buffer, notice)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
err = t.BodyTemplate.Execute(buffer, data)
|
||||
if err != nil { return c, err }
|
||||
c.Body = buffer.String()
|
||||
return c, nil
|
||||
}
|
||||
|
|
3
version.go
Normal file
3
version.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package main
|
||||
|
||||
var Version = "devel"
|
|
@ -57,9 +57,7 @@ func (e ApiEndpoint) getNotices(since time.Time) ([]WidNotice, time.Time, error)
|
|||
if err == nil {
|
||||
if res.StatusCode == 200 {
|
||||
resBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return []WidNotice{}, since, err
|
||||
}
|
||||
if err != nil { return []WidNotice{}, since, err }
|
||||
var decodedData map[string]interface{}
|
||||
if err = json.Unmarshal(resBody, &decodedData); err != nil {
|
||||
return []WidNotice{}, since, err
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue