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

- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回

- agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编

- 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值

- 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知

- API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
2026-09-24 20:50:17 +08:00
parent 8f75e99339
commit 862442e77f
42 changed files with 3034 additions and 190 deletions
+11 -1
View File
@@ -131,17 +131,27 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
}
currentPath := m.cfg.NginxConfigPath()
// 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚)
if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
return m.activate(ctx)
}
// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。
// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。
func (m *Manager) activate(ctx context.Context) (ApplyResult, error) {
if !m.available() {
return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
}
+104
View File
@@ -0,0 +1,104 @@
package nginx
import (
"context"
"fmt"
"os"
"time"
"github.com/fonu/fonu/internal/fsutil"
)
// maxConfigVersions 主配置保留的版本份数。Apply 每次保存都会留一份,fsutil 会跳过内容相同的版本,
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
const maxConfigVersions = 10
// ConfigVersion 是主配置(nginx.conf)的一个历史版本。
type ConfigVersion struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// ReadConfig 读取当前主配置内容,供前端与历史版本做差异对比。
func (m *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(m.cfg.NginxConfigPath())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// ListConfigVersions 列出主配置的历史版本(新的在前);从未写过配置时返回空列表。
func (m *Manager) ListConfigVersions() ([]ConfigVersion, error) {
versions, err := fsutil.ListVersions(m.cfg.NginxConfigPath())
if err != nil {
return nil, err
}
out := make([]ConfigVersion, 0, len(versions))
for _, version := range versions {
out = append(out, ConfigVersion{Name: version.Name, Size: version.Size, CreatedAt: version.CreatedAt})
}
return out, nil
}
// ReadConfigVersion 读取某个历史版本的内容。
func (m *Manager) ReadConfigVersion(name string) (string, error) {
data, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return "", err
}
return string(data), nil
}
// RollbackConfigVersion 把主配置回退到指定版本。
//
// 与 Apply 同构:先语法校验、再 nginx -t 校验,两关都过才写回并重载;校验失败保持当前配置不动。
// 写回前把当前内容也留成一个版本,所以回退本身同样可以再回退。
func (m *Manager) RollbackConfigVersion(ctx context.Context, name string) (ApplyResult, error) {
if name == "" {
return ApplyResult{}, fmt.Errorf("请选择要回滚的版本")
}
content, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return ApplyResult{}, err
}
if err := m.EnsureDirs(); err != nil {
return ApplyResult{}, err
}
if err := ValidateConfigSyntax(string(content)); err != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过语法校验,已保持当前配置:%s", err)
}
if m.available() {
tmpPath := m.cfg.NginxConfigPath() + ".rollback.tmp"
if err := fsutil.WriteFileAtomic(tmpPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
validateErr := m.validate(ctx, tmpPath)
_ = os.Remove(tmpPath)
if validateErr != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过 nginx 校验,已保持当前配置:%s", validateErr)
}
}
currentPath := m.cfg.NginxConfigPath()
if err := fsutil.Backup(currentPath); err != nil {
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
return ApplyResult{}, err
}
if err := fsutil.WriteFileAtomic(currentPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
result, err := m.activate(ctx)
if err != nil {
return ApplyResult{}, err
}
result.Message = "已回滚到所选版本," + result.Message
return result, nil
}