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.
260 lines
8.9 KiB
260 lines
8.9 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
|
|
}
|
|
|
|
type FactoryResetter interface{ FactoryReset(context.Context) error }
|
|
|
|
func (MockController) FactoryReset(context.Context) error { return nil }
|
|
|