diff --git a/.gitignore b/.gitignore index 9732ee2..8330426 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ .env .env.local .env.signing +deploy/config.local.yaml +deploy/ops-config.local.yaml apps/*/.env.signing apps/*/dist/ apps/*/out/ @@ -23,4 +25,5 @@ apps/*/firefox-mv*/ *.tmp apps/*/stats.html ops-api -tmp/* \ No newline at end of file +tmp/* +*.pem diff --git a/deploy/OFFLINE_PACKAGE.md b/deploy/OFFLINE_PACKAGE.md index 073e67f..afcd4b7 100644 --- a/deploy/OFFLINE_PACKAGE.md +++ b/deploy/OFFLINE_PACKAGE.md @@ -40,3 +40,15 @@ Default ports are NAS-friendly: - MinIO console: `19001` Edit `.env` before rerunning `bash deploy.sh` if you need real API keys, stronger secrets, or different ports. + +For multi-container / multi-Pod login password encryption, put the same RSA private key in every replica's config override: + +- tenant API: copy `config.local.yaml.example` to `config.local.yaml`, then fill `auth.password_cipher.private_key_pem` +- ops API: copy `ops-config.local.yaml.example` to `ops-config.local.yaml`, then fill `auth.password_cipher.private_key_pem` + +Generate keys with: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ops-login-password-rsa.pem +``` diff --git a/deploy/config.local.yaml.example b/deploy/config.local.yaml.example new file mode 100644 index 0000000..87722e6 --- /dev/null +++ b/deploy/config.local.yaml.example @@ -0,0 +1,11 @@ +auth: + password_cipher: + enabled: true + key_id: login-password-rsa-oaep-v1 + # 多 Pod / 多容器部署必须使用同一把私钥。复制本文件为 config.local.yaml, + # 再粘贴完整 PEM;不要提交生产私钥。留空时应用会生成单进程临时私钥, + # 只适合本机单副本试跑,不适合多副本生产。 + # + # 生成示例: + # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem + private_key_pem: "" diff --git a/deploy/config.yaml b/deploy/config.yaml index 8d49efe..1b54b1f 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -169,6 +169,13 @@ jwt: access_ttl: 15m refresh_ttl: 720h +auth: + password_cipher: + # 生产多实例请在 config.local.yaml 中配置同一把 private_key_pem。 + enabled: true + key_id: login-password-rsa-oaep-v1 + private_key_pem: "" + log: level: info,warn,error format: json diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index e9a0a23..024911b 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -22,6 +22,7 @@ x-app-env: &app-env x-app-volumes: &app-volumes - ./config.yaml:/app/configs/config.yaml:ro + - ./config.local.yaml:/app/configs/config.local.yaml:ro - ./prompts.yml:/app/configs/prompts.yml:ro x-app-depends: &app-depends @@ -226,6 +227,7 @@ services: CONFIG_PATH: /app/configs/config.yaml volumes: - ./config.yaml:/app/configs/config.yaml:ro + - ./config.local.yaml:/app/configs/config.local.yaml:ro - ./prompts.yml:/app/configs/prompts.yml:ro entrypoint: > /bin/sh -c " @@ -355,6 +357,7 @@ services: OPS_DEFAULT_ADMIN_EMAIL: ${OPS_DEFAULT_ADMIN_EMAIL:-} volumes: - ./ops-config.yaml:/app/configs/ops-config.yaml:ro + - ./ops-config.local.yaml:/app/configs/ops-config.local.yaml:ro depends_on: pgbouncer: condition: service_healthy diff --git a/deploy/k3s/README.md b/deploy/k3s/README.md index cdf7b2a..783685c 100644 --- a/deploy/k3s/README.md +++ b/deploy/k3s/README.md @@ -36,11 +36,22 @@ sudo k3s ctr images import images.tar - `SCHEDULER_INTERNAL_METRICS_TOKEN` - `BROWSER_FETCH_TOKEN` - `POSTGRES_PASSWORD` / `RABBITMQ_DEFAULT_PASS` / `MINIO_ROOT_*` +- `config.local.yaml` 里的 `auth.password_cipher.private_key_pem` +- `ops-config.local.yaml` 里的 `auth.password_cipher.private_key_pem` - `config/config.yaml` 里的 LLM / SiliconFlow API key - `config.local.yaml` 里对应的数据库、RabbitMQ、MinIO、JWT 配置 注意:应用运行时通过 `pgbouncer` / `monitoring-pgbouncer` 访问数据库;`migrate` 任务仍然直连 `postgres` / `monitoring-postgres`。数据库密码统一来自 `POSTGRES_PASSWORD` secret,应用配置和 PgBouncer 都会跟随这个 secret。 +登录密码加密使用 RSA-OAEP-SHA256。多 Pod 部署时,同一类服务的所有副本必须读取同一把私钥和同一个 `key_id`,否则某个 Pod 下发的公钥对应密文可能被另一个 Pod 解不开。建议租户端和 ops 端分别生成不同私钥: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ops-login-password-rsa.pem +``` + +然后把完整 PEM 分别粘贴到 `deploy/k3s/secrets.yaml` 的 `config.local.yaml` 和 `ops-config.local.yaml`。 + ## 3. 部署 ```bash diff --git a/deploy/k3s/apps.yaml b/deploy/k3s/apps.yaml index 87c10ec..8190af5 100644 --- a/deploy/k3s/apps.yaml +++ b/deploy/k3s/apps.yaml @@ -556,6 +556,11 @@ spec: items: - key: ops-config.yaml path: ops-config.yaml + - secret: + name: geo-rankly-app-secret + items: + - key: ops-config.local.yaml + path: ops-config.local.yaml --- apiVersion: v1 kind: Service diff --git a/deploy/k3s/config/config.yaml b/deploy/k3s/config/config.yaml index 5347472..0596842 100644 --- a/deploy/k3s/config/config.yaml +++ b/deploy/k3s/config/config.yaml @@ -1,6 +1,11 @@ server: port: 8080 mode: release + # 生产环境请配置为真实前端域名,例如 https://app.example.com。 + # 留空时沿用开发环境 CORS 放开策略。 + allowed_origins: [] + security_headers: + enabled: true trusted_proxies: - 127.0.0.1 - ::1 @@ -30,6 +35,47 @@ monitoring_database: rabbitmq: url: amqp://geo:geo_dev@rabbitmq:5672/geo + monitor_result_exchange: monitor.result + monitor_result_routing_key: monitor.result.ingest + monitor_result_queue: monitor.result.ingest + monitor_result_dlx: monitor.result.dlx + monitor_result_dlq: monitor.result.ingest.dlq + monitor_result_dlq_routing_key: monitor.result.ingest.dlq + monitor_projection_exchange: monitor.projection + monitor_projection_routing_key: monitor.projection.rebuild + monitor_projection_queue: monitor.projection.rebuild + monitor_projection_dlx: monitor.projection.dlx + monitor_projection_dlq: monitor.projection.rebuild.dlq + monitor_projection_dlq_routing_key: monitor.projection.rebuild.dlq + generation_exchange: generation.task + generation_routing_key: generation.task.run + generation_queue: generation.task.run + generation_dlx: generation.task.dlx + generation_dlq: generation.task.run.dlq + generation_dlq_routing_key: generation.task.run.dlq + generation_event_exchange: generation.event + desktop_task_event_exchange: desktop.task.event + desktop_dispatch_exchange: desktop.task.dispatch + desktop_dispatch_publish_prefix: publish + desktop_dispatch_monitor_prefix: monitor + desktop_account_health_exchange: desktop.account.health + desktop_account_health_routing_key: desktop.account.health.report + desktop_account_health_queue: desktop.account.health.report + desktop_account_health_dlx: desktop.account.health.dlx + desktop_account_health_dlq: desktop.account.health.report.dlq + desktop_account_health_dlq_routing_key: desktop.account.health.report.dlq + template_assist_exchange: template.assist + template_assist_routing_key: template.assist.run + template_assist_queue: template.assist.run + template_assist_dlx: template.assist.dlx + template_assist_dlq: template.assist.run.dlq + template_assist_dlq_routing_key: template.assist.run.dlq + kol_assist_exchange: kol.assist + kol_assist_routing_key: kol.assist.run + kol_assist_queue: kol.assist.run + kol_assist_dlx: kol.assist.dlx + kol_assist_dlq: kol.assist.run.dlq + kol_assist_dlq_routing_key: kol.assist.run.dlq compliance_review_exchange: compliance.review compliance_review_routing_key: compliance.review.run compliance_review_queue: compliance.review.run @@ -53,6 +99,9 @@ monitoring_workers: result_ingest_concurrency: 4 projection_rebuild_concurrency: 2 +monitoring_dispatch: + desktop_tasks_rollout_percentage: 5 + membership: default_plan_code: free plans: @@ -96,7 +145,6 @@ brand_library: max_keywords: 5 max_questions_per_keyword: 5 - redis: addr: redis:6379 db: 0 @@ -126,6 +174,16 @@ jwt: access_ttl: 15m refresh_ttl: 720h +auth: + login_guard: + enabled: true + password_cipher: + # 留空 private_key_pem 时会在单进程内生成临时密钥;生产多 Pod 请在 + # geo-rankly-app-secret/config.local.yaml 中配置同一把私钥。 + enabled: true + key_id: login-password-rsa-oaep-v1 + private_key_pem: "" + log: level: info,warn,error format: json @@ -182,14 +240,16 @@ browser_fetch: enabled: true provider: lightpanda service_url: http://browser-fetch:8082 + token: dev-browser-fetch-token queue_size: 64 - worker_concurrency: 2 + worker_concurrency: 3 request_timeout: 45s cache_ttl: 6h allowed_domains: - zhuanlan.zhihu.com - zhihu.com - baidu.com + - weixin.sogou.com trigger_status: - 403 - 429 diff --git a/deploy/k3s/config/ops-config.yaml b/deploy/k3s/config/ops-config.yaml index 009f8ed..768639e 100644 --- a/deploy/k3s/config/ops-config.yaml +++ b/deploy/k3s/config/ops-config.yaml @@ -1,6 +1,11 @@ server: port: 8090 mode: release + # 生产环境请配置为真实运维后台域名,例如 https://ops.example.com。 + # 留空时沿用开发环境 CORS 放开策略。 + allowed_origins: [] + security_headers: + enabled: true trusted_proxies: - 127.0.0.1 - ::1 @@ -43,6 +48,14 @@ jwt: secret: "geo-ops-15645415841" access_ttl: 8h +auth: + password_cipher: + # 留空 private_key_pem 时会在单进程内生成临时密钥;生产多 Pod 请在 + # geo-rankly-app-secret/ops-config.local.yaml 中配置同一把私钥。 + enabled: true + key_id: ops-password-rsa-oaep-v1 + private_key_pem: "" + default_admin: username: admin display_name: system-admin diff --git a/deploy/k3s/secrets.yaml b/deploy/k3s/secrets.yaml index a65df7f..2ca2f50 100644 --- a/deploy/k3s/secrets.yaml +++ b/deploy/k3s/secrets.yaml @@ -35,3 +35,25 @@ stringData: browser_fetch: token: ${BROWSER_FETCH_TOKEN:geo-browser-fetch-change-me} + + auth: + password_cipher: + enabled: true + key_id: login-password-rsa-oaep-v1 + # 多 Pod 必须使用同一把私钥;上线前用 `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048` + # 生成后粘贴完整 PEM。不要把生产私钥提交到代码仓库。 + private_key_pem: | + -----BEGIN PRIVATE KEY----- + REPLACE_WITH_TENANT_LOGIN_PASSWORD_RSA_PRIVATE_KEY + -----END PRIVATE KEY----- + + ops-config.local.yaml: | + auth: + password_cipher: + enabled: true + key_id: ops-password-rsa-oaep-v1 + # 建议与租户端使用不同私钥;所有 ops-api Pod 必须挂载同一份 Secret。 + private_key_pem: | + -----BEGIN PRIVATE KEY----- + REPLACE_WITH_OPS_LOGIN_PASSWORD_RSA_PRIVATE_KEY + -----END PRIVATE KEY----- diff --git a/deploy/ops-config.local.yaml.example b/deploy/ops-config.local.yaml.example new file mode 100644 index 0000000..9dab987 --- /dev/null +++ b/deploy/ops-config.local.yaml.example @@ -0,0 +1,8 @@ +auth: + password_cipher: + enabled: true + key_id: ops-password-rsa-oaep-v1 + # 建议与租户端使用不同私钥。复制本文件为 ops-config.local.yaml, + # 所有 ops-api 副本必须读取同一份私钥;不要提交生产私钥。 + # 留空时应用会生成单进程临时私钥,只适合本机单副本试跑。 + private_key_pem: "" diff --git a/deploy/ops-config.yaml b/deploy/ops-config.yaml index 9a9ce24..ef37b03 100644 --- a/deploy/ops-config.yaml +++ b/deploy/ops-config.yaml @@ -43,6 +43,13 @@ jwt: secret: "geo-ops-15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供 access_ttl: 8h +auth: + password_cipher: + # 生产多实例请在 ops-config.local.yaml 中配置同一把 private_key_pem。 + enabled: true + key_id: ops-password-rsa-oaep-v1 + private_key_pem: "" + default_admin: username: admin display_name: 系统管理员 diff --git a/deploy/scripts/load-and-start.sh b/deploy/scripts/load-and-start.sh index c021848..32ef6d7 100755 --- a/deploy/scripts/load-and-start.sh +++ b/deploy/scripts/load-and-start.sh @@ -25,6 +25,18 @@ if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then echo " 如需填写真实 API Key 或修改端口,可编辑 ${SCRIPT_DIR}/.env 后再次执行 bash deploy.sh" fi +ensure_local_config() { + local file="$1" example="$2" + if [[ -f "${SCRIPT_DIR}/${file}" ]]; then + return + fi + if [[ -f "${SCRIPT_DIR}/${example}" ]]; then + cp "${SCRIPT_DIR}/${example}" "${SCRIPT_DIR}/${file}" + echo "==> 已从 ${example} 创建 ${file}。" + echo " 生产多副本部署前请把其中的 password_cipher.private_key_pem 替换为同一把 RSA 私钥。" + fi +} + env_value() { local key="$1" default_value="$2" value value="$(grep -E "^${key}=" "${SCRIPT_DIR}/.env" | tail -1 | cut -d= -f2- || true)" @@ -34,6 +46,9 @@ env_value() { } deploy_compose() { + ensure_local_config "config.local.yaml" "config.local.yaml.example" + ensure_local_config "ops-config.local.yaml" "ops-config.local.yaml.example" + # ─── Step 1: 加载镜像 ─────────────────────────────────────────────────────── echo "==> [1/3] 加载 Docker 镜像(可能需要几分钟)..." docker load -i "${SCRIPT_DIR}/images.tar" diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index bfb3582..ed51b2f 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -268,10 +268,12 @@ func RestartRequired(changes []FieldChange) bool { } func load(path string) (*Config, error) { - resolved := configruntime.New(configruntime.WithSource( - runtimefile.NewSource(path), - runtimeenv.NewSource("OPS"), - )) + sources := []configruntime.Source{runtimefile.NewSource(path)} + if localPath := localOverridePath(path); localPath != "" { + sources = append(sources, runtimefile.NewSource(localPath)) + } + sources = append(sources, runtimeenv.NewSource("OPS")) + resolved := configruntime.New(configruntime.WithSource(sources...)) defer resolved.Close() if err := resolved.Load(); err != nil { @@ -571,12 +573,37 @@ func watchFiles(path string) []string { if strings.TrimSpace(path) == "" { return nil } - return []string{path} + files := []string{path} + if localPath := localOverridePath(path); localPath != "" { + files = append(files, localPath) + } + return files } func watchNames(path string) []string { if strings.TrimSpace(path) == "" { return nil } - return []string{filepath.Base(path)} + names := []string{filepath.Base(path)} + if localPath := localOverridePath(path); localPath != "" { + names = append(names, filepath.Base(localPath)) + } + return names +} + +func localOverridePath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + ext := filepath.Ext(trimmed) + base := strings.TrimSuffix(trimmed, ext) + if strings.HasSuffix(base, ".local") { + return "" + } + localPath := base + ".local" + ext + if _, err := os.Stat(localPath); err != nil { + return "" + } + return localPath } diff --git a/server/internal/shared/auth/password_cipher.go b/server/internal/shared/auth/password_cipher.go index 46665fa..27ee4c7 100644 --- a/server/internal/shared/auth/password_cipher.go +++ b/server/internal/shared/auth/password_cipher.go @@ -96,7 +96,7 @@ func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) { } func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) { - trimmed := strings.TrimSpace(value) + trimmed := normalizePrivateKeyPEM(value) if trimmed == "" { return nil, fmt.Errorf("password cipher private key is required") } @@ -126,3 +126,48 @@ func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) { return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type) } } + +func normalizePrivateKeyPEM(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + normalized := strings.ReplaceAll(trimmed, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + normalized = strings.ReplaceAll(normalized, `\n`, "\n") + if block, _ := pem.Decode([]byte(normalized)); block != nil { + return normalized + } + + for _, blockType := range []string{"RSA PRIVATE KEY", "PRIVATE KEY"} { + begin := "-----BEGIN " + blockType + "-----" + end := "-----END " + blockType + "-----" + start := strings.Index(normalized, begin) + stop := strings.Index(normalized, end) + if start < 0 || stop <= start { + continue + } + body := normalized[start+len(begin) : stop] + body = strings.Join(strings.Fields(body), "") + if body == "" { + return normalized + } + return begin + "\n" + wrapBase64Lines(body, 64) + "\n" + end + "\n" + } + return normalized +} + +func wrapBase64Lines(value string, width int) string { + if width <= 0 || len(value) <= width { + return value + } + var b strings.Builder + b.Grow(len(value) + len(value)/width) + for len(value) > width { + b.WriteString(value[:width]) + b.WriteByte('\n') + value = value[width:] + } + b.WriteString(value) + return b.String() +} diff --git a/server/internal/shared/auth/password_cipher_test.go b/server/internal/shared/auth/password_cipher_test.go index 3675da2..9e15c2f 100644 --- a/server/internal/shared/auth/password_cipher_test.go +++ b/server/internal/shared/auth/password_cipher_test.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "strings" "testing" "github.com/stretchr/testify/require" @@ -62,3 +63,53 @@ func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) { require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm) require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY") } + +func TestPasswordCipherAcceptsFoldedPrivateKeyPEM(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + privatePEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: mustMarshalPKCS8PrivateKey(t, key), + })) + foldedPEM := strings.Join(strings.Fields(privatePEM), " ") + + _, err = NewPasswordCipher(foldedPEM, "folded") + require.NoError(t, err) +} + +func TestPasswordCipherAcceptsEscapedNewlinePrivateKeyPEM(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + privatePEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: mustMarshalPKCS8PrivateKey(t, key), + })) + escapedPEM := strings.ReplaceAll(strings.TrimSpace(privatePEM), "\n", `\n`) + + _, err = NewPasswordCipher(escapedPEM, "escaped") + require.NoError(t, err) +} + +func TestNormalizePrivateKeyPEMPreservesValidPEM(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + privatePEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: mustMarshalPKCS8PrivateKey(t, key), + })) + + require.Equal(t, strings.TrimSpace(privatePEM), strings.TrimSpace(normalizePrivateKeyPEM(privatePEM))) +} + +func mustMarshalPKCS8PrivateKey(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + return der +}