Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ pnpm dev:backend # backend
pnpm tauri build
```

#### Docker image build
```bash
docker buildx build \
--platform linux/amd64 \
--push \
-t hub.yeastardigital.com/novo-middleware/dbx:latest \
-f deploy/Dockerfile \
--build-arg BUILDPLATFORM=linux/amd64 \
.
```

The installer will be in `src-tauri/target/release/bundle/`.

## Tech Stack
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,12 @@ function onLoginSuccess() {
initApp();
}

async function onLogout() {
await fetch("/api/auth/logout", { method: "POST" });
authenticated.value = false;
window.history.replaceState(null, "", "/login");
}

function initApp() {
const t0 = performance.now();
console.log("[STARTUP] initApp begin");
Expand Down Expand Up @@ -1000,6 +1006,7 @@ onUnmounted(() => {
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
:has-connections="connectionStore.connections.length > 0"
:has-sql-file-connections="hasSqlFileConnections"
:needs-auth="needsAuth"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@set-theme-mode="setThemeMode"
Expand All @@ -1013,6 +1020,7 @@ onUnmounted(() => {
@open-sql-file="dialogs.showSqlFileDialog.value = true"
@open-schema-diff="dialogs.showSchemaDiffDialog.value = true"
@open-data-compare="dialogs.showDataCompareDialog.value = true"
@logout="onLogout"
/>

<div
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/components/layout/AppToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Settings,
CloudDownload,
Package,
LogOut,
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
Expand All @@ -40,6 +41,7 @@ const props = defineProps<{
agentDriverUpdateCount: number;
hasConnections: boolean;
hasSqlFileConnections: boolean;
needsAuth: boolean;
}>();

const emit = defineEmits<{
Expand All @@ -56,6 +58,7 @@ const emit = defineEmits<{
"open-sql-file": [];
"open-schema-diff": [];
"open-data-compare": [];
"logout": [];
}>();

const { t } = useI18n();
Expand Down Expand Up @@ -291,6 +294,15 @@ function onToolbarDblClick(e: MouseEvent) {
<TooltipContent>{{ t("settings.title") }}</TooltipContent>
</Tooltip>

<Tooltip v-if="needsAuth">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="emit('logout')">
<LogOut class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("auth.logout") }}</TooltipContent>
</Tooltip>

<WindowControls
v-if="showControls"
:is-maximized="isMaximized"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default {
passwordChanged: "Password changed successfully",
changePasswordFailed: "Failed to change password",
changePasswordDescription: "Enter your current password and choose a new one",
logout: "Logout",
},
toolbar: {
newConnection: "New Connection",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default {
passwordChanged: "密码修改成功",
changePasswordFailed: "密码修改失败",
changePasswordDescription: "输入当前密码并设置新密码",
logout: "退出登录",
},
toolbar: {
newConnection: "新建连接",
Expand Down
8 changes: 4 additions & 4 deletions crates/dbx-web/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
}

let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
state.sessions.write().await.insert(token.clone(), std::time::Instant::now());

let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
Expand Down Expand Up @@ -108,7 +108,7 @@ pub async fn setup(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq

// Auto-login: create session
let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
state.sessions.write().await.insert(token.clone(), std::time::Instant::now());

let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
Expand All @@ -120,7 +120,7 @@ pub async fn check(State(state): State<Arc<WebState>>, req: Request<axum::body::
return Json(AuthCheckResponse { authenticated: false, required: false, setup_required: true });
}
let authenticated = match extract_session_token(&req) {
Some(token) => state.sessions.read().await.contains(&token),
Some(token) => state.is_session_valid(&token).await,
None => false,
};
Json(AuthCheckResponse { authenticated, required: true, setup_required: false })
Expand Down Expand Up @@ -202,7 +202,7 @@ pub async fn auth_middleware(

// Check session token
if let Some(token) = extract_session_token(&req) {
if state.sessions.read().await.contains(&token) {
if state.is_session_valid(&token).await {
return next.run(req).await;
}
}
Expand Down
16 changes: 14 additions & 2 deletions crates/dbx-web/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod routes;
mod sse;
mod state;

use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;

Expand Down Expand Up @@ -73,7 +73,7 @@ async fn main() {
app: app_state,
data_dir,
password_hash: RwLock::new(password_hash),
sessions: RwLock::new(HashSet::new()),
sessions: RwLock::new(HashMap::new()),
sse_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
Expand Down Expand Up @@ -344,6 +344,18 @@ async fn main() {
tracing::info!("Password protection is enabled");
}

// Background task: purge expired sessions every hour
{
let state = web_state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
loop {
interval.tick().await;
state.purge_expired_sessions().await;
}
});
}

let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind address");
axum::serve(listener, app).await.expect("Server error");
}
27 changes: 24 additions & 3 deletions crates/dbx-web/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
use dbx_core::connection::AppState;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio_util::sync::CancellationToken;

pub fn session_ttl() -> Duration {
let hours = std::env::var("DBX_SESSION_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&h| h > 0)
.unwrap_or(12);
Duration::from_secs(hours * 60 * 60)
}

pub struct LoginRateLimit {
pub fail_count: u32,
pub locked_until: Option<std::time::Instant>,
pub locked_until: Option<Instant>,
}

pub struct WebState {
pub app: Arc<AppState>,
pub data_dir: PathBuf,
pub password_hash: RwLock<Option<String>>,
pub sessions: RwLock<HashSet<String>>,
/// token -> created_at
pub sessions: RwLock<HashMap<String, Instant>>,
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
pub login_rate_limit: Mutex<LoginRateLimit>,
Expand All @@ -26,4 +37,14 @@ impl WebState {
pub async fn remove_sse_channel(&self, id: &str) {
self.sse_channels.write().await.remove(id);
}

pub async fn is_session_valid(&self, token: &str) -> bool {
let sessions = self.sessions.read().await;
sessions.get(token).is_some_and(|created_at| created_at.elapsed() < session_ttl())
}

pub async fn purge_expired_sessions(&self) {
let mut sessions = self.sessions.write().await;
sessions.retain(|_, created_at| created_at.elapsed() < session_ttl());
}
}
13 changes: 7 additions & 6 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ COPY src-tauri/build.rs src-tauri/
COPY src-tauri/tauri.conf.json src-tauri/

RUN case "$TARGETARCH" in \
amd64) rust_target=x86_64-unknown-linux-gnu; lib_arch=x86_64-linux-gnu ;; \
arm64) rust_target=aarch64-unknown-linux-gnu; lib_arch=aarch64-linux-gnu ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
amd64) rust_target=x86_64-unknown-linux-gnu; lib_arch=x86_64-linux-gnu ;; \
arm64) rust_target=aarch64-unknown-linux-gnu; lib_arch=aarch64-linux-gnu ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
esac && \
LIBRARY_PATH="/usr/lib/$lib_arch" \
PKG_CONFIG_PATH="/usr/lib/$lib_arch/pkgconfig" \
Expand All @@ -56,9 +56,9 @@ COPY crates/ crates/
RUN find crates/ -name '*.rs' -exec touch {} +

RUN case "$TARGETARCH" in \
amd64) rust_target=x86_64-unknown-linux-gnu; lib_arch=x86_64-linux-gnu ;; \
arm64) rust_target=aarch64-unknown-linux-gnu; lib_arch=aarch64-linux-gnu ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
amd64) rust_target=x86_64-unknown-linux-gnu; lib_arch=x86_64-linux-gnu ;; \
arm64) rust_target=aarch64-unknown-linux-gnu; lib_arch=aarch64-linux-gnu ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
esac && \
LIBRARY_PATH="/usr/lib/$lib_arch" \
PKG_CONFIG_PATH="/usr/lib/$lib_arch/pkgconfig" \
Expand All @@ -77,6 +77,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-dejavu-core \
libfreetype6 \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend /out/${TARGETPLATFORM}/dbx-web /usr/local/bin/
COPY --from=frontend /app/dist /app/static
Expand Down
113 changes: 113 additions & 0 deletions deploy/k8s/configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: dbx-init-scripts
data:
init-connections.sh: |
#!/bin/sh
set -e

# ============================================================
# 连接配置 — 按实际情况修改以下变量
# ============================================================

# DBX_PASSWORD 从 deployment env 注入,此处不设置
# --- PostgreSQL ---
PG_ID="auto-init-pg"
PG_NAME="PostgreSQL"
PG_HOST="postgres-rw.middleware.svc.cluster.local"
PG_PORT=5432
PG_USER="postgres"
PG_PASSWORD=""
PG_DATABASE="postgres"

# --- Redis 模式:standalone | sentinel | cluster ---
REDIS_MODE="standalone"
REDIS_NAME="Redis"
REDIS_PASSWORD=""

# standalone 直连
REDIS_HOST="valkey.middleware.svc.cluster.local"
REDIS_PORT=6379

# sentinel 哨兵(REDIS_MODE=sentinel 时生效)
REDIS_SENTINEL_NODES="sentinel-0:26379,sentinel-1:26379,sentinel-2:26379"
REDIS_SENTINEL_MASTER="mymaster"
REDIS_SENTINEL_USERNAME=""
REDIS_SENTINEL_PASSWORD=""
REDIS_SENTINEL_TLS=false

# cluster 集群(REDIS_MODE=cluster 时生效)
REDIS_CLUSTER_NODES="node-0:6379,node-1:6379,node-2:6379"

# ============================================================

DBX_PID=""
cleanup() {
[ -n "$DBX_PID" ] && kill "$DBX_PID" 2>/dev/null
}
trap cleanup TERM INT

dbx-web &
DBX_PID=$!

echo "[init] waiting for dbx-web to start..."
until curl -sf -o /dev/null http://localhost:4224/api/auth/check; do
sleep 1
done
echo "[init] dbx-web is ready"

COOKIES=$(mktemp)

if [ -n "$DBX_PASSWORD" ]; then
curl -sf -c "$COOKIES" \
-X POST http://localhost:4224/api/auth/login \
-H "Content-Type: application/json" \
-d "{\"password\":\"$DBX_PASSWORD\"}" > /dev/null
fi

CONFIGS="[]"

# PostgreSQL
PG_CFG=$(printf '{"id":"%s","name":"%s","db_type":"postgres","host":"%s","port":%s,"username":"%s","password":"%s","database":"%s"}' \
"$PG_ID" "$PG_NAME" "$PG_HOST" "$PG_PORT" "$PG_USER" "$PG_PASSWORD" "$PG_DATABASE")
CONFIGS="[$PG_CFG]"
echo "[init] added PostgreSQL: $PG_HOST:$PG_PORT"

# Redis
case "$REDIS_MODE" in
sentinel)
REDIS_CFG=$(printf '{"id":"auto-init-redis","name":"%s (Sentinel)","db_type":"redis","host":"","port":6379,"username":"","password":"%s","database":null,"redis_connection_mode":"sentinel","redis_sentinel_master":"%s","redis_sentinel_nodes":"%s","redis_sentinel_username":"%s","redis_sentinel_password":"%s","redis_sentinel_tls":%s}' \
"$REDIS_NAME" "$REDIS_PASSWORD" "$REDIS_SENTINEL_MASTER" "$REDIS_SENTINEL_NODES" \
"$REDIS_SENTINEL_USERNAME" "$REDIS_SENTINEL_PASSWORD" "$REDIS_SENTINEL_TLS")
echo "[init] added Redis Sentinel: master=$REDIS_SENTINEL_MASTER"
;;
cluster)
REDIS_CFG=$(printf '{"id":"auto-init-redis","name":"%s (Cluster)","db_type":"redis","host":"","port":6379,"username":"","password":"%s","database":null,"redis_connection_mode":"cluster","redis_cluster_nodes":"%s"}' \
"$REDIS_NAME" "$REDIS_PASSWORD" "$REDIS_CLUSTER_NODES")
echo "[init] added Redis Cluster: $REDIS_CLUSTER_NODES"
;;
*)
REDIS_CFG=$(printf '{"id":"auto-init-redis","name":"%s","db_type":"redis","host":"%s","port":%s,"username":"","password":"%s","database":null,"redis_connection_mode":"standalone"}' \
"$REDIS_NAME" "$REDIS_HOST" "$REDIS_PORT" "$REDIS_PASSWORD")
echo "[init] added Redis standalone: $REDIS_HOST:$REDIS_PORT"
;;
esac
CONFIGS="[$PG_CFG,$REDIS_CFG]"

echo "[init] saving connections..."
SAVE_RESP=$(curl -s -w "\n%{http_code}" -b "$COOKIES" \
-X POST http://localhost:4224/api/connection/save \
-H "Content-Type: application/json" \
-d "{\"configs\":$CONFIGS}")
HTTP_CODE=$(printf '%s' "$SAVE_RESP" | tail -1)
BODY=$(printf '%s' "$SAVE_RESP" | head -1)
echo "[init] save response: $HTTP_CODE $BODY"
if [ "$HTTP_CODE" != "200" ]; then
echo "[init] ERROR: failed to save connections (HTTP $HTTP_CODE)"
exit 1
fi
echo "[init] connection configs saved"

rm -f "$COOKIES"
wait "$DBX_PID"
Loading
Loading