You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
304 lines
9.3 KiB
304 lines
9.3 KiB
|
2 months ago
|
package alarmdelivery
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/tls"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"net"
|
||
|
|
"net/smtp"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gosnmp/gosnmp"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Attempt struct {
|
||
|
|
DeliveryID string
|
||
|
|
Channel string
|
||
|
|
Request Request
|
||
|
|
}
|
||
|
|
|
||
|
|
type Transport interface {
|
||
|
|
Send(context.Context, Attempt) error
|
||
|
|
}
|
||
|
|
|
||
|
|
type TransportFunc func(context.Context, Attempt) error
|
||
|
|
|
||
|
|
func (f TransportFunc) Send(ctx context.Context, attempt Attempt) error { return f(ctx, attempt) }
|
||
|
|
|
||
|
|
type WebTransport struct{}
|
||
|
|
|
||
|
|
func (WebTransport) Send(context.Context, Attempt) error { return nil }
|
||
|
|
|
||
|
|
type SecretResolver interface{ Get(string) ([]byte, bool) }
|
||
|
|
|
||
|
|
type EmailTransport struct {
|
||
|
|
Secrets SecretResolver
|
||
|
|
DialContext DialContextFunc
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t EmailTransport) Send(ctx context.Context, attempt Attempt) error {
|
||
|
|
target := attempt.Request.Email
|
||
|
|
if target == nil {
|
||
|
|
return errors.New("email target is not configured")
|
||
|
|
}
|
||
|
|
if err := validateEmailTarget(*target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
dial := t.DialContext
|
||
|
|
if dial == nil {
|
||
|
|
dial = (&net.Dialer{Timeout: 5 * time.Second}).DialContext
|
||
|
|
}
|
||
|
|
address := net.JoinHostPort(target.Host, strconv.Itoa(target.Port))
|
||
|
|
connection, err := dial(ctx, "tcp", address)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("connect SMTP server: %w", err)
|
||
|
|
}
|
||
|
|
defer connection.Close()
|
||
|
|
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
|
||
|
|
client, err := smtp.NewClient(connection, target.Host)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("initialize SMTP client: %w", err)
|
||
|
|
}
|
||
|
|
defer client.Close()
|
||
|
|
if target.STARTTLS {
|
||
|
|
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||
|
|
return errors.New("SMTP server does not advertise STARTTLS")
|
||
|
|
}
|
||
|
|
if err := client.StartTLS(&tls.Config{MinVersion: tls.VersionTLS12, ServerName: target.Host}); err != nil {
|
||
|
|
return fmt.Errorf("start SMTP TLS: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if target.Auth {
|
||
|
|
if t.Secrets == nil {
|
||
|
|
return errors.New("SMTP secret resolver is unavailable")
|
||
|
|
}
|
||
|
|
password, ok := t.Secrets.Get(target.PasswordKey)
|
||
|
|
if !ok {
|
||
|
|
return errors.New("SMTP password is not available")
|
||
|
|
}
|
||
|
|
if err := client.Auth(smtp.PlainAuth("", target.Username, string(password), target.Host)); err != nil {
|
||
|
|
return fmt.Errorf("authenticate SMTP client: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := client.Mail(target.From); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
recipients := append(append([]string(nil), target.To...), target.CC...)
|
||
|
|
for _, recipient := range recipients {
|
||
|
|
if err := client.Rcpt(recipient); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
writer, err := client.Data()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
subject := sanitizeHeader(target.Subject)
|
||
|
|
if subject == "" {
|
||
|
|
subject = "TERA console server alarm"
|
||
|
|
}
|
||
|
|
body := target.Content
|
||
|
|
if body == "" {
|
||
|
|
body = attempt.Request.Message
|
||
|
|
}
|
||
|
|
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\n", sanitizeHeader(target.From), strings.Join(target.To, ", "))
|
||
|
|
if len(target.CC) > 0 {
|
||
|
|
headers += fmt.Sprintf("Cc: %s\r\n", strings.Join(target.CC, ", "))
|
||
|
|
}
|
||
|
|
headers += fmt.Sprintf("Subject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n", subject)
|
||
|
|
if _, err = io.WriteString(writer, headers+body+"\r\n"); err != nil {
|
||
|
|
_ = writer.Close()
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err = writer.Close(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return client.Quit()
|
||
|
|
}
|
||
|
|
|
||
|
|
func sanitizeHeader(value string) string {
|
||
|
|
return strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ", "\x00", "").Replace(value))
|
||
|
|
}
|
||
|
|
|
||
|
|
type SNMPTrapTransport struct {
|
||
|
|
Secrets SecretResolver
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t SNMPTrapTransport) Send(ctx context.Context, attempt Attempt) error {
|
||
|
|
for _, target := range attempt.Request.SNMPTraps {
|
||
|
|
if err := validateSNMPTrapTarget(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
params := &gosnmp.GoSNMP{
|
||
|
|
Target: target.Host, Port: uint16(target.Port), Transport: "udp",
|
||
|
|
Context: ctx, Timeout: 5 * time.Second, Retries: 0,
|
||
|
|
Logger: gosnmp.NewLogger(log.New(io.Discard, "", 0)),
|
||
|
|
}
|
||
|
|
var trap gosnmp.SnmpTrap
|
||
|
|
switch target.Version {
|
||
|
|
case "v1":
|
||
|
|
community, err := t.secret(target.TargetID, target.CredentialKey, "community")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
params.Version, params.Community = gosnmp.Version1, community
|
||
|
|
trap = gosnmp.SnmpTrap{Enterprise: ".1.3.6.1.4.1.55555", AgentAddress: "0.0.0.0", GenericTrap: 6, SpecificTrap: 1, Timestamp: uint(time.Now().Unix()), Variables: alarmTrapVariables(attempt.Request.Message, false)}
|
||
|
|
case "v2c":
|
||
|
|
community, err := t.secret(target.TargetID, target.CredentialKey, "community")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
params.Version, params.Community = gosnmp.Version2c, community
|
||
|
|
trap = gosnmp.SnmpTrap{Variables: alarmTrapVariables(attempt.Request.Message, true)}
|
||
|
|
case "v3":
|
||
|
|
if err := t.configureV3(params, target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
trap = gosnmp.SnmpTrap{Variables: alarmTrapVariables(attempt.Request.Message, true)}
|
||
|
|
}
|
||
|
|
if err := params.Connect(); err != nil {
|
||
|
|
return fmt.Errorf("connect SNMP target %s: %w", target.TargetID, err)
|
||
|
|
}
|
||
|
|
_, sendErr := params.SendTrap(trap)
|
||
|
|
_ = params.Close()
|
||
|
|
if sendErr != nil {
|
||
|
|
return fmt.Errorf("write SNMP trap %s: %w", target.TargetID, sendErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t SNMPTrapTransport) secret(targetID, key, name string) (string, error) {
|
||
|
|
if t.Secrets == nil {
|
||
|
|
return "", errors.New("SNMP secret resolver is unavailable")
|
||
|
|
}
|
||
|
|
value, ok := t.Secrets.Get(key)
|
||
|
|
if !ok || len(value) == 0 {
|
||
|
|
return "", fmt.Errorf("SNMP %s for %s is not available", name, targetID)
|
||
|
|
}
|
||
|
|
return string(value), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t SNMPTrapTransport) configureV3(params *gosnmp.GoSNMP, target SNMPTrapTarget) error {
|
||
|
|
authProtocol, flags := gosnmp.NoAuth, gosnmp.NoAuthNoPriv
|
||
|
|
var authPassphrase string
|
||
|
|
switch target.AuthProtocol {
|
||
|
|
case "none":
|
||
|
|
case "md5":
|
||
|
|
authProtocol, flags = gosnmp.MD5, gosnmp.AuthNoPriv
|
||
|
|
case "sha1":
|
||
|
|
authProtocol, flags = gosnmp.SHA, gosnmp.AuthNoPriv
|
||
|
|
case "sha256":
|
||
|
|
authProtocol, flags = gosnmp.SHA256, gosnmp.AuthNoPriv
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("SNMPv3 target %s has unsupported auth protocol", target.TargetID)
|
||
|
|
}
|
||
|
|
if flags != gosnmp.NoAuthNoPriv {
|
||
|
|
var err error
|
||
|
|
authPassphrase, err = t.secret(target.TargetID, target.CredentialKey, "authentication credential")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
privacyProtocol := gosnmp.NoPriv
|
||
|
|
var privacyPassphrase string
|
||
|
|
switch target.PrivacyProtocol {
|
||
|
|
case "none":
|
||
|
|
case "des":
|
||
|
|
privacyProtocol, flags = gosnmp.DES, gosnmp.AuthPriv
|
||
|
|
case "aes":
|
||
|
|
privacyProtocol, flags = gosnmp.AES, gosnmp.AuthPriv
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("SNMPv3 target %s has unsupported privacy protocol", target.TargetID)
|
||
|
|
}
|
||
|
|
if flags == gosnmp.AuthPriv {
|
||
|
|
var err error
|
||
|
|
privacyPassphrase, err = t.secret(target.TargetID, target.PrivacyKeyRef, "privacy credential")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
params.Version = gosnmp.Version3
|
||
|
|
params.SecurityModel = gosnmp.UserSecurityModel
|
||
|
|
params.MsgFlags = flags
|
||
|
|
params.ContextEngineID = target.EngineID
|
||
|
|
params.SecurityParameters = &gosnmp.UsmSecurityParameters{
|
||
|
|
AuthoritativeEngineID: target.EngineID, UserName: target.Username,
|
||
|
|
AuthenticationProtocol: authProtocol, AuthenticationPassphrase: authPassphrase,
|
||
|
|
PrivacyProtocol: privacyProtocol, PrivacyPassphrase: privacyPassphrase,
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func alarmTrapVariables(message string, includeTrapOID bool) []gosnmp.SnmpPDU {
|
||
|
|
variables := make([]gosnmp.SnmpPDU, 0, 2)
|
||
|
|
if includeTrapOID {
|
||
|
|
variables = append(variables, gosnmp.SnmpPDU{Name: ".1.3.6.1.6.3.1.1.4.1.0", Type: gosnmp.ObjectIdentifier, Value: ".1.3.6.1.4.1.55555.1.1"})
|
||
|
|
}
|
||
|
|
return append(variables, gosnmp.SnmpPDU{Name: ".1.3.6.1.4.1.55555.1.2", Type: gosnmp.OctetString, Value: []byte(sanitizeSNMPMessage(message))})
|
||
|
|
}
|
||
|
|
func sanitizeSNMPMessage(value string) string {
|
||
|
|
value = sanitizeHeader(value)
|
||
|
|
runes := []rune(value)
|
||
|
|
if len(runes) > 512 {
|
||
|
|
value = string(runes[:512])
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
type DialContextFunc func(context.Context, string, string) (net.Conn, error)
|
||
|
|
|
||
|
|
type SyslogTransport struct{ DialContext DialContextFunc }
|
||
|
|
|
||
|
|
var syslogFacilities = map[string]int{
|
||
|
|
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5,
|
||
|
|
"lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
|
||
|
|
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
|
||
|
|
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t SyslogTransport) Send(ctx context.Context, attempt Attempt) error {
|
||
|
|
if attempt.Request.Syslog == nil {
|
||
|
|
return errors.New("syslog target is not configured")
|
||
|
|
}
|
||
|
|
target, err := normalizeSyslogTarget(*attempt.Request.Syslog)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
dial := t.DialContext
|
||
|
|
if dial == nil {
|
||
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||
|
|
dial = dialer.DialContext
|
||
|
|
}
|
||
|
|
connection, err := dial(ctx, target.Protocol, net.JoinHostPort(target.Host, strconv.Itoa(target.Port)))
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("connect remote syslog: %w", err)
|
||
|
|
}
|
||
|
|
defer connection.Close()
|
||
|
|
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
|
||
|
|
priority := syslogFacilities[target.Facility]*8 + 4
|
||
|
|
message := sanitizeSyslogValue(attempt.Request.Message)
|
||
|
|
ruleID := sanitizeSyslogValue(attempt.Request.RuleID)
|
||
|
|
if ruleID == "" {
|
||
|
|
ruleID = "delivery-test"
|
||
|
|
}
|
||
|
|
payload := fmt.Sprintf("<%d>1 %s tera tera-alarmd - %s - %s\n", priority, time.Now().UTC().Format(time.RFC3339), ruleID, message)
|
||
|
|
if _, err := connection.Write([]byte(payload)); err != nil {
|
||
|
|
return fmt.Errorf("write remote syslog: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func sanitizeSyslogValue(value string) string {
|
||
|
|
value = strings.TrimSpace(value)
|
||
|
|
value = strings.ReplaceAll(value, "\r", " ")
|
||
|
|
value = strings.ReplaceAll(value, "\n", " ")
|
||
|
|
value = strings.ReplaceAll(value, "\x00", "")
|
||
|
|
return value
|
||
|
|
}
|