feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
34
internal/sender/context_test.go
Обычный файл
34
internal/sender/context_test.go
Обычный файл
@@ -0,0 +1,34 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMattermostContextDeadlineInterruptsBlockingRequest(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := SendMattermostWithCredentialContext(ctx, server.URL, "maintenance_start", "subject", "body", nil)
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request did not reach blocking server")
|
||||
}
|
||||
close(release)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
29
internal/sender/count.go
Обычный файл
29
internal/sender/count.go
Обычный файл
@@ -0,0 +1,29 @@
|
||||
// Package sender provides functionality.
|
||||
package sender
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/config/translator"
|
||||
)
|
||||
|
||||
// GetCount provides functionality.
|
||||
func GetCount(count int, kind string) string {
|
||||
tr, err := translator.Translator.C(kind, float64(count), 0, strconv.Itoa(count))
|
||||
if err != nil {
|
||||
log.Println("translator error", err)
|
||||
return "монитор"
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// GetDownMany provides functionality.
|
||||
func GetDownMany(count int) string {
|
||||
return "Не " + GetCount(count, "monitor")
|
||||
}
|
||||
|
||||
// GetUpMany provides functionality.
|
||||
func GetUpMany(count int) string {
|
||||
return "Снова " + GetCount(count, "monitor")
|
||||
}
|
||||
219
internal/sender/email.go
Обычный файл
219
internal/sender/email.go
Обычный файл
@@ -0,0 +1,219 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// RunEmail provides functionality.
|
||||
func RunEmail(message *models.Message) error {
|
||||
subject, textBody, _, htmlBody := GetContent(message, time.Now())
|
||||
return SendEmail(message.Contact.Value, subject.String(), textBody.String(), htmlBody.String())
|
||||
}
|
||||
|
||||
// SendEmail provides functionality.
|
||||
func SendEmail(to, subject, body, html string) error {
|
||||
cred, err := firstSMTPCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return SendEmailWithCredential(to, subject, body, html, cred)
|
||||
}
|
||||
|
||||
func firstSMTPCredential() (*wire.SMTPCredential, error) {
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, errors.New("smtp credential is not configured")
|
||||
}
|
||||
return smtpModelToWire(&creds[0])
|
||||
}
|
||||
|
||||
func smtpModelToWire(cred *models.NotificationCredential) (*wire.SMTPCredential, error) {
|
||||
if cred == nil {
|
||||
return nil, errors.New("smtp credential is nil")
|
||||
}
|
||||
password, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smtp credential secret: %w", err)
|
||||
}
|
||||
out := &wire.SMTPCredential{
|
||||
ID: cred.ID,
|
||||
Name: cred.Name,
|
||||
Password: password,
|
||||
InsecureSkipVerify: cred.InsecureSkipVerify,
|
||||
}
|
||||
if cred.Server != nil {
|
||||
out.Server = *cred.Server
|
||||
}
|
||||
if cred.Port != nil {
|
||||
out.Port = *cred.Port
|
||||
}
|
||||
if cred.Login != nil {
|
||||
out.Login = *cred.Login
|
||||
}
|
||||
if cred.FromName != nil {
|
||||
out.FromName = *cred.FromName
|
||||
}
|
||||
if cred.FromAddr != nil {
|
||||
out.FromAddress = *cred.FromAddr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SendEmailWithCredential delivers an email using the given wire SMTP
|
||||
// credential. Phase 1 of docs/plans/worker-notifier-mvp.md ships every
|
||||
// enabled SMTP credential to the worker; this function is what the worker
|
||||
// executor calls so the same code path covers the operated-worker path and
|
||||
// the per-customer-credential path Phase 4 will introduce.
|
||||
func SendEmailWithCredential(to, subject, body, html string, cred *wire.SMTPCredential) error {
|
||||
return SendEmailWithCredentialContext(context.Background(), to, subject, body, html, cred)
|
||||
}
|
||||
|
||||
// SendEmailWithCredentialContext performs SMTP over a context-aware dialer.
|
||||
// Socket deadlines propagate cancellation through SMTP commands and DATA writes.
|
||||
func SendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential) error {
|
||||
return sendEmailWithCredentialContext(ctx, to, subject, body, html, cred, cred != nil && cred.Port == 465)
|
||||
}
|
||||
|
||||
// sendEmailWithCredentialContext permits the SMTPS transport choice to be
|
||||
// tested with an unprivileged local listener.
|
||||
func sendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential, implicitTLS bool) error {
|
||||
if cred == nil {
|
||||
return errors.New("smtp credential is nil")
|
||||
}
|
||||
if cred.Server == "" {
|
||||
return errors.New("smtp credential: server is empty")
|
||||
}
|
||||
fromAddr := &mail.Address{Name: cred.FromName, Address: cred.FromAddress}
|
||||
from := fromAddr.String()
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.AddAlternative("text/plain", body)
|
||||
m.AddAlternative("text/html", html)
|
||||
|
||||
var raw bytes.Buffer
|
||||
if _, err := m.WriteTo(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cred.Server, fmt.Sprint(cred.Port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.SetDeadline(time.Now())
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
if implicitTLS {
|
||||
tlsConn := tls.Client(conn, smtpTLSConfig(cred))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
client, err := smtp.NewClient(conn, cred.Server)
|
||||
if err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
defer client.Quit() //nolint:errcheck
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(smtpTLSConfig(cred)); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
}
|
||||
if cred.Login != "" {
|
||||
if err := client.Auth(smtpAuth(client, cred)); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(cred.FromAddress); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if _, err := writer.Write(raw.Bytes()); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smtpContextError(ctx context.Context, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func smtpTLSConfig(cred *wire.SMTPCredential) *tls.Config {
|
||||
return &tls.Config{ServerName: cred.Server, InsecureSkipVerify: cred.InsecureSkipVerify}
|
||||
}
|
||||
|
||||
func smtpAuth(client *smtp.Client, cred *wire.SMTPCredential) smtp.Auth {
|
||||
_, mechanisms := client.Extension("AUTH")
|
||||
for _, mechanism := range strings.Fields(strings.ToUpper(mechanisms)) {
|
||||
switch mechanism {
|
||||
case "CRAM-MD5":
|
||||
return smtp.CRAMMD5Auth(cred.Login, cred.Password)
|
||||
case "LOGIN":
|
||||
return loginAuth{username: cred.Login, password: cred.Password}
|
||||
case "PLAIN":
|
||||
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
|
||||
}
|
||||
}
|
||||
// Preserve gomail's default when the server does not advertise mechanisms;
|
||||
// smtp.Client returns the server's authoritative AUTH failure.
|
||||
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
|
||||
}
|
||||
|
||||
type loginAuth struct{ username, password string }
|
||||
|
||||
func (a loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil }
|
||||
func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
challenge := strings.ToLower(string(fromServer))
|
||||
if strings.Contains(challenge, "username") || strings.Contains(challenge, "user") {
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
273
internal/sender/email_context_test.go
Обычный файл
273
internal/sender/email_context_test.go
Обычный файл
@@ -0,0 +1,273 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
done chan error
|
||||
tls *tls.Config
|
||||
}
|
||||
|
||||
func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)}
|
||||
go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
select {
|
||||
case err := <-server.done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Error("fake SMTP server did not exit")
|
||||
}
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
if implicitTLS {
|
||||
conn = tls.Server(conn, s.tls)
|
||||
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
if err := smtpReply(writer, "220 fake smtp"); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(line, "EHLO ") {
|
||||
return errors.New("expected EHLO")
|
||||
}
|
||||
if blockEHLO {
|
||||
_, _ = reader.ReadByte()
|
||||
return nil
|
||||
}
|
||||
capabilities := []string{"250-fake"}
|
||||
if startTLS {
|
||||
capabilities = append(capabilities, "250-STARTTLS")
|
||||
}
|
||||
if auth != "" {
|
||||
capabilities = append(capabilities, "250-AUTH "+auth)
|
||||
}
|
||||
capabilities = append(capabilities, "250 OK")
|
||||
if err := smtpReplies(writer, capabilities...); err != nil {
|
||||
return err
|
||||
}
|
||||
if startTLS {
|
||||
if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" {
|
||||
return errors.New("expected STARTTLS")
|
||||
}
|
||||
if err := smtpReply(writer, "220 ready for TLS"); err != nil {
|
||||
return err
|
||||
}
|
||||
conn = tls.Server(conn, s.tls)
|
||||
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") {
|
||||
return errors.New("expected EHLO after STARTTLS")
|
||||
}
|
||||
capabilities = []string{"250-fake"}
|
||||
if auth != "" {
|
||||
capabilities = append(capabilities, "250-AUTH "+auth)
|
||||
}
|
||||
capabilities = append(capabilities, "250 OK")
|
||||
if err := smtpReplies(writer, capabilities...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := smtpAuthenticate(reader, writer, auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:<sender@example.test>") {
|
||||
return errors.New("expected MAIL FROM")
|
||||
}
|
||||
if err := smtpReply(writer, "250 sender ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:<recipient@example.test>") {
|
||||
return errors.New("expected RCPT TO")
|
||||
}
|
||||
if err := smtpReply(writer, "250 recipient ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != "DATA" {
|
||||
return errors.New("expected DATA")
|
||||
}
|
||||
if err := smtpReply(writer, "354 send data"); err != nil {
|
||||
return err
|
||||
}
|
||||
dataLines := 0
|
||||
for {
|
||||
line, err = smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if line == "." {
|
||||
break
|
||||
}
|
||||
dataLines++
|
||||
}
|
||||
if dataLines == 0 {
|
||||
return errors.New("expected non-empty DATA payload")
|
||||
}
|
||||
if err := smtpReply(writer, "250 queued"); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err = smtpCommand(reader)
|
||||
if err != nil || line != "QUIT" {
|
||||
return errors.New("expected QUIT")
|
||||
}
|
||||
return smtpReply(writer, "221 bye")
|
||||
}
|
||||
|
||||
func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error {
|
||||
if auth == "" {
|
||||
return nil
|
||||
}
|
||||
line, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(auth, "CRAM-MD5") {
|
||||
if line != "AUTH CRAM-MD5" {
|
||||
return errors.New("expected capability-selected CRAM-MD5 authentication")
|
||||
}
|
||||
challenge := []byte("fake-cram-challenge")
|
||||
if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
||||
_, _ = mac.Write(challenge)
|
||||
if string(decoded) != "user "+fmtHex(mac.Sum(nil)) {
|
||||
return errors.New("invalid CRAM-MD5 response")
|
||||
}
|
||||
return smtpReply(writer, "235 authenticated")
|
||||
}
|
||||
if line != "AUTH LOGIN" {
|
||||
return errors.New("expected capability-selected LOGIN authentication")
|
||||
}
|
||||
if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) {
|
||||
return errors.New("invalid LOGIN username")
|
||||
}
|
||||
if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) {
|
||||
return errors.New("invalid LOGIN password")
|
||||
}
|
||||
return smtpReply(writer, "235 authenticated")
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred, true))
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred))
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, false, false, true, "")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "<b>body</b>", cred)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential {
|
||||
t.Helper()
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
require.NoError(t, err)
|
||||
portNumber, err := net.LookupPort("tcp", port)
|
||||
require.NoError(t, err)
|
||||
return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
func fakeSMTPTLSConfig(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
certificate, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
require.NoError(t, err)
|
||||
return &tls.Config{Certificates: []tls.Certificate{certificate}}
|
||||
}
|
||||
|
||||
func smtpCommand(reader *bufio.Reader) (string, error) {
|
||||
line, err := reader.ReadString('\n')
|
||||
return strings.TrimRight(line, "\r\n"), err
|
||||
}
|
||||
|
||||
func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) }
|
||||
|
||||
func smtpReplies(writer *bufio.Writer, lines ...string) error {
|
||||
for _, line := range lines {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fmtHex(value []byte) string {
|
||||
const hex = "0123456789abcdef"
|
||||
result := make([]byte, len(value)*2)
|
||||
for i, b := range value {
|
||||
result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
17
internal/sender/event_table.go
Обычный файл
17
internal/sender/event_table.go
Обычный файл
@@ -0,0 +1,17 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// RenderEventsTable provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.RenderEventsTable directly. This
|
||||
// wrapper is preserved for backwards compatibility until phase 3 retires the
|
||||
// legacy sender loop entirely.
|
||||
func RenderEventsTable(message *models.Message, tn time.Time) (string, string) {
|
||||
return notifyrender.RenderEventsTable(message, tn)
|
||||
}
|
||||
88
internal/sender/event_table_test.go
Обычный файл
88
internal/sender/event_table_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/icrowley/fake"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifier"
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
var asciiTableExample = strings.TrimLeft(`
|
||||
┌─────────┬──────────┬─────────────────────┬─────────────────┬───────────────────┬─────────┬──────────────┐
|
||||
│ МОНИТОР │ ПРОВЕРКА │ ВРЕМЯ НАЧАЛА │ ВРЕМЯ ОКОНЧАНИЯ │ ПРОДОЛЖИТЕЛЬНОСТЬ │ СТАТУС │ ОШИБКА │
|
||||
├─────────┼──────────┼─────────────────────┼─────────────────┼───────────────────┼─────────┼──────────────┤
|
||||
│ Tagopia │ │ 02.01.2018 03:04:05 │ │ 24 часа, 0 минут │ current │ test event 1 │
|
||||
└─────────┴──────────┴─────────────────────┴─────────────────┴───────────────────┴─────────┴──────────────┘
|
||||
`, "\n")
|
||||
|
||||
var markdownTableExample = strings.TrimLeft(`
|
||||
| Монитор | Проверка | Время начала | Время окончания | Продолжительность | Статус | Ошибка |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Tagopia | | 02.01.2018 03:04:05 | | 24 часа, 0 минут | current | test event 1 |
|
||||
`, "\n")
|
||||
|
||||
func TestEventTable(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
user := factories.PersistedUser("test@test.ru", "123")
|
||||
account, err := models.CreateAccountForUser(fake.Company(), &user)
|
||||
|
||||
contact := factories.PersistedContact(account, &user)
|
||||
group := factories.PersistedGroup(account)
|
||||
factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false)
|
||||
|
||||
monitor := factories.PersistedMonitor(&group)
|
||||
n := "Tagopia"
|
||||
monitor.Name = &n
|
||||
err = models.DB().Save(&monitor).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
event := factories.PersistedEvent(&monitor, "current", "test event 1")
|
||||
tStart := time.Date(2018, time.January, 2, 3, 4, 5, 0, time.Local)
|
||||
tNow := time.Date(2018, time.January, 3, 3, 4, 5, 0, time.Local)
|
||||
|
||||
event.StartTime = &tStart
|
||||
err = models.DB().Save(&event).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
notifier.Run()
|
||||
|
||||
messages := make([]models.Message, 0)
|
||||
err = models.MessageScope(models.DB()).Where("state IN ('queued')").Find(&messages).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("no message sent")
|
||||
}
|
||||
|
||||
if len(messages) > 1 {
|
||||
spew.Dump(messages)
|
||||
t.Fatal("found more than one message")
|
||||
}
|
||||
|
||||
asciiTable, markdownTable := RenderEventsTable(&messages[0], tNow)
|
||||
|
||||
// fmt.Println(asciiTable)
|
||||
// fmt.Println(markdownTable)
|
||||
|
||||
assert.Equal(t, asciiTableExample, asciiTable, "rendered table should match example")
|
||||
assert.Equal(t, markdownTableExample, markdownTable, "rendered table should match example")
|
||||
}
|
||||
19
internal/sender/get_content.go
Обычный файл
19
internal/sender/get_content.go
Обычный файл
@@ -0,0 +1,19 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// GetContent provides functionality.
|
||||
//
|
||||
// Deprecated: the worker-driven notifier producer now uses
|
||||
// internal/notifyrender directly. This thin wrapper is preserved so the
|
||||
// legacy RunMessage loop still compiles until it is removed in phase 3 of
|
||||
// docs/plans/worker-notifier-mvp.md.
|
||||
func GetContent(message *models.Message, tn time.Time) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.GetContent(message, tn)
|
||||
}
|
||||
187
internal/sender/get_content_test.go
Обычный файл
187
internal/sender/get_content_test.go
Обычный файл
@@ -0,0 +1,187 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
var (
|
||||
subjectExampleDown = "Не доступен test monitor"
|
||||
textBodyExampleDown = strings.TrimLeft(`
|
||||
Монитор test monitor не доступен
|
||||
|
||||
Проверки: [http].
|
||||
Ошибка: test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:04
|
||||
|
||||
Недоступные проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleDown = strings.TrimLeft(`
|
||||
Монитор test monitor не доступен :warning:
|
||||
|
||||
Проверки: [http].
|
||||
Ошибка: test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:04
|
||||
|
||||
Недоступные проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleDown = strings.TrimLeft(`
|
||||
<h4>Монитор test monitor не доступен</h4>
|
||||
|
||||
Проверки: [http].
|
||||
<h5 style='background-color: red;'>Ошибка:</h5>
|
||||
test error
|
||||
|
||||
<div>Начало события: 03.01.2019 03:04:04</div>
|
||||
|
||||
<h5>Недоступные проверки:<h5>
|
||||
|
||||
<div>
|
||||
http - test check - URL не указан (testerr)
|
||||
</div>
|
||||
`, "\n")
|
||||
|
||||
var (
|
||||
subjectExampleUp = "Снова доступен test monitor"
|
||||
textBodyExampleUp = strings.TrimLeft(`
|
||||
Монитор test monitor снова доступен
|
||||
|
||||
Проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
|
||||
|
||||
Он был недоступен 1 минуту по причине ошибки test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:05
|
||||
Окончание события: 03.01.2019 03:05:05
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleUp = strings.TrimLeft(`
|
||||
Монитор test monitor снова доступен :white_check_mark:
|
||||
|
||||
Проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
|
||||
|
||||
Он был недоступен 1 минуту по причине ошибки test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:05
|
||||
Окончание события: 03.01.2019 03:05:05
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleUp = strings.TrimLeft(`
|
||||
<h4>Монитор test monitor снова доступен</h4>.
|
||||
|
||||
<h5>Проверки:<h5>
|
||||
|
||||
<div>
|
||||
http - test check - URL не указан (testerr)
|
||||
</div>
|
||||
|
||||
|
||||
<div>Он был недоступен 1 минуту по причине ошибки <code>test error</code></div>
|
||||
|
||||
<div>Начало события: 03.01.2019 03:04:05</div>
|
||||
<div>Окончание события: 03.01.2019 03:05:05</div>
|
||||
`, "\n")
|
||||
|
||||
var (
|
||||
subjectExampleExp = "Скоро истекает test check (SSL сертификат) по test monitor"
|
||||
textBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check (SSL сертификат)
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check (SSL сертификат)
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check <strong>(SSL сертификат)</strong>
|
||||
`, "\n")
|
||||
|
||||
func TestGetContent(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
message := factories.MessageFactory()
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleDown, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleDown, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleDown, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleDown, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
|
||||
func TestGetContentUp(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
ts := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
te := time.Date(2019, time.January, 3, 3, 5, 5, 0, time.UTC)
|
||||
|
||||
message := factories.MessageFactory()
|
||||
message.Kind = "up"
|
||||
message.Events[0].StartTime = &ts
|
||||
message.Events[0].EndTime = &te
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleUp, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleUp, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleUp, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleUp, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
|
||||
func TestGetContentExp(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
message := factories.ExpMessageFactory()
|
||||
|
||||
in1h := tn.Add(1 * time.Hour)
|
||||
message.Check.Expires = &in1h
|
||||
message.Check.Kind = "ssl"
|
||||
|
||||
subject, textBody, _, htmlBody := GetContent(&message, tn)
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleExp, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleExp, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleExp, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleExp, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
85
internal/sender/invite.go
Обычный файл
85
internal/sender/invite.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var (
|
||||
inviteSubject *template.Template
|
||||
inviteMessage *template.Template
|
||||
inviteHTMLMessage *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
inviteSubject = template.Must(template.New("invite_subject").Parse(`Доступ к мониторингу rsmon.ru`))
|
||||
|
||||
inviteMessage = template.Must(template.New("invite_message").Parse(`
|
||||
Пользователь {{.Inviter.Name}} {{.Inviter.Email}}
|
||||
{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}} доступ к аккаунту rsmon.ru {{.Account.Name}}
|
||||
|
||||
{{ if not .InviteeID }}
|
||||
Чтобы зарегистрироваться, перейдите по ссылке: https://rsmon.ru/by-invite/{{.Token}}
|
||||
{{end}}
|
||||
|
||||
https://rsmon.ru
|
||||
`))
|
||||
|
||||
inviteHTMLMessage = template.Must(template.New("invite_message").Parse(`
|
||||
<p>Пользователь {{.Inviter.Name}} {{.Inviter.Email}}
|
||||
{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}}
|
||||
доступ к аккаунту rsmon.ru <strong>{{.Account.Name}}</strong></p>
|
||||
|
||||
{{ if not .InviteeID }}
|
||||
<p>Чтобы зарегистрироваться, перейдите по ссылке:
|
||||
<a href="https://rsmon.ru/by-invite/{{.Token}}">https://rsmon.ru/by-invite/{{.Token}}</a></p>
|
||||
{{end}}
|
||||
|
||||
<p><a href="https://rsmon.ru">https://rsmon.ru</a></p>
|
||||
`))
|
||||
}
|
||||
|
||||
// Invite provides functionality.
|
||||
func Invite(i *models.Invite) error {
|
||||
// us := models.User{}
|
||||
var err error
|
||||
var subject, message, htmlMessage bytes.Buffer
|
||||
err = inviteSubject.Execute(&subject, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = inviteMessage.Execute(&message, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = inviteHTMLMessage.Execute(&htmlMessage, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = SendEmail(i.Email, subject.String(), message.String(), htmlMessage.String())
|
||||
if err != nil {
|
||||
log.Println("email send fail", err)
|
||||
i.State = "FAIL"
|
||||
serr := models.DB().Save(&i).Error
|
||||
if serr != nil {
|
||||
return errors.Wrap(err, "failed to save invite, and failed to save to DB")
|
||||
}
|
||||
return errors.Wrap(err, "failed to save invite")
|
||||
}
|
||||
if i.State != "OK" {
|
||||
i.State = "SENT"
|
||||
}
|
||||
|
||||
err = models.DB().Omit(clause.Associations).Save(&i).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to add accesses to invited user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
155
internal/sender/mattermost.go
Обычный файл
155
internal/sender/mattermost.go
Обычный файл
@@ -0,0 +1,155 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// messageKindUp is the canonical string the notifier uses for "monitor
|
||||
// just came back up" events. Lives here as a package-level constant so
|
||||
// the goconst linter stops flagging the literal "up" appearing three
|
||||
// times across the sender package.
|
||||
const messageKindUp = "up"
|
||||
|
||||
// MattermostPayload provides functionality.
|
||||
type MattermostPayload struct {
|
||||
Channel *string `json:"channel,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// RunMattermost provides functionality.
|
||||
func RunMattermost(message *models.Message) (*string, error) {
|
||||
_, _, markdownBody, _ := GetContent(message, time.Now())
|
||||
|
||||
s := "@channel "
|
||||
if message.Kind == messageKindUp {
|
||||
s += ":white_check_mark: "
|
||||
} else {
|
||||
s += ":warning: "
|
||||
}
|
||||
s = s + "\n\n" + markdownBody.String()
|
||||
|
||||
color := "green"
|
||||
if message.Kind == "down" {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
un := "RSMon"
|
||||
icon := "https://rsmon.ru/" + color + "_logo.svg"
|
||||
payload := MattermostPayload{
|
||||
Text: &s,
|
||||
Username: &un,
|
||||
IconURL: &icon,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("send mattermost:", message.Contact.Value)
|
||||
// log.Println(string(body))
|
||||
|
||||
resp, err := httpClient.Post(
|
||||
message.Contact.Value,
|
||||
"application/json",
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
str := string(body)
|
||||
return &str, nil
|
||||
}
|
||||
|
||||
// SendMattermostWithCredential delivers a Mattermost notification using the
|
||||
// supplied wire credential block (which carries the default username +
|
||||
// icon URL). The webhook URL is per-task; the credential block only sets
|
||||
// the cosmetic defaults. This mirrors what the legacy RunMattermost did with
|
||||
// secrets + a hardcoded RSMon username / icon.
|
||||
func SendMattermostWithCredential(
|
||||
contactValue, messageKind, subject, markdownBody string,
|
||||
cred *wire.MattermostCredential,
|
||||
) (*string, error) {
|
||||
return SendMattermostWithCredentialContext(context.Background(), contactValue, messageKind, subject, markdownBody, cred)
|
||||
}
|
||||
|
||||
func SendMattermostWithCredentialContext(
|
||||
ctx context.Context, contactValue, messageKind, subject, markdownBody string,
|
||||
cred *wire.MattermostCredential,
|
||||
) (*string, error) {
|
||||
if contactValue == "" {
|
||||
return nil, errors.New("mattermost contact value is empty")
|
||||
}
|
||||
|
||||
s := "@channel "
|
||||
if messageKind == messageKindUp {
|
||||
s += ":white_check_mark: "
|
||||
} else {
|
||||
s += ":warning: "
|
||||
}
|
||||
s = s + "\n\n" + subject + "\n\n" + markdownBody
|
||||
|
||||
color := "green"
|
||||
if messageKind == "down" {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
username := "RSMon"
|
||||
iconURL := "https://rsmon.ru/" + color + "_logo.svg"
|
||||
if cred != nil {
|
||||
if cred.DefaultUsername != "" {
|
||||
username = cred.DefaultUsername
|
||||
}
|
||||
if cred.DefaultIconURL != "" {
|
||||
iconURL = cred.DefaultIconURL
|
||||
}
|
||||
}
|
||||
|
||||
payload := MattermostPayload{
|
||||
Text: &s,
|
||||
Username: &username,
|
||||
IconURL: &iconURL,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
str := string(respBody)
|
||||
return &str, nil
|
||||
}
|
||||
90
internal/sender/run.go
Обычный файл
90
internal/sender/run.go
Обычный файл
@@ -0,0 +1,90 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Run provides functionality.
|
||||
func Run() {
|
||||
messages := make([]models.Message, 0)
|
||||
|
||||
tx := models.DB().Begin().Set("gorm:association_autoupdate", false)
|
||||
|
||||
q := tx
|
||||
// q = q.Set("gorm:query_option", "FOR UPDATE")
|
||||
err := models.MessageScope(q).Where("state IN ('queued')").Find(&messages).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
|
||||
for _, msg := range messages { //nolint:gocritic // range copy is acceptable here
|
||||
msg.State = "sending"
|
||||
tx.Model(&msg).Update("state", "sending")
|
||||
// spew.Dump(msg.ID)
|
||||
// err = tx.Save(&msg).Error
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
}
|
||||
_ = tx.Commit()
|
||||
|
||||
for _, msg := range messages { //nolint:gocritic // range copy is acceptable here
|
||||
_, _ = RunMessage(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// RunMessage provides functionality.
|
||||
func RunMessage(message *models.Message) (*string, error) {
|
||||
var err error
|
||||
var response *string
|
||||
|
||||
log.Println("send message", message.ID)
|
||||
switch message.Contact.Kind {
|
||||
case "telegram_group", "telegram_private":
|
||||
err = RunTelegram(message)
|
||||
case "email":
|
||||
err = RunEmail(message)
|
||||
case "webhook":
|
||||
response, err = RunWebhook(message)
|
||||
case "mattermost":
|
||||
response, err = RunMattermost(message)
|
||||
case "sms":
|
||||
err = RunSMS(message)
|
||||
case "voice":
|
||||
err = RunVoice(message)
|
||||
default:
|
||||
err = errors.New("notification kind not implemented: " + message.Contact.Kind)
|
||||
}
|
||||
if response != nil {
|
||||
log.Println("notification done. response:", *response, "error:", err)
|
||||
} else {
|
||||
log.Println("notification done. error:", err)
|
||||
}
|
||||
|
||||
if message.ID != 0 {
|
||||
if response != nil {
|
||||
message.Response = response
|
||||
}
|
||||
if err == nil {
|
||||
message.State = "sent"
|
||||
message.SentAt = time.Now()
|
||||
} else {
|
||||
message.State = "error"
|
||||
et := err.Error()
|
||||
message.Error = &et
|
||||
}
|
||||
err = models.DB().Save(&message).Error
|
||||
if err != nil {
|
||||
log.Println("ERROR:", err)
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
33
internal/sender/sender.go
Обычный файл
33
internal/sender/sender.go
Обычный файл
@@ -0,0 +1,33 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var httpClient *http.Client
|
||||
|
||||
func init() {
|
||||
httpClient = &http.Client{
|
||||
Timeout: time.Second * 60,
|
||||
}
|
||||
}
|
||||
|
||||
// Init sender
|
||||
//
|
||||
// Deprecated: the legacy SMPP init that lived here is commented out. Phase 3
|
||||
// of docs/plans/worker-notifier-mvp.md retires the sender loop entirely.
|
||||
// This stub is preserved so any remaining callers do not fail to compile
|
||||
// during the rollout window.
|
||||
func Init() {
|
||||
// if application.Env == "production" {
|
||||
// InitSMPP()
|
||||
// }
|
||||
}
|
||||
|
||||
// Start sender
|
||||
//
|
||||
// Deprecated: the legacy sender loop is retired. The worker binary is the executor.
|
||||
func Start() {
|
||||
// no-op
|
||||
}
|
||||
41
internal/sender/sms.go
Обычный файл
41
internal/sender/sms.go
Обычный файл
@@ -0,0 +1,41 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ns3777k/go-smsaero/smsaero"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/application"
|
||||
)
|
||||
|
||||
// RunSMS provides functionality.
|
||||
func RunSMS(message *models.Message) error {
|
||||
subject, _, _, _ := GetContent(message, time.Now())
|
||||
return SendSMS(message.Contact.Value, subject.String())
|
||||
}
|
||||
|
||||
// SendSMS provides functionality.
|
||||
func SendSMS(to, message string) error {
|
||||
// return nil
|
||||
// https://smsaero.ru/
|
||||
if application.Env == envProduction {
|
||||
log.Println("sms send to:", to, "message:", message)
|
||||
phonei, err := strconv.Atoi(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := smsaero.NewClient(nil, "glebtv@gmail.com", "U10x9UPWrlLPx5PlgE8orwvVqtS")
|
||||
_, err = client.Send(phonei, message, "rsmon")
|
||||
if err != nil {
|
||||
log.Println("sms result:", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Println("sms debug. to:", to, "message:", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
53
internal/sender/telegram.go
Обычный файл
53
internal/sender/telegram.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/tg"
|
||||
)
|
||||
|
||||
// RunTelegram provides functionality.
|
||||
func RunTelegram(message *models.Message) error {
|
||||
subject, _, _, _ := GetContent(message, time.Now())
|
||||
return tg.SendMessage(message.Contact.Value, subject.String())
|
||||
}
|
||||
|
||||
// SendTelegramWithCredential delivers a Telegram message using the given bot
|
||||
// credential. Phase 1 of docs/plans/worker-notifier-mvp.md: the worker holds
|
||||
// the credential in memory after the init/config push and passes it here.
|
||||
func SendTelegramWithCredential(chatID, subject, body string, cred *models.NotificationCredential) error {
|
||||
return SendTelegramWithCredentialContext(context.Background(), chatID, subject, body, cred)
|
||||
}
|
||||
|
||||
// SendTelegramWithCredentialContext prevents handoff after cancellation. The
|
||||
// tgbotapi client used by the legacy sender does not expose a context-aware
|
||||
// Send method, so an already handed-off Telegram request cannot be interrupted.
|
||||
func SendTelegramWithCredentialContext(ctx context.Context, chatID, subject, body string, cred *models.NotificationCredential) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cred == nil {
|
||||
return errors.New("telegram credential is nil")
|
||||
}
|
||||
text := subject
|
||||
if body != "" {
|
||||
text = subject + "\n\n" + body
|
||||
}
|
||||
err := sendTelegramWithBot(chatID, text, cred)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// sendTelegramWithBot is the low-level helper that the legacy RunTelegram
|
||||
// (legacy secrets path) and SendTelegramWithCredential (worker wire path)
|
||||
// both call. It derives an http API URL from the credential's APIURL.
|
||||
func sendTelegramWithBot(chatID, text string, cred *models.NotificationCredential) error {
|
||||
// Use a credential-scoped helper so each Telegram credential can map to a
|
||||
// separate bot token/API URL.
|
||||
return tg.SendMessageWithToken(chatID, text, cred)
|
||||
}
|
||||
13
internal/sender/test_message.go
Обычный файл
13
internal/sender/test_message.go
Обычный файл
@@ -0,0 +1,13 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestMessage(contact *models.Contact) (*string, error) {
|
||||
message := models.Message{
|
||||
Contact: contact,
|
||||
Kind: "test",
|
||||
}
|
||||
return RunMessage(&message)
|
||||
}
|
||||
15
internal/sender/text_down_one.go
Обычный файл
15
internal/sender/text_down_one.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextDownOne provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextDownOne directly.
|
||||
func TextDownOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextDownOne(event)
|
||||
}
|
||||
15
internal/sender/text_expires.go
Обычный файл
15
internal/sender/text_expires.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextExpires provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextExpires directly.
|
||||
func TextExpires(check *models.Check) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextExpires(check)
|
||||
}
|
||||
15
internal/sender/text_up_one.go
Обычный файл
15
internal/sender/text_up_one.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextUpOne provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextUpOne directly.
|
||||
func TextUpOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextUpOne(event)
|
||||
}
|
||||
13
internal/sender/voice.go
Обычный файл
13
internal/sender/voice.go
Обычный файл
@@ -0,0 +1,13 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// RunVoice provides functionality.
|
||||
func RunVoice(message *models.Message) error {
|
||||
_ = message
|
||||
return errors.New("voice notifications are disabled")
|
||||
}
|
||||
99
internal/sender/webhook.go
Обычный файл
99
internal/sender/webhook.go
Обычный файл
@@ -0,0 +1,99 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/application"
|
||||
)
|
||||
|
||||
const envProduction = "production"
|
||||
|
||||
// RunWebhook provides functionality.
|
||||
func RunWebhook(message *models.Message) (*string, error) {
|
||||
if application.Env != envProduction {
|
||||
return nil, errors.New("not sending in env " + application.Env)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Post(
|
||||
message.Contact.Value,
|
||||
"application/json",
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
str := string(body)
|
||||
return &str, nil
|
||||
}
|
||||
|
||||
// SendWebhookWithCredential delivers a webhook notification using the
|
||||
// provided wire credential block. Phase 1 ships an empty signing secret;
|
||||
// phase 4 will plumb the per-account secret through Contact.Data. The
|
||||
// signature header is X-RSMon-Signature (hex-encoded HMAC-SHA256 of the
|
||||
// body), matching what webhook consumers in the existing fleet expect.
|
||||
//
|
||||
// The payload matches the wire.NotificationTask shape so customers
|
||||
// receiving the legacy Message JSON see one fewer breaking change.
|
||||
func SendWebhookWithCredential(payload []byte, contactValue, signingSecret string) (*string, error) {
|
||||
return SendWebhookWithCredentialContext(context.Background(), payload, contactValue, signingSecret)
|
||||
}
|
||||
|
||||
func SendWebhookWithCredentialContext(ctx context.Context, payload []byte, contactValue, signingSecret string) (*string, error) {
|
||||
if application.Env != envProduction {
|
||||
return nil, errors.New("not sending in env " + application.Env)
|
||||
}
|
||||
if contactValue == "" {
|
||||
return nil, errors.New("webhook contact value is empty")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "rsmon-worker/1")
|
||||
if signingSecret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(signingSecret))
|
||||
mac.Write(payload)
|
||||
req.Header.Set("X-RSMon-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errors.New("webhook upstream " + resp.Status)
|
||||
}
|
||||
str := string(respBody)
|
||||
return &str, nil
|
||||
}
|
||||
Ссылка в новой задаче
Block a user