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 удалений

57
internal/notify/email.go Обычный файл
Просмотреть файл

@@ -0,0 +1,57 @@
// Package notify delivers notifications using credentials persisted in the
// notification_credentials table. It is the credential-backed counterpart to
// the legacy secrets.yml driven senders in internal/sender and internal/tg.
package notify
import (
"crypto/tls"
"fmt"
"gopkg.in/gomail.v2"
"rsgit.ru/rsmon/rsmon/app/models"
)
// Email sends an email using the given SMTP credential. bodyText is sent as
// text/plain and, when non-empty, bodyHTML is added as a text/html alternative
// so the recipient's MUA picks the best representation.
func Email(cred *models.NotificationCredential, to, subject, bodyText, bodyHTML string) error {
if cred == nil {
return fmt.Errorf("credential is nil")
}
if cred.Kind != models.CredentialKindSMTP {
return fmt.Errorf("credential %d is not smtp (kind=%s)", cred.ID, cred.Kind)
}
if cred.Server == nil || cred.Port == nil || cred.Login == nil || cred.FromAddr == nil {
return fmt.Errorf("credential %d missing required smtp fields", cred.ID)
}
password, err := cred.GetSecret()
if err != nil {
return fmt.Errorf("decrypt smtp password: %w", err)
}
fromName := "RSMon"
if cred.FromName != nil && *cred.FromName != "" {
fromName = *cred.FromName
}
m := gomail.NewMessage()
m.SetHeader("From", m.FormatAddress(*cred.FromAddr, fromName))
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.AddAlternative("text/plain", bodyText)
if bodyHTML != "" {
m.AddAlternative("text/html", bodyHTML)
}
d := gomail.NewDialer(*cred.Server, *cred.Port, *cred.Login, password)
if cred.InsecureSkipVerify {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
if err := d.DialAndSend(m); err != nil {
return fmt.Errorf("send smtp via credential %d: %w", cred.ID, err)
}
return nil
}

202
internal/notify/email_network_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,202 @@
package notify
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"rsgit.ru/rsmon/rsmon/app/models"
)
const (
mailhogSMTPHost = "localhost"
mailhogSMTPPort = 31025
mailhogAPIURL = "http://localhost:38025"
networkDialTimeout = 2 * time.Second
mailhogPollInterval = 100 * time.Millisecond
mailhogPollDeadline = 3 * time.Second
)
// mailhogMessage mirrors the subset of MailHog's /api/v2/messages payload we
// care about in tests: the parsed headers and the rendered body.
type mailhogMessage struct {
Content struct {
Headers map[string][]string `json:"Headers"`
Body string `json:"Body"`
} `json:"Content"`
}
// skipIfMailHogDown skips the test if MailHog's SMTP port is unreachable.
// Tests stay green on dev machines without docker; on CI with docker they
// run for real against the running MailHog container.
func skipIfMailHogDown(t *testing.T) {
t.Helper()
addr := net.JoinHostPort(mailhogSMTPHost, fmt.Sprintf("%d", mailhogSMTPPort))
conn, err := net.DialTimeout("tcp", addr, networkDialTimeout)
if err != nil {
t.Skipf("mailhog not reachable at %s: %v", addr, err)
}
_ = conn.Close()
}
// mailhogMessages fetches the most recent messages from MailHog's HTTP API.
func mailhogMessages(t *testing.T) []mailhogMessage {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mailhogAPIURL+"/api/v2/messages?limit=50", nil)
if err != nil {
t.Fatalf("build mailhog request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("query mailhog: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var out struct {
Total int `json:"total"`
Items []mailhogMessage `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode mailhog response: %v", err)
}
return out.Items
}
// mailhogDeleteAll clears the MailHog in-memory mailbox so each test starts
// from a known state.
func mailhogDeleteAll(t *testing.T) {
t.Helper()
req, err := http.NewRequest(http.MethodDelete, mailhogAPIURL+"/api/v1/messages", nil)
if err != nil {
t.Fatalf("build mailhog delete: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete mailhog messages: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("mailhog delete returned %d", resp.StatusCode)
}
}
func TestEmail_Network_SendAndVerify(t *testing.T) {
skipIfMailHogDown(t)
mailhogDeleteAll(t)
port := mailhogSMTPPort
enabled := true
cred := &models.NotificationCredential{
Kind: models.CredentialKindSMTP,
Name: "mailhog-test",
Server: strPtr(mailhogSMTPHost),
Port: &port,
Login: strPtr(""),
FromName: strPtr("RSMon Tests"),
FromAddr: strPtr("tests@rsmon.test"),
Enabled: &enabled,
// MailHog accepts any password; "plain:" prefix keeps GetSecret
// working without the credential encryption key configured.
SecretEnc: "plain:",
}
subject := "rsmon-test-subject-" + time.Now().Format("150405.000")
body := "rsmon-test-body hello mailhog"
to := "to@rsmon.test"
if err := Email(cred, to, subject, body, ""); err != nil {
t.Fatalf("Email returned error: %v", err)
}
deadline := time.Now().Add(mailhogPollDeadline)
var found *mailhogMessage
for time.Now().Before(deadline) {
msgs := mailhogMessages(t)
for i := range msgs {
hsubj := msgs[i].Content.Headers["Subject"]
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
found = &msgs[i]
break
}
}
if found != nil {
break
}
time.Sleep(mailhogPollInterval)
}
if found == nil {
t.Fatalf("message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
}
if !strings.Contains(found.Content.Body, body) {
t.Errorf("body mismatch: want contains %q, got %q", body, found.Content.Body)
}
if hfrom := found.Content.Headers["From"]; len(hfrom) == 0 || !strings.Contains(hfrom[0], "tests@rsmon.test") {
t.Errorf("from mismatch: want contains tests@rsmon.test, got %v", hfrom)
}
if hto := found.Content.Headers["To"]; len(hto) == 0 || !strings.Contains(hto[0], to) {
t.Errorf("to mismatch: want contains %s, got %v", to, hto)
}
}
func TestEmail_Network_HTMLAlternative(t *testing.T) {
skipIfMailHogDown(t)
mailhogDeleteAll(t)
port := mailhogSMTPPort
enabled := true
cred := &models.NotificationCredential{
Kind: models.CredentialKindSMTP,
Name: "mailhog-html",
Server: strPtr(mailhogSMTPHost),
Port: &port,
Login: strPtr(""),
FromAddr: strPtr("tests@rsmon.test"),
Enabled: &enabled,
SecretEnc: "plain:",
}
subject := "rsmon-html-" + time.Now().Format("150405.000")
bodyText := "plain text body"
bodyHTML := "<p>html <b>body</b></p>"
if err := Email(cred, "to@rsmon.test", subject, bodyText, bodyHTML); err != nil {
t.Fatalf("Email returned error: %v", err)
}
deadline := time.Now().Add(mailhogPollDeadline)
var found *mailhogMessage
for time.Now().Before(deadline) {
msgs := mailhogMessages(t)
for i := range msgs {
hsubj := msgs[i].Content.Headers["Subject"]
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
found = &msgs[i]
break
}
}
if found != nil {
break
}
time.Sleep(mailhogPollInterval)
}
if found == nil {
t.Fatalf("html message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
}
body := found.Content.Body
if !strings.Contains(body, bodyText) {
t.Errorf("text part missing: want contains %q in %q", bodyText, body)
}
if !strings.Contains(body, bodyHTML) {
t.Errorf("html part missing: want contains %q in %q", bodyHTML, body)
}
}

54
internal/notify/email_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,54 @@
package notify
import (
"testing"
"rsgit.ru/rsmon/rsmon/app/models"
)
func TestEmail_NilCred(t *testing.T) {
if err := Email(nil, "to@x.com", "s", "b", ""); err == nil {
t.Fatal("expected error for nil cred")
}
}
func TestEmail_WrongKind(t *testing.T) {
cred := &models.NotificationCredential{Kind: models.CredentialKindTelegram}
if err := Email(cred, "to@x.com", "s", "b", ""); err == nil {
t.Fatal("expected error for wrong kind")
}
}
func TestEmail_MissingRequiredFields(t *testing.T) {
cases := []struct {
name string
cred *models.NotificationCredential
}{
{"nil server", &models.NotificationCredential{Kind: models.CredentialKindSMTP}},
{"nil port", &models.NotificationCredential{
Kind: models.CredentialKindSMTP,
Server: strPtr("smtp.x.com"),
}},
{"nil login", &models.NotificationCredential{
Kind: models.CredentialKindSMTP,
Server: strPtr("smtp.x.com"),
Port: intPtr(587),
}},
{"nil fromaddr", &models.NotificationCredential{
Kind: models.CredentialKindSMTP,
Server: strPtr("smtp.x.com"),
Port: intPtr(587),
Login: strPtr("u"),
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := Email(tc.cred, "to@x.com", "s", "b", ""); err == nil {
t.Fatalf("expected error for %s", tc.name)
}
})
}
}
func strPtr(s string) *string { return &s }
func intPtr(i int) *int { return &i }

66
internal/notify/telegram.go Обычный файл
Просмотреть файл

@@ -0,0 +1,66 @@
package notify
import (
"fmt"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"rsgit.ru/rsmon/rsmon/app/models"
)
// Telegram sends a text message to the given chat ID using the given Telegram
// bot credential. chatID is the numeric Telegram chat id assigned by Telegram
// to a private chat, group or channel.
func Telegram(cred *models.NotificationCredential, chatID int64, text string) error {
if cred == nil {
return fmt.Errorf("credential is nil")
}
if cred.Kind != models.CredentialKindTelegram {
return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind)
}
token, err := cred.GetSecret()
if err != nil {
return fmt.Errorf("decrypt telegram token: %w", err)
}
if token == "" {
return fmt.Errorf("credential %d has empty token", cred.ID)
}
apiURL := ""
if cred.APIURL != nil && *cred.APIURL != "" {
apiURL = *cred.APIURL
}
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(token, telegramEndpoint(apiURL))
if err != nil {
return fmt.Errorf("init telegram bot: %w", err)
}
msg := tgbotapi.NewMessage(chatID, text)
if _, err := bot.Send(msg); err != nil {
return fmt.Errorf("send telegram via credential %d: %w", cred.ID, err)
}
return nil
}
// telegramEndpoint returns the Bot API endpoint pattern that tgbotapi expects
// (".../bot%s/%s"). If rawURL is empty, returns the default Telegram endpoint.
//
// rawURL may be a base URL with optional basic-auth credentials in the
// userinfo (https://user:pass@host/) — net/http applies the userinfo as the
// Authorization header automatically, so reverse proxies with basic auth
// work transparently.
//
// We deliberately avoid url.Parse here: it percent-encodes the literal "%"
// in "/bot%s/%s" to "/bot%25s/%25s", which makes tgbotapi's fmt.Sprintf
// produce a malformed URL (verified against the deploy.rscz.ru proxy).
// Stripping the path and appending the bot-method pattern as a string is
// both simpler and safe.
func telegramEndpoint(rawURL string) string {
if rawURL == "" {
return tgbotapi.APIEndpoint
}
return strings.TrimRight(rawURL, "/") + "/bot%s/%s"
}

110
internal/notify/telegram_network_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,110 @@
package notify
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"rsgit.ru/rsmon/rsmon/app/models"
)
func TestTelegram_Network_MockBotAPI(t *testing.T) {
var (
gotPath atomic.Value
gotMethod atomic.Value
gotContentTy atomic.Value
gotForm atomic.Pointer[url.Values]
)
gotPath.Store("")
gotMethod.Store("")
gotContentTy.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath.Store(r.URL.Path)
gotMethod.Store(r.Method)
gotContentTy.Store(r.Header.Get("Content-Type"))
if err := r.ParseForm(); err == nil {
form := r.PostForm
gotForm.Store(&form)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"test_bot"}}`))
}))
defer srv.Close()
enabled := true
cred := &models.NotificationCredential{
Kind: models.CredentialKindTelegram,
Name: "test-bot",
BotName: strPtr("test_bot"),
APIURL: strPtr(srv.URL),
Enabled: &enabled,
SecretEnc: "plain:FAKE_TOKEN_FOR_TEST",
}
if err := Telegram(cred, 123456789, "hello from test"); err != nil {
t.Fatalf("Telegram returned error: %v", err)
}
path := gotPath.Load().(string)
method := gotMethod.Load().(string)
ct := gotContentTy.Load().(string)
if method != http.MethodPost {
t.Errorf("method = %s; want POST", method)
}
if !strings.HasPrefix(path, "/botFAKE_TOKEN_FOR_TEST/") {
t.Errorf("unexpected path: %s", path)
}
if !strings.HasSuffix(path, "/sendMessage") {
t.Errorf("expected path to end with /sendMessage, got: %s", path)
}
if !strings.Contains(ct, "application/x-www-form-urlencoded") {
t.Errorf("content-type = %q; want application/x-www-form-urlencoded", ct)
}
formPtr := gotForm.Load()
if formPtr == nil {
t.Fatal("form body was not captured")
}
form := *formPtr
if got := form.Get("chat_id"); got != "123456789" {
t.Errorf("chat_id = %q; want 123456789", got)
}
if got := form.Get("text"); got != "hello from test" {
t.Errorf("text = %q; want %q", got, "hello from test")
}
}
func TestTelegram_Network_BasicAuthProxy(t *testing.T) {
var gotAuth atomic.Value
gotAuth.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"username":"t"}}`))
}))
defer srv.Close()
authedURL := strings.Replace(srv.URL, "http://", "http://gleb:rokBelHoho@", 1)
enabled := true
cred := &models.NotificationCredential{
Kind: models.CredentialKindTelegram,
Name: "proxy-bot",
APIURL: strPtr(authedURL),
Enabled: &enabled,
SecretEnc: "plain:TOKEN",
}
if err := Telegram(cred, 1, "x"); err != nil {
t.Fatalf("Telegram returned error: %v", err)
}
got := gotAuth.Load().(string)
if !strings.HasPrefix(got, "Basic ") {
t.Errorf("expected Basic auth header, got %q", got)
}
}

53
internal/notify/telegram_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,53 @@
package notify
import (
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"rsgit.ru/rsmon/rsmon/app/models"
)
func TestTelegram_NilCred(t *testing.T) {
if err := Telegram(nil, 123, "x"); err == nil {
t.Fatal("expected error for nil cred")
}
}
func TestTelegram_WrongKind(t *testing.T) {
cred := &models.NotificationCredential{Kind: models.CredentialKindSMTP}
if err := Telegram(cred, 123, "x"); err == nil {
t.Fatal("expected error for wrong kind")
}
}
func TestTelegram_EmptyToken(t *testing.T) {
enabled := true
cred := &models.NotificationCredential{
Kind: models.CredentialKindTelegram,
Enabled: &enabled,
}
if err := Telegram(cred, 123, "x"); err == nil {
t.Fatal("expected error for empty token")
}
}
func TestTelegramEndpoint_Default(t *testing.T) {
if got := telegramEndpoint(""); got != tgbotapi.APIEndpoint {
t.Fatalf("expected default endpoint %q, got %q", tgbotapi.APIEndpoint, got)
}
}
func TestTelegramEndpoint_CustomURL(t *testing.T) {
cases := []struct{ in, want string }{
{"https://api.telegram.org", "https://api.telegram.org/bot%s/%s"},
{"https://api.telegram.org/", "https://api.telegram.org/bot%s/%s"},
{"https://user:pass@deploy.rscz.ru/", "https://user:pass@deploy.rscz.ru/bot%s/%s"},
{"https://deploy.rscz.ru", "https://deploy.rscz.ru/bot%s/%s"},
}
for _, tc := range cases {
if got := telegramEndpoint(tc.in); got != tc.want {
t.Errorf("telegramEndpoint(%q) = %q; want %q", tc.in, got, tc.want)
}
}
}