diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f78052..cc7bea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [v1.4.4] - 2026-09-28 + +### 修复 + +- **Agent nginx 版本目录被误加载**:切换 HTTPS 直连时,配置备份会写到 `<配置文件>.versions` 目录;该目录位于 `sites-enabled` 时会被 nginx 的 `include .../sites-enabled/*` 当成配置文件读取,导致 `nginx -t` 报 `pread() ".../fonuagent-https.conf.versions" failed (21: Is a directory)`。现在版本目录改为隐藏目录 `.<文件名>.versions`,`include *` 不再匹配;旧目录会在读取、备份或 `nginx -t` 前自动迁移,历史版本内容保留 + +### 变更 + +- 版本号提升到 1.4.4;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.0 → **0.13.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/fsutil`:新增隐藏版本目录与旧目录迁移测试 +- `internal/agent`:新增 `migrateLegacyVersionDirs` 迁移测试 + ## [v1.4.3] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index 428b770..1c99cf0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.3 +1.4.4 diff --git a/docs/README.md b/docs/README.md index 39e2e0b..df05e89 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.4.3) +> 最近更新:2026-09-28(v1.4.4) ## 方案与设计(本目录) diff --git a/docs/agent-listen-strategy-plan.md b/docs/agent-listen-strategy-plan.md index 08ca82a..f93d554 100644 --- a/docs/agent-listen-strategy-plan.md +++ b/docs/agent-listen-strategy-plan.md @@ -2,10 +2,10 @@ > 方案编号:P0-5 > 制定日期:2026-09-28 -> 当前 AgentVersion:0.13.0 +> 当前 AgentVersion:0.13.1 > 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 -> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。 +> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 修复 nginx 版本目录被 `include *` 误加载的问题;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。 --- diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index db3510e..1590fb7 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.4.3 的验证流程: +示例页面用于验证升级结果。v1.4.4 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.3`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.4`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.4.3`; - - 页面显示验证标识 `v1.4.3-example-page`; + - 页面显示的版本为 `1.4.4`; + - 页面显示验证标识 `v1.4.4-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/server.go b/internal/agent/server.go index ff2075d..eb87520 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -52,7 +52,9 @@ import ( // 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。 // 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。 // 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。 -const AgentVersion = "0.13.0" +// 0.13.1:nginx 版本备份目录改为隐藏目录(..versions)并自动迁移旧目录, +// 修掉旧 .versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。 +const AgentVersion = "0.13.1" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -760,6 +762,7 @@ func detectNginxConfDir(binary string) (string, error) { }) var lastReason string for _, dir := range dirs { + migrateLegacyVersionDirs(dir) if err := os.MkdirAll(dir, 0o755); err != nil { lastReason = fmt.Sprintf("%s: %v", dir, err) continue @@ -1275,9 +1278,30 @@ func (s *Server) routePath(domain string) string { } func (s *Server) validateNginx() error { + migrateLegacyVersionDirs(s.cfg.NginxConfDir) return runCommand(s.nginxPath(), "-t") } +// migrateLegacyVersionDirs 把 conf 目录下的 .versions 目录迁移为隐藏目录。 +// 旧布局会被 nginx 的 include * 当成配置文件读取,报 pread() "Is a directory" 导致 nginx -t 失败。 +func migrateLegacyVersionDirs(dir string) { + dir = strings.TrimSpace(dir) + if dir == "" { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == ".versions" || !strings.HasSuffix(entry.Name(), ".versions") { + continue + } + base := strings.TrimSuffix(filepath.Join(dir, entry.Name()), ".versions") + _ = fsutil.MigrateLegacyVersions(base) + } +} + func (s *Server) reloadNginx() error { nginx := s.nginxPath() if nginx == "" { diff --git a/internal/agent/versions_test.go b/internal/agent/versions_test.go new file mode 100644 index 0000000..285f022 --- /dev/null +++ b/internal/agent/versions_test.go @@ -0,0 +1,30 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMigrateLegacyVersionDirs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fonuagent-https.conf") + legacy := path + ".versions" + if err := os.MkdirAll(legacy, 0o700); err != nil { + t.Fatal(err) + } + name := "20260101T000000.000-abcd.conf" + if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + migrateLegacyVersionDirs(dir) + + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err) + } + entries, err := os.ReadDir(filepath.Join(dir, ".fonuagent-https.conf.versions")) + if err != nil || len(entries) != 1 { + t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err) + } +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index b6953b5..ee8d9f5 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 17e689d..d999d97 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index b9dff69..b9dc331 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -61,15 +61,62 @@ func Backup(path string) error { return WriteFileAtomic(path+".bak", data, 0o600) } -// VersionsDir 返回某文件的版本目录(.versions),供列表 / 回滚读取。 +// VersionsDir 返回某文件的版本目录(..versions),供列表 / 回滚读取。 +// 目录名以点号开头,避免被 nginx 的 include /* 当成配置文件读取。 func VersionsDir(path string) string { + return filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".versions") +} + +// legacyVersionsDir 是旧布局(.versions)。它会被 nginx 的 include * 误读为配置文件, +// 因此读取 / 备份前统一迁移到隐藏目录。 +func legacyVersionsDir(path string) string { return path + ".versions" } -// BackupVersioned 把现有文件留存为 .versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。 +// MigrateLegacyVersions 把旧版本目录迁移到隐藏目录。只移动内容,不删除历史版本; +// 迁移后旧目录已空,删除它是为了让 nginx 的 include * 不再命中该目录。 +func MigrateLegacyVersions(path string) error { + legacy := legacyVersionsDir(path) + info, err := os.Stat(legacy) + if err != nil || !info.IsDir() { + return nil + } + target := VersionsDir(path) + if _, err := os.Stat(target); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.Rename(legacy, target); err == nil { + return nil + } + } + if err := os.MkdirAll(target, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(legacy) + if err != nil { + return err + } + for _, entry := range entries { + src := filepath.Join(legacy, entry.Name()) + dst := filepath.Join(target, entry.Name()) + if _, err := os.Stat(dst); err == nil { + continue + } + if err := os.Rename(src, dst); err != nil { + return err + } + } + return os.Remove(legacy) +} + +// BackupVersioned 把现有文件留存为 ..versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。 // 文件名前缀是 UTC 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。 // 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。 func BackupVersioned(path string, keep int) (string, error) { + if err := MigrateLegacyVersions(path); err != nil { + return "", err + } data, err := os.ReadFile(path) if os.IsNotExist(err) { return "", nil @@ -107,6 +154,9 @@ type VersionEntry struct { // ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。 func ListVersions(path string) ([]VersionEntry, error) { + if err := MigrateLegacyVersions(path); err != nil { + return nil, err + } entries, err := os.ReadDir(VersionsDir(path)) if err != nil { if os.IsNotExist(err) { @@ -135,6 +185,9 @@ func ReadVersion(path, name string) ([]byte, error) { if name == "" || name != filepath.Base(name) || name == "." || name == ".." { return nil, fmt.Errorf("版本名无效") } + if err := MigrateLegacyVersions(path); err != nil { + return nil, err + } data, err := os.ReadFile(filepath.Join(VersionsDir(path), name)) if err != nil { if os.IsNotExist(err) { diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go index b08b933..0e5587b 100644 --- a/internal/fsutil/fsutil_test.go +++ b/internal/fsutil/fsutil_test.go @@ -198,3 +198,34 @@ func TestReadVersionRejectsTraversal(t *testing.T) { t.Fatalf("版本目录不存在时应返回空列表且无错误:%v %v", versions, err) } } + +func TestVersionsDirIsHidden(t *testing.T) { + path := filepath.Join("/etc/nginx/sites-enabled", "fonuagent-https.conf") + base := filepath.Base(VersionsDir(path)) + if !strings.HasPrefix(base, ".") { + t.Fatalf("版本目录应以点号开头,避免被 nginx 的 include * 加载:%s", base) + } +} + +func TestMigrateLegacyVersions(t *testing.T) { + path := filepath.Join(t.TempDir(), "fonuagent-https.conf") + legacy := path + ".versions" + if err := os.MkdirAll(legacy, 0o700); err != nil { + t.Fatal(err) + } + name := "20260101T000000.000-abcd.conf" + if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + versions, err := ListVersions(path) + if err != nil || len(versions) != 1 { + t.Fatalf("迁移后应保留 1 个版本:%v %v", versions, err) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Fatalf("旧版本目录应被移走,避免 nginx include * 命中:%v", err) + } + data, err := ReadVersion(path, versions[0].Name) + if err != nil || string(data) != "old" { + t.Fatalf("迁移后版本内容应可读:%q %v", data, err) + } +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index 30b46c2..ccdadda 100644 --- a/internal/nginx/nginx.go +++ b/internal/nginx/nginx.go @@ -131,7 +131,7 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou } currentPath := m.cfg.NginxConfigPath() - // 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚) + // 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),隐藏的 .versions 目录保留最近 10 份(供版本对比与回滚) if err := fsutil.Backup(currentPath); err != nil { _ = os.Remove(tmpPath) return ApplyResult{}, err diff --git a/web/package-lock.json b/web/package-lock.json index 3d6c64e..a8c072f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.4.3", + "version": "1.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.4.3", + "version": "1.4.4", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 4681259..27e404c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.4.3", + "version": "1.4.4", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 5b2415b..8a68c70 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.3。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.4。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.4.3。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.3。
  • -
  • 验证标识更新为 v1.4.3-example-page。
  • +
  • 页面版本提示更新为 v1.4.4。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.4。
  • +
  • 验证标识更新为 v1.4.4-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.4.3' -const verificationMarker = 'v1.4.3-example-page' +const verificationRound = 'v1.4.4' +const verificationMarker = 'v1.4.4-example-page' async function refreshVersion() { refreshing.value = true