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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 运行)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user