mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
cc3aff2434
Every Discord alert field went through cleanField, which only trimmed and truncated, so a login username, a node's error text, a remark or an email reached Discord as live Markdown: masked links, bold, headings, mentions. A failed login with a crafted username could post a working phishing link in the admins' channel. Foreign text is now Markdown-escaped in every field and command reply, and values shown in code spans can no longer close their span.
378 lines
9.8 KiB
Go
378 lines
9.8 KiB
Go
package discord
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// Subscriber handles event bus messages and forwards them to Discord.
|
|
type Subscriber struct {
|
|
settingService service.SettingService
|
|
discordService *DiscordService
|
|
limiter *eventbus.RateLimiter
|
|
}
|
|
|
|
// NewSubscriber creates a new Discord event subscriber.
|
|
func NewSubscriber(settingService service.SettingService, discordService *DiscordService) *Subscriber {
|
|
return &Subscriber{
|
|
settingService: settingService,
|
|
discordService: discordService,
|
|
limiter: eventbus.NewRateLimiter(1 * time.Minute),
|
|
}
|
|
}
|
|
|
|
// HandleEvent is the eventbus subscriber callback.
|
|
func (s *Subscriber) HandleEvent(e eventbus.Event) {
|
|
if s.discordService == nil {
|
|
return
|
|
}
|
|
if on, err := s.settingService.GetDiscordBotEnable(); err != nil || !on {
|
|
return
|
|
}
|
|
if !s.isEventEnabled(e.Type) {
|
|
return
|
|
}
|
|
embed, ok := s.FormatEmbed(e)
|
|
if !ok {
|
|
return
|
|
}
|
|
if e.Type != eventbus.EventLoginAttempt {
|
|
if !s.limiter.Allow(e.Type, e.Source) {
|
|
return
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := s.discordService.SendEmbed(ctx, embed); err != nil {
|
|
logger.Warning("discord subscriber: send failed:", err)
|
|
}
|
|
}
|
|
|
|
func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
|
|
events, err := s.settingService.GetDiscordEnabledEvents()
|
|
if err != nil || events == "" {
|
|
return false
|
|
}
|
|
for e := range strings.SplitSeq(events, ",") {
|
|
if strings.TrimSpace(e) == string(t) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// truncateUnits cuts s to maxUnits of the length Discord measures its field
|
|
// name, value and footer caps by: runes alone overrun them on astral text.
|
|
func truncateUnits(s string, maxUnits int) string {
|
|
if discordCharLen(s) <= maxUnits {
|
|
return s
|
|
}
|
|
suffix := "..."
|
|
budget := maxUnits
|
|
if maxUnits <= len(suffix) {
|
|
suffix = ""
|
|
} else {
|
|
budget -= len(suffix)
|
|
}
|
|
var b strings.Builder
|
|
units := 0
|
|
for _, r := range s {
|
|
size := utf16.RuneLen(r)
|
|
if size < 1 {
|
|
size = 1
|
|
}
|
|
if units+size > budget {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
units += size
|
|
}
|
|
return b.String() + suffix
|
|
}
|
|
|
|
// markdownEscaper covers what Discord renders as markup in an embed field:
|
|
// formatting, headings, quotes, masked links, mentions and bare-URL auto-links.
|
|
var markdownEscaper = strings.NewReplacer(
|
|
`\`, `\\`, "`", "\\`", `*`, `\*`, `_`, `\_`, `~`, `\~`, `|`, `\|`, `#`, `\#`,
|
|
`[`, `\[`, `]`, `\]`, `<`, `\<`, `>`, `\>`, `://`, `\://`,
|
|
)
|
|
|
|
// escapeMarkdown makes text the panel did not write itself (login usernames,
|
|
// node errors, remarks, emails) show up literally instead of as Markdown.
|
|
func escapeMarkdown(s string) string {
|
|
return markdownEscaper.Replace(s)
|
|
}
|
|
|
|
// cleanField builds a field from plain text: Discord shows both strings literally.
|
|
func cleanField(name, value string, inline bool) EmbedField {
|
|
return markupField(escapeMarkdown(name), escapeMarkdown(value), inline)
|
|
}
|
|
|
|
// markupField keeps the caller's own Markdown, so whatever foreign text the
|
|
// caller puts into it has to be escaped beforehand.
|
|
func markupField(name, value string, inline bool) EmbedField {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = "-"
|
|
} else {
|
|
name = truncateUnits(name, 256)
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
value = "-"
|
|
} else {
|
|
value = truncateUnits(value, 1024)
|
|
}
|
|
return EmbedField{
|
|
Name: name,
|
|
Value: value,
|
|
Inline: inline,
|
|
}
|
|
}
|
|
|
|
// FormatEmbed converts an eventbus.Event into a Discord Embed.
|
|
// Returns false if the event should not produce a message (e.g. thresholds not exceeded).
|
|
func (s *Subscriber) FormatEmbed(e eventbus.Event) (Embed, bool) {
|
|
h, _ := os.Hostname()
|
|
if h == "" {
|
|
h = "unknown"
|
|
}
|
|
var ts string
|
|
if e.Timestamp.IsZero() {
|
|
ts = time.Now().UTC().Format(time.RFC3339)
|
|
} else {
|
|
ts = e.Timestamp.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
footer := &EmbedFooter{
|
|
Text: truncateUnits("3x-ui • "+h, 2048),
|
|
}
|
|
tr := translator(s.settingService)
|
|
|
|
switch e.Type {
|
|
case eventbus.EventOutboundDown:
|
|
fields := []EmbedField{
|
|
cleanField(tr("discord.fields.outbound"), e.Source, true),
|
|
}
|
|
var data *eventbus.OutboundHealthData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.OutboundHealthData:
|
|
data = d
|
|
case eventbus.OutboundHealthData:
|
|
data = &d
|
|
}
|
|
if data != nil {
|
|
if data.Error != "" {
|
|
fields = append(fields, cleanField(tr("discord.fields.error"), data.Error, false))
|
|
}
|
|
if data.Delay > 0 {
|
|
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
|
|
}
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.outboundDown"),
|
|
Color: ColorRed,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
|
|
case eventbus.EventOutboundUp:
|
|
fields := []EmbedField{
|
|
cleanField(tr("discord.fields.outbound"), e.Source, true),
|
|
}
|
|
var data *eventbus.OutboundHealthData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.OutboundHealthData:
|
|
data = d
|
|
case eventbus.OutboundHealthData:
|
|
data = &d
|
|
}
|
|
if data != nil && data.Delay > 0 {
|
|
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.Delay), true))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.outboundUp"),
|
|
Color: ColorGreen,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
|
|
case eventbus.EventNodeDown:
|
|
fields := []EmbedField{
|
|
cleanField(tr("discord.fields.node"), e.Source, true),
|
|
}
|
|
var data *eventbus.NodeHealthData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.NodeHealthData:
|
|
data = d
|
|
case eventbus.NodeHealthData:
|
|
data = &d
|
|
}
|
|
if data != nil && data.XrayError != "" {
|
|
fields = append(fields, cleanField(tr("discord.fields.error"), data.XrayError, false))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.nodeDown"),
|
|
Color: ColorRed,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
|
|
case eventbus.EventNodeUp:
|
|
fields := []EmbedField{
|
|
cleanField(tr("discord.fields.node"), e.Source, true),
|
|
}
|
|
var data *eventbus.NodeHealthData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.NodeHealthData:
|
|
data = d
|
|
case eventbus.NodeHealthData:
|
|
data = &d
|
|
}
|
|
if data != nil && data.LatencyMs > 0 {
|
|
fields = append(fields, cleanField(tr("discord.fields.delay"), fmt.Sprintf("%dms", data.LatencyMs), true))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.nodeUp"),
|
|
Color: ColorGreen,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
|
|
case eventbus.EventXrayCrash:
|
|
var fields []EmbedField
|
|
if e.Data != nil {
|
|
fields = append(fields, cleanField(tr("discord.fields.error"), fmt.Sprint(e.Data), false))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.xrayCrash"),
|
|
Color: ColorRed,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
|
|
case eventbus.EventCPUHigh:
|
|
var data *eventbus.SystemMetricData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.SystemMetricData:
|
|
data = d
|
|
case eventbus.SystemMetricData:
|
|
data = &d
|
|
}
|
|
if data != nil {
|
|
discordCpu, err := s.settingService.GetDiscordCpu()
|
|
if err != nil || discordCpu <= 0 || data.Percent <= float64(discordCpu) {
|
|
return Embed{}, false
|
|
}
|
|
fields := []EmbedField{
|
|
cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
|
|
cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordCpu), true),
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.cpuHigh"),
|
|
Color: ColorOrange,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
}
|
|
return Embed{}, false
|
|
|
|
case eventbus.EventMemoryHigh:
|
|
var data *eventbus.SystemMetricData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.SystemMetricData:
|
|
data = d
|
|
case eventbus.SystemMetricData:
|
|
data = &d
|
|
}
|
|
if data != nil {
|
|
discordMem, err := s.settingService.GetDiscordMemory()
|
|
if err != nil || discordMem <= 0 || data.Percent <= float64(discordMem) {
|
|
return Embed{}, false
|
|
}
|
|
fields := []EmbedField{
|
|
cleanField(tr("usage"), fmt.Sprintf("%.2f%%", data.Percent), true),
|
|
cleanField(tr("discord.fields.threshold"), fmt.Sprintf("%d%%", discordMem), true),
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.memoryHigh"),
|
|
Color: ColorOrange,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
}
|
|
return Embed{}, false
|
|
|
|
case eventbus.EventLoginAttempt:
|
|
var data *eventbus.LoginEventData
|
|
switch d := e.Data.(type) {
|
|
case *eventbus.LoginEventData:
|
|
data = d
|
|
case eventbus.LoginEventData:
|
|
data = &d
|
|
}
|
|
if data != nil {
|
|
if data.Status == "success" {
|
|
fields := []EmbedField{
|
|
cleanField(tr("username"), data.Username, true),
|
|
cleanField("IP", data.IP, true),
|
|
}
|
|
if data.Time != "" {
|
|
fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.loginSuccess"),
|
|
Color: ColorGreen,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
}
|
|
fields := []EmbedField{
|
|
cleanField(tr("username"), data.Username, true),
|
|
cleanField("IP", data.IP, true),
|
|
}
|
|
if data.Reason != "" {
|
|
fields = append(fields, cleanField(tr("discord.fields.reason"), data.Reason, false))
|
|
}
|
|
if data.Time != "" {
|
|
fields = append(fields, cleanField(tr("discord.fields.time"), data.Time, true))
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.loginFailed"),
|
|
Color: ColorRed,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
}
|
|
fields := []EmbedField{
|
|
cleanField(tr("discord.fields.source"), e.Source, true),
|
|
}
|
|
return Embed{
|
|
Title: tr("discord.alerts.loginFailed"),
|
|
Color: ColorRed,
|
|
Timestamp: ts,
|
|
Fields: fields,
|
|
Footer: footer,
|
|
}, true
|
|
}
|
|
|
|
return Embed{}, false
|
|
}
|