feat(config): 密钥自动生成下沉到后端 + 安装文档极简化
- resolveSecrets 统一所有部署形态的零配置体验:显式 env 优先 → DATA_DIR/.secrets.env → 自动生成随机密钥持久化(0600,重启不变);无法持久化才拒绝启动(单测覆盖生成/复用/显式优先) - install.md 重写为三方式各一条命令(K8s install.sh / docker compose up -d / release 包), 密钥与数据说明收敛到统一「说明」一节;README Docker 段同步精简
This commit is contained in:
1 parent
88b2ac2a6d
commit
65bb29aa72
3 files changed
+111
-46
No files matched your search
+53
-10
@@ -1,10 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -24,20 +27,60 @@ type Config struct {
|
||||
HostKeyDir string // 新增:SSH主机密钥存储目录
|
||||
}
|
||||
|
||||
// resolveSecrets 密钥解析(所有部署形态统一的零配置体验):
|
||||
// 显式环境变量优先;缺失时读取 DATA_DIR/.secrets.env;两者皆无则生成随机密钥
|
||||
// 并持久化(0600),重启/升级保持不变。无法持久化时拒绝启动,绝不使用易失密钥。
|
||||
func resolveSecrets(dataDir string) (jwt, enc string) {
|
||||
jwt = os.Getenv("JWT_SECRET")
|
||||
enc = os.Getenv("ENCRYPTION_KEY")
|
||||
if jwt != "" && enc != "" {
|
||||
return jwt, enc
|
||||
}
|
||||
secretsFile := filepath.Join(dataDir, ".secrets.env")
|
||||
if data, err := os.ReadFile(secretsFile); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "JWT_SECRET="); ok && jwt == "" && v != "" {
|
||||
jwt = v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "ENCRYPTION_KEY="); ok && enc == "" && v != "" {
|
||||
enc = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if jwt != "" && enc != "" {
|
||||
return jwt, enc
|
||||
}
|
||||
gen := func() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
log.Fatal("[SECURITY] 生成随机密钥失败: ", err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
if jwt == "" {
|
||||
jwt = gen()
|
||||
log.Printf("[SECURITY] JWT_SECRET 未设置,已生成随机密钥并持久化到 %s", secretsFile)
|
||||
}
|
||||
if enc == "" {
|
||||
enc = gen()
|
||||
log.Printf("[SECURITY] ENCRYPTION_KEY 未设置,已生成随机密钥并持久化到 %s", secretsFile)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
log.Fatal("[SECURITY] 无法创建数据目录以持久化密钥: ", err)
|
||||
}
|
||||
if err := os.WriteFile(secretsFile, []byte(fmt.Sprintf(
|
||||
"# LokiStack auto-generated. Delete to regenerate.\nJWT_SECRET=%s\nENCRYPTION_KEY=%s\n", jwt, enc,
|
||||
)), 0o600); err != nil {
|
||||
log.Fatal("[SECURITY] 密钥持久化失败(请显式设置 JWT_SECRET / ENCRYPTION_KEY): ", err)
|
||||
}
|
||||
return jwt, enc
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
once.Do(func() {
|
||||
dataDir := getEnv("DATA_DIR", "./data")
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
encKey := os.Getenv("ENCRYPTION_KEY")
|
||||
|
||||
// 生产环境强制校验:JWT_SECRET和ENCRYPTION_KEY必须设置
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("[SECURITY] JWT_SECRET environment variable is required. Please set a strong random secret.")
|
||||
}
|
||||
if encKey == "" {
|
||||
log.Fatal("[SECURITY] ENCRYPTION_KEY environment variable is required. Please set a strong 32-byte random key (base64 or hex encoded).")
|
||||
}
|
||||
jwtSecret, encKey := resolveSecrets(dataDir)
|
||||
|
||||
// ENCRYPTION_KEY 长度校验:至少32字节(256位)
|
||||
if len(encKey) < 32 {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveSecretsAutoGenerate 零配置:未设置环境变量时自动生成随机密钥并持久化,二次调用复用
|
||||
func TestResolveSecretsAutoGenerate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
t.Setenv("ENCRYPTION_KEY", "")
|
||||
|
||||
jwt1, enc1 := resolveSecrets(dir)
|
||||
if len(jwt1) != 64 || len(enc1) != 64 {
|
||||
t.Fatalf("生成的密钥长度异常: %d/%d", len(jwt1), len(enc1))
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".secrets.env"))
|
||||
if err != nil {
|
||||
t.Fatalf("密钥未持久化: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), jwt1) || !strings.Contains(string(data), enc1) {
|
||||
t.Fatal("持久化内容与返回值不一致")
|
||||
}
|
||||
info, _ := os.Stat(filepath.Join(dir, ".secrets.env"))
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("密钥文件权限应为 0600,实际 %v", info.Mode().Perm())
|
||||
}
|
||||
// 二次调用复用
|
||||
jwt2, enc2 := resolveSecrets(dir)
|
||||
if jwt1 != jwt2 || enc1 != enc2 {
|
||||
t.Fatal("二次解析应复用持久化密钥")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSecretsExplicitEnvWins 显式环境变量优先,不读不写持久化文件
|
||||
func TestResolveSecretsExplicitEnvWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("JWT_SECRET", "explicit-jwt-secret-0123456789abcdef")
|
||||
t.Setenv("ENCRYPTION_KEY", "explicit-enc-secret-0123456789abcdef")
|
||||
jwt, enc := resolveSecrets(dir)
|
||||
if jwt != "explicit-jwt-secret-0123456789abcdef" || enc != "explicit-enc-secret-0123456789abcdef" {
|
||||
t.Fatal("显式环境变量必须原样生效")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".secrets.env")); !os.IsNotExist(err) {
|
||||
t.Fatal("显式提供时不应写持久化文件")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user