diff --git a/backend/config/config.go b/backend/config/config.go index 5bf9a18..c50a606 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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 { diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..4d7c6a0 --- /dev/null +++ b/backend/config/config_test.go @@ -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("显式提供时不应写持久化文件") + } +} diff --git a/docs/user-guide/install.md b/docs/user-guide/install.md index f00787f..ba76a63 100644 --- a/docs/user-guide/install.md +++ b/docs/user-guide/install.md @@ -1,57 +1,29 @@ # 安装与启动 -LokiStack 面向 Linux 服务器交付:单二进制(内嵌前端、文档与落地页),或 Docker / Kubernetes 一键部署。按环境选择。 - -## 一键安装(推荐) - -```bash -bash -c "$(curl -fsSL https://gitea.91ai.io/rlwj-lab/LokiStack/raw/main/scripts/quick_start.sh)" -``` - -自动检测架构、下载发布包、生成随机密钥并注册 systemd 服务;重复执行即升级(数据自动备份)。可用环境变量覆盖安装目录(`INSTALL_DIR`)、版本(`VERSION`)、端口(`PORT`)与下载源(`BASE_URL`)。 - -## 环境要求 - -- 平台自身:Linux x86_64 或 arm64,内存 ≥ 256MB -- 目标 Kubernetes 集群 ≥ 1.20;或裸金属 Linux 主机(SSH 可达) - -## 方式二:Kubernetes 一键部署 +## 方式一:Kubernetes ```bash bash kubernetes/install.sh ``` -自动适配阿里云 ACK / 腾讯云 TKE / AWS EKS / 华为云 CCE / GCP GKE / 自建集群,部署到 `log` 命名空间,幂等可重复执行。常用变量: - -```bash -WITH_NODEPORT=1 bash kubernetes/install.sh # 同时开 NodePort(配合 Ingress) -STORAGE_CLASS=local-path bash kubernetes/install.sh # 手动指定 StorageClass -``` - -## 方式三:Docker +## 方式二:Docker ```bash docker compose up -d ``` -一条命令即可启动:未显式提供 `JWT_SECRET` / `ENCRYPTION_KEY` 时,容器自动生成随机密钥并持久化到数据卷(`$DATA_DIR/.secrets.env`,权限 0600),重启与升级保持不变。需要固定密钥或接入自有密钥管理时,再通过环境变量显式提供即可覆盖。 - -## 方式四:单二进制 - -从 Release 下载对应架构的包(Linux x86_64 / arm64): +## 方式三:单二进制 ```bash curl -LO https://gitea.91ai.io/rlwj-lab/LokiStack/releases/download/v2.0.0/lokistack-linux-amd64.tar.gz tar xzf lokistack-linux-amd64.tar.gz && cd lokistack-linux-amd64 -export DATA_DIR=/data # 密钥未设置时启动会自动生成并持久化到 $DATA_DIR/.secrets.env ./lokistack ``` -包内含二进制、用户手册(`/docs`)与落地页(`/homepage`),无需其它依赖;arm64 服务器换 `lokistack-linux-arm64.tar.gz`。 +arm64 服务器下载 `lokistack-linux-arm64.tar.gz`。 -首次启动自动创建管理员账号,初始密码写入 `$DATA_DIR/initial-admin-password.txt`,请立即修改。 +## 说明 -## 数据与升级 - -- 全部状态存储在 `$DATA_DIR/lokistack.json`(JSON 文件,无外部数据库依赖,单实例运行) -- 升级:替换二进制(或重新拉取镜像)后重启;历史命名数据文件会在启动时自动迁移 +- 未提供 `JWT_SECRET` / `ENCRYPTION_KEY` 时,自动生成随机密钥并持久化到数据目录(`.secrets.env`,权限 0600),重启与升级保持不变;显式设置环境变量可随时覆盖 +- 全部状态存储在 `$DATA_DIR/lokistack.json`,单实例运行;首次启动自动创建管理员账号,初始密码在 `$DATA_DIR/initial-admin-password.txt` +- 升级:替换二进制或重新拉取镜像后重启,历史数据自动迁移