373 lines
11 KiB
Go
373 lines
11 KiB
Go
// Package probenotify is the item 7 notification engine: the monitor
|
|
// hub's notify.rs, re-homed on the panel's own stores (ITEM7-SCOPE
|
|
// §3.5/§5.6/B6). Events: a node going offline and returning, a billing
|
|
// period's traffic crossing the configured step and the full
|
|
// allowance, expiry dates approaching (a daily digest) or rolled
|
|
// forward for a node still online past them, and a sign-in to the
|
|
// panel.
|
|
//
|
|
// Channels: Telegram through the panel's existing bot (the
|
|
// tgBotToken/tgBotChatId settings — the hub's separate
|
|
// notify_telegram_token/chat are deliberately NOT ported, one bot
|
|
// configuration serves both) and a new webhook channel (JSON body
|
|
// template, custom headers, no redirect following). Delivery is
|
|
// queued and retried per channel; nothing here may stall or fail the
|
|
// panel flow that raised the event — a dead channel degrades to log
|
|
// lines only.
|
|
//
|
|
// Durable state (offline marks, traffic steps already announced, the
|
|
// digest's last-sent date) lives in probe.db's probe_setting through
|
|
// the probe package, so a master restart neither replays an offline
|
|
// alert nor loses the recovery pairing.
|
|
package probenotify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// Note is one alert. Node lists the node names it concerns,
|
|
// comma-separated, and is empty for a sign-in.
|
|
type Note struct {
|
|
Event string
|
|
Node string
|
|
Title string
|
|
Message string
|
|
// Unix seconds, stamped when queued: delivery can trail the
|
|
// event by minutes while a channel is retried.
|
|
Time int64
|
|
}
|
|
|
|
// Numeric settings as (key, min, max, default, the panel's name for
|
|
// it) — the hub's NUMBERS table, unchanged.
|
|
var numbersTable = []struct {
|
|
key, label string
|
|
min, max, def int64
|
|
}{
|
|
// Minutes a node may stay away before it is reported. Capped at
|
|
// the flap grace (30 min): a longer grace would outwait a
|
|
// flapping node too.
|
|
{"notify_grace", "离线宽限期", 1, flapGrace / 60, 3},
|
|
// Percent of the allowance that raises the first traffic alert;
|
|
// 0 disables traffic alerts.
|
|
{"notify_traffic", "流量提醒", 0, 100, 80},
|
|
// Days ahead an expiry is listed; 0 disables both expiry
|
|
// reminders and renewal notices.
|
|
{"notify_expiry", "到期提醒", 0, 365, 7},
|
|
}
|
|
|
|
const (
|
|
// How often connectivity and traffic are examined (the sweep
|
|
// job's cadence); an offline alert trails the grace period by at
|
|
// most this.
|
|
sweepInterval = 30 * time.Second
|
|
|
|
// A node that returned from an absence longer than the grace
|
|
// period within flapWindow of going away again is flapping, and
|
|
// that absence is reported only once it lasts flapGrace.
|
|
flapWindow = 3600 // seconds
|
|
flapGrace = 1800 // seconds
|
|
|
|
// Nodes listed in one alert before the rest are only counted.
|
|
listedMax = 20
|
|
|
|
// onlineWindow is how fresh an agent's last heartbeat must be
|
|
// for the sweep to call it online — the B3 public view's rule
|
|
// (LastSeen ≤150s against the 60s heartbeat).
|
|
onlineWindow = 150 // seconds
|
|
|
|
defaultBody = `{"event":"{{event}}","node":"{{node}}","title":"{{title}}","message":"{{message}}"}`
|
|
defaultText = "{{title}}\n{{message}}"
|
|
)
|
|
|
|
// setting reads one main-store settings value; ok=false when unset.
|
|
// The notify keys live in the same KV as every other panel setting
|
|
// (hub names kept), read fresh at each use so a panel edit applies
|
|
// to the next alert.
|
|
var settingFn = func(key string) (string, bool) {
|
|
var row model.Setting
|
|
if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil {
|
|
return "", false
|
|
}
|
|
return row.Value, true
|
|
}
|
|
|
|
// setSetting writes one main-store settings value (empty deletes the
|
|
// row, hub's "empty clears" shape).
|
|
var setSettingFn = func(key, value string) error {
|
|
db := database.GetDB()
|
|
if value == "" {
|
|
return db.Where("key = ?", key).Delete(&model.Setting{}).Error
|
|
}
|
|
var row model.Setting
|
|
err := db.Where("key = ?", key).First(&row).Error
|
|
if database.IsNotFound(err) {
|
|
return db.Create(&model.Setting{Key: key, Value: value}).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row.Value = value
|
|
return db.Save(&row).Error
|
|
}
|
|
|
|
// number reads a numeric notify setting the hub way: parse, range
|
|
// check, default on anything else.
|
|
func number(key string) int64 {
|
|
for _, n := range numbersTable {
|
|
if n.key == key {
|
|
if v, ok := settingFn(key); ok {
|
|
if parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && parsed >= n.min && parsed <= n.max {
|
|
return parsed
|
|
}
|
|
}
|
|
return n.def
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// templateSetting is a template setting, where empty means default.
|
|
func templateSetting(key, def string) string {
|
|
if v, ok := settingFn(key); ok && v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// loginEnabled reports the notify_login switch (default on, hub).
|
|
func loginEnabled() bool {
|
|
v, _ := settingFn("notify_login")
|
|
return v != "off"
|
|
}
|
|
|
|
// siteName is the {{site}} placeholder's value: the public page's
|
|
// site_name setting, "Monitor" by default (hub site_name()).
|
|
func siteName() string {
|
|
if v, ok := settingFn("site_name"); ok && v != "" {
|
|
return v
|
|
}
|
|
return "Monitor"
|
|
}
|
|
|
|
// SettingError reports why a notification setting value cannot be
|
|
// stored, or "" when it can — the hub's setting_error for the notify_*
|
|
// keys, minus the Telegram token/chat pair (the panel bot owns those
|
|
// credentials; see the package comment) and with the same texts.
|
|
func SettingError(key, value string) string {
|
|
for _, n := range numbersTable {
|
|
if n.key == key {
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || parsed < n.min || parsed > n.max {
|
|
return fmt.Sprintf("%s要填 %d 到 %d 之间的整数", n.label, n.min, n.max)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
switch key {
|
|
case "notify_login":
|
|
if value != "on" && value != "off" {
|
|
return "登录提醒只能是 on 或 off"
|
|
}
|
|
case "notify_webhook_headers":
|
|
_, err := parseHeaders(value)
|
|
return err
|
|
case "notify_telegram_text", "notify_webhook_url", "notify_webhook_body":
|
|
// Empty clears the channel field, or restores a template's
|
|
// default.
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
switch key {
|
|
case "notify_telegram_text":
|
|
// Any text goes; placeholders substitute at send time.
|
|
case "notify_webhook_url":
|
|
if !validWebhookURL(value) {
|
|
return "Webhook URL 必须以 http:// 或 https:// 开头"
|
|
}
|
|
case "notify_webhook_body":
|
|
var v any
|
|
if err := json.Unmarshal([]byte(render(value, sampleNote(), "s\"i\\te", true)), &v); err != nil {
|
|
return "Webhook 请求体填入内容后必须是合法的 JSON,占位符要放在引号里"
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Sprintf("没有这个设置项:%s", key)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validWebhookURL(value string) bool {
|
|
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
|
|
}
|
|
|
|
// sampleNote carries values that exercise every escape the body
|
|
// template must survive (hub sample()).
|
|
func sampleNote() Note {
|
|
return Note{
|
|
Event: "test",
|
|
Node: "a\"b\\c",
|
|
Title: "{{message}}",
|
|
Message: "line\nline",
|
|
}
|
|
}
|
|
|
|
// parseHeaders parses "Name: value", one per line. Values are never
|
|
// echoed in an error: they are typically the credential.
|
|
func parseHeaders(text string) ([][2]string, string) {
|
|
var out [][2]string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
name, value, found := strings.Cut(line, ":")
|
|
if !found {
|
|
return nil, "Webhook 请求头每行要写成「名称: 值」"
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
value = strings.TrimSpace(value)
|
|
if !validHeaderName(name) {
|
|
return nil, fmt.Sprintf("Webhook 请求头名称 %q 不合法", name)
|
|
}
|
|
if strings.ContainsAny(value, "\r\n") {
|
|
return nil, fmt.Sprintf("Webhook 请求头 %s 的值不合法", name)
|
|
}
|
|
out = append(out, [2]string{name, value})
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// validHeaderName is RFC 7230 token.
|
|
func validHeaderName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
for _, r := range name {
|
|
if r <= ' ' || r >= 0x7f || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// render substitutes the placeholders in a single pass, JSON-escaping
|
|
// each value when jsonEsc is set so it is valid inside a string
|
|
// literal. A second pass would also substitute placeholders contained
|
|
// in the inserted values, such as a node name (hub render()).
|
|
func render(tmpl string, note Note, site string, jsonEsc bool) string {
|
|
fields := []struct{ key, value string }{
|
|
{"{{event}}", note.Event},
|
|
{"{{node}}", note.Node},
|
|
{"{{title}}", note.Title},
|
|
{"{{message}}", note.Message},
|
|
{"{{site}}", site},
|
|
{"{{time}}", clock(note.Time)},
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(tmpl))
|
|
rest := tmpl
|
|
for {
|
|
at := strings.Index(rest, "{{")
|
|
if at < 0 {
|
|
out.WriteString(rest)
|
|
return out.String()
|
|
}
|
|
out.WriteString(rest[:at])
|
|
rest = rest[at:]
|
|
matched := false
|
|
for _, f := range fields {
|
|
if strings.HasPrefix(rest, f.key) {
|
|
if jsonEsc {
|
|
quoted, _ := json.Marshal(f.value)
|
|
out.Write(quoted[1 : len(quoted)-1])
|
|
} else {
|
|
out.WriteString(f.value)
|
|
}
|
|
rest = rest[len(f.key):]
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
out.WriteString("{{")
|
|
rest = rest[2:]
|
|
}
|
|
}
|
|
}
|
|
|
|
// batch builds one alert for any number of nodes: a list in the
|
|
// message, the names in Note.Node. ok is false when there is nothing
|
|
// to say (hub batch()).
|
|
func batch(event, mark, what string, items []batchItem) (Note, bool) {
|
|
if len(items) == 0 {
|
|
return Note{}, false
|
|
}
|
|
var title, message string
|
|
if len(items) == 1 {
|
|
title = fmt.Sprintf("%s %s %s", mark, items[0].name, what)
|
|
message = items[0].detail
|
|
} else {
|
|
lines := make([]string, 0, listedMax+1)
|
|
for i, it := range items {
|
|
if i >= listedMax {
|
|
break
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s · %s", it.name, it.detail))
|
|
}
|
|
if len(items) > listedMax {
|
|
lines = append(lines, fmt.Sprintf("……另外 %d 台", len(items)-listedMax))
|
|
}
|
|
title = fmt.Sprintf("%s %d 台节点%s", mark, len(items), what)
|
|
message = strings.Join(lines, "\n")
|
|
}
|
|
names := make([]string, len(items))
|
|
for i, it := range items {
|
|
names[i] = it.name
|
|
}
|
|
return Note{Event: event, Node: strings.Join(names, ", "), Title: title, Message: message}, true
|
|
}
|
|
|
|
type batchItem struct {
|
|
name, detail string
|
|
}
|
|
|
|
// span renders a duration the hub way ("3 分钟" / "2 小时 5 分钟" /
|
|
// "1 天 2 小时").
|
|
func span(seconds int64) string {
|
|
if seconds < 0 {
|
|
seconds = 0
|
|
}
|
|
minutes := seconds / 60
|
|
d, h, m := minutes/1440, minutes/60%24, minutes%60
|
|
switch {
|
|
case d == 0 && h == 0:
|
|
return fmt.Sprintf("%d 分钟", m)
|
|
case d == 0:
|
|
return fmt.Sprintf("%d 小时 %d 分钟", h, m)
|
|
default:
|
|
return fmt.Sprintf("%d 天 %d 小时", d, h)
|
|
}
|
|
}
|
|
|
|
// clock formats a unix stamp in local time with its offset: a master
|
|
// in a container commonly runs on UTC while its operator reads local
|
|
// time (hub clock()).
|
|
func clock(ts int64) string {
|
|
if ts == 0 {
|
|
return ""
|
|
}
|
|
return time.Unix(ts, 0).Format("01-02 15:04 -07:00")
|
|
}
|
|
|
|
// gib renders bytes as GiB, labelled GB as in the panel (hub gib()).
|
|
func gib(bytes int64) string {
|
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(int64(1)<<30))
|
|
}
|