From b06e330a445830028d779c50283ed76489cc2937 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Wed, 23 Sep 2026 23:53:00 +0800 Subject: [PATCH] feat(security): add demo mode lockdown for public deployments DBX_DEMO_MODE=1 returns 403 for plugin/JDBC/agent installs, connection probes and edits, AI provider config, cloud sync and credential endpoints; /connection/connect only accepts pre-seeded connections with matching endpoint identity. Ships a deploy/demo compose recipe with seeded databases and scheduled reset. --- crates/dbx-web/src/demo.rs | 376 ++++++++++++++++++++++++ crates/dbx-web/src/error.rs | 4 + crates/dbx-web/src/main.rs | 8 + crates/dbx-web/src/routes/connection.rs | 5 + crates/dbx-web/src/routes/update.rs | 9 +- crates/dbx-web/src/state.rs | 3 + deploy/demo/Dockerfile | 12 + deploy/demo/README.md | 58 ++++ deploy/demo/docker-compose.yml | 71 +++++ deploy/demo/reset.sh | 9 + deploy/demo/seed/connections.json | 36 +++ deploy/demo/seed/entrypoint.sh | 38 +++ deploy/demo/seed/sql/mysql/init.sql | 82 ++++++ deploy/demo/seed/sql/postgres/init.sql | 85 ++++++ 14 files changed, 793 insertions(+), 3 deletions(-) create mode 100644 crates/dbx-web/src/demo.rs create mode 100644 deploy/demo/Dockerfile create mode 100644 deploy/demo/README.md create mode 100644 deploy/demo/docker-compose.yml create mode 100755 deploy/demo/reset.sh create mode 100644 deploy/demo/seed/connections.json create mode 100755 deploy/demo/seed/entrypoint.sh create mode 100644 deploy/demo/seed/sql/mysql/init.sql create mode 100644 deploy/demo/seed/sql/postgres/init.sql diff --git a/crates/dbx-web/src/demo.rs b/crates/dbx-web/src/demo.rs new file mode 100644 index 0000000000..5bd687dc9c --- /dev/null +++ b/crates/dbx-web/src/demo.rs @@ -0,0 +1,376 @@ +//! `DBX_DEMO_MODE` 守卫:面向公网演示部署封锁会改变服务器状态、或让服务器向 +//! 任意端点发起连接的入口。默认关闭,行为与常规部署完全一致。 +//! +//! 封锁面分两类: +//! 1. `demo_mode_gate` 中间件按路径拦截:凭据改写(auth/setup、cloud-sync、AI +//! 供应商配置)、插件/JDBC/驱动安装(sidecar 原生进程 = 远程代码执行)、纯 +//! 探测端点(connection/test*、mq/nacos test-connection、tunnel-profiles/test +//! —— body 携带完整连接配置,等于 SSRF)。 +//! 2. `connection/connect` 不整体封锁(演示访客必须能连上预置连接),改为 +//! `ensure_demo_connect_allowed`:请求体里的连接 id 必须已保存,且端点身份 +//! 与保存值一致,防止伪造 body 把服务器拨向任意主机。 + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{Method, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use dbx_core::connection::{connection_configs_session_credentials_compatible, AppState}; +use dbx_core::models::connection::ConnectionConfig; + +use crate::auth::middleware_api_path_suffix; +use crate::state::WebState; + +pub(crate) const DEMO_CONNECT_REJECTED: &str = "Demo mode only allows connecting to the pre-configured connections"; + +pub fn demo_mode_from_env() -> bool { + demo_mode_from_env_value(std::env::var("DBX_DEMO_MODE").ok().as_deref()) +} + +pub fn demo_mode_from_env_value(value: Option<&str>) -> bool { + value.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on")).unwrap_or(false) +} + +/// 无论方法一律拦截的路径(对应路由本身只注册了这一种方法)。 +const BLOCKED_EXACT: &[&str] = &[ + // 访客不得抢先设置或改写演示实例口令 + "auth/setup", + "auth/change-password", + // 纯探测端点:body 携带完整连接配置,服务器会拨向任意主机 + "connection/test", + "connection/test-info", + "connection/test-ssh-tunnel", + "mq/test-connection", + "nacos/test-connection", + "tunnel-profiles/test", + // 演示连接清单只读:整表覆盖会互删,新增等于 SSRF + "connection/save", + "connection/mcp/add", + "connection/mcp/duplicate", + "connection/mcp/remove", + "tunnel-profiles/save", + // 已存连接凭据解密出口 + "app-settings/config/decrypt", + // 读取服务器本地 ~/.ssh/config + "ssh/config-hosts", +]; + +/// 非 GET 请求整族拦截的前缀:插件/JDBC/驱动安装与运行时控制、云同步外发通道。 +/// GET 保持放行(商店浏览、已装列表、安装进度)。 +const BLOCKED_MUTATING_PREFIXES: &[&str] = &["plugins/", "jdbc/", "agents/", "cloud-sync/"]; + +/// AI 供应商配置写入与按配置外呼的端点(无配置时本就不可用,一并封锁防止访客 +/// 自带 key 配置后把服务器当外呼跳板)。会话、取消与只读 GET 不受影响。 +const BLOCKED_AI_EXACT: &[&str] = &[ + "ai/config", + "ai/provider-config", + "ai/configs", + "ai/default-config", + "ai/config-item", + "ai/chat-selection", + "ai/complete", + "ai/stream", + "ai/agent-stream", + "ai/test-connection", + "ai/models", + "ai/model-effort", +]; + +pub(crate) fn is_demo_blocked(method: &Method, suffix: &str) -> bool { + if BLOCKED_EXACT.contains(&suffix) { + return true; + } + if method != Method::GET { + if BLOCKED_MUTATING_PREFIXES.iter().any(|prefix| suffix.starts_with(prefix)) { + return true; + } + // DELETE /ai/config/{config_id} + if suffix.starts_with("ai/config/") { + return true; + } + } + method != Method::GET && BLOCKED_AI_EXACT.contains(&suffix) +} + +pub async fn demo_mode_gate( + State(state): State>, + request: Request, + next: Next, +) -> Response { + if state.demo_mode { + let suffix = middleware_api_path_suffix(request.uri().path(), &state.public_base_path); + if suffix.is_some_and(|suffix| is_demo_blocked(request.method(), suffix)) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "code": "DEMO_MODE_DISABLED", + "message": "This operation is disabled in demo mode" + })), + ) + .into_response(); + } + } + next.run(request).await +} + +/// 演示模式下 `/connection/connect` 的准入:连接必须已保存,且请求体的端点身份 +/// 与保存值一致(忽略密码与当前选库)。凭据解析仍走原路径,存储里的预置连接 +/// 密码照常生效。 +pub(crate) async fn ensure_demo_connect_allowed(app: &AppState, config: &ConnectionConfig) -> Result<(), String> { + let stored = app + .storage + .load_connections() + .await + .map_err(|error| format!("Failed to load connections: {error}"))? + .into_iter() + .find(|stored| stored.id == config.id); + match stored { + Some(stored) if demo_connect_endpoint_matches(&stored, config) => Ok(()), + _ => Err(DEMO_CONNECT_REJECTED.to_string()), + } +} + +fn demo_connect_endpoint_matches(stored: &ConnectionConfig, requested: &ConnectionConfig) -> bool { + let mut stored = stored.clone(); + let mut requested = requested.clone(); + // 密码以存储为准、选库是会话级选择,两者不参与端点身份比较。 + stored.save_password = false; + requested.save_password = false; + stored.password.clear(); + requested.password.clear(); + stored.database = None; + requested.database = None; + connection_configs_session_credentials_compatible(&stored, &requested) +} + +#[cfg(test)] +mod tests { + use super::{ + demo_connect_endpoint_matches, demo_mode_from_env_value, ensure_demo_connect_allowed, is_demo_blocked, + }; + use axum::http::Method; + use dbx_core::models::connection::ConnectionConfig; + + fn config(json: serde_json::Value) -> ConnectionConfig { + serde_json::from_value(json).unwrap() + } + + fn stored_config() -> ConnectionConfig { + config(serde_json::json!({ + "id": "demo-pg", + "name": "Demo PostgreSQL", + "db_type": "postgres", + "host": "demo-postgres", + "port": 5432, + "username": "demo", + "password": "stored-secret", + "database": "dvdrental", + "save_password": true + })) + } + + #[test] + fn demo_env_flag_accepts_common_truthy_spellings_only() { + for value in ["1", "true", "TRUE", " Yes ", "on"] { + assert!(demo_mode_from_env_value(Some(value)), "expected truthy: {value}"); + } + for value in [None, Some(""), Some("0"), Some("false"), Some("off"), Some("demo")] { + assert!(!demo_mode_from_env_value(value), "expected falsy: {value:?}"); + } + } + + #[test] + fn demo_gate_blocks_credential_plugin_and_probe_routes() { + let blocked = [ + (Method::POST, "auth/setup"), + (Method::POST, "auth/change-password"), + (Method::POST, "connection/test"), + (Method::POST, "connection/test-info"), + (Method::POST, "connection/test-ssh-tunnel"), + (Method::POST, "connection/save"), + (Method::POST, "connection/mcp/add"), + (Method::POST, "mq/test-connection"), + (Method::POST, "nacos/test-connection"), + (Method::POST, "tunnel-profiles/test"), + (Method::POST, "tunnel-profiles/save"), + (Method::POST, "app-settings/config/decrypt"), + (Method::GET, "ssh/config-hosts"), + (Method::POST, "plugins/install"), + (Method::POST, "plugins/marketplace/install"), + (Method::POST, "plugins/uninstall"), + (Method::POST, "plugins/trusted-keys/save"), + (Method::POST, "plugins/filesystem/write"), + (Method::POST, "jdbc/plugin/install"), + (Method::POST, "jdbc/plugin/install-local"), + (Method::DELETE, "jdbc/drivers/maven/bundle-1"), + (Method::POST, "agents/install"), + (Method::POST, "agents/import-offline"), + (Method::POST, "agents/runtime/stop"), + (Method::POST, "cloud-sync/webdav/upload"), + (Method::POST, "cloud-sync/snippet/save-token"), + (Method::POST, "ai/config"), + (Method::POST, "ai/provider-config"), + (Method::POST, "ai/stream"), + (Method::POST, "ai/test-connection"), + (Method::DELETE, "ai/config/cfg-1"), + ]; + for (method, suffix) in blocked { + assert!(is_demo_blocked(&method, suffix), "expected blocked: {method} {suffix}"); + } + } + + #[test] + fn demo_gate_keeps_readonly_and_demo_core_routes_open() { + let allowed = [ + (Method::GET, "connection/list"), + (Method::POST, "connection/connect"), + (Method::POST, "connection/disconnect"), + (Method::POST, "query/execute"), + (Method::POST, "query/cancel"), + (Method::GET, "plugins"), + (Method::GET, "plugins/marketplace/catalogs"), + (Method::GET, "agents/installed"), + (Method::GET, "agents/progress/op-1"), + (Method::POST, "history/save"), + (Method::DELETE, "history/entry-1"), + (Method::POST, "saved-sql"), + (Method::POST, "ai/conversation"), + (Method::POST, "ai/cancel-stream"), + (Method::GET, "ai/config"), + (Method::POST, "app-settings/mcp-policy"), + (Method::POST, "plugin/table-metadata"), + (Method::POST, "migration/start"), + (Method::POST, "database-backups"), + (Method::POST, "schema/cache"), + (Method::DELETE, "schema/cache-prefix"), + (Method::POST, "redis/set-string"), + (Method::POST, "mongo/find-documents"), + (Method::POST, "consul/txn"), + (Method::POST, "export/table"), + (Method::POST, "import/preview"), + ]; + for (method, suffix) in allowed { + assert!(!is_demo_blocked(&method, suffix), "expected allowed: {method} {suffix}"); + } + } + + #[test] + fn demo_connect_endpoint_match_ignores_password_and_selected_database() { + let stored = stored_config(); + let requested = config(serde_json::json!({ + "id": "demo-pg", + "name": "Demo PostgreSQL", + "db_type": "postgres", + "host": "demo-postgres", + "port": 5432, + "username": "demo", + "password": "", + "database": "postgres", + "save_password": true + })); + assert!(demo_connect_endpoint_matches(&stored, &requested)); + } + + #[test] + fn demo_connect_endpoint_match_rejects_endpoint_and_transport_changes() { + let stored = stored_config(); + + let hijacked_host = config(serde_json::json!({ + "id": "demo-pg", + "name": "Demo PostgreSQL", + "db_type": "postgres", + "host": "internal-bastion", + "port": 5432, + "username": "demo", + "password": "" + })); + assert!(!demo_connect_endpoint_matches(&stored, &hijacked_host)); + + let mut smuggled_tunnel = stored_config(); + smuggled_tunnel.password.clear(); + smuggled_tunnel.transport_layers = vec![serde_json::from_value(serde_json::json!({ + "type": "ssh", + "id": "smuggled", + "enabled": true, + "host": "internal-bastion", + "port": 22, + "user": "root" + })) + .unwrap()]; + assert!(!demo_connect_endpoint_matches(&stored, &smuggled_tunnel)); + } + + #[tokio::test] + async fn demo_connect_guard_requires_a_pool_equivalent_stored_connection() { + let directory = tempfile::tempdir().unwrap(); + let storage = dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(); + let app = std::sync::Arc::new(dbx_core::connection::AppState::new(storage)); + + let unknown = stored_config(); + let error = ensure_demo_connect_allowed(&app, &unknown).await.unwrap_err(); + assert_eq!(error, super::DEMO_CONNECT_REJECTED); + + app.storage.save_connections(&[stored_config()]).await.unwrap(); + + let mut tampered = stored_config(); + tampered.host = "evil.example.com".to_string(); + assert_eq!(ensure_demo_connect_allowed(&app, &tampered).await.unwrap_err(), super::DEMO_CONNECT_REJECTED); + + let mut legitimate = stored_config(); + legitimate.password.clear(); + legitimate.database = Some("postgres".to_string()); + ensure_demo_connect_allowed(&app, &legitimate).await.unwrap(); + } + + #[tokio::test] + async fn demo_gate_returns_forbidden_only_when_enabled() { + use axum::routing::{get, post}; + + let directory = tempfile::tempdir().unwrap(); + let storage = dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(); + let app = std::sync::Arc::new(dbx_core::connection::AppState::new(storage)); + let state = { + let mut web_state = crate::state::WebState::for_tests(app, directory.path().to_path_buf()); + web_state.demo_mode = true; + std::sync::Arc::new(web_state) + }; + let router = axum::Router::new() + .route("/api/plugins/install", post(|| async { "install" })) + .route("/api/connection/list", get(|| async { "connections" })) + .layer(axum::middleware::from_fn_with_state(state.clone(), super::demo_mode_gate)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let client = reqwest::Client::new(); + + let blocked = client.post(format!("http://{address}/api/plugins/install")).send().await.unwrap(); + assert_eq!(blocked.status(), reqwest::StatusCode::FORBIDDEN); + let payload = blocked.json::().await.unwrap(); + assert_eq!(payload["code"], "DEMO_MODE_DISABLED"); + assert_eq!(payload["message"], "This operation is disabled in demo mode"); + + let allowed = client.get(format!("http://{address}/api/connection/list")).send().await.unwrap(); + assert_eq!(allowed.status(), reqwest::StatusCode::OK); + assert_eq!(allowed.text().await.unwrap(), "connections"); + + server.abort(); + + // 关闭开关后同一中间件直接放行。 + let app = std::sync::Arc::new(dbx_core::connection::AppState::new( + dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(), + )); + let state = std::sync::Arc::new(crate::state::WebState::for_tests(app, directory.path().to_path_buf())); + let router = axum::Router::new() + .route("/api/plugins/install", post(|| async { "install" })) + .layer(axum::middleware::from_fn_with_state(state, super::demo_mode_gate)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let response = client.post(format!("http://{address}/api/plugins/install")).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "install"); + server.abort(); + } +} diff --git a/crates/dbx-web/src/error.rs b/crates/dbx-web/src/error.rs index c173df9e8c..2e2f56bf68 100644 --- a/crates/dbx-web/src/error.rs +++ b/crates/dbx-web/src/error.rs @@ -26,6 +26,10 @@ impl AppError { Self::with_status(msg.into(), StatusCode::BAD_REQUEST) } + pub fn forbidden(msg: impl Into) -> Self { + Self::with_status(msg.into(), StatusCode::FORBIDDEN) + } + pub fn not_found(msg: impl Into) -> Self { Self::with_status(msg.into(), StatusCode::NOT_FOUND) } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index afd7815a4e..c0d82d9d80 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -1,4 +1,5 @@ mod auth; +mod demo; mod error; mod routes; mod sse; @@ -386,11 +387,14 @@ async fn serve() { let public_base_path = normalize_public_base_path(std::env::var("DBX_PUBLIC_BASE_PATH").ok()); + let demo_mode = demo::demo_mode_from_env(); + let migration_ready = storage_migration_ready(&app_state).await; let web_state = Arc::new(WebState { app: app_state, data_dir, public_base_path: public_base_path.clone(), + demo_mode, password_disabled, password_hash: RwLock::new(password_hash), sessions: RwLock::new(HashSet::new()), @@ -1247,6 +1251,7 @@ async fn serve() { let api = add_mq_routes(api) .layer(middleware::from_fn_with_state(web_state.clone(), migration_gate)) + .layer(middleware::from_fn_with_state(web_state.clone(), demo::demo_mode_gate)) .layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware)) .with_state(web_state.clone()); @@ -1278,6 +1283,9 @@ async fn serve() { } else if std::env::var("DBX_PASSWORD").is_ok() { tracing::info!("Password protection is enabled"); } + if demo_mode { + tracing::info!("Demo mode is enabled: connection/plugin/AI mutations are blocked"); + } let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind address"); let shutdown_state = web_state.app.clone(); diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index b34b276a56..bea3e93bf1 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -401,6 +401,11 @@ pub async fn connect_db( Json(body): Json, ) -> Result, AppError> { let config = body.config; + // 演示模式:只允许连接已保存的连接,端点身份以存储为准,防止伪造 body + // 配置把服务器拨向任意主机(见 demo 模块)。 + if state.demo_mode { + crate::demo::ensure_demo_connect_allowed(&state.app, &config).await.map_err(AppError::forbidden)?; + } if config.db_type == dbx_core::models::connection::DatabaseType::Sqlite { dbx_core::db::sqlite::validate_persistent_attachments( &config.host, diff --git a/crates/dbx-web/src/routes/update.rs b/crates/dbx-web/src/routes/update.rs index a267ba8b68..0b30460327 100644 --- a/crates/dbx-web/src/routes/update.rs +++ b/crates/dbx-web/src/routes/update.rs @@ -1,10 +1,13 @@ -use axum::{extract::Query, Json}; +use std::sync::Arc; + +use axum::{extract::Query, extract::State, Json}; use dbx_core::{changelog, update}; use crate::error::AppError; +use crate::state::WebState; -pub async fn get_version() -> Json { - Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") })) +pub async fn get_version(State(state): State>) -> Json { + Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "demoMode": state.demo_mode })) } #[derive(serde::Deserialize)] diff --git a/crates/dbx-web/src/state.rs b/crates/dbx-web/src/state.rs index ca9c23df85..c02a6262ff 100644 --- a/crates/dbx-web/src/state.rs +++ b/crates/dbx-web/src/state.rs @@ -33,6 +33,8 @@ pub struct WebState { pub data_dir: PathBuf, pub public_base_path: String, pub password_disabled: bool, + /// `DBX_DEMO_MODE`:公网演示部署的封锁开关(见 `demo` 模块)。 + pub demo_mode: bool, pub password_hash: RwLock>, pub sessions: RwLock>, pub sse_channels: RwLock>>, @@ -61,6 +63,7 @@ impl WebState { data_dir, public_base_path: "/".to_string(), password_disabled: false, + demo_mode: false, password_hash: RwLock::new(None), sessions: RwLock::new(HashSet::new()), sse_channels: RwLock::new(HashMap::new()), diff --git a/deploy/demo/Dockerfile b/deploy/demo/Dockerfile new file mode 100644 index 0000000000..6d014d4d43 --- /dev/null +++ b/deploy/demo/Dockerfile @@ -0,0 +1,12 @@ +# 演示镜像:在官方发布镜像上叠加首次启动播种逻辑与示例数据。 +# 中国大陆可改用 CNB 镜像源:FROM docker.cnb.cool/dbxio.com/dbx:latest +FROM t8y2/dbx:latest + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY seed/connections.json /seed/connections.json +COPY seed/entrypoint.sh /seed/entrypoint.sh +RUN chmod +x /seed/entrypoint.sh + +ENTRYPOINT ["/seed/entrypoint.sh"] diff --git a/deploy/demo/README.md b/deploy/demo/README.md new file mode 100644 index 0000000000..e4512bb88c --- /dev/null +++ b/deploy/demo/README.md @@ -0,0 +1,58 @@ +# DBX 公开演示环境 + +一键拉起一个可对外暴露的 DBX Web 演示栈:dbx-web(演示模式)+ PostgreSQL + +MySQL + Redis(均带示例数据)。访客打开 `http://:4224` 即可免登录体验 +完整的产品功能(连接预置的演示库、执行 SQL、网格编辑、导出等)。 + +## 快速开始 + +```bash +cd deploy/demo +docker compose up -d --build +``` + +定时重置(清空所有访客改动并重新播种): + +```bash +chmod +x reset.sh +# crontab -e +# */15 * * * * cd /opt/dbx-demo && ./reset.sh >> /var/log/dbx-demo-reset.log 2>&1 +``` + +建议挂在 Cloudflare 等反代之后,用 WAF/限流保护源站 4224 端口。 + +## 安全模型(为什么可以公开) + +三层防线叠加,缺一不可: + +1. **`DBX_DEMO_MODE=1`(服务端封锁,见 `crates/dbx-web/src/demo.rs`)**: + - 封锁插件安装 / JDBC / 驱动管理(sidecar 原生进程 = 远程代码执行); + - 封锁连接探测与增删改(`connection/test*`、`connection/save`、 + `mq/nacos test-connection` 等 body 携带完整连接配置的 SSRF 入口); + - 封锁 AI 供应商配置与外呼、云同步凭据、口令改写、凭据解密; + - `/connection/connect` 保留但要求连接已预置且端点身份与存储一致。 +2. **`internal: true` 网络(网络层兜底)**:dbx-web 容器无法主动出网, + 即使有遗漏的出网入口也在网络层被挡掉;对外只发布 4224。 +3. **定时重置**:`reset.sh` 销毁全部数据卷,任何数据破坏 15 分钟内自愈。 + +### 已知的共享实例固有行为(非漏洞,属产品形态) + +所有访客共享同一工作台:查询历史、SQL 片段、布局互通;任一访客断开连接 +会中断该连接上其他访客的查询。介意时可升级为每访客临时实例编排(同一镜像 +与封锁开关可直接复用,按访客拉起独立 compose 项目即可)。 + +## 文件说明 + +| 文件 | 用途 | +| --- | --- | +| `Dockerfile` | 基于官方发布镜像叠加播种入口 | +| `docker-compose.yml` | 演示栈编排(internal 网络 + 演示库健康检查) | +| `seed/entrypoint.sh` | 首次启动时在未发布端口播种预置连接后转正式模式 | +| `seed/connections.json` | 预置的三条演示连接 | +| `seed/sql/postgres/init.sql` / `seed/sql/mysql/init.sql` | 演示库示例数据(迷你电商) | +| `reset.sh` | 定时全量重置 | + +## 前端适配(可选跟进) + +`GET /api/version` 现返回 `demoMode` 标志,前端可据此隐藏"新建连接"等入口并 +展示演示横幅;服务端 403(`DEMO_MODE_DISABLED`)已足够兜底。 diff --git a/deploy/demo/docker-compose.yml b/deploy/demo/docker-compose.yml new file mode 100644 index 0000000000..69eadc61b4 --- /dev/null +++ b/deploy/demo/docker-compose.yml @@ -0,0 +1,71 @@ +# 公开演示栈:dbx-web(DBX_DEMO_MODE)+ 三台仅供演示的数据库。 +# 整个网络标记 internal: true —— 容器间互通、对外只暴露 4224 端口, +# dbx-web 自身无法主动出网(插件 URL 安装 / 探测外网端点在网络层就被挡掉)。 +services: + dbx-demo: + build: . + # 中国大陆构建基镜像可改用:docker.cnb.cool/dbxio.com/dbx:latest(见 Dockerfile) + ports: + - "4224:4224" + environment: + DBX_DEMO_MODE: "1" + DBX_DISABLE_PASSWORD: "1" + DBX_MAX_UPLOAD_MB: "16" + volumes: + - dbx-demo-data:/app/data + depends_on: + demo-postgres: + condition: service_healthy + demo-mysql: + condition: service_healthy + demo-redis: + condition: service_started + stop_grace_period: 30s + restart: unless-stopped + + demo-postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: demo + POSTGRES_USER: demo + POSTGRES_PASSWORD: demo + volumes: + - dbx-demo-postgres:/var/lib/postgresql/data + # 目录挂载(而非单文件):对 rootless docker 等环境更稳。 + - ./seed/sql/postgres:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U demo -d demo"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + demo-mysql: + image: mysql:8 + environment: + MYSQL_DATABASE: demo + MYSQL_USER: demo + MYSQL_PASSWORD: demo + MYSQL_ROOT_PASSWORD: root-demo-only + volumes: + - dbx-demo-mysql:/var/lib/mysql + - ./seed/sql/mysql:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-udemo", "-pdemo"] + interval: 5s + timeout: 3s + retries: 30 + restart: unless-stopped + + demo-redis: + image: redis:7-alpine + restart: unless-stopped + +volumes: + dbx-demo-data: + dbx-demo-postgres: + dbx-demo-mysql: + +networks: + default: + internal: true diff --git a/deploy/demo/reset.sh b/deploy/demo/reset.sh new file mode 100755 index 0000000000..ac3c9c908e --- /dev/null +++ b/deploy/demo/reset.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# 完整重置演示环境:销毁全部数据卷(含 dbx-web 的连接与历史、三台演示库), +# 重新拉起并触发首次播种。建议 cron 定时执行,例如每 15 分钟: +# */15 * * * * cd /opt/dbx-demo && ./reset.sh >> /var/log/dbx-demo-reset.log 2>&1 +set -e +cd "$(dirname "$0")" +docker compose down -v --remove-orphans +docker compose up -d --quiet-pull +echo "[demo] environment reset at $(date)" diff --git a/deploy/demo/seed/connections.json b/deploy/demo/seed/connections.json new file mode 100644 index 0000000000..5e41303913 --- /dev/null +++ b/deploy/demo/seed/connections.json @@ -0,0 +1,36 @@ +{ + "configs": [ + { + "id": "demo-postgres", + "name": "演示 · PostgreSQL", + "db_type": "postgres", + "host": "demo-postgres", + "port": 5432, + "username": "demo", + "password": "demo", + "database": "demo", + "save_password": true + }, + { + "id": "demo-mysql", + "name": "演示 · MySQL", + "db_type": "mysql", + "host": "demo-mysql", + "port": 3306, + "username": "demo", + "password": "demo", + "database": "demo", + "save_password": true + }, + { + "id": "demo-redis", + "name": "演示 · Redis", + "db_type": "redis", + "host": "demo-redis", + "port": 6379, + "username": "", + "password": "", + "save_password": true + } + ] +} diff --git a/deploy/demo/seed/entrypoint.sh b/deploy/demo/seed/entrypoint.sh new file mode 100755 index 0000000000..d521dad779 --- /dev/null +++ b/deploy/demo/seed/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# 演示容器入口:数据卷为空(首次启动或刚被 reset.sh 重置)时,先在一个 +# 未对外发布的端口上以普通模式短暂启动 dbx-web,通过 /api/connection/save +# 写入预置连接,再以 DBX_DEMO_MODE 正式启动。播种端口 4555 不在 compose +# 中发布,演示模式封锁面对外不可达。 +set -e + +DATA_DIR="${DBX_DATA_DIR:-/app/data}" +SEED_PORT=4555 + +if [ ! -f "$DATA_DIR/dbx.db" ]; then + echo "[demo] empty data dir: seeding demo connections" + # 播种实例必须绕过演示封锁(否则 connection/save 会被拒),且只监听内部端口。 + DBX_PORT="$SEED_PORT" DBX_DISABLE_PASSWORD=1 DBX_DEMO_MODE=0 /usr/local/bin/dbx-web & + SEED_PID=$! + + for _ in $(seq 1 120); do + if curl -sf -o /dev/null "http://127.0.0.1:$SEED_PORT/api/version"; then + break + fi + sleep 0.5 + done + + curl -sf -o /dev/null \ + -H 'Content-Type: application/json' \ + --data-binary @/seed/connections.json \ + "http://127.0.0.1:$SEED_PORT/api/connection/save" + + kill "$SEED_PID" 2>/dev/null || true + for _ in $(seq 1 30); do + kill -0 "$SEED_PID" 2>/dev/null || break + sleep 0.5 + done + kill -9 "$SEED_PID" 2>/dev/null || true + echo "[demo] seeding complete" +fi + +exec /usr/local/bin/dbx-web diff --git a/deploy/demo/seed/sql/mysql/init.sql b/deploy/demo/seed/sql/mysql/init.sql new file mode 100644 index 0000000000..1e207da09a --- /dev/null +++ b/deploy/demo/seed/sql/mysql/init.sql @@ -0,0 +1,82 @@ +-- MySQL 演示库:与 PostgreSQL 演示库同构的迷你电商数据。 +-- 建库由 MYSQL_DATABASE=demo 完成,本脚本在 demo 库内建表并填充数据。 + +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + email VARCHAR(128) NOT NULL UNIQUE, + city VARCHAR(32) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE = InnoDB; + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + category VARCHAR(32) NOT NULL, + price DECIMAL(10, 2) NOT NULL, + stock INT NOT NULL DEFAULT 0, + CHECK (price >= 0) +) ENGINE = InnoDB; + +CREATE TABLE orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id), + INDEX idx_orders_customer (customer_id) +) ENGINE = InnoDB; + +CREATE TABLE order_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + order_id INT NOT NULL, + product_id INT NOT NULL, + quantity INT NOT NULL, + unit_price DECIMAL(10, 2) NOT NULL, + CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT fk_items_product FOREIGN KEY (product_id) REFERENCES products (id), + INDEX idx_order_items_order (order_id) +) ENGINE = InnoDB; + +CREATE VIEW v_order_summary AS +SELECT o.id AS order_id, + c.name AS customer, + c.city AS city, + o.status AS status, + COUNT(i.id) AS item_count, + SUM(i.quantity * i.unit_price) AS order_total +FROM orders o +JOIN customers c ON c.id = o.customer_id +JOIN order_items i ON i.order_id = o.id +GROUP BY o.id, c.name, c.city, o.status; + +INSERT INTO customers (name, email, city) +WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 200) +SELECT CONCAT('客户', n), +CONCAT('user', n, '@example.com'), +ELT(1 + MOD(n, 6), '北京', '上海', '广州', '深圳', '杭州', '成都') +FROM seq; + +INSERT INTO products (name, category, price, stock) +WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 120) +SELECT CONCAT('商品 ', n), +ELT(1 + MOD(n, 5), '键盘', '显示器', '鼠标', '耳机', '硬盘'), +ROUND(50 + MOD(n * 13, 2000), 2), +MOD(n, 100) +FROM seq; + +INSERT INTO orders (customer_id, status) +WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 800) +SELECT 1 + MOD(n, 200), ELT(1 + MOD(n, 4), 'pending', 'paid', 'shipped', 'done') +FROM seq; + +INSERT INTO order_items (order_id, product_id, quantity, unit_price) +SELECT o.id, 1 + MOD(o.id * 7, 120), 1 + MOD(o.id, 3), p.price +FROM orders o +JOIN products p ON p.id = 1 + MOD(o.id * 7, 120); + +UPDATE orders o +JOIN (SELECT order_id, SUM(quantity * unit_price) AS total FROM order_items GROUP BY order_id) s + ON s.order_id = o.id +SET o.total_amount = s.total; diff --git a/deploy/demo/seed/sql/postgres/init.sql b/deploy/demo/seed/sql/postgres/init.sql new file mode 100644 index 0000000000..17c192f5aa --- /dev/null +++ b/deploy/demo/seed/sql/postgres/init.sql @@ -0,0 +1,85 @@ +-- PostgreSQL 演示库:迷你电商(客户 / 商品 / 订单 / 订单明细 + 汇总视图)。 +-- 建库由 POSTGRES_DB=demo 完成,本脚本在 demo 库内建表并填充数据。 + +CREATE TABLE customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + city TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), + stock INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers (id), + status TEXT NOT NULL DEFAULT 'pending', + total_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE order_items ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES orders (id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products (id), + quantity INTEGER NOT NULL CHECK (quantity > 0), + unit_price NUMERIC(10, 2) NOT NULL +); + +CREATE INDEX idx_orders_customer ON orders (customer_id); +CREATE INDEX idx_order_items_order ON order_items (order_id); + +CREATE VIEW v_order_summary AS +SELECT o.id AS order_id, + c.name AS customer, + c.city AS city, + o.status AS status, + count(i.id) AS item_count, + sum(i.quantity * i.unit_price) AS order_total +FROM orders o +JOIN customers c ON c.id = o.customer_id +JOIN order_items i ON i.order_id = o.id +GROUP BY o.id, c.name, c.city, o.status; + +INSERT INTO customers (name, email, city, created_at) +SELECT '客户' || n, + 'user' || n || '@example.com', + (ARRAY ['北京', '上海', '广州', '深圳', '杭州', '成都']) [1 + (n % 6)], + now() - (n || ' days')::interval +FROM generate_series(1, 200) AS n; + +INSERT INTO products (name, category, price, stock) +SELECT '商品 ' || n, + (ARRAY ['键盘', '显示器', '鼠标', '耳机', '硬盘']) [1 + (n % 5)], + round((50 + (n * 13 % 2000))::numeric, 2), + n % 100 +FROM generate_series(1, 120) AS n; + +INSERT INTO orders (customer_id, status, total_amount, created_at) +SELECT 1 + (n % 200), + (ARRAY ['pending', 'paid', 'shipped', 'done']) [1 + (n % 4)], + 0, + now() - (n % 30 || ' hours')::interval +FROM generate_series(1, 800) AS n; + +INSERT INTO order_items (order_id, product_id, quantity, unit_price) +SELECT o.id, + 1 + ((o.id * 7) % 120), + 1 + (o.id % 3), + p.price +FROM orders o +JOIN products p ON p.id = 1 + ((o.id * 7) % 120); + +UPDATE orders o +SET total_amount = s.total +FROM (SELECT order_id, sum(quantity * unit_price) AS total FROM order_items GROUP BY order_id) s +WHERE s.order_id = o.id; + +ANALYZE;