diff --git a/.gitignore b/.gitignore index da854dc..3acb6db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ cmd/fonu/web/dist/ dist/ *.db /data/ +# 会话密钥等本地环境变量(.env.example 这类模板可以照常入库) +.env +.env.local .DS_Store .cursor/ # scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a5748..36bea28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [v1.2.17] - 2026-09-27 + +### 新增 + +- **会话密钥自动生成**:`FONU_SESSION_SECRET` 未配置时,应用首次启动会在数据目录里生成 32 字节随机密钥(64 字符)并持久化到 `<数据目录>/session_secret`(权限 0600,先写临时文件再改名;读到损坏内容会重新生成)。启动日志会写明密钥来源(环境变量 / 文件路径 / 仍在使用历史默认值的提示) +- **compose 改为「留空即自动」**:三个 compose 统一 `FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}`;`docker-compose.hub.yml` 的单文件部署现在只要 `docker compose up -d --build`,**不再需要预先准备 `.env`** + +### 变更 + +- `FONU_SESSION_SECRET` 的默认值由固定的 `change-me-in-production` 改为空(空 = 未配置 → 自动生成) +- **不会自动替换已配置的值**:这个密钥同时是 `secret.NewBox` 的加密密钥,替换会让库里已加密的凭据(frp token、通知密码等)无法解密;对仍在使用历史默认值的实例只提示、不改数据 +- `.gitignore` 补上 `.env` 与 `.env.local`(`.env.example` 这类模板仍可入库) +- 版本号提升到 1.2.17;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7 + +### 测试 + +- `internal/config`:密钥生成与复用、文件权限(类 Unix 上校验 0600)、显式配置优先且不写文件、历史默认值保留并提示、损坏文件重新生成 + ## [v1.2.16] - 2026-09-27 ### 改进 diff --git a/VERSION b/VERSION index f69752a..b66183a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.16 +1.2.17 diff --git a/docker-compose.host.yml b/docker-compose.host.yml index a934bf3..39ae9d7 100644 --- a/docker-compose.host.yml +++ b/docker-compose.host.yml @@ -19,7 +19,9 @@ services: environment: FONU_DATA_DIR: /data FONU_LISTEN: :6893 - FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} # 时区:通知消息时间按此显示(默认东八区) TZ: ${TZ:-Asia/Shanghai} FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080} diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 1a4b903..9c5065a 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -1,11 +1,14 @@ -# 从仓库拉源码构建并启动(适合干净机器:只需要这一个文件 + 一个 .env) +# 从仓库拉源码构建并启动(适合干净机器:只要这一个文件) # # 用法: # mkdir -p /opt/fonu && cd /opt/fonu # # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来): # # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址> -# printf 'FONU_SESSION_SECRET=%s\n' "$(openssl rand -hex 32)" > .env -# docker compose up -d --build +# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里 +# +# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 +# FONU_SESSION_SECRET=<一串随机字符,openssl rand -hex 32 可生成> +# 注意:它同时是凭据加密密钥,数据卷里已经有数据后不要随意更换,否则已存的凭据无法解密。 # # 构建上下文用的是 Git 地址:BuildKit 会自己把仓库克隆下来,不需要先 git clone 到本地; # 仓库匿名可读时无需凭据。需要认证时改用 https://<用户名>:<令牌>@<主机>/<路径>.git, @@ -35,9 +38,9 @@ services: environment: FONU_DATA_DIR: /data FONU_LISTEN: :6893 - # 不再默默回退到 change-me-in-production:没在 .env 里给值就直接拒绝启动 - # (生成方式:openssl rand -hex 32) - FONU_SESSION_SECRET: "${FONU_SESSION_SECRET:?请先创建 .env 并写入 FONU_SESSION_SECRET(openssl rand -hex 32 可生成)}" + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} # 时区:通知消息时间按此显示(默认东八区) TZ: ${TZ:-Asia/Shanghai} diff --git a/docker-compose.yml b/docker-compose.yml index f30152d..a4602ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,9 @@ services: environment: FONU_DATA_DIR: /data FONU_LISTEN: :6893 - FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-} # 时区:通知消息时间按此显示(默认东八区) TZ: ${TZ:-Asia/Shanghai} diff --git a/internal/app/app.go b/internal/app/app.go index 24bc0e6..efaf469 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -57,6 +57,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) } } + // 会话密钥:显式配置优先;未配置则在数据目录里读/生成并持久化 + // (它同时是 secret box 的加密密钥,所以只在未配置时才生成,不替换已配置的值) + secretSource, err := cfg.ResolveSessionSecret() + if err != nil { + return nil, err + } + logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource) + conn, err := db.Open(cfg.DBPath()) if err != nil { return nil, err diff --git a/internal/config/config.go b/internal/config/config.go index 81f54ed..e020748 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,13 @@ package config import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "path/filepath" "strconv" + "strings" ) type Config struct { @@ -28,7 +31,7 @@ func Load() Config { return Config{ ListenAddr: envOr("FONU_LISTEN", ":6893"), DataDir: dataDir, - SessionSecret: envOr("FONU_SESSION_SECRET", "change-me-in-production"), + SessionSecret: envOr("FONU_SESSION_SECRET", ""), NginxBin: envOr("FONU_NGINX_BIN", "nginx"), NginxPIDFile: envOr("FONU_NGINX_PID", dataDir+"/nginx/nginx.pid"), NginxMimeTypes: envOr("FONU_NGINX_MIME_TYPES", defaultMimeTypes()), @@ -123,6 +126,69 @@ func (c Config) FrpLogPath() string { return filepath.Join(c.LogsDir(), "frpc.log") } +// LegacySessionSecret 是历史版本在未配置 FONU_SESSION_SECRET 时使用的固定值。 +// 它同时是 secret box 的加密密钥,所以只提醒、不自动替换: +// 换掉它会让库里已加密的凭据(frp token、通知密码等)再也解不开。 +const LegacySessionSecret = "change-me-in-production" + +// sessionSecretPath 会话密钥的持久化位置(与数据库同在数据目录,重建容器/升级都不受影响) +func (c Config) sessionSecretPath() string { + return filepath.Join(c.DataDir, "session_secret") +} + +// ResolveSessionSecret 补齐会话密钥并返回它的来源(写进启动日志,便于确认到底用的哪一个): +// - 显式配置了(包括历史默认值)→ 原样使用,与既有数据保持兼容; +// - 没配置 → 读数据目录里的 session_secret;不存在或内容明显无效就生成一个(0600)。 +// +// 这样 Docker 与原生部署都不必手工准备密钥,也不会出现「忘配就用固定弱密钥跑在公网」。 +func (c *Config) ResolveSessionSecret() (string, error) { + if secret := strings.TrimSpace(c.SessionSecret); secret != "" { + if secret == LegacySessionSecret { + return "环境变量(仍是历史默认值,建议改成随机值,注意更换后需重新登录)", nil + } + return "环境变量", nil + } + path := c.sessionSecretPath() + if data, err := os.ReadFile(path); err == nil { + // 长度不足或带空白的视为损坏(人工改坏、写了一半):重新生成,而不是带着坏密钥启动 + if secret := strings.TrimSpace(string(data)); len(secret) >= 32 && !strings.ContainsAny(secret, " \t\r\n") { + c.SessionSecret = secret + return path, nil + } + } + secret, err := randomSessionSecret() + if err != nil { + return "", err + } + if err := os.MkdirAll(c.DataDir, 0o755); err != nil { + return "", err + } + // 先写临时文件再改名:避免中途失败留下半个密钥 + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(secret+"\n"), 0o600); err != nil { + return "", err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return "", err + } + // WriteFile 的权限会被 umask 干扰,这里再显式收一道(密钥文件不该让同机其他用户读到) + if err := os.Chmod(path, 0o600); err != nil { + return "", err + } + c.SessionSecret = secret + return path, nil +} + +// randomSessionSecret 32 字节随机数的十六进制表示(64 字符) +func randomSessionSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("生成会话密钥失败: %w", err) + } + return hex.EncodeToString(buf), nil +} + func defaultDataDir() string { if _, err := os.Stat("/data"); err == nil { return "/data" diff --git a/internal/config/session_secret_test.go b/internal/config/session_secret_test.go new file mode 100644 index 0000000..9a567d9 --- /dev/null +++ b/internal/config/session_secret_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// 未配置时:生成一个 64 字符密钥、以 0600 落盘,并且下次启动复用同一个 +func TestResolveSessionSecretGeneratesAndReuses(t *testing.T) { + dir := t.TempDir() + cfg := Config{DataDir: dir} + + source, err := cfg.ResolveSessionSecret() + if err != nil { + t.Fatalf("生成密钥失败:%v", err) + } + if len(cfg.SessionSecret) != 64 { + t.Fatalf("应生成 64 字符密钥,实际 %q", cfg.SessionSecret) + } + if source != cfg.sessionSecretPath() { + t.Fatalf("来源应指向密钥文件,实际 %q", source) + } + info, err := os.Stat(cfg.sessionSecretPath()) + if err != nil { + t.Fatalf("密钥文件没写出来:%v", err) + } + // Windows 没有 POSIX 权限位(Perm() 返回合成值),只在类 Unix 上校验 0600 + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("密钥文件权限应为 0600,实际 %v", info.Mode().Perm()) + } + + // 模拟下一次启动:必须复用同一个密钥,否则所有人都会掉线 + again := Config{DataDir: dir} + if _, err := again.ResolveSessionSecret(); err != nil { + t.Fatalf("二次读取失败:%v", err) + } + if again.SessionSecret != cfg.SessionSecret { + t.Fatalf("重复启动应复用同一个密钥:%q vs %q", again.SessionSecret, cfg.SessionSecret) + } +} + +// 显式配置优先级最高,且不应该再写密钥文件 +func TestResolveSessionSecretPrefersExplicitValue(t *testing.T) { + cfg := Config{DataDir: t.TempDir(), SessionSecret: "explicit-value-1234567890"} + source, err := cfg.ResolveSessionSecret() + if err != nil { + t.Fatalf("不应报错:%v", err) + } + if cfg.SessionSecret != "explicit-value-1234567890" { + t.Fatalf("显式配置应原样保留,实际 %q", cfg.SessionSecret) + } + if source != "环境变量" { + t.Fatalf("来源应标为环境变量,实际 %q", source) + } + if _, err := os.Stat(cfg.sessionSecretPath()); err == nil { + t.Fatal("显式配置时不应生成密钥文件") + } +} + +// 历史默认值必须原样保留(它同时是 secret box 的密钥),但来源要提示出来 +func TestResolveSessionSecretKeepsLegacyValueWithWarning(t *testing.T) { + cfg := Config{DataDir: t.TempDir(), SessionSecret: LegacySessionSecret} + source, err := cfg.ResolveSessionSecret() + if err != nil { + t.Fatalf("不应报错:%v", err) + } + if cfg.SessionSecret != LegacySessionSecret { + t.Fatalf("历史默认值必须保留,实际 %q", cfg.SessionSecret) + } + if source == "" || source == "环境变量" { + t.Fatalf("应提示这仍是历史默认值,实际 %q", source) + } +} + +// 密钥文件被写坏时重新生成,而不是带着坏密钥启动 +func TestResolveSessionSecretReplacesCorruptedFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "session_secret"), []byte("short\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg := Config{DataDir: dir} + if _, err := cfg.ResolveSessionSecret(); err != nil { + t.Fatalf("不应报错:%v", err) + } + if len(cfg.SessionSecret) != 64 { + t.Fatalf("损坏的密钥文件应被替换,实际 %q", cfg.SessionSecret) + } +} diff --git a/web/package.json b/web/package.json index d45378c..365ca8c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.2.16", + "version": "1.2.17", "packageManager": "npm@10.9.4", "type": "module", "scripts": {