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.
This commit is contained in:
t8y2
2026-09-23 23:53:00 +08:00
parent 8897e12714
commit b06e330a44
14 changed files with 793 additions and 3 deletions
+376
View File
@@ -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<Arc<WebState>>,
request: Request<axum::body::Body>,
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::<serde_json::Value>().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();
}
}
+4
View File
@@ -26,6 +26,10 @@ impl AppError {
Self::with_status(msg.into(), StatusCode::BAD_REQUEST)
}
pub fn forbidden(msg: impl Into<String>) -> Self {
Self::with_status(msg.into(), StatusCode::FORBIDDEN)
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::with_status(msg.into(), StatusCode::NOT_FOUND)
}
+8
View File
@@ -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();
+5
View File
@@ -401,6 +401,11 @@ pub async fn connect_db(
Json(body): Json<ConnectRequest>,
) -> Result<Json<String>, 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,
+6 -3
View File
@@ -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<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }))
pub async fn get_version(State(state): State<Arc<WebState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "demoMode": state.demo_mode }))
}
#[derive(serde::Deserialize)]
+3
View File
@@ -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<Option<String>>,
pub sessions: RwLock<HashSet<String>>,
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
@@ -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()),
+12
View File
@@ -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"]
+58
View File
@@ -0,0 +1,58 @@
# DBX 公开演示环境
一键拉起一个可对外暴露的 DBX Web 演示栈:dbx-web(演示模式)+ PostgreSQL +
MySQL + Redis(均带示例数据)。访客打开 `http://<host>: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`)已足够兜底。
+71
View File
@@ -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
+9
View File
@@ -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)"
+36
View File
@@ -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
}
]
}
+38
View File
@@ -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
+82
View File
@@ -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;
+85
View File
@@ -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;