feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326)

* feat(notifications): event bus architecture with Telegram and SMTP subscribers

- Event bus core with buffered channel, fan-out, panic recovery
- Telegram subscriber with HTML formatting and rate limiting
- Email subscriber with SMTP/TLS/STARTTLS support and stage diagnostics
- 5 event types: outbound.down/up, xray.crash, cpu.high, login.attempt
- CPU threshold checks per subscriber (tgCpu for TG, smtpCpu for Email)
- SystemMetricData struct for raw metric values in events
- i18n keys for en-US, ru-RU, and English defaults for other locales

* fix

* fix(notifications): repair crash/CPU alerts, harden secrets, add node alerts

Bug fixes:
- Xray crash notifications were permanently suppressed after the first crash:
  XrayStateTracker latched state="down" with no reset and no recovery event,
  so only the first crash per process lifetime ever notified. Removed the
  tracker; the existing 1/min rate limiter already dedupes crash-loop spam.
- Email CPU alerts could never fire unless Telegram was also enabled, because
  the CPU job was registered only inside the tgbot block. Register it whenever
  either Telegram or SMTP wants cpu.high (new cpuAlarmWanted gate) and relax
  the cadence to @every 1m (cpu.Percent already samples over a full minute).
- SMTP password (and, pre-existing, all other secrets) were shipped to the
  browser in plaintext: GetAllSettingView was dead code and /setting/all
  returned the raw model. Wire getAllSetting -> GetAllSettingView, redact
  smtpPassword with a hasSmtpPassword presence flag, and preserve it on blank
  save. Closes the leak for tgBotToken/ldapPassword/2FA token too.

Polish:
- email Send: use nil SMTP auth when no credentials (Go refuses PlainAuth over
  the unencrypted "none" transport).
- Remove unused EventClientDepleted; fix inaccurate bus.go doc comments; drop
  stale tgBotLoginNotify from the frontend schema; gofmt alignment.

Feature - node online/offline alerts:
- Emit node.down/node.up from the heartbeat job on a real status transition
  (with a startup-spam guard), reusing NodeHealthData. Formatted by both the
  Telegram and email subscribers and selectable in the settings UI.

Regenerated frontend types (hasSmtpPassword). New i18n keys added to en-US;
other locales fall back to English (bundle default) until translated.

* fix(settings): use antd Space orientation instead of deprecated direction

Ant Design 6 deprecated Space's `direction` prop in favor of `orientation`,
which logged a console warning from the Telegram/Email notification tabs. Brings
these two tabs in line with the rest of the codebase, which already uses
`orientation`.

* i18n(notifications): translate the notification feature into all locales

The notifications PR shipped ~99 new strings (SMTP settings, event labels,
Telegram/email message templates) as English placeholders in every non-English
locale. Translate them — plus the node-alert keys added during this review —
into all 12 locales: Arabic, Spanish, Persian, Indonesian, Japanese,
Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/
Traditional Chinese.

Go-template placeholders ({{ .Tag }}, {{ .Name }}, etc.) are preserved exactly;
tgbot message values carry no leading status emoji (the bot/email code adds
those, so an emoji in the value would duplicate it); product/protocol names
(SMTP, STARTTLS, TLS, CPU, Xray, Telegram) are kept as-is.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Sentiago
2026-06-15 22:03:41 +03:00
committed by GitHub
parent 7fe082a7f1
commit eec030f86f
48 changed files with 3854 additions and 141 deletions
+297
View File
@@ -0,0 +1,297 @@
package email
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// EmailService sends email notifications via SMTP.
type EmailService struct {
settingService service.SettingService
}
// SMTPTestResult holds the result of an SMTP connection test.
type SMTPTestResult struct {
Success bool `json:"success"`
Stage string `json:"stage"` // "connect" | "auth" | "send"
Message string `json:"message"` // classified error message
}
// NewEmailService creates a new EmailService.
func NewEmailService(settingService service.SettingService) *EmailService {
return &EmailService{settingService: settingService}
}
// Send sends an HTML email to all configured recipients.
func (s *EmailService) Send(subject, body string) error {
host, err := s.settingService.GetSmtpHost()
if err != nil || host == "" {
return fmt.Errorf("smtp host not configured")
}
port, err := s.settingService.GetSmtpPort()
if err != nil || port <= 0 {
port = 587
}
username, _ := s.settingService.GetSmtpUsername()
password, _ := s.settingService.GetSmtpPassword()
toStr, _ := s.settingService.GetSmtpTo()
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
from := username
if from == "" {
return fmt.Errorf("smtp from not configured")
}
recipients := parseRecipients(toStr)
if len(recipients) == 0 {
return fmt.Errorf("no recipients configured")
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
msg := buildMessage(from, recipients, subject, body)
// Authenticate only when credentials are set. Go's PlainAuth refuses to run
// over the unencrypted "none" transport, so an open relay must use nil auth.
var auth smtp.Auth
if username != "" && password != "" {
auth = smtp.PlainAuth("", username, password, host)
}
// Wrap in a channel with timeout to prevent indefinite blocking
type result struct{ err error }
ch := make(chan result, 1)
go func() {
switch encryptionType {
case "tls":
ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
case "starttls", "none":
ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
default:
ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
}
}()
select {
case r := <-ch:
return r.err
case <-time.After(30 * time.Second):
return fmt.Errorf("smtp connection timed out after 30s")
}
}
// TestConnection tests SMTP connection stage by stage and sends a test email.
func (s *EmailService) TestConnection() SMTPTestResult {
host, err := s.settingService.GetSmtpHost()
if err != nil || host == "" {
return SMTPTestResult{false, "connect", "smtpHostNotConfigured"}
}
port, err := s.settingService.GetSmtpPort()
if err != nil || port <= 0 {
port = 587
}
username, _ := s.settingService.GetSmtpUsername()
password, _ := s.settingService.GetSmtpPassword()
toStr, _ := s.settingService.GetSmtpTo()
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
from := username
recipients := parseRecipients(toStr)
if len(recipients) == 0 {
return SMTPTestResult{false, "send", "smtpNoRecipients"}
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Stage 1: Connect
var conn net.Conn
dialer := &net.Dialer{Timeout: 5 * time.Second}
switch encryptionType {
case "tls":
conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
ServerName: host,
InsecureSkipVerify: false,
})
default:
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return SMTPTestResult{false, "connect", classifySMTPError(err)}
}
defer conn.Close()
// Stage 2: Handshake + Auth
client, err := smtp.NewClient(conn, host)
if err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
defer client.Close()
if err = client.Hello("localhost"); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
// STARTTLS upgrade for non-TLS connections
if encryptionType == "starttls" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
}
}
if username != "" && password != "" {
auth := smtp.PlainAuth("", username, password, host)
if err = client.Auth(auth); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
}
// Stage 3: Send test email
if err = client.Mail(from); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
for _, r := range recipients {
if err = client.Rcpt(r); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
}
msg := buildMessage(from, recipients, "[3x-ui] Test email",
`<html><body style="font-family:monospace;font-size:14px">
<h2>Test email from 3x-ui</h2>
<p>If you received this, SMTP is configured correctly.</p>
</body></html>`)
w, err := client.Data()
if err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
if _, err = w.Write(msg); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
if err = w.Close(); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
return SMTPTestResult{true, "send", "smtpTestSuccess"}
}
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
// Dial with explicit timeout
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
ServerName: host,
InsecureSkipVerify: false,
})
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
if err = client.Hello("localhost"); err != nil {
return err
}
if auth != nil {
if err = client.Auth(auth); err != nil {
return err
}
}
if err = client.Mail(from); err != nil {
return err
}
for _, r := range to {
if err = client.Rcpt(r); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(msg); err != nil {
return err
}
return w.Close()
}
// SendTest sends a test email and returns any error with detail.
func (s *EmailService) SendTest() error {
return s.Send(
"[3x-ui] Test email",
`<html><body style="font-family:monospace;font-size:14px">
<h2>Test email from 3x-ui</h2>
<p>If you received this, SMTP is configured correctly.</p>
</body></html>`,
)
}
// classifySMTPError maps raw SMTP errors to human-readable messages.
func classifySMTPError(err error) string {
msg := err.Error()
msgLower := strings.ToLower(msg)
switch {
case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
return "pages.settings.smtpErrorAuth"
case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
return "pages.settings.smtpErrorStarttls"
case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
return "pages.settings.smtpErrorTls"
case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
return "pages.settings.smtpErrorRefused"
case strings.Contains(msgLower, "timeout"):
return "pages.settings.smtpErrorTimeout"
case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
return "pages.settings.smtpErrorRelay"
case strings.Contains(msgLower, "eof"):
return "pages.settings.smtpErrorEof"
default:
return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
}
}
func parseRecipients(toStr string) []string {
if toStr == "" {
return nil
}
var out []string
for _, s := range strings.Split(toStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
}
func buildMessage(from string, to []string, subject, body string) []byte {
headers := map[string]string{
"From": from,
"To": strings.Join(to, ","),
"Subject": subject,
"MIME-Version": "1.0",
"Content-Type": "text/html; charset=utf-8",
}
var msg strings.Builder
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(body)
return []byte(msg.String())
}
@@ -0,0 +1,52 @@
package email
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
func TestRateLimiterAllow(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("first call should be allowed")
}
}
func TestRateLimiterCooldown(t *testing.T) {
rl := eventbus.NewRateLimiter(100 * time.Millisecond)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("should be blocked during cooldown")
}
time.Sleep(110 * time.Millisecond)
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("should be allowed after cooldown")
}
}
func TestRateLimiterPerType(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if !rl.Allow(eventbus.EventOutboundUp, "proxy-1") {
t.Error("different event types should be independent")
}
}
func TestRateLimiterPerSource(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if !rl.Allow(eventbus.EventOutboundDown, "proxy-2") {
t.Error("different sources should be independent")
}
}
+182
View File
@@ -0,0 +1,182 @@
package email
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// Subscriber handles event bus messages and sends email notifications.
type Subscriber struct {
settingService service.SettingService
emailService *EmailService
limiter *eventbus.RateLimiter
}
// NewSubscriber creates a new email event subscriber.
func NewSubscriber(settingService service.SettingService, emailService *EmailService) *Subscriber {
return &Subscriber{
settingService: settingService,
emailService: emailService,
limiter: eventbus.NewRateLimiter(1 * time.Minute),
}
}
// HandleEvent is the eventbus subscriber callback.
func (s *Subscriber) HandleEvent(e eventbus.Event) {
if !s.isEventEnabled(e.Type) {
return
}
if e.Type != eventbus.EventLoginAttempt {
if !s.limiter.Allow(e.Type, e.Source) {
return
}
}
subject, body := s.formatMessage(e)
if subject == "" {
return
}
if err := s.emailService.Send(subject, body); err != nil {
logger.Warning("email subscriber: send failed:", err)
}
}
func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
events, err := s.settingService.GetSmtpEnabledEvents()
if err != nil || events == "" {
return false
}
for _, e := range strings.Split(events, ",") {
if strings.TrimSpace(e) == string(t) {
return true
}
}
return false
}
func i18n(key string, params ...string) string {
return locale.I18n(locale.Bot, key, params...)
}
func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) {
h, _ := hostname()
host := h
ts := e.Timestamp.Format("2006-01-02 15:04:05")
wrap := func(title, content string) string {
// Strip newlines from title to prevent broken HTML
title = strings.ReplaceAll(title, "\r\n", "")
title = strings.ReplaceAll(title, "\n", "")
return fmt.Sprintf(`<html><body style="font-family:monospace;font-size:14px;color:#333">
<h2 style="color:#555;border-bottom:1px solid #ddd;padding-bottom:8px">📡 %s %s</h2>
%s
<p style="color:#999;font-size:12px;margin-top:20px">%s</p>
</body></html>`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts))
}
kv := func(key, val string) string {
return fmt.Sprintf("<p><b>%s:</b> %s</p>", key, val)
}
switch e.Type {
case eventbus.EventOutboundDown:
subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
content += kv(i18n("email.labelOutbound"), e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
if data.Error != "" {
content += kv(i18n("email.labelError"), data.Error)
}
if data.Delay > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
}
}
body = wrap(i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source), content)
case eventbus.EventOutboundUp:
subject = host + " " + i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
content += kv(i18n("email.labelOutbound"), e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
}
body = wrap(i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source), content)
case eventbus.EventXrayCrash:
subject = host + " " + i18n("tgbot.messages.eventXrayCrash")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusCrashed")+`</span>`)
if e.Data != nil {
content += kv(i18n("email.labelError"), fmt.Sprint(e.Data))
}
body = wrap(i18n("tgbot.messages.eventXrayCrash"), content)
case eventbus.EventNodeDown:
subject = host + " " + i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
content += kv(i18n("email.labelNode"), e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
content += kv(i18n("email.labelError"), data.XrayError)
}
body = wrap(i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source), content)
case eventbus.EventNodeUp:
subject = host + " " + i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
content += kv(i18n("email.labelNode"), e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.LatencyMs))
}
body = wrap(i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source), content)
case eventbus.EventCPUHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
smtpCpu, err := s.settingService.GetSmtpCpu()
if err != nil || smtpCpu <= 0 || data.Percent <= float64(smtpCpu) {
return
}
subject = host + " " + i18n("tgbot.messages.cpuThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+fmt.Sprintf("%d", smtpCpu))
content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
body = wrap(subject, content)
}
case eventbus.EventLoginAttempt:
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
if data.Status == "success" {
subject = host + " " + i18n("tgbot.messages.loginSuccess")
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusSuccess")+`</span>`)
content += kv(i18n("email.labelUsername"), data.Username)
content += kv(i18n("email.labelIP"), data.IP)
body = wrap(i18n("tgbot.messages.loginSuccess"), content)
} else {
subject = host + " " + i18n("tgbot.messages.loginFailed")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
if data.Reason != "" {
content += kv(i18n("email.labelReason"), data.Reason)
}
content += kv(i18n("email.labelUsername"), data.Username)
content += kv(i18n("email.labelIP"), data.IP)
body = wrap(i18n("tgbot.messages.loginFailed"), content)
}
} else {
subject = host + " " + i18n("tgbot.messages.loginFailed")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
content += kv(i18n("email.labelSource"), e.Source)
body = wrap(i18n("tgbot.messages.loginFailed"), content)
}
}
return
}
func hostname() (string, error) {
return os.Hostname()
}
+107 -5
View File
@@ -53,7 +53,6 @@ var defaultValueMap = map[string]string{
"tgBotChatId": "",
"tgRunTime": "@daily",
"tgBotBackup": "false",
"tgBotLoginNotify": "true",
"tgCpu": "80",
"tgLang": "en-US",
"twoFactorEnable": "false",
@@ -119,6 +118,20 @@ var defaultValueMap = map[string]string{
"ldapDefaultTotalGB": "0",
"ldapDefaultExpiryDays": "0",
"ldapDefaultLimitIP": "0",
// Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high",
"smtpEnabledEvents": "login.attempt,cpu.high",
"smtpCpu": "80",
// Email (SMTP) notifications
"smtpEnable": "false",
"smtpHost": "",
"smtpPort": "587",
"smtpUsername": "",
"smtpPassword": "",
"smtpTo": "",
"smtpEncryptionType": "starttls", // no, starttls, tls
}
// SettingService provides business logic for application settings management.
@@ -220,6 +233,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
@@ -227,6 +241,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.TgBotToken = ""
view.TwoFactorToken = ""
view.LdapPassword = ""
view.SmtpPassword = ""
return view, nil
}
@@ -504,10 +519,6 @@ func (s *SettingService) GetTgBotBackup() (bool, error) {
return s.getBool("tgBotBackup")
}
func (s *SettingService) GetTgBotLoginNotify() (bool, error) {
return s.getBool("tgBotLoginNotify")
}
func (s *SettingService) GetTgCpu() (int, error) {
return s.getInt("tgCpu")
}
@@ -918,6 +929,90 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
return s.getInt("ldapDefaultLimitIP")
}
// Event bus — per-subscriber event filtering
func (s *SettingService) GetTgEnabledEvents() (string, error) {
return s.getString("tgEnabledEvents")
}
func (s *SettingService) SetTgEnabledEvents(events string) error {
return s.setString("tgEnabledEvents", events)
}
func (s *SettingService) GetSmtpEnabledEvents() (string, error) {
return s.getString("smtpEnabledEvents")
}
func (s *SettingService) SetSmtpEnabledEvents(events string) error {
return s.setString("smtpEnabledEvents", events)
}
// Email (SMTP) settings
func (s *SettingService) GetSmtpEnable() (bool, error) {
return s.getBool("smtpEnable")
}
func (s *SettingService) SetSmtpEnable(value bool) error {
return s.setBool("smtpEnable", value)
}
func (s *SettingService) GetSmtpHost() (string, error) {
return s.getString("smtpHost")
}
func (s *SettingService) SetSmtpHost(value string) error {
return s.setString("smtpHost", value)
}
func (s *SettingService) GetSmtpPort() (int, error) {
return s.getInt("smtpPort")
}
func (s *SettingService) SetSmtpPort(value int) error {
return s.setInt("smtpPort", value)
}
func (s *SettingService) GetSmtpUsername() (string, error) {
return s.getString("smtpUsername")
}
func (s *SettingService) SetSmtpUsername(value string) error {
return s.setString("smtpUsername", value)
}
func (s *SettingService) GetSmtpPassword() (string, error) {
return s.getString("smtpPassword")
}
func (s *SettingService) SetSmtpPassword(value string) error {
return s.setString("smtpPassword", value)
}
func (s *SettingService) GetSmtpTo() (string, error) {
return s.getString("smtpTo")
}
func (s *SettingService) SetSmtpTo(value string) error {
return s.setString("smtpTo", value)
}
func (s *SettingService) GetSmtpEncryptionType() (string, error) {
return s.getString("smtpEncryptionType")
}
func (s *SettingService) SetSmtpEncryptionType(value string) error {
return s.setString("smtpEncryptionType", value)
}
func (s *SettingService) GetSmtpCpu() (int, error) {
return s.getInt("smtpCpu")
}
func (s *SettingService) SetSmtpCpu(value int) error {
return s.setInt("smtpCpu", value)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := s.preserveRedactedSecrets(allSetting); err != nil {
return err
@@ -967,6 +1062,13 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
}
allSetting.TwoFactorToken = value
}
if strings.TrimSpace(allSetting.SmtpPassword) == "" {
value, err := s.GetSmtpPassword()
if err != nil {
return err
}
allSetting.SmtpPassword = value
}
return nil
}
+11 -2
View File
@@ -32,6 +32,9 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
t.Fatal(err)
}
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
@@ -40,10 +43,10 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" {
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" {
t.Fatalf("settings view leaked secrets: %#v", view)
}
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken {
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword {
t.Fatalf("settings view did not report configured secret flags: %#v", view)
}
}
@@ -63,6 +66,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
t.Fatal(err)
}
view, err := s.GetAllSettingView()
if err != nil {
@@ -81,6 +87,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
t.Fatalf("2fa token = %q, want preserved secret", got)
}
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
t.Fatalf("smtp password = %q, want preserved secret", got)
}
}
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
+4
View File
@@ -15,6 +15,7 @@ import (
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
@@ -43,6 +44,9 @@ var (
hostname string
hashStorage *global.HashStorage
// EventBus is set from web layer to publish login/security events.
EventBus *eventbus.Bus
// Performance improvements
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
+150
View File
@@ -0,0 +1,150 @@
package tgbot
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
var cachedHostname string
func getHostname() string {
if cachedHostname != "" {
return cachedHostname
}
h, err := os.Hostname()
if err != nil {
cachedHostname = "unknown"
} else {
cachedHostname = h
}
return cachedHostname
}
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
// HandleEvent is the eventbus subscriber callback. It formats incoming events
// as Telegram messages and sends them to all admin chats.
func (t *Tgbot) HandleEvent(e eventbus.Event) {
if !t.isEventEnabled(e.Type) {
return
}
if e.Type != eventbus.EventLoginAttempt {
if !tgEventLimiter.Allow(e.Type, e.Source) {
return
}
}
msg := t.formatEventMessage(e)
if msg != "" {
t.SendMsgToTgbotAdmins(msg)
}
}
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
events, err := t.settingService.GetTgEnabledEvents()
if err != nil || events == "" {
return false
}
for _, e := range strings.Split(events, ",") {
if strings.TrimSpace(e) == string(eventType) {
return true
}
}
return false
}
func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
host := getHostname()
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
switch e.Type {
case eventbus.EventOutboundDown:
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
if data.Error != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
"Error=="+data.Error)
}
if data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
}
return msg
case eventbus.EventOutboundUp:
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
return msg
case eventbus.EventXrayCrash:
errStr := ""
if e.Data != nil {
errStr = fmt.Sprint(e.Data)
}
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
if errStr != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
}
return msg
case eventbus.EventNodeDown:
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
}
return msg
case eventbus.EventNodeUp:
msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
}
return msg
case eventbus.EventCPUHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
tgCpu, err := t.settingService.GetTgCpu()
if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
return ""
}
return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+strconv.Itoa(tgCpu))
}
return ""
case eventbus.EventLoginAttempt:
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
if data.Status == "success" {
msg := t.I18nBot("tgbot.messages.loginSuccess")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
return msg
}
msg := t.I18nBot("tgbot.messages.loginFailed")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
if data.Reason != "" {
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
}
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
return msg
}
return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
}
return ""
}
+18 -22
View File
@@ -12,6 +12,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -153,38 +154,33 @@ func (t *Tgbot) prepareServerUsageInfo() string {
return info
}
// UserLoginNotify sends a notification about user login attempts to admins.
// UserLoginNotify publishes a login event to the event bus.
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
if !t.IsRunning() {
return
}
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
logger.Warning("UserLoginNotify failed, invalid info!")
return
}
loginNotifyEnabled, err := t.settingService.GetTgBotLoginNotify()
if err != nil || !loginNotifyEnabled {
if EventBus == nil {
return
}
msg := ""
switch attempt.Status {
case LoginSuccess:
msg += t.I18nBot("tgbot.messages.loginSuccess")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
case LoginFail:
msg += t.I18nBot("tgbot.messages.loginFailed")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
if attempt.Reason != "" {
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
}
status := "fail"
if attempt.Status == LoginSuccess {
status = "success"
}
msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
go t.SendMsgToTgbotAdmins(msg)
EventBus.Publish(eventbus.Event{
Type: eventbus.EventLoginAttempt,
Source: attempt.IP,
Data: &eventbus.LoginEventData{
Username: attempt.Username,
IP: attempt.IP,
Time: attempt.Time,
Status: status,
Reason: attempt.Reason,
},
})
}
// getExhausted retrieves and sends information about exhausted clients.
+17
View File
@@ -2,6 +2,7 @@ package tgbot
import (
"context"
"fmt"
"strings"
"time"
@@ -247,3 +248,19 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
logger.Info("Message deleted successfully")
}
}
// TestConnection verifies the bot token is valid and the API is reachable.
func (t *Tgbot) TestConnection() error {
tgBotMutex.Lock()
b := bot
tgBotMutex.Unlock()
if b == nil {
return fmt.Errorf("bot not initialized")
}
me, err := b.GetMe(context.Background())
if err != nil {
return fmt.Errorf("API unreachable: %w", err)
}
_ = me
return nil
}
+39
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
@@ -29,6 +30,15 @@ type ObsTagSnapshot struct {
UpdatedAt int64 `json:"updatedAt"`
}
// eventBus is the shared bus for publishing observatory state-change events.
// Set once during startup via SetEventBus; nil when no bus is configured.
var eventBus *eventbus.Bus
// SetEventBus assigns the global event bus used by applyObservatory to publish
// outbound health transitions. Must be called once during startup before any
// Sample tick runs.
func SetEventBus(b *eventbus.Bus) { eventBus = b }
type XrayMetricsService struct {
settingService SettingService
@@ -205,6 +215,35 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
}
s.mu.Lock()
// Detect transitions and publish events
if eventBus != nil {
// Check existing tags for state changes
for tag, old := range s.obsByTag {
cur, exists := next[tag]
if !exists {
// Tag disappeared from observatory — skip, not a real failure
continue
}
if old.Alive && !cur.Alive {
errMsg := ""
if cur.Delay < 0 {
errMsg = "probe failed"
}
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundDown,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
})
} else if !old.Alive && cur.Alive {
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundUp,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
})
}
}
}
for tag := range s.obsByTag {
if _, kept := next[tag]; !kept {
xrayMetrics.drop(obsHistoryKey(tag))