// 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 }