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 }