mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"]
|
||||
@@ -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`)已足够兜底。
|
||||
@@ -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
|
||||
Executable
+9
@@ -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)"
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+38
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user