feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回 - agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编 - 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值 - 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知 - API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
+83
-44
@@ -9,16 +9,29 @@ const (
|
||||
EventIPFrequentAccess = "ip_frequent_access"
|
||||
EventLoginFailure = "login_failure"
|
||||
EventNginxReloadFailure = "nginx_reload_failure"
|
||||
EventResourceThreshold = "resource_threshold"
|
||||
EventTunnelDown = "tunnel_down"
|
||||
|
||||
MaskedSecret = "********"
|
||||
|
||||
DefaultBarkServer = "https://api.day.app"
|
||||
DefaultIPFrequentThreshold = 100
|
||||
DefaultIPFrequentWindowSec = 60
|
||||
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
|
||||
DefaultLoginFailureThreshold = 5
|
||||
DefaultLoginFailureWindowSec = 300
|
||||
DefaultBarkServer = "https://api.day.app"
|
||||
DefaultNtfyServer = "https://ntfy.sh"
|
||||
DefaultWecomServer = "https://qyapi.weixin.qq.com"
|
||||
DefaultDingtalkServer = "https://oapi.dingtalk.com"
|
||||
DefaultFeishuServer = "https://open.feishu.cn"
|
||||
DefaultIPFrequentThreshold = 100
|
||||
DefaultIPFrequentWindowSec = 60
|
||||
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
|
||||
DefaultLoginFailureThreshold = 5
|
||||
DefaultLoginFailureWindowSec = 300
|
||||
DefaultLoginFailureAlertCooldown = 15 * 60 // seconds
|
||||
DefaultCPUThreshold = 90
|
||||
DefaultMemoryThreshold = 90
|
||||
DefaultDiskThreshold = 85
|
||||
DefaultTunnelFailThreshold = 3
|
||||
// DefaultResourceAlertCooldown 是资源类告警的静默期:持续高占用时每半小时提醒一次,
|
||||
// 期间恢复正常会清掉冷却,再次超标可立即再报。
|
||||
DefaultResourceAlertCooldown = 30 * 60 // seconds
|
||||
)
|
||||
|
||||
type NotifyType string
|
||||
@@ -32,12 +45,26 @@ const (
|
||||
type WebhookProvider string
|
||||
|
||||
const (
|
||||
WebhookBark WebhookProvider = "bark"
|
||||
WebhookNtfy WebhookProvider = "ntfy"
|
||||
WebhookGotify WebhookProvider = "gotify"
|
||||
WebhookCustom WebhookProvider = "custom"
|
||||
WebhookBark WebhookProvider = "bark"
|
||||
WebhookNtfy WebhookProvider = "ntfy"
|
||||
WebhookGotify WebhookProvider = "gotify"
|
||||
WebhookWecom WebhookProvider = "wecom"
|
||||
WebhookDingtalk WebhookProvider = "dingtalk"
|
||||
WebhookFeishu WebhookProvider = "feishu"
|
||||
WebhookCustom WebhookProvider = "custom"
|
||||
)
|
||||
|
||||
// providerUsesKey 表示该预设把凭据放在 Webhook.Key(而不是 WebhookSecret)里。
|
||||
// 保存时的「已配置」判定与密钥落库都按这份名单走,新增预设时只需改这一处。
|
||||
func providerUsesKey(provider WebhookProvider) bool {
|
||||
switch provider {
|
||||
case WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -64,22 +91,28 @@ type TelegramConfig struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Type NotifyType `json:"type"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
Type NotifyType `json:"type"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
OnDDNSIPChange bool `json:"on_ddns_ip_change"`
|
||||
OnDDNSFailure bool `json:"on_ddns_failure"`
|
||||
OnCertExpiry bool `json:"on_cert_expiry"`
|
||||
OnCertRenewSuccess bool `json:"on_cert_renew_success"`
|
||||
OnCertRenewFailure bool `json:"on_cert_renew_failure"`
|
||||
OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
|
||||
OnLoginFailure bool `json:"on_login_failure"`
|
||||
OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
|
||||
IPFrequentThreshold int `json:"ip_frequent_threshold"`
|
||||
IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
|
||||
LoginFailureThreshold int `json:"login_failure_threshold"`
|
||||
LoginFailureWindowSec int `json:"login_failure_window_sec"`
|
||||
OnDDNSIPChange bool `json:"on_ddns_ip_change"`
|
||||
OnDDNSFailure bool `json:"on_ddns_failure"`
|
||||
OnCertExpiry bool `json:"on_cert_expiry"`
|
||||
OnCertRenewSuccess bool `json:"on_cert_renew_success"`
|
||||
OnCertRenewFailure bool `json:"on_cert_renew_failure"`
|
||||
OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
|
||||
OnLoginFailure bool `json:"on_login_failure"`
|
||||
OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
|
||||
IPFrequentThreshold int `json:"ip_frequent_threshold"`
|
||||
IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
|
||||
LoginFailureThreshold int `json:"login_failure_threshold"`
|
||||
LoginFailureWindowSec int `json:"login_failure_window_sec"`
|
||||
OnResourceThreshold bool `json:"on_resource_threshold"`
|
||||
OnTunnelDown bool `json:"on_tunnel_down"`
|
||||
CPUThreshold int `json:"cpu_threshold"`
|
||||
MemoryThreshold int `json:"memory_threshold"`
|
||||
DiskThreshold int `json:"disk_threshold"`
|
||||
TunnelFailThreshold int `json:"tunnel_fail_threshold"`
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
@@ -90,25 +123,31 @@ type RuntimeConfig struct {
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Type NotifyType
|
||||
Email EmailConfig
|
||||
SMTPPassword string
|
||||
Webhook WebhookConfig
|
||||
WebhookSecret string
|
||||
Telegram TelegramConfig
|
||||
TelegramToken string
|
||||
OnDDNSIPChange bool
|
||||
OnDDNSFailure bool
|
||||
OnCertExpiry bool
|
||||
OnCertRenewSuccess bool
|
||||
OnCertRenewFailure bool
|
||||
OnIPFrequentAccess bool
|
||||
OnLoginFailure bool
|
||||
OnNginxReloadFailure bool
|
||||
IPFrequentThreshold int
|
||||
IPFrequentWindowSec int
|
||||
LoginFailureThreshold int
|
||||
LoginFailureWindowSec int
|
||||
Type NotifyType
|
||||
Email EmailConfig
|
||||
SMTPPassword string
|
||||
Webhook WebhookConfig
|
||||
WebhookSecret string
|
||||
Telegram TelegramConfig
|
||||
TelegramToken string
|
||||
OnDDNSIPChange bool
|
||||
OnDDNSFailure bool
|
||||
OnCertExpiry bool
|
||||
OnCertRenewSuccess bool
|
||||
OnCertRenewFailure bool
|
||||
OnIPFrequentAccess bool
|
||||
OnLoginFailure bool
|
||||
OnNginxReloadFailure bool
|
||||
IPFrequentThreshold int
|
||||
IPFrequentWindowSec int
|
||||
LoginFailureThreshold int
|
||||
LoginFailureWindowSec int
|
||||
OnResourceThreshold bool
|
||||
OnTunnelDown bool
|
||||
CPUThreshold int
|
||||
MemoryThreshold int
|
||||
DiskThreshold int
|
||||
TunnelFailThreshold int
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
|
||||
+75
-10
@@ -20,6 +20,12 @@ func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, co
|
||||
return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookGotify:
|
||||
return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookWecom:
|
||||
return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookDingtalk:
|
||||
return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookFeishu:
|
||||
return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookCustom:
|
||||
return sendCustomWebhook(ctx, client, cfg, content)
|
||||
default:
|
||||
@@ -58,7 +64,7 @@ func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = "https://ntfy.sh"
|
||||
server = DefaultNtfyServer
|
||||
}
|
||||
endpoint := joinURL(server, topic)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
|
||||
@@ -83,18 +89,69 @@ func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, tit
|
||||
return fmt.Errorf("Gotify 服务地址未配置")
|
||||
}
|
||||
endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
|
||||
payload := map[string]any{
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"priority": 5,
|
||||
})
|
||||
}
|
||||
|
||||
// 企业微信群机器人:POST /cgi-bin/webhook/send?key=<key>,markdown 正文
|
||||
func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("企业微信机器人 Key 未配置")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultWecomServer
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return doRequest(client, req)
|
||||
endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"content": markdownBody(title, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 钉钉群机器人:POST /robot/send?access_token=<key>。
|
||||
// 正文固定带 “Fonu · ” 前缀,机器人用「自定义关键词 = Fonu」的安全设置即可命中。
|
||||
func sendDingtalk(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("钉钉机器人 Access Token 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultDingtalkServer
|
||||
}
|
||||
titled := "Fonu · " + title
|
||||
endpoint := joinURL(server, "robot", "send") + "?access_token=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"title": titled, "text": markdownBody(titled, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 飞书自定义机器人:POST /open-apis/bot/v2/hook/<key>,纯文本消息
|
||||
func sendFeishu(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("飞书机器人 Key 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultFeishuServer
|
||||
}
|
||||
endpoint := joinURL(server, "open-apis", "bot", "v2", "hook", key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msg_type": "text",
|
||||
"content": map[string]string{"text": title + "\n" + message},
|
||||
})
|
||||
}
|
||||
|
||||
// markdownBody 拼出国内 IM 机器人都能渲染的 markdown(企业微信 / 钉钉都认 ## 标题 + 正文)
|
||||
func markdownBody(title, message string) string {
|
||||
return "## " + title + "\n" + message
|
||||
}
|
||||
|
||||
func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
|
||||
@@ -102,8 +159,16 @@ func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConf
|
||||
if target == "" {
|
||||
return fmt.Errorf("Webhook URL 未配置")
|
||||
}
|
||||
body, _ := json.Marshal(content.webhookPayload())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
|
||||
return postJSON(ctx, client, target, content.webhookPayload())
|
||||
}
|
||||
|
||||
// postJSON 发一个 JSON POST:各国内 IM 机器人都是这个形状,只是 URL 与 payload 不同。
|
||||
func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,3 +71,93 @@ func TestJoinURL(t *testing.T) {
|
||||
t.Fatalf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 国内 IM 机器人:固定路径 + 固定 payload 形状,这里把三家的端点与请求体锁住
|
||||
func TestSendWecomUsesKeyAndMarkdown(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookWecom, Server: server.URL, Key: "abc"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send wecom: %v", err)
|
||||
}
|
||||
if path != "/cgi-bin/webhook/send" || query != "key=abc" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
if !strings.Contains(body, `"msgtype":"markdown"`) || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDingtalkUsesAccessTokenAndKeyword(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookDingtalk, Server: server.URL, Key: "tok"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send dingtalk: %v", err)
|
||||
}
|
||||
if path != "/robot/send" || query != "access_token=tok" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
// 「自定义关键词」安全设置靠正文里的 Fonu 命中(PushTitle 前面还有事件图标)
|
||||
if !strings.Contains(body, "Fonu · ") || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("missing keyword prefix: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFeishuUsesHookPath(t *testing.T) {
|
||||
var path, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookFeishu, Server: server.URL, Key: "hookkey"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send feishu: %v", err)
|
||||
}
|
||||
if path != "/open-apis/bot/v2/hook/hookkey" {
|
||||
t.Fatalf("unexpected path: %s", path)
|
||||
}
|
||||
if !strings.Contains(body, `"msg_type":"text"`) {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesKeyCoversKeyBasedPresets(t *testing.T) {
|
||||
for _, provider := range []WebhookProvider{WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu} {
|
||||
if !providerUsesKey(provider) {
|
||||
t.Fatalf("%s 的凭据存在 Webhook.Key,密钥落库路径必须覆盖它", provider)
|
||||
}
|
||||
}
|
||||
if providerUsesKey(WebhookCustom) {
|
||||
t.Fatal("自定义 Webhook 的凭据来自 URL,不应走 Key 分支")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,10 @@ func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
|
||||
return cfg.OnLoginFailure
|
||||
case EventNginxReloadFailure:
|
||||
return cfg.OnNginxReloadFailure
|
||||
case EventResourceThreshold:
|
||||
return cfg.OnResourceThreshold
|
||||
case EventTunnelDown:
|
||||
return cfg.OnTunnelDown
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
|
||||
cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
cfg.CPUThreshold = intSetting(s.settings, ctx, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
cfg.MemoryThreshold = intSetting(s.settings, ctx, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
cfg.DiskThreshold = intSetting(s.settings, ctx, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
cfg.TunnelFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
|
||||
if hasNotifyEventSettings(ctx, s.settings) {
|
||||
cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
|
||||
@@ -86,6 +90,9 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
|
||||
cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
|
||||
cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
|
||||
// 资源与隧道告警默认关闭:升级后不改变现有用户收到的通知
|
||||
cfg.OnResourceThreshold = flag(s.settings, ctx, settings.KeyNotifyOnResourceThreshold, false)
|
||||
cfg.OnTunnelDown = flag(s.settings, ctx, settings.KeyNotifyOnTunnelDown, false)
|
||||
} else {
|
||||
legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
|
||||
legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
|
||||
@@ -184,6 +191,8 @@ func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
|
||||
settings.KeyNotifyOnCertRenewFailure,
|
||||
settings.KeyNotifyOnLoginFailure,
|
||||
settings.KeyNotifyOnNginxReloadFailure,
|
||||
settings.KeyNotifyOnResourceThreshold,
|
||||
settings.KeyNotifyOnTunnelDown,
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, err := store.Get(ctx, key)
|
||||
@@ -226,6 +235,24 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnResourceThreshold, in.OnResourceThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnTunnelDown, in.OnTunnelDown); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyCPUThreshold, clampThreshold(in.CPUThreshold, DefaultCPUThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyMemoryThreshold, clampThreshold(in.MemoryThreshold, DefaultMemoryThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyDiskThreshold, clampThreshold(in.DiskThreshold, DefaultDiskThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyTunnelFailThreshold, clampThreshold(in.TunnelFailThreshold, DefaultTunnelFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
threshold := in.IPFrequentThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultIPFrequentThreshold
|
||||
@@ -269,7 +296,7 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
secretValue := strings.TrimSpace(in.WebhookSecret)
|
||||
if secretValue != "" && secretValue != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
} else if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
|
||||
} else if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
}
|
||||
@@ -306,11 +333,7 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
|
||||
webhookPlain := secretValue
|
||||
if webhookPlain == "" || webhookPlain == MaskedSecret {
|
||||
if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhookPlain = strings.TrimSpace(webhook.Key)
|
||||
}
|
||||
} else if webhook.Provider == WebhookNtfy {
|
||||
if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhookPlain = strings.TrimSpace(webhook.Key)
|
||||
}
|
||||
@@ -376,6 +399,18 @@ func validateSaveInput(in SaveInput) error {
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写 Gotify App Token")
|
||||
}
|
||||
case WebhookWecom:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写企业微信机器人 Key")
|
||||
}
|
||||
case WebhookDingtalk:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写钉钉机器人 Access Token")
|
||||
}
|
||||
case WebhookFeishu:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写飞书机器人 Key")
|
||||
}
|
||||
case WebhookCustom:
|
||||
if strings.TrimSpace(in.Webhook.URL) == "" {
|
||||
return fmt.Errorf("请填写 Webhook URL")
|
||||
@@ -452,9 +487,27 @@ func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
||||
in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
|
||||
in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
in.OnResourceThreshold = values[settings.KeyNotifyOnResourceThreshold] == "1" || strings.EqualFold(values[settings.KeyNotifyOnResourceThreshold], "true")
|
||||
in.OnTunnelDown = values[settings.KeyNotifyOnTunnelDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnTunnelDown], "true")
|
||||
in.CPUThreshold = intSettingFromMap(values, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
in.MemoryThreshold = intSettingFromMap(values, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
in.DiskThreshold = intSettingFromMap(values, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
in.TunnelFailThreshold = intSettingFromMap(values, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
return in, true
|
||||
}
|
||||
|
||||
// clampThreshold 把未配置或越界的阈值拉回合法范围:0/负数用默认值,超过 100 的按 100 处理
|
||||
// (百分比阈值与「连续失败次数」的上限都用这一条)。
|
||||
func clampThreshold(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if value > 100 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func intSettingFromMap(values map[string]string, key string, fallback int) int {
|
||||
raw := strings.TrimSpace(values[key])
|
||||
if raw == "" {
|
||||
|
||||
Reference in New Issue
Block a user