feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

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