Files
Havline/internal/app/app.go
T
mrlee 74d731d0ac feat: 自动备份与保留策略、健康持续化与状态页、指标历史与趋势图,并修隧道判定误报与仪表盘布局(v1.2.11)
- 自动备份:每日一份(按最新备份是否在 24h 内判定,重启不重复)、保留份数可配、列表/下载/恢复/删除,恢复前先校验归档;顺手修 writeArchive 逐个检查 tar/gzip/文件 Close()(审查报告 §二)

- 健康持续化:只存状态变更(up/down/unknown)并推导可用率与不可用区间;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率

- 隧道判定修复:RouteHealthCandidate 新增 tunnel_known,frps 管理接口不可用时不再把「判不出来」当成「不通」(此前会误报「隧道连续不通」);汇总与徽标同样区分「无法判定」

- 指标历史:metrics_samples 纵向点表(本机 CPU/内存/磁盘 + 每规则上下行,30 天保留、查询按桶平均、不做降采样表);仪表盘趋势卡支持 1h/24h/7d

- 仪表盘布局:运行状态并入顶部统计区(运行时长补进「服务」卡);性能并入系统资源那一行,整行 4 列

- 测试:backup/metrics/frp/api/app 新增用例;gofmt、go build/vet/test、vue-tsc、vite build 全绿

- agent 无改动,AgentVersion 保持 0.12.2
2026-09-25 13:05:58 +08:00

253 lines
7.6 KiB
Go

package app
import (
"context"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/fonu/fonu/internal/acme"
"github.com/fonu/fonu/internal/api"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/discovery"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/logstore"
"github.com/fonu/fonu/internal/metrics"
"github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/nginx"
"github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/proxy"
"github.com/fonu/fonu/internal/scheduler"
"github.com/fonu/fonu/internal/secret"
"github.com/fonu/fonu/internal/service"
"github.com/fonu/fonu/internal/settings"
"github.com/fonu/fonu/internal/traffic"
)
type App struct {
cfg config.Config
logger *slog.Logger
server *http.Server
nginx *nginx.Manager
frpMulti *frp.Service
scheduler *scheduler.Scheduler
shutdownFn context.CancelFunc
db interface{ Close() error }
}
func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
}
conn, err := db.Open(cfg.DBPath())
if err != nil {
return nil, err
}
if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
conn.Close()
return nil, err
}
secretBox, err := secret.NewBox(cfg.SessionSecret)
if err != nil {
conn.Close()
return nil, err
}
authSvc := auth.New(conn)
if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
conn.Close()
return nil, fmt.Errorf("bootstrap admin: %w", err)
}
proxyStore := proxy.NewStore(conn)
settingsStore := settings.NewStore(conn)
ddnsStore := ddns.NewStore(conn)
certStore := certificate.NewStore(conn)
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
if err := nginxMgr.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
notifySvc := notify.New(settingsStore, secretBox, logger)
proxySvc.SetNotify(notifySvc)
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
backupSvc := backup.New(cfg.DataDir)
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
metricsStore := metrics.NewStore(conn)
discoverySvc := discovery.New()
// 新增:多服务端 FRP 模块(接管原网关模式)
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
if err := frpMultiSvc.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
handler := api.NewRouter(api.Deps{
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Traffic: trafficCollector,
StaticFS: staticFS,
StartedAt: startedAt,
})
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
ReadTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
}
ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
}
chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
if chinaHours <= 0 {
chinaHours = 24
}
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New(
scheduler.Job{
Name: "ddns",
Interval: time.Duration(intervalMinutes) * time.Minute,
Run: ddnsSvc.Tick,
},
scheduler.Job{
Name: "acme",
Interval: 24 * time.Hour,
Run: acmeSvc.Tick,
},
scheduler.Job{
Name: "china_cidr",
Interval: time.Duration(chinaHours) * time.Hour,
Run: chinaCIDRSvc.Tick,
},
scheduler.Job{
Name: "monitor",
Interval: 2 * time.Minute,
Run: monitorChecker.Run,
},
scheduler.Job{
Name: "backup",
Interval: backup.AutoInterval,
Run: backupRunner.Run,
},
)
sched.Start(ctx)
trafficCollector.Start(ctx)
app := &App{
cfg: cfg,
logger: logger,
server: server,
nginx: nginxMgr,
frpMulti: frpMultiSvc,
scheduler: sched,
shutdownFn: cancel,
db: conn,
}
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
cancel()
conn.Close()
return nil, err
}
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
}
if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
}
logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
return app, nil
}
func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
if err := proxySvc.ReloadAll(ctx); err != nil {
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
}
return nil
}
func (a *App) Run() error {
return a.server.ListenAndServe()
}
func (a *App) Shutdown(ctx context.Context) error {
a.logger.Info("application shutting down", "module", "SYSTEM")
if a.shutdownFn != nil {
a.shutdownFn()
}
if err := a.server.Shutdown(ctx); err != nil {
return err
}
if err := a.nginx.Stop(ctx); err != nil {
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
}
if a.frpMulti != nil {
if err := a.frpMulti.Stop(ctx); err != nil {
a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
}
}
if a.db != nil {
return a.db.Close()
}
return nil
}
func ResolveMigrationsDir() (string, error) {
candidates := []string{
"migrations",
filepath.Join("..", "migrations"),
filepath.Join("..", "..", "migrations"),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c, nil
}
}
return "", fmt.Errorf("migrations directory not found")
}