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.

257 lines
8.8 KiB

// Package alarmdelivery owns bounded alarm fan-out and durable retry state.
package alarmdelivery
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
const (
ChannelWeb = "web"
ChannelSyslog = "syslog"
ChannelEmail = "email"
ChannelSNMPTrap = "snmp_trap"
)
type SyslogTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Facility string `json:"facility"`
}
type Request struct {
RuleID string `json:"rule_id,omitempty"`
Message string `json:"message"`
Channels []string `json:"channels"`
RetryCount int `json:"retry_count"`
RetryIntervalSeconds int `json:"retry_interval_seconds"`
QueueLimit int `json:"queue_limit"`
Syslog *SyslogTarget `json:"syslog,omitempty"`
Email *EmailTarget `json:"email,omitempty"`
SNMPTraps []SNMPTrapTarget `json:"snmp_traps,omitempty"`
}
type EmailTarget struct {
Host string `json:"host"`
Port int `json:"port"`
STARTTLS bool `json:"starttls"`
Auth bool `json:"auth"`
Username string `json:"username,omitempty"`
PasswordKey string `json:"password_key,omitempty"`
From string `json:"from"`
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
Subject string `json:"subject"`
Content string `json:"content,omitempty"`
}
type SNMPTrapTarget struct {
TargetID string `json:"target_id"`
Host string `json:"host"`
Port int `json:"port"`
Version string `json:"version"`
Username string `json:"username,omitempty"`
AuthProtocol string `json:"auth_protocol,omitempty"`
PrivacyProtocol string `json:"privacy_protocol,omitempty"`
EngineID string `json:"engine_id,omitempty"`
CredentialKey string `json:"credential_key"`
PrivacyKeyRef string `json:"privacy_key_ref,omitempty"`
}
type Result struct {
DeliveryID string `json:"delivery_id"`
State string `json:"state"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
AcceptedAt time.Time `json:"accepted_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type Controller interface {
Deliver(context.Context, Request) (Result, error)
Status(context.Context, string) (Result, error)
}
type MockController struct{ Clock func() time.Time }
func (c MockController) Deliver(_ context.Context, request Request) (Result, error) {
request, err := NormalizeRequest(request)
if err != nil {
return Result{}, err
}
now := time.Now().UTC()
if c.Clock != nil {
now = c.Clock().UTC()
}
return Result{DeliveryID: "mock_alarm_delivery", State: "delivered", Attempts: 1, AcceptedAt: now, UpdatedAt: now, CompletedAt: &now}, nil
}
func (MockController) Status(context.Context, string) (Result, error) {
return Result{}, errors.New("mock alarm delivery status is unavailable")
}
type UnavailableController struct{ Reason string }
func (c UnavailableController) Deliver(context.Context, Request) (Result, error) {
return Result{}, &UnavailableError{Reason: c.reason()}
}
func (c UnavailableController) Status(context.Context, string) (Result, error) {
return Result{}, &UnavailableError{Reason: c.reason()}
}
func (c UnavailableController) reason() string {
if strings.TrimSpace(c.Reason) == "" {
return "tera-alarmd is not configured"
}
return c.Reason
}
type UnavailableError struct{ Reason string }
func (e *UnavailableError) Error() string {
if e == nil || strings.TrimSpace(e.Reason) == "" {
return "alarm delivery service is unavailable"
}
return e.Reason
}
func IsUnavailable(err error) bool {
var unavailable *UnavailableError
return errors.As(err, &unavailable)
}
func NormalizeRequest(request Request) (Request, error) {
request.RuleID = strings.ToLower(strings.TrimSpace(request.RuleID))
if len(request.RuleID) > 64 {
return Request{}, errors.New("alarm delivery rule id must be 64 characters or fewer")
}
request.Message = strings.TrimSpace(request.Message)
if request.Message == "" || len([]rune(request.Message)) > 1024 {
return Request{}, errors.New("alarm delivery message must be between 1 and 1024 characters")
}
if len(request.Channels) < 1 || len(request.Channels) > 4 {
return Request{}, errors.New("alarm delivery channels must contain between 1 and 4 entries")
}
seen := make(map[string]bool, len(request.Channels))
channels := make([]string, 0, len(request.Channels))
for _, channel := range request.Channels {
channel = strings.ToLower(strings.TrimSpace(channel))
switch channel {
case ChannelWeb, ChannelSyslog, ChannelEmail, ChannelSNMPTrap:
default:
return Request{}, errors.New("alarm delivery channel must be web, syslog, email, or snmp_trap")
}
if !seen[channel] {
seen[channel] = true
channels = append(channels, channel)
}
}
request.Channels = channels
if request.RetryCount < 0 || request.RetryCount > 10 {
return Request{}, errors.New("alarm delivery retry count must be between 0 and 10")
}
if request.RetryIntervalSeconds < 1 || request.RetryIntervalSeconds > 86400 {
return Request{}, errors.New("alarm delivery retry interval seconds must be between 1 and 86400")
}
if request.QueueLimit < 1 || request.QueueLimit > 1000 {
return Request{}, errors.New("alarm delivery queue limit must be between 1 and 1000")
}
if seen[ChannelSyslog] {
if request.Syslog == nil {
return Request{}, errors.New("alarm delivery syslog channel requires a target")
}
target, err := normalizeSyslogTarget(*request.Syslog)
if err != nil {
return Request{}, err
}
request.Syslog = &target
} else {
request.Syslog = nil
}
if seen[ChannelEmail] {
if request.Email == nil {
return Request{}, errors.New("alarm delivery email channel requires a target")
}
if err := validateEmailTarget(*request.Email); err != nil {
return Request{}, err
}
} else {
request.Email = nil
}
if seen[ChannelSNMPTrap] {
if len(request.SNMPTraps) == 0 || len(request.SNMPTraps) > 8 {
return Request{}, errors.New("alarm delivery SNMP channel requires 1 through 8 targets")
}
for _, target := range request.SNMPTraps {
if err := validateSNMPTrapTarget(target); err != nil {
return Request{}, err
}
}
} else {
request.SNMPTraps = nil
}
return request, nil
}
func validateEmailTarget(target EmailTarget) error {
if strings.TrimSpace(target.Host) == "" || target.Port < 1 || target.Port > 65535 {
return errors.New("alarm delivery SMTP target is invalid")
}
if strings.TrimSpace(target.From) == "" || len(target.To) == 0 {
return errors.New("alarm delivery email sender and recipient are required")
}
if target.Auth && (target.Username == "" || target.PasswordKey == "") {
return errors.New("alarm delivery SMTP authentication secret reference is required")
}
return nil
}
func validateSNMPTrapTarget(target SNMPTrapTarget) error {
if target.TargetID == "" || target.Host == "" || target.Port < 1 || target.Port > 65535 {
return errors.New("alarm delivery SNMP target is invalid")
}
if target.Version != "v1" && target.Version != "v2c" && target.Version != "v3" {
return errors.New("alarm delivery SNMP version is invalid")
}
if target.Version != "v3" && target.CredentialKey == "" {
return errors.New("alarm delivery SNMP community reference is required")
}
if target.Version == "v3" {
if target.Username == "" || target.EngineID == "" {
return errors.New("alarm delivery SNMPv3 username and engine id are required")
}
if target.AuthProtocol != "none" && target.CredentialKey == "" {
return errors.New("alarm delivery SNMPv3 authentication secret reference is required")
}
if target.PrivacyProtocol != "none" && target.PrivacyKeyRef == "" {
return errors.New("alarm delivery SNMPv3 privacy secret reference is required")
}
}
return nil
}
func normalizeSyslogTarget(target SyslogTarget) (SyslogTarget, error) {
target.Host = strings.TrimSpace(target.Host)
if target.Host == "" || len(target.Host) > 253 || strings.ContainsAny(target.Host, "\r\n\x00") {
return SyslogTarget{}, errors.New("alarm delivery syslog host is invalid")
}
if target.Port < 1 || target.Port > 65535 {
return SyslogTarget{}, errors.New("alarm delivery syslog port must be between 1 and 65535")
}
target.Protocol = strings.ToLower(strings.TrimSpace(target.Protocol))
if target.Protocol != "udp" && target.Protocol != "tcp" {
return SyslogTarget{}, errors.New("alarm delivery syslog protocol must be udp or tcp")
}
target.Facility = strings.ToLower(strings.TrimSpace(target.Facility))
if _, ok := syslogFacilities[target.Facility]; !ok {
return SyslogTarget{}, fmt.Errorf("alarm delivery syslog facility %q is unsupported", target.Facility)
}
return target, nil
}