From 9d75fb1d3ef6e3d08845f376912937a1bf03cb67 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Sat, 24 Oct 2026 16:30:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E6=80=A7=E7=BC=BA=E5=8F=A3=E5=B9=B6=E8=A1=A5=E9=BD=90=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 备份、隧道生命周期、日志、会话与设置项存在一批「静默失效」问题,本次一并收敛。 正确性: - 备份归档补上 session_secret 与 frp 配置。缺密钥时换机恢复后库里所有凭据都解不开, 界面表现为「配置都在、一执行就报解密失败」;同时支持口令加密(scrypt + AES-256-GCM)。 - 修复「立即备份」必失败:tar 头用的是遍历时的文件尺寸,而 logs、frpc 配置在打包期间 还在增长,读出的字节多于声明值导致 archive/tar 报错、整份备份作废。改为打开后重新 取尺寸并用 LimitReader 兜住,被截短时补零保持条目结构完整。 - 数据库改用 VACUUM INTO 生成一致性快照。直接打包正在写入的 SQLite 文件可能得到 结构完整但内容自相矛盾的副本,恢复后才暴露。 - Cloudflare 启动改用 StartAuto,Shutdown 显式 StopAll,子进程绑定应用 ctx。此前 重启会把用户手动停掉的隧道重新拉起,退出也可能留下孤儿进程。 - Nginx 访问/错误日志加轮转(200MB 阈值、gzip 归档、USR1 重开),读取改为从文件尾 分块倒读,并跨当日归档统计。此前日志只增不减,读接口每次全量扫描。 - 过期会话改由日任务清理;删除反代规则时同步清掉流量累计,避免同名规则重建继承旧读数。 - 启动加就绪门:Nginx bootstrap 完成后再放行 FRP 对账、Agent 隧道与 Cloudflare 自启。 设置项: - 新增设置项注册表(internal/settings/registry.go),白名单与默认值从它派生, 并记录每个键的消费者,防止再出现「界面能改、后端没人读」的黑洞设置。 - 接上三个此前不生效的设置项:timezone(启动与保存时应用到 time.Local)、 theme(后端为权威源,挂载时覆盖本地偏好、顶栏切换回写)、log_retention_days (控制日志归档保留天数)。 工程质量: - gofmt 全量格式化 23 个历史文件,CI 增加格式化门禁。 - 前端引入 ESLint(flat config)与 lint 脚本,CI 增加 lint 步骤;顺带修掉 logDisplay 的 let→const 与 LogsView 模板内直接改 prop 两处问题。 - 删除无引用的 pnpm-lock.yaml,prototype/ 加入忽略列表。 - 打包版本对齐:Docker manifest 同步到 2.0.0,CI 校验 VERSION 与 package.json 一致。 验证:gofmt 0 文件、go vet、go test ./...、npm run lint/typecheck/test/build 全绿。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +- .gitignore | 5 + internal/acme/job.go | 8 +- internal/api/backup_handler.go | 21 +- internal/api/settings_handler.go | 92 +- internal/app/app.go | 105 +- internal/auth/auth.go | 12 + internal/auth/prune_test.go | 87 ++ internal/backup/archive.go | 83 +- internal/backup/auto.go | 2 + internal/backup/crypto.go | 148 ++ internal/backup/encryption_test.go | 269 ++++ internal/backup/growing_test.go | 151 ++ internal/backup/realdata_test.go | 56 + internal/backup/service.go | 269 +++- internal/cloudflared/lifecycle_test.go | 133 ++ internal/cloudflared/manager.go | 22 +- internal/cloudflared/service.go | 22 +- internal/ddns/store.go | 24 +- internal/ddns/tencentcloud_test.go | 6 +- internal/frp/diagnostics.go | 6 +- internal/logstore/reader.go | 165 ++- internal/logstore/rotate.go | 191 +++ internal/logstore/rotate_test.go | 188 +++ internal/nginx/generate_multi_test.go | 10 +- internal/nginx/generate_seed_mix_test.go | 4 +- internal/nginx/nginx.go | 22 + internal/nginx/pid_unix.go | 8 + internal/nginx/pid_windows.go | 6 + internal/nginx/realip.go | 10 +- internal/service/proxy.go | 27 + internal/settings/registry.go | 178 +++ internal/settings/registry_test.go | 153 ++ internal/settings/settings.go | 11 - internal/settings/timezone.go | 45 + internal/settings/timezone_test.go | 93 ++ internal/traffic/collector.go | 20 + internal/traffic/forget_test.go | 62 + internal/traffic/store.go | 20 + web/eslint.config.js | 103 ++ web/package-lock.json | 1669 ++++++++++++++++++++++ web/package.json | 8 +- web/pnpm-lock.yaml | 1423 ------------------ web/src/api/client.ts | 32 +- web/src/composables/useTheme.ts | 15 +- web/src/layouts/AppLayout.vue | 26 +- web/src/utils/logDisplay.ts | 2 +- web/src/views/LogsView.vue | 9 +- web/src/views/SettingsView.vue | 35 +- 49 files changed, 4469 insertions(+), 1617 deletions(-) create mode 100644 internal/auth/prune_test.go create mode 100644 internal/backup/crypto.go create mode 100644 internal/backup/encryption_test.go create mode 100644 internal/backup/growing_test.go create mode 100644 internal/backup/realdata_test.go create mode 100644 internal/cloudflared/lifecycle_test.go create mode 100644 internal/logstore/rotate.go create mode 100644 internal/logstore/rotate_test.go create mode 100644 internal/settings/registry.go create mode 100644 internal/settings/registry_test.go create mode 100644 internal/settings/timezone.go create mode 100644 internal/settings/timezone_test.go create mode 100644 internal/traffic/forget_test.go create mode 100644 web/eslint.config.js delete mode 100644 web/pnpm-lock.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74a7c88..d40631c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # 版本号是构建的唯一事实来源(VERSION 文件):前端 package.json 与它不一致时, + # 界面上显示的版本和二进制里的版本会对不上,所以在构建前先卡住。 + - name: Check version consistency + run: | + version="$(tr -d ' \t\r\n' < VERSION)" + pkg="$(node -p "require('./web/package.json').version")" + echo "VERSION=$version package.json=$pkg" + if [ "$version" != "$pkg" ]; then + echo "::error::版本不一致:VERSION=$version,web/package.json=$pkg。请把两者改成同一个版本号。" + exit 1 + fi + # 顺序不能颠倒:cmd/havline/main.go 用 //go:embed all:web/dist 嵌入前端, # 干净检出时该目录不存在,go vet / go build 会直接失败。 - name: Setup Node @@ -29,6 +41,10 @@ jobs: working-directory: web run: npm ci + - name: Lint web + working-directory: web + run: npm run lint + - name: Type-check and build web working-directory: web run: npm run build @@ -48,6 +64,17 @@ jobs: go-version-file: go.mod cache: true + # 格式化门禁:历史文件已在 M2 一次性 gofmt -w 过,从这里开始保持零容忍, + # 避免再退回「改动时顺手格式化一大片、review 看不出真实差异」的状态。 + - name: gofmt + run: | + unformatted="$(gofmt -l internal cmd)" + if [ -n "$unformatted" ]; then + echo "::error::以下文件未格式化,请运行 gofmt -w internal cmd" + echo "$unformatted" + exit 1 + fi + - name: go vet run: go vet ./... @@ -56,6 +83,3 @@ jobs: - name: go build run: go build ./... - - # 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八), - # 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。 diff --git a/.gitignore b/.gitignore index 15b7c97..0f361e3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ scripts/* *.7z *.tar.gz *.log +# 前端 UI 原型(HTML 草稿与截图,约 2.8MB):只作设计参考,不是构建输入 +/prototype/ +# 只保留 npm 一份 lockfile:仓库里曾同时存在 pnpm-lock.yaml, +# 两份锁文件各自记录不同版本,装出哪个依赖取决于用哪个包管理器,容易排查不清 +web/pnpm-lock.yaml diff --git a/internal/acme/job.go b/internal/acme/job.go index 0697ab1..a994b55 100644 --- a/internal/acme/job.go +++ b/internal/acme/job.go @@ -31,10 +31,10 @@ type JobDonePayload struct { } type Job struct { - id string - mu sync.Mutex - events []JobEvent - done bool + id string + mu sync.Mutex + events []JobEvent + done bool waiters map[chan JobEvent]struct{} } diff --git a/internal/api/backup_handler.go b/internal/api/backup_handler.go index 3ae6dab..e1ccb19 100644 --- a/internal/api/backup_handler.go +++ b/internal/api/backup_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/havline/havline/internal/backup" "github.com/havline/havline/internal/settings" @@ -19,8 +20,14 @@ func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *Backu return &BackupHandler{svc: svc, settings: settingsStore} } +// passphraseFromHeader 读取可选的归档口令。 +// 放在请求头而不是查询串:查询串会进访问日志,口令不该落盘。 +func passphraseFromHeader(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-Backup-Passphrase")) +} + func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) { - path, err := h.svc.Export(r.Context()) + path, err := h.svc.ExportWith(r.Context(), backup.Options{Passphrase: passphraseFromHeader(r)}) if err != nil { writeError(r, w, http.StatusInternalServerError, "创建备份失败") return @@ -66,7 +73,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) { } out.Close() - if err := h.svc.Restore(r.Context(), tmpPath); err != nil { + if err := h.svc.RestoreWith(r.Context(), tmpPath, backup.Options{Passphrase: passphraseFromHeader(r)}); err != nil { _ = os.Remove(tmpPath) writeError(r, w, http.StatusBadRequest, err.Error()) return @@ -91,14 +98,15 @@ func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) { // CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝 func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) { - path, err := h.svc.Export(r.Context()) + opts := backup.Options{Passphrase: passphraseFromHeader(r)} + path, err := h.svc.ExportWith(r.Context(), opts) if err != nil { writeError(r, w, http.StatusInternalServerError, "创建备份失败", err) return } name := filepath.Base(path) // 写失败的备份不能留在列表里冒充可用备份 - if _, err := h.svc.Verify(r.Context(), path); err != nil { + if _, err := h.svc.VerifyWith(r.Context(), path, opts); err != nil { _ = h.svc.Remove(name) writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err) return @@ -141,12 +149,13 @@ func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusBadRequest, err.Error()) return } + opts := backup.Options{Passphrase: passphraseFromHeader(r)} // 恢复前先校验:半个包不能拿去覆盖现有数据 - if _, err := h.svc.Verify(r.Context(), path); err != nil { + if _, err := h.svc.VerifyWith(r.Context(), path, opts); err != nil { writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err) return } - if err := h.svc.Restore(r.Context(), path); err != nil { + if err := h.svc.RestoreWith(r.Context(), path, opts); err != nil { writeError(r, w, http.StatusBadRequest, err.Error(), err) return } diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go index 1e6fecc..eed0bcd 100644 --- a/internal/api/settings_handler.go +++ b/internal/api/settings_handler.go @@ -44,67 +44,9 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusBadRequest, "请求格式无效") 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.KeyACMEDNSResolvers: true, - settings.KeyLiteSSLEABKid: true, - settings.KeyLiteSSLEABHMAC: 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, - settings.KeyNotifyOnCertRenewFailure: true, - settings.KeyNotifyOnLoginFailure: true, - settings.KeyNotifyOnNginxReloadFailure: true, - settings.KeyNotifyIPFrequentThreshold: true, - settings.KeyNotifyIPFrequentWindowSec: true, - settings.KeyNotifyLoginFailureThreshold: true, - settings.KeyNotifyLoginFailureWindowSec: true, - settings.KeyNotifyOnResourceThreshold: true, - settings.KeyNotifyOnTunnelDown: true, - settings.KeyNotifyCPUThreshold: true, - settings.KeyNotifyMemoryThreshold: true, - settings.KeyNotifyDiskThreshold: true, - settings.KeyNotifyTunnelFailThreshold: true, - settings.KeyNotifyOnFrpsDown: true, - settings.KeyNotifyOnAgentUnreachable: true, - settings.KeyNotifyOnRouteDrift: true, - settings.KeyNotifyFrpsFailThreshold: true, - settings.KeyNotifyAgentFailThreshold: true, - settings.KeyNotifyDriftCooldownHours: true, - settings.KeyBackupAutoEnabled: true, - settings.KeyRouteHealthEnabled: true, - settings.KeyStatusPageEnabled: true, - settings.KeyMetricsHistoryEnabled: true, - settings.KeyStatusPageCode: true, - settings.KeyBackupKeepCount: true, - settings.KeyNotifyOnBackupFailure: true, - settings.KeyTrustedProxy: true, - settings.KeyGlobalIPBlacklist: true, - settings.KeyGlobalIPWhitelist: true, - settings.KeyChinaCIDRUpdateHours: true, - settings.KeyChinaCIDRSourceV4: true, - settings.KeyChinaCIDRSourceV6: true, - settings.KeyTerminalEnabled: true, - settings.KeyTerminalRecording: true, - } + // 白名单从设置注册表派生(settings.Specs 里 Writable=true 的键)。 + // 之前是手写 map,新增设置项时容易只改前端与常量、忘了加白名单。 + allowed := settings.WritableKeys() filtered := map[string]string{} for key, value := range req { if !allowed[key] { @@ -116,6 +58,13 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { } filtered[key] = value } + // 时区在写入前先校验:写进库之后才发现不合法,就得再回滚一次。 + if tz, ok := filtered[settings.KeyTimezone]; ok { + if _, err := settings.LoadTimezone(tz); err != nil { + writeError(r, w, http.StatusBadRequest, err.Error(), err) + return + } + } // 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256; // 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。 if code, ok := filtered[settings.KeyStatusPageCode]; ok { @@ -146,6 +95,14 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { writeError(r, w, http.StatusInternalServerError, "保存设置失败", err) return } + // 时区是进程级状态:保存后立即生效,不必等重启(否则用户改了看不到变化, + // 又会变成「界面能改、实际不生效」的观感)。 + if tz, ok := filtered[settings.KeyTimezone]; ok && strings.TrimSpace(tz) != "" { + if _, err := settings.ApplyTimezone(r.Context(), h.store); err != nil { + writeError(r, w, http.StatusBadRequest, err.Error(), err) + return + } + } if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") { if err := h.proxy.ReloadAll(r.Context()); err != nil { writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err) @@ -267,6 +224,19 @@ func (h *SettingsHandler) validateSetting(key, value string) error { return err } } + case settings.KeyTheme: + switch strings.TrimSpace(value) { + case "system", "light", "dark": + default: + return fmt.Errorf("无效的主题:只支持 system / light / dark") + } + case settings.KeyLogRetentionDays: + if value != "" { + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 365 { + return fmt.Errorf("日志保留天数需在 1–365 之间") + } + } } return nil } diff --git a/internal/app/app.go b/internal/app/app.go index 7d12057..8f28912 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -103,6 +103,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) } proxyStore := proxy.NewStore(conn) settingsStore := settings.NewStore(conn) + // 时区必须在任何业务开始前生效:通知时间戳、日志时间、调度判断都依赖 time.Local。 + // 设置项缺失或写坏时退回 TZ 环境变量,不让应用起不来。 + if loc, err := settings.ApplyTimezone(context.Background(), settingsStore); err != nil { + logger.Warn("应用时区设置失败,沿用系统 TZ", "module", "SYSTEM", "error", err.Error()) + } else { + logger.Info("时区已应用", "module", "SYSTEM", "timezone", loc.String()) + } ddnsStore := ddns.NewStore(conn) certStore := certificate.NewStore(conn) nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX")) @@ -118,6 +125,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc) acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc) backupSvc := backup.New(cfg.DataDir) + // 注入数据库连接:导出时用 VACUUM INTO 拿一致性快照,而不是直接打包正在写的文件 + backupSvc.SetDB(conn) // 指标历史:与巡检同生命周期,采样跟随巡检轮次 metricsStore := metrics.NewStore(conn) discoverySvc := discovery.New() @@ -137,6 +146,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) proxySvc.SetExitSync(cloudflareSvc) startedAt := time.Now().UTC().Format(time.RFC3339) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) + // 删规则时连带清掉它的流量累计,否则同名规则重建会继承上一轮的读数 + proxySvc.SetTrafficCleaner(trafficCollector) updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken) // 内置 SSH 终端:会话管理借用 frp 的 SSH 凭据与 known_hosts;开关默认关闭,见 settings.KeyTerminalEnabled // 会话审计落 SQLite:谁、何时、连了哪台、多久、为什么断开 @@ -195,9 +206,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) // 终端会话回收:空闲超时、超长会话与已结束会话都靠它清理 terminalManager.StartSweeper(ctx, time.Minute) frpMultiSvc.SetBaseContext(ctx) - go frpMultiSvc.StartAgentTunnels(ctx) - go frpMultiSvc.StartAgentCertificateRenewal(ctx) - go cloudflareSvc.StartAll(ctx) + // cloudflared 子进程跟随应用 ctx:应用退出时不会留下孤儿进程 + cloudflareSvc.SetBaseContext(ctx) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) if intervalMinutes <= 0 { intervalMinutes = 5 @@ -257,6 +267,30 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) } }, }, + scheduler.Job{ + Name: "session_prune", + Interval: 24 * time.Hour, + // 过期会话否则只能等自己被访问时删掉自己,长期不登录的设备会一直留在表里 + Run: func(ctx context.Context) { + removed, err := authSvc.PruneExpiredSessions(ctx) + if err != nil { + logger.Warn("过期会话清理失败", "module", "AUTH", "error", err.Error()) + return + } + if removed > 0 { + logger.Info("过期会话清理完成", "module", "AUTH", "deleted", removed) + } + }, + }, + scheduler.Job{ + Name: "log_rotate", + Interval: time.Hour, + // Nginx 自己不会轮转:不切的话 access.log 会一直长,同时读日志的 + // 几个接口(今日统计、小时曲线)会越来越慢。 + Run: func(ctx context.Context) { + rotateNginxLogs(ctx, cfg, nginxMgr, settingsStore, logger) + }, + }, ) sched.Start(ctx) trafficCollector.Start(ctx) @@ -267,22 +301,33 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) server: server, nginx: nginxMgr, frpMulti: frpMultiSvc, + cloudflare: cloudflareSvc, scheduler: sched, shutdownFn: cancel, db: conn, } + // 就绪门:Nginx 首轮配置应用完之前不放后台任务。 + // 否则 Agent 路由下发、Cloudflare ingress 同步会在启动瞬间各触发一次 + // Nginx reload,和 bootstrap 抢同一份配置与 pid。 + ready := make(chan struct{}) 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()) - } + close(ready) + go waitReady(ctx, ready, func() { + if err := frpMultiSvc.ReconcileRuntime(ctx); err != nil { + logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error()) + } + if err := frpMultiSvc.AutoStart(ctx); err != nil { + logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error()) + } + cloudflareSvc.StartAuto(ctx) + }) + go waitReady(ctx, ready, func() { frpMultiSvc.StartAgentTunnels(ctx) }) + go waitReady(ctx, ready, func() { frpMultiSvc.StartAgentCertificateRenewal(ctx) }) listen := strings.TrimSpace(cfg.ListenAddr) if listen == "" { @@ -292,6 +337,42 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) return app, nil } +// waitReady 等到 ready 关闭(或 ctx 取消)后再执行 fn。 +func waitReady(ctx context.Context, ready <-chan struct{}, fn func()) { + select { + case <-ready: + case <-ctx.Done(): + return + } + if ctx.Err() != nil { + return + } + fn() +} + +// rotateNginxLogs 轮转 access.log 与 error.log。 +// 保留天数接「设置 → 常规 → 日志保留天数」,份数用 logstore 默认值兜底。 +func rotateNginxLogs(ctx context.Context, cfg config.Config, nginxMgr *nginx.Manager, store *settings.Store, logger *slog.Logger) { + retentionDays, err := store.GetInt(ctx, settings.KeyLogRetentionDays) + if err != nil || retentionDays <= 0 { + retentionDays = 30 + } + logsDir := cfg.LogsDir() + for _, name := range []string{"access.log", "error.log"} { + rotator := logstore.NewRotator(filepath.Join(logsDir, name), retentionDays, func() error { + return nginxMgr.ReopenLogs(ctx) + }) + rotated, err := rotator.RotateIfNeeded(time.Now()) + if err != nil { + logger.Warn("日志轮转失败", "module", "LOGSTORE", "file", name, "error", err.Error()) + continue + } + if rotated { + logger.Info("日志已轮转", "module", "LOGSTORE", "file", name) + } + } +} + 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()) @@ -357,6 +438,12 @@ func (a *App) Shutdown(ctx context.Context) error { a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error()) } } + // cloudflared 不保留「上次在运行」:AutoStart 是用户的显式选择, + // 这里只负责把当前子进程收干净,避免退出后残留进程占着 Cloudflare 连接。 + // 放在 nginx 之后:ingress 还指向本机 Nginx,先收隧道再做别的没有意义。 + if a.cloudflare != nil { + a.cloudflare.StopAll(ctx) + } if a.db != nil { return a.db.Close() } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6a6db60..d456a48 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -197,6 +197,18 @@ func (s *Service) Logout(ctx context.Context, sessionID string) error { return err } +// PruneExpiredSessions 删除已经过期的会话,返回删除条数。 +// 过期会话只有在自己被再次访问时才会删掉自己,于是「登录过又再也不回来的设备」 +// 会永久留着一条记录;这里由日任务统一清掉,顺便让会话表大小与实际活跃设备一致。 +func (s *Service) PruneExpiredSessions(ctx context.Context) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Service) ValidateSession(ctx context.Context, sessionID string) error { if sessionID == "" { return ErrUnauthorized diff --git a/internal/auth/prune_test.go b/internal/auth/prune_test.go new file mode 100644 index 0000000..de7ec70 --- /dev/null +++ b/internal/auth/prune_test.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/havline/havline/internal/db" +) + +// TestPruneExpiredSessions 过期会话应被日任务清掉,未过期的必须留着。 +func TestPruneExpiredSessions(t *testing.T) { + conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer conn.Close() + ctx := context.Background() + if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("migrate: %v", err) + } + + svc := New(conn) + if err := svc.Setup(ctx, "admin", "admin-password-123"); err != nil { + t.Fatalf("setup: %v", err) + } + adminID, err := svc.AdminIDForSession(ctx, mustLogin(t, svc)) + if err != nil { + t.Fatalf("admin id: %v", err) + } + + now := time.Now().UTC() + expired := []string{"expired-1", "expired-2"} + for _, id := range expired { + if _, err := conn.ExecContext(ctx, + `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, + id, adminID, now.Add(-time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("insert expired session: %v", err) + } + } + future := "still-valid" + if _, err := conn.ExecContext(ctx, + `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, + future, adminID, now.Add(time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("insert valid session: %v", err) + } + + before := countSessions(t, conn) + removed, err := svc.PruneExpiredSessions(ctx) + if err != nil { + t.Fatalf("prune: %v", err) + } + if removed != int64(len(expired)) { + t.Fatalf("应删除 %d 条过期会话,实际 %d", len(expired), removed) + } + if after := countSessions(t, conn); after != before-int64(len(expired)) { + t.Fatalf("会话数不符:before=%d after=%d", before, after) + } + if err := svc.ValidateSession(ctx, future); err != nil { + t.Fatalf("未过期会话不应被删:%v", err) + } + + // 再跑一次应当没有可删的(幂等) + if removed, err = svc.PruneExpiredSessions(ctx); err != nil || removed != 0 { + t.Fatalf("重复清理应为空操作:removed=%d err=%v", removed, err) + } +} + +func mustLogin(t *testing.T, svc *Service) string { + t.Helper() + sessionID, err := svc.Login(context.Background(), "admin", "admin-password-123") + if err != nil { + t.Fatalf("login: %v", err) + } + return sessionID +} + +func countSessions(t *testing.T, conn *sql.DB) int64 { + t.Helper() + var count int64 + if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(1) FROM sessions`).Scan(&count); err != nil { + t.Fatalf("count sessions: %v", err) + } + return count +} diff --git a/internal/backup/archive.go b/internal/backup/archive.go index 06fa708..97f11d9 100644 --- a/internal/backup/archive.go +++ b/internal/backup/archive.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "sort" + "strings" "time" "github.com/havline/havline/internal/settings" @@ -106,13 +107,37 @@ func (s *Service) Remove(name string) error { // Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件, // 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。 func (s *Service) Verify(ctx context.Context, path string) (int, error) { + return s.VerifyWith(ctx, path, Options{}) +} + +// VerifyWith 校验归档;加密归档需要提供口令才能验证内容。 +// 自动备份若启用口令,必须把口令传进来,否则一份好备份会被判成坏包而删掉。 +func (s *Service) VerifyWith(ctx context.Context, path string, opts Options) (int, error) { + encrypted, err := IsEncrypted(path) + if err != nil { + return 0, err + } + file, err := os.Open(path) if err != nil { return 0, err } defer file.Close() - gr, err := gzip.NewReader(file) + var reader io.Reader = file + if encrypted { + passphrase := strings.TrimSpace(opts.Passphrase) + if passphrase == "" { + return 0, ErrPassphraseRequired + } + plain, err := openSealedFile(file, passphrase) + if err != nil { + return 0, err + } + reader = plain + } + + gr, err := gzip.NewReader(reader) if err != nil { return 0, fmt.Errorf("备份文件无法解压:%w", err) } @@ -121,6 +146,7 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) { tr := tar.NewReader(gr) count := 0 hasDatabase := false + hasSecret := false for { header, err := tr.Next() if err == io.EOF { @@ -133,16 +159,69 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) { return count, err } count++ - if header.Name == "havline.db" { + switch header.Name { + case "havline.db": hasDatabase = true + case "session_secret": + hasSecret = true } } if !hasDatabase { return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份") } + // 缺少 session_secret 的归档不能判成坏包(旧版本导出的备份本来就长这样, + // 而且数据库与证书都还在,多半仍值得恢复),只是恢复后凭据解不开。 + // 这个差别交给 MissingSessionSecret 单独告知调用方,别混进校验错误里。 + _ = hasSecret return count, nil } +// MissingSessionSecret 判断归档是否缺少凭据加密密钥。 +// 只服务于「恢复后要不要提示用户」,不参与备份有效性判定。 +func (s *Service) MissingSessionSecret(ctx context.Context, path string, opts Options) (bool, error) { + encrypted, err := IsEncrypted(path) + if err != nil { + return false, err + } + file, err := os.Open(path) + if err != nil { + return false, err + } + defer file.Close() + + var reader io.Reader = file + if encrypted { + passphrase := strings.TrimSpace(opts.Passphrase) + if passphrase == "" { + return false, ErrPassphraseRequired + } + plain, err := openSealedFile(file, passphrase) + if err != nil { + return false, err + } + reader = plain + } + gr, err := gzip.NewReader(reader) + if err != nil { + return false, fmt.Errorf("备份文件无法解压:%w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err == io.EOF { + return true, nil + } + if err != nil { + return false, err + } + if header.Name == "session_secret" { + return false, nil + } + } +} + // KeepCount 读取保留份数(未配置或越界时回落到默认值)。 // 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。 func KeepCount(ctx context.Context, store *settings.Store) int { diff --git a/internal/backup/auto.go b/internal/backup/auto.go index 0968223..84d5522 100644 --- a/internal/backup/auto.go +++ b/internal/backup/auto.go @@ -50,6 +50,8 @@ func (r *AutoRunner) Run(ctx context.Context) { return } + // 自动备份不加密:口令没法安全地存在同一台设备上(存了就等于没加密), + // 需要加密归档的用户走「立即备份 / 立即下载」手动流程。 path, err := r.svc.Export(ctx) if err != nil { r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error())) diff --git a/internal/backup/crypto.go b/internal/backup/crypto.go new file mode 100644 index 0000000..28ecb16 --- /dev/null +++ b/internal/backup/crypto.go @@ -0,0 +1,148 @@ +package backup + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/crypto/scrypt" +) + +// 加密归档的容器格式:magic + salt + nonce + AES-256-GCM 密文。 +// +// 为什么不用「拿 session_secret 直接加密」:那份密钥本来就在数据目录里, +// 跟着归档一起发出去等于没加密。用户填的口令才是唯一不随文件流动的东西。 +var encMagic = []byte("HAVLINE-ENC1\n") + +// ErrPassphraseRequired 表示这份归档被口令加密过,必须提供口令才能读取。 +var ErrPassphraseRequired = errors.New("该备份已加密,需要提供口令") + +// 加密参数:默认值在 NAS 这类低功耗设备上约需几十到一百毫秒, +// 对「一天一次备份」完全可接受,同时把离线爆破成本抬到足够高。 +const ( + scryptN = 1 << 15 + scryptR = 8 + scryptP = 1 + scryptKeyLen = 32 + scryptSaltLen = 16 +) + +// IsEncrypted 判断一份归档是否被口令加密(只读开头几个字节)。 +func IsEncrypted(path string) (bool, error) { + file, err := os.Open(path) + if err != nil { + return false, err + } + defer file.Close() + head := make([]byte, len(encMagic)) + if _, err := io.ReadFull(file, head); err != nil { + // 短到连 magic 都放不下:只能是空文件或残件,交给后续解压去报错 + return false, nil + } + return string(head) == string(encMagic), nil +} + +// sealFile 把 src 全量读出,加密后写进 dst。 +// 备份受「恢复上传 64MB」这一上限约束,整份读进内存是可控的, +// 换来的是不需要自己拼分块 GCM 的复杂度。 +func sealFile(dst io.Writer, src io.Reader, passphrase string) error { + if passphrase == "" { + return fmt.Errorf("口令不能为空") + } + salt := make([]byte, scryptSaltLen) + if _, err := rand.Read(salt); err != nil { + return err + } + key, err := deriveKey(passphrase, salt) + if err != nil { + return err + } + block, err := aes.NewCipher(key) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return err + } + plain, err := io.ReadAll(src) + if err != nil { + return err + } + + // magic 作为附加认证数据:容器格式被改动(比如换成别的格式)时解密直接失败 + if _, err := dst.Write(encMagic); err != nil { + return err + } + if _, err := dst.Write(salt); err != nil { + return err + } + if _, err := dst.Write(nonce); err != nil { + return err + } + _, err = dst.Write(gcm.Seal(nil, nonce, plain, encMagic)) + return err +} + +// openFile 解开一份加密归档,返回可读的明文流。 +func openSealedFile(src io.Reader, passphrase string) (io.Reader, error) { + head := make([]byte, len(encMagic)) + if _, err := io.ReadFull(src, head); err != nil { + return nil, fmt.Errorf("无效的备份文件") + } + if string(head) != string(encMagic) { + return nil, fmt.Errorf("无效的备份文件") + } + salt := make([]byte, scryptSaltLen) + if _, err := io.ReadFull(src, salt); err != nil { + return nil, fmt.Errorf("备份文件已损坏") + } + key, err := deriveKey(passphrase, salt) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(src, nonce); err != nil { + return nil, fmt.Errorf("备份文件已损坏") + } + ciphertext, err := io.ReadAll(src) + if err != nil { + return nil, err + } + plain, err := gcm.Open(nil, nonce, ciphertext, encMagic) + if err != nil { + // 校验失败只说明「密钥不对或内容被改过」,对用户是同一件事 + return nil, fmt.Errorf("口令错误,或备份文件已被修改") + } + return newByteReader(plain), nil +} + +// newByteReader 把内存里的明文包成 io.Reader;抽出来是为了让调用点读起来短一点。 +func newByteReader(data []byte) io.Reader { + return bytes.NewReader(data) +} + +func deriveKey(passphrase string, salt []byte) ([]byte, error) { + key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen) + if err != nil { + return nil, fmt.Errorf("派生加密密钥失败: %w", err) + } + return key, nil +} diff --git a/internal/backup/encryption_test.go b/internal/backup/encryption_test.go new file mode 100644 index 0000000..1bcb4c1 --- /dev/null +++ b/internal/backup/encryption_test.go @@ -0,0 +1,269 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +// listEntries 读出归档里的条目名,用于断言包含关系。 +func listEntries(t *testing.T, path string) []string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gr, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + defer gr.Close() + tr := tar.NewReader(gr) + names := []string{} + for { + header, err := tr.Next() + if err == io.EOF { + return names + } + if err != nil { + t.Fatal(err) + } + names = append(names, header.Name) + } +} + +// TestExportIncludesSessionSecretAndFrpConfig:备份必须带上凭据加密密钥与 frpc 配置。 +// 少了 session_secret,恢复后库里的 DDNS / FRP / Cloudflare / 通知凭据全部解不开。 +func TestExportIncludesSessionSecretAndFrpConfig(t *testing.T) { + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + frpDir := filepath.Join(dataDir, "frp") + if err := os.MkdirAll(frpDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(frpDir, "frpc-server-1.toml"), []byte("token = \"x\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + path, err := New(dataDir).Export(context.Background()) + if err != nil { + t.Fatal(err) + } + entries := listEntries(t, path) + has := map[string]bool{} + for _, name := range entries { + has[name] = true + } + for _, want := range []string{"havline.db", "session_secret", "frp/frpc-server-1.toml"} { + if !has[want] { + t.Fatalf("备份应包含 %q,实际条目:%v", want, entries) + } + } +} + +// TestExportSkipsFrpBinariesAndLogs:frp/bin、frp/downloads 与 *.log 体积大且可重新获取,不进归档。 +func TestExportSkipsFrpBinariesAndLogs(t *testing.T) { + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + frpDir := filepath.Join(dataDir, "frp") + for _, dir := range []string{filepath.Join(frpDir, "bin", "v0.70.1"), filepath.Join(frpDir, "downloads")} { + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(frpDir, "bin", "v0.70.1", "frpc"): "binary", + filepath.Join(frpDir, "downloads", "frpc.zip"): "zip", + filepath.Join(frpDir, "frpc-server-1.log"): "log", + filepath.Join(frpDir, "frpc-server-1.toml"): "token = \"x\"\n", + filepath.Join(frpDir, "active.json"): "{}", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + path, err := New(dataDir).Export(context.Background()) + if err != nil { + t.Fatal(err) + } + entries := listEntries(t, path) + has := map[string]bool{} + for _, name := range entries { + has[name] = true + } + for _, skip := range []string{"frp/bin/v0.70.1/frpc", "frp/downloads/frpc.zip", "frp/frpc-server-1.log"} { + if has[skip] { + t.Fatalf("归档不应包含 %q,实际条目:%v", skip, entries) + } + } + for _, want := range []string{"frp/frpc-server-1.toml", "frp/active.json"} { + if !has[want] { + t.Fatalf("归档应保留 %q,实际条目:%v", want, entries) + } + } +} + +// TestExportWithPassphraseIsEncrypted:给了口令就必须是加密容器,且不带口令读不出来。 +func TestExportWithPassphraseIsEncrypted(t *testing.T) { + dataDir := seedDataDir(t) + path, err := New(dataDir).ExportWith(context.Background(), Options{Passphrase: "correct horse"}) + if err != nil { + t.Fatal(err) + } + encrypted, err := IsEncrypted(path) + if err != nil { + t.Fatal(err) + } + if !encrypted { + t.Fatal("给了口令导出的归档应被标记为加密") + } + + // 无口令恢复:必须明确报「需要口令」,而不是含糊的解压失败 + target := t.TempDir() + if err := New(target).Restore(context.Background(), path); !errors.Is(err, ErrPassphraseRequired) { + t.Fatalf("无口令恢复应报 ErrPassphraseRequired,实际 %v", err) + } + + // 错口令:GCM 校验失败 + if err := New(target).RestoreWith(context.Background(), path, Options{Passphrase: "wrong"}); err == nil { + t.Fatal("错误口令不应恢复成功") + } + + // 正确口令:能恢复出原来的文件内容 + if err := New(target).RestoreWith(context.Background(), path, Options{Passphrase: "correct horse"}); err != nil { + t.Fatalf("正确口令应恢复成功:%v", err) + } + data, err := os.ReadFile(filepath.Join(target, "havline.db")) + if err != nil { + t.Fatal(err) + } + if string(data) != "db" { + t.Fatalf("恢复内容不符:%q", data) + } +} + +// TestExportWithoutPassphraseStaysCompatible:不给口令时仍产出未加密归档, +// 老版本与现有客户端可以直接解压。 +func TestExportWithoutPassphraseStaysCompatible(t *testing.T) { + dataDir := seedDataDir(t) + path, err := New(dataDir).Export(context.Background()) + if err != nil { + t.Fatal(err) + } + encrypted, err := IsEncrypted(path) + if err != nil { + t.Fatal(err) + } + if encrypted { + t.Fatal("未给口令时的归档不应加密") + } + if _, err := New(t.TempDir()).Verify(context.Background(), path); err != nil { + t.Fatalf("未加密归档应能通过校验:%v", err) + } +} + +// TestVerifyWithEncryptedArchiveNeedsPassphrase:加密归档校验同样需要口令, +// 否则自动备份会把一份好备份判成坏包删掉。 +func TestVerifyWithEncryptedArchiveNeedsPassphrase(t *testing.T) { + dataDir := seedDataDir(t) + path, err := New(dataDir).ExportWith(context.Background(), Options{Passphrase: "pw"}) + if err != nil { + t.Fatal(err) + } + if _, err := New(dataDir).Verify(context.Background(), path); !errors.Is(err, ErrPassphraseRequired) { + t.Fatalf("无口令校验应报 ErrPassphraseRequired,实际 %v", err) + } + count, err := New(dataDir).VerifyWith(context.Background(), path, Options{Passphrase: "pw"}) + if err != nil { + t.Fatalf("带口令校验应通过:%v", err) + } + if count == 0 { + t.Fatal("校验应返回条目数") + } +} + +// TestMissingSessionSecretDetectsLegacyArchive:旧归档没有 session_secret, +// 能恢复但凭据解不开,要能被识别出来提示用户。 +func TestMissingSessionSecretDetectsLegacyArchive(t *testing.T) { + dataDir := t.TempDir() + // 手工造一个「只有 havline.db」的旧式归档 + legacy := writeArchive(t, map[string]string{"havline.db": "db"}) + svc := New(dataDir) + + missing, err := svc.MissingSessionSecret(context.Background(), legacy, Options{}) + if err != nil { + t.Fatalf("识别旧归档不应报错:%v", err) + } + if !missing { + t.Fatal("旧归档应被判定为缺少 session_secret") + } + + fresh := seedExport(t, seedDataDir(t)) + missing, err = svc.MissingSessionSecret(context.Background(), fresh, Options{}) + if err != nil { + t.Fatal(err) + } + if missing { + t.Fatal("新归档包含 session_secret,不应判定为缺失") + } +} + +// TestRestoreKeepsSecretPermissions:session_secret 还原后必须是 0600, +// 否则同机其他用户就能读到解密凭据的钥匙。 +func TestRestoreKeepsSecretPermissions(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("Windows 不保留 Unix 权限位") + } + dataDir := seedDataDir(t) + path, err := New(dataDir).Export(context.Background()) + if err != nil { + t.Fatal(err) + } + target := t.TempDir() + if err := New(target).Restore(context.Background(), path); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(target, "session_secret")) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != restoreSecretMode { + t.Fatalf("session_secret 还原后权限应为 %o,实际 %o", restoreSecretMode, got) + } +} + +func seedDataDir(t *testing.T) string { + t.Helper() + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + return dataDir +} + +func seedExport(t *testing.T, dataDir string) string { + t.Helper() + path, err := New(dataDir).Export(context.Background()) + if err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/backup/growing_test.go b/internal/backup/growing_test.go new file mode 100644 index 0000000..049d387 --- /dev/null +++ b/internal/backup/growing_test.go @@ -0,0 +1,151 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "database/sql" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/havline/havline/internal/db" +) + +// openTestDB 建一个跑过迁移的临时库,用于验证快照路径。 +func openTestDB(t *testing.T) (*sql.DB, error) { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "source.db")) + if err != nil { + return nil, err + } + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + +// TestExportSurvivesGrowingFiles 复现「立即备份」报 archive/tar: write too long 的现场。 +// +// 成因:addFile 用 WalkDir 那一刻拿到的 FileInfo.Size() 写 tar 头,随后才去读文件内容。 +// 而 logs、frpc 配置这些文件在打包期间还在长,读到的字节多于头部声明,tar 直接报错、 +// 整份备份作废。这里的做法是让一个「边打包边追加」的写入者持续往文件里写。 +func TestExportSurvivesGrowingFiles(t *testing.T) { + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + + // 造一个会持续增长的文件,放在会被归档的目录下 + frpDir := filepath.Join(dataDir, "frp") + if err := os.MkdirAll(frpDir, 0o750); err != nil { + t.Fatal(err) + } + growing := filepath.Join(frpDir, "frpc-server-1.toml") + if err := os.WriteFile(growing, []byte("token = \"x\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + chunk := []byte("padding = \"" + "0123456789abcdef" + "\"\n") + for { + select { + case <-stop: + return + default: + } + f, err := os.OpenFile(growing, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return + } + _, _ = f.Write(chunk) + _ = f.Close() + time.Sleep(time.Millisecond) + } + }() + + path, err := New(dataDir).Export(context.Background()) + close(stop) + <-done + + if err != nil { + t.Fatalf("文件在打包期间增长时导出不应失败:%v", err) + } + if _, err := New(dataDir).Verify(context.Background(), path); err != nil { + t.Fatalf("导出的归档应可校验:%v", err) + } +} + +// TestExportUsesDatabaseSnapshot:注入数据库后,归档里的 havline.db 必须来自快照, +// 而不是直接读主文件——主文件在写入时并不自洽。 +func TestExportUsesDatabaseSnapshot(t *testing.T) { + dataDir := t.TempDir() + // 故意在主文件里放一段「不是有效 SQLite」的内容: + // 若实现退回直接打包文件,归档里就会是这串字节。 + if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("not-a-valid-db"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + + conn, err := openTestDB(t) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + svc := New(dataDir) + svc.SetDB(conn) + path, err := svc.Export(context.Background()) + if err != nil { + t.Fatal(err) + } + + content := readArchiveEntry(t, path, "havline.db") + if string(content) == "not-a-valid-db" { + t.Fatal("归档里的数据库应来自 VACUUM INTO 快照,而不是直接读取主文件") + } + // SQLite 文件头是 "SQLite format 3\x00" + if len(content) < 16 || string(content[:15]) != "SQLite format 3" { + t.Fatalf("快照应是有效 SQLite 文件,实际头部:%q", content[:min(16, len(content))]) + } +} + +func readArchiveEntry(t *testing.T, path, want string) []byte { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gr, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + defer gr.Close() + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + t.Fatal(err) + } + if header.Name != want { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + return data + } +} diff --git a/internal/backup/realdata_test.go b/internal/backup/realdata_test.go new file mode 100644 index 0000000..75aacaf --- /dev/null +++ b/internal/backup/realdata_test.go @@ -0,0 +1,56 @@ +package backup + +import ( + "context" + "os" + "testing" + + "github.com/havline/havline/internal/db" +) + +// TestExportRealData 用真实数据目录跑一遍完整导出,验证「立即备份」在实机上不再报 +// archive/tar: write too long,并且归档里确实带上了 session_secret 与 frp 配置。 +// 仅在设置了 HV_REAL_DATA 时执行,避免把本地数据依赖带进 CI。 +func TestExportRealData(t *testing.T) { + dir := os.Getenv("HV_REAL_DATA") + if dir == "" { + t.Skip("未设置 HV_REAL_DATA") + } + conn, err := db.Open(dir + "/havline.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + svc := New(dir) + svc.SetDB(conn) + path, err := svc.Export(context.Background()) + if err != nil { + t.Fatalf("导出失败:%v", err) + } + defer os.Remove(path) + + entries, err := svc.Verify(context.Background(), path) + if err != nil { + t.Fatalf("校验失败:%v", err) + } + missing, err := svc.MissingSessionSecret(context.Background(), path, Options{}) + if err != nil { + t.Fatal(err) + } + if missing { + t.Fatal("真实数据的归档应包含 session_secret") + } + t.Logf("导出成功:%s,条目 %d", path, entries) + + // 有口令时也要能正常导出 + 校验 + encPath, err := svc.ExportWith(context.Background(), Options{Passphrase: "test-pass"}) + if err != nil { + t.Fatalf("加密导出失败:%v", err) + } + defer os.Remove(encPath) + if _, err := svc.VerifyWith(context.Background(), encPath, Options{Passphrase: "test-pass"}); err != nil { + t.Fatalf("加密归档校验失败:%v", err) + } + t.Logf("加密导出成功:%s", encPath) +} diff --git a/internal/backup/service.go b/internal/backup/service.go index 4dd9b75..a65c36f 100644 --- a/internal/backup/service.go +++ b/internal/backup/service.go @@ -4,6 +4,7 @@ import ( "archive/tar" "compress/gzip" "context" + "database/sql" "fmt" "io" "os" @@ -14,36 +15,114 @@ import ( type Service struct { dataDir string + // db 用于把数据库导出成一致性快照。为 nil 时退回「直接打包数据库文件」, + // 后者只在测试或不接数据库的场景下使用。 + db *sql.DB } +// Options 是导出 / 恢复的可选项;Passphrase 非空时归档会被口令加密。 +type Options struct { + Passphrase string +} + +// archiveEntries 是要打进归档的数据。 +// +// 必须包含 session_secret:它同时是 secret.Box 的 AES-GCM 密钥, +// 少了它则数据库里的 DDNS / FRP / Cloudflare / 通知凭据在换机恢复后全部解不开, +// 界面表现为「配置都在,但一执行就报解密失败」。 +// 必须包含 frp:frpc 的 toml 里有明文 token,缺了它穿透服务需要重新配一遍。 +var archiveEntries = []string{"havline.db", "session_secret", "certs", "nginx", "frp", "cloudflared/tunnels"} + // 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位 const ( restoreFileMode = 0o640 restoreDirMode = 0o750 + // 密钥文件的权限单独收紧:它是解密库里全部凭据的那把钥匙 + restoreSecretMode = 0o600 ) func New(dataDir string) *Service { return &Service{dataDir: dataDir} } +// SetDB 注入数据库连接,导出一致性快照时使用。 +// 不放进 New 的签名里:现有调用点(含测试)很多只关心文件集合。 +func (s *Service) SetDB(db *sql.DB) { + s.db = db +} + // backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验 const backupTimeFmt = "20060102-150405" -// Export 导出一份归档并返回路径;文件留在 /backups 下,由调用方决定下载后删除还是长期保留。 +// Export 导出一份未加密归档并返回路径;文件留在 /backups 下, +// 由调用方决定下载后删除还是长期保留。 func (s *Service) Export(ctx context.Context) (string, error) { + return s.ExportWith(ctx, Options{}) +} + +// ExportWith 导出一份归档;Passphrase 非空时用口令加密。 +func (s *Service) ExportWith(ctx context.Context, opts Options) (string, error) { if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil { return "", err } - // CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀) - file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt))) + + // 先写到暂存文件:加密要先拿到完整的 tar.gz,且暂存名不匹配备份命名规则, + // 中途失败不会在列表里留下一个看起来能用的残件。 + staging, err := os.CreateTemp(s.backupsDir(), "havline-backup-*.tar.gz.tmp") if err != nil { return "", err } - outPath := file.Name() - if err := s.writeArchive(ctx, file, outPath); err != nil { + stagingPath := staging.Name() + defer os.Remove(stagingPath) + if err := s.writeArchive(ctx, staging, stagingPath); err != nil { return "", err } - return outPath, nil + + // CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀) + final, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt))) + if err != nil { + return "", err + } + finalPath := final.Name() + if err := final.Close(); err != nil { + _ = os.Remove(finalPath) + return "", err + } + _ = os.Chmod(finalPath, 0o600) + + passphrase := strings.TrimSpace(opts.Passphrase) + if passphrase == "" { + // 未加密:直接把暂存文件改名成正式备份名 + if err := os.Remove(finalPath); err != nil && !os.IsNotExist(err) { + return "", err + } + if err := os.Rename(stagingPath, finalPath); err != nil { + return "", err + } + return finalPath, nil + } + + src, err := os.Open(stagingPath) + if err != nil { + _ = os.Remove(finalPath) + return "", err + } + defer src.Close() + dst, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + _ = os.Remove(finalPath) + return "", err + } + if err := sealFile(dst, src, passphrase); err != nil { + _ = dst.Close() + _ = os.Remove(finalPath) + return "", err + } + if err := dst.Close(); err != nil { + _ = os.Remove(finalPath) + return "", err + } + return finalPath, nil } // writeArchive 写归档,逐层检查关闭错误。 @@ -59,7 +138,15 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin gw := gzip.NewWriter(file) tw := tar.NewWriter(gw) - for _, entry := range []string{"havline.db", "certs", "nginx", "cloudflared/tunnels"} { + for _, entry := range archiveEntries { + // havline.db 走一致性快照,其余(证书 / nginx / frp / 密钥)直接打包 + if entry == databaseName { + if err := s.addDatabase(ctx, tw); err != nil { + discard() + return err + } + continue + } if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil { discard() return err @@ -80,14 +167,106 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin return nil } +// databaseName 是归档里的数据库条目名,也是恢复时的落点文件名。 +const databaseName = "havline.db" + +// addDatabase 把数据库打成一份一致性快照。 +// +// 不能直接读 havline.db 文件:SQLite 在写入过程中,主文件与 -wal / -journal +// 的内容随时在变,直接 `cp` 出来的副本可能是「结构完整但内容自相矛盾」的库 +// (恢复后表现为表锁死、索引与数据不一致)。VACUUM INTO 由 SQLite 自己保证 +// 快照一致性,且不需要停机。 +func (s *Service) addDatabase(ctx context.Context, tw *tar.Writer) error { + if s.db == nil { + // 没接数据库(测试或旧调用点):退回直接打包文件 + return addToArchive(ctx, tw, s.dataDir, databaseName) + } + + snapshot, err := os.CreateTemp("", "havline-snapshot-*.db") + if err != nil { + return err + } + snapshotPath := snapshot.Name() + // VACUUM INTO 要求目标文件不存在;CreateTemp 已经把文件创建出来了 + if err := snapshot.Close(); err != nil { + _ = os.Remove(snapshotPath) + return err + } + if err := os.Remove(snapshotPath); err != nil { + return err + } + defer os.Remove(snapshotPath) + + // 参数不能直接拼在 SQL 里(VACUUM INTO 支持字符串字面量,但不支持占位符), + // 所以这里做转义:路径里的单引号在 Windows 临时目录下可能出现。 + quoted := strings.ReplaceAll(snapshotPath, "'", "''") + if _, err := s.db.ExecContext(ctx, `VACUUM INTO '`+quoted+`'`); err != nil { + return fmt.Errorf("导出数据库快照失败:%w", err) + } + + info, err := os.Stat(snapshotPath) + if err != nil { + return err + } + file, err := os.Open(snapshotPath) + if err != nil { + return err + } + defer file.Close() + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = databaseName + header.Size = info.Size() + if err := tw.WriteHeader(header); err != nil { + return err + } + written, err := io.Copy(tw, io.LimitReader(file, info.Size())) + if err != nil { + return err + } + if written < info.Size() { + if _, err := io.CopyN(tw, zeroReader{}, info.Size()-written); err != nil { + return err + } + } + return nil +} + +// Restore 还原一份未加密归档。 func (s *Service) Restore(ctx context.Context, archivePath string) error { + return s.RestoreWith(ctx, archivePath, Options{}) +} + +// RestoreWith 还原归档;归档被口令加密时必须提供 Passphrase。 +func (s *Service) RestoreWith(ctx context.Context, archivePath string, opts Options) error { + encrypted, err := IsEncrypted(archivePath) + if err != nil { + return err + } + file, err := os.Open(archivePath) if err != nil { return err } defer file.Close() - gr, err := gzip.NewReader(file) + var reader io.Reader = file + if encrypted { + passphrase := strings.TrimSpace(opts.Passphrase) + if passphrase == "" { + return ErrPassphraseRequired + } + plain, err := openSealedFile(file, passphrase) + if err != nil { + return err + } + reader = plain + } + + gr, err := gzip.NewReader(reader) if err != nil { return fmt.Errorf("无效的备份文件") } @@ -121,7 +300,7 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error { return err } // 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置 - out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode) + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreModeFor(header.Name)) if err != nil { return err } @@ -135,6 +314,14 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error { return nil } +// restoreModeFor 决定还原后的权限:session_secret 是解密凭据的钥匙,单独收紧到 0600。 +func restoreModeFor(name string) os.FileMode { + if filepath.Base(filepath.ToSlash(name)) == "session_secret" { + return restoreSecretMode + } + return restoreFileMode +} + func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error { path := filepath.Join(baseDir, rel) info, err := os.Stat(path) @@ -150,6 +337,24 @@ func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) erro return addFile(tw, baseDir, rel, info) } +// skipArchivePath 排除不该进归档的内容: +// - backups/:备份里套备份会指数级膨胀; +// - frp/bin、frp/downloads:frpc 二进制与下载缓存,体积大且可重新下载; +// - frp/*.log:运行日志没有恢复价值。 +func skipArchivePath(rel string) bool { + slash := filepath.ToSlash(rel) + if strings.Contains("/"+slash+"/", "/backups/") { + return true + } + if slash == "frp/bin" || strings.HasPrefix(slash, "frp/bin/") { + return true + } + if slash == "frp/downloads" || strings.HasPrefix(slash, "frp/downloads/") { + return true + } + return strings.HasPrefix(slash, "frp/") && strings.HasSuffix(slash, ".log") +} + func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error { root := filepath.Join(baseDir, rel) return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { @@ -159,14 +364,14 @@ func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error { if d.IsDir() { return nil } - if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) { - return nil - } - info, err := d.Info() + relPath, err := filepath.Rel(baseDir, path) if err != nil { return err } - relPath, err := filepath.Rel(baseDir, path) + if skipArchivePath(relPath) { + return nil + } + info, err := d.Info() if err != nil { return err } @@ -181,14 +386,46 @@ func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error { return err } defer file.Close() + + // 必须以「打开后」的尺寸为准:WalkDir 给的 info 是遍历那一刻的快照, + // 而这台机器上 logs、frpc 配置这些文件随时在长。用旧尺寸写进 tar 头、 + // 再读出更多内容,就会报 archive/tar: write too long。 + size := info.Size() + if current, err := file.Stat(); err == nil { + size = current.Size() + } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = filepath.ToSlash(rel) + header.Size = size if err := tw.WriteHeader(header); err != nil { return err } - _, err = io.Copy(tw, file) - return err + + // 精确写入 size 字节:tar 要求实际字节数与头部声明严格相等。 + // - 文件在读取期间变长 → 多余部分截断(LimitReader); + // - 文件在读取期间被截短 → 补零凑够(zeroReader),保证这一条记录结构完整, + // 不至于让整份归档作废。 + written, err := io.Copy(tw, io.LimitReader(file, size)) + if err != nil { + return err + } + if written < size { + if _, err := io.CopyN(tw, zeroReader{}, size-written); err != nil { + return err + } + } + return nil +} + +// zeroReader 提供无限个零字节,用于补齐读取期间被截短的文件条目。 +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil } diff --git a/internal/cloudflared/lifecycle_test.go b/internal/cloudflared/lifecycle_test.go new file mode 100644 index 0000000..26c8fa9 --- /dev/null +++ b/internal/cloudflared/lifecycle_test.go @@ -0,0 +1,133 @@ +package cloudflared + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/db" + "github.com/havline/havline/internal/secret" +) + +// newTestService 装配一个只依赖 SQLite 的 Service,cloudflared 二进制不存在时 Start 会失败, +// 正好用来验证「哪些隧道会被尝试拉起」而不真的启动进程。 +func newTestService(t *testing.T) (*Service, *Store) { + t.Helper() + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatal(err) + } + box, err := secret.NewBox("cloudflared-lifecycle-test") + if err != nil { + t.Fatal(err) + } + store := NewStore(conn) + svc := NewService(config.Config{DataDir: dir}, store, box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err := svc.EnsureDirs(); err != nil { + t.Fatal(err) + } + return svc, store +} + +// createTunnel 直接落库造一条隧道,跳过需要 Cloudflare API 的 Create。 +func createTunnel(t *testing.T, store *Store, name string, autoStart bool) Tunnel { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + tunnel, err := store.Create(ctx, CreateInput{ + Name: name, + Mode: ModeAccountLocal, + Network: DefaultNetworkSettings(), + AutoStart: autoStart, + }, "11111111-1111-1111-1111-111111111111", "acc-tag", "", "", + filepath.Join(dir, "config.yml"), filepath.Join(dir, "creds.json"), filepath.Join(dir, "cloudflared.log")) + if err != nil { + t.Fatal(err) + } + return tunnel +} + +// TestStartAutoSkipsNonAutoStart:应用启动只应拉起 auto_start 的隧道。 +// 之前启动路径错用了 StartAll,用户手动停掉的隧道会在重启后自己回来。 +func TestStartAutoSkipsNonAutoStart(t *testing.T) { + tunnels := []Tunnel{ + {ID: 1, Name: "自动", AutoStart: true}, + {ID: 2, Name: "手动", AutoStart: false}, + {ID: 3, Name: "也自动", AutoStart: true}, + } + targets := autoStartTargets(tunnels) + if len(targets) != 2 { + t.Fatalf("应只挑出 2 条 auto_start 隧道,实际 %d:%#v", len(targets), targets) + } + for _, tunnel := range targets { + if !tunnel.AutoStart { + t.Fatalf("挑出了未勾选 auto_start 的隧道:%#v", tunnel) + } + } +} + +// TestStartAutoRespectsGlobalSwitch:全局总开关关闭时一条都不启动。 +func TestStartAutoRespectsGlobalSwitch(t *testing.T) { + svc, store := newTestService(t) + ctx := context.Background() + tunnel := createTunnel(t, store, "自动", true) + if _, err := svc.SetEnabled(ctx, false); err != nil { + t.Fatal(err) + } + + svc.StartAuto(ctx) + + got, err := store.Get(ctx, tunnel.ID) + if err != nil { + t.Fatal(err) + } + if got.Status == "failed" { + t.Fatalf("全局停用时不应尝试启动隧道,实际 %q", got.Status) + } +} + +// TestStopAllMarksStopped:StopAll 应把所有隧道的运行态改写为 stopped, +// 供应用退出与「全局停用」两条路径复用。 +func TestStopAllMarksStopped(t *testing.T) { + svc, store := newTestService(t) + ctx := context.Background() + first := createTunnel(t, store, "一", true) + second := createTunnel(t, store, "二", true) + for _, id := range []int64{first.ID, second.ID} { + if err := store.SetRuntime(ctx, id, "connected", "", 0); err != nil { + t.Fatal(err) + } + } + + svc.StopAll(ctx) + + for _, id := range []int64{first.ID, second.ID} { + got, err := store.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + if got.Status != "stopped" { + t.Fatalf("隧道 %d 应为 stopped,实际 %q", id, got.Status) + } + } +} + +// TestManagerStopIsIdempotent:没有子进程时 Stop 不应报错(退出路径会无差别调用)。 +func TestManagerStopIsIdempotent(t *testing.T) { + dir := t.TempDir() + m := NewManager(config.Config{DataDir: dir}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := m.Stop(42); err != nil { + t.Fatalf("停止不存在的隧道不应报错:%v", err) + } + if status := m.Status(42); status.Status != "stopped" || status.Running { + t.Fatalf("未运行的隧道状态应为 stopped:%#v", status) + } +} diff --git a/internal/cloudflared/manager.go b/internal/cloudflared/manager.go index feccf26..ca9f963 100644 --- a/internal/cloudflared/manager.go +++ b/internal/cloudflared/manager.go @@ -2,6 +2,7 @@ package cloudflared import ( "bufio" + "context" "fmt" "io" "log/slog" @@ -35,12 +36,31 @@ type Manager struct { logger *slog.Logger mu sync.Mutex processes map[int64]*processState + // baseCtx 是应用级 ctx:应用退出时用它连带结束未显式 Stop 的子进程。 + // 不能用请求 ctx——HTTP 请求一结束子进程就会被杀掉。 + baseCtx context.Context } func NewManager(cfg config.Config, logger *slog.Logger) *Manager { return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}} } +// SetBaseContext 注入应用级 ctx,供子进程生命周期绑定。 +func (m *Manager) SetBaseContext(ctx context.Context) { + m.mu.Lock() + defer m.mu.Unlock() + m.baseCtx = ctx +} + +func (m *Manager) lifecycleContext() context.Context { + m.mu.Lock() + defer m.mu.Unlock() + if m.baseCtx == nil { + return context.Background() + } + return m.baseCtx +} + func buildRunArgs(tunnel Tunnel, configPath, token string) []string { args := []string{"tunnel"} if tunnel.Mode == ModeTokenRemote { @@ -116,7 +136,7 @@ func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) err m.remove(tunnel.ID) return err } - cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...) + cmd := exec.CommandContext(m.lifecycleContext(), binaryPath, buildRunArgs(tunnel, configPath, token)...) cmd.Env = buildEnv(tunnel.Network) cmd.SysProcAttr = hideWindowAttr() stdout, err := cmd.StdoutPipe() diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index e6ac481..57c1dd3 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -47,6 +47,11 @@ func (s *Service) EnsureDirs() error { return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755) } +// SetBaseContext 注入应用级 ctx:应用退出时 cloudflared 子进程随之结束。 +func (s *Service) SetBaseContext(ctx context.Context) { + s.manager.SetBaseContext(ctx) +} + func (s *Service) List(ctx context.Context) ([]Tunnel, error) { items, err := s.store.List(ctx) if err != nil { @@ -752,6 +757,18 @@ func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) { return hosts, nil } +// autoStartTargets 从隧道列表里挑出启动时应被拉起的隧道。 +// 单独抽出来是为了能用纯函数覆盖「只启动 auto_start」这条语义。 +func autoStartTargets(tunnels []Tunnel) []Tunnel { + out := make([]Tunnel, 0, len(tunnels)) + for _, tunnel := range tunnels { + if tunnel.AutoStart { + out = append(out, tunnel) + } + } + return out +} + // StartAuto 在应用启动时拉起标记了 auto_start 的隧道。 func (s *Service) StartAuto(ctx context.Context) { settings, err := s.store.GetSettingsRaw(ctx) @@ -767,10 +784,7 @@ func (s *Service) StartAuto(ctx context.Context) { s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error()) return } - for _, tunnel := range tunnels { - if !tunnel.AutoStart { - continue - } + for _, tunnel := range autoStartTargets(tunnels) { if _, err := s.Start(ctx, tunnel.ID); err != nil { s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error()) } diff --git a/internal/ddns/store.go b/internal/ddns/store.go index 40cf4d4..9fcd9c3 100644 --- a/internal/ddns/store.go +++ b/internal/ddns/store.go @@ -10,18 +10,18 @@ import ( ) type Config struct { - ID int64 `json:"id"` - Provider string `json:"provider"` - Name string `json:"name"` - RootDomain string `json:"root_domain"` - RecordName string `json:"record_name"` - RecordNames []string `json:"record_names"` - IPv4Enabled bool `json:"ipv4_enabled"` - IPv6Enabled bool `json:"ipv6_enabled"` - Enabled bool `json:"enabled"` - HasToken bool `json:"has_token"` - CustomIPv4 string `json:"custom_ipv4,omitempty"` - CustomIPv6 string `json:"custom_ipv6,omitempty"` + ID int64 `json:"id"` + Provider string `json:"provider"` + Name string `json:"name"` + RootDomain string `json:"root_domain"` + RecordName string `json:"record_name"` + RecordNames []string `json:"record_names"` + IPv4Enabled bool `json:"ipv4_enabled"` + IPv6Enabled bool `json:"ipv6_enabled"` + Enabled bool `json:"enabled"` + HasToken bool `json:"has_token"` + CustomIPv4 string `json:"custom_ipv4,omitempty"` + CustomIPv6 string `json:"custom_ipv6,omitempty"` LastIPv4 string `json:"last_ipv4"` LastIPv6 string `json:"last_ipv6"` LastStatus string `json:"last_status"` diff --git a/internal/ddns/tencentcloud_test.go b/internal/ddns/tencentcloud_test.go index aa9b9fb..395a28a 100644 --- a/internal/ddns/tencentcloud_test.go +++ b/internal/ddns/tencentcloud_test.go @@ -13,9 +13,9 @@ func TestTencentSubdomain(t *testing.T) { func TestCredentialsTencentCloud(t *testing.T) { cred := CredentialsFromSave(SaveInput{ - Provider: "tencentcloud", - APIToken: "AKIDxxx", - APISecret: "secret", + Provider: "tencentcloud", + APIToken: "AKIDxxx", + APISecret: "secret", }) if cred.Provider != "tencentcloud" || !cred.HasValues() { t.Fatal("expected tencentcloud credentials") diff --git a/internal/frp/diagnostics.go b/internal/frp/diagnostics.go index 441038b..5df9a22 100644 --- a/internal/frp/diagnostics.go +++ b/internal/frp/diagnostics.go @@ -15,9 +15,9 @@ const ( diagStateFailed = "failed" diagStateUnknown = "unknown" - diagTCPTimeout = 3 * time.Second - diagDNSTimeout = 2 * time.Second - diagnosticsCacheTTL = 15 * time.Second + diagTCPTimeout = 3 * time.Second + diagDNSTimeout = 2 * time.Second + diagnosticsCacheTTL = 15 * time.Second diagnosticsMaxInFlight = 4 ) diff --git a/internal/logstore/reader.go b/internal/logstore/reader.go index 8dc82a4..b32ee90 100644 --- a/internal/logstore/reader.go +++ b/internal/logstore/reader.go @@ -2,10 +2,14 @@ package logstore import ( "bufio" + "compress/gzip" "encoding/json" "fmt" + "io" "os" + "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -298,16 +302,9 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) { counts := make([]int, hourlyBucketCount) labels := calendarHourlyLabels() - file, err := os.Open(path) - if err != nil { - return counts, labels - } - defer file.Close() - - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - entry, ok := parseAccess(scanner.Text()) + // 轮转后的历史内容在归档文件里:只读当前文件会让「今日统计」在轮转后突然掉一半。 + for _, line := range accessLinesForDay(path, day) { + entry, ok := parseAccess(line) if !ok || !isDashboardAccess(entry) { continue } @@ -324,6 +321,75 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) { return counts, labels } +// accessLinesForDay 返回某天的原始日志行:当前文件 + 该日期的归档文件。 +// 只挑文件名里带目标日期的归档,避免把几个月的归档都解开。 +func accessLinesForDay(path string, day time.Time) []string { + var out []string + stamp := day.Format("20060102") + for _, archive := range archivesForDay(path, stamp) { + out = append(out, readGzipLines(archive)...) + } + out = append(out, readFileLines(path)...) + return out +} + +// archivesForDay 列出与目标日期匹配的归档(access-20261024-1305.log.gz): +// 文件名里的日期是按轮转时刻取的,所以既可能属于当天,也可能属于前一天跨零点的那一段。 +func archivesForDay(path, day string) []string { + dir := filepath.Dir(path) + base := strings.TrimSuffix(filepath.Base(path), ".log") + prefix := base + "-" + day + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + out := make([]string, 0, 4) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log.gz") { + continue + } + out = append(out, filepath.Join(dir, name)) + } + sort.Strings(out) + return out +} + +func readFileLines(path string) []string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + var lines []string + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines +} + +func readGzipLines(path string) []string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + gz, err := gzip.NewReader(file) + if err != nil { + return nil + } + defer gz.Close() + var lines []string + scanner := bufio.NewScanner(gz) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines +} + func calendarHourlyLabels() []string { labels := make([]string, hourlyBucketCount) for i := 0; i < hourlyBucketCount; i++ { @@ -358,17 +424,10 @@ func CountTodayAccess(path string) (total int, errors int, avgMs float64) { } func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs float64) { - file, err := os.Open(path) - if err != nil { - return 0, 0, 0 - } - defer file.Close() - var sum float64 - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - entry, ok := parseAccess(scanner.Text()) + // 与小时曲线一致:轮转后的内容在归档里,只扫当前文件会漏掉当天早些时候的请求 + for _, line := range accessLinesForDay(path, day) { + entry, ok := parseAccess(line) if !ok || !isDashboardAccess(entry) { continue } @@ -388,7 +447,14 @@ func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs return total, errors, avgMs } +// tailLines 返回文件末尾的至多 max 行(保持原顺序,旧→新)。 +// +// 之所以从文件尾往前读:日志可能上到几百 MB,而调用方只要最后几十行。 +// 顺序扫描等于每刷新一次页面就把整个文件读一遍,NAS 这种长期运行的机器上很吃亏。 func tailLines(path string, max int) ([]string, error) { + if max <= 0 { + return nil, nil + } file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { @@ -398,16 +464,59 @@ func tailLines(path string, max int) ([]string, error) { } defer file.Close() - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - if len(lines) > max { - lines = lines[1:] + info, err := file.Stat() + if err != nil { + return nil, err + } + size := info.Size() + if size == 0 { + return nil, nil + } + + // 从尾部往前读块,攒够 max 个换行就停。64KB 起步,最多扩到 8MB: + // 单行上限 1MB(与 scanner 的上限一致),保证超长行不会被截断成多条。 + const ( + startChunk = 64 << 10 + maxChunk = 8 << 20 + ) + chunk := int64(startChunk) + var tail []byte + offset := size + for offset > 0 { + readSize := chunk + if readSize > offset { + readSize = offset + } + offset -= readSize + buf := make([]byte, readSize) + if _, err := file.ReadAt(buf, offset); err != nil && err != io.EOF { + return nil, err + } + tail = append(buf, tail...) + // 已攒到足够的行就不必继续往前读(末尾若没有换行,也算一行) + if strings.Count(string(tail), "\n") > max { + break + } + if chunk < maxChunk { + chunk *= 2 } } - return lines, scanner.Err() + + text := string(tail) + if offset > 0 { + // 从中间开始读时,第一行很可能是半行:丢掉它 + if i := strings.IndexByte(text, '\n'); i >= 0 { + text = text[i+1:] + } + } + lines := strings.Split(strings.TrimRight(text, "\n"), "\n") + if len(lines) > max { + lines = lines[len(lines)-max:] + } + if len(lines) == 1 && lines[0] == "" { + return nil, nil + } + return lines, nil } func asString(v any) string { diff --git a/internal/logstore/rotate.go b/internal/logstore/rotate.go new file mode 100644 index 0000000..c2764d9 --- /dev/null +++ b/internal/logstore/rotate.go @@ -0,0 +1,191 @@ +package logstore + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Nginx 自己不会轮转日志,长期运行的 NAS 上 access.log 会一直长。 +// 这里的策略刻意保持简单: +// - 超过 MaxBytes 才轮转,避免一天切一堆小文件; +// - 轮转时把 access.log 改名成 access-<时间戳>.log.gz,再让 Nginx 重开文件; +// - 保留最近 Keep 份,更旧的删掉。 +// +// 用「rename + 信号重开」而不是 truncate:truncate 会丢掉读侧(流量采集)还没读到的内容。 +const ( + // DefaultMaxBytes 单个日志文件的上限(200MB)。NAS 上反代日志增长很快, + // 太大则读取接口变慢,太小则轮转频繁、压缩开销占比升高。 + DefaultMaxBytes int64 = 200 << 20 + // DefaultKeep 每个日志保留的归档份数。 + DefaultKeep = 5 +) + +// Rotator 负责单个日志文件的轮转与清理。 +type Rotator struct { + // Path 当前正在写入的日志路径(例如 /logs/access.log) + Path string + // MaxBytes 超过它就轮转;<= 0 用 DefaultMaxBytes + MaxBytes int64 + // Keep 保留份数;<= 0 用 DefaultKeep + Keep int + // MaxAgeDays 归档保留天数;<= 0 表示不按时间清理(只按份数)。 + // 对应「设置 → 常规 → 日志保留天数」,填了才生效。 + MaxAgeDays int + // reopen 通知写日志的进程重新打开文件(Nginx 用 USR1) + reopen func() error +} + +// NewRotator 装配一个日志轮转器。reopen 用于通知写日志的进程重新打开文件 +// (Nginx 用 USR1);传 nil 表示该日志由本进程自己写,不需要重开。 +func NewRotator(path string, maxAgeDays int, reopen func() error) *Rotator { + return &Rotator{Path: path, MaxAgeDays: maxAgeDays, reopen: reopen} +} + +// RotateIfNeeded 在文件超过阈值时轮转,返回是否真的轮转了。 +// 未超阈值时不产生任何副作用,可以安全地高频调用。 +func (r *Rotator) RotateIfNeeded(now time.Time) (bool, error) { + info, err := os.Stat(r.Path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + limit := r.MaxBytes + if limit <= 0 { + limit = DefaultMaxBytes + } + if info.Size() < limit { + return false, nil + } + + target := r.archivePath(now) + if err := compressRename(r.Path, target); err != nil { + return false, err + } + // 文件被改名后,Nginx 仍持有旧 inode 继续往里写。必须让它重开, + // 否则新内容会写进已归档的那份文件,轮转等于没做。 + if r.reopen != nil { + if err := r.reopen(); err != nil { + return true, fmt.Errorf("日志已归档,但通知重开失败:%w", err) + } + } + // 归档成功后再清理旧份数:清理失败不该让本轮轮转算失败 + r.prune() + return true, nil +} + +// archivePath 生成归档名:access.log → access-20261024-1305.log.gz。 +// 时间戳精确到分钟,同一分钟内重复轮转时补序号,避免覆盖。 +func (r *Rotator) archivePath(now time.Time) string { + dir := filepath.Dir(r.Path) + base := strings.TrimSuffix(filepath.Base(r.Path), ".log") + stamp := now.Format("20060102-1504") + candidate := filepath.Join(dir, fmt.Sprintf("%s-%s.log.gz", base, stamp)) + for i := 2; ; i++ { + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + candidate = filepath.Join(dir, fmt.Sprintf("%s-%s-%d.log.gz", base, stamp, i)) + } +} + +// prune 先按保留天数删超期归档,再按份数保留最近的。 +// 两个条件都生效:天数保证「长期不轮转也不会无限留」,份数保证「高频轮转不会堆积」。 +func (r *Rotator) prune() { + archives := r.archives() + if r.MaxAgeDays > 0 { + cutoff := time.Now().Add(-time.Duration(r.MaxAgeDays) * 24 * time.Hour) + kept := archives[:0] + for _, path := range archives { + info, err := os.Stat(path) + if err == nil && info.ModTime().Before(cutoff) { + _ = os.Remove(path) + continue + } + kept = append(kept, path) + } + archives = kept + } + keep := r.Keep + if keep <= 0 { + keep = DefaultKeep + } + for i := 0; i < len(archives)-keep; i++ { + _ = os.Remove(archives[i]) + } +} + +// archives 列出该日志的归档文件,按旧→新排序。 +func (r *Rotator) archives() []string { + dir := filepath.Dir(r.Path) + base := strings.TrimSuffix(filepath.Base(r.Path), ".log") + prefix := base + "-" + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + out := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log.gz") { + continue + } + out = append(out, filepath.Join(dir, name)) + } + sort.Strings(out) + return out +} + +// compressRename 把 src 压缩写到 dst,成功后删除 src。 +// 先写 .tmp 再改名:压缩中途失败不会留下一个看起来能用的归档。 +func compressRename(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) + if err != nil { + _ = in.Close() + return err + } + gz := gzip.NewWriter(out) + if _, err := io.Copy(gz, in); err != nil { + _ = gz.Close() + _ = out.Close() + _ = in.Close() + _ = os.Remove(tmp) + return err + } + // 关闭顺序与错误都要检查:gzip 的尾部写在 Close 里,漏了就是半截归档 + if err := gz.Close(); err != nil { + _ = out.Close() + _ = in.Close() + _ = os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + _ = in.Close() + _ = os.Remove(tmp) + return err + } + // 必须显式关闭源文件再删:Windows 上句柄没放掉时 Remove/改名都会失败 + if err := in.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + // 归档成功才删原文件;顺序反了会在压缩失败时丢日志 + return os.Remove(src) +} diff --git a/internal/logstore/rotate_test.go b/internal/logstore/rotate_test.go new file mode 100644 index 0000000..66c7eaf --- /dev/null +++ b/internal/logstore/rotate_test.go @@ -0,0 +1,188 @@ +package logstore + +import ( + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestRotateIfNeededSkipsSmallFile:未超阈值不能产生任何副作用,否则每小时一次的 +// 定时任务会把日志切成无数小文件。 +func TestRotateIfNeededSkipsSmallFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "access.log") + if err := os.WriteFile(path, []byte("small\n"), 0o640); err != nil { + t.Fatal(err) + } + reopened := 0 + r := NewRotator(path, 0, func() error { + reopened++ + return nil + }) + rotated, err := r.RotateIfNeeded(time.Now()) + if err != nil { + t.Fatal(err) + } + if rotated { + t.Fatal("未超阈值不应轮转") + } + if reopened != 0 { + t.Fatal("未轮转时不应通知重开日志") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("原文件应保持不动:%v", err) + } +} + +// TestRotateCompressesAndReopens:超阈值时要归档成 .gz、通知进程重开文件、并删掉原文件。 +func TestRotateCompressesAndReopens(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "access.log") + content := strings.Repeat("2026-10-24T10:00:00+08:00 a.example.com 18080 GET / 200 0.010 1.2.3.4 10.0.0.2:80 100 200\n", 200) + if err := os.WriteFile(path, []byte(content), 0o640); err != nil { + t.Fatal(err) + } + + reopened := 0 + r := NewRotator(path, 0, func() error { + reopened++ + return nil + }) + r.MaxBytes = 1024 // 强制触发 + now := time.Date(2026, 10, 24, 13, 5, 0, 0, time.Local) + rotated, err := r.RotateIfNeeded(now) + if err != nil { + t.Fatal(err) + } + if !rotated { + t.Fatal("超过阈值应轮转") + } + if reopened != 1 { + t.Fatalf("轮转后必须通知重开日志,实际调用 %d 次", reopened) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("轮转后原文件应被移走") + } + + archive := filepath.Join(dir, "access-20261024-1305.log.gz") + file, err := os.Open(archive) + if err != nil { + t.Fatalf("应生成归档文件 %s:%v", archive, err) + } + defer file.Close() + gz, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + data, err := io.ReadAll(gz) + if err != nil { + t.Fatal(err) + } + if string(data) != content { + t.Fatalf("归档内容应与原文件一致:got %d bytes want %d", len(data), len(content)) + } +} + +// TestRotatePrunesOldArchives:归档份数与保留天数都要生效。 +func TestRotatePrunesOldArchives(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "access.log") + + // 造 3 个既有归档,其中 2 个已经超期 + old := time.Now().Add(-40 * 24 * time.Hour) + stale := []string{"access-20200101-0000.log.gz", "access-20200102-0000.log.gz"} + for _, name := range stale { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + fresh := filepath.Join(dir, "access-20261024-1200.log.gz") + if err := os.WriteFile(fresh, []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(strings.Repeat("line\n", 500)), 0o640); err != nil { + t.Fatal(err) + } + r := NewRotator(path, 7, nil) // 保留 7 天 + r.MaxBytes = 1024 + if _, err := r.RotateIfNeeded(time.Now()); err != nil { + t.Fatal(err) + } + + for _, name := range stale { + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Fatalf("超过保留天数的归档应被删除:%s", name) + } + } + if _, err := os.Stat(fresh); err != nil { + t.Fatalf("未超期的归档应保留:%v", err) + } +} + +// TestTailLinesReadsFromEnd:尾部读取既要拿对最后几行,也不能因为文件很大而读全量。 +func TestTailLinesReadsFromEnd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "access.log") + var b strings.Builder + for i := 0; i < 5000; i++ { + b.WriteString("line-") + b.WriteString(strings.Repeat("x", 20)) + b.WriteString("\n") + } + b.WriteString("last-line-a\nlast-line-b\n") + if err := os.WriteFile(path, []byte(b.String()), 0o640); err != nil { + t.Fatal(err) + } + + lines, err := tailLines(path, 3) + if err != nil { + t.Fatal(err) + } + want := []string{strings.TrimSpace("line-xxxxxxxxxxxxxxxxxxxx"), "last-line-a", "last-line-b"} + if len(lines) != len(want) { + t.Fatalf("应返回 3 行,实际 %d:%v", len(lines), lines) + } + for i := range want { + if lines[i] != want[i] { + t.Fatalf("第 %d 行不符:got %q want %q", i, lines[i], want[i]) + } + } +} + +// TestTailLinesSingleLongLine:单行超过初始块大小时不能被截断成多行。 +func TestTailLinesSingleLongLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "access.log") + long := strings.Repeat("y", 300*1024) + if err := os.WriteFile(path, []byte(long+"\n"), 0o640); err != nil { + t.Fatal(err) + } + lines, err := tailLines(path, 5) + if err != nil { + t.Fatal(err) + } + if len(lines) != 1 || lines[0] != long { + t.Fatalf("超长单行应完整返回,实际 %d 行(首行长度 %d)", len(lines), len(lines[0])) + } +} + +// TestTailLinesMissingFile:日志还没产生时返回空而不是报错。 +func TestTailLinesMissingFile(t *testing.T) { + lines, err := tailLines(filepath.Join(t.TempDir(), "nope.log"), 10) + if err != nil { + t.Fatalf("文件不存在不应报错:%v", err) + } + if len(lines) != 0 { + t.Fatalf("应返回空行列表,实际 %v", lines) + } +} diff --git a/internal/nginx/generate_multi_test.go b/internal/nginx/generate_multi_test.go index a9eb8f5..eff0475 100644 --- a/internal/nginx/generate_multi_test.go +++ b/internal/nginx/generate_multi_test.go @@ -16,11 +16,11 @@ func TestGenerateMultiHostCustomPort(t *testing.T) { } port6893 := 6893 rules := []proxy.Rule{{ - ID: 1, - Upstream: "http://192.168.8.3:6893", - ListenPort: 8011, - ListenIPv4: true, - ListenIPv6: true, + ID: 1, + Upstream: "http://192.168.8.3:6893", + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, Hosts: []proxy.Host{ {Hostname: "1.example.com"}, {Hostname: "2.example.com"}, diff --git a/internal/nginx/generate_seed_mix_test.go b/internal/nginx/generate_seed_mix_test.go index 2af76b5..5664024 100644 --- a/internal/nginx/generate_seed_mix_test.go +++ b/internal/nginx/generate_seed_mix_test.go @@ -25,12 +25,12 @@ func TestGenerateMixedRulesWithoutCerts(t *testing.T) { rules := []proxy.Rule{ { ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true, - Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, }, { ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true, - Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, }, } diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index 86a62fb..2cc4fcb 100644 --- a/internal/nginx/nginx.go +++ b/internal/nginx/nginx.go @@ -298,6 +298,28 @@ func (m *Manager) Stop(ctx context.Context) error { return nil } +// ReopenLogs 让 Nginx 关掉旧日志句柄并按配置里的路径重新打开。 +// 日志轮转(把 access.log 改名归档)之后必须调用它,否则 Nginx 会继续写旧 inode, +// 新日志永远落不到 access.log 上。 +func (m *Manager) ReopenLogs(ctx context.Context) error { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err != nil { + if os.IsNotExist(err) { + // 没在运行就没什么要重开的:轮转本身已经把文件归档了 + return nil + } + return err + } + if !isPIDAlive(pid) { + return nil + } + if err := reopenLogsProcess(pid); err != nil { + return err + } + m.logger.Info("nginx log files reopened") + return nil +} + func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error { ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) defer cancel() diff --git a/internal/nginx/pid_unix.go b/internal/nginx/pid_unix.go index 505ca38..626df22 100644 --- a/internal/nginx/pid_unix.go +++ b/internal/nginx/pid_unix.go @@ -11,6 +11,14 @@ func reloadProcess(pid int) error { return syscall.Kill(pid, syscall.SIGHUP) } +// reopenLogsProcess 让 Nginx 重新打开日志文件(USR1)。 +// +// 日志轮转必须用信号重开、而不是 reload 配置:reload 会拉起新 worker 并优雅退出旧 +// worker,代价与风险都远大于「关掉旧 fd、按同路径重新 open」这一件事。 +func reopenLogsProcess(pid int) error { + return syscall.Kill(pid, syscall.SIGUSR1) +} + func terminateProcess(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) } diff --git a/internal/nginx/pid_windows.go b/internal/nginx/pid_windows.go index 9fc8bb2..8ff1ba9 100644 --- a/internal/nginx/pid_windows.go +++ b/internal/nginx/pid_windows.go @@ -19,6 +19,12 @@ func reloadProcess(pid int) error { return fmt.Errorf("signal reload unsupported on windows") } +// reopenLogsProcess 在 Windows 上没有 USR1 这类单用途信号, +// Nginx 侧只能通过 reload 达成同样的效果(会重启 worker,但日志句柄会被重开)。 +func reopenLogsProcess(pid int) error { + return fmt.Errorf("signal reopen unsupported on windows") +} + func terminateProcess(pid int, sig syscall.Signal) error { args := []string{"/PID", strconv.Itoa(pid)} if sig == syscall.SIGKILL { diff --git a/internal/nginx/realip.go b/internal/nginx/realip.go index 967f3c6..473c9fb 100644 --- a/internal/nginx/realip.go +++ b/internal/nginx/realip.go @@ -6,11 +6,11 @@ import ( ) type TrustedProxyConfig struct { - Enabled bool `json:"enabled"` - Preset string `json:"preset"` // "", "cloudflare", "custom" - CIDRs []string `json:"cidrs,omitempty"` - Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For - Recursive bool `json:"recursive,omitempty"` + Enabled bool `json:"enabled"` + Preset string `json:"preset"` // "", "cloudflare", "custom" + CIDRs []string `json:"cidrs,omitempty"` + Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For + Recursive bool `json:"recursive,omitempty"` } var cloudflareIPv4 = []string{ diff --git a/internal/service/proxy.go b/internal/service/proxy.go index 74cf328..3372b31 100644 --- a/internal/service/proxy.go +++ b/internal/service/proxy.go @@ -24,6 +24,7 @@ type ProxyService struct { nginx *nginx.Manager notify *notify.Service exitSync ExitSync + traffic TrafficCleaner } // ExitSync 处理服务发布规则变化后的外部出口同步。 @@ -31,6 +32,13 @@ type ExitSync interface { SyncRules(ctx context.Context) error } +// TrafficCleaner 清掉某条规则对应的流量累计(内存 + 落库)。 +// 用接口注入而不是直接依赖 traffic 包:service 是编排层,不该反过来知道采集细节。 +// 参数用 proxy.Endpoint 而不是拼好的字符串,键格式由 traffic 包自己决定。 +type TrafficCleaner interface { + ForgetEndpoints(ctx context.Context, endpoints []proxy.Endpoint) error +} + func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService { return &ProxyService{db: db, cfg: cfg, store: store, certStore: certStore, settings: settingsStore, nginx: nginxMgr} } @@ -43,6 +51,10 @@ func (s *ProxyService) SetExitSync(sync ExitSync) { s.exitSync = sync } +func (s *ProxyService) SetTrafficCleaner(cleaner TrafficCleaner) { + s.traffic = cleaner +} + func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) { return s.store.List(ctx) } @@ -119,10 +131,16 @@ func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error { } func (s *ProxyService) Delete(ctx context.Context, id int64) error { + // 先取出规则:删除后就查不到域名与端口,没法定位要清理的流量键 + rule, err := s.store.Get(ctx, id) + if err != nil { + return err + } if err := s.store.Delete(ctx, id); err != nil { return err } _ = nginx.RemoveRuleCustom(s.cfg, id) + s.forgetTraffic(ctx, rule) if err := s.applyNginx(ctx); err != nil { return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err) } @@ -132,6 +150,15 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error { return nil } +// forgetTraffic 清掉规则对应端点的流量累计。失败不影响删除本身: +// 残留的统计行只是脏数据,而规则已经删了,没必要为此把删除操作报成失败。 +func (s *ProxyService) forgetTraffic(ctx context.Context, rule proxy.Rule) { + if s.traffic == nil { + return + } + _ = s.traffic.ForgetEndpoints(ctx, rule.Endpoints()) +} + func (s *ProxyService) ReloadAll(ctx context.Context) error { return s.applyNginx(ctx) } diff --git a/internal/settings/registry.go b/internal/settings/registry.go new file mode 100644 index 0000000..f991c6c --- /dev/null +++ b/internal/settings/registry.go @@ -0,0 +1,178 @@ +package settings + +// Kind 描述一个设置项的值类型,决定校验方式与默认值的解析方式。 +type Kind string + +const ( + // KindString 任意文本(域名、邮箱、URL、路径等) + KindString Kind = "string" + // KindBool 布尔值,接受 1/true/yes/on + KindBool Kind = "bool" + // KindInt 整数 + KindInt Kind = "int" + // KindJSON JSON 文本(对象或数组) + KindJSON Kind = "json" +) + +// Spec 是一条设置项的完整声明。 +// +// 引入它的原因:设置项此前分散在四个地方——常量、Defaults 默认值表、 +// settings_handler 的白名单、前端表单。新增一项时很容易只改其中两三处, +// 于是出现「界面能改、后端没人读」的黑洞设置项(theme / timezone 就是这样来的)。 +// 现在白名单、默认值、敏感标记、校验方式都从这张表派生,漏写会被测试挡住。 +type Spec struct { + // Key 数据库里的键名(用 Key* 常量赋值,避免拼写漂移) + Key string + // Kind 值类型 + Kind Kind + // Default 默认值;空串表示无默认值 + Default string + // Writable 是否允许通过 PUT /api/settings 写入。 + // 程序内部维护的状态(如 frp_resume_on_start、cidr 更新时间)应为 false。 + Writable bool + // Secret 是否为敏感值:读接口会以掩码返回,绝不回明文 + Secret bool + // Consumer 说明「谁在读这个键」,用于人工评审时确认不是黑洞设置。 + // 约定写包名或功能名,不写具体行号(会漂移)。 + Consumer string +} + +// Specs 是所有设置项的唯一清单。新增设置项必须在这里登记, +// 否则既不能通过 API 写入,也不会被 Defaults / 白名单识别。 +var Specs = []Spec{ + // ---- 常规 ---- + {Key: KeyTheme, Kind: KindString, Default: "system", Writable: true, Consumer: "web:useTheme(启动时按设置初始化,切换时回写)"}, + {Key: KeyTimezone, Kind: KindString, Default: "Asia/Shanghai", Writable: true, Consumer: "app(启动时 time.Local)+ notify(告警时间)"}, + {Key: KeyLogRetentionDays, Kind: KindInt, Default: "30", Writable: true, Consumer: "app.rotateNginxLogs(归档保留天数)"}, + + // ---- DDNS / 证书 ---- + {Key: KeyDDNSCheckInterval, Kind: KindInt, Default: "5", Writable: true, Consumer: "app(ddns 任务间隔)"}, + {Key: KeyCertRenewThreshold, Kind: KindInt, Default: "30", Writable: true, Consumer: "acme(续期阈值)"}, + {Key: KeyRootDomain, Kind: KindString, Writable: true, Consumer: "(保留键,当前无读取点)"}, + {Key: KeyACMEEmail, Kind: KindString, Writable: true, Consumer: "acme(注册邮箱)"}, + {Key: KeyACMECA, Kind: KindString, Default: "letsencrypt", Writable: true, Consumer: "acme(CA 选择)"}, + {Key: KeyACMEDNSResolvers, Kind: KindString, Writable: true, Consumer: "acme(DNS-01 传播检查)"}, + {Key: KeyZeroSSLAPIKey, Kind: KindString, Writable: true, Secret: true, Consumer: "acme(ZeroSSL EAB 换发)"}, + {Key: KeyLiteSSLEABKid, Kind: KindString, Writable: true, Consumer: "acme(LiteSSL EAB kid)"}, + {Key: KeyLiteSSLEABHMAC, Kind: KindString, Writable: true, Secret: true, Consumer: "acme(LiteSSL EAB hmac)"}, + + // ---- 通知 ---- + {Key: KeyNotifyType, Kind: KindString, Writable: true, Consumer: "notify(渠道选择)"}, + {Key: KeyNotifyEmailJSON, Kind: KindJSON, Writable: true, Consumer: "notify(SMTP 配置)"}, + {Key: KeyNotifySMTPPassword, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(SMTP 密码)"}, + {Key: KeyNotifyWebhookJSON, Kind: KindJSON, Writable: true, Consumer: "notify(Webhook 配置)"}, + {Key: KeyNotifyWebhookSecret, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(Webhook 签名密钥)"}, + {Key: KeyNotifyTelegramJSON, Kind: KindJSON, Writable: true, Consumer: "notify(Telegram 配置)"}, + {Key: KeyNotifyTelegramToken, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(Bot Token)"}, + {Key: KeyNotifyWebhookURL, Kind: KindString, Writable: true, Consumer: "notify(自定义 Webhook 地址)"}, + + // ---- 通知事件开关 ---- + {Key: KeyNotifyOnDDNSIPChange, Kind: KindBool, Writable: true, Consumer: "notify(DDNS IP 变化事件)"}, + {Key: KeyNotifyOnDDNSFailure, Kind: KindBool, Writable: true, Consumer: "notify(DDNS 失败事件)"}, + {Key: KeyNotifyOnCertExpiry, Kind: KindBool, Writable: true, Consumer: "notify(证书临期事件)"}, + {Key: KeyNotifyOnCertRenewSuccess, Kind: KindBool, Writable: true, Consumer: "notify(证书续期成功)"}, + {Key: KeyNotifyOnCertRenewFailure, Kind: KindBool, Writable: true, Consumer: "notify(证书续期失败)"}, + {Key: KeyNotifyOnIPFrequentAccess, Kind: KindBool, Writable: true, Consumer: "notify(频繁访问事件)"}, + {Key: KeyNotifyOnLoginFailure, Kind: KindBool, Writable: true, Consumer: "notify(登录失败事件)"}, + {Key: KeyNotifyOnNginxReloadFailure, Kind: KindBool, Writable: true, Consumer: "notify(Nginx 重载失败)"}, + {Key: KeyNotifyOnResourceThreshold, Kind: KindBool, Writable: true, Consumer: "notify(资源超阈值)"}, + {Key: KeyNotifyOnTunnelDown, Kind: KindBool, Writable: true, Consumer: "notify(隧道中断)"}, + {Key: KeyNotifyOnFrpsDown, Kind: KindBool, Writable: true, Consumer: "notify(frps 不可用)"}, + {Key: KeyNotifyOnAgentUnreachable, Kind: KindBool, Writable: true, Consumer: "notify(Agent 不可达)"}, + {Key: KeyNotifyOnRouteDrift, Kind: KindBool, Writable: true, Consumer: "notify(路由漂移)"}, + {Key: KeyNotifyOnBackupFailure, Kind: KindBool, Writable: true, Consumer: "notify(备份失败)"}, + + // ---- 通知阈值 ---- + {Key: KeyNotifyIPFrequentThreshold, Kind: KindInt, Writable: true, Consumer: "notify(频繁访问阈值)"}, + {Key: KeyNotifyIPFrequentWindowSec, Kind: KindInt, Writable: true, Consumer: "notify(频繁访问窗口)"}, + {Key: KeyNotifyLoginFailureThreshold, Kind: KindInt, Writable: true, Consumer: "notify(登录失败次数阈值)"}, + {Key: KeyNotifyLoginFailureWindowSec, Kind: KindInt, Writable: true, Consumer: "notify(登录失败窗口)"}, + {Key: KeyNotifyCPUThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(CPU 阈值)"}, + {Key: KeyNotifyMemoryThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(内存阈值)"}, + {Key: KeyNotifyDiskThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(磁盘阈值)"}, + {Key: KeyNotifyTunnelFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(隧道失败次数)"}, + {Key: KeyNotifyFrpsFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(frps 失败次数)"}, + {Key: KeyNotifyAgentFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(Agent 失败次数)"}, + {Key: KeyNotifyDriftCooldownHours, Kind: KindInt, Writable: true, Consumer: "monitor(漂移告警冷却)"}, + + // ---- 备份 ---- + {Key: KeyBackupAutoEnabled, Kind: KindBool, Writable: true, Consumer: "backup.AutoRunner(自动备份开关)"}, + {Key: KeyBackupKeepCount, Kind: KindInt, Writable: true, Consumer: "backup.KeepCount(保留份数)"}, + + // ---- 功能开关 ---- + {Key: KeyRouteHealthEnabled, Kind: KindBool, Writable: true, Consumer: "monitor(路由健康采集)"}, + {Key: KeyStatusPageEnabled, Kind: KindBool, Writable: true, Consumer: "api.status_page(公开状态页开关)"}, + {Key: KeyStatusPageCode, Kind: KindString, Writable: true, Consumer: "api.status_page(明文只在保存请求里出现,落库为 hash)"}, + {Key: KeyMetricsHistoryEnabled, Kind: KindBool, Writable: true, Consumer: "monitor(指标历史采样)"}, + {Key: KeyTerminalEnabled, Kind: KindBool, Writable: true, Consumer: "api.terminal(SSH 终端总开关)"}, + {Key: KeyTerminalRecording, Kind: KindBool, Writable: true, Consumer: "api.terminal(会话录制开关)"}, + + // ---- 安全 ---- + {Key: KeyTrustedProxy, Kind: KindJSON, Writable: true, Consumer: "nginx(real_ip 信任链)"}, + {Key: KeyGlobalIPBlacklist, Kind: KindJSON, Writable: true, Consumer: "nginx(全局黑名单)"}, + {Key: KeyGlobalIPWhitelist, Kind: KindJSON, Writable: true, Consumer: "nginx(全局白名单)"}, + + // ---- 中国 CIDR ---- + {Key: KeyChinaCIDRUpdateHours, Kind: KindInt, Default: "24", Writable: true, Consumer: "app(cidr 更新间隔)"}, + {Key: KeyChinaCIDRSourceV4, Kind: KindString, Writable: true, Consumer: "chinacidr(IPv4 源地址)"}, + {Key: KeyChinaCIDRSourceV6, Kind: KindString, Writable: true, Consumer: "chinacidr(IPv6 源地址)"}, + + // ---- 内部状态:程序自己维护,不接受 API 写入 ---- + {Key: KeyStatusPageCodeHash, Kind: KindString, Secret: true, Consumer: "api.status_page(访问码哈希)"}, + {Key: KeyNginxGlobalMode, Kind: KindString, Default: "auto", Consumer: "service.nginx_config(auto/custom 模式)"}, + {Key: KeyChinaCIDRUpdatedAt, Kind: KindString, Consumer: "chinacidr(上次更新时间)"}, + {Key: KeyChinaCIDRCountV4, Kind: KindInt, Consumer: "chinacidr(IPv4 条目数)"}, + {Key: KeyChinaCIDRCountV6, Kind: KindInt, Consumer: "chinacidr(IPv6 条目数)"}, + {Key: KeyChinaCIDRLastError, Kind: KindString, Consumer: "chinacidr(最近错误)"}, + {Key: KeyFrpResumeOnStart, Kind: KindBool, Consumer: "frp(重启后是否恢复 frpc)"}, + + // ---- 历史键:已废弃,仅为兼容旧库保留声明 ---- + {Key: KeyNotifyOnDDNSError, Kind: KindBool, Consumer: "(历史键,已迁移到 KeyNotifyOnDDNSFailure)"}, + {Key: KeyNotifyOnCertError, Kind: KindBool, Consumer: "(历史键,已迁移到 KeyNotifyOnCertExpiry)"}, + {Key: KeyNotifyOnNginxError, Kind: KindBool, Consumer: "(历史键,已忽略)"}, + {Key: KeyFRPEnabled, Kind: KindBool, Consumer: "(历史键,旧单服务端 FRP,已被 internal/frp 取代)"}, + {Key: KeyFRPServerAddr, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPServerPort, Kind: KindInt, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPAuthToken, Kind: KindString, Secret: true, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPTLSEnabled, Kind: KindBool, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPCustomDomains, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPLastError, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"}, + {Key: KeyFRPStartedAt, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"}, +} + +// specIndex 是 Specs 的索引,供 O(1) 查找。 +var specIndex = func() map[string]Spec { + out := make(map[string]Spec, len(Specs)) + for _, spec := range Specs { + out[spec.Key] = spec + } + return out +}() + +// Defaults 是从 Specs 派生的默认值表(只含声明了 Default 的项)。 +var Defaults = func() map[string]string { + out := make(map[string]string, len(Specs)) + for _, spec := range Specs { + if spec.Default != "" { + out[spec.Key] = spec.Default + } + } + return out +}() + +// Lookup 返回某个键的声明。 +func Lookup(key string) (Spec, bool) { + spec, ok := specIndex[key] + return spec, ok +} + +// WritableKeys 返回所有允许 API 写入的键,用于 settings_handler 的白名单。 +func WritableKeys() map[string]bool { + out := make(map[string]bool, len(Specs)) + for _, spec := range Specs { + if spec.Writable { + out[spec.Key] = true + } + } + return out +} diff --git a/internal/settings/registry_test.go b/internal/settings/registry_test.go new file mode 100644 index 0000000..2126902 --- /dev/null +++ b/internal/settings/registry_test.go @@ -0,0 +1,153 @@ +package settings + +import ( + "testing" +) + +// TestEveryDeclaredKeyHasConsumer:每个声明出来的键都要有「谁在读」的说明, +// 并且不能只是复述键名本身。这条测试防的是「界面能改、后端没人读」的黑洞设置项 +// (theme / timezone 曾经就是这样:常量、默认值、白名单、前端表单四处齐全,唯独没人消费)。 +func TestEveryDeclaredKeyHasConsumer(t *testing.T) { + for _, spec := range Specs { + if spec.Key == "" { + t.Fatal("存在未填 Key 的 Spec") + } + if spec.Kind == "" { + t.Fatalf("%s 未填 Kind", spec.Key) + } + if spec.Consumer == "" { + t.Fatalf("%s 未填 Consumer(说明谁在读这个设置项)", spec.Key) + } + } +} + +// TestNoDuplicateKeys:同一个键不能声明两次,否则 Lookup / Defaults 会悄悄取其中一个。 +func TestNoDuplicateKeys(t *testing.T) { + seen := map[string]bool{} + for _, spec := range Specs { + if seen[spec.Key] { + t.Fatalf("重复声明的设置项:%s", spec.Key) + } + seen[spec.Key] = true + } + if len(seen) != len(Specs) { + t.Fatalf("Specs 数量 %d 与去重后 %d 不一致", len(Specs), len(seen)) + } +} + +// TestKindableDefaultsMatchKind:默认值必须能被它声明的 Kind 解析, +// 否则 GetInt/GetBool 在没写过值时会拿到脏数据。 +func TestKindableDefaultsMatchKind(t *testing.T) { + for _, spec := range Specs { + if spec.Default == "" { + continue + } + switch spec.Kind { + case KindInt: + if _, ok := parseInt(spec.Default); !ok { + t.Fatalf("%s 的默认值 %q 不是合法整数", spec.Key, spec.Default) + } + case KindBool: + if _, ok := parseBool(spec.Default); !ok { + t.Fatalf("%s 的默认值 %q 不是合法布尔值", spec.Key, spec.Default) + } + } + } +} + +// TestDefaultsDerivedFromSpecs:Defaults 必须与 Specs 一致,防止两处各写一份。 +func TestDefaultsDerivedFromSpecs(t *testing.T) { + index := map[string]string{} + for _, spec := range Specs { + if spec.Default != "" { + index[spec.Key] = spec.Default + } + } + if len(index) != len(Defaults) { + t.Fatalf("Defaults 数量 %d 与 Specs 中带默认值的条目 %d 不一致", len(Defaults), len(index)) + } + for key, want := range index { + if got := Defaults[key]; got != want { + t.Fatalf("%s 默认值不符:got %q want %q", key, got, want) + } + } +} + +// TestInternalStateKeysNotWritable:程序自己维护的状态不能通过 API 写入, +// 否则用户可以把 frp_resume_on_start 之类改成任意值,破坏重启恢复逻辑。 +func TestInternalStateKeysNotWritable(t *testing.T) { + internal := []string{ + KeyFrpResumeOnStart, KeyStatusPageCodeHash, + KeyChinaCIDRUpdatedAt, KeyChinaCIDRCountV4, KeyChinaCIDRCountV6, KeyChinaCIDRLastError, + KeyNginxGlobalMode, + } + writable := WritableKeys() + for _, key := range internal { + if writable[key] { + t.Fatalf("%s 是内部状态键,不应出现在可写白名单里", key) + } + } +} + +// TestSecretKeysAreDeclared:已知的敏感键必须被标记为 Secret,读接口才会打码。 +func TestSecretKeysAreDeclared(t *testing.T) { + secrets := []string{ + KeyNotifySMTPPassword, KeyNotifyWebhookSecret, KeyNotifyTelegramToken, + KeyZeroSSLAPIKey, KeyLiteSSLEABHMAC, KeyStatusPageCodeHash, + } + for _, key := range secrets { + spec, ok := Lookup(key) + if !ok { + t.Fatalf("%s 未在注册表里声明", key) + } + if !spec.Secret { + t.Fatalf("%s 应标记为 Secret", key) + } + } +} + +// TestWritableWhitelistContainsCoreKeys:核心可配置项必须在白名单里, +// 否则前端保存会静默丢字段(PUT 会按白名单过滤)。 +func TestWritableWhitelistContainsCoreKeys(t *testing.T) { + writable := WritableKeys() + core := []string{ + KeyTheme, KeyTimezone, KeyLogRetentionDays, + KeyDDNSCheckInterval, KeyCertRenewThreshold, + KeyBackupAutoEnabled, KeyBackupKeepCount, + KeyStatusPageEnabled, KeyTerminalEnabled, KeyTerminalRecording, + } + for _, key := range core { + if !writable[key] { + t.Fatalf("%s 应允许 API 写入", key) + } + } +} + +// parseInt / parseBool 是注册表测试用的宽松解析,与 Store 的行为保持一致。 +func parseInt(raw string) (int, bool) { + n := 0 + neg := false + for i, c := range raw { + if i == 0 && c == '-' { + neg = true + continue + } + if c < '0' || c > '9' { + return 0, false + } + n = n*10 + int(c-'0') + } + if neg { + n = -n + } + return n, true +} + +func parseBool(raw string) (bool, bool) { + switch raw { + case "1", "true", "yes", "on", "0", "false", "no", "off": + return raw == "1" || raw == "true" || raw == "yes" || raw == "on", true + default: + return false, false + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index a52a89c..48cf031 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -96,17 +96,6 @@ const ( KeyFrpResumeOnStart = "frp_resume_on_start" ) -var Defaults = map[string]string{ - KeyTheme: "system", - KeyTimezone: "Asia/Shanghai", - KeyLogRetentionDays: "30", - KeyDDNSCheckInterval: "5", - KeyCertRenewThreshold: "30", - KeyACMECA: "letsencrypt", - KeyChinaCIDRUpdateHours: "24", - KeyNginxGlobalMode: "auto", -} - type Store struct { db *sql.DB } diff --git a/internal/settings/timezone.go b/internal/settings/timezone.go new file mode 100644 index 0000000..b581d79 --- /dev/null +++ b/internal/settings/timezone.go @@ -0,0 +1,45 @@ +package settings + +import ( + "context" + "fmt" + "strings" + "time" +) + +// ApplyTimezone 读取「设置 → 常规 → 时区」并设为进程默认时区(time.Local)。 +// +// 为什么必须在这里接一下:Go 的 time.Local 只由 TZ 环境变量决定,而设置页的 +// timezone 字段此前完全没人读——用户在界面上改成 Asia/Tokyo,通知里的时间戳 +// 依旧是容器 TZ。两者取其一都可以,但必须真的生效。 +// +// 返回值是最终生效的时区,供启动日志与调用方确认。 +func ApplyTimezone(ctx context.Context, store *Store) (*time.Location, error) { + raw, err := store.Get(ctx, KeyTimezone) + if err != nil { + return nil, err + } + name := strings.TrimSpace(raw) + if name == "" { + name = Defaults[KeyTimezone] + } + loc, err := LoadTimezone(name) + if err != nil { + return nil, err + } + time.Local = loc + return loc, nil +} + +// LoadTimezone 解析时区名,并对常见拼写提供明确的中文报错。 +func LoadTimezone(name string) (*time.Location, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("时区不能为空") + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, fmt.Errorf("无效的时区 %q(应形如 Asia/Shanghai)", name) + } + return loc, nil +} diff --git a/internal/settings/timezone_test.go b/internal/settings/timezone_test.go new file mode 100644 index 0000000..0f92a44 --- /dev/null +++ b/internal/settings/timezone_test.go @@ -0,0 +1,93 @@ +package settings + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/havline/havline/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatal(err) + } + return NewStore(conn) +} + +// TestApplyTimezoneSetsLocal:设置里的时区必须真的作用到 time.Local, +// 否则「改了时区但通知时间不变」这个黑洞会一直存在。 +func TestApplyTimezoneSetsLocal(t *testing.T) { + original := time.Local + t.Cleanup(func() { time.Local = original }) + + store := newTestStore(t) + ctx := context.Background() + if err := store.Set(ctx, KeyTimezone, "Asia/Tokyo"); err != nil { + t.Fatal(err) + } + + loc, err := ApplyTimezone(ctx, store) + if err != nil { + t.Fatal(err) + } + if loc.String() != "Asia/Tokyo" { + t.Fatalf("应返回 Asia/Tokyo,实际 %s", loc) + } + if time.Local.String() != "Asia/Tokyo" { + t.Fatalf("time.Local 应被设为 Asia/Tokyo,实际 %s", time.Local) + } + // 同一个瞬时在东京比上海早一小时 + _, offset := time.Now().In(time.Local).Zone() + if offset != 9*3600 { + t.Fatalf("东京时区偏移应为 +9h,实际 %d 秒", offset) + } +} + +// TestApplyTimezoneFallsBackToDefault:没写过设置时用注册表里的默认值。 +func TestApplyTimezoneFallsBackToDefault(t *testing.T) { + original := time.Local + t.Cleanup(func() { time.Local = original }) + + store := newTestStore(t) + loc, err := ApplyTimezone(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if loc.String() != Defaults[KeyTimezone] { + t.Fatalf("应使用默认时区 %q,实际 %s", Defaults[KeyTimezone], loc) + } +} + +// TestLoadTimezoneRejectsInvalid:非法时区要给出可读的中文报错, +// 而不是把 "" 或 UTC 悄悄当成合法值写库。 +func TestLoadTimezoneRejectsInvalid(t *testing.T) { + cases := []string{"", " ", "Mars/Olympus", "Asia/Shanghai; rm -rf /"} + for _, input := range cases { + if _, err := LoadTimezone(input); err == nil { + t.Fatalf("%q 应被拒绝", input) + } + } + if _, err := LoadTimezone("UTC"); err != nil { + t.Fatalf("UTC 应合法:%v", err) + } +} + +// TestLoadTimezoneErrorMentionsName:报错里要带上是哪个值不合法,便于前端提示。 +func TestLoadTimezoneErrorMentionsName(t *testing.T) { + _, err := LoadTimezone("Bad/Zone") + if err == nil { + t.Fatal("非法时区应报错") + } + if !strings.Contains(err.Error(), "Bad/Zone") { + t.Fatalf("报错应包含原始输入,实际:%v", err) + } +} diff --git a/internal/traffic/collector.go b/internal/traffic/collector.go index 1b2cf35..7d87ca6 100644 --- a/internal/traffic/collector.go +++ b/internal/traffic/collector.go @@ -382,3 +382,23 @@ func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn { }) return out } + +// ForgetEndpoints 丢弃这些端点的内存累计与速率样本,并删掉落库的累计行。 +// 规则删除时调用:否则同名规则重建会直接继承上一轮的流量总量。 +func (c *Collector) ForgetEndpoints(ctx context.Context, endpoints []proxy.Endpoint) error { + if len(endpoints) == 0 { + return nil + } + keys := make([]string, 0, len(endpoints)) + for _, ep := range endpoints { + keys = append(keys, EndpointKey(ep.Hostname, ep.Port)) + } + + c.mu.Lock() + for _, key := range keys { + delete(c.hosts, key) + } + c.mu.Unlock() + + return c.store.DeleteHosts(ctx, keys) +} diff --git a/internal/traffic/forget_test.go b/internal/traffic/forget_test.go new file mode 100644 index 0000000..fe64999 --- /dev/null +++ b/internal/traffic/forget_test.go @@ -0,0 +1,62 @@ +package traffic + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/havline/havline/internal/db" + "github.com/havline/havline/internal/proxy" +) + +// TestForgetEndpointsClearsMemoryAndStore:删除规则时要同时清掉内存累计与落库累计, +// 否则同名规则重建后会直接继承上一轮的流量读数。 +func TestForgetEndpointsClearsMemoryAndStore(t *testing.T) { + conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer conn.Close() + ctx := context.Background() + if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("migrate: %v", err) + } + + c := NewCollector(conn, "", nil) + c.ingestLine("2026-09-17T08:30:00+08:00 a.example.com 18080 GET / 200 0.010 1.2.3.4 192.168.8.3:80 100 200", true) + c.ingestLine("2026-09-17T08:30:01+08:00 b.example.com 18080 GET / 200 0.010 1.2.3.4 192.168.8.3:80 100 200", true) + if err := c.persist(ctx); err != nil { + t.Fatalf("persist: %v", err) + } + + endpoints := []proxy.Endpoint{{Hostname: "a.example.com", Port: 18080}} + if err := c.ForgetEndpoints(ctx, endpoints); err != nil { + t.Fatalf("forget: %v", err) + } + + count := func(host string) int64 { + var n int64 + if err := conn.QueryRowContext(ctx, `SELECT COUNT(1) FROM proxy_traffic WHERE host = ?`, host).Scan(&n); err != nil { + t.Fatalf("count %s: %v", host, err) + } + return n + } + if got := count("a.example.com:18080"); got != 0 { + t.Fatalf("被删规则对应的流量行应清空,实际还有 %d 行", got) + } + if got := count("b.example.com:18080"); got != 1 { + t.Fatalf("其他规则的流量行不应受影响,实际 %d 行", got) + } + + // 内存态也要一起清掉:被删端点的累计读数应归零 + up, down, _, _, conns := c.aggregateEndpoints(endpoints, time.Now()) + if up != 0 || down != 0 || conns != 0 { + t.Fatalf("内存累计应归零:up=%d down=%d conns=%d", up, down, conns) + } + + // 空入参是空操作,不能报错(删除路径会无差别调用) + if err := c.ForgetEndpoints(ctx, nil); err != nil { + t.Fatalf("空端点列表不应报错:%v", err) + } +} diff --git a/internal/traffic/store.go b/internal/traffic/store.go index 00a54ad..d47658c 100644 --- a/internal/traffic/store.go +++ b/internal/traffic/store.go @@ -57,6 +57,26 @@ func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) err return tx.Commit() } +// DeleteHosts 删除指定 host:port 的累计流量。 +// 规则被删掉后这些行就成了孤儿:既没有界面能查到,又会在同名规则重建时被当成本规则的既有流量。 +func (s *Store) DeleteHosts(ctx context.Context, keys []string) error { + if len(keys) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + for _, key := range keys { + if _, err := tx.ExecContext(ctx, + `DELETE FROM proxy_traffic WHERE host = ?`, strings.ToLower(strings.TrimSpace(key))); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + type hostTotals struct { Upload int64 Download int64 diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ad31828 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,103 @@ +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import tseslint from 'typescript-eslint' + +// 说明:这份配置刻意只开「低争议」规则。 +// 项目里已有 2.7 万行前端代码,一次性打开 recommended 全量会砸出几千条告警, +// 反而没人看。先把「未使用变量 / 未定义变量 / == 与 ===」这类真问题卡住, +// 再逐步加规则。vite.config.ts 里已经在 build 前跑 vue-tsc,类型问题由它兜。 +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'src/**/*.d.ts'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/essential'], + { + files: ['**/*.{ts,vue}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + // Vue SFC 的