feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
CI / verify (push) Canceled after 0s

- 配置版本: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:
2026-09-24 20:50:17 +08:00
parent 8f75e99339
commit 862442e77f
42 changed files with 3034 additions and 190 deletions
+8 -6
View File
@@ -40,7 +40,8 @@ import (
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
const AgentVersion = "0.12.1"
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
const AgentVersion = "0.12.2"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
@@ -167,6 +168,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes))
mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
mux.HandleFunc("GET /api/v1/routes/{domain}/versions", s.auth(s.routeVersions))
mux.HandleFunc("GET /api/v1/routes/{domain}/versions/{name}", s.auth(s.routeVersion))
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute))
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
@@ -862,6 +866,8 @@ func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
// 版本历史跟着 vhost 一起清掉,避免同域名重新部署后看到上一轮部署的旧版本
_ = os.RemoveAll(fsutil.VersionsDir(path))
if err := s.reloadNginx(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
@@ -1086,13 +1092,9 @@ func (s *Server) writeRoute(req routeRequest) error {
if err := s.applyLimitState(req); err != nil {
return err
}
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
if err := s.applyRouteContent(path, []byte(content)); err != nil {
return err
}
if err := s.validateNginx(); err != nil {
_ = os.Remove(path)
return fmt.Errorf("nginx config rejected: %w", err)
}
return s.reloadNginx()
}
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/fonu/fonu/internal/fsutil"
)
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
const maxRouteVersions = 10
type routeVersionInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// routeVersions 返回某域名的当前配置与历史版本,供应用侧把「漂移」与「历史」放在一起看。
func (s *Server) routeVersions(w http.ResponseWriter, r *http.Request) {
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
if !validDomain(domain) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
path := s.routePath(domain)
content, err := os.ReadFile(path)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
return
}
versions, err := fsutil.ListVersions(path)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"domain": domain,
"content": string(content),
"versions": mapRouteVersions(versions),
})
}
// routeVersion 返回某个历史版本的内容(前端拿它与当前配置做差异对比)。
func (s *Server) routeVersion(w http.ResponseWriter, r *http.Request) {
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
if !validDomain(domain) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
return
}
name := r.PathValue("name")
s.mu.Lock()
defer s.mu.Unlock()
content, err := fsutil.ReadVersion(s.routePath(domain), name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "name": name, "content": string(content)})
}
// rollbackRoute 把某域名的 vhost 回退到指定版本。
func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
if !validDomain(domain) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
return
}
name := r.PathValue("name")
s.mu.Lock()
defer s.mu.Unlock()
path := s.routePath(domain)
content, err := fsutil.ReadVersion(path, name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
if err := s.applyRouteContent(path, content); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
if err := s.reloadNginx(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain, "name": name})
}
// applyRouteContent 写入 vhost 内容并做 nginx -t 校验。
//
// 写之前先把当前内容留成一个版本;校验失败时恢复回上一个可用版本(此前没有文件才删除)。
// 旧行为是校验失败直接删文件——那等于把已经在跑的站点从磁盘上抹掉,只能靠重新部署救回来。
func (s *Server) applyRouteContent(path string, content []byte) error {
previous, readErr := os.ReadFile(path)
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
return err
}
if err := atomicWrite(path, content, 0o640); err != nil {
return err
}
if err := s.validateNginx(); err != nil {
if readErr == nil {
_ = atomicWrite(path, previous, 0o640)
} else {
_ = os.Remove(path)
}
return fmt.Errorf("nginx config rejected: %w", err)
}
return nil
}
func mapRouteVersions(entries []fsutil.VersionEntry) []routeVersionInfo {
out := make([]routeVersionInfo, 0, len(entries))
for _, entry := range entries {
out = append(out, routeVersionInfo{Name: entry.Name, Size: entry.Size, CreatedAt: entry.CreatedAt})
}
return out
}
+82 -15
View File
@@ -398,38 +398,105 @@ func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, details)
}
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
// agentRouteTarget 解析服务端与规则归属,返回该规则用于公网反代的首个域名。
// status 非 0 表示校验失败,调用方直接把 status 与 errMsg 写回响应。
func (h *FRPMultiHandler) agentRouteTarget(r *http.Request, serverIDRaw, proxyIDRaw string) (serverID, proxyID int64, domain string, status int, errMsg string) {
serverID, err := parseID(serverIDRaw)
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
return 0, 0, "", http.StatusBadRequest, "FRP 服务端 ID 无效"
}
proxyID, err := parseID(r.PathValue("proxyID"))
proxyID, err = parseID(proxyIDRaw)
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
return
return 0, 0, "", http.StatusBadRequest, "FRP 规则 ID 无效"
}
proxy, err := h.svc.GetProxy(r.Context(), proxyID)
if err != nil || proxy.ServerID != id {
writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
return
if err != nil || proxy.ServerID != serverID {
return 0, 0, "", http.StatusNotFound, "FRP 规则不存在"
}
if len(proxy.CustomDomains) == 0 {
writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
return 0, 0, "", http.StatusBadRequest, "该规则没有自定义域名"
}
return serverID, proxyID, proxy.CustomDomains[0], 0, ""
}
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
serverID, proxyID, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
if status != 0 {
writeError(r, w, status, msg)
return
}
client, ok := h.svc.AgentClient(r.Context(), id)
client, ok := h.svc.AgentClient(r.Context(), serverID)
if !ok {
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
return
}
content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
content, err := client.RouteConf(r.Context(), domain)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": domain, "content": content})
}
// AgentRouteVersions 返回规则首个域名在 VPS 上的当前 vhost 与历史版本,供差异对比与回滚。
func (h *FRPMultiHandler) AgentRouteVersions(w http.ResponseWriter, r *http.Request) {
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
if status != 0 {
writeError(r, w, status, msg)
return
}
client, ok := h.svc.AgentClient(r.Context(), serverID)
if !ok {
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
return
}
result, err := client.RouteVersions(r.Context(), domain)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// AgentRouteVersion 读取某个历史版本的内容(与当前 vhost 对比用)。
func (h *FRPMultiHandler) AgentRouteVersion(w http.ResponseWriter, r *http.Request) {
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
if status != 0 {
writeError(r, w, status, msg)
return
}
client, ok := h.svc.AgentClient(r.Context(), serverID)
if !ok {
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
return
}
result, err := client.RouteVersion(r.Context(), domain, r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// AgentRollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本(agent 侧校验不过会保持当前配置)。
func (h *FRPMultiHandler) AgentRollbackRouteVersion(w http.ResponseWriter, r *http.Request) {
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
if status != 0 {
writeError(r, w, status, msg)
return
}
client, ok := h.svc.AgentClient(r.Context(), serverID)
if !ok {
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
return
}
result, err := client.RollbackRouteVersion(r.Context(), domain, r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+40
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"strings"
"github.com/fonu/fonu/internal/auth"
)
@@ -24,9 +25,30 @@ func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
}
}
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
return
}
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
if tokenDeniedPath(r.URL.Path) {
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
return
}
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
return
}
next.ServeHTTP(w, r)
return
}
sessionID, _ := r.Context().Value(sessionContextKey).(string)
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
@@ -37,6 +59,24 @@ func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
}
}
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
const prefix = "Bearer "
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
func tokenDeniedPath(path string) bool {
if path == "/api/auth/password" || path == "/api/auth/logout" {
return true
}
return strings.HasPrefix(path, "/api/settings/tokens")
}
func sessionIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(sessionContextKey).(string)
return v
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/db"
)
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := auth.New(conn)
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
if err != nil {
t.Fatalf("create token: %v", err)
}
return svc, created.Token
}
func okTokenHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
}
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
}
}
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
}
}
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
cases := map[string]string{
"未知令牌": "Bearer deadbeef",
"错误前缀": "Token abc",
"没有凭据": "",
}
for name, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
}
}
}
func TestBearerTokenParsing(t *testing.T) {
cases := map[string]string{
"Bearer abc": "abc",
"bearer abc": "abc",
"Bearer abc": "abc",
"Basic abc": "",
"Bearer ": "",
"": "",
}
for header, want := range cases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
if got := bearerToken(req); got != want {
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
}
}
}
+27
View File
@@ -123,6 +123,33 @@ func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersions(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.GetNginxConfigVersions()
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.ReadNginxConfigVersion(r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.RollbackNginxConfigVersion(r.Context(), r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
if err == nil {
return
+9
View File
@@ -41,6 +41,9 @@ func NewRouter(deps Deps) http.Handler {
}
protect("POST /api/auth/password", r.authHandler.ChangePassword)
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
protect("POST /api/settings/tokens", r.authHandler.CreateToken)
protect("DELETE /api/settings/tokens/{id}", r.authHandler.DeleteToken)
protect("GET /api/status", r.statusHandler.Get)
protect("GET /api/proxies", r.proxyHandler.List)
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
@@ -56,6 +59,9 @@ func NewRouter(deps Deps) http.Handler {
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
protect("GET /api/settings/nginx/config/versions", r.nginxHandler.GetConfigVersions)
protect("GET /api/settings/nginx/config/versions/{name}", r.nginxHandler.GetConfigVersion)
protect("POST /api/settings/nginx/config/versions/{name}/rollback", r.nginxHandler.RollbackConfigVersion)
protect("GET /api/ddns", r.ddnsHandler.List)
protect("POST /api/ddns", r.ddnsHandler.Create)
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
@@ -125,6 +131,9 @@ func NewRouter(deps Deps) http.Handler {
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}/rollback", frpHandler.AgentRollbackRouteVersion)
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
+37 -25
View File
@@ -39,25 +39,25 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
return
}
allowed := map[string]bool{
settings.KeyTheme: true,
settings.KeyTimezone: true,
settings.KeyLogRetentionDays: true,
settings.KeyDDNSCheckInterval: true,
settings.KeyCertRenewThreshold: true,
settings.KeyRootDomain: true,
settings.KeyACMEEmail: true,
settings.KeyACMECA: true,
settings.KeyZeroSSLAPIKey: true,
settings.KeyNotifyWebhookURL: true,
settings.KeyNotifyType: true,
settings.KeyNotifyEmailJSON: true,
settings.KeyNotifySMTPPassword: true,
settings.KeyNotifyWebhookJSON: true,
settings.KeyNotifyWebhookSecret: true,
settings.KeyNotifyTelegramJSON: true,
settings.KeyNotifyTelegramToken: true,
settings.KeyNotifyOnDDNSIPChange: true,
settings.KeyNotifyOnDDNSFailure: true,
settings.KeyTheme: true,
settings.KeyTimezone: true,
settings.KeyLogRetentionDays: true,
settings.KeyDDNSCheckInterval: true,
settings.KeyCertRenewThreshold: true,
settings.KeyRootDomain: true,
settings.KeyACMEEmail: true,
settings.KeyACMECA: true,
settings.KeyZeroSSLAPIKey: true,
settings.KeyNotifyWebhookURL: true,
settings.KeyNotifyType: true,
settings.KeyNotifyEmailJSON: true,
settings.KeyNotifySMTPPassword: true,
settings.KeyNotifyWebhookJSON: true,
settings.KeyNotifyWebhookSecret: true,
settings.KeyNotifyTelegramJSON: true,
settings.KeyNotifyTelegramToken: true,
settings.KeyNotifyOnDDNSIPChange: true,
settings.KeyNotifyOnDDNSFailure: true,
settings.KeyNotifyOnCertExpiry: true,
settings.KeyNotifyOnCertRenewSuccess: true,
settings.KeyNotifyOnIPFrequentAccess: true,
@@ -68,12 +68,18 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
settings.KeyNotifyIPFrequentWindowSec: true,
settings.KeyNotifyLoginFailureThreshold: true,
settings.KeyNotifyLoginFailureWindowSec: true,
settings.KeyTrustedProxy: true,
settings.KeyGlobalIPBlacklist: true,
settings.KeyGlobalIPWhitelist: true,
settings.KeyChinaCIDRUpdateHours: true,
settings.KeyChinaCIDRSourceV4: true,
settings.KeyChinaCIDRSourceV6: true,
settings.KeyNotifyOnResourceThreshold: true,
settings.KeyNotifyOnTunnelDown: true,
settings.KeyNotifyCPUThreshold: true,
settings.KeyNotifyMemoryThreshold: true,
settings.KeyNotifyDiskThreshold: true,
settings.KeyNotifyTunnelFailThreshold: true,
settings.KeyTrustedProxy: true,
settings.KeyGlobalIPBlacklist: true,
settings.KeyGlobalIPWhitelist: true,
settings.KeyChinaCIDRUpdateHours: true,
settings.KeyChinaCIDRSourceV4: true,
settings.KeyChinaCIDRSourceV6: true,
}
filtered := map[string]string{}
for key, value := range req {
@@ -139,6 +145,12 @@ func isNotifySettingKey(key string) bool {
settings.KeyNotifyIPFrequentWindowSec,
settings.KeyNotifyLoginFailureThreshold,
settings.KeyNotifyLoginFailureWindowSec,
settings.KeyNotifyOnResourceThreshold,
settings.KeyNotifyOnTunnelDown,
settings.KeyNotifyCPUThreshold,
settings.KeyNotifyMemoryThreshold,
settings.KeyNotifyDiskThreshold,
settings.KeyNotifyTunnelFailThreshold,
settings.KeyNotifyWebhookURL:
return true
default:
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/fonu/fonu/internal/auth"
)
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
const maxTokenExpiryDays = 3650
type createTokenRequest struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
func (h *AuthHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := h.auth.ListAPITokens(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
}
// CreateToken 新建令牌;明文只在这一个响应里返回,之后无法再取回。
func (h *AuthHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = auth.TokenScopeRead
}
var expiresAt time.Time
if req.ExpiresInDays > 0 {
if req.ExpiresInDays > maxTokenExpiryDays {
writeError(r, w, http.StatusBadRequest, "有效期最长 3650 天")
return
}
expiresAt = time.Now().AddDate(0, 0, req.ExpiresInDays)
}
token, err := h.auth.CreateAPIToken(r.Context(), req.Name, scope, expiresAt)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, token)
}
func (h *AuthHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的令牌 ID")
return
}
if err := h.auth.DeleteAPIToken(r.Context(), id); err != nil {
if err.Error() == "令牌不存在" {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusInternalServerError, "删除令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+26
View File
@@ -23,6 +23,7 @@ import (
"github.com/fonu/fonu/internal/discovery"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/logstore"
"github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/nginx"
"github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/proxy"
@@ -138,6 +139,26 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
if chinaHours <= 0 {
chinaHours = 24
}
// 资源与隧道巡检:间隔比其它任务短,「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, func(ctx context.Context) ([]monitor.RouteStatus, error) {
servers, err := frpMultiSvc.ListServers(ctx)
if err != nil {
return nil, err
}
routes := make([]monitor.RouteStatus, 0, len(servers))
for _, server := range servers {
// 单台服务端探测失败不影响其它服务端:它本轮跳过,等下一轮
health, err := frpMultiSvc.RouteHealth(ctx, server.ID)
if err != nil {
logger.Warn("巡检跳过服务端", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
continue
}
for _, item := range health {
routes = append(routes, monitor.RouteStatus{Domain: item.Domain, TunnelOK: item.TunnelOK, Detail: item.TunnelDetail})
}
}
return routes, nil
})
sched := scheduler.New(
scheduler.Job{
Name: "ddns",
@@ -154,6 +175,11 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Interval: time.Duration(chinaHours) * time.Hour,
Run: chinaCIDRSvc.Tick,
},
scheduler.Job{
Name: "monitor",
Interval: 2 * time.Minute,
Run: monitorChecker.Run,
},
)
sched.Start(ctx)
trafficCollector.Start(ctx)
+166
View File
@@ -0,0 +1,166 @@
package auth
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
//
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
const (
TokenScopeRead = "read"
TokenScopeWrite = "write"
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
// 同一分钟内重复使用不再写。
lastUsedWriteInterval = time.Minute
maxTokenNameLength = 64
)
type APIToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresAt string `json:"expires_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
type CreatedAPIToken struct {
APIToken
Token string `json:"token"`
}
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
FROM api_tokens ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
tokens := make([]APIToken, 0, 8)
for rows.Next() {
var token APIToken
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, rows.Err()
}
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
name = strings.TrimSpace(name)
if name == "" {
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
}
if len([]rune(name)) > maxTokenNameLength {
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
}
plain, err := randomToken(32)
if err != nil {
return CreatedAPIToken{}, err
}
expires := ""
if !expiresAt.IsZero() {
expires = expiresAt.UTC().Format(time.RFC3339)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
name, hashToken(plain), scope, expires)
if err != nil {
return CreatedAPIToken{}, err
}
id, err := res.LastInsertId()
if err != nil {
return CreatedAPIToken{}, err
}
token, err := s.apiTokenByID(ctx, id)
if err != nil {
return CreatedAPIToken{}, err
}
return CreatedAPIToken{APIToken: token, Token: plain}, nil
}
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("令牌不存在")
}
return nil
}
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", ErrUnauthorized
}
var (
id int64
scope string
expiresAt string
)
err := s.db.QueryRowContext(ctx,
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
Scan(&id, &scope, &expiresAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUnauthorized
}
return "", err
}
if expiresAt != "" {
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(expires) {
return "", ErrUnauthorized
}
}
now := time.Now().UTC()
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
// 回写失败不影响本次调用:last_used_at 只是审计信息
_, _ = s.db.ExecContext(ctx,
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
now.Format(time.RFC3339), id, cutoff)
return scope, nil
}
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
var token APIToken
err := s.db.QueryRowContext(ctx, `
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
FROM api_tokens WHERE id = ?`, id).
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
return token, err
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/db"
)
func newTestService(t *testing.T) *Service {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(conn)
}
func TestAPITokenLifecycle(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
t.Fatalf("unexpected created token: %+v", created)
}
// 库里只存 SHA-256:明文不落盘
var stored string
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
t.Fatalf("read hash: %v", err)
}
if stored == created.Token || len(stored) != 64 {
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
}
scope, err := svc.ValidateAPIToken(ctx, created.Token)
if err != nil || scope != TokenScopeRead {
t.Fatalf("validate: scope=%q err=%v", scope, err)
}
var lastUsed string
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
t.Fatalf("read last_used_at: %v", err)
}
if lastUsed == "" {
t.Fatal("校验通过后应回写 last_used_at")
}
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
t.Fatalf("未知令牌应拒绝,实际 %v", err)
}
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
t.Fatalf("删除后令牌应失效,实际 %v", err)
}
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
t.Fatal("重复删除应报错")
}
}
func TestAPITokenExpiry(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
t.Fatalf("过期令牌应拒绝,实际 %v", err)
}
}
func TestAPITokenRejectsBadInput(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
t.Fatal("空名称应报错")
}
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
t.Fatal("未知权限范围应报错")
}
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
t.Fatalf("空令牌应拒绝,实际 %v", err)
}
}
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
t.Fatalf("create first: %v", err)
}
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
t.Fatalf("create second: %v", err)
}
tokens, err := svc.ListAPITokens(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(tokens) != 2 || tokens[0].Name != "second" {
t.Fatalf("应按 id 倒序返回:%+v", tokens)
}
}
+40 -8
View File
@@ -80,6 +80,9 @@ func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
const agentMinVersionInsights = "0.11.0"
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
const agentMinVersionRouteVersions = "0.12.2"
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
@@ -93,11 +96,11 @@ func (e *statusError) Error() string {
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
}
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误,其它错误原样返回
func wrapEndpointMissing(err error) error {
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
func wrapEndpointMissing(err error, minVersion string) error {
var statusErr *statusError
if errors.As(err, &statusErr) && (statusErr.code == http.StatusNotFound || statusErr.code == http.StatusMethodNotAllowed) {
return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, agentMinVersionInsights)
return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, minVersion)
}
return err
}
@@ -106,7 +109,7 @@ func wrapEndpointMissing(err error) error {
func (c *AgentClient) Metrics(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/metrics", nil, &result); err != nil {
return nil, wrapEndpointMissing(err)
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
@@ -118,7 +121,7 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma
query.Set("lines", strconv.Itoa(lines))
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/logs/nginx?"+query.Encode(), nil, &result); err != nil {
return nil, wrapEndpointMissing(err)
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
@@ -127,7 +130,7 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma
func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/certs", nil, &result); err != nil {
return nil, wrapEndpointMissing(err)
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
@@ -136,7 +139,7 @@ func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/reload", nil, &result); err != nil {
return nil, wrapEndpointMissing(err)
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
@@ -145,7 +148,7 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/frps/config", nil, &result); err != nil {
return nil, wrapEndpointMissing(err)
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
@@ -258,3 +261,32 @@ func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, pr
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
}, nil)
}
// RouteVersions 返回域名当前 vhost 内容与历史版本列表(需 agent ≥ 0.12.2)。
func (c *AgentClient) RouteVersions(ctx context.Context, domain string) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/versions", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
// RouteVersion 读取某个历史版本的内容,供与当前 vhost 做差异对比。
func (c *AgentClient) RouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
var result map[string]any
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name)
if err := c.request(ctx, http.MethodGet, path, nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
// RollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本。
func (c *AgentClient) RollbackRouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
var result map[string]any
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name) + "/rollback"
if err := c.request(ctx, http.MethodPost, path, nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
Binary file not shown.
Binary file not shown.
+119
View File
@@ -5,8 +5,15 @@
package fsutil
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// WriteFileAtomic 先写同目录的临时文件再 rename:读者要么看到旧内容、要么看到新内容。
@@ -53,3 +60,115 @@ func Backup(path string) error {
}
return WriteFileAtomic(path+".bak", data, 0o600)
}
// VersionsDir 返回某文件的版本目录(<path>.versions),供列表 / 回滚读取。
func VersionsDir(path string) string {
return path + ".versions"
}
// BackupVersioned 把现有文件留存为 <path>.versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。
// 文件名前缀是 UTC 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。
// 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。
func BackupVersioned(path string, keep int) (string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
dir := VersionsDir(path)
// 内容与最新版本一致时不重复记录:Apply 每次保存都会调用,去重后保留期才对应「真实的 N 次变更」
if versions, err := ListVersions(path); err == nil && len(versions) > 0 {
if newest, err := ReadVersion(path, versions[0].Name); err == nil && bytes.Equal(newest, data) {
return filepath.Join(dir, versions[0].Name), nil
}
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := time.Now().UTC().Format("20060102T150405.000") + "-" + randSuffix() + filepath.Ext(path)
target := filepath.Join(dir, name)
if err := WriteFileAtomic(target, data, 0o600); err != nil {
return "", err
}
if keep > 0 {
pruneVersions(dir, keep)
}
return target, nil
}
// VersionEntry 描述一个已留存的版本(名称前缀是 UTC 时间戳,因此按名称倒序就是新旧顺序)。
type VersionEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。
func ListVersions(path string) ([]VersionEntry, error) {
entries, err := os.ReadDir(VersionsDir(path))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]VersionEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, VersionEntry{Name: entry.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name })
return out, nil
}
// ReadVersion 读取指定版本的内容。版本名虽然来自 ListVersions,这里仍校验一次:
// 带路径分隔符 / 上跳的名称直接拒绍,避免接口被用来读版本目录以外的文件。
func ReadVersion(path, name string) ([]byte, error) {
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
return nil, fmt.Errorf("版本名无效")
}
data, err := os.ReadFile(filepath.Join(VersionsDir(path), name))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("版本不存在")
}
return nil, err
}
return data, nil
}
// pruneVersions 只保留最新的 keep 份(按文件名倒序,前缀是时间戳)。
// 剪枝失败不影响本次备份结果,因此只忽略错误。
func pruneVersions(dir string, keep int) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
names = append(names, entry.Name())
}
sort.Sort(sort.Reverse(sort.StringSlice(names)))
for _, name := range names[min(keep, len(names)):] {
_ = os.Remove(filepath.Join(dir, name))
}
}
func randSuffix() string {
var buf [2]byte
if _, err := rand.Read(buf[:]); err != nil {
return "0000"
}
return hex.EncodeToString(buf[:])
}
+129
View File
@@ -69,3 +69,132 @@ func TestBackupCopiesContent(t *testing.T) {
t.Fatalf("备份内容不符:%q", data)
}
}
func TestBackupVersionedStoresContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
if err := os.WriteFile(path, []byte("worker_processes 1;"), 0o600); err != nil {
t.Fatal(err)
}
version, err := BackupVersioned(path, 5)
if err != nil || version == "" {
t.Fatalf("应返回版本路径:%q %v", version, err)
}
if !strings.Contains(version, VersionsDir(path)) {
t.Fatalf("版本应落在版本目录内:%s", version)
}
data, err := os.ReadFile(version)
if err != nil {
t.Fatal(err)
}
if string(data) != "worker_processes 1;" {
t.Fatalf("版本内容不符:%q", data)
}
}
func TestBackupVersionedPrunesToKeep(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
// 每次写入不同内容:内容相同会被去重,剪枝也就无从检验
for _, content := range []string{"v1", "v2", "v3", "v4", "v5"} {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if _, err := BackupVersioned(path, 2); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(VersionsDir(path))
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("应只保留 2 份版本,实际 %d", len(entries))
}
}
func TestBackupVersionedSkipsMissingFile(t *testing.T) {
version, err := BackupVersioned(filepath.Join(t.TempDir(), "nope.conf"), 5)
if err != nil || version != "" {
t.Fatalf("文件不存在时应返回空路径且无错误:%q %v", version, err)
}
}
func TestListVersionsNewestFirst(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := BackupVersioned(path, 5); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := BackupVersioned(path, 5); err != nil {
t.Fatal(err)
}
versions, err := ListVersions(path)
if err != nil {
t.Fatal(err)
}
if len(versions) != 2 || versions[0].Name <= versions[1].Name {
t.Fatalf("版本应按名称倒序(新的在前)列出:%v", versions)
}
seen := map[string]bool{}
for _, version := range versions {
data, err := ReadVersion(path, version.Name)
if err != nil {
t.Fatal(err)
}
if version.Size != int64(len(data)) {
t.Fatalf("版本大小不符:%d vs %d", version.Size, len(data))
}
seen[string(data)] = true
}
if !seen["v1"] || !seen["v2"] {
t.Fatalf("两个版本的内容都应在:%v", seen)
}
}
func TestBackupVersionedSkipsIdenticalContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
if err := os.WriteFile(path, []byte("same"), 0o600); err != nil {
t.Fatal(err)
}
first, err := BackupVersioned(path, 5)
if err != nil {
t.Fatal(err)
}
second, err := BackupVersioned(path, 5)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("内容未变时不应新增版本:%q vs %q", first, second)
}
versions, err := ListVersions(path)
if err != nil {
t.Fatal(err)
}
if len(versions) != 1 {
t.Fatalf("内容未变时应只有 1 个版本,实际 %d", len(versions))
}
}
func TestReadVersionRejectsTraversal(t *testing.T) {
path := filepath.Join(t.TempDir(), "nginx.conf")
if _, err := ReadVersion(path, "../nginx.conf"); err == nil {
t.Fatal("带路径上跳的版本名应被拒绝")
}
if _, err := ReadVersion(path, "missing.conf"); err == nil {
t.Fatal("不存在的版本应报错")
}
versions, err := ListVersions(path)
if err != nil || len(versions) != 0 {
t.Fatalf("版本目录不存在时应返回空列表且无错误:%v %v", versions, err)
}
}
+194
View File
@@ -0,0 +1,194 @@
// Package monitor 周期巡检「资源占用」与「隧道连通性」这两类必须持续观察才会出现的问题。
// 事件型通知(DDNS 失败、证书到期)由各自模块在发生时直接发出,不需要巡检。
//
// 判定在这里,通知只走 notify.Service:开关、阈值、冷却都读设置项,
// 所以本包不需要知道通知通道的任何细节。
package monitor
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/settings"
"github.com/fonu/fonu/internal/sysinfo"
)
// RouteStatus 是巡检关心的隧道状态;由调用方注入(避免 monitor 依赖 frp 包)。
type RouteStatus struct {
Domain string
TunnelOK bool
Detail string
}
// Checker 每轮都重新读设置项:改阈值或开关不需要重启。
type Checker struct {
settings *settings.Store
notify *notify.Service
logger *slog.Logger
routes func(ctx context.Context) ([]RouteStatus, error)
mu sync.Mutex
cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time
tunnelFailures map[string]int
}
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, routes func(context.Context) ([]RouteStatus, error)) *Checker {
if logger == nil {
logger = slog.Default()
}
return &Checker{
settings: settingsStore,
notify: notifySvc,
logger: logger,
routes: routes,
resourceAlerts: map[string]time.Time{},
tunnelFailures: map[string]int{},
}
}
// Run 跑一轮巡检。scheduler 已对任务包了 recover,这里不重复兜底。
func (c *Checker) Run(ctx context.Context) {
if c == nil || c.settings == nil {
return
}
c.checkResources(ctx)
c.checkTunnels(ctx)
}
func (c *Checker) checkResources(ctx context.Context) {
// 开关关闭时连采集都省掉(关掉的功能不该有后台开销)
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold); !enabled {
return
}
cpuLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyCPUThreshold)
memoryLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyMemoryThreshold)
diskLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyDiskThreshold)
c.mu.Lock()
stats, sample := sysinfo.Read(c.cpuPrev)
c.cpuPrev = sample
c.mu.Unlock()
limits := []struct {
metric string
label string
limit int
value float64
}{
{"cpu", "CPU", cpuLimit, stats.CPUPercent},
{"memory", "内存", memoryLimit, percent(stats.MemUsedBytes, stats.MemTotalBytes)},
{"disk", "磁盘", diskLimit, percent(stats.DiskUsedBytes, stats.DiskTotalBytes)},
}
now := time.Now()
for _, item := range limits {
// 阈值 <= 0 表示这一项不告警(CPU 首次采样时占用率为 0,也不会误报)
if item.limit <= 0 {
continue
}
if item.value < float64(item.limit) {
// 回落到阈值以下就清冷却:再次超标可以立刻提醒
c.mu.Lock()
delete(c.resourceAlerts, item.metric)
c.mu.Unlock()
continue
}
if !c.allowResourceAlert(item.metric, now) {
continue
}
c.notify.Alert(ctx, notify.EventResourceThreshold, "资源占用过高",
fmt.Sprintf("%s 使用率 %.1f%%,已超过阈值 %d%%", item.label, item.value, item.limit))
}
}
// allowResourceAlert 按静默期限流:持续高占用每 DefaultResourceAlertCooldown 提醒一次。
func (c *Checker) allowResourceAlert(metric string, now time.Time) bool {
cooldown := time.Duration(notify.DefaultResourceAlertCooldown) * time.Second
c.mu.Lock()
defer c.mu.Unlock()
if last, ok := c.resourceAlerts[metric]; ok && now.Sub(last) < cooldown {
return false
}
c.resourceAlerts[metric] = now
return true
}
func (c *Checker) checkTunnels(ctx context.Context) {
if c.routes == nil {
return
}
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown); !enabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
routes, err := c.routes(ctx)
if err != nil {
c.logger.Warn("巡检隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
c.mu.Lock()
failures, event := tunnelTransition(c.tunnelFailures[route.Domain], route.TunnelOK, limit)
c.tunnelFailures[route.Domain] = failures
c.mu.Unlock()
switch event {
case tunnelEventDown:
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未在 frps 上注册:%s", route.Domain, limit, detailOr(route.Detail)))
case tunnelEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道已恢复",
fmt.Sprintf("%s 已重新在 frps 上注册", route.Domain))
}
}
}
type tunnelEvent string
const (
tunnelEventNone tunnelEvent = ""
tunnelEventDown tunnelEvent = "down"
tunnelEventRecovered tunnelEvent = "recovered"
)
// tunnelTransition 推进某个域名的连续失败计数,返回新计数与本轮要发的事件。
// 只在「刚好达到阈值」与「从失败中恢复」两个时刻发,避免每轮巡检都刷通知。
func tunnelTransition(previous int, tunnelOK bool, limit int) (int, tunnelEvent) {
if tunnelOK {
if previous >= limit {
return 0, tunnelEventRecovered
}
return 0, tunnelEventNone
}
next := previous + 1
if next == limit {
return next, tunnelEventDown
}
return next, tunnelEventNone
}
func percent(used, total uint64) float64 {
if total == 0 {
return 0
}
return float64(used) / float64(total) * 100
}
func detailOr(detail string) string {
if strings.TrimSpace(detail) == "" {
return "未返回具体原因"
}
return detail
}
+71
View File
@@ -0,0 +1,71 @@
package monitor
import "testing"
func TestTunnelTransitionAlertsOncePerOutage(t *testing.T) {
const limit = 3
failures := 0
var events []tunnelEvent
// 连续三轮不通:只在第 3 轮(刚好达到阈值)发一次
for i := 0; i < 5; i++ {
var event tunnelEvent
failures, event = tunnelTransition(failures, false, limit)
if event != tunnelEventNone {
events = append(events, event)
}
}
if len(events) != 1 || events[0] != tunnelEventDown {
t.Fatalf("连续不通应只告警一次,实际 %v", events)
}
if failures != 5 {
t.Fatalf("失败计数应继续累加,实际 %d", failures)
}
// 恢复:发一次恢复通知并清零
failures, event := tunnelTransition(failures, true, limit)
if event != tunnelEventRecovered || failures != 0 {
t.Fatalf("恢复应通知一次并清零:event=%v failures=%d", event, failures)
}
// 恢复后再次不通:还能再报一次
failures, event = tunnelTransition(failures, false, limit)
if event != tunnelEventNone || failures != 1 {
t.Fatalf("恢复后重新计数:event=%v failures=%d", event, failures)
}
failures, event = tunnelTransition(failures, false, limit)
if event != tunnelEventNone {
t.Fatalf("未达阈值不应告警:%v", event)
}
if _, event = tunnelTransition(failures, false, limit); event != tunnelEventDown {
t.Fatalf("第二次中断达到阈值应再告警一次:%v", event)
}
}
func TestTunnelTransitionBelowThresholdIsSilent(t *testing.T) {
// 单次抖动(未达阈值)不打扰用户,也不算作恢复
if failures, event := tunnelTransition(0, true, 3); failures != 0 || event != tunnelEventNone {
t.Fatalf("健康状态不应产生事件:%d %v", failures, event)
}
if failures, event := tunnelTransition(1, true, 3); failures != 0 || event != tunnelEventNone {
t.Fatalf("未达阈值的恢复不应通知:%d %v", failures, event)
}
}
func TestPercent(t *testing.T) {
if got := percent(0, 0); got != 0 {
t.Fatalf("总数为 0 时应返回 0,实际 %v", got)
}
if got := percent(50, 200); got != 25 {
t.Fatalf("期望 25,实际 %v", got)
}
}
func TestDetailOrFallsBack(t *testing.T) {
if got := detailOr(" "); got != "未返回具体原因" {
t.Fatalf("空详情应给出兜底文案,实际 %q", got)
}
if got := detailOr("dashboard 超时"); got != "dashboard 超时" {
t.Fatalf("非空详情应原样返回,实际 %q", got)
}
}
+11 -1
View File
@@ -131,17 +131,27 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
}
currentPath := m.cfg.NginxConfigPath()
// 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚)
if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
return m.activate(ctx)
}
// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。
// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。
func (m *Manager) activate(ctx context.Context) (ApplyResult, error) {
if !m.available() {
return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
}
+104
View File
@@ -0,0 +1,104 @@
package nginx
import (
"context"
"fmt"
"os"
"time"
"github.com/fonu/fonu/internal/fsutil"
)
// maxConfigVersions 主配置保留的版本份数。Apply 每次保存都会留一份,fsutil 会跳过内容相同的版本,
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
const maxConfigVersions = 10
// ConfigVersion 是主配置(nginx.conf)的一个历史版本。
type ConfigVersion struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// ReadConfig 读取当前主配置内容,供前端与历史版本做差异对比。
func (m *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(m.cfg.NginxConfigPath())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// ListConfigVersions 列出主配置的历史版本(新的在前);从未写过配置时返回空列表。
func (m *Manager) ListConfigVersions() ([]ConfigVersion, error) {
versions, err := fsutil.ListVersions(m.cfg.NginxConfigPath())
if err != nil {
return nil, err
}
out := make([]ConfigVersion, 0, len(versions))
for _, version := range versions {
out = append(out, ConfigVersion{Name: version.Name, Size: version.Size, CreatedAt: version.CreatedAt})
}
return out, nil
}
// ReadConfigVersion 读取某个历史版本的内容。
func (m *Manager) ReadConfigVersion(name string) (string, error) {
data, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return "", err
}
return string(data), nil
}
// RollbackConfigVersion 把主配置回退到指定版本。
//
// 与 Apply 同构:先语法校验、再 nginx -t 校验,两关都过才写回并重载;校验失败保持当前配置不动。
// 写回前把当前内容也留成一个版本,所以回退本身同样可以再回退。
func (m *Manager) RollbackConfigVersion(ctx context.Context, name string) (ApplyResult, error) {
if name == "" {
return ApplyResult{}, fmt.Errorf("请选择要回滚的版本")
}
content, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return ApplyResult{}, err
}
if err := m.EnsureDirs(); err != nil {
return ApplyResult{}, err
}
if err := ValidateConfigSyntax(string(content)); err != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过语法校验,已保持当前配置:%s", err)
}
if m.available() {
tmpPath := m.cfg.NginxConfigPath() + ".rollback.tmp"
if err := fsutil.WriteFileAtomic(tmpPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
validateErr := m.validate(ctx, tmpPath)
_ = os.Remove(tmpPath)
if validateErr != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过 nginx 校验,已保持当前配置:%s", validateErr)
}
}
currentPath := m.cfg.NginxConfigPath()
if err := fsutil.Backup(currentPath); err != nil {
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
return ApplyResult{}, err
}
if err := fsutil.WriteFileAtomic(currentPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
result, err := m.activate(ctx)
if err != nil {
return ApplyResult{}, err
}
result.Message = "已回滚到所选版本," + result.Message
return result, nil
}
+83 -44
View File
@@ -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
View File
@@ -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
}
+90
View File
@@ -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 分支")
}
}
+4
View File
@@ -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
}
+59 -6
View File
@@ -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 == "" {
+70 -5
View File
@@ -25,11 +25,11 @@ type RuleNginxView struct {
}
type GlobalNginxView struct {
Mode string `json:"mode"`
GeneratedFramework string `json:"generated_framework"`
GeneratedSnippet string `json:"generated_snippet"`
Content string `json:"content"`
Backups []NginxBackupInfo `json:"backups"`
Mode string `json:"mode"`
GeneratedFramework string `json:"generated_framework"`
GeneratedSnippet string `json:"generated_snippet"`
Content string `json:"content"`
Backups []NginxBackupInfo `json:"backups"`
}
type SaveRuleNginxInput struct {
@@ -351,3 +351,68 @@ func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
}
return out
}
// NginxConfigVersionInfo 描述主配置(nginx.conf)的一个历史版本。
type NginxConfigVersionInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
}
type NginxConfigVersionsView struct {
Message string `json:"message,omitempty"`
Content string `json:"content"`
Versions []NginxConfigVersionInfo `json:"versions"`
}
type NginxConfigVersionView struct {
Name string `json:"name"`
Content string `json:"content"`
}
// GetNginxConfigVersions 返回当前主配置与其历史版本,前端拿两者做差异对比。
func (s *ProxyService) GetNginxConfigVersions() (NginxConfigVersionsView, error) {
content, err := s.nginx.ReadConfig()
if err != nil {
return NginxConfigVersionsView{}, err
}
versions, err := s.nginx.ListConfigVersions()
if err != nil {
return NginxConfigVersionsView{}, err
}
return NginxConfigVersionsView{Content: content, Versions: mapConfigVersions(versions)}, nil
}
func (s *ProxyService) ReadNginxConfigVersion(name string) (NginxConfigVersionView, error) {
content, err := s.nginx.ReadConfigVersion(name)
if err != nil {
return NginxConfigVersionView{}, err
}
return NginxConfigVersionView{Name: name, Content: content}, nil
}
// RollbackNginxConfigVersion 回滚主配置;校验不过时 nginx 层不会动磁盘上的当前配置。
func (s *ProxyService) RollbackNginxConfigVersion(ctx context.Context, name string) (NginxConfigVersionsView, error) {
result, err := s.nginx.RollbackConfigVersion(ctx, name)
if err != nil {
return NginxConfigVersionsView{}, err
}
view, err := s.GetNginxConfigVersions()
if err != nil {
return NginxConfigVersionsView{}, err
}
view.Message = result.Message
return view, nil
}
func mapConfigVersions(entries []nginx.ConfigVersion) []NginxConfigVersionInfo {
out := make([]NginxConfigVersionInfo, 0, len(entries))
for _, entry := range entries {
out = append(out, NginxConfigVersionInfo{
Name: entry.Name,
Size: entry.Size,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
return out
}
+65 -59
View File
@@ -8,68 +8,74 @@ import (
)
const (
KeyTheme = "theme"
KeyTimezone = "timezone"
KeyLogRetentionDays = "log_retention_days"
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
KeyCertRenewThreshold = "cert_renew_threshold_days"
KeyRootDomain = "root_domain"
KeyACMEEmail = "acme_email"
KeyACMECA = "acme_ca"
KeyZeroSSLAPIKey = "zerossl_api_key"
KeyNotifyWebhookURL = "notify_webhook_url"
KeyNotifyType = "notify_type"
KeyNotifyEmailJSON = "notify_email_json"
KeyNotifySMTPPassword = "notify_smtp_password"
KeyNotifyWebhookJSON = "notify_webhook_json"
KeyNotifyWebhookSecret = "notify_webhook_secret"
KeyNotifyTelegramJSON = "notify_telegram_json"
KeyNotifyTelegramToken = "notify_telegram_token"
KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored
KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change"
KeyNotifyOnDDNSFailure = "notify_on_ddns_failure"
KeyNotifyOnCertExpiry = "notify_on_cert_expiry"
KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success"
KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access"
KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure"
KeyNotifyOnLoginFailure = "notify_on_login_failure"
KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure"
KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold"
KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec"
KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
KeyTrustedProxy = "trusted_proxy_json"
KeyGlobalIPBlacklist = "global_ip_blacklist"
KeyGlobalIPWhitelist = "global_ip_whitelist"
KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours"
KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4"
KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6"
KeyChinaCIDRUpdatedAt = "china_cidr_updated_at"
KeyChinaCIDRCountV4 = "china_cidr_count_v4"
KeyChinaCIDRCountV6 = "china_cidr_count_v6"
KeyChinaCIDRLastError = "china_cidr_last_error"
KeyFRPEnabled = "frp_enabled"
KeyFRPServerAddr = "frp_server_addr"
KeyFRPServerPort = "frp_server_port"
KeyFRPAuthToken = "frp_auth_token"
KeyFRPTLSEnabled = "frp_tls_enabled"
KeyFRPCustomDomains = "frp_custom_domains"
KeyFRPLastError = "frp_last_error"
KeyFRPStartedAt = "frp_started_at"
KeyNginxGlobalMode = "nginx_global_mode"
KeyTheme = "theme"
KeyTimezone = "timezone"
KeyLogRetentionDays = "log_retention_days"
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
KeyCertRenewThreshold = "cert_renew_threshold_days"
KeyRootDomain = "root_domain"
KeyACMEEmail = "acme_email"
KeyACMECA = "acme_ca"
KeyZeroSSLAPIKey = "zerossl_api_key"
KeyNotifyWebhookURL = "notify_webhook_url"
KeyNotifyType = "notify_type"
KeyNotifyEmailJSON = "notify_email_json"
KeyNotifySMTPPassword = "notify_smtp_password"
KeyNotifyWebhookJSON = "notify_webhook_json"
KeyNotifyWebhookSecret = "notify_webhook_secret"
KeyNotifyTelegramJSON = "notify_telegram_json"
KeyNotifyTelegramToken = "notify_telegram_token"
KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored
KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change"
KeyNotifyOnDDNSFailure = "notify_on_ddns_failure"
KeyNotifyOnCertExpiry = "notify_on_cert_expiry"
KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success"
KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access"
KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure"
KeyNotifyOnLoginFailure = "notify_on_login_failure"
KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure"
KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold"
KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec"
KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
KeyNotifyOnResourceThreshold = "notify_on_resource_threshold"
KeyNotifyOnTunnelDown = "notify_on_tunnel_down"
KeyNotifyCPUThreshold = "notify_cpu_threshold"
KeyNotifyMemoryThreshold = "notify_memory_threshold"
KeyNotifyDiskThreshold = "notify_disk_threshold"
KeyNotifyTunnelFailThreshold = "notify_tunnel_fail_threshold"
KeyTrustedProxy = "trusted_proxy_json"
KeyGlobalIPBlacklist = "global_ip_blacklist"
KeyGlobalIPWhitelist = "global_ip_whitelist"
KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours"
KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4"
KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6"
KeyChinaCIDRUpdatedAt = "china_cidr_updated_at"
KeyChinaCIDRCountV4 = "china_cidr_count_v4"
KeyChinaCIDRCountV6 = "china_cidr_count_v6"
KeyChinaCIDRLastError = "china_cidr_last_error"
KeyFRPEnabled = "frp_enabled"
KeyFRPServerAddr = "frp_server_addr"
KeyFRPServerPort = "frp_server_port"
KeyFRPAuthToken = "frp_auth_token"
KeyFRPTLSEnabled = "frp_tls_enabled"
KeyFRPCustomDomains = "frp_custom_domains"
KeyFRPLastError = "frp_last_error"
KeyFRPStartedAt = "frp_started_at"
KeyNginxGlobalMode = "nginx_global_mode"
)
var Defaults = map[string]string{
KeyTheme: "system",
KeyTimezone: "Asia/Shanghai",
KeyLogRetentionDays: "30",
KeyDDNSCheckInterval: "5",
KeyCertRenewThreshold: "30",
KeyACMECA: "letsencrypt",
KeyChinaCIDRUpdateHours: "24",
KeyNginxGlobalMode: "auto",
KeyTheme: "system",
KeyTimezone: "Asia/Shanghai",
KeyLogRetentionDays: "30",
KeyDDNSCheckInterval: "5",
KeyCertRenewThreshold: "30",
KeyACMECA: "letsencrypt",
KeyChinaCIDRUpdateHours: "24",
KeyNginxGlobalMode: "auto",
}
type Store struct {