diff --git a/Cargo.lock b/Cargo.lock index be9d9a6..86ffd54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "askama", diff --git a/README.md b/README.md index d1fa020..5a65ba9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ cargo build --release -p secrets-mcp | 变量 | 说明 | |------|------| -| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 | +| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP)。 | +| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 | +| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 | +| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer`、`disable`、`allow`、`require`)。 | | `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 | @@ -27,9 +30,26 @@ cargo build --release -p secrets-mcp cargo run -p secrets-mcp ``` +生产推荐示例(PostgreSQL TLS): + +```bash +SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp +SECRETS_DATABASE_SSL_MODE=verify-full +SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt +SECRETS_ENV=production +``` + - **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。 - **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer ` + `X-Encryption-Key: ` 请求头(读密文工具须带密钥)。 +## PostgreSQL TLS 加固 + +- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`。 +- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA),并保证证书 `SAN` 包含 `db.refining.ltd`。 +- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。 +- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`。 +- 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md)。 + ## MCP 与 AI 工作流(v0.3+) 条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun` 与 `ricnsmart/aliyun`)。 diff --git a/crates/secrets-core/src/config.rs b/crates/secrets-core/src/config.rs index 966a81e..8cb29b5 100644 --- a/crates/secrets-core/src/config.rs +++ b/crates/secrets-core/src/config.rs @@ -1,4 +1,15 @@ -use anyhow::Result; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use sqlx::postgres::PgSslMode; + +#[derive(Debug, Clone)] +pub struct DatabaseConfig { + pub url: String, + pub ssl_mode: Option, + pub ssl_root_cert: Option, + pub enforce_strict_tls: bool, +} /// Resolve database URL from environment. /// Priority: `SECRETS_DATABASE_URL` env var → error. @@ -18,3 +29,54 @@ pub fn resolve_db_url(override_url: &str) -> Result { Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname" ) } + +fn env_var_non_empty(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn parse_ssl_mode_from_env() -> Result> { + let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else { + return Ok(None); + }; + + let parsed = mode.parse::().with_context(|| { + format!( + "Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full." + ) + })?; + Ok(Some(parsed)) +} + +fn resolve_ssl_root_cert_from_env() -> Result> { + let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else { + return Ok(None); + }; + let path = PathBuf::from(path); + if !path.exists() { + anyhow::bail!( + "SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}", + path.display() + ); + } + Ok(Some(path)) +} + +fn is_production_env() -> bool { + matches!( + env_var_non_empty("SECRETS_ENV") + .as_deref() + .map(|value| value.to_ascii_lowercase()), + Some(value) if value == "prod" || value == "production" + ) +} + +pub fn resolve_db_config(override_url: &str) -> Result { + Ok(DatabaseConfig { + url: resolve_db_url(override_url)?, + ssl_mode: parse_ssl_mode_from_env()?, + ssl_root_cert: resolve_ssl_root_cert_from_env()?, + enforce_strict_tls: is_production_env(), + }) +} diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 0e76be8..5c150b9 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -1,14 +1,45 @@ -use anyhow::Result; +use std::str::FromStr; + +use anyhow::{Context, Result}; use serde_json::Value; use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; -pub async fn create_pool(database_url: &str) -> Result { +use crate::config::DatabaseConfig; + +fn build_connect_options(config: &DatabaseConfig) -> Result { + let mut options = PgConnectOptions::from_str(&config.url) + .with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?; + + if let Some(mode) = config.ssl_mode { + options = options.ssl_mode(mode); + } + if let Some(path) = &config.ssl_root_cert { + options = options.ssl_root_cert(path); + } + + if config.enforce_strict_tls + && !matches!( + options.get_ssl_mode(), + PgSslMode::VerifyCa | PgSslMode::VerifyFull + ) + { + anyhow::bail!( + "Refusing to start in production with weak PostgreSQL TLS mode. \ + Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full." + ); + } + + Ok(options) +} + +pub async fn create_pool(config: &DatabaseConfig) -> Result { tracing::debug!("connecting to database"); + let connect_options = build_connect_options(config)?; let pool = PgPoolOptions::new() .max_connections(10) .acquire_timeout(std::time::Duration::from_secs(5)) - .connect(database_url) + .connect_with(connect_options) .await?; tracing::debug!("database connection established"); Ok(pool) diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 1f8aeb9..10f444e 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.3.2" +version = "0.3.3" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/main.rs b/crates/secrets-mcp/src/main.rs index 9c520b2..b62486c 100644 --- a/crates/secrets-mcp/src/main.rs +++ b/crates/secrets-mcp/src/main.rs @@ -21,7 +21,7 @@ use tower_sessions_sqlx_store_chrono::PostgresStore; use tracing_subscriber::EnvFilter; use tracing_subscriber::fmt::time::FormatTime; -use secrets_core::config::resolve_db_url; +use secrets_core::config::resolve_db_config; use secrets_core::db::{create_pool, migrate}; use crate::oauth::OAuthConfig; @@ -78,9 +78,9 @@ async fn main() -> Result<()> { .init(); // ── Database ────────────────────────────────────────────────────────────── - let db_url = resolve_db_url("") + let db_config = resolve_db_config("") .context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?; - let pool = create_pool(&db_url) + let pool = create_pool(&db_config) .await .context("failed to connect to database")?; migrate(&pool) diff --git a/deploy/.env.example b/deploy/.env.example index 9cfee02..d818151 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -3,7 +3,13 @@ # ─── 数据库 ─────────────────────────────────────────────────────────── # Web 会话(tower-sessions)与业务数据共用此库;启动时会自动 migrate 会话表,无需额外环境变量。 -SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp +SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-mcp +# 强烈建议生产使用 verify-full(至少 verify-ca) +SECRETS_DATABASE_SSL_MODE=verify-full +# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空 +# SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt +# 当设为 prod/production 时,服务会拒绝弱 TLS 模式(prefer/disable/allow/require) +SECRETS_ENV=production # ─── 服务地址 ───────────────────────────────────────────────────────── # 内网监听地址(Cloudflare / Nginx 反代时填内网端口) diff --git a/deploy/postgres-tls-hardening.md b/deploy/postgres-tls-hardening.md new file mode 100644 index 0000000..9f16cd4 --- /dev/null +++ b/deploy/postgres-tls-hardening.md @@ -0,0 +1,92 @@ +# PostgreSQL TLS Hardening Runbook + +This runbook applies to: + +- PostgreSQL server: `47.117.131.22` (`db.refining.ltd`) +- `secrets-mcp` app server: `47.238.146.244` (`secrets.refining.app`) + +## 1) Issue certificate for `db.refining.ltd` (Let's Encrypt + Cloudflare DNS-01) + +Install `acme.sh` on the PostgreSQL server and use a Cloudflare API token with DNS edit permission for the target zone. + +```bash +curl https://get.acme.sh | sh -s email=ops@refining.ltd +export CF_Token="your_cloudflare_dns_token" +export CF_Zone_ID="your_zone_id" +~/.acme.sh/acme.sh --issue --dns dns_cf -d db.refining.ltd --keylength ec-256 +``` + +Install cert/key into a PostgreSQL-readable path: + +```bash +sudo mkdir -p /etc/postgresql/tls +sudo ~/.acme.sh/acme.sh --install-cert -d db.refining.ltd --ecc \ + --fullchain-file /etc/postgresql/tls/fullchain.pem \ + --key-file /etc/postgresql/tls/privkey.pem \ + --reloadcmd "systemctl reload postgresql || systemctl restart postgresql" +sudo chown -R postgres:postgres /etc/postgresql/tls +sudo chmod 600 /etc/postgresql/tls/privkey.pem +sudo chmod 644 /etc/postgresql/tls/fullchain.pem +``` + +## 2) Configure PostgreSQL TLS and access rules + +In `postgresql.conf`: + +```conf +ssl = on +ssl_cert_file = '/etc/postgresql/tls/fullchain.pem' +ssl_key_file = '/etc/postgresql/tls/privkey.pem' +``` + +In `pg_hba.conf`, allow app traffic via TLS only (example): + +```conf +hostssl secrets-mcp postgres 47.238.146.244/32 scram-sha-256 +``` + +Keep a safe admin path (`local` socket or restricted source CIDR) before removing old plaintext `host` rules. + +Reload PostgreSQL: + +```bash +sudo systemctl reload postgresql +``` + +## 3) Verify server-side TLS + +```bash +openssl s_client -starttls postgres -connect db.refining.ltd:5432 -servername db.refining.ltd +``` + +The handshake should succeed and the certificate should match `db.refining.ltd`. + +## 4) Update `secrets-mcp` app server env + +Use environment values like: + +```bash +SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp +SECRETS_DATABASE_SSL_MODE=verify-full +SECRETS_ENV=production +``` + +If you use private CA instead of public CA, also set: + +```bash +SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt +``` + +Restart `secrets-mcp` after updating env. + +## 5) Verify from app server + +Run positive and negative checks: + +- Positive: app starts, migrations pass, dashboard + MCP API work. +- Negative: + - wrong hostname -> connection fails + - wrong CA file -> connection fails + - disable TLS on DB -> connection fails + +This ensures no silent downgrade to weak TLS in production.