fix(agent): 修复 nginx 版本目录被误加载并发布 v1.4.4
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 14:37:15 +08:00
parent 8c07e64e87
commit c92d06642f
15 changed files with 177 additions and 23 deletions
+16
View File
@@ -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
### 修复
+1 -1
View File
@@ -1 +1 @@
1.4.3
1.4.4
+1 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.4.3)
> 最近更新:2026-09-28(v1.4.4)
## 方案与设计(本目录)
+2 -2
View File
@@ -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 兼容清理和可选主机防火墙辅助。
---
+4 -4
View File
@@ -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`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
+25 -1
View File
@@ -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 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
// 修掉旧 <name>.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 目录下的 <name>.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 == "" {
+30
View File
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
+55 -2
View File
@@ -61,15 +61,62 @@ func Backup(path string) error {
return WriteFileAtomic(path+".bak", data, 0o600)
}
// VersionsDir 返回某文件的版本目录(<path>.versions),供列表 / 回滚读取。
// VersionsDir 返回某文件的版本目录(.<name>.versions),供列表 / 回滚读取。
// 目录名以点号开头,避免被 nginx 的 include <dir>/* 当成配置文件读取。
func VersionsDir(path string) string {
return filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".versions")
}
// legacyVersionsDir 是旧布局(<path>.versions)。它会被 nginx 的 include * 误读为配置文件,
// 因此读取 / 备份前统一迁移到隐藏目录。
func legacyVersionsDir(path string) string {
return path + ".versions"
}
// BackupVersioned 把现有文件留存为 <path>.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 把现有文件留存为 .<name>.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) {
+31
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.3 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.4 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.3。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.4。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.4.3 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.4.4 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.4.3。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.3。</li>
<li>验证标识更新为 <span class="mono">v1.4.3-example-page</span>。</li>
<li>页面版本提示更新为 v1.4.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.4。</li>
<li>验证标识更新为 <span class="mono">v1.4.4-example-page</span>。</li>
</ul>
</FonuCard>
@@ -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