feat(config,docker): 会话密钥未配置时自动生成并持久化,compose 改为留空即自动(v1.2.17)
CI / verify (push) Canceled after 0s

- internal/config 新增 ResolveSessionSecret:显式配置优先;未配置则读 <数据目录>/session_secret,不存在或内容损坏就生成 32 字节随机密钥(64 字符,0600,先写临时文件再改名、写后显式 Chmod)

- FONU_SESSION_SECRET 默认值由 change-me-in-production 改为空(空 = 未配置 → 自动生成);启动日志打印密钥来源。注意该密钥同时是 secret.NewBox 的加密密钥,因此不替换已配置的值(含历史默认值,只提示)——替换会让库里已加密的凭据无法解密

- 三个 compose 统一 \;docker-compose.hub.yml 单文件部署不再需要 .env,直接 docker compose up -d --build

- .gitignore 补 .env 与 .env.local;测试 4 例(生成复用/权限/显式优先/历史默认值保留/损坏重生成),go build/vet/test 全绿
This commit is contained in:
2026-09-27 15:08:28 +08:00
parent 84842e9359
commit 1309cfef5b
10 changed files with 204 additions and 11 deletions
+8
View File
@@ -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
+67 -1
View File
@@ -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"
+90
View File
@@ -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)
}
}