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
|
||||
}
|
||||
Reference in New Issue
Block a user