备份、隧道生命周期、日志、会话与设置项存在一批「静默失效」问题,本次一并收敛。 正确性: - 备份归档补上 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:
@@ -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` 作为门禁。
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"`
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
Generated
+1669
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -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"
|
||||||
|
|||||||
Generated
-1423
File diff suppressed because it is too large
Load Diff
+25
-7
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user