safing-portmaster/cmds/observation-hub/apprise.go

257 lines
6.4 KiB
Go

package main
import (
"bytes"
"crypto/tls"
_ "embed"
"errors"
"flag"
"fmt"
"net/http"
"strings"
"text/template"
"time"
"github.com/safing/portbase/apprise"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/safing/portmaster/service/intel/geoip"
)
var (
appriseModule *modules.Module
appriseNotifier *apprise.Notifier
appriseURL string
appriseTag string
appriseClientCert string
appriseClientKey string
appriseGreet bool
)
func init() {
appriseModule = modules.Register("apprise", nil, startApprise, nil)
flag.StringVar(&appriseURL, "apprise-url", "", "set the apprise URL to enable notifications via apprise")
flag.StringVar(&appriseTag, "apprise-tag", "", "set the apprise tag(s) according to their docs")
flag.StringVar(&appriseClientCert, "apprise-client-cert", "", "set the apprise client certificate")
flag.StringVar(&appriseClientKey, "apprise-client-key", "", "set the apprise client key")
flag.BoolVar(&appriseGreet, "apprise-greet", false, "send a greeting message to apprise on start")
}
func startApprise() error {
// Check if apprise should be configured.
if appriseURL == "" {
return nil
}
// Check if there is a tag.
if appriseTag == "" {
return errors.New("an apprise tag is required")
}
// Create notifier.
appriseNotifier = &apprise.Notifier{
URL: appriseURL,
DefaultType: apprise.TypeInfo,
DefaultTag: appriseTag,
DefaultFormat: apprise.FormatMarkdown,
AllowUntagged: false,
}
if appriseClientCert != "" || appriseClientKey != "" {
// Load client cert from disk.
cert, err := tls.LoadX509KeyPair(appriseClientCert, appriseClientKey)
if err != nil {
return fmt.Errorf("failed to load client cert/key: %w", err)
}
// Set client cert in http client.
appriseNotifier.SetClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
},
},
Timeout: 10 * time.Second,
})
}
if appriseGreet {
err := appriseNotifier.Send(appriseModule.Ctx, &apprise.Message{
Title: "👋 Observation Hub Reporting In",
Body: "I am the Observation Hub. I am connected to the SPN and watch out for it. I will report notable changes to the network here.",
})
if err != nil {
log.Warningf("apprise: failed to send test message: %s", err)
} else {
log.Info("apprise: sent greeting message")
}
}
return nil
}
func reportToApprise(change *observedChange) (errs error) {
// Check if configured.
if appriseNotifier == nil {
return nil
}
handleTag:
for _, tag := range strings.Split(appriseNotifier.DefaultTag, ",") {
// Check if we are shutting down.
if appriseModule.IsStopping() {
return nil
}
// Render notification based on tag / destination.
buf := &bytes.Buffer{}
switch {
case strings.HasPrefix(tag, "matrix-"):
if err := templates.ExecuteTemplate(buf, "matrix-notification", change); err != nil {
return fmt.Errorf("failed to render notification: %w", err)
}
case strings.HasPrefix(tag, "discord-"):
if err := templates.ExecuteTemplate(buf, "discord-notification", change); err != nil {
return fmt.Errorf("failed to render notification: %w", err)
}
default:
// Use matrix notification template as default for now.
if err := templates.ExecuteTemplate(buf, "matrix-notification", change); err != nil {
return fmt.Errorf("failed to render notification: %w", err)
}
}
// Send notification to apprise.
var err error
for i := 0; i < 3; i++ {
// Try three times.
err = appriseNotifier.Send(appriseModule.Ctx, &apprise.Message{
Body: buf.String(),
Tag: tag,
})
if err == nil {
continue handleTag
}
// Wait for 5 seconds, then try again.
time.Sleep(5 * time.Second)
}
// Add error to errors.
if err != nil {
errs = errors.Join(errs, fmt.Errorf("| failed to send: %w", err))
}
}
return errs
}
// var (
// entityTemplate = template.Must(template.New("entity").Parse(
// `Entity: {{ . }}
// {{ .IP }} [{{ .ASN }} - {{ .ASOrg }}]
// `,
// ))
// // {{ with .GetCountryInfo -}}
// // {{ .Name }} ({{ .Code }})
// // {{- end }}
// matrixTemplate = template.Must(template.New("matrix observer notification").Parse(
// `{{ .Title }}
// {{ if .Summary }}
// Details:
// {{ .Summary }}
// Note: Changes were registered at {{ .UpdateTime }} and were possibly merged.
// {{ end }}
// {{ template "entity" .UpdatedPin.EntityV4 }}
// Hub Info:
// Test: {{ .UpdatedPin.EntityV4 }}
// {{ template "entity" .UpdatedPin.EntityV4 }}
// {{ template "entity" .UpdatedPin.EntityV6 }}
// `,
// ))
// discordTemplate = template.Must(template.New("discord observer notification").Parse(
// ``,
// ))
// defaultTemplate = template.Must(template.New("default observer notification").Parse(
// ``,
// ))
// )
var (
//go:embed notifications.tmpl
templateFile string
templates = template.Must(template.New("notifications").Funcs(
template.FuncMap{
"joinStrings": joinStrings,
"textBlock": textBlock,
"getCountryInfo": getCountryInfo,
},
).Parse(templateFile))
)
func joinStrings(slice []string, sep string) string {
return strings.Join(slice, sep)
}
func textBlock(block, addPrefix, addSuffix string) string {
// Trim whitespaces.
block = strings.TrimSpace(block)
// Prepend and append string for every line.
lines := strings.Split(block, "\n")
for i, line := range lines {
lines[i] = addPrefix + line + addSuffix
}
// Return as block.
return strings.Join(lines, "\n")
}
func getCountryInfo(code string) geoip.CountryInfo {
// Get the country info directly instead of via the entity location,
// so it also works in test without the geoip module.
return geoip.GetCountryInfo(code)
}
// func init() {
// templates = template.Must(template.New(templateFile).Parse(templateFile))
// nt, err := templates.New("entity").Parse(
// `Entity: {{ . }}
// {{ .IP }} [{{ .ASN }} - {{ .ASOrg }}]
// `,
// )
// if err != nil {
// panic(err)
// }
// templates.AddParseTree(nt.Tree)
// if _, err := templates.New("matrix-notification").Parse(
// `{{ .Title }}
// {{ if .Summary }}
// Details:
// {{ .Summary }}
// Note: Changes were registered at {{ .UpdateTime }} and were possibly merged.
// {{ end }}
// {{ template "entity" .UpdatedPin.EntityV4 }}
// Hub Info:
// Test: {{ .UpdatedPin.EntityV4 }}
// {{ template "entity" .UpdatedPin.EntityV4 }}
// {{ template "entity" .UpdatedPin.EntityV6 }}
// `,
// ); err != nil {
// panic(err)
// }
// }