fix: 收敛正确性缺口并补齐质量门禁
CI / verify (push) Canceled after 0s

备份、隧道生命周期、日志、会话与设置项存在一批「静默失效」问题,本次一并收敛。

正确性:
- 备份归档补上 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>
This commit is contained in:
2026-10-24 16:30:00 +08:00
co-authored by Devin
parent 6dd2136dd2
commit 9d75fb1d3e
49 changed files with 4469 additions and 1617 deletions
+27 -3
View File
@@ -16,6 +16,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 嵌入前端, # 顺序不能颠倒:cmd/havline/main.go 用 //go:embed all:web/dist 嵌入前端,
# 干净检出时该目录不存在,go vet / go build 会直接失败。 # 干净检出时该目录不存在,go vet / go build 会直接失败。
- name: Setup Node - name: Setup Node
@@ -29,6 +41,10 @@ jobs:
working-directory: web working-directory: web
run: npm ci run: npm ci
- name: Lint web
working-directory: web
run: npm run lint
- name: Type-check and build web - name: Type-check and build web
working-directory: web working-directory: web
run: npm run build run: npm run build
@@ -48,6 +64,17 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
cache: true 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 - name: go vet
run: go vet ./... run: go vet ./...
@@ -56,6 +83,3 @@ jobs:
- name: go build - name: go build
run: go build ./... run: go build ./...
# 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八),
# 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。
+5
View File
@@ -34,3 +34,8 @@ scripts/*
*.7z *.7z
*.tar.gz *.tar.gz
*.log *.log
# 前端 UI 原型(HTML 草稿与截图,约 2.8MB):只作设计参考,不是构建输入
/prototype/
# 只保留 npm 一份 lockfile:仓库里曾同时存在 pnpm-lock.yaml,
# 两份锁文件各自记录不同版本,装出哪个依赖取决于用哪个包管理器,容易排查不清
web/pnpm-lock.yaml
+4 -4
View File
@@ -31,10 +31,10 @@ type JobDonePayload struct {
} }
type Job struct { type Job struct {
id string id string
mu sync.Mutex mu sync.Mutex
events []JobEvent events []JobEvent
done bool done bool
waiters map[chan JobEvent]struct{} waiters map[chan JobEvent]struct{}
} }
+15 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/havline/havline/internal/backup" "github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/settings" "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} 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) { 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 { if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败") writeError(r, w, http.StatusInternalServerError, "创建备份失败")
return return
@@ -66,7 +73,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
} }
out.Close() 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) _ = os.Remove(tmpPath)
writeError(r, w, http.StatusBadRequest, err.Error()) writeError(r, w, http.StatusBadRequest, err.Error())
return return
@@ -91,14 +98,15 @@ func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝 // CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err) writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
return return
} }
name := filepath.Base(path) 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) _ = h.svc.Remove(name)
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err) writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
return return
@@ -141,12 +149,13 @@ func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, err.Error()) writeError(r, w, http.StatusBadRequest, err.Error())
return 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) writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
return 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) writeError(r, w, http.StatusBadRequest, err.Error(), err)
return return
} }
+31 -61
View File
@@ -44,67 +44,9 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "请求格式无效") writeError(r, w, http.StatusBadRequest, "请求格式无效")
return return
} }
allowed := map[string]bool{ // 白名单从设置注册表派生(settings.Specs 里 Writable=true 的键)。
settings.KeyTheme: true, // 之前是手写 map,新增设置项时容易只改前端与常量、忘了加白名单。
settings.KeyTimezone: true, allowed := settings.WritableKeys()
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,
}
filtered := map[string]string{} filtered := map[string]string{}
for key, value := range req { for key, value := range req {
if !allowed[key] { if !allowed[key] {
@@ -116,6 +58,13 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
} }
filtered[key] = value 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; // 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。 // 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
if code, ok := filtered[settings.KeyStatusPageCode]; ok { 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) writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
return 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 h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
if err := h.proxy.ReloadAll(r.Context()); err != nil { if err := h.proxy.ReloadAll(r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err) writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
@@ -267,6 +224,19 @@ func (h *SettingsHandler) validateSetting(key, value string) error {
return err 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 return nil
} }
+96 -9
View File
@@ -103,6 +103,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
} }
proxyStore := proxy.NewStore(conn) proxyStore := proxy.NewStore(conn)
settingsStore := settings.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) ddnsStore := ddns.NewStore(conn)
certStore := certificate.NewStore(conn) certStore := certificate.NewStore(conn)
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX")) 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) ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc) acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
backupSvc := backup.New(cfg.DataDir) backupSvc := backup.New(cfg.DataDir)
// 注入数据库连接:导出时用 VACUUM INTO 拿一致性快照,而不是直接打包正在写的文件
backupSvc.SetDB(conn)
// 指标历史:与巡检同生命周期,采样跟随巡检轮次 // 指标历史:与巡检同生命周期,采样跟随巡检轮次
metricsStore := metrics.NewStore(conn) metricsStore := metrics.NewStore(conn)
discoverySvc := discovery.New() discoverySvc := discovery.New()
@@ -137,6 +146,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
proxySvc.SetExitSync(cloudflareSvc) proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339) startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc) trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
// 删规则时连带清掉它的流量累计,否则同名规则重建会继承上一轮的读数
proxySvc.SetTrafficCleaner(trafficCollector)
updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken) updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
// 内置 SSH 终端:会话管理借用 frp 的 SSH 凭据与 known_hosts;开关默认关闭,见 settings.KeyTerminalEnabled // 内置 SSH 终端:会话管理借用 frp 的 SSH 凭据与 known_hosts;开关默认关闭,见 settings.KeyTerminalEnabled
// 会话审计落 SQLite:谁、何时、连了哪台、多久、为什么断开 // 会话审计落 SQLite:谁、何时、连了哪台、多久、为什么断开
@@ -195,9 +206,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
// 终端会话回收:空闲超时、超长会话与已结束会话都靠它清理 // 终端会话回收:空闲超时、超长会话与已结束会话都靠它清理
terminalManager.StartSweeper(ctx, time.Minute) terminalManager.StartSweeper(ctx, time.Minute)
frpMultiSvc.SetBaseContext(ctx) frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx) // cloudflared 子进程跟随应用 ctx:应用退出时不会留下孤儿进程
go frpMultiSvc.StartAgentCertificateRenewal(ctx) cloudflareSvc.SetBaseContext(ctx)
go cloudflareSvc.StartAll(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 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) sched.Start(ctx)
trafficCollector.Start(ctx) trafficCollector.Start(ctx)
@@ -267,22 +301,33 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
server: server, server: server,
nginx: nginxMgr, nginx: nginxMgr,
frpMulti: frpMultiSvc, frpMulti: frpMultiSvc,
cloudflare: cloudflareSvc,
scheduler: sched, scheduler: sched,
shutdownFn: cancel, shutdownFn: cancel,
db: conn, db: conn,
} }
// 就绪门:Nginx 首轮配置应用完之前不放后台任务。
// 否则 Agent 路由下发、Cloudflare ingress 同步会在启动瞬间各触发一次
// Nginx reload,和 bootstrap 抢同一份配置与 pid。
ready := make(chan struct{})
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil { if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
cancel() cancel()
conn.Close() conn.Close()
return nil, err return nil, err
} }
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil { close(ready)
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error()) go waitReady(ctx, ready, func() {
} if err := frpMultiSvc.ReconcileRuntime(ctx); err != nil {
if err := frpMultiSvc.AutoStart(context.Background()); err != nil { logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
logger.Warn("initial frp autostart 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) listen := strings.TrimSpace(cfg.ListenAddr)
if listen == "" { if listen == "" {
@@ -292,6 +337,42 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
return app, nil 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 { func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
if err := proxySvc.ReloadAll(ctx); err != nil { if err := proxySvc.ReloadAll(ctx); err != nil {
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error()) 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()) 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 { if a.db != nil {
return a.db.Close() return a.db.Close()
} }
+12
View File
@@ -197,6 +197,18 @@ func (s *Service) Logout(ctx context.Context, sessionID string) error {
return err 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 { func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
if sessionID == "" { if sessionID == "" {
return ErrUnauthorized return ErrUnauthorized
+87
View File
@@ -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
}
+81 -2
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings"
"time" "time"
"github.com/havline/havline/internal/settings" "github.com/havline/havline/internal/settings"
@@ -106,13 +107,37 @@ func (s *Service) Remove(name string) error {
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件, // Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
// 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。 // 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。
func (s *Service) Verify(ctx context.Context, path string) (int, error) { 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) file, err := os.Open(path)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer file.Close() 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 { if err != nil {
return 0, fmt.Errorf("备份文件无法解压:%w", err) 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) tr := tar.NewReader(gr)
count := 0 count := 0
hasDatabase := false hasDatabase := false
hasSecret := false
for { for {
header, err := tr.Next() header, err := tr.Next()
if err == io.EOF { if err == io.EOF {
@@ -133,16 +159,69 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) {
return count, err return count, err
} }
count++ count++
if header.Name == "havline.db" { switch header.Name {
case "havline.db":
hasDatabase = true hasDatabase = true
case "session_secret":
hasSecret = true
} }
} }
if !hasDatabase { if !hasDatabase {
return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份") return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份")
} }
// 缺少 session_secret 的归档不能判成坏包(旧版本导出的备份本来就长这样,
// 而且数据库与证书都还在,多半仍值得恢复),只是恢复后凭据解不开。
// 这个差别交给 MissingSessionSecret 单独告知调用方,别混进校验错误里。
_ = hasSecret
return count, nil 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 读取保留份数(未配置或越界时回落到默认值)。 // KeepCount 读取保留份数(未配置或越界时回落到默认值)。
// 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。 // 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。
func KeepCount(ctx context.Context, store *settings.Store) int { func KeepCount(ctx context.Context, store *settings.Store) int {
+2
View File
@@ -50,6 +50,8 @@ func (r *AutoRunner) Run(ctx context.Context) {
return return
} }
// 自动备份不加密:口令没法安全地存在同一台设备上(存了就等于没加密),
// 需要加密归档的用户走「立即备份 / 立即下载」手动流程。
path, err := r.svc.Export(ctx) path, err := r.svc.Export(ctx)
if err != nil { if err != nil {
r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error())) r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error()))
+148
View File
@@ -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
}
+269
View File
@@ -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
}
+151
View File
@@ -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
}
}
+56
View File
@@ -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)
}
+253 -16
View File
@@ -4,6 +4,7 @@ import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"context" "context"
"database/sql"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -14,36 +15,114 @@ import (
type Service struct { type Service struct {
dataDir string 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 配置,不接受归档自带的权限位 // 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
const ( const (
restoreFileMode = 0o640 restoreFileMode = 0o640
restoreDirMode = 0o750 restoreDirMode = 0o750
// 密钥文件的权限单独收紧:它是解密库里全部凭据的那把钥匙
restoreSecretMode = 0o600
) )
func New(dataDir string) *Service { func New(dataDir string) *Service {
return &Service{dataDir: dataDir} return &Service{dataDir: dataDir}
} }
// SetDB 注入数据库连接,导出一致性快照时使用。
// 不放进 New 的签名里:现有调用点(含测试)很多只关心文件集合。
func (s *Service) SetDB(db *sql.DB) {
s.db = db
}
// backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验 // backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验
const backupTimeFmt = "20060102-150405" const backupTimeFmt = "20060102-150405"
// Export 导出一份归档并返回路径;文件留在 <DataDir>/backups 下,由调用方决定下载后删除还是长期保留。 // Export 导出一份未加密归档并返回路径;文件留在 <DataDir>/backups 下,
// 由调用方决定下载后删除还是长期保留。
func (s *Service) Export(ctx context.Context) (string, error) { 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 { if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil {
return "", err 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 { if err != nil {
return "", err return "", err
} }
outPath := file.Name() stagingPath := staging.Name()
if err := s.writeArchive(ctx, file, outPath); err != nil { defer os.Remove(stagingPath)
if err := s.writeArchive(ctx, staging, stagingPath); err != nil {
return "", err 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 写归档,逐层检查关闭错误。 // writeArchive 写归档,逐层检查关闭错误。
@@ -59,7 +138,15 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
gw := gzip.NewWriter(file) gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw) 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 { if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard() discard()
return err return err
@@ -80,14 +167,106 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
return nil 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 { 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) file, err := os.Open(archivePath)
if err != nil { if err != nil {
return err return err
} }
defer file.Close() 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 { if err != nil {
return fmt.Errorf("无效的备份文件") return fmt.Errorf("无效的备份文件")
} }
@@ -121,7 +300,7 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
return err return err
} }
// 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置 // 归档里的权限位不照搬(构造的归档可带 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 { if err != nil {
return err return err
} }
@@ -135,6 +314,14 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
return nil 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 { func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
path := filepath.Join(baseDir, rel) path := filepath.Join(baseDir, rel)
info, err := os.Stat(path) 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) 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 { func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
root := filepath.Join(baseDir, rel) root := filepath.Join(baseDir, rel)
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { 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() { if d.IsDir() {
return nil return nil
} }
if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) { relPath, err := filepath.Rel(baseDir, path)
return nil
}
info, err := d.Info()
if err != nil { if err != nil {
return err return err
} }
relPath, err := filepath.Rel(baseDir, path) if skipArchivePath(relPath) {
return nil
}
info, err := d.Info()
if err != nil { if err != nil {
return err return err
} }
@@ -181,14 +386,46 @@ func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
return err return err
} }
defer file.Close() 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, "") header, err := tar.FileInfoHeader(info, "")
if err != nil { if err != nil {
return err return err
} }
header.Name = filepath.ToSlash(rel) header.Name = filepath.ToSlash(rel)
header.Size = size
if err := tw.WriteHeader(header); err != nil { if err := tw.WriteHeader(header); err != nil {
return err 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
} }
+133
View File
@@ -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)
}
}
+21 -1
View File
@@ -2,6 +2,7 @@ package cloudflared
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -35,12 +36,31 @@ type Manager struct {
logger *slog.Logger logger *slog.Logger
mu sync.Mutex mu sync.Mutex
processes map[int64]*processState processes map[int64]*processState
// baseCtx 是应用级 ctx:应用退出时用它连带结束未显式 Stop 的子进程。
// 不能用请求 ctx——HTTP 请求一结束子进程就会被杀掉。
baseCtx context.Context
} }
func NewManager(cfg config.Config, logger *slog.Logger) *Manager { func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}} 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 { func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"} args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote { if tunnel.Mode == ModeTokenRemote {
@@ -116,7 +136,7 @@ func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) err
m.remove(tunnel.ID) m.remove(tunnel.ID)
return err 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.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr() cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
+18 -4
View File
@@ -47,6 +47,11 @@ func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755) 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) { func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
items, err := s.store.List(ctx) items, err := s.store.List(ctx)
if err != nil { if err != nil {
@@ -752,6 +757,18 @@ func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
return hosts, nil 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 的隧道。 // StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) { func (s *Service) StartAuto(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx) settings, err := s.store.GetSettingsRaw(ctx)
@@ -767,10 +784,7 @@ func (s *Service) StartAuto(ctx context.Context) {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error()) s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return return
} }
for _, tunnel := range tunnels { for _, tunnel := range autoStartTargets(tunnels) {
if !tunnel.AutoStart {
continue
}
if _, err := s.Start(ctx, tunnel.ID); err != nil { if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error()) s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
} }
+12 -12
View File
@@ -10,18 +10,18 @@ import (
) )
type Config struct { type Config struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Provider string `json:"provider"` Provider string `json:"provider"`
Name string `json:"name"` Name string `json:"name"`
RootDomain string `json:"root_domain"` RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"` RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"` RecordNames []string `json:"record_names"`
IPv4Enabled bool `json:"ipv4_enabled"` IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"` IPv6Enabled bool `json:"ipv6_enabled"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HasToken bool `json:"has_token"` HasToken bool `json:"has_token"`
CustomIPv4 string `json:"custom_ipv4,omitempty"` CustomIPv4 string `json:"custom_ipv4,omitempty"`
CustomIPv6 string `json:"custom_ipv6,omitempty"` CustomIPv6 string `json:"custom_ipv6,omitempty"`
LastIPv4 string `json:"last_ipv4"` LastIPv4 string `json:"last_ipv4"`
LastIPv6 string `json:"last_ipv6"` LastIPv6 string `json:"last_ipv6"`
LastStatus string `json:"last_status"` LastStatus string `json:"last_status"`
+3 -3
View File
@@ -13,9 +13,9 @@ func TestTencentSubdomain(t *testing.T) {
func TestCredentialsTencentCloud(t *testing.T) { func TestCredentialsTencentCloud(t *testing.T) {
cred := CredentialsFromSave(SaveInput{ cred := CredentialsFromSave(SaveInput{
Provider: "tencentcloud", Provider: "tencentcloud",
APIToken: "AKIDxxx", APIToken: "AKIDxxx",
APISecret: "secret", APISecret: "secret",
}) })
if cred.Provider != "tencentcloud" || !cred.HasValues() { if cred.Provider != "tencentcloud" || !cred.HasValues() {
t.Fatal("expected tencentcloud credentials") t.Fatal("expected tencentcloud credentials")
+3 -3
View File
@@ -15,9 +15,9 @@ const (
diagStateFailed = "failed" diagStateFailed = "failed"
diagStateUnknown = "unknown" diagStateUnknown = "unknown"
diagTCPTimeout = 3 * time.Second diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second diagnosticsCacheTTL = 15 * time.Second
diagnosticsMaxInFlight = 4 diagnosticsMaxInFlight = 4
) )
+137 -28
View File
@@ -2,10 +2,14 @@ package logstore
import ( import (
"bufio" "bufio"
"compress/gzip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -298,16 +302,9 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
counts := make([]int, hourlyBucketCount) counts := make([]int, hourlyBucketCount)
labels := calendarHourlyLabels() labels := calendarHourlyLabels()
file, err := os.Open(path) // 轮转后的历史内容在归档文件里:只读当前文件会让「今日统计」在轮转后突然掉一半。
if err != nil { for _, line := range accessLinesForDay(path, day) {
return counts, labels entry, ok := parseAccess(line)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry, ok := parseAccess(scanner.Text())
if !ok || !isDashboardAccess(entry) { if !ok || !isDashboardAccess(entry) {
continue continue
} }
@@ -324,6 +321,75 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
return counts, labels 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 { func calendarHourlyLabels() []string {
labels := make([]string, hourlyBucketCount) labels := make([]string, hourlyBucketCount)
for i := 0; i < hourlyBucketCount; i++ { 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) { 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 var sum float64
scanner := bufio.NewScanner(file) // 与小时曲线一致:轮转后的内容在归档里,只扫当前文件会漏掉当天早些时候的请求
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for _, line := range accessLinesForDay(path, day) {
for scanner.Scan() { entry, ok := parseAccess(line)
entry, ok := parseAccess(scanner.Text())
if !ok || !isDashboardAccess(entry) { if !ok || !isDashboardAccess(entry) {
continue continue
} }
@@ -388,7 +447,14 @@ func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs
return total, errors, avgMs return total, errors, avgMs
} }
// tailLines 返回文件末尾的至多 max 行(保持原顺序,旧→新)。
//
// 之所以从文件尾往前读:日志可能上到几百 MB,而调用方只要最后几十行。
// 顺序扫描等于每刷新一次页面就把整个文件读一遍,NAS 这种长期运行的机器上很吃亏。
func tailLines(path string, max int) ([]string, error) { func tailLines(path string, max int) ([]string, error) {
if max <= 0 {
return nil, nil
}
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -398,16 +464,59 @@ func tailLines(path string, max int) ([]string, error) {
} }
defer file.Close() defer file.Close()
scanner := bufio.NewScanner(file) info, err := file.Stat()
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) if err != nil {
var lines []string return nil, err
for scanner.Scan() { }
lines = append(lines, scanner.Text()) size := info.Size()
if len(lines) > max { if size == 0 {
lines = lines[1:] 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 { func asString(v any) string {
+191
View File
@@ -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 当前正在写入的日志路径(例如 <DataDir>/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)
}
+188
View File
@@ -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)
}
}
+5 -5
View File
@@ -16,11 +16,11 @@ func TestGenerateMultiHostCustomPort(t *testing.T) {
} }
port6893 := 6893 port6893 := 6893
rules := []proxy.Rule{{ rules := []proxy.Rule{{
ID: 1, ID: 1,
Upstream: "http://192.168.8.3:6893", Upstream: "http://192.168.8.3:6893",
ListenPort: 8011, ListenPort: 8011,
ListenIPv4: true, ListenIPv4: true,
ListenIPv6: true, ListenIPv6: true,
Hosts: []proxy.Host{ Hosts: []proxy.Host{
{Hostname: "1.example.com"}, {Hostname: "1.example.com"},
{Hostname: "2.example.com"}, {Hostname: "2.example.com"},
+2 -2
View File
@@ -25,12 +25,12 @@ func TestGenerateMixedRulesWithoutCerts(t *testing.T) {
rules := []proxy.Rule{ rules := []proxy.Rule{
{ {
ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true, 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, HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
}, },
{ {
ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: 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, HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
}, },
} }
+22
View File
@@ -298,6 +298,28 @@ func (m *Manager) Stop(ctx context.Context) error {
return nil 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 { func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel() defer cancel()
+8
View File
@@ -11,6 +11,14 @@ func reloadProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGHUP) 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 { func terminateProcess(pid int, sig syscall.Signal) error {
return syscall.Kill(pid, sig) return syscall.Kill(pid, sig)
} }
+6
View File
@@ -19,6 +19,12 @@ func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported on windows") 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 { func terminateProcess(pid int, sig syscall.Signal) error {
args := []string{"/PID", strconv.Itoa(pid)} args := []string{"/PID", strconv.Itoa(pid)}
if sig == syscall.SIGKILL { if sig == syscall.SIGKILL {
+5 -5
View File
@@ -6,11 +6,11 @@ import (
) )
type TrustedProxyConfig struct { type TrustedProxyConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Preset string `json:"preset"` // "", "cloudflare", "custom" Preset string `json:"preset"` // "", "cloudflare", "custom"
CIDRs []string `json:"cidrs,omitempty"` CIDRs []string `json:"cidrs,omitempty"`
Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive,omitempty"` Recursive bool `json:"recursive,omitempty"`
} }
var cloudflareIPv4 = []string{ var cloudflareIPv4 = []string{
+27
View File
@@ -24,6 +24,7 @@ type ProxyService struct {
nginx *nginx.Manager nginx *nginx.Manager
notify *notify.Service notify *notify.Service
exitSync ExitSync exitSync ExitSync
traffic TrafficCleaner
} }
// ExitSync 处理服务发布规则变化后的外部出口同步。 // ExitSync 处理服务发布规则变化后的外部出口同步。
@@ -31,6 +32,13 @@ type ExitSync interface {
SyncRules(ctx context.Context) error 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 { 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} 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 s.exitSync = sync
} }
func (s *ProxyService) SetTrafficCleaner(cleaner TrafficCleaner) {
s.traffic = cleaner
}
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) { func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
return s.store.List(ctx) 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 { 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 { if err := s.store.Delete(ctx, id); err != nil {
return err return err
} }
_ = nginx.RemoveRuleCustom(s.cfg, id) _ = nginx.RemoveRuleCustom(s.cfg, id)
s.forgetTraffic(ctx, rule)
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err) return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
} }
@@ -132,6 +150,15 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
return nil 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 { func (s *ProxyService) ReloadAll(ctx context.Context) error {
return s.applyNginx(ctx) return s.applyNginx(ctx)
} }
+178
View File
@@ -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
}
+153
View File
@@ -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
}
}
-11
View File
@@ -96,17 +96,6 @@ const (
KeyFrpResumeOnStart = "frp_resume_on_start" 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 { type Store struct {
db *sql.DB db *sql.DB
} }
+45
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+20
View File
@@ -382,3 +382,23 @@ func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
}) })
return out 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)
}
+62
View File
@@ -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)
}
}
+20
View File
@@ -57,6 +57,26 @@ func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) err
return tx.Commit() 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 { type hostTotals struct {
Upload int64 Upload int64
Download int64 Download int64
+103
View File
@@ -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 的 <script setup lang="ts"> 必须显式指定 TS 解析器,
// 否则 eslint-plugin-vue 用的默认解析器不认识类型标注,会报
// 「Parsing error: Unexpected token XxxType」。
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: ['.vue'],
ecmaFeatures: { jsx: false },
},
globals: {
// 浏览器环境
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
fetch: 'readonly',
FormData: 'readonly',
Blob: 'readonly',
URL: 'readonly',
EventSource: 'readonly',
WebSocket: 'readonly',
ResizeObserver: 'readonly',
MutationObserver: 'readonly',
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLTextAreaElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLCanvasElement: 'readonly',
Node: 'readonly',
Element: 'readonly',
Event: 'readonly',
KeyboardEvent: 'readonly',
MouseEvent: 'readonly',
AbortController: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
matchMedia: 'readonly',
console: 'readonly',
crypto: 'readonly',
performance: 'readonly',
// 构建期注入
__APP_VERSION__: 'readonly',
},
},
rules: {
// 真问题:未使用变量、未定义变量
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrors: 'none',
},
],
// 宽松处理 any:现有 API 类型里有历史遗留,先不拦
'@typescript-eslint/no-explicit-any': 'off',
// 空块与空接口在这类前端代码里常用于「占位」,不视为错误
'no-empty': ['error', { allowEmptyCatch: true }],
// Vue:多词组件名不强求(现有单文件组件已大量单词名)
'vue/multi-word-component-names': 'off',
// 未使用组件显式注册检查交由 vue-tsc
'vue/no-unused-components': 'off',
// TaskProviderForm / LogsView 里的 form、autoRefresh 都是 v-model 双向绑定的 prop。
// 这是 Vue 官方推荐写法(父组件传 ref,子组件 v-model 直接改),规则默认会误报。
'vue/no-mutating-props': ['error', { shallowOnly: true }],
},
},
{
// 配置文件与脚本允许 CommonJS 风格的环境
files: ['*.config.{ts,js}', 'vite.config.ts'],
languageOptions: {
globals: {
process: 'readonly',
__dirname: 'readonly',
},
},
},
)
+1669
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -9,7 +9,8 @@
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"test": "vitest run" "test": "vitest run",
"lint": "eslint . --max-warnings=0"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
@@ -33,9 +34,14 @@
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"eslint": "^9.39.4",
"eslint-plugin-vue": "^9.33.0",
"globals": "^15.15.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.72.0",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
-1423
View File
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -61,6 +61,14 @@ export function asList<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [] return Array.isArray(value) ? value : []
} }
// backupHeaders 把可选的归档口令放进请求头。
// 用请求头而不是查询串:查询串会进 Nginx 访问日志,口令不该落盘。
function backupHeaders(passphrase?: string): HeadersInit | undefined {
const value = passphrase?.trim()
if (!value) return undefined
return { 'X-Backup-Passphrase': value }
}
const REQUEST_TIMEOUT_MS = 30_000 const REQUEST_TIMEOUT_MS = 30_000
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000 const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000 const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000
@@ -455,8 +463,11 @@ export const api = {
return request<DiscoveredService[]>(`/api/discovery/scan?${q}`) return request<DiscoveredService[]>(`/api/discovery/scan?${q}`)
}, },
async exportBackup() { async exportBackup(passphrase?: string) {
const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' }) const response = await fetch(withBase('/api/backup/export'), {
credentials: 'include',
headers: backupHeaders(passphrase),
})
if (!response.ok) { if (!response.ok) {
throw new Error('导出备份失败') throw new Error('导出备份失败')
} }
@@ -471,10 +482,16 @@ export const api = {
listBackupArchives: () => listBackupArchives: () =>
request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'), request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'),
createBackupArchive: () => createBackupArchive: (passphrase?: string) =>
request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }), request<{ name: string; removed: number }>('/api/backup/archives', {
restoreBackupArchive: (name: string) => method: 'POST',
request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }), headers: backupHeaders(passphrase),
}),
restoreBackupArchive: (name: string, passphrase?: string) =>
request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, {
method: 'POST',
headers: backupHeaders(passphrase),
}),
deleteBackupArchive: (name: string) => deleteBackupArchive: (name: string) =>
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }), request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
async downloadBackupArchive(name: string) { async downloadBackupArchive(name: string) {
@@ -491,12 +508,13 @@ export const api = {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}, },
async restoreBackup(file: File) { async restoreBackup(file: File, passphrase?: string) {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
const response = await fetch(withBase('/api/backup/restore'), { const response = await fetch(withBase('/api/backup/restore'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: backupHeaders(passphrase),
body: form, body: form,
}) })
if (!response.ok) { if (!response.ok) {
+14 -1
View File
@@ -115,6 +115,19 @@ export function useTheme() {
themeMode.value = isDark.value ? 'light' : 'dark' themeMode.value = isDark.value ? 'light' : 'dark'
} }
// syncFromSettings 用后端设置覆盖本地偏好。
//
// 后端 settings.theme 是唯一权威来源(这样换浏览器、换设备看到的是同一个主题),
// localStorage 只作为「拿到后端值之前」的快速回显,避免首屏闪一下浅色。
// 调用方在登录后拉一次 /api/settings 时调用它。
function syncFromSettings(mode: string | undefined) {
const value = (mode ?? '').trim()
if (!value) return
if (value !== 'system' && value !== 'light' && value !== 'dark') return
if (value === themeMode.value) return
themeMode.value = value
}
watch( watch(
themeMode, themeMode,
(value) => { (value) => {
@@ -132,5 +145,5 @@ export function useTheme() {
}) })
} }
return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode } return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode, syncFromSettings }
} }
+24 -2
View File
@@ -77,7 +77,7 @@
<header class="topbar"> <header class="topbar">
<span class="topbar__datetime">{{ datetime }}</span> <span class="topbar__datetime">{{ datetime }}</span>
<div class="topbar__right"> <div class="topbar__right">
<n-button quaternary circle @click="toggleTheme"> <n-button quaternary circle @click="handleToggleTheme">
<template #icon> <template #icon>
<n-icon :component="isDark ? MoonOutline : SunnyOutline" /> <n-icon :component="isDark ? MoonOutline : SunnyOutline" />
</template> </template>
@@ -148,7 +148,7 @@ import UpdateModal from '../components/UpdateModal.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const { isDark, toggleTheme } = useTheme() const { isDark, themeMode, toggleTheme, syncFromSettings } = useTheme()
const collapsed = ref(false) const collapsed = ref(false)
const datetime = ref('') const datetime = ref('')
const appVersion = ref(__APP_VERSION__) const appVersion = ref(__APP_VERSION__)
@@ -243,6 +243,18 @@ function handleMenu(key: string) {
router.push({ name: key }) router.push({ name: key })
} }
// handleToggleTheme 顶栏的快捷切换:本地立即生效,同时回写后端设置。
// 不回写的话会出现「这里切了、设置页显示的还是旧值」,下次任何一次保存设置
// 又会把主题改回去。
async function handleToggleTheme() {
toggleTheme()
try {
await api.saveSettings({ theme: themeMode.value })
} catch {
// 回写失败不影响当前会话的外观,下次进设置页仍能看到服务端的值
}
}
async function handleUserMenu(key: string) { async function handleUserMenu(key: string) {
if (key === 'logout') { if (key === 'logout') {
await api.logout() await api.logout()
@@ -255,6 +267,16 @@ onMounted(() => {
loadVersion() loadVersion()
updateClock() updateClock()
clockTimer = window.setInterval(updateClock, 30_000) clockTimer = window.setInterval(updateClock, 30_000)
// 主题以后端设置为准:localStorage 只是首屏回显,登录后要用服务端的值覆盖,
// 否则换设备/换浏览器两个人看到的是不同主题(而设置页显示的是服务端那个)。
api
.getSettings()
.then((settings) => {
syncFromSettings(settings.theme)
})
.catch(() => {
// 读不到设置就沿用本地偏好,不影响使用
})
}) })
onUnmounted(() => { onUnmounted(() => {
+1 -1
View File
@@ -43,7 +43,7 @@ export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
const message = entry.message.trim() const message = entry.message.trim()
const msgLower = message.toLowerCase() const msgLower = message.toLowerCase()
let level = entry.level.trim().toUpperCase() let level = entry.level.trim().toUpperCase()
let displayModule = systemModuleLabel(entry.module) const displayModule = systemModuleLabel(entry.module)
if ( if (
level === 'INFO' && level === 'INFO' &&
+8 -1
View File
@@ -130,7 +130,7 @@
:show-status="true" :show-status="true"
:auto-refresh="autoRefresh" :auto-refresh="autoRefresh"
@refresh="loadAccess" @refresh="loadAccess"
@toggle-auto="autoRefresh = !autoRefresh" @toggle-auto="toggleAutoRefresh"
/> />
<n-data-table <n-data-table
v-if="filteredAccessLogs.length > 0" v-if="filteredAccessLogs.length > 0"
@@ -318,6 +318,13 @@ const tab = ref(resolveTab(route.query.tab))
const pageError = ref('') const pageError = ref('')
const autoRefresh = ref(false) const autoRefresh = ref(false)
// toggleAutoRefresh 用函数而不是在模板里直接赋值:
// LogToolbar 是 defineComponent + props 的子组件,在模板里写 autoRefresh = !autoRefresh
// 会被 eslint 判成「修改 prop」(模板作用域里分不清 ref 与 prop),也容易误改到子组件状态。
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
}
const accessLogs = ref<AccessLogEntry[]>([]) const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([]) const proxyRules = ref<ProxyRule[]>([])
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value)) const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
+29 -6
View File
@@ -34,7 +34,7 @@
<div class="settings-block settings-block--spaced"> <div class="settings-block settings-block--spaced">
<div class="settings-block__label">时区</div> <div class="settings-block__label">时区</div>
<n-select v-model:value="form.timezone" :options="timezoneOptions" /> <n-select v-model:value="form.timezone" :options="timezoneOptions" />
<p class="field-hint">影响日志、证书到期时间等显示</p> <p class="field-hint">决定通知消息里的时间戳与后端日志时间(保存后立即生效)</p>
</div> </div>
</HavlineCard> </HavlineCard>
@@ -59,7 +59,9 @@
<span class="settings-field__label">日志保留天数</span> <span class="settings-field__label">日志保留天数</span>
<n-input-number v-model:value="logRetention" :min="1" :max="365" class="settings-field__input" /> <n-input-number v-model:value="logRetention" :min="1" :max="365" class="settings-field__input" />
</div> </div>
<p class="field-hint">超过保留天数的日志将被自动清理</p> <p class="field-hint">
Nginx 访问/错误日志超过 200MB 会轮转归档,这里控制归档文件保留多少天
</p>
</div> </div>
</div> </div>
</HavlineCard> </HavlineCard>
@@ -130,6 +132,19 @@
</div> </div>
</n-upload> </n-upload>
</div> </div>
<div class="backup-passphrase">
<n-input
v-model:value="backupPassphrase"
type="password"
show-password-on="click"
placeholder="归档口令(可选)"
class="backup-passphrase__input"
/>
<p class="field-hint">
填写后导出与「立即备份」会产出加密归档,恢复与校验时需要同一个口令;
自动备份不加密(口令存在同一台设备上等于没有加密)。留空则使用原有未加密格式。
</p>
</div>
<div class="backup-auto"> <div class="backup-auto">
<div class="form-switch-row"> <div class="form-switch-row">
<div class="form-switch-row__text"> <div class="form-switch-row__text">
@@ -917,6 +932,8 @@ const backupKeepCount = ref(DEFAULT_BACKUP_KEEP_COUNT)
const backupArchives = ref<BackupArchive[]>([]) const backupArchives = ref<BackupArchive[]>([])
const backupBusy = ref<string | null>(null) const backupBusy = ref<string | null>(null)
const backupError = ref('') const backupError = ref('')
// 可选的归档口令:只放内存,不写 localStorage(加密归档的口令不该留在浏览器里)
const backupPassphrase = ref('')
// 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理) // 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理)
const statusPageEnabled = ref(false) const statusPageEnabled = ref(false)
@@ -1700,7 +1717,7 @@ async function createBackupArchive() {
if (backupBusy.value) return if (backupBusy.value) return
backupBusy.value = 'create' backupBusy.value = 'create'
try { try {
await api.createBackupArchive() await api.createBackupArchive(backupPassphrase.value)
await loadBackupArchives() await loadBackupArchives()
message.success('已创建一份备份') message.success('已创建一份备份')
} catch (error) { } catch (error) {
@@ -1730,7 +1747,7 @@ async function downloadBackupArchive(archive: BackupArchive) {
async function restoreBackupArchive(archive: BackupArchive) { async function restoreBackupArchive(archive: BackupArchive) {
backupBusy.value = `restore:${archive.name}` backupBusy.value = `restore:${archive.name}`
try { try {
const result = await api.restoreBackupArchive(archive.name) const result = await api.restoreBackupArchive(archive.name, backupPassphrase.value)
message.success(result.message || '备份已恢复,建议重启 Havline') message.success(result.message || '备份已恢复,建议重启 Havline')
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '恢复失败') message.error(error instanceof Error ? error.message : '恢复失败')
@@ -1756,7 +1773,7 @@ function openStatusPage() {
async function exportBackup() { async function exportBackup() {
exporting.value = true exporting.value = true
try { try {
await api.exportBackup() await api.exportBackup(backupPassphrase.value)
message.success('备份已开始下载') message.success('备份已开始下载')
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '导出失败') message.error(error instanceof Error ? error.message : '导出失败')
@@ -1769,7 +1786,7 @@ async function onRestoreFile({ file }: { file: UploadFileInfo }) {
if (!file.file) return if (!file.file) return
restoring.value = true restoring.value = true
try { try {
const result = await api.restoreBackup(file.file) const result = await api.restoreBackup(file.file, backupPassphrase.value)
message.success(result.message) message.success(result.message)
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '恢复失败') message.error(error instanceof Error ? error.message : '恢复失败')
@@ -1925,6 +1942,12 @@ useVisibilityPolling(loadChinaCIDRStatus, 2000, { enabled: chinaCIDRPolling })
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--havline-error); line-height: 1.6; } .config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--havline-error); line-height: 1.6; }
/* 自动备份:开关 + 保留份数 + 备份列表 */ /* 自动备份:开关 + 保留份数 + 备份列表 */
.backup-passphrase {
display: grid; gap: 6px;
margin-top: var(--havline-space-4); padding-top: var(--havline-space-4);
border-top: 1px solid var(--havline-border);
}
.backup-passphrase__input { max-width: 320px; }
.backup-auto { .backup-auto {
display: grid; gap: var(--havline-space-3); display: grid; gap: var(--havline-space-3);
margin-top: var(--havline-space-4); padding-top: var(--havline-space-4); margin-top: var(--havline-space-4); padding-top: var(--havline-space-4);